-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: dynamic table MAASENG-2987 (#125)
* feat: dynamic table boilerplate * feat: dynamic table MAASENG-2987 * fix: address review comments * fix: remove react table from story
- Loading branch information
Showing
9 changed files
with
361 additions
and
1 deletion.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
), | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./DynamicTable"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters