Skip to content

Commit

Permalink
feat: dynamic table MAASENG-2987 (#125)
Browse files Browse the repository at this point in the history
* feat: dynamic table boilerplate

* feat: dynamic table MAASENG-2987

* fix: address review comments

* fix: remove react table from story
  • Loading branch information
ndv99 authored Apr 11, 2024
1 parent cccb601 commit 4aa8cfa
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 1 deletion.
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-router-dom": "^6.0.0",
"vanilla-framework": "^4.6.0"
"vanilla-framework": "^4.6.0",
"@tanstack/react-table": "^8.15.3"
}
}
11 changes: 11 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Vanilla breakpoint values https://vanillaframework.io/docs/settings/breakpoint-settings
export const BREAKPOINTS = {
// Mobile (portrait)
xSmall: 460,
// Mobile or tablet
small: 620,
// Desktop
large: 1036,
// Large desktop
xLarge: 1681,
};
53 changes: 53 additions & 0 deletions src/lib/components/DynamicTable/DynamicTable.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
@import "vanilla-framework";

.p-table-dynamic {
margin-bottom: 0;

thead,
tbody {
display: block;
overflow: hidden auto;
}

thead {
scrollbar-gutter: stable;
}

thead tr,
tbody tr {
display: table;
table-layout: fixed;
width: 100%;
}

tbody {
height: auto;
min-height: auto;
}

&:not([aria-busy="true"]) tbody:not([aria-busy="true"]) {
tr:hover,
tr:focus-within {
@include vf-transition($property: #{background}, $duration: fast);

background-color: $colors--light-theme--background-hover;
}
}

thead th:last-child,
tbody td:last-child {
text-align: right;
}

thead th button {
padding-top: 0;
margin-bottom: 0;
}
}

.p-table-dynamic.p-table-dynamic--with-select {
thead th:first-child,
tbody td:first-child {
width: 3rem;
}
}
56 changes: 56 additions & 0 deletions src/lib/components/DynamicTable/DynamicTable.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Button, Icon } from "@canonical/react-components";
import { Meta, StoryObj } from "@storybook/react";

import { DynamicTable } from "@/lib/components/DynamicTable/DynamicTable";

const data = Array.from({ length: 25 }, (_, index) => ({
fqdn: `machine-${index}`,
ipAddress: `192.168.1.${index}`,
zone: `zone-${index}`,
owner: `owner-${index}`,
}));

const meta: Meta<typeof DynamicTable> = {
title: "components/DynamicTable",
component: DynamicTable,
tags: ["autodocs"],
};

export default meta;

export const Example: StoryObj<typeof DynamicTable> = {
args: {
className: "machines-table",
children: (
<>
<thead>
<tr>
<th>FQDN</th>
<th>IP address</th>
<th>Zone</th>
<th>Owner</th>
<th>Actions</th>
</tr>
</thead>
<DynamicTable.Body>
{data.map((item) => (
<tr key={item.fqdn}>
<td>{item.fqdn}</td>
<td>{item.fqdn}</td>
<td>{item.fqdn}</td>
<td>{item.fqdn}</td>
<td>
<Button
appearance="base"
style={{ marginBottom: 0, padding: 0 }}
>
<Icon name="delete" />
</Button>
</td>
</tr>
))}
</DynamicTable.Body>
</>
),
},
};
73 changes: 73 additions & 0 deletions src/lib/components/DynamicTable/DynamicTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";

import { DynamicTable } from "./DynamicTable";

import { BREAKPOINTS } from "@/constants";

const offset = 100;

beforeAll(() => {
// simulate top offset as JSDOM doesn't support getBoundingClientRect
// - equivalent of another element of height 100px being displayed above the table
vi.spyOn(
window.HTMLElement.prototype,
"getBoundingClientRect",
).mockReturnValue({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: offset,
width: 0,
} as DOMRect);
});

