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: