From 2d3218490be99e5edb168a3f81bb6434cffb2485 Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Mon, 8 Apr 2024 11:32:41 +0100 Subject: [PATCH 1/4] feat: dynamic table boilerplate --- .../components/DynamicTable/DynamicTable.scss | 5 +++++ .../DynamicTable/DynamicTable.stories.tsx | 20 +++++++++++++++++++ .../DynamicTable/DynamicTable.test.tsx | 8 ++++++++ .../components/DynamicTable/DynamicTable.tsx | 5 +++++ src/lib/components/DynamicTable/index.ts | 1 + src/lib/components/index.ts | 2 ++ 6 files changed, 41 insertions(+) create mode 100644 src/lib/components/DynamicTable/DynamicTable.scss create mode 100644 src/lib/components/DynamicTable/DynamicTable.stories.tsx create mode 100644 src/lib/components/DynamicTable/DynamicTable.test.tsx create mode 100644 src/lib/components/DynamicTable/DynamicTable.tsx create mode 100644 src/lib/components/DynamicTable/index.ts diff --git a/src/lib/components/DynamicTable/DynamicTable.scss b/src/lib/components/DynamicTable/DynamicTable.scss new file mode 100644 index 0000000..9188dad --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.scss @@ -0,0 +1,5 @@ +@import "vanilla-framework"; + +.p-table-dynamic { + margin-bottom: 0; +} diff --git a/src/lib/components/DynamicTable/DynamicTable.stories.tsx b/src/lib/components/DynamicTable/DynamicTable.stories.tsx new file mode 100644 index 0000000..e68a2d6 --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.stories.tsx @@ -0,0 +1,20 @@ +import { Meta } from "@storybook/react"; + +import { DynamicTable } from "@/lib/components/DynamicTable/DynamicTable"; + +const meta: Meta = { + title: "components/DynamicTable", + component: DynamicTable, + tags: ["autodocs"], + parameters: { + status: { + type: "candidate", + }, + }, +}; + +export default meta; + +export const Example = { + args: {}, +}; diff --git a/src/lib/components/DynamicTable/DynamicTable.test.tsx b/src/lib/components/DynamicTable/DynamicTable.test.tsx new file mode 100644 index 0000000..f5786a4 --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.test.tsx @@ -0,0 +1,8 @@ +import { render, screen } from "@testing-library/react"; + +import { DynamicTable } from "./DynamicTable"; + +it("renders without crashing", () => { + render(); + expect(screen.getByText("DynamicTable component")).toBeInTheDocument(); +}); diff --git a/src/lib/components/DynamicTable/DynamicTable.tsx b/src/lib/components/DynamicTable/DynamicTable.tsx new file mode 100644 index 0000000..b4854c2 --- /dev/null +++ b/src/lib/components/DynamicTable/DynamicTable.tsx @@ -0,0 +1,5 @@ +export interface DynamicTableProps {} + +export const DynamicTable: React.FC = (props) => { + return
DynamicTable component
; +}; diff --git a/src/lib/components/DynamicTable/index.ts b/src/lib/components/DynamicTable/index.ts new file mode 100644 index 0000000..aab07d3 --- /dev/null +++ b/src/lib/components/DynamicTable/index.ts @@ -0,0 +1 @@ +export * from "./DynamicTable"; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 047c010..cfa1bd9 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -3,3 +3,5 @@ export * from "./Pagination"; export * from "./Stepper"; export * from "./FileUpload"; export * from "./MultiSelect"; + +export * from "./DynamicTable"; \ No newline at end of file From 9a1c1dfe5253a85f097878a97b014b256f5612b3 Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Tue, 9 Apr 2024 15:32:25 +0100 Subject: [PATCH 2/4] feat: dynamic table MAASENG-2987 --- package-lock.json | 34 +++++ package.json | 3 + src/constants.ts | 10 ++ .../components/DynamicTable/DynamicTable.scss | 48 +++++++ .../DynamicTable/DynamicTable.stories.tsx | 97 +++++++++++++- .../DynamicTable/DynamicTable.test.tsx | 73 ++++++++++- .../components/DynamicTable/DynamicTable.tsx | 123 +++++++++++++++++- 7 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 src/constants.ts diff --git a/package-lock.json b/package-lock.json index 75ca755..846ac49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@canonical/maas-react-components", "version": "0.1.0", "license": "AGPL-3.0", + "dependencies": { + "@tanstack/react-table": "^8.15.3" + }, "devDependencies": { "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", @@ -8583,6 +8586,37 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@tanstack/react-table": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz", + "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==", + "dependencies": { + "@tanstack/table-core": "8.15.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.15.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz", + "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", diff --git a/package.json b/package.json index 7fbee4c..d96ba23 100644 --- a/package.json +++ b/package.json @@ -112,5 +112,8 @@ "react-dropzone": "14.2.3", "react-router-dom": "^6.0.0", "vanilla-framework": "^4.6.0" + }, + "dependencies": { + "@tanstack/react-table": "^8.15.3" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..bc44e65 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +export const BREAKPOINTS = { + // Mobile (portrait) + xSmall: 460, + // Mobile or tablet + small: 620, + // Desktop + large: 1036, + // Large desktop + xLarge: 1681, +}; diff --git a/src/lib/components/DynamicTable/DynamicTable.scss b/src/lib/components/DynamicTable/DynamicTable.scss index 9188dad..f825e66 100644 --- a/src/lib/components/DynamicTable/DynamicTable.scss +++ b/src/lib/components/DynamicTable/DynamicTable.scss @@ -2,4 +2,52 @@ .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; + } } diff --git a/src/lib/components/DynamicTable/DynamicTable.stories.tsx b/src/lib/components/DynamicTable/DynamicTable.stories.tsx index e68a2d6..d75b06c 100644 --- a/src/lib/components/DynamicTable/DynamicTable.stories.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.stories.tsx @@ -1,7 +1,100 @@ import { Meta } from "@storybook/react"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; import { DynamicTable } from "@/lib/components/DynamicTable/DynamicTable"; +type Device = { + fqdn: string; + ipAddress: string; + zone: string; + owner: string; +}; + +type DeviceColumnDef = ColumnDef; + +const columns: DeviceColumnDef[] = [ + { + id: "fqdn", + accessorKey: "fqdn", + header: () =>
FQDN
, + }, + { + id: "ipAddress", + accessorKey: "ipAddress", + header: () =>
IP address
, + }, + { + id: "zone", + accessorKey: "zone", + header: () =>
Zone
, + }, + { + id: "owner", + accessorKey: "owner", + header: () =>
Owner
, + }, +]; + +const data = Array.from({ length: 50 }, (_, index) => ({ + fqdn: `machine-${index}`, + ipAddress: `192.168.1.${index}`, + zone: `zone-${index}`, + owner: `owner-${index}`, +})); + +const TableChildren = () => { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <> + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + })} + + + ); +}; + const meta: Meta = { title: "components/DynamicTable", component: DynamicTable, @@ -16,5 +109,7 @@ const meta: Meta = { export default meta; export const Example = { - args: {}, + args: { + children: , + }, }; diff --git a/src/lib/components/DynamicTable/DynamicTable.test.tsx b/src/lib/components/DynamicTable/DynamicTable.test.tsx index f5786a4..26f86cb 100644 --- a/src/lib/components/DynamicTable/DynamicTable.test.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.test.tsx @@ -1,8 +1,73 @@ -import { render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { vi } from "vitest"; import { DynamicTable } from "./DynamicTable"; -it("renders without crashing", () => { - render(); - expect(screen.getByText("DynamicTable component")).toBeInTheDocument(); +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( + + + + Test content + + + , + ); + + 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( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(container.querySelector("tbody")).toHaveAttribute("aria-busy", "true"); + expect(screen.getAllByRole("row", { hidden: true })).toHaveLength(10); }); diff --git a/src/lib/components/DynamicTable/DynamicTable.tsx b/src/lib/components/DynamicTable/DynamicTable.tsx index b4854c2..91a8899 100644 --- a/src/lib/components/DynamicTable/DynamicTable.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.tsx @@ -1,5 +1,122 @@ -export interface DynamicTableProps {} +import type { AriaAttributes, PropsWithChildren, RefObject } from "react"; +import { + useState, + useEffect, + useLayoutEffect, + useRef, + useCallback, +} from "react"; -export const DynamicTable: React.FC = (props) => { - return
DynamicTable component
; +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 }>; + +export const DynamicTable = ({ + className, + children, + ...props +}: DynamicTableProps) => { + return ( + + {children} +
+ ); +}; + +const SkeletonRows = ({ columns }: { columns: Array<{ id: string }> }) => ( + <> + {Array.from({ length: 10 }, (_, index) => { + return ( + + {columns.map((column, columnIndex) => { + return ( + + + + ); + })} + + ); + })} + +); + +const DynamicTableLoading = ({ + className, + table, +}: { + className?: string; + table?: Table; + placeholderLengths?: { [key: string]: string }; +}) => { + const columns = table + ? table.getAllColumns() + : (Array.from({ length: 10 }).fill({ id: "" }) as Array<{ id: string }>); + + return ( + <> + Loading... + + + + + ); +}; +/** + * 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 = useRef(null); + const [offset, setOffset] = useState(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 ( + + {children} + + ); }; +DynamicTable.Body = DynamicTableBody; +DynamicTable.Loading = DynamicTableLoading; From a6078786021e5c8536d8c91562ac045fd4ee6095 Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Wed, 10 Apr 2024 11:02:03 +0100 Subject: [PATCH 3/4] fix: address review comments --- package-lock.json | 6 +++--- package.json | 4 +--- src/constants.ts | 1 + .../DynamicTable/DynamicTable.stories.tsx | 16 ++++++++-------- src/lib/components/DynamicTable/DynamicTable.tsx | 7 +++++++ 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 846ac49..8584cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "@canonical/maas-react-components", "version": "0.1.0", "license": "AGPL-3.0", - "dependencies": { - "@tanstack/react-table": "^8.15.3" - }, "devDependencies": { "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", @@ -67,6 +64,7 @@ }, "peerDependencies": { "@canonical/react-components": "0.47.4", + "@tanstack/react-table": "^8.15.3", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "classnames": "^2.3.2", @@ -8590,6 +8588,7 @@ "version": "8.15.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.15.3.tgz", "integrity": "sha512-aocQ4WpWiAh7R+yxNp+DGQYXeVACh5lv2kk96DjYgFiHDCB0cOFoYMT/pM6eDOzeMXR9AvPoLeumTgq8/0qX+w==", + "peer": true, "dependencies": { "@tanstack/table-core": "8.15.3" }, @@ -8609,6 +8608,7 @@ "version": "8.15.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.15.3.tgz", "integrity": "sha512-wOgV0HfEvuMOv8RlqdR9MdNNqq0uyvQtP39QOvGlggHvIObOE4exS+D5LGO8LZ3LUXxId2IlUKcHDHaGujWhUg==", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index d96ba23..ea56c3b 100644 --- a/package.json +++ b/package.json @@ -111,9 +111,7 @@ "react-dom": "18.2.0", "react-dropzone": "14.2.3", "react-router-dom": "^6.0.0", - "vanilla-framework": "^4.6.0" - }, - "dependencies": { + "vanilla-framework": "^4.6.0", "@tanstack/react-table": "^8.15.3" } } diff --git a/src/constants.ts b/src/constants.ts index bc44e65..f3561da 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ +// Vanilla breakpoint values https://vanillaframework.io/docs/settings/breakpoint-settings export const BREAKPOINTS = { // Mobile (portrait) xSmall: 460, diff --git a/src/lib/components/DynamicTable/DynamicTable.stories.tsx b/src/lib/components/DynamicTable/DynamicTable.stories.tsx index d75b06c..3abdc44 100644 --- a/src/lib/components/DynamicTable/DynamicTable.stories.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.stories.tsx @@ -1,4 +1,4 @@ -import { Meta } from "@storybook/react"; +import { Meta, StoryObj } from "@storybook/react"; import { ColumnDef, flexRender, @@ -99,17 +99,17 @@ const meta: Meta = { title: "components/DynamicTable", component: DynamicTable, tags: ["autodocs"], - parameters: { - status: { - type: "candidate", - }, - }, + render: () => ( + + + + ), }; export default meta; -export const Example = { +export const Example: StoryObj = { args: { - children: , + className: "machines-table", }, }; diff --git a/src/lib/components/DynamicTable/DynamicTable.tsx b/src/lib/components/DynamicTable/DynamicTable.tsx index 91a8899..0754b59 100644 --- a/src/lib/components/DynamicTable/DynamicTable.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.tsx @@ -16,6 +16,13 @@ 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 element + * @param children The markup of the table itself, composed of and DynamicTable.Body + * @returns + */ export const DynamicTable = ({ className, children, From 33a5da15f02a3da553294a2e15f3a7d2b0b2534a Mon Sep 17 00:00:00 2001 From: Nick De Villiers Date: Wed, 10 Apr 2024 16:10:37 +0100 Subject: [PATCH 4/4] fix: remove react table from story --- .../DynamicTable/DynamicTable.stories.tsx | 125 +++++------------- 1 file changed, 33 insertions(+), 92 deletions(-) diff --git a/src/lib/components/DynamicTable/DynamicTable.stories.tsx b/src/lib/components/DynamicTable/DynamicTable.stories.tsx index 3abdc44..cc05e0e 100644 --- a/src/lib/components/DynamicTable/DynamicTable.stories.tsx +++ b/src/lib/components/DynamicTable/DynamicTable.stories.tsx @@ -1,109 +1,19 @@ +import { Button, Icon } from "@canonical/react-components"; import { Meta, StoryObj } from "@storybook/react"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; import { DynamicTable } from "@/lib/components/DynamicTable/DynamicTable"; -type Device = { - fqdn: string; - ipAddress: string; - zone: string; - owner: string; -}; - -type DeviceColumnDef = ColumnDef; - -const columns: DeviceColumnDef[] = [ - { - id: "fqdn", - accessorKey: "fqdn", - header: () =>
FQDN
, - }, - { - id: "ipAddress", - accessorKey: "ipAddress", - header: () =>
IP address
, - }, - { - id: "zone", - accessorKey: "zone", - header: () =>
Zone
, - }, - { - id: "owner", - accessorKey: "owner", - header: () =>
Owner
, - }, -]; - -const data = Array.from({ length: 50 }, (_, index) => ({ +const data = Array.from({ length: 25 }, (_, index) => ({ fqdn: `machine-${index}`, ipAddress: `192.168.1.${index}`, zone: `zone-${index}`, owner: `owner-${index}`, })); -const TableChildren = () => { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - <> -
- {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - ); - })} - - ))} - - - {table.getRowModel().rows.map((row) => { - return ( - - {row.getVisibleCells().map((cell) => { - return ( - - ); - })} - - ); - })} - - - ); -}; - const meta: Meta = { title: "components/DynamicTable", component: DynamicTable, tags: ["autodocs"], - render: () => ( - - - - ), }; export default meta; @@ -111,5 +21,36 @@ export default meta; export const Example: StoryObj = { args: { className: "machines-table", + children: ( + <> + + + + + + + + + + + {data.map((item) => ( + + + + + + + + ))} + + + ), }, };
- {flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
FQDNIP addressZoneOwnerActions
{item.fqdn}{item.fqdn}{item.fqdn}{item.fqdn} + +