it("sets a fixed table body height based on top offset on large screens", async () => {
vi.spyOn(window, "innerWidth", "get").mockReturnValue(BREAKPOINTS.xSmall);

await act(async () => {
fireEvent(window, new Event("resize"));
});

const { container } = render(
<DynamicTable>
<DynamicTable.Body className="test-class">
<tr>
<td>Test content</td>
</tr>
</DynamicTable.Body>
</DynamicTable>,
);

const tbody = container.querySelector("tbody");

await act(async () => {
fireEvent(window, new Event("resize"));
});

// does not alter the height on small screens
expect(tbody).toHaveStyle("height: undefined");

vi.spyOn(window, "innerWidth", "get").mockReturnValue(BREAKPOINTS.large);

await act(async () => {
fireEvent(window, new Event("resize"));
});

await vi.waitFor(() =>
expect(tbody).toHaveStyle(`height: calc(100vh - ${offset + 1}px)`),
);
});

it("displays loading state", () => {
const { container } = render(
<DynamicTable>
<DynamicTable.Loading />
</DynamicTable>,
);

expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(container.querySelector("tbody")).toHaveAttribute("aria-busy", "true");
expect(screen.getAllByRole("row", { hidden: true })).toHaveLength(10);
});
129 changes: 129 additions & 0 deletions src/lib/components/DynamicTable/DynamicTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { AriaAttributes, PropsWithChildren, RefObject } from "react";
import {
useState,
useEffect,
useLayoutEffect,
useRef,
useCallback,
} from "react";

import type { RowData, Table } from "@tanstack/react-table";
import classNames from "classnames";

import { BREAKPOINTS } from "@/constants";
import { Placeholder } from "@/lib/elements";
import "./DynamicTable.scss";

export type DynamicTableProps = PropsWithChildren<{ className?: string }>;

/**
* A table based on tanstack/react-table with a fixed header, where the table body can be scrolled vertically independent of the page itself.
*
* @param className A class name to apply to the <table> element
* @param children The markup of the table itself, composed of <thead> and DynamicTable.Body
* @returns
*/
export const DynamicTable = ({
className,
children,
...props
}: DynamicTableProps) => {
return (
<table {...props} className={classNames("p-table-dynamic", className)}>
{children}
</table>
);
};

const SkeletonRows = ({ columns }: { columns: Array<{ id: string }> }) => (
<>
{Array.from({ length: 10 }, (_, index) => {
return (
<tr aria-hidden="true" key={index}>
{columns.map((column, columnIndex) => {
return (
<td
className={classNames(column.id, "u-text-overflow-clip")}
key={columnIndex}
>
<Placeholder isPending text="XXXxxxx.xxxxxxxxx" />
</td>
);
})}
</tr>
);
})}
</>
);

const DynamicTableLoading = <TData extends RowData>({
className,
table,
}: {
className?: string;
table?: Table<TData>;
placeholderLengths?: { [key: string]: string };
}) => {
const columns = table
? table.getAllColumns()
: (Array.from({ length: 10 }).fill({ id: "" }) as Array<{ id: string }>);

return (
<>
<caption className="u-visually-hidden">Loading...</caption>
<DynamicTableBody aria-busy="true" className={className}>
<SkeletonRows columns={columns} />
</DynamicTableBody>
</>
);
};
/**
* sets a fixed height for the table body
* allowing it to be scrolled independently of the page
*/
const DynamicTableBody = ({
className,
children,
...props
}: PropsWithChildren<{ className?: string } & AriaAttributes>) => {
const tableBodyRef: RefObject<HTMLTableSectionElement> = useRef(null);
const [offset, setOffset] = useState<number | null>(null);

const handleResize = useCallback(() => {
if (window.innerWidth > BREAKPOINTS.small) {
const top = tableBodyRef.current?.getBoundingClientRect?.().top;
if (top) setOffset(top + 1);
} else {
setOffset(null);
}
}, []);

useLayoutEffect(() => {
handleResize();
}, [handleResize]);

useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [handleResize]);

return (
<tbody
className={className}
ref={tableBodyRef}
style={
offset
? {
height: `calc(100vh - ${offset}px)`,
minHeight: `calc(100vh - ${offset}px)`,
}
: undefined
}
{...props}
>
{children}
</tbody>
);
};
DynamicTable.Body = DynamicTableBody;
DynamicTable.Loading = DynamicTableLoading;
1 change: 1 addition & 0 deletions src/lib/components/DynamicTable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./DynamicTable";
2 changes: 2 additions & 0 deletions src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from "./Pagination";
export * from "./Stepper";
export * from "./FileUpload";
export * from "./MultiSelect";

export * from "./DynamicTable";

0 comments on commit 4aa8cfa

Please sign in to comment.