From 6d60899f24e958c9470c643579ee8d2864aff600 Mon Sep 17 00:00:00 2001 From: interacsean Date: Fri, 26 Jun 2026 17:22:26 +1000 Subject: [PATCH 1/2] refactor(collection): single useURLCollectionVariables hook + coerce typed filter values Proposed refinements on top of improve-use-url-collection-state. Interface: collapse the URL-state common path into one hook. useURLCollectionState() only ever wrapped react-router's useSearchParams, so it added no flexibility over a fused hook while forcing a two-hook dance. Replace it with useURLCollectionVariables(options) (read + write URL state in one call). Keep useCollectionVariables (router-free primitive) and the pure withURLCollectionState(options, binding) decorator (custom-binding escape hatch). Fix: parseCollectionSearchParams now coerces decoded filter values to the field's declared metadata type (number/boolean, incl. in/nin arrays and between bounds). Previously a URL-restored numeric filter such as f.price:gt=130 came back as the string "130", contradicting TypedCollectionVariables and silently breaking type-aware filtering. The untyped overload (no metadata) is unchanged. Update the nextjs data-table-demo to the new hook, refresh the changeset, and add tests for coercion and the numeric write -> parse round-trip. Co-Authored-By: Claude Opus 4.8 --- .changeset/witty-mice-drum.md | 19 ++- .../src/modules/pages/data-table-demo.tsx | 14 +- packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 73 ++++++++--- packages/core/src/lib/collection-url-state.ts | 124 +++++++++++++++--- 5 files changed, 175 insertions(+), 57 deletions(-) diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md index 2a7c2148..7de09834 100644 --- a/.changeset/witty-mice-drum.md +++ b/.changeset/witty-mice-drum.md @@ -2,16 +2,15 @@ "@tailor-platform/app-shell": minor --- -Add `withURLCollectionState` and `useURLCollectionState` for wiring collection state to the URL in `useCollectionVariables`. - -`useCollectionVariables` now reports state updates through `onParamsChange` using the same `params` shape it accepts. +Add `useURLCollectionVariables` for wiring collection state (filters, sort, page size) to the URL query string in a single call. It seeds initial state from the current router search params and writes changes back as the user filters, sorts, or pages. ```tsx -const withURLCollectionState = useURLCollectionState(); -const { variables, control } = useCollectionVariables( - withURLCollectionState({ - tableMetadata, - params: { pageSize: 20 }, - }), -); +const { variables, control } = useURLCollectionVariables({ + tableMetadata, + params: { pageSize: 20 }, +}); ``` + +For cases that need URL persistence without react-router's `useSearchParams` (e.g. a custom binding), the pure `withURLCollectionState(options, [searchParams, setSearchParams])` decorator is also exported and can be composed with `useCollectionVariables` directly. + +`useCollectionVariables` now reports state updates through `onParamsChange` using the same `params` shape it accepts. diff --git a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx index 4e959c1e..e9b87e7e 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -2,8 +2,7 @@ import { defineResource, DataTable, useDataTable, - useCollectionVariables, - useURLCollectionState, + useURLCollectionVariables, createColumnHelper, Layout, type RowAction, @@ -141,13 +140,10 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const withURLCollectionState = useURLCollectionState(); - const { variables, control } = useCollectionVariables( - withURLCollectionState({ - params: { pageSize: 5 }, - tableMetadata: productMetadata, - }), - ); + const { variables, control } = useURLCollectionVariables({ + params: { pageSize: 5 }, + tableMetadata: productMetadata, + }); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df7afa87..f280d5ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,7 +190,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useURLCollectionState, withURLCollectionState } from "./lib/collection-url-state"; +export { useURLCollectionVariables, withURLCollectionState } from "./lib/collection-url-state"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/lib/collection-url-state.test.ts b/packages/core/src/lib/collection-url-state.test.ts index 4ec3decc..479ca86b 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -5,7 +5,7 @@ import { MemoryRouter } from "react-router"; import { describe, it, expect, vi } from "vitest"; import type { CollectionPersistedState, TableMetadataMap } from "@/types/collection"; import { - useURLCollectionState, + useURLCollectionVariables, withURLCollectionState, encodeFilterValue, decodeFilterValue, @@ -21,6 +21,8 @@ const tableMetadata = { { name: "status", type: "enum", required: true, enumValues: ["active", "pending", "closed"] }, { name: "createdAt", type: "datetime", required: true }, { name: "title", type: "string", required: true }, + { name: "price", type: "number", required: true }, + { name: "archived", type: "boolean", required: false }, ], }, } as const satisfies TableMetadataMap; @@ -77,6 +79,37 @@ describe("parseCollectionSearchParams", () => { filters: [{ field: "tags", operator: "in", value: ["a", "b"] }], }); }); + + it("coerces number and boolean filter values using metadata", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams('f.price:gt=130&f.archived:eq=true&f.price:in=["10","20"]'), + ); + + expect(result.filters).toEqual([ + { field: "price", operator: "gt", value: 130 }, + { field: "archived", operator: "eq", value: true }, + { field: "price", operator: "in", value: [10, 20] }, + ]); + }); + + it("round-trips a numeric filter through write → parse as a number", () => { + const written = writeCollectionSearchParams(new URLSearchParams(), { + filters: [{ field: "price", operator: "gt", value: 130 }], + sortStates: [], + pageSize: 20, + }); + + expect(parseCollectionSearchParams(tableMetadata.task, written).filters).toEqual([ + { field: "price", operator: "gt", value: 130 }, + ]); + }); + + it("leaves numeric-looking values as strings when no metadata is provided", () => { + const result = parseCollectionSearchParams(new URLSearchParams("f.price:gt=130")); + + expect(result.filters).toEqual([{ field: "price", operator: "gt", value: "130" }]); + }); }); describe("writeCollectionSearchParams", () => { @@ -169,25 +202,27 @@ describe("withURLCollectionState", () => { }); }); -describe("useURLCollectionState", () => { - it("binds useSearchParams and returns a decorator", () => { - const { result } = renderHook(() => useURLCollectionState(), { - wrapper: SearchParamsWrapper, - }); - - const options = result.current({ - tableMetadata: tableMetadata.task, - params: { - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }, - }); +describe("useURLCollectionVariables", () => { + it("seeds collection control state from the URL search params", () => { + const { result } = renderHook( + () => + useURLCollectionVariables({ + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + }), + { wrapper: SearchParamsWrapper }, + ); - expect(options.params).toEqual({ - initialFilters: [{ field: "status", operator: "eq", value: "active" }], - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 50, - }); + // URL (`?p=50&f.status:eq=active`) wins over the `pageSize: 20` default and + // contributes the filter; `initialSort` is kept since the URL has no sort. + expect(result.current.control.filters).toEqual([ + { field: "status", operator: "eq", value: "active" }, + ]); + expect(result.current.control.sortStates).toEqual([{ field: "createdAt", direction: "Desc" }]); + expect(result.current.control.pageSize).toBe(50); }); }); diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index b221796a..64e8a064 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -1,15 +1,20 @@ import { useSearchParams } from "react-router"; +import { useCollectionVariables } from "@/hooks/use-collection-variables"; import { OPERATORS_BY_FILTER_TYPE, fieldTypeToFilterConfig, fieldTypeToSortConfig, type CollectionInitialState, type CollectionPersistedState, + type CollectionVariables, + type FieldType, type Filter, type TableFieldName, type TableMetadata, type TableMetadataFilter, + type TypedCollectionVariables, type UseCollectionOptions, + type UseCollectionReturn, } from "@/types/collection"; const KEY_PAGE_SIZE = "p"; @@ -27,17 +32,6 @@ export type SearchParamsBinding = readonly [ ) => void, ]; -export interface URLCollectionStateDecorator { - ( - options: UseCollectionOptions, TableMetadataFilter> & { - tableMetadata: TTable; - }, - ): UseCollectionOptions, TableMetadataFilter> & { - tableMetadata: TTable; - }; - (options: UseCollectionOptions & { tableMetadata?: never }): UseCollectionOptions; -} - function isValidSortField(tableMetadata: TableMetadata | undefined, field: string): boolean { if (!tableMetadata) return true; const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); @@ -63,6 +57,55 @@ function isValidFilter( return OPERATORS_BY_FILTER_TYPE[filterConfig.type].includes(operator as never); } +/** Look up a field's metadata-declared type, if metadata is available. */ +function fieldTypeOf( + tableMetadata: TableMetadata | undefined, + field: string, +): FieldType | undefined { + return tableMetadata?.fields.find((candidate) => candidate.name === field)?.type; +} + +/** + * Coerce a single decoded scalar to the field's declared metadata type. + * + * `decodeFilterValue` intentionally returns numeric-/boolean-looking values as + * strings (it can't know the intended type on its own). When table metadata is + * available we *do* know the type, so we restore it here — otherwise a number + * field's `gt`/`eq`/… value round-trips from the URL as a string and silently + * fails any type-aware comparison (and contradicts `TypedCollectionVariables`, + * which declares these as `number`/`boolean`). + */ +function coerceScalarToFieldType(type: FieldType, value: unknown): unknown { + if (typeof value !== "string") return value; + switch (type) { + case "number": { + const n = Number(value); + return Number.isFinite(n) ? n : value; + } + case "boolean": { + if (value === "true") return true; + if (value === "false") return false; + return value; + } + default: + return value; + } +} + +/** + * Coerce a decoded filter value to the field's declared type, descending into + * `in`/`nin` arrays and `between` `{ min, max }` objects. + */ +function coerceFilterValueToFieldType(type: FieldType, value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => coerceScalarToFieldType(type, item)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, coerceScalarToFieldType(type, item)]), + ); + } + return coerceScalarToFieldType(type, value); +} + /** * Parse URL search params into collection state. */ @@ -98,10 +141,15 @@ export function parseCollectionSearchParams( if (!key.startsWith(FILTER_PREFIX) || !value) continue; const [field, operator] = key.slice(FILTER_PREFIX.length).split(":"); if (!field || !operator || !isValidFilter(tableMetadata, field, operator)) continue; + const decoded = decodeFilterValue(value); + const fieldType = fieldTypeOf(tableMetadata, field); nextFilters.push({ field, operator: operator as Filter["operator"], - value: decodeFilterValue(value), + // With metadata we know the field type, so restore number/boolean values + // that `decodeFilterValue` returned as strings. Without metadata (untyped + // overload) we can't, so the value stays a string. + value: fieldType ? coerceFilterValueToFieldType(fieldType, decoded) : decoded, }); } if (nextFilters.length > 0) nextState.filters = nextFilters; @@ -220,14 +268,54 @@ function mergeCollectionStateIntoParams( } /** - * Hook that binds the current router search params and returns a - * `withURLCollectionState()`-compatible decorator. + * Hook for managing collection query parameters (filters, sort, pagination) + * with state persisted to the URL query string. + * + * This is the one-call convenience over {@link useCollectionVariables}: it seeds + * the initial filter/sort/page-size state from the current router search params + * and writes changes back as the user filters, sorts, or pages (using `replace` + * so each change doesn't push a new history entry). + * + * Reach for the bare {@link useCollectionVariables} when you don't want URL + * persistence, or the pure {@link withURLCollectionState} decorator when you + * need to supply a non-react-router search-params binding. + * + * @example + * ```tsx + * import { tableMetadata } from "./generated/data-viewer-metadata.generated"; + * + * const { variables, control } = useURLCollectionVariables({ + * tableMetadata: tableMetadata.task, + * params: { pageSize: 20 }, + * }); + * ``` */ -export function useURLCollectionState(): URLCollectionStateDecorator { +export function useURLCollectionVariables( + options: UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; + }, +): UseCollectionReturn< + TableFieldName, + TypedCollectionVariables, + TableMetadataFilter +>; +export function useURLCollectionVariables( + options: UseCollectionOptions & { tableMetadata?: never }, +): UseCollectionReturn; +export function useURLCollectionVariables( + options: UseCollectionOptions & { tableMetadata?: TableMetadata }, +): unknown { const searchParamsBinding = useSearchParams(); - - return ((options: UseCollectionOptions & { tableMetadata?: TableMetadata }) => - applyURLCollectionState(options, searchParamsBinding)) as URLCollectionStateDecorator; + // `useCollectionVariables` is overloaded on whether `tableMetadata` is present; + // the decorated options carry an optional `tableMetadata` that matches neither + // overload, so narrow to the no-metadata one for the call. `tableMetadata` is + // type-only (the implementation never reads it), so this is safe at runtime, + // and callers see the precise return type from this hook's overloads above. + return useCollectionVariables( + applyURLCollectionState(options, searchParamsBinding) as UseCollectionOptions & { + tableMetadata?: never; + }, + ); } /** From 1340062360a17fed457d6c6c168ceb36c8170a84 Mon Sep 17 00:00:00 2001 From: interacsean Date: Fri, 26 Jun 2026 17:28:01 +1000 Subject: [PATCH 2/2] docs(example): add vite-app Products page demoing useURLCollectionVariables Runnable DataTable example with a mock remote query (filters, sort, cursor pagination, multi-select) wired through useURLCollectionVariables, so state is persisted to and hydrated from the URL. Doubles as the repro for the typed filter-value coercion fix (e.g. ?f.price:gt=130 now hydrates correctly). Co-Authored-By: Claude Opus 4.8 --- examples/vite-app/src/App.tsx | 1 + examples/vite-app/src/mock-products.ts | 469 ++++++++++++++++++ .../src/pages/dashboard/products/page.tsx | 153 ++++++ examples/vite-app/src/routes.generated.ts | 1 + 4 files changed, 624 insertions(+) create mode 100644 examples/vite-app/src/mock-products.ts create mode 100644 examples/vite-app/src/pages/dashboard/products/page.tsx diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index fa478f54..76c7f3fb 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -33,6 +33,7 @@ const App = () => { + diff --git a/examples/vite-app/src/mock-products.ts b/examples/vite-app/src/mock-products.ts new file mode 100644 index 00000000..4e8bc156 --- /dev/null +++ b/examples/vite-app/src/mock-products.ts @@ -0,0 +1,469 @@ +export type Product = { + id: string; + name: string; + description: string; + category: string; + publishedAt: string; + availableOn: string; + restockAt: string; + price: number; + stock: number; + status: "Active" | "Draft" | "Archived"; + tags: string[]; +}; + +export const allProducts: Product[] = [ + { + id: "p-001", + name: "Ergonomic Chair", + description: + "Adjustable lumbar support, breathable mesh back, and 4D armrests engineered for full-day comfort across body types and desk heights.", + category: "Furniture", + publishedAt: "2026-01-05T09:15:00Z", + availableOn: "2026-02-01", + restockAt: "09:30", + price: 499.99, + stock: 42, + status: "Active", + tags: ["Ergonomic", "Office", "Best Seller"], + }, + { + id: "p-002", + name: "Standing Desk", + description: + "Dual electric motors lift 270 lbs from 24 to 50 inches in seconds, with four programmable height presets and silent sub-50 dB operation.", + category: "Furniture", + publishedAt: "2026-01-12T11:45:00Z", + availableOn: "2026-02-05", + restockAt: "10:15", + price: 899.0, + stock: 15, + status: "Active", + tags: ["Motorized", "Office", "Adjustable", "Premium"], + }, + { + id: "p-003", + name: "Mechanical Keyboard", + description: "Hot-swappable switches and per-key RGB.", + category: "Electronics", + publishedAt: "2026-01-19T14:00:00Z", + availableOn: "2026-02-08", + restockAt: "08:45", + price: 159.99, + stock: 230, + status: "Active", + tags: ["Mechanical", "RGB", "Hot-swap"], + }, + { + id: "p-004", + name: "USB-C Hub", + description: + "Seven-in-one passthrough hub: 100 W power delivery, 4K HDMI, gigabit Ethernet, SD/microSD, and two USB-A ports in a single braided cable.", + category: "Electronics", + publishedAt: "2026-01-22T16:20:00Z", + availableOn: "2026-02-12", + restockAt: "13:00", + price: 79.99, + stock: 0, + status: "Draft", + tags: ["USB-C", "Portable"], + }, + { + id: "p-005", + name: "Monitor Arm", + description: + "Counter-balanced single-monitor mount supporting screens up to 34 inches and 19 lbs, with full-motion articulation and integrated cable routing.", + category: "Accessories", + publishedAt: "2026-01-28T07:30:00Z", + availableOn: "2026-02-15", + restockAt: "15:30", + price: 129.0, + stock: 57, + status: "Active", + tags: ["Ergonomic", "Adjustable"], + }, + { + id: "p-006", + name: "Webcam HD", + description: "1080p sensor with autofocus and a stereo mic.", + category: "Electronics", + publishedAt: "2026-02-02T12:10:00Z", + availableOn: "2026-02-18", + restockAt: "11:00", + price: 89.99, + stock: 120, + status: "Archived", + tags: ["HD", "Autofocus"], + }, + { + id: "p-007", + name: "Desk Lamp", + description: + "Tunable warm-to-cool LED desk lamp with five brightness levels, a touch dimmer, and a USB charging port built into the weighted base.", + category: "Accessories", + publishedAt: "2026-02-06T18:05:00Z", + availableOn: "2026-02-20", + restockAt: "17:45", + price: 45.0, + stock: 88, + status: "Active", + tags: ["LED", "USB Charging"], + }, + { + id: "p-008", + name: "Cable Tray", + description: + "Under-desk steel cable management tray with snap-on cover, mounting hardware, and a perforated channel that keeps power bricks ventilated.", + category: "Accessories", + publishedAt: "2026-02-10T10:00:00Z", + availableOn: "2026-02-22", + restockAt: "14:20", + price: 29.99, + stock: 200, + status: "Draft", + tags: ["Cable Management"], + }, + { + id: "p-009", + name: "Noise-Cancelling Headphones", + description: + "Over-ear ANC headphones with a 36-hour battery, adaptive transparency mode, multipoint Bluetooth 5.3, and memory-foam ear cushions for long sessions.", + category: "Electronics", + publishedAt: "2026-02-14T08:50:00Z", + availableOn: "2026-02-25", + restockAt: "09:10", + price: 349.99, + stock: 64, + status: "Active", + tags: ["ANC", "Bluetooth", "Wireless", "Premium"], + }, + { + id: "p-010", + name: "Laptop Stand", + description: "Aluminum riser, folds flat for travel.", + category: "Accessories", + publishedAt: "2026-02-17T13:25:00Z", + availableOn: "2026-02-28", + restockAt: "16:40", + price: 59.99, + stock: 110, + status: "Active", + tags: ["Portable", "Foldable"], + }, + { + id: "p-011", + name: "Wireless Mouse", + description: + "Ergonomic right-handed wireless mouse with a 4000 DPI optical sensor, six programmable buttons, and a USB-C rechargeable battery rated for 70 days.", + category: "Electronics", + publishedAt: "2026-02-20T06:40:00Z", + availableOn: "2026-03-02", + restockAt: "07:20", + price: 69.99, + stock: 180, + status: "Active", + tags: ["Ergonomic", "Wireless", "Rechargeable"], + }, + { + id: "p-012", + name: "Desk Mat", + description: + "Large 36x18 inch felt-and-cork desk mat with a non-slip backing and a stitched edge that resists curling after months of daily mouse travel.", + category: "Accessories", + publishedAt: "2026-02-23T15:55:00Z", + availableOn: "2026-03-05", + restockAt: "12:30", + price: 34.99, + stock: 300, + status: "Active", + tags: ["Eco-friendly", "Non-slip"], + }, + { + id: "p-013", + name: "Bookshelf", + description: + "Five-shelf engineered-wood bookshelf with steel reinforcement bars, anti-tip wall mounts, and adjustable shelf heights for oversized volumes.", + category: "Furniture", + publishedAt: "2026-02-26T09:05:00Z", + availableOn: "2026-03-08", + restockAt: "10:50", + price: 249.0, + stock: 22, + status: "Draft", + tags: ["Adjustable", "Heavy-duty"], + }, + { + id: "p-014", + name: "Power Strip", + description: "Eight outlets, surge protection, six-foot braided cord.", + category: "Accessories", + publishedAt: "2026-03-01T19:15:00Z", + availableOn: "2026-03-11", + restockAt: "18:00", + price: 24.99, + stock: 500, + status: "Active", + tags: ["Surge Protection"], + }, + { + id: "p-015", + name: "Whiteboard", + description: + "Ghost-resistant porcelain steel whiteboard with an aluminum frame, magnetic surface, mounting cleat, and an integrated marker tray along the bottom.", + category: "Furniture", + publishedAt: "2026-03-04T11:35:00Z", + availableOn: "2026-03-14", + restockAt: "08:15", + price: 189.0, + stock: 18, + status: "Active", + tags: ["Magnetic", "Office"], + }, + { + id: "p-016", + name: "USB Microphone", + description: + "Cardioid USB condenser with a built-in pop filter, zero-latency headphone monitoring, and a shock mount tuned for desk-strike isolation.", + category: "Electronics", + publishedAt: "2026-03-07T17:45:00Z", + availableOn: "2026-03-17", + restockAt: "11:40", + price: 129.99, + stock: 75, + status: "Active", + tags: ["USB", "Cardioid", "Streaming"], + }, + { + id: "p-017", + name: "Filing Cabinet", + description: + "Two-drawer lateral file cabinet built from 22-gauge steel, with a keyed central lock, anti-tilt interlock, and full-extension ball-bearing slides.", + category: "Furniture", + publishedAt: "2026-03-10T08:25:00Z", + availableOn: "2026-03-20", + restockAt: "09:55", + price: 349.0, + stock: 8, + status: "Draft", + tags: ["Lockable", "Heavy-duty", "Office"], + }, + { + id: "p-018", + name: "HDMI Cable", + description: "6 ft, 4K @ 120 Hz, braided jacket.", + category: "Accessories", + publishedAt: "2026-03-13T14:30:00Z", + availableOn: "2026-03-22", + restockAt: "13:35", + price: 14.99, + stock: 600, + status: "Active", + tags: ["4K", "Braided"], + }, + { + id: "p-019", + name: "Ergonomic Footrest", + description: + "Tilting under-desk footrest with a textured massaging surface, non-slip base, and 14 degrees of adjustable rocking motion for circulation.", + category: "Furniture", + publishedAt: "2026-03-16T10:10:00Z", + availableOn: "2026-03-25", + restockAt: "15:05", + price: 79.0, + stock: 45, + status: "Archived", + tags: ["Ergonomic", "Adjustable"], + }, + { + id: "p-020", + name: "Docking Station", + description: + "Thunderbolt 4 docking station with dual 4K display outputs, 96 W laptop charging, 2.5 GbE Ethernet, and ten downstream USB ports for a complete desk hub.", + category: "Electronics", + publishedAt: "2026-03-19T16:50:00Z", + availableOn: "2026-03-28", + restockAt: "10:25", + price: 199.99, + stock: 33, + status: "Active", + tags: ["Thunderbolt", "USB-C", "4K", "Ethernet", "Premium"], + }, +]; + +// --------------------------------------------------------------------------- +// Mock query hook (simulates a real useQuery call) +// --------------------------------------------------------------------------- + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { CollectionVariables } from "@tailor-platform/app-shell"; + +const MOCK_LATENCY_MS = 800; + +function compareValues(left: unknown, right: unknown): number | null { + if (typeof left === "number" && typeof right === "number") { + return left < right ? -1 : left > right ? 1 : 0; + } + if (typeof left === "string" && typeof right === "string") { + return left < right ? -1 : left > right ? 1 : 0; + } + return null; +} + +function matchStringOperator(fieldValue: unknown, operator: string, expected: unknown): boolean { + const value = String(fieldValue ?? ""); + const needle = String(expected ?? ""); + + switch (operator) { + case "contains": + return value.includes(needle); + case "notContains": + return !value.includes(needle); + case "hasPrefix": + return value.startsWith(needle); + case "hasSuffix": + return value.endsWith(needle); + case "notHasPrefix": + return !value.startsWith(needle); + case "notHasSuffix": + return !value.endsWith(needle); + default: + return false; + } +} + +function matchOperator(fieldValue: unknown, operator: string, expected: unknown): boolean { + switch (operator) { + case "eq": + return fieldValue === expected; + case "ne": + return fieldValue !== expected; + case "in": + return Array.isArray(expected) && expected.some((item) => item === fieldValue); + case "nin": + return Array.isArray(expected) && !expected.some((item) => item === fieldValue); + case "regex": { + const pattern = String(expected ?? ""); + const caseInsensitive = pattern.startsWith("(?i)"); + const regexBody = caseInsensitive ? pattern.slice(4) : pattern; + const re = new RegExp(regexBody, caseInsensitive ? "i" : ""); + return re.test(String(fieldValue ?? "")); + } + case "contains": + case "notContains": + case "hasPrefix": + case "hasSuffix": + case "notHasPrefix": + case "notHasSuffix": + return matchStringOperator(fieldValue, operator, expected); + case "gt": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared > 0; + } + case "gte": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared >= 0; + } + case "lt": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared < 0; + } + case "lte": { + const compared = compareValues(fieldValue, expected); + return compared != null && compared <= 0; + } + case "between": { + if (!expected || typeof expected !== "object") return false; + const range = expected as { min?: unknown; max?: unknown }; + const minCompared = + range.min === undefined || range.min === null ? 0 : compareValues(fieldValue, range.min); + const maxCompared = + range.max === undefined || range.max === null ? 0 : compareValues(fieldValue, range.max); + const passMin = minCompared != null && minCompared >= 0; + const passMax = maxCompared != null && maxCompared <= 0; + return passMin && passMax; + } + default: + // Ignore unsupported operators in mock mode. + return true; + } +} + +function applyQueryFilters(products: Product[], variables: CollectionVariables): Product[] { + if (!variables.query) return [...products]; + + return products.filter((product) => { + return Object.entries(variables.query ?? {}).every(([field, operators]) => { + const fieldValue = product[field as keyof Product]; + return Object.entries(operators ?? {}).every(([operator, expected]) => { + return matchOperator(fieldValue, operator, expected); + }); + }); + }); +} + +export function useProductsQuery(variables: CollectionVariables) { + const result = useMemo(() => { + // Filter + let rows = applyQueryFilters(allProducts, variables); + + // Sort + if (variables.order && variables.order.length > 0) { + rows.sort((a, b) => { + for (const { field, direction } of variables.order!) { + const aVal = a[field as keyof Product]; + const bVal = b[field as keyof Product]; + if (aVal == null || bVal == null) continue; + const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + if (cmp !== 0) return direction === "Desc" ? -cmp : cmp; + } + return 0; + }); + } + + // Paginate + const pageSize = variables.pagination.first ?? variables.pagination.last ?? rows.length; + let page = 1; + if (variables.pagination.after) { + page = Number(variables.pagination.after); + } else if (variables.pagination.before) { + page = Number(variables.pagination.before); + } else if (variables.pagination.last && !variables.pagination.before) { + // last without before = last page + page = Math.max(1, Math.ceil(rows.length / pageSize)); + } + const start = (page - 1) * pageSize; + const end = Math.min(start + pageSize, rows.length); + const pageRows = rows.slice(start, end); + const hasNextPage = end < rows.length; + const hasPreviousPage = page > 1; + + return { + edges: pageRows.map((node) => ({ node })), + pageInfo: { + hasNextPage, + endCursor: hasNextPage ? String(page + 1) : null, + hasPreviousPage, + startCursor: hasPreviousPage ? String(page - 1) : null, + }, + total: rows.length, + }; + }, [variables]); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const timerRef = useRef | null>(null); + + useEffect(() => { + setLoading(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setData(result); + setLoading(false); + }, MOCK_LATENCY_MS); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [result]); + + return { data, loading }; +} diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx new file mode 100644 index 00000000..1f3fa614 --- /dev/null +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -0,0 +1,153 @@ +import { + DataTable, + Layout, + useDataTable, + useURLCollectionVariables, + createColumnHelper, + type AppShellPageProps, + type RowAction, +} from "@tailor-platform/app-shell"; +import { Package } from "lucide-react"; +import { useState } from "react"; +import { type Product, useProductsQuery } from "../../../mock-products"; + +const productMetadata = { + name: "product", + pluralForm: "products", + fields: [ + { name: "id", type: "uuid", required: true }, + { name: "name", type: "string", required: true }, + { name: "description", type: "string", required: true }, + { name: "category", type: "string", required: true }, + { name: "price", type: "number", required: true }, + { name: "stock", type: "number", required: false }, + { + name: "status", + type: "enum", + required: true, + enumValues: ["Active", "Draft", "Archived"], + }, + { name: "publishedAt", type: "datetime", required: true }, + { name: "tags", type: "string", required: false }, + ], +} as const; + +const { column, inferColumns } = createColumnHelper(); +const infer = inferColumns(productMetadata); + +const columns = [ + column({ + ...infer("name"), + render: (row) => {row.name}, + }), + column({ ...infer("description"), type: "text", truncate: true }), + column(infer("category")), + column({ ...infer("price"), type: "money" }), + column({ ...infer("stock"), type: "number" }), + column({ + ...infer("status"), + type: "badge", + typeOptions: { + badgeVariantMap: { + Active: "success", + Draft: "outline-warning", + Archived: "neutral", + }, + }, + }), + column({ + ...infer("tags"), + type: "badge", + typeOptions: { + badgeVariantMap: { + Premium: "warning", + Ergonomic: "success", + Office: "outline-info", + Wireless: "outline-neutral", + }, + defaultBadgeVariant: "neutral", + maxVisible: 2, + }, + }), +]; + +const rowActions: RowAction[] = [ + { + id: "edit", + label: "Edit", + onClick: (row) => alert(`Edit: ${row.name}`), + }, + { + id: "delete", + label: "Delete", + variant: "destructive", + isDisabled: (row) => row.status === "Active", + onClick: (row) => alert(`Delete: ${row.name}`), + }, +]; + +const ProductsPage = () => { + // Single composed hook: filter/sort/pagination state is persisted to the URL + // (bookmarkable, back-button friendly) and the URL seeds the initial state + // synchronously, so the first fetch already reflects the URL. Check the + // address bar as you interact. + const { variables, control } = useURLCollectionVariables({ + params: { pageSize: 5 }, + tableMetadata: productMetadata, + }); + + const { data, loading } = useProductsQuery(variables); + const [selectedIds, setSelectedIds] = useState([]); + + const table = useDataTable({ + columns, + data: data + ? { + rows: data.edges.map((e) => e.node), + pageInfo: data.pageInfo, + total: data.total, + } + : undefined, + loading, + control, + rowActions, + onClickRow: (row) => alert(`Clicked: ${row.name}`), + onSelectionChange: (ids) => setSelectedIds(ids), + }); + + return ( + + + +

+ DataTable with mock remote query, pagination, sorting, and filters — all synced to the URL + via useURLCollectionVariables. Sort a + column, change page size, or add a filter, then check the address bar. Deep-link state + (e.g. ?p=10&s=price:desc) hydrates on + load. +

+ + + + + + + + + + {selectedIds.length > 0 && ( +

Selected: {selectedIds.join(", ")}

+ )} +
+
+ ); +}; + +ProductsPage.appShellPageProps = { + meta: { + title: "Products", + icon: , + }, +} satisfies AppShellPageProps; + +export default ProductsPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index 8edef362..586629b2 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -19,6 +19,7 @@ export type GeneratedRouteParams = { "/dashboard": {}; "/dashboard/orders": {}; "/dashboard/orders/:id": { id: string }; + "/dashboard/products": {}; "/settings": {}; };