diff --git a/frontend/__tests__/components/ui/table/DataTable.spec.tsx b/frontend/__tests__/components/ui/table/DataTable.spec.tsx new file mode 100644 index 000000000000..5b4143708140 --- /dev/null +++ b/frontend/__tests__/components/ui/table/DataTable.spec.tsx @@ -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(() => ); + + 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(() => ( + No data} + /> + )); + + expect(screen.getByText("No data")).toBeInTheDocument(); + }); + + it("sorts rows when clicking a sortable header", async () => { + render(() => ); + + 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(() => ); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.queryByText("Age")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index bac5f695da18..af7aafda140a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index 80469e898977..e0373a6699a3 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -53,4 +53,7 @@ .rounded-half { border-radius: calc(var(--roundness) / 2); } + .has-button\:p-0:has(button) { + padding: 0; + } } diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx new file mode 100644 index 000000000000..664ff05aad48 --- /dev/null +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -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 = + | ColumnDef + | AccessorFnColumnDef + | AccessorKeyColumnDef; + +type DataTableProps = { + id: string; + columns: AnyColumnDef[]; + data: TData[]; + fallback?: JSXElement; +}; + +export function DataTable( + props: DataTableProps, +): JSXElement { + const [sorting, setSorting] = useLocalStorage({ + //oxlint-disable-next-line solid/reactivity + key: `${props.id}Sort`, + schema: SortingStateSchema, + fallback: [], + //migrate old state from sorted-table + migrate: (value: Record | 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({ + 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 ( + + + + + {(headerGroup) => ( + + + {(header) => ( + + + + } + else={ + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + } + /> + )} + + + )} + + + + + {(row) => ( + + + {(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 ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + }} + + + )} + + +
+
+ ); +} diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx new file mode 100644 index 000000000000..1e1f5e762ae4 --- /dev/null +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -0,0 +1,87 @@ +import type { Component, ComponentProps } from "solid-js"; +import { splitProps } from "solid-js"; + +import { cn } from "../../../utils/cn"; + +const Table: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( +
+ ); +}; + +const TableHeader: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:bg-none", local.class)} + {...others} + > + ); +}; + +const TableBody: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + tr]:odd:bg-sub-alt text-xs md:text-sm lg:text-base", + local.class, + )} + {...others} + > + ); +}; + +const TableFooter: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableRow: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ( + td]:first:rounded-l [&>td]:last:rounded-r", local.class)} + {...others} + > + ); +}; + +const TableHead: Component> = (props) => { + const [local, others] = splitProps(props, ["class", "aria-label"]); + return ( + + ); +}; + +const TableCell: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +const TableCaption: Component> = (props) => { + const [local, others] = splitProps(props, ["class"]); + return ; +}; + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +}; diff --git a/frontend/src/ts/signals/breakpoints.ts b/frontend/src/ts/signals/breakpoints.ts index e1664f25718c..d007aa935686 100644 --- a/frontend/src/ts/signals/breakpoints.ts +++ b/frontend/src/ts/signals/breakpoints.ts @@ -1,11 +1,11 @@ import { Accessor, createSignal, onCleanup } from "solid-js"; import { debounce } from "throttle-debounce"; -type BreakpointKeys = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; -type Breakpoints = Record; +export type BreakpointKey = "xxl" | "xl" | "lg" | "md" | "sm" | "xs" | "xxs"; +type Breakpoints = Record; const styles = getComputedStyle(document.documentElement); -const tw: Record = { +const tw: Record = { xxs: 0, xs: parseInt(styles.getPropertyValue("--breakpoint-xs")), sm: parseInt(styles.getPropertyValue("--breakpoint-sm")), @@ -18,7 +18,7 @@ const tw: Record = { export const bp = createBreakpoints(tw); function createBreakpoints( - breakpoints: Record, + breakpoints: Record, ): Accessor { const queries = Object.fromEntries( Object.entries(breakpoints).map(([key, px]) => [ @@ -49,5 +49,5 @@ function createBreakpoints( } }); - return matches as Accessor>; + return matches as Accessor>; } diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts new file mode 100644 index 000000000000..d69863e3b835 --- /dev/null +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -0,0 +1,32 @@ +import "@tanstack/solid-table"; +import type { JSX } from "solid-js"; +import { BreakpointKey } from "../signals/breakpoints"; + +declare module "@tanstack/solid-table" { + //This needs to be an interface + // oxlint-disable-next-line typescript/consistent-type-definitions + interface ColumnMeta { + /** + * define minimal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + breakpoint?: BreakpointKey; + + /** + * additional attributes to be set on the table cell. + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + cellMeta?: + | JSX.HTMLAttributes + | ((ctx: { + value: TValue; + row: TData; + }) => JSX.HTMLAttributes); + + /** + * additional attributes to be set on the header if it is sortable + * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` + */ + sortableHeaderMeta?: JSX.HTMLAttributes; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25366c8d830d..a704e458ba8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: '@solidjs/meta': specifier: 0.29.4 version: 0.29.4(solid-js@1.9.10) + '@tanstack/solid-table': + specifier: 8.21.3 + version: 8.21.3(solid-js@1.9.10) '@ts-rest/core': specifier: 3.52.1 version: 3.52.1(@types/node@24.9.1)(zod@3.23.8) @@ -417,7 +420,7 @@ importers: version: 5.0.2 '@vitest/coverage-v8': specifier: 4.0.15 - version: 4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.31) @@ -510,7 +513,7 @@ importers: version: 2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) vitest: specifier: 4.0.15 - version: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + version: 4.0.15(@opentelemetry/api@1.8.0)(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) packages/contracts: dependencies: @@ -705,7 +708,7 @@ packages: resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} peerDependencies: openapi3-ts: ^2.0.0 || ^3.0.0 - zod: 3.23.8 + zod: ^3.20.0 '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} @@ -3376,6 +3379,16 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/solid-table@8.21.3': + resolution: {integrity: sha512-PmhfSLBxVKiFs01LtYOYrCRhCyTUjxmb4KlxRQiqcALtip8+DOJeeezQM4RSX/GUS0SMVHyH/dNboCpcO++k2A==} + engines: {node: '>=12'} + peerDependencies: + solid-js: '>=1.3' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -13064,6 +13077,13 @@ snapshots: tailwindcss: 4.1.18 vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) + '@tanstack/solid-table@8.21.3(solid-js@1.9.10)': + dependencies: + '@tanstack/table-core': 8.21.3 + solid-js: 1.9.10 + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -13497,23 +13517,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.15 - ast-v8-to-istanbul: 0.3.8 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magicast: 0.5.1 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -20694,45 +20697,6 @@ snapshots: - tsx - yaml - vitest@4.0.15(@types/node@24.9.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1): - dependencies: - '@vitest/expect': 4.0.15 - '@vitest/mocker': 4.0.15(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.15 - '@vitest/runner': 4.0.15 - '@vitest/snapshot': 4.0.15 - '@vitest/spy': 4.0.15 - '@vitest/utils': 4.0.15 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.70.0)(terser@5.46.0)(tsx@4.16.2)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.9.1 - happy-dom: 20.0.10 - jsdom: 27.4.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - vlq@0.2.3: {} w3c-xmlserializer@5.0.0: