From bb9fd7c7076faaea817dbcdf92171b8accb9c96c Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Sat, 20 Jun 2026 12:35:21 +0200 Subject: [PATCH 1/4] feat: added the ability to filter db data --- package-lock.json | 71 ++++- packages/sqlite-erd/package.json | 1 + .../sqlite-erd/src/components/data-drawer.tsx | 276 +++++++++++++++--- packages/sqlite-erd/src/hooks/use-schema.ts | 21 +- packages/sqlite-erd/src/lib/db-parser.ts | 65 ++++- 5 files changed, 372 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 08382ad..31b09e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4219,9 +4219,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4477,6 +4477,7 @@ "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "@tanstack/react-table": "^9.0.0-beta.16", "@tsdown/css": "0.22.0", "@xyflow/react": "12.10.2", "class-variance-authority": "0.7.1", @@ -4523,6 +4524,70 @@ "optional": true } } + }, + "node_modules/@tanstack/react-table": { + "version": "9.0.0-beta.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.0.0-beta.16.tgz", + "integrity": "sha512-E23zMaa7+tWHX88Xut+39pl4pAQ/52hRocDCUfnokFt3qcPgVs3LYg+Wk84HiF+M2hrFIRtCgn3J+hFerSf1KA==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.0.0-beta.16" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/react-table/node_modules/@tanstack/react-store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz", + "integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.0", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", + "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.0.0-beta.16", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.0.0-beta.16.tgz", + "integrity": "sha512-2FdqnEAQXv3Q7qWy30efQ62rwmIspKqUg/9IRgYbG3Plvba6QL1zlplvokrEqE62Vb6MDYlAG86KdMBkvubGaQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } } } } diff --git a/packages/sqlite-erd/package.json b/packages/sqlite-erd/package.json index ac97cc5..cc8bf96 100644 --- a/packages/sqlite-erd/package.json +++ b/packages/sqlite-erd/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@sqlite.org/sqlite-wasm": "3.53.0-build1", + "@tanstack/react-table": "^9.0.0-beta.16", "@tsdown/css": "0.22.0", "@xyflow/react": "12.10.2", "class-variance-authority": "0.7.1", diff --git a/packages/sqlite-erd/src/components/data-drawer.tsx b/packages/sqlite-erd/src/components/data-drawer.tsx index 3cf0471..7173a60 100644 --- a/packages/sqlite-erd/src/components/data-drawer.tsx +++ b/packages/sqlite-erd/src/components/data-drawer.tsx @@ -1,4 +1,15 @@ -import { useEffect, useMemo, useState } from 'react'; +import { + type ColumnDef, + type ColumnFiltersState, + columnFilteringFeature, + createFilteredRowModel, + type FilterFn, + filterFns, + type TableFeatures, + tableFeatures, + useTable, +} from '@tanstack/react-table'; +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import { LuChevronLeft as ChevronLeft, LuChevronRight as ChevronRight, @@ -8,12 +19,16 @@ import { LuX as X, } from 'react-icons/lu'; import { Button } from '@/components/ui/button'; -import { type TableDataPage, tableDataRowKey } from '@/lib/db-parser'; -import type { Table } from '@/lib/schema-types'; +import { + type TableDataFilter, + type TableDataPage, + tableDataRowKey, +} from '@/lib/db-parser'; +import type { Table as SchemaTable } from '@/lib/schema-types'; interface DataDrawerProps { collapsed: boolean; - table: Table | null; + table: SchemaTable | null; onCollapse: () => void; onClose: () => void; onExpand: () => void; @@ -21,9 +36,12 @@ interface DataDrawerProps { tableName: string, page: number, pageSize?: number, + filters?: readonly TableDataFilter[], ) => Promise; } +type DataRow = Record; + const PAGE_SIZE = 100; const formatCellValue = (value: unknown) => { @@ -42,19 +60,87 @@ const formatCellValue = (value: unknown) => { return String(value); }; +const dataInspectorValueFilter: FilterFn = ( + row, + columnId, + filterValue, +) => { + const search = String(filterValue ?? '') + .trim() + .toLowerCase(); + + if (!search) { + return true; + } + + return formatCellValue(row.getValue(columnId)).toLowerCase().includes(search); +}; + +dataInspectorValueFilter.autoRemove = (value) => + String(value ?? '').trim().length === 0; + +const dataInspectorFeatures = tableFeatures({ + columnFilteringFeature, + filteredRowModel: createFilteredRowModel(), + filterFns: { + ...filterFns, + dataInspectorValue: dataInspectorValueFilter, + }, +}); + +const clampPage = (page: number, totalPages: number) => + Math.min(Math.max(page, 1), totalPages); + export const DataDrawer = ({ collapsed, - table, + table: selectedTable, onCollapse, onClose, onExpand, loadTableData, }: DataDrawerProps) => { + const drawerRef = useRef(null); const [page, setPage] = useState(1); + const [pageInput, setPageInput] = useState('1'); + const [columnFilters, setColumnFilters] = useState([]); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const tableName = table?.name; + const tableName = selectedTable?.name; + + const columns = useMemo< + Array> + >( + () => + selectedTable?.columns.map((column) => ({ + id: column.name, + accessorFn: (row) => row[column.name], + header: column.name, + filterFn: 'dataInspectorValue', + cell: (info) => formatCellValue(info.getValue()), + })) ?? [], + [selectedTable], + ); + const activeFilters = useMemo( + () => + columnFilters + .map((filter) => ({ + column: filter.id, + value: String(filter.value ?? '').trim(), + })) + .filter((filter) => filter.value.length > 0), + [columnFilters], + ); + const dataTable = useTable({ + features: dataInspectorFeatures, + columns, + data: data?.rows ?? [], + manualFiltering: true, + state: { + columnFilters, + }, + onColumnFiltersChange: setColumnFilters, + }); useEffect(() => { if (!tableName) { @@ -62,12 +148,17 @@ export const DataDrawer = ({ } setPage(1); + setColumnFilters([]); }, [tableName]); + useEffect(() => { + setPageInput(String(page)); + }, [page]); + useEffect(() => { let cancelled = false; - if (!table) { + if (!tableName) { setData(null); return; } @@ -75,7 +166,7 @@ export const DataDrawer = ({ setLoading(true); setError(null); - loadTableData(table.name, page, PAGE_SIZE) + loadTableData(tableName, page, PAGE_SIZE, activeFilters) .then((result) => { if (!cancelled) { setData(result); @@ -98,7 +189,7 @@ export const DataDrawer = ({ return () => { cancelled = true; }; - }, [table, page, loadTableData]); + }, [tableName, page, activeFilters, loadTableData]); const totalPages = useMemo(() => { if (!data) { @@ -108,13 +199,64 @@ export const DataDrawer = ({ return Math.max(Math.ceil(data.totalRows / data.pageSize), 1); }, [data]); - if (!table) { + useEffect(() => { + setPage((current) => clampPage(current, totalPages)); + }, [totalPages]); + + useEffect(() => { + if (!selectedTable) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const drawer = drawerRef.current; + + if ( + drawer && + event.target instanceof Node && + !drawer.contains(event.target) + ) { + onClose(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [onClose, selectedTable]); + + if (!selectedTable) { return null; } + const firstRow = data?.totalRows ? (page - 1) * PAGE_SIZE + 1 : 0; + const lastRow = data ? Math.min(page * PAGE_SIZE, data.totalRows) : 0; + const hasFilters = activeFilters.length > 0; + const rows = dataTable.getRowModel().rows; + + const goToPage = (event?: FormEvent) => { + event?.preventDefault(); + + const requestedPage = Number(pageInput); + + if (!Number.isFinite(requestedPage)) { + setPageInput(String(page)); + return; + } + + const nextPage = clampPage(Math.trunc(requestedPage), totalPages); + setPageInput(String(nextPage)); + setPage(nextPage); + }; + if (collapsed) { return ( -