Skip to content

Commit 8ab345d

Browse files
fehmerMiodec
andauthored
feat: add DataTable component (@fehmer) (#7397)
following https://www.solid-ui.com/docs/components/data-table --------- Co-authored-by: Miodec <jack@monkeytype.com>
1 parent 4753884 commit 8ab345d

8 files changed

Lines changed: 518 additions & 64 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { render, screen, fireEvent } from "@solidjs/testing-library";
2+
import { createSignal } from "solid-js";
3+
import { describe, it, expect, vi, beforeEach } from "vitest";
4+
5+
import { DataTable } from "../../../../src/ts/components/ui/table/DataTable";
6+
7+
const [localStorage, setLocalStorage] = createSignal([]);
8+
vi.mock("../../../../src/ts/hooks/useLocalStorage", () => {
9+
return {
10+
useLocalStorage: () => {
11+
return [localStorage, setLocalStorage] as const;
12+
},
13+
};
14+
});
15+
16+
const bpSignal = createSignal({
17+
xxs: true,
18+
sm: true,
19+
md: true,
20+
});
21+
22+
vi.mock("../../../../src/ts/signals/breakpoints", () => ({
23+
bp: () => bpSignal[0](),
24+
}));
25+
26+
type Person = {
27+
name: string;
28+
age: number;
29+
};
30+
31+
const columns = [
32+
{
33+
accessorKey: "name",
34+
header: "Name",
35+
cell: (info: any) => info.getValue(),
36+
meta: { breakpoint: "xxs" },
37+
},
38+
{
39+
accessorKey: "age",
40+
header: "Age",
41+
cell: (info: any) => info.getValue(),
42+
meta: { breakpoint: "sm" },
43+
},
44+
];
45+
46+
const data: Person[] = [
47+
{ name: "Alice", age: 30 },
48+
{ name: "Bob", age: 20 },
49+
];
50+
51+
describe("DataTable", () => {
52+
beforeEach(() => {
53+
bpSignal[1]({
54+
xxs: true,
55+
sm: true,
56+
md: true,
57+
});
58+
});
59+
60+
it("renders table headers and rows", () => {
61+
render(() => <DataTable id="people" columns={columns} data={data} />);
62+
63+
expect(screen.getByText("Name")).toBeInTheDocument();
64+
expect(screen.getByText("Age")).toBeInTheDocument();
65+
66+
expect(screen.getByText("Alice")).toBeInTheDocument();
67+
expect(screen.getByText("Bob")).toBeInTheDocument();
68+
expect(screen.getByText("30")).toBeInTheDocument();
69+
expect(screen.getByText("20")).toBeInTheDocument();
70+
});
71+
72+
it("renders fallback when there is no data", () => {
73+
render(() => (
74+
<DataTable
75+
id="empty"
76+
columns={columns}
77+
data={[]}
78+
fallback={<div>No data</div>}
79+
/>
80+
));
81+
82+
expect(screen.getByText("No data")).toBeInTheDocument();
83+
});
84+
85+
it("sorts rows when clicking a sortable header", async () => {
86+
render(() => <DataTable id="sorting" columns={columns} data={data} />);
87+
88+
const ageHeaderButton = screen.getByRole("button", { name: "Age" });
89+
const ageHeaderCell = ageHeaderButton.closest("th");
90+
91+
// Initial
92+
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
93+
expect(ageHeaderCell?.querySelector("i")).toHaveClass("fa-fw");
94+
95+
// Descending
96+
await fireEvent.click(ageHeaderButton);
97+
expect(ageHeaderCell).toHaveAttribute("aria-sort", "descending");
98+
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
99+
"fa-sort-down",
100+
"fas",
101+
"fa-fw",
102+
);
103+
expect(localStorage()).toEqual([
104+
{
105+
desc: true,
106+
id: "age",
107+
},
108+
]);
109+
110+
let rows = screen.getAllByRole("row");
111+
expect(rows[1]).toHaveTextContent("Alice"); // age 30
112+
expect(rows[2]).toHaveTextContent("Bob"); // age 20
113+
114+
// Ascending
115+
await fireEvent.click(ageHeaderButton);
116+
expect(ageHeaderCell).toHaveAttribute("aria-sort", "ascending");
117+
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
118+
"fa-sort-up",
119+
"fas",
120+
"fa-fw",
121+
);
122+
expect(localStorage()).toEqual([
123+
{
124+
desc: false,
125+
id: "age",
126+
},
127+
]);
128+
129+
rows = screen.getAllByRole("row");
130+
expect(rows[1]).toHaveTextContent("Bob");
131+
expect(rows[2]).toHaveTextContent("Alice");
132+
133+
//back to initial
134+
await fireEvent.click(ageHeaderButton);
135+
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
136+
expect(localStorage()).toEqual([]);
137+
});
138+
139+
it("hides columns based on breakpoint visibility", () => {
140+
bpSignal[1]({
141+
xxs: true,
142+
sm: false,
143+
md: false,
144+
});
145+
146+
render(() => <DataTable id="breakpoints" columns={columns} data={data} />);
147+
148+
expect(screen.getByText("Name")).toBeInTheDocument();
149+
expect(screen.queryByText("Age")).not.toBeInTheDocument();
150+
});
151+
});

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@sentry/browser": "9.14.0",
3131
"@sentry/vite-plugin": "3.3.1",
3232
"@solidjs/meta": "0.29.4",
33+
"@tanstack/solid-table": "8.21.3",
3334
"@ts-rest/core": "3.52.1",
3435
"animejs": "4.2.2",
3536
"balloon-css": "1.2.0",

frontend/src/styles/tailwind.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,7 @@
5353
.rounded-half {
5454
border-radius: calc(var(--roundness) / 2);
5555
}
56+
.has-button\:p-0:has(button) {
57+
padding: 0;
58+
}
5659
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
AccessorFnColumnDef,
3+
AccessorKeyColumnDef,
4+
ColumnDef,
5+
createSolidTable,
6+
flexRender,
7+
getCoreRowModel,
8+
getSortedRowModel,
9+
SortingState,
10+
} from "@tanstack/solid-table";
11+
import { createMemo, For, JSXElement, Match, Show, Switch } from "solid-js";
12+
import { z } from "zod";
13+
14+
import { useLocalStorage } from "../../../hooks/useLocalStorage";
15+
import { bp } from "../../../signals/breakpoints";
16+
import { Conditional } from "../../common/Conditional";
17+
import { Fa } from "../../common/Fa";
18+
19+
import {
20+
Table,
21+
TableBody,
22+
TableCell,
23+
TableHead,
24+
TableHeader,
25+
TableRow,
26+
} from "./Table";
27+
28+
const SortingStateSchema = z.array(
29+
z.object({
30+
desc: z.boolean(),
31+
id: z.string(),
32+
}),
33+
);
34+
35+
export type AnyColumnDef<TData, TValue = unknown> =
36+
| ColumnDef<TData, TValue>
37+
| AccessorFnColumnDef<TData, TValue>
38+
| AccessorKeyColumnDef<TData, TValue>;
39+
40+
type DataTableProps<TData, TValue> = {
41+
id: string;
42+
columns: AnyColumnDef<TData, TValue>[];
43+
data: TData[];
44+
fallback?: JSXElement;
45+
};
46+
47+
export function DataTable<TData, TValue = unknown>(
48+
props: DataTableProps<TData, TValue>,
49+
): JSXElement {
50+
const [sorting, setSorting] = useLocalStorage<SortingState>({
51+
//oxlint-disable-next-line solid/reactivity
52+
key: `${props.id}Sort`,
53+
schema: SortingStateSchema,
54+
fallback: [],
55+
//migrate old state from sorted-table
56+
migrate: (value: Record<string, unknown> | unknown[]) =>
57+
value !== null &&
58+
typeof value === "object" &&
59+
"property" in value &&
60+
"descending" in value
61+
? [
62+
{
63+
id: value["property"] as string,
64+
desc: value["descending"] as boolean,
65+
},
66+
]
67+
: [],
68+
});
69+
70+
const columnVisibility = createMemo(() => {
71+
const current = bp();
72+
const result = Object.fromEntries(
73+
props.columns.map((col, index) => {
74+
const id =
75+
col.id ??
76+
("accessorKey" in col && col.accessorKey !== null
77+
? String(col.accessorKey)
78+
: `__col_${index}`);
79+
80+
return [id, current[col.meta?.breakpoint ?? "xxs"]];
81+
}),
82+
);
83+
84+
return result;
85+
});
86+
87+
const table = createSolidTable<TData>({
88+
get data() {
89+
return props.data;
90+
},
91+
get columns() {
92+
return props.columns;
93+
},
94+
getCoreRowModel: getCoreRowModel(),
95+
onSortingChange: setSorting,
96+
getSortedRowModel: getSortedRowModel(),
97+
state: {
98+
get sorting() {
99+
return sorting();
100+
},
101+
get columnVisibility() {
102+
return columnVisibility();
103+
},
104+
},
105+
});
106+
107+
return (
108+
<Show when={table.getRowModel().rows?.length} fallback={props.fallback}>
109+
<Table>
110+
<TableHeader>
111+
<For each={table.getHeaderGroups()}>
112+
{(headerGroup) => (
113+
<TableRow>
114+
<For each={headerGroup.headers}>
115+
{(header) => (
116+
<Conditional
117+
if={header.column.getCanSort()}
118+
then={
119+
<TableHead
120+
colSpan={header.colSpan}
121+
aria-sort={
122+
header.column.getIsSorted() === "asc"
123+
? "ascending"
124+
: header.column.getIsSorted() === "desc"
125+
? "descending"
126+
: "none"
127+
}
128+
>
129+
<button
130+
type="button"
131+
role="button"
132+
onClick={(e) => {
133+
header.column.getToggleSortingHandler()?.(e);
134+
}}
135+
class="text-sub hover:bg-sub-alt m-0 box-border flex h-full w-full cursor-pointer items-start justify-start rounded-none border-0 bg-transparent p-2 text-left font-normal whitespace-nowrap"
136+
{...(header.column.columnDef.meta
137+
?.sortableHeaderMeta ?? {})}
138+
>
139+
<Show when={!header.isPlaceholder}>
140+
{flexRender(
141+
header.column.columnDef.header,
142+
header.getContext(),
143+
)}
144+
</Show>
145+
146+
<Switch fallback={<i class="fa-fw"></i>}>
147+
<Match
148+
when={header.column.getIsSorted() === "asc"}
149+
>
150+
<Fa
151+
icon={"fa-sort-up"}
152+
fixedWidth
153+
aria-hidden="true"
154+
/>
155+
</Match>
156+
<Match
157+
when={header.column.getIsSorted() === "desc"}
158+
>
159+
<Fa
160+
icon={"fa-sort-down"}
161+
fixedWidth
162+
aria-hidden="true"
163+
/>
164+
</Match>
165+
</Switch>
166+
</button>
167+
</TableHead>
168+
}
169+
else={
170+
<TableHead colSpan={header.colSpan}>
171+
<Show when={!header.isPlaceholder}>
172+
{flexRender(
173+
header.column.columnDef.header,
174+
header.getContext(),
175+
)}
176+
</Show>
177+
</TableHead>
178+
}
179+
/>
180+
)}
181+
</For>
182+
</TableRow>
183+
)}
184+
</For>
185+
</TableHeader>
186+
<TableBody>
187+
<For each={table.getRowModel().rows}>
188+
{(row) => (
189+
<TableRow data-state={row.getIsSelected() && "selected"}>
190+
<For each={row.getVisibleCells()}>
191+
{(cell) => {
192+
const cellMeta =
193+
typeof cell.column.columnDef.meta?.cellMeta === "function"
194+
? cell.column.columnDef.meta.cellMeta({
195+
value: cell.getValue(),
196+
row: cell.row.original,
197+
})
198+
: (cell.column.columnDef.meta?.cellMeta ?? {});
199+
return (
200+
<TableCell {...cellMeta}>
201+
{flexRender(
202+
cell.column.columnDef.cell,
203+
cell.getContext(),
204+
)}
205+
</TableCell>
206+
);
207+
}}
208+
</For>
209+
</TableRow>
210+
)}
211+
</For>
212+
</TableBody>
213+
</Table>
214+
</Show>
215+
);
216+
}

0 commit comments

Comments
 (0)