From 2410541f7caa8b8a15b2a695968fe1dc2136aa57 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 16 Feb 2026 23:22:09 -0600 Subject: [PATCH] feat: use data table with dynamic columns for records page --- web/components.json | 4 +- web/package-lock.json | 126 +++++++ web/package.json | 4 + web/src/app/(dashboard)/records/page.tsx | 149 +++++++-- .../data-table/data-table-column-header.tsx | 99 ++++++ .../data-table/data-table-date-filter.tsx | 225 +++++++++++++ .../data-table/data-table-faceted-filter.tsx | 189 +++++++++++ .../data-table/data-table-pagination.tsx | 112 +++++++ .../data-table/data-table-skeleton.tsx | 115 +++++++ .../data-table/data-table-slider-filter.tsx | 256 ++++++++++++++ .../data-table/data-table-toolbar.tsx | 149 +++++++++ .../data-table/data-table-view-options.tsx | 89 +++++ web/src/components/data-table/data-table.tsx | 101 ++++++ web/src/components/ui/calendar.tsx | 220 ++++++++++++ web/src/components/ui/command.tsx | 184 ++++++++++ web/src/components/ui/dialog.tsx | 2 +- web/src/components/ui/popover.tsx | 89 +++++ web/src/components/ui/slider.tsx | 63 ++++ web/src/config/data-table.ts | 82 +++++ web/src/hooks/use-callback-ref.ts | 27 ++ web/src/hooks/use-data-table.ts | 316 ++++++++++++++++++ web/src/hooks/use-debounced-callback.ts | 28 ++ web/src/lib/data-table.ts | 77 +++++ web/src/lib/format.ts | 17 + web/src/lib/parsers.ts | 99 ++++++ web/src/types/data-table.ts | 53 +++ 26 files changed, 2837 insertions(+), 38 deletions(-) create mode 100644 web/src/components/data-table/data-table-column-header.tsx create mode 100644 web/src/components/data-table/data-table-date-filter.tsx create mode 100644 web/src/components/data-table/data-table-faceted-filter.tsx create mode 100644 web/src/components/data-table/data-table-pagination.tsx create mode 100644 web/src/components/data-table/data-table-skeleton.tsx create mode 100644 web/src/components/data-table/data-table-slider-filter.tsx create mode 100644 web/src/components/data-table/data-table-toolbar.tsx create mode 100644 web/src/components/data-table/data-table-view-options.tsx create mode 100644 web/src/components/data-table/data-table.tsx create mode 100644 web/src/components/ui/calendar.tsx create mode 100644 web/src/components/ui/command.tsx create mode 100644 web/src/components/ui/popover.tsx create mode 100644 web/src/components/ui/slider.tsx create mode 100644 web/src/config/data-table.ts create mode 100644 web/src/hooks/use-callback-ref.ts create mode 100644 web/src/hooks/use-data-table.ts create mode 100644 web/src/hooks/use-debounced-callback.ts create mode 100644 web/src/lib/data-table.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/parsers.ts create mode 100644 web/src/types/data-table.ts diff --git a/web/components.json b/web/components.json index 03909d9..9260fa5 100644 --- a/web/components.json +++ b/web/components.json @@ -19,5 +19,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@diceui": "https://diceui.com/r/{name}.json" + } } diff --git a/web/package-lock.json b/web/package-lock.json index b10c101..76a5c99 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,11 +16,15 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.564.0", "next": "16.1.6", "next-themes": "^0.4.6", + "nuqs": "^2.8.8", "radix-ui": "^1.4.3", "react": "19.2.3", + "react-day-picker": "^9.13.2", "react-dom": "19.2.3", "recharts": "^2.15.4", "sonner": "^2.0.7", @@ -108,6 +112,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -519,6 +524,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -536,6 +547,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -743,6 +755,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1938,6 +1951,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3572,6 +3586,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -4096,6 +4116,7 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4106,6 +4127,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4116,6 +4138,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4179,6 +4202,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -4692,6 +4716,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5078,6 +5103,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5167,6 +5193,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5448,6 +5475,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -5814,6 +5857,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6332,6 +6391,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6517,6 +6577,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6836,6 +6897,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7574,6 +7636,7 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9360,6 +9423,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nuqs": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.8.tgz", + "integrity": "sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10092,15 +10192,38 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.2.tgz", + "integrity": "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11372,6 +11495,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11629,6 +11753,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12264,6 +12389,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 209da20..0bb1f6b 100644 --- a/web/package.json +++ b/web/package.json @@ -17,11 +17,15 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.564.0", "next": "16.1.6", "next-themes": "^0.4.6", + "nuqs": "^2.8.8", "radix-ui": "^1.4.3", "react": "19.2.3", + "react-day-picker": "^9.13.2", "react-dom": "19.2.3", "recharts": "^2.15.4", "sonner": "^2.0.7", diff --git a/web/src/app/(dashboard)/records/page.tsx b/web/src/app/(dashboard)/records/page.tsx index 3577145..59510ea 100644 --- a/web/src/app/(dashboard)/records/page.tsx +++ b/web/src/app/(dashboard)/records/page.tsx @@ -1,6 +1,12 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" import { useAuth } from "@/lib/auth-context" import { @@ -34,14 +40,16 @@ import { } from "@/components/ui/table" function parseAtUri(uri: string): { did: string; rkey: string } { - // at://did:plc:xxx/collection/rkey const parts = uri.replace("at://", "").split("/") return { did: parts[0] ?? "", rkey: parts[2] ?? "" } } -function truncateJson(record: AdminRecord, maxLen = 120): string { - const str = JSON.stringify(record.record) - return str.length > maxLen ? str.slice(0, maxLen) + "..." : str +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") + return String(value) + return JSON.stringify(value) } export default function RecordsPage() { @@ -55,7 +63,6 @@ export default function RecordsPage() { const [error, setError] = useState(null) const [viewRecord, setViewRecord] = useState(null) - // Load collections from stats useEffect(() => { getStats(getToken) .then((stats) => setCollections(stats.collections)) @@ -81,6 +88,68 @@ export default function RecordsPage() { [getToken] ) + // Build columns dynamically from the union of all record keys + const columns = useMemo[]>(() => { + const keySet = new Set() + for (const r of records) { + for (const key of Object.keys(r.record)) { + keySet.add(key) + } + } + + const cols: ColumnDef[] = [ + { + id: "did", + header: "DID", + accessorFn: (row) => parseAtUri(row.uri).did, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + id: "rkey", + header: "Rkey", + accessorFn: (row) => parseAtUri(row.uri).rkey, + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + ] + + for (const key of keySet) { + cols.push({ + id: key, + header: key, + accessorFn: (row) => row.record[key], + cell: ({ getValue }) => { + const val = getValue() + const str = formatCellValue(val) + return ( + + {str} + + ) + }, + }) + } + + return cols + }, [records]) + + const table = useReactTable({ + data: records, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.uri, + }) + function handleSelectCollection(collection: string) { setSelectedCollection(collection) setCursorStack([]) @@ -97,7 +166,7 @@ export default function RecordsPage() { function handlePrevious() { if (cursorStack.length === 0 || !selectedCollection) return const stack = [...cursorStack] - stack.pop() // remove current page's cursor + stack.pop() const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined setCursorStack(stack) fetchRecords(selectedCollection, prevCursor) @@ -110,7 +179,10 @@ export default function RecordsPage() { {error &&

{error}

}
- @@ -126,30 +198,39 @@ export default function RecordsPage() { {selectedCollection && ( <> -
+
- - DID - Rkey - Record - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} {loading && ( Loading... )} - {!loading && records.length === 0 && ( + {!loading && table.getRowModel().rows.length === 0 && ( No records found. @@ -157,26 +238,22 @@ export default function RecordsPage() { )} {!loading && - records.map((record) => { - const { did, rkey } = parseAtUri(record.uri) - return ( - setViewRecord(record)} - > - - {did} - - - {rkey} - - - {truncateJson(record)} + table.getRowModel().rows.map((row) => ( + setViewRecord(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} - - ) - })} + ))} + + ))}
diff --git a/web/src/components/data-table/data-table-column-header.tsx b/web/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000..960ba6d --- /dev/null +++ b/web/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,99 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { + ChevronDown, + ChevronsUpDown, + ChevronUp, + EyeOff, + X, +} from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +interface DataTableColumnHeaderProps + extends React.ComponentProps { + column: Column; + label: string; +} + +export function DataTableColumnHeader({ + column, + label, + className, + ...props +}: DataTableColumnHeaderProps) { + if (!column.getCanSort() && !column.getCanHide()) { + return
{label}
; + } + + return ( + + + {label} + {column.getCanSort() && + (column.getIsSorted() === "desc" ? ( + + ) : column.getIsSorted() === "asc" ? ( + + ) : ( + + ))} + + + {column.getCanSort() && ( + <> + column.toggleSorting(false)} + > + + Asc + + column.toggleSorting(true)} + > + + Desc + + {column.getIsSorted() && ( + column.clearSorting()} + > + + Reset + + )} + + )} + {column.getCanHide() && ( + column.toggleVisibility(false)} + > + + Hide + + )} + + + ); +} diff --git a/web/src/components/data-table/data-table-date-filter.tsx b/web/src/components/data-table/data-table-date-filter.tsx new file mode 100644 index 0000000..f6dbc08 --- /dev/null +++ b/web/src/components/data-table/data-table-date-filter.tsx @@ -0,0 +1,225 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { CalendarIcon, XCircle } from "lucide-react"; +import * as React from "react"; +import type { DateRange } from "react-day-picker"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { formatDate } from "@/lib/format"; + +type DateSelection = Date[] | DateRange; + +function getIsDateRange(value: DateSelection): value is DateRange { + return value && typeof value === "object" && !Array.isArray(value); +} + +function parseAsDate(timestamp: number | string | undefined): Date | undefined { + if (!timestamp) return undefined; + const numericTimestamp = + typeof timestamp === "string" ? Number(timestamp) : timestamp; + const date = new Date(numericTimestamp); + return !Number.isNaN(date.getTime()) ? date : undefined; +} + +function parseColumnFilterValue(value: unknown) { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === "number" || typeof item === "string") { + return item; + } + return undefined; + }); + } + + if (typeof value === "string" || typeof value === "number") { + return [value]; + } + + return []; +} + +interface DataTableDateFilterProps { + column: Column; + title?: string; + multiple?: boolean; +} + +export function DataTableDateFilter({ + column, + title, + multiple, +}: DataTableDateFilterProps) { + const columnFilterValue = column.getFilterValue(); + + const selectedDates = React.useMemo(() => { + if (!columnFilterValue) { + return multiple ? { from: undefined, to: undefined } : []; + } + + if (multiple) { + const timestamps = parseColumnFilterValue(columnFilterValue); + return { + from: parseAsDate(timestamps[0]), + to: parseAsDate(timestamps[1]), + }; + } + + const timestamps = parseColumnFilterValue(columnFilterValue); + const date = parseAsDate(timestamps[0]); + return date ? [date] : []; + }, [columnFilterValue, multiple]); + + const onSelect = React.useCallback( + (date: Date | DateRange | undefined) => { + if (!date) { + column.setFilterValue(undefined); + return; + } + + if (multiple && !("getTime" in date)) { + const from = date.from?.getTime(); + const to = date.to?.getTime(); + column.setFilterValue(from || to ? [from, to] : undefined); + } else if (!multiple && "getTime" in date) { + column.setFilterValue(date.getTime()); + } + }, + [column, multiple], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + column.setFilterValue(undefined); + }, + [column], + ); + + const hasValue = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return false; + return selectedDates.from || selectedDates.to; + } + if (!Array.isArray(selectedDates)) return false; + return selectedDates.length > 0; + }, [multiple, selectedDates]); + + const formatDateRange = React.useCallback((range: DateRange) => { + if (!range.from && !range.to) return ""; + if (range.from && range.to) { + return `${formatDate(range.from)} - ${formatDate(range.to)}`; + } + return formatDate(range.from ?? range.to); + }, []); + + const label = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return null; + + const hasSelectedDates = selectedDates.from || selectedDates.to; + const dateText = hasSelectedDates + ? formatDateRange(selectedDates) + : "Select date range"; + + return ( + + {title} + {hasSelectedDates && ( + <> + + {dateText} + + )} + + ); + } + + if (getIsDateRange(selectedDates)) return null; + + const hasSelectedDate = selectedDates.length > 0; + const dateText = hasSelectedDate + ? formatDate(selectedDates[0]) + : "Select date"; + + return ( + + {title} + {hasSelectedDate && ( + <> + + {dateText} + + )} + + ); + }, [selectedDates, multiple, formatDateRange, title]); + + return ( + + + + + + {multiple ? ( + + ) : ( + + )} + + + ); +} diff --git a/web/src/components/data-table/data-table-faceted-filter.tsx b/web/src/components/data-table/data-table-faceted-filter.tsx new file mode 100644 index 0000000..77554c8 --- /dev/null +++ b/web/src/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,189 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { Check, PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import type { Option } from "@/types/data-table"; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: Option[]; + multiple?: boolean; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + multiple, +}: DataTableFacetedFilterProps) { + const [open, setOpen] = React.useState(false); + + const columnFilterValue = column?.getFilterValue(); + const selectedValues = new Set( + Array.isArray(columnFilterValue) ? columnFilterValue : [], + ); + + const onItemSelect = React.useCallback( + (option: Option, isSelected: boolean) => { + if (!column) return; + + if (multiple) { + const newSelectedValues = new Set(selectedValues); + if (isSelected) { + newSelectedValues.delete(option.value); + } else { + newSelectedValues.add(option.value); + } + const filterValues = Array.from(newSelectedValues); + column.setFilterValue(filterValues.length ? filterValues : undefined); + } else { + column.setFilterValue(isSelected ? undefined : [option.value]); + setOpen(false); + } + }, + [column, multiple, selectedValues], + ); + + const onReset = React.useCallback( + (event?: React.MouseEvent) => { + event?.stopPropagation(); + column?.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + + + + + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + + return ( + onItemSelect(option, isSelected)} + > +
+ +
+ {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + onReset()} + className="justify-center text-center" + > + Clear filters + + + + )} +
+
+
+
+ ); +} diff --git a/web/src/components/data-table/data-table-pagination.tsx b/web/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000..8420753 --- /dev/null +++ b/web/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,112 @@ +import type { Table } from "@tanstack/react-table"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +interface DataTablePaginationProps extends React.ComponentProps<"div"> { + table: Table; + pageSizeOptions?: number[]; +} + +export function DataTablePagination({ + table, + pageSizeOptions = [10, 20, 30, 40, 50], + className, + ...props +}: DataTablePaginationProps) { + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/web/src/components/data-table/data-table-skeleton.tsx b/web/src/components/data-table/data-table-skeleton.tsx new file mode 100644 index 0000000..df678e2 --- /dev/null +++ b/web/src/components/data-table/data-table-skeleton.tsx @@ -0,0 +1,115 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; + +interface DataTableSkeletonProps extends React.ComponentProps<"div"> { + columnCount: number; + rowCount?: number; + filterCount?: number; + cellWidths?: string[]; + withViewOptions?: boolean; + withPagination?: boolean; + shrinkZero?: boolean; +} + +export function DataTableSkeleton({ + columnCount, + rowCount = 10, + filterCount = 0, + cellWidths = ["auto"], + withViewOptions = true, + withPagination = true, + shrinkZero = false, + className, + ...props +}: DataTableSkeletonProps) { + const cozyCellWidths = Array.from( + { length: columnCount }, + (_, index) => cellWidths[index % cellWidths.length] ?? "auto", + ); + + return ( +
+
+
+ {filterCount > 0 + ? Array.from({ length: filterCount }).map((_, i) => ( + + )) + : null} +
+ {withViewOptions ? ( + + ) : null} +
+
+ + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + +
+
+ {withPagination ? ( +
+ +
+
+ + +
+
+ +
+
+ + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/data-table/data-table-slider-filter.tsx b/web/src/components/data-table/data-table-slider-filter.tsx new file mode 100644 index 0000000..8177a7c --- /dev/null +++ b/web/src/components/data-table/data-table-slider-filter.tsx @@ -0,0 +1,256 @@ +"use client"; + +import type { Column } from "@tanstack/react-table"; +import { PlusCircle, XCircle } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { cn } from "@/lib/utils"; + +interface Range { + min: number; + max: number; +} + +type RangeValue = [number, number]; + +function getIsValidRange(value: unknown): value is RangeValue { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "number" && + typeof value[1] === "number" + ); +} + +function parseValuesAsNumbers(value: unknown): RangeValue | undefined { + if ( + Array.isArray(value) && + value.length === 2 && + value.every( + (v) => + (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), + ) + ) { + return [Number(value[0]), Number(value[1])]; + } + + return undefined; +} + +interface DataTableSliderFilterProps { + column: Column; + title?: string; +} + +export function DataTableSliderFilter({ + column, + title, +}: DataTableSliderFilterProps) { + const id = React.useId(); + + const columnFilterValue = parseValuesAsNumbers(column.getFilterValue()); + + const defaultRange = column.columnDef.meta?.range; + const unit = column.columnDef.meta?.unit; + + const { min, max, step } = React.useMemo(() => { + let minValue = 0; + let maxValue = 100; + + if (defaultRange && getIsValidRange(defaultRange)) { + [minValue, maxValue] = defaultRange; + } else { + const values = column.getFacetedMinMaxValues(); + if (values && Array.isArray(values) && values.length === 2) { + const [facetMinValue, facetMaxValue] = values; + if ( + typeof facetMinValue === "number" && + typeof facetMaxValue === "number" + ) { + minValue = facetMinValue; + maxValue = facetMaxValue; + } + } + } + + const rangeSize = maxValue - minValue; + const step = + rangeSize <= 20 + ? 1 + : rangeSize <= 100 + ? Math.ceil(rangeSize / 20) + : Math.ceil(rangeSize / 50); + + return { min: minValue, max: maxValue, step }; + }, [column, defaultRange]); + + const range = React.useMemo((): RangeValue => { + return columnFilterValue ?? [min, max]; + }, [columnFilterValue, min, max]); + + const formatValue = React.useCallback((value: number) => { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + }, []); + + const onFromInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) { + column.setFilterValue([numValue, range[1]]); + } + }, + [column, min, range], + ); + + const onToInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) { + column.setFilterValue([range[0], numValue]); + } + }, + [column, max, range], + ); + + const onSliderValueChange = React.useCallback( + (value: RangeValue) => { + if (Array.isArray(value) && value.length === 2) { + column.setFilterValue(value); + } + }, + [column], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + if (event.target instanceof HTMLDivElement) { + event.stopPropagation(); + } + column.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + +
+

+ {title} +

+
+ +
+ + {unit && ( + + {unit} + + )} +
+ +
+ + {unit && ( + + {unit} + + )} +
+
+ + +
+ +
+
+ ); +} diff --git a/web/src/components/data-table/data-table-toolbar.tsx b/web/src/components/data-table/data-table-toolbar.tsx new file mode 100644 index 0000000..e227b7d --- /dev/null +++ b/web/src/components/data-table/data-table-toolbar.tsx @@ -0,0 +1,149 @@ +"use client"; + +import type { Column, Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { DataTableDateFilter } from "@/components/data-table/data-table-date-filter"; +import { DataTableFacetedFilter } from "@/components/data-table/data-table-faceted-filter"; +import { DataTableSliderFilter } from "@/components/data-table/data-table-slider-filter"; +import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +interface DataTableToolbarProps extends React.ComponentProps<"div"> { + table: Table; +} + +export function DataTableToolbar({ + table, + children, + className, + ...props +}: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + const columns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanFilter()), + [table], + ); + + const onReset = React.useCallback(() => { + table.resetColumnFilters(); + }, [table]); + + return ( +
+
+ {columns.map((column) => ( + + ))} + {isFiltered && ( + + )} +
+
+ {children} + +
+
+ ); +} +interface DataTableToolbarFilterProps { + column: Column; +} + +function DataTableToolbarFilter({ + column, +}: DataTableToolbarFilterProps) { + { + const columnMeta = column.columnDef.meta; + + const onFilterRender = React.useCallback(() => { + if (!columnMeta?.variant) return null; + + switch (columnMeta.variant) { + case "text": + return ( + column.setFilterValue(event.target.value)} + className="h-8 w-40 lg:w-56" + /> + ); + + case "number": + return ( +
+ column.setFilterValue(event.target.value)} + className={cn("h-8 w-[120px]", columnMeta.unit && "pr-8")} + /> + {columnMeta.unit && ( + + {columnMeta.unit} + + )} +
+ ); + + case "range": + return ( + + ); + + case "date": + case "dateRange": + return ( + + ); + + case "select": + case "multiSelect": + return ( + + ); + + default: + return null; + } + }, [column, columnMeta]); + + return onFilterRender(); + } +} diff --git a/web/src/components/data-table/data-table-view-options.tsx b/web/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000..8935d15 --- /dev/null +++ b/web/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { Check, Settings2 } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface DataTableViewOptionsProps + extends React.ComponentProps { + table: Table; + disabled?: boolean; +} + +export function DataTableViewOptions({ + table, + disabled, + ...props +}: DataTableViewOptionsProps) { + const columns = React.useMemo( + () => + table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== "undefined" && column.getCanHide(), + ), + [table], + ); + + return ( + + + + + + + + + No columns found. + + {columns.map((column) => ( + + column.toggleVisibility(!column.getIsVisible()) + } + > + + {column.columnDef.meta?.label ?? column.id} + + + + ))} + + + + + + ); +} diff --git a/web/src/components/data-table/data-table.tsx b/web/src/components/data-table/data-table.tsx new file mode 100644 index 0000000..89d91ea --- /dev/null +++ b/web/src/components/data-table/data-table.tsx @@ -0,0 +1,101 @@ +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table"; +import type * as React from "react"; + +import { DataTablePagination } from "@/components/data-table/data-table-pagination"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getColumnPinningStyle } from "@/lib/data-table"; +import { cn } from "@/lib/utils"; + +interface DataTableProps extends React.ComponentProps<"div"> { + table: TanstackTable; + actionBar?: React.ReactNode; +} + +export function DataTable({ + table, + actionBar, + children, + className, + ...props +}: DataTableProps) { + return ( +
+ {children} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+ + {actionBar && + table.getFilteredSelectedRowModel().rows.length > 0 && + actionBar} +
+
+ ); +} diff --git a/web/src/components/ui/calendar.tsx b/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..9fb18ca --- /dev/null +++ b/web/src/components/ui/calendar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +