Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions frontend/__tests__/components/ui/table/DataTable.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { render, screen, fireEvent } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, it, expect, vi, beforeEach } from "vitest";

import { DataTable } from "../../../../src/ts/components/ui/table/DataTable";

const [localStorage, setLocalStorage] = createSignal([]);
vi.mock("../../../../src/ts/hooks/useLocalStorage", () => {
return {
useLocalStorage: () => {
return [localStorage, setLocalStorage] as const;
},
};
});

const bpSignal = createSignal({
xxs: true,
sm: true,
md: true,
});

vi.mock("../../../../src/ts/signals/breakpoints", () => ({
bp: () => bpSignal[0](),
}));

type Person = {
name: string;
age: number;
};

const columns = [
{
accessorKey: "name",
header: "Name",
cell: (info: any) => info.getValue(),
meta: { breakpoint: "xxs" },
},
{
accessorKey: "age",
header: "Age",
cell: (info: any) => info.getValue(),
meta: { breakpoint: "sm" },
},
];

const data: Person[] = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 20 },
];

describe("DataTable", () => {
beforeEach(() => {
bpSignal[1]({
xxs: true,
sm: true,
md: true,
});
});

it("renders table headers and rows", () => {
render(() => <DataTable id="people" columns={columns} data={data} />);

expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Age")).toBeInTheDocument();

expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("30")).toBeInTheDocument();
expect(screen.getByText("20")).toBeInTheDocument();
});

it("renders fallback when there is no data", () => {
render(() => (
<DataTable
id="empty"
columns={columns}
data={[]}
fallback={<div>No data</div>}
/>
));

expect(screen.getByText("No data")).toBeInTheDocument();
});

it("sorts rows when clicking a sortable header", async () => {
render(() => <DataTable id="sorting" columns={columns} data={data} />);

const ageHeaderButton = screen.getByRole("button", { name: "Age" });
const ageHeaderCell = ageHeaderButton.closest("th");

// Initial
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
expect(ageHeaderCell?.querySelector("i")).toHaveClass("fa-fw");

// Descending
await fireEvent.click(ageHeaderButton);
expect(ageHeaderCell).toHaveAttribute("aria-sort", "descending");
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
"fa-sort-down",
"fas",
"fa-fw",
);
expect(localStorage()).toEqual([
{
desc: true,
id: "age",
},
]);

let rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("Alice"); // age 30
expect(rows[2]).toHaveTextContent("Bob"); // age 20

// Ascending
await fireEvent.click(ageHeaderButton);
expect(ageHeaderCell).toHaveAttribute("aria-sort", "ascending");
expect(ageHeaderCell?.querySelector("i")).toHaveClass(
"fa-sort-up",
"fas",
"fa-fw",
);
expect(localStorage()).toEqual([
{
desc: false,
id: "age",
},
]);

rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("Bob");
expect(rows[2]).toHaveTextContent("Alice");

//back to initial
await fireEvent.click(ageHeaderButton);
expect(ageHeaderCell).toHaveAttribute("aria-sort", "none");
expect(localStorage()).toEqual([]);
});

it("hides columns based on breakpoint visibility", () => {
bpSignal[1]({
xxs: true,
sm: false,
md: false,
});

render(() => <DataTable id="breakpoints" columns={columns} data={data} />);

expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.queryByText("Age")).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@sentry/browser": "9.14.0",
"@sentry/vite-plugin": "3.3.1",
"@solidjs/meta": "0.29.4",
"@tanstack/solid-table": "8.21.3",
"@ts-rest/core": "3.52.1",
"animejs": "4.2.2",
"balloon-css": "1.2.0",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,7 @@
.rounded-half {
border-radius: calc(var(--roundness) / 2);
}
.has-button\:p-0:has(button) {
padding: 0;
}
}
216 changes: 216 additions & 0 deletions frontend/src/ts/components/ui/table/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {
AccessorFnColumnDef,
AccessorKeyColumnDef,
ColumnDef,
createSolidTable,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
} from "@tanstack/solid-table";
import { createMemo, For, JSXElement, Match, Show, Switch } from "solid-js";
import { z } from "zod";

import { useLocalStorage } from "../../../hooks/useLocalStorage";
import { bp } from "../../../signals/breakpoints";
import { Conditional } from "../../common/Conditional";
import { Fa } from "../../common/Fa";

import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./Table";

const SortingStateSchema = z.array(
z.object({
desc: z.boolean(),
id: z.string(),
}),
);

export type AnyColumnDef<TData, TValue = unknown> =
| ColumnDef<TData, TValue>
| AccessorFnColumnDef<TData, TValue>
| AccessorKeyColumnDef<TData, TValue>;

type DataTableProps<TData, TValue> = {
id: string;
columns: AnyColumnDef<TData, TValue>[];
data: TData[];
fallback?: JSXElement;
};

export function DataTable<TData, TValue = unknown>(
props: DataTableProps<TData, TValue>,
): JSXElement {
const [sorting, setSorting] = useLocalStorage<SortingState>({
//oxlint-disable-next-line solid/reactivity
key: `${props.id}Sort`,
schema: SortingStateSchema,
fallback: [],
//migrate old state from sorted-table
migrate: (value: Record<string, unknown> | unknown[]) =>
value !== null &&
typeof value === "object" &&
"property" in value &&
"descending" in value
? [
{
id: value["property"] as string,
desc: value["descending"] as boolean,
},
]
: [],
});

const columnVisibility = createMemo(() => {
const current = bp();
const result = Object.fromEntries(
props.columns.map((col, index) => {
const id =
col.id ??
("accessorKey" in col && col.accessorKey !== null
? String(col.accessorKey)
: `__col_${index}`);

return [id, current[col.meta?.breakpoint ?? "xxs"]];
}),
);

return result;
});

const table = createSolidTable<TData>({
get data() {
return props.data;
},
get columns() {
return props.columns;
},
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
state: {
get sorting() {
return sorting();
},
get columnVisibility() {
return columnVisibility();
},
},
});

return (
<Show when={table.getRowModel().rows?.length} fallback={props.fallback}>
<Table>
<TableHeader>
<For each={table.getHeaderGroups()}>
{(headerGroup) => (
<TableRow>
<For each={headerGroup.headers}>
{(header) => (
<Conditional
if={header.column.getCanSort()}
then={
<TableHead
colSpan={header.colSpan}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
>
<button
type="button"
role="button"
onClick={(e) => {
header.column.getToggleSortingHandler()?.(e);
}}
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"
{...(header.column.columnDef.meta
?.sortableHeaderMeta ?? {})}
>
<Show when={!header.isPlaceholder}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Show>

<Switch fallback={<i class="fa-fw"></i>}>
<Match
when={header.column.getIsSorted() === "asc"}
>
<Fa
icon={"fa-sort-up"}
fixedWidth
aria-hidden="true"
/>
</Match>
<Match
when={header.column.getIsSorted() === "desc"}
>
<Fa
icon={"fa-sort-down"}
fixedWidth
aria-hidden="true"
/>
</Match>
</Switch>
</button>
</TableHead>
}
else={
<TableHead colSpan={header.colSpan}>
<Show when={!header.isPlaceholder}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Show>
</TableHead>
}
/>
)}
</For>
</TableRow>
)}
</For>
</TableHeader>
<TableBody>
<For each={table.getRowModel().rows}>
{(row) => (
<TableRow data-state={row.getIsSelected() && "selected"}>
<For each={row.getVisibleCells()}>
{(cell) => {
const cellMeta =
typeof cell.column.columnDef.meta?.cellMeta === "function"
? cell.column.columnDef.meta.cellMeta({
value: cell.getValue(),
row: cell.row.original,
})
: (cell.column.columnDef.meta?.cellMeta ?? {});
return (
<TableCell {...cellMeta}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
}}
</For>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
);
}
Loading