From 1e10edbe5a3ecc32ff603e1a92364b607693f832 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 Jan 2026 12:36:48 +0100 Subject: [PATCH 01/12] feat: add DataTable component (@fehmer) --- frontend/package.json | 1 + .../src/ts/components/ui/table/DataTable.tsx | 164 ++++++++++++++++++ frontend/src/ts/components/ui/table/Table.tsx | 87 ++++++++++ .../components/ui/table/TableColumnHeader.tsx | 50 ++++++ frontend/src/ts/signals/breakpoints.ts | 10 +- frontend/src/ts/types/tanstack-table.d.ts | 26 +++ frontend/tsconfig.json | 3 +- pnpm-lock.yaml | 82 +++------ 8 files changed, 358 insertions(+), 65 deletions(-) create mode 100644 frontend/src/ts/components/ui/table/DataTable.tsx create mode 100644 frontend/src/ts/components/ui/table/Table.tsx create mode 100644 frontend/src/ts/components/ui/table/TableColumnHeader.tsx create mode 100644 frontend/src/ts/types/tanstack-table.d.ts 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/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx new file mode 100644 index 000000000000..75bb57ec3434 --- /dev/null +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -0,0 +1,164 @@ +import { + AccessorKeyColumnDef, + ColumnDef, + createSolidTable, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, +} from "@tanstack/solid-table"; +import { createMemo, For, JSXElement, Show } from "solid-js"; +import { z } from "zod"; + +import { useLocalStorage } from "../../../hooks/useLocalStorage"; +import { bp } from "../../../signals/breakpoints"; + +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( + // oxlint-disable-next-line typescript/no-explicit-any + 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[]) => + 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) => { + //fill missing columnIds, otherwise hidinc columns will not work + if (col.id === undefined) { + if ("accessorKey" in col) { + col.id = col.accessorKey as string; + } + } + return [col.id as string, 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) => ( + + + {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..ca8943f312ac --- /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/components/ui/table/TableColumnHeader.tsx b/frontend/src/ts/components/ui/table/TableColumnHeader.tsx new file mode 100644 index 000000000000..eb68a3c61bf8 --- /dev/null +++ b/frontend/src/ts/components/ui/table/TableColumnHeader.tsx @@ -0,0 +1,50 @@ +import { Column } from "@tanstack/solid-table"; +import { + ComponentProps, + JSXElement, + Match, + Show, + splitProps, + Switch, +} from "solid-js"; + +import { cn } from "../../../utils/cn"; + +type TableColumnHeaderProps = ComponentProps<"button"> & { + column: Column; + title: string; +}; + +export function TableColumnHeader( + props: TableColumnHeaderProps, +): JSXElement { + const [local, others] = splitProps(props, ["column", "title", "class"]); + + return ( + + + + ); +} 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..4a748e51cc0a --- /dev/null +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -0,0 +1,26 @@ +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); + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 467adc1eec01..c8efba5ed40c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,7 +22,8 @@ "./src/**/*.tsx", "./scripts/**/*.ts", "vite-plugins/**/*.ts", - "vite.config.ts" + "vite.config.ts", + "./src/types/**/*.d.ts" ], "exclude": ["node_modules", "build", "setup-tests.ts", "./__tests__/**/*.*"] } 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: From 22a5284be787073c341bf5de5790ad2a48c26cd2 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 27 Jan 2026 22:04:42 +0100 Subject: [PATCH 02/12] review comments --- frontend/src/styles/tailwind.css | 3 ++ .../src/ts/components/ui/table/DataTable.tsx | 52 ++++++++++++++++--- frontend/src/ts/components/ui/table/Table.tsx | 2 +- .../components/ui/table/TableColumnHeader.tsx | 50 ------------------ frontend/src/ts/types/tanstack-table.d.ts | 6 +++ 5 files changed, 56 insertions(+), 57 deletions(-) delete mode 100644 frontend/src/ts/components/ui/table/TableColumnHeader.tsx 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 index 75bb57ec3434..2742c2c4e5da 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -5,13 +5,15 @@ import { flexRender, getCoreRowModel, getSortedRowModel, + Header, SortingState, } from "@tanstack/solid-table"; -import { createMemo, For, JSXElement, Show } from "solid-js"; +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 { Fa } from "../../common/Fa"; import { Table, @@ -99,6 +101,11 @@ export function DataTable( }, }); + const renderHeader = (header: Header): JSXElement => ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + ); return ( @@ -118,11 +125,44 @@ export function DataTable( : "none" } > - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} + + )} diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx index ca8943f312ac..293e55542782 100644 --- a/frontend/src/ts/components/ui/table/Table.tsx +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -57,7 +57,7 @@ const TableHead: Component> = (props) => {
= ComponentProps<"button"> & { - column: Column; - title: string; -}; - -export function TableColumnHeader( - props: TableColumnHeaderProps, -): JSXElement { - const [local, others] = splitProps(props, ["column", "title", "class"]); - - return ( - - - - ); -} diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts index 4a748e51cc0a..d69863e3b835 100644 --- a/frontend/src/ts/types/tanstack-table.d.ts +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -22,5 +22,11 @@ declare module "@tanstack/solid-table" { 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; } } From fafdc955f0edb6496ee4c058048e3bd6f9a2ff94 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 27 Jan 2026 22:29:52 +0100 Subject: [PATCH 03/12] fix gap on mouseover --- frontend/src/ts/components/ui/table/Table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx index 293e55542782..1e1f5e762ae4 100644 --- a/frontend/src/ts/components/ui/table/Table.tsx +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -57,7 +57,7 @@ const TableHead: Component> = (props) => { Date: Wed, 28 Jan 2026 00:08:52 +0100 Subject: [PATCH 04/12] cleanup --- .../src/ts/components/ui/table/DataTable.tsx | 113 ++++++++++-------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index 2742c2c4e5da..1102c4f9504f 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -5,7 +5,6 @@ import { flexRender, getCoreRowModel, getSortedRowModel, - Header, SortingState, } from "@tanstack/solid-table"; import { createMemo, For, JSXElement, Match, Show, Switch } from "solid-js"; @@ -13,6 +12,7 @@ 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 { @@ -101,11 +101,6 @@ export function DataTable( }, }); - const renderHeader = (header: Header): JSXElement => ( - - {flexRender(header.column.columnDef.header, header.getContext())} - - ); return ( @@ -115,56 +110,68 @@ export function DataTable( {(header) => ( - - - - - + }> + + + + + + + + } + else={ + + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + } + /> )} From 5a6219ae6551348d3edf6223ff86c3febcd53996 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 28 Jan 2026 00:14:04 +0100 Subject: [PATCH 05/12] copilot comments --- frontend/src/ts/components/ui/table/DataTable.tsx | 7 +++++-- frontend/tsconfig.json | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index 1102c4f9504f..13f4a9ae21d5 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -54,7 +54,10 @@ export function DataTable( fallback: [], //migrate old state from sorted-table migrate: (value: Record | unknown[]) => - typeof value === "object" && "property" in value && "descending" in value + value !== null && + typeof value === "object" && + "property" in value && + "descending" in value ? [ { id: value["property"] as string, @@ -68,7 +71,7 @@ export function DataTable( const current = bp(); const result = Object.fromEntries( props.columns.map((col) => { - //fill missing columnIds, otherwise hidinc columns will not work + //fill missing columnIds, otherwise hiding columns will not work if (col.id === undefined) { if ("accessorKey" in col) { col.id = col.accessorKey as string; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c8efba5ed40c..467adc1eec01 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,8 +22,7 @@ "./src/**/*.tsx", "./scripts/**/*.ts", "vite-plugins/**/*.ts", - "vite.config.ts", - "./src/types/**/*.d.ts" + "vite.config.ts" ], "exclude": ["node_modules", "build", "setup-tests.ts", "./__tests__/**/*.*"] } From 09df1bac15baf4dfaa9dd0823a36f2b13cfa9194 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 28 Jan 2026 00:16:05 +0100 Subject: [PATCH 06/12] lint --- frontend/src/ts/components/ui/table/DataTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index 13f4a9ae21d5..a344a58ea8e1 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -141,7 +141,7 @@ export function DataTable( > {header.column.columnDef.header} - }> + }> From 7f89292a1b756e482a1f44322ee60b61ecc8a40d Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 28 Jan 2026 18:52:02 +0100 Subject: [PATCH 07/12] a very hard fix --- frontend/src/ts/components/ui/table/DataTable.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index a344a58ea8e1..2813ebac9e73 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -135,7 +135,7 @@ export function DataTable( 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 text-left font-normal whitespace-nowrap" + 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 ?? {})} > @@ -167,10 +167,12 @@ export function DataTable( else={ - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
} From e791fa335fdc619fefe53db88d7a62d2340d2cc3 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 28 Jan 2026 19:03:47 +0100 Subject: [PATCH 08/12] cleanup --- frontend/src/ts/components/ui/table/DataTable.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index 2813ebac9e73..e0740ea4e595 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -167,12 +167,10 @@ export function DataTable( else={ -
- {flexRender( - header.column.columnDef.header, - header.getContext(), - )} -
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )}
} From f2a942ad1491648c67c3f4eb0ab3035518e6bc18 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 31 Jan 2026 14:57:47 +0100 Subject: [PATCH 09/12] fix type --- frontend/src/ts/components/ui/table/DataTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index e0740ea4e595..c6875386ab3b 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -1,4 +1,5 @@ import { + AccessorFnColumnDef, AccessorKeyColumnDef, ColumnDef, createSolidTable, @@ -33,7 +34,7 @@ const SortingStateSchema = z.array( export type AnyColumnDef = | ColumnDef - // | AccessorFnColumnDef + | AccessorFnColumnDef | AccessorKeyColumnDef; type DataTableProps = { @@ -43,9 +44,8 @@ type DataTableProps = { fallback?: JSXElement; }; -export function DataTable( - // oxlint-disable-next-line typescript/no-explicit-any - props: DataTableProps, +export function DataTable( + props: DataTableProps, ): JSXElement { const [sorting, setSorting] = useLocalStorage({ //oxlint-disable-next-line solid/reactivity From a59ed65409a3e4b88d27c1591e69e40dcd90333a Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 31 Jan 2026 15:04:12 +0100 Subject: [PATCH 10/12] allow complex headers with sorting --- frontend/src/ts/components/ui/table/DataTable.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index c6875386ab3b..7831f83f1b81 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -32,7 +32,7 @@ const SortingStateSchema = z.array( }), ); -export type AnyColumnDef = +export type AnyColumnDef = | ColumnDef | AccessorFnColumnDef | AccessorKeyColumnDef; @@ -114,10 +114,7 @@ export function DataTable( {(header) => ( ( {...(header.column.columnDef.meta ?.sortableHeaderMeta ?? {})} > - {header.column.columnDef.header} + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + }> Date: Sat, 31 Jan 2026 15:27:13 +0100 Subject: [PATCH 11/12] added tests --- .../components/ui/table/DataTable.spec.tsx | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 frontend/__tests__/components/ui/table/DataTable.spec.tsx 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(); + }); +}); From 1325ab31faaf5040b7c8f2a1e631ea1753a4ea73 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 31 Jan 2026 15:35:16 +0100 Subject: [PATCH 12/12] copilot --- .../src/ts/components/ui/table/DataTable.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/ts/components/ui/table/DataTable.tsx b/frontend/src/ts/components/ui/table/DataTable.tsx index 7831f83f1b81..664ff05aad48 100644 --- a/frontend/src/ts/components/ui/table/DataTable.tsx +++ b/frontend/src/ts/components/ui/table/DataTable.tsx @@ -70,14 +70,14 @@ export function DataTable( const columnVisibility = createMemo(() => { const current = bp(); const result = Object.fromEntries( - props.columns.map((col) => { - //fill missing columnIds, otherwise hiding columns will not work - if (col.id === undefined) { - if ("accessorKey" in col) { - col.id = col.accessorKey as string; - } - } - return [col.id as string, current[col.meta?.breakpoint ?? "xxs"]]; + 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"]]; }), );