diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md new file mode 100644 index 00000000..7de09834 --- /dev/null +++ b/.changeset/witty-mice-drum.md @@ -0,0 +1,16 @@ +--- +"@tailor-platform/app-shell": minor +--- + +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 { 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 2cf0e190..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,7 +2,7 @@ import { defineResource, DataTable, useDataTable, - useCollectionVariables, + useURLCollectionVariables, createColumnHelper, Layout, type RowAction, @@ -140,7 +140,7 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const { variables, control } = useCollectionVariables({ + const { variables, control } = useURLCollectionVariables({ params: { pageSize: 5 }, tableMetadata: productMetadata, }); 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": {}; }; diff --git a/packages/core/src/hooks/use-collection-variables.test-d.ts b/packages/core/src/hooks/use-collection-variables.test-d.ts index 50a998e5..1db183af 100644 --- a/packages/core/src/hooks/use-collection-variables.test-d.ts +++ b/packages/core/src/hooks/use-collection-variables.test-d.ts @@ -10,9 +10,11 @@ import { describe, it, expectTypeOf } from "vitest"; import type { BuildQueryVariables, + CollectionVariables, TableFieldName, TableMetadata, TableOrderableFieldName, + TypedCollectionVariables, } from "@/types/collection"; type TestTable = { @@ -56,6 +58,7 @@ type TestTable = { }; type TestQuery = BuildQueryVariables; +type TestTypedCollectionVariables = TypedCollectionVariables; describe("BuildQueryVariables", () => { it("TestTable is assignable to TableMetadata", () => { @@ -121,6 +124,16 @@ describe("BuildQueryVariables", () => { }); }); +describe("TypedCollectionVariables", () => { + it("extends CollectionVariables while keeping typed query fields", () => { + expectTypeOf().toExtend(); + expectTypeOf>().toExtend<{ + title?: { eq?: string; contains?: string }; + status?: { eq?: "active" | "inactive" }; + }>(); + }); +}); + describe("TableFieldName", () => { it("extracts all field names", () => { type AllNames = diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index 8d0b5ae7..ad0e7800 100644 --- a/packages/core/src/hooks/use-collection-variables.test.ts +++ b/packages/core/src/hooks/use-collection-variables.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import type { TableMetadataMap } from "@/types/collection"; import { useCollectionVariables } from "./use-collection-variables"; @@ -55,6 +55,51 @@ describe("useCollectionVariables", () => { status: { eq: "ACTIVE" }, }); }); + + it("applies params together", () => { + const { result } = renderHook(() => + useCollectionVariables({ + params: { + initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 50, + }, + }), + ); + + 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.variables.pagination).toEqual({ first: 50 }); + }); + }); + + describe("onParamsChange", () => { + it("does not notify on initial render", () => { + const onParamsChange = vi.fn(); + renderHook(() => useCollectionVariables({ onParamsChange })); + expect(onParamsChange).not.toHaveBeenCalled(); + }); + + it("notifies with params after changes", () => { + const onParamsChange = vi.fn(); + const { result } = renderHook(() => useCollectionVariables({ onParamsChange })); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(onParamsChange).toHaveBeenLastCalledWith({ + initialFilters: [ + { field: "status", operator: "eq", value: "ACTIVE", caseSensitive: undefined }, + ], + initialSort: [], + pageSize: 20, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 52c44f7c..3102fde3 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,16 +1,14 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { - BuildQueryVariables, CollectionControl, CollectionVariables, Filter, FilterOperator, - PaginationVariables, SortState, TableFieldName, TableMetadata, TableMetadataFilter, - TableOrderableFieldName, + TypedCollectionVariables, UseCollectionOptions, UseCollectionReturn, } from "@/types/collection"; @@ -85,16 +83,7 @@ export function useCollectionVariables( }, ): UseCollectionReturn< TableFieldName, - { - query: BuildQueryVariables | undefined; - order: - | { - field: TableOrderableFieldName; - direction: "Asc" | "Desc"; - }[] - | undefined; - pagination: PaginationVariables; - }, + TypedCollectionVariables, TableMetadataFilter >; @@ -130,8 +119,10 @@ export function useCollectionVariables( export function useCollectionVariables( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, ): unknown { - const { params = {} } = options; - const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; + const { params = {}, onParamsChange } = options; + const initialFilters = params.initialFilters ?? []; + const initialSort = params.initialSort ?? []; + const initialPageSize = params.pageSize ?? 20; // --------------------------------------------------------------------------- // State @@ -152,6 +143,8 @@ export function useCollectionVariables( getHasNextPage, resetCount, } = useCursorPagination(initialPageSize); + const onParamsChangeRef = useRef(onParamsChange); + const didMountRef = useRef(false); // --------------------------------------------------------------------------- // Filter operations @@ -262,6 +255,23 @@ export function useCollectionVariables( [queryVars, orderVars, paginationVariables], ); + useEffect(() => { + onParamsChangeRef.current = onParamsChange; + }, [onParamsChange]); + + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + + onParamsChangeRef.current?.({ + initialFilters: filters, + initialSort: sortStates, + pageSize, + }); + }, [filters, sortStates, pageSize]); + // --------------------------------------------------------------------------- // Return // --------------------------------------------------------------------------- diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9e5f1cb3..61eb11df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -156,6 +156,9 @@ export { type PageInfo, type CollectionVariables, type CollectionControl, + type CollectionInitialState, + type CollectionParams, + type CollectionPersistedState, type CollectionResult, type NodeType, type PaginationVariables, @@ -188,6 +191,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; +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 new file mode 100644 index 00000000..479ca86b --- /dev/null +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -0,0 +1,298 @@ +import { renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { createElement } from "react"; +import { MemoryRouter } from "react-router"; +import { describe, it, expect, vi } from "vitest"; +import type { CollectionPersistedState, TableMetadataMap } from "@/types/collection"; +import { + useURLCollectionVariables, + withURLCollectionState, + encodeFilterValue, + decodeFilterValue, + parseCollectionSearchParams, + writeCollectionSearchParams, +} from "./collection-url-state"; + +const tableMetadata = { + task: { + name: "task", + pluralForm: "tasks", + fields: [ + { 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; + +function resolveSearchParamsBindingCall( + setSearchParams: ReturnType, + prev = new URLSearchParams(), +): URLSearchParams { + const [updaterOrValue] = setSearchParams.mock.calls[0]; + if (typeof updaterOrValue === "function") { + const result = updaterOrValue(prev); + return result instanceof URLSearchParams ? result : new URLSearchParams(result); + } + return updaterOrValue instanceof URLSearchParams + ? updaterOrValue + : new URLSearchParams(updaterOrValue); +} + +describe("parseCollectionSearchParams", () => { + it("hydrates pageSize, sort, and filters", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams("p=50&s=createdAt:desc&f.status:eq=active"), + ); + + expect(result).toEqual({ + pageSize: 50, + sortStates: [{ field: "createdAt", direction: "Desc" }], + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + }); + + it("filters out URL fields/operators not allowed by tableMetadata", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams( + "s=amount:asc&f.amount:eq=10&f.createdAt:contains=2026&f.status:eq=active", + ), + ); + + expect(result).toEqual({ + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + }); + + it("supports untyped parsing without metadata", () => { + const result = parseCollectionSearchParams( + new URLSearchParams('p=25&s=name:asc&f.tags:in=["a","b"]'), + ); + + expect(result).toEqual({ + pageSize: 25, + sortStates: [{ field: "name", direction: "Asc" }], + 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", () => { + it("writes persisted state to URL params", () => { + const state: CollectionPersistedState<"status" | "createdAt"> = { + filters: [{ field: "status", operator: "eq", value: "pending" }], + sortStates: [{ field: "createdAt", direction: "Desc" }], + pageSize: 30, + }; + const result = writeCollectionSearchParams(new URLSearchParams("foo=bar"), state); + + expect(result.toString()).toBe("foo=bar&p=30&s=createdAt%3Adesc&f.status%3Aeq=pending"); + }); + + it("returns prev when param multiset is unchanged", () => { + const prev = new URLSearchParams("f.b:eq=2&f.a:eq=1&p=20"); + const result = writeCollectionSearchParams(prev, { + filters: [ + { field: "a", operator: "eq", value: "1" }, + { field: "b", operator: "eq", value: "2" }, + ], + sortStates: [], + pageSize: 20, + }); + + expect(result).toBe(prev); + }); +}); + +function SearchParamsWrapper({ children }: PropsWithChildren) { + return createElement(MemoryRouter, { initialEntries: ["/?p=50&f.status:eq=active"] }, children); +} + +describe("withURLCollectionState", () => { + it("parses URL state and merges it into params", () => { + const setSearchParams = vi.fn(); + const options = withURLCollectionState( + { + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + }, + [new URLSearchParams("p=50&f.status:eq=active"), setSearchParams], + ); + + expect(options.params).toEqual({ + initialFilters: [{ field: "status", operator: "eq", value: "active" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 50, + }); + }); + + it("merges URL state into params and composes onParamsChange", () => { + const setSearchParams = vi.fn(); + const onParamsChange = vi.fn(); + const options = withURLCollectionState( + { + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + }, + onParamsChange, + }, + [new URLSearchParams("p=25"), setSearchParams], + ); + + expect(options.params).toEqual({ + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 25, + }); + + options.onParamsChange?.({ + initialFilters: [{ field: "status", operator: "eq", value: "pending" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 30, + }); + + expect(onParamsChange).toHaveBeenCalledWith({ + initialFilters: [{ field: "status", operator: "eq", value: "pending" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 30, + }); + expect(setSearchParams).toHaveBeenCalledTimes(1); + expect(setSearchParams.mock.calls[0][1]).toEqual({ replace: true }); + expect(resolveSearchParamsBindingCall(setSearchParams).toString()).toBe( + "p=30&s=createdAt%3Adesc&f.status%3Aeq=pending", + ); + }); +}); + +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 }, + ); + + // 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); + }); +}); + +describe("encodeFilterValue", () => { + it("encodes strings", () => { + expect(encodeFilterValue("hello")).toBe("hello"); + }); + + it("encodes numbers", () => { + expect(encodeFilterValue(42)).toBe("42"); + }); + + it("encodes booleans", () => { + expect(encodeFilterValue(true)).toBe("true"); + }); + + it("encodes arrays as JSON to avoid comma ambiguity", () => { + expect(encodeFilterValue(["a", "b", "c"])).toBe('["a","b","c"]'); + }); + + it("preserves values containing commas in arrays", () => { + expect(encodeFilterValue(["Smith, John", "Doe, Jane"])).toBe('["Smith, John","Doe, Jane"]'); + }); + + it("encodes null as empty string", () => { + expect(encodeFilterValue(null)).toBe(""); + }); + + it("encodes undefined as empty string", () => { + expect(encodeFilterValue(undefined)).toBe(""); + }); + + it("encodes objects as JSON", () => { + expect(encodeFilterValue({ foo: "bar" })).toBe('{"foo":"bar"}'); + }); +}); + +describe("decodeFilterValue", () => { + it("decodes JSON arrays", () => { + expect(decodeFilterValue('["a","b","c"]')).toEqual(["a", "b", "c"]); + }); + + it("decodes JSON arrays with values containing commas", () => { + expect(decodeFilterValue('["Smith, John","Doe, Jane"]')).toEqual(["Smith, John", "Doe, Jane"]); + }); + + it("decodes JSON objects (between {min,max} shape)", () => { + expect(decodeFilterValue('{"min":1,"max":10}')).toEqual({ min: 1, max: 10 }); + }); + + it("round-trips an object value through encode → decode", () => { + const value = { min: "2026-01-01", max: "2026-12-31" }; + expect(decodeFilterValue(encodeFilterValue(value))).toEqual(value); + }); + + it("does not coerce numeric- or boolean-looking strings to primitives", () => { + expect(decodeFilterValue("5")).toBe("5"); + expect(decodeFilterValue("true")).toBe("true"); + }); + + it("returns plain string for non-array values", () => { + expect(decodeFilterValue("hello")).toBe("hello"); + }); + + it("returns plain string with commas as-is (not split)", () => { + expect(decodeFilterValue("Smith, John")).toBe("Smith, John"); + }); + + it("returns plain string for malformed JSON", () => { + expect(decodeFilterValue("[not valid json")).toBe("[not valid json"); + expect(decodeFilterValue("{broken")).toBe("{broken"); + }); +}); diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts new file mode 100644 index 00000000..64e8a064 --- /dev/null +++ b/packages/core/src/lib/collection-url-state.ts @@ -0,0 +1,381 @@ +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"; +const KEY_SORT = "s"; +const FILTER_PREFIX = "f."; + +/** + * Setter shape compatible with `useSearchParams()`. + */ +export type SearchParamsBinding = readonly [ + URLSearchParams, + ( + next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams), + options?: { replace?: boolean }, + ) => void, +]; + +function isValidSortField(tableMetadata: TableMetadata | undefined, field: string): boolean { + if (!tableMetadata) return true; + const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); + return !!metadataField && !!fieldTypeToSortConfig(metadataField.name, metadataField.type); +} + +function isValidFilter( + tableMetadata: TableMetadata | undefined, + field: string, + operator: string, +): boolean { + if (!tableMetadata) return true; + const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); + if (!metadataField) return false; + + const filterConfig = fieldTypeToFilterConfig( + metadataField.name, + metadataField.type, + metadataField.enumValues, + ); + if (!filterConfig) return false; + + 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. + */ +export function parseCollectionSearchParams( + tableMetadata: TTable, + params: URLSearchParams, +): CollectionInitialState, TableMetadataFilter>; +export function parseCollectionSearchParams(params: URLSearchParams): CollectionInitialState; +export function parseCollectionSearchParams( + tableMetadataOrParams: TableMetadata | URLSearchParams, + maybeParams?: URLSearchParams, +): CollectionInitialState { + const tableMetadata = maybeParams ? (tableMetadataOrParams as TableMetadata) : undefined; + const params = maybeParams ?? (tableMetadataOrParams as URLSearchParams); + const nextState: CollectionInitialState = {}; + + const pageSize = params.get(KEY_PAGE_SIZE); + if (pageSize) { + const n = Number(pageSize); + if (Number.isFinite(n) && n > 0) nextState.pageSize = n; + } + + const sort = params.get(KEY_SORT); + if (sort) { + const [field, rawDir] = sort.split(":"); + if (field && isValidSortField(tableMetadata, field)) { + nextState.sortStates = [{ field, direction: rawDir === "desc" ? "Desc" : "Asc" }]; + } + } + + const nextFilters: Filter[] = []; + for (const [key, value] of params.entries()) { + 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"], + // 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; + + return nextState; +} + +/** + * Apply collection state to a URLSearchParams. + */ +export function writeCollectionSearchParams< + TFieldName extends string, + TFilter extends Filter, +>(prev: URLSearchParams, state: CollectionPersistedState): URLSearchParams { + const next = new URLSearchParams(prev); + + if (state.pageSize) { + next.set(KEY_PAGE_SIZE, String(state.pageSize)); + } else { + next.delete(KEY_PAGE_SIZE); + } + + if (state.sortStates.length > 0) { + const { field, direction } = state.sortStates[0]; + next.set(KEY_SORT, `${field}:${direction === "Desc" ? "desc" : "asc"}`); + } else { + next.delete(KEY_SORT); + } + + // Snapshot keys before iterating — we delete entries during the loop. + // eslint-disable-next-line unicorn/no-useless-spread + for (const key of [...next.keys()]) { + if (key.startsWith(FILTER_PREFIX)) next.delete(key); + } + for (const filter of state.filters) { + next.set(`${FILTER_PREFIX}${filter.field}:${filter.operator}`, encodeFilterValue(filter.value)); + } + + // Bail out if nothing changed — avoids a no-op navigation that could still + // trigger re-renders in some react-router versions. Compare on a sorted + // snapshot rather than `.toString()`: the filter rebuild above is delete- + // then-set, so key order can shift between renders even when the param + // multiset is identical (and `.toString()` is additionally sensitive to + // `&`/`=` characters inside values). + if (stableQueryString(next) === stableQueryString(prev)) return prev; + return next; +} + +/** + * Decorate `useCollectionVariables` options with URL-backed collection state. + */ +export function withURLCollectionState( + options: UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; + }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; +}; +export function withURLCollectionState( + options: UseCollectionOptions & { + tableMetadata?: never; + }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions; +export function withURLCollectionState( + options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + searchParamsBinding: SearchParamsBinding, +): UseCollectionOptions & { tableMetadata?: TableMetadata } { + return applyURLCollectionState(options, searchParamsBinding); +} + +function applyURLCollectionState( + options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions & { tableMetadata?: TableMetadata } { + const initialState = options.tableMetadata + ? parseCollectionSearchParams(options.tableMetadata, searchParams) + : parseCollectionSearchParams(searchParams); + + return { + ...options, + params: mergeCollectionStateIntoParams(options.params, initialState), + onParamsChange(params) { + options.onParamsChange?.(params); + setSearchParams( + (prev) => writeCollectionSearchParams(prev, collectionParamsToPersistedState(params)), + { replace: true }, + ); + }, + }; +} + +function collectionParamsToPersistedState( + params: UseCollectionOptions["params"], +): CollectionPersistedState { + return { + filters: params?.initialFilters ?? [], + sortStates: params?.initialSort ?? [], + pageSize: params?.pageSize ?? 20, + }; +} + +function mergeCollectionStateIntoParams( + params: UseCollectionOptions["params"], + initialState: CollectionInitialState, +): UseCollectionOptions["params"] { + if (!initialState.filters && !initialState.sortStates && !initialState.pageSize) return params; + + return { + ...params, + ...(initialState.filters ? { initialFilters: initialState.filters } : {}), + ...(initialState.sortStates ? { initialSort: initialState.sortStates } : {}), + ...(initialState.pageSize ? { pageSize: initialState.pageSize } : {}), + }; +} + +/** + * 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 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(); + // `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; + }, + ); +} + +/** + * Encodes a filter value for URL storage. + * - Arrays are JSON-encoded to avoid ambiguity with values that contain commas. + * - Objects are JSON-encoded. + * - Primitives are stringified directly. + */ +export function encodeFilterValue(value: unknown): string { + // Arrays use JSON so that individual elements containing commas are preserved + // correctly during round-trip (encode → decode). + if (Array.isArray(value)) return JSON.stringify(value.map((v) => stringifyPrimitive(v))); + if (value == null) return ""; + if (typeof value === "object") return JSON.stringify(value); + return stringifyPrimitive(value); +} + +function stringifyPrimitive(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (value == null) return ""; + return JSON.stringify(value); +} + +/** + * Decodes a filter value from URL storage. + * - JSON arrays are parsed back into arrays. + * - JSON objects are parsed back into objects (e.g. the `between` operator's + * `{ min, max }` shape). + * - All other values — including numeric/boolean-looking strings such as `"5"` + * or `"true"` — are returned as plain strings, preserving the string-vs- + * numeric distinction that operator implementations rely on. + */ +export function decodeFilterValue(raw: string): unknown { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed; + // Objects (e.g. a `between` filter's `{ min, max }`) are JSON-encoded by + // encodeFilterValue and must be decoded back here; without this, object- + // valued filters round-trip to a raw string on reload and silently break. + if (parsed && typeof parsed === "object") return parsed; + } catch { + // Not valid JSON — fall through to plain string + } + return raw; +} + +/** + * Order-insensitive, unambiguous snapshot of a URLSearchParams for equality + * checks. Entries are sorted by key then value so a reordered-but-equivalent + * param set compares equal, and the pairs are JSON-encoded so a key or value + * containing `&`/`=` can't collide with the entry boundary the way a re-joined + * query string would (e.g. `[["a","x"],["b","y"]]` vs `[["a","x&b=y"]]`). + */ +function stableQueryString(sp: URLSearchParams): string { + return JSON.stringify( + Array.from(sp.entries()).toSorted( + ([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv), + ), + ); +} diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 0627c92b..3bfc3a0c 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -298,6 +298,20 @@ export interface CollectionVariables { pagination: PaginationVariables; } +/** + * Metadata-aware collection variables that remain assignable to the broad + * `CollectionVariables` shape expected by GraphQL clients. + */ +export type TypedCollectionVariables = CollectionVariables & { + query: BuildQueryVariables | undefined; + order: + | { + field: TableOrderableFieldName; + direction: "Asc" | "Desc"; + }[] + | undefined; +}; + // ============================================================================= // Collection Result (Tailor Platform standard) // ============================================================================= @@ -350,6 +364,38 @@ export type TableOrderableFieldName = // Collection Control & Options // ============================================================================= +/** + * Serializable subset of collection state used for initialization and persistence. + */ +export interface CollectionPersistedState< + TFieldName extends string = string, + TFilter extends Filter = Filter, +> { + filters: TFilter[]; + sortStates: { field: TFieldName; direction: "Asc" | "Desc" }[]; + pageSize: number; +} + +/** + * Partial initial state used to seed `useCollectionVariables`. + */ +export type CollectionInitialState< + TFieldName extends string = string, + TFilter extends Filter = Filter, +> = Partial>; + +/** + * Initial and change payload shape for `useCollectionVariables`. + */ +export interface CollectionParams< + TFieldName extends string = string, + TFilter extends Filter = Filter, +> { + initialFilters?: TFilter[]; + initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; + pageSize?: number; +} + /** * Options for `useCollectionVariables` hook. */ @@ -357,11 +403,8 @@ export interface UseCollectionOptions< TFieldName extends string = string, TFilter extends Filter = Filter, > { - params?: { - initialFilters?: TFilter[]; - initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; - pageSize?: number; - }; + params?: CollectionParams; + onParamsChange?(params: CollectionParams): void; } /**