From bf1bee48ac12b0b8a60dc694aac506459969d5b8 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 27 May 2026 16:44:54 +0900 Subject: [PATCH 01/10] improve: simplify and harden useUrlCollectionState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove params from write effect deps using function updater to eliminate feedback loop (setParams → params change → effect re-runs) - Fix filter value encoding: use JSON for arrays to correctly handle values containing commas (e.g. "Smith, John") - Fix hydration race condition: introduce SyncPhase lifecycle (pending → hydrated → ready) to prevent writing stale defaults to URL before hydrated state propagates - Simplify decodeFilterValue: remove startsWith check, rely on JSON.parse + Array.isArray for correctness - Replace Array.from(next.keys()) with spread syntax --- .../hooks/use-url-collection-state.test.ts | 277 ++++++++++++++++++ .../src/hooks/use-url-collection-state.ts | 167 +++++++++++ packages/core/src/index.ts | 1 + 3 files changed, 445 insertions(+) create mode 100644 packages/core/src/hooks/use-url-collection-state.test.ts create mode 100644 packages/core/src/hooks/use-url-collection-state.ts diff --git a/packages/core/src/hooks/use-url-collection-state.test.ts b/packages/core/src/hooks/use-url-collection-state.test.ts new file mode 100644 index 00000000..b1ddced4 --- /dev/null +++ b/packages/core/src/hooks/use-url-collection-state.test.ts @@ -0,0 +1,277 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { CollectionControl } from "@/types/collection"; +import { + useUrlCollectionState, + encodeFilterValue, + decodeFilterValue, +} from "./use-url-collection-state"; + +// --------------------------------------------------------------------------- +// Mock react-router's useSearchParams +// --------------------------------------------------------------------------- +let mockParams: URLSearchParams; +const mockSetParams = vi.fn(); + +vi.mock("react-router", () => ({ + useSearchParams: () => [mockParams, mockSetParams], +})); + +/** + * Helper: the hook now calls setParams with a function updater. + * This helper resolves the updater against mockParams to get the resulting params. + */ +function resolveSetParamsCall(callIndex: number): URLSearchParams { + const [updaterOrValue] = mockSetParams.mock.calls[callIndex]; + if (typeof updaterOrValue === "function") { + const result = updaterOrValue(mockParams); + return result instanceof URLSearchParams ? result : new URLSearchParams(result); + } + return updaterOrValue instanceof URLSearchParams + ? updaterOrValue + : new URLSearchParams(updaterOrValue); +} + +function makeControl(overrides?: Partial): CollectionControl { + return { + filters: [], + addFilter: vi.fn(), + setFilters: vi.fn(), + removeFilter: vi.fn(), + clearFilters: vi.fn(), + sortStates: [], + setSort: vi.fn(), + clearSort: vi.fn(), + pageSize: 20, + setPageSize: vi.fn(), + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + resetPage: vi.fn(), + goToFirstPage: vi.fn(), + goToLastPage: vi.fn(), + getHasPrevPage: vi.fn(), + getHasNextPage: vi.fn(), + resetCount: 0, + ...overrides, + }; +} + +describe("useUrlCollectionState", () => { + beforeEach(() => { + mockParams = new URLSearchParams(); + mockSetParams.mockClear(); + }); + + // --------------------------------------------------------------------------- + // Hydration from URL + // --------------------------------------------------------------------------- + describe("hydration from URL", () => { + it("hydrates pageSize from URL param", () => { + mockParams = new URLSearchParams("p=50"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setPageSize).toHaveBeenCalledWith(50); + }); + + it("ignores invalid pageSize values", () => { + mockParams = new URLSearchParams("p=abc"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setPageSize).not.toHaveBeenCalled(); + }); + + it("ignores non-positive pageSize values", () => { + mockParams = new URLSearchParams("p=-5"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setPageSize).not.toHaveBeenCalled(); + }); + + it("hydrates sort from URL param (asc)", () => { + mockParams = new URLSearchParams("s=name:asc"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setSort).toHaveBeenCalledWith("name", "Asc"); + }); + + it("hydrates sort from URL param (desc)", () => { + mockParams = new URLSearchParams("s=createdAt:desc"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setSort).toHaveBeenCalledWith("createdAt", "Desc"); + }); + + it("hydrates filters from URL params", () => { + mockParams = new URLSearchParams("f.status:eq=active&f.priority:gt=3"); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setFilters).toHaveBeenCalledWith([ + { field: "status", operator: "eq", value: "active" }, + { field: "priority", operator: "gt", value: "3" }, + ]); + }); + + it("hydrates array filter values (JSON format)", () => { + mockParams = new URLSearchParams('f.status:in=["active","pending","closed"]'); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setFilters).toHaveBeenCalledWith([ + { + field: "status", + operator: "in", + value: ["active", "pending", "closed"], + }, + ]); + }); + + it("skips filters with missing value", () => { + mockParams = new URLSearchParams("f.status:eq="); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setFilters).not.toHaveBeenCalled(); + }); + + it("does not hydrate twice on re-render", () => { + mockParams = new URLSearchParams("p=30"); + const control = makeControl(); + const { rerender } = renderHook(() => useUrlCollectionState(control)); + rerender(); + expect(control.setPageSize).toHaveBeenCalledTimes(1); + }); + }); + + // --------------------------------------------------------------------------- + // Writing state to URL + // --------------------------------------------------------------------------- + describe("writing state to URL", () => { + it("writes pageSize to URL when control state changes", () => { + // Start with default, then simulate a user changing pageSize + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl({ pageSize: 20 }) }, + }); + // First render: write effect is skipped (skipWriteRef). + // Simulate user changing pageSize → triggers dep change → write effect fires. + rerender({ control: makeControl({ pageSize: 25 }) }); + const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); + expect(nextParams.get("p")).toBe("25"); + }); + + it("writes sort to URL when control state changes", () => { + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl() }, + }); + rerender({ + control: makeControl({ + sortStates: [{ field: "name", direction: "Desc" }], + }), + }); + const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); + expect(nextParams.get("s")).toBe("name:desc"); + }); + + it("writes filters to URL when control state changes", () => { + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl() }, + }); + rerender({ + control: makeControl({ + filters: [{ field: "status", operator: "eq" as never, value: "active" }], + }), + }); + const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); + expect(nextParams.get("f.status:eq")).toBe("active"); + }); + + it("returns prev params unchanged when URL is already in sync", () => { + mockParams = new URLSearchParams("p=20"); + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl({ pageSize: 20 }) }, + }); + // Change a non-relevant field to trigger the effect + rerender({ + control: makeControl({ + pageSize: 20, + sortStates: [], + filters: [], + }), + }); + const lastCallIndex = mockSetParams.mock.calls.length - 1; + const [updater] = mockSetParams.mock.calls[lastCallIndex]; + if (typeof updater === "function") { + const result = updater(mockParams); + // When no change, it should return the original prev instance + expect(result).toBe(mockParams); + } + }); + + it("uses replace: true for setParams", () => { + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl({ pageSize: 20 }) }, + }); + rerender({ control: makeControl({ pageSize: 30 }) }); + const lastCallIndex = mockSetParams.mock.calls.length - 1; + const [, options] = mockSetParams.mock.calls[lastCallIndex]; + expect(options).toEqual({ replace: true }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- +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("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/hooks/use-url-collection-state.ts b/packages/core/src/hooks/use-url-collection-state.ts new file mode 100644 index 00000000..2eb7d398 --- /dev/null +++ b/packages/core/src/hooks/use-url-collection-state.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router"; +import type { CollectionControl, Filter } from "@/types/collection"; + +const KEY_PAGE_SIZE = "p"; +const KEY_SORT = "s"; +const FILTER_PREFIX = "f."; + +/** + * Lifecycle phase of `useUrlCollectionState`. + */ +type SyncPhase = + /** Initial state. Hydration has not run yet. */ + | "pending" + /** Hydration complete. The first write effect should be skipped because + * control state set during hydration (via setState) won't be reflected + * until the next render. */ + | "hydrated" + /** Normal operation. The write effect actively syncs control state to the URL. */ + | "ready"; + +/** + * Persists CollectionControl state (filters, sort, page size) to the URL query + * string so pages are bookmarkable and the browser back button works. + * + * Designed to be entity-agnostic: keys are short and the operator/value + * encoding is derived from the current Filter shape, not hard-coded per field. + * + * Cursor/direction state is intentionally NOT persisted — `CollectionControl` + * manages cursor state internally and no longer exposes it publicly. We accept + * the regression that a refresh resets to page 1. + */ +export function useUrlCollectionState< + TFieldName extends string, + TFilter extends Filter, +>(control: CollectionControl): void { + const [params, setParams] = useSearchParams(); + const phaseRef = useRef("pending"); + + // Hydrate control from URL on first render. + useEffect(() => { + if (phaseRef.current !== "pending") return; + phaseRef.current = "hydrated"; + + const pageSize = params.get(KEY_PAGE_SIZE); + if (pageSize) { + const n = Number(pageSize); + if (Number.isFinite(n) && n > 0) control.setPageSize(n); + } + + const sort = params.get(KEY_SORT); + if (sort) { + const [field, rawDir] = sort.split(":"); + if (field) { + const direction = rawDir === "desc" ? "Desc" : "Asc"; + control.setSort(field as TFieldName, direction); + } + } + + 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) continue; + nextFilters.push({ + field: field as TFieldName, + operator, + value: decodeFilterValue(value), + } as Filter); + } + if (nextFilters.length > 0) { + control.setFilters(nextFilters); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Write control state back to URL whenever it changes. + // Uses the setParams function updater to avoid depending on `params` in the + // dependency array, which would cause a feedback loop: + // setParams → params change → effect re-runs → setParams (no-op but wasteful) + useEffect(() => { + if (phaseRef.current === "pending") return; + // Skip the first write cycle that fires immediately after hydration. + // Control state set during hydration (setPageSize, setSort, etc.) is async + // and won't be reflected until the next render. + if (phaseRef.current === "hydrated") { + phaseRef.current = "ready"; + return; + } + + setParams( + (prev) => { + const next = new URLSearchParams(prev); + + if (control.pageSize) { + next.set(KEY_PAGE_SIZE, String(control.pageSize)); + } else { + next.delete(KEY_PAGE_SIZE); + } + + if (control.sortStates.length > 0) { + const { field, direction } = control.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 control.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. + if (next.toString() === prev.toString()) return prev; + return next; + }, + { replace: true }, + ); + }, [control.pageSize, control.sortStates, control.filters, setParams]); +} + +/** + * 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. + * - All other values are returned as plain strings. + */ +export function decodeFilterValue(raw: string): unknown { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed; + } catch { + // Not valid JSON — fall through to plain string + } + return raw; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9da325fa..8b1668d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -186,6 +186,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; +export { useUrlCollectionState } from "./hooks/use-url-collection-state"; export { CollectionControlProvider, useCollectionControl, From 27a86693c86f4700e81e819caad87e7c2ca94f00 Mon Sep 17 00:00:00 2001 From: interacsean Date: Mon, 15 Jun 2026 14:22:07 +1000 Subject: [PATCH 02/10] fix: round-trip object-valued filters in useUrlCollectionState decodeFilterValue only parsed JSON arrays back, so object-valued filters (the `between` operator's { min, max } shape) round-tripped to a raw string on reload and silently broke. Decode objects too; primitives like "5"/"true" still fall through to strings, preserving string-vs-numeric filter semantics. Also harden the write-effect bail-out: compare on a sorted, JSON-encoded snapshot (stableQueryString) rather than `.toString()`, which is sensitive to filter key-insertion order and to `&`/`=` inside values. Folds in fixes already running in the Denim Tears IMS copy of this hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hooks/use-url-collection-state.test.ts | 55 +++++++++++++++++++ .../src/hooks/use-url-collection-state.ts | 33 ++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/core/src/hooks/use-url-collection-state.test.ts b/packages/core/src/hooks/use-url-collection-state.test.ts index b1ddced4..bc79b009 100644 --- a/packages/core/src/hooks/use-url-collection-state.test.ts +++ b/packages/core/src/hooks/use-url-collection-state.test.ts @@ -124,6 +124,21 @@ describe("useUrlCollectionState", () => { ]); }); + it("hydrates object filter values (between {min,max} JSON format)", () => { + mockParams = new URLSearchParams( + 'f.createdAt:between={"min":"2026-01-01","max":"2026-12-31"}', + ); + const control = makeControl(); + renderHook(() => useUrlCollectionState(control)); + expect(control.setFilters).toHaveBeenCalledWith([ + { + field: "createdAt", + operator: "between", + value: { min: "2026-01-01", max: "2026-12-31" }, + }, + ]); + }); + it("skips filters with missing value", () => { mockParams = new URLSearchParams("f.status:eq="); const control = makeControl(); @@ -204,6 +219,30 @@ describe("useUrlCollectionState", () => { } }); + it("treats reordered params as unchanged (order-insensitive compare)", () => { + // prev URL holds filters in b,a order; control emits the same multiset in + // a,b order. The delete-then-set rebuild flips key order, but the param + // multiset is identical, so the write must bail out and return prev. + // A plain `.toString()` compare would see a difference here and navigate. + mockParams = new URLSearchParams("f.b:eq=2&f.a:eq=1&p=20"); + const filters = [ + { field: "a", operator: "eq" as never, value: "1" }, + { field: "b", operator: "eq" as never, value: "2" }, + ]; + const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { + initialProps: { control: makeControl({ pageSize: 20, filters }) }, + }); + // Re-render with a fresh control (new array refs) to fire the write effect. + rerender({ control: makeControl({ pageSize: 20, filters: [...filters] }) }); + const lastCallIndex = mockSetParams.mock.calls.length - 1; + const [updater] = mockSetParams.mock.calls[lastCallIndex]; + expect(typeof updater).toBe("function"); + if (typeof updater === "function") { + const result = updater(mockParams); + expect(result).toBe(mockParams); + } + }); + it("uses replace: true for setParams", () => { const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { initialProps: { control: makeControl({ pageSize: 20 }) }, @@ -262,6 +301,22 @@ describe("decodeFilterValue", () => { 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", () => { + // These parse as JSON (number/boolean) but must stay strings so operator + // implementations keep their string-vs-numeric semantics. + expect(decodeFilterValue("5")).toBe("5"); + expect(decodeFilterValue("true")).toBe("true"); + }); + it("returns plain string for non-array values", () => { expect(decodeFilterValue("hello")).toBe("hello"); }); diff --git a/packages/core/src/hooks/use-url-collection-state.ts b/packages/core/src/hooks/use-url-collection-state.ts index 2eb7d398..20cfa7b6 100644 --- a/packages/core/src/hooks/use-url-collection-state.ts +++ b/packages/core/src/hooks/use-url-collection-state.ts @@ -118,8 +118,12 @@ export function useUrlCollectionState< } // Bail out if nothing changed — avoids a no-op navigation that could - // still trigger re-renders in some react-router versions. - if (next.toString() === prev.toString()) return prev; + // 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; }, { replace: true }, @@ -154,14 +158,37 @@ function stringifyPrimitive(value: unknown): string { /** * Decodes a filter value from URL storage. * - JSON arrays are parsed back into arrays. - * - All other values are returned as plain strings. + * - 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), + ), + ); +} From add84ec7bf0971fdfaaa55bffd46fb9d6628e459 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 19 Jun 2026 16:42:36 +0900 Subject: [PATCH 03/10] feat(core): add withURLState helper for collection variables --- .../src/modules/pages/data-table-demo.tsx | 19 +- .../hooks/use-collection-variables.test.ts | 67 ++++- .../src/hooks/use-collection-variables.ts | 28 +- .../hooks/use-url-collection-state.test.ts | 104 ++++++- .../src/hooks/use-url-collection-state.ts | 260 +++++++++++++----- packages/core/src/index.ts | 5 +- packages/core/src/types/collection.ts | 32 +++ 7 files changed, 437 insertions(+), 78 deletions(-) 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..6196062f 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -3,8 +3,11 @@ import { DataTable, useDataTable, useCollectionVariables, + withURLState, + useSearchParams, createColumnHelper, Layout, + type CollectionVariables, type RowAction, } from "@tailor-platform/app-shell"; import { useState } from "react"; @@ -140,11 +143,17 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const { variables, control } = useCollectionVariables({ - params: { pageSize: 5 }, - tableMetadata: productMetadata, - }); - const { data, loading } = useProductsQuery(variables); + const searchParams = useSearchParams(); + const { variables, control } = useCollectionVariables( + withURLState( + { + params: { pageSize: 5 }, + tableMetadata: productMetadata, + }, + searchParams, + ), + ); + const { data, loading } = useProductsQuery(variables as CollectionVariables); const [selectedIds, setSelectedIds] = useState([]); const table = useDataTable({ diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index 8d0b5ae7..ef4ed8f6 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,71 @@ describe("useCollectionVariables", () => { status: { eq: "ACTIVE" }, }); }); + + it("prefers initialState over params", () => { + const { result } = renderHook(() => + useCollectionVariables({ + params: { + initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + initialState: { + filters: [{ field: "status", operator: "eq", value: "INACTIVE" }], + sortStates: [{ field: "name", direction: "Asc" }], + pageSize: 50, + }, + }), + ); + + expect(result.current.control.filters).toEqual([ + { field: "status", operator: "eq", value: "INACTIVE" }, + ]); + expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); + expect(result.current.variables.pagination).toEqual({ first: 50 }); + }); + + it("falls back to params for keys missing from initialState", () => { + const { result } = renderHook(() => + useCollectionVariables({ + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + initialState: { + pageSize: 50, + }, + }), + ); + + expect(result.current.control.sortStates).toEqual([ + { field: "createdAt", direction: "Desc" }, + ]); + expect(result.current.variables.pagination).toEqual({ first: 50 }); + }); + }); + + describe("saver", () => { + it("does not save on initial render", () => { + const saver = { save: vi.fn() }; + renderHook(() => useCollectionVariables({ saver })); + expect(saver.save).not.toHaveBeenCalled(); + }); + + it("saves persisted state after changes", () => { + const saver = { save: vi.fn() }; + const { result } = renderHook(() => useCollectionVariables({ saver })); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(saver.save).toHaveBeenLastCalledWith({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE", caseSensitive: undefined }], + sortStates: [], + pageSize: 20, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 52c44f7c..f3ad3db2 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,7 +1,8 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { BuildQueryVariables, CollectionControl, + CollectionPersistedState, CollectionVariables, Filter, FilterOperator, @@ -130,8 +131,10 @@ export function useCollectionVariables( export function useCollectionVariables( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, ): unknown { - const { params = {} } = options; - const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; + const { params = {}, initialState, saver } = options; + const initialFilters = initialState?.filters ?? params.initialFilters ?? []; + const initialSort = initialState?.sortStates ?? params.initialSort ?? []; + const initialPageSize = initialState?.pageSize ?? params.pageSize ?? 20; // --------------------------------------------------------------------------- // State @@ -152,6 +155,8 @@ export function useCollectionVariables( getHasNextPage, resetCount, } = useCursorPagination(initialPageSize); + const saverRef = useRef(saver); + const didMountRef = useRef(false); // --------------------------------------------------------------------------- // Filter operations @@ -262,6 +267,23 @@ export function useCollectionVariables( [queryVars, orderVars, paginationVariables], ); + useEffect(() => { + saverRef.current = saver; + }, [saver]); + + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + + saverRef.current?.save({ + filters, + sortStates: sortStates as CollectionPersistedState["sortStates"], + pageSize, + }); + }, [filters, sortStates, pageSize]); + // --------------------------------------------------------------------------- // Return // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-url-collection-state.test.ts b/packages/core/src/hooks/use-url-collection-state.test.ts index bc79b009..d64fd51a 100644 --- a/packages/core/src/hooks/use-url-collection-state.test.ts +++ b/packages/core/src/hooks/use-url-collection-state.test.ts @@ -1,8 +1,9 @@ import { renderHook } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { CollectionControl } from "@/types/collection"; +import type { CollectionControl, TableMetadataMap } from "@/types/collection"; import { useUrlCollectionState, + withURLState, encodeFilterValue, decodeFilterValue, } from "./use-url-collection-state"; @@ -32,6 +33,32 @@ function resolveSetParamsCall(callIndex: number): URLSearchParams { : new URLSearchParams(updaterOrValue); } +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 }, + ], + }, +} 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); +} + function makeControl(overrides?: Partial): CollectionControl { return { filters: [], @@ -255,6 +282,81 @@ describe("useUrlCollectionState", () => { }); }); +describe("withURLState", () => { + it("parses URL state and keeps params defaults intact", () => { + const setSearchParams = vi.fn(); + const options = withURLState( + { + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + }, + [new URLSearchParams("p=50&f.status:eq=active"), setSearchParams], + ); + + expect(options.initialState).toEqual({ + pageSize: 50, + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + expect(options.params).toEqual({ + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }); + }); + + it("filters out URL fields/operators not allowed by tableMetadata", () => { + const options = withURLState({ tableMetadata: tableMetadata.task }, [ + new URLSearchParams( + "s=amount:asc&f.amount:eq=10&f.createdAt:contains=2026&f.status:eq=active", + ), + vi.fn(), + ]); + + expect(options.initialState).toEqual({ + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + }); + + it("merges existing initialState and composes saver", () => { + const setSearchParams = vi.fn(); + const baseSaver = { save: vi.fn() }; + const options = withURLState( + { + tableMetadata: tableMetadata.task, + initialState: { + sortStates: [{ field: "createdAt", direction: "Desc" }], + }, + saver: baseSaver, + }, + [new URLSearchParams("p=25"), setSearchParams], + ); + + expect(options.initialState).toEqual({ + sortStates: [{ field: "createdAt", direction: "Desc" }], + pageSize: 25, + }); + + options.saver?.save({ + filters: [{ field: "status", operator: "eq", value: "pending" }], + sortStates: [{ field: "createdAt", direction: "Desc" }], + pageSize: 30, + }); + + expect(baseSaver.save).toHaveBeenCalledWith({ + filters: [{ field: "status", operator: "eq", value: "pending" }], + sortStates: [{ 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", + ); + }); +}); + // --------------------------------------------------------------------------- // Utility functions // --------------------------------------------------------------------------- diff --git a/packages/core/src/hooks/use-url-collection-state.ts b/packages/core/src/hooks/use-url-collection-state.ts index 20cfa7b6..b4bbad17 100644 --- a/packages/core/src/hooks/use-url-collection-state.ts +++ b/packages/core/src/hooks/use-url-collection-state.ts @@ -1,11 +1,34 @@ import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router"; -import type { CollectionControl, Filter } from "@/types/collection"; +import { + OPERATORS_BY_FILTER_TYPE, + fieldTypeToFilterConfig, + fieldTypeToSortConfig, + type CollectionControl, + type CollectionInitialState, + type CollectionPersistedState, + type Filter, + type TableFieldName, + type TableMetadata, + type TableMetadataFilter, + type UseCollectionOptions, +} 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, +]; + /** * Lifecycle phase of `useUrlCollectionState`. */ @@ -19,13 +42,162 @@ type SyncPhase = /** Normal operation. The write effect actively syncs control state to the URL. */ | "ready"; +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); +} + +/** + * 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; + nextFilters.push({ + field, + operator: operator as Filter["operator"], + value: decodeFilterValue(value), + }); + } + 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 initial state and saving. + */ +export function withURLState( + options: UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; + }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; +}; +export function withURLState( + options: UseCollectionOptions & { + tableMetadata?: never; + }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions; +export function withURLState( + options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + [searchParams, setSearchParams]: SearchParamsBinding, +): UseCollectionOptions & { tableMetadata?: TableMetadata } { + const initialState = options.tableMetadata + ? parseCollectionSearchParams(options.tableMetadata, searchParams) + : parseCollectionSearchParams(searchParams); + + return { + ...options, + initialState: { + ...options.initialState, + ...initialState, + }, + saver: { + save(state) { + options.saver?.save(state); + setSearchParams((prev) => writeCollectionSearchParams(prev, state), { replace: true }); + }, + }, + }; +} + /** * Persists CollectionControl state (filters, sort, page size) to the URL query * string so pages are bookmarkable and the browser back button works. * - * Designed to be entity-agnostic: keys are short and the operator/value - * encoding is derived from the current Filter shape, not hard-coded per field. - * * Cursor/direction state is intentionally NOT persisted — `CollectionControl` * manages cursor state internally and no longer exposes it publicly. We accept * the regression that a refresh resets to page 1. @@ -42,34 +214,16 @@ export function useUrlCollectionState< if (phaseRef.current !== "pending") return; phaseRef.current = "hydrated"; - const pageSize = params.get(KEY_PAGE_SIZE); - if (pageSize) { - const n = Number(pageSize); - if (Number.isFinite(n) && n > 0) control.setPageSize(n); + const initialState = parseCollectionSearchParams(params); + if (initialState.pageSize !== undefined) { + control.setPageSize(initialState.pageSize); } - - const sort = params.get(KEY_SORT); - if (sort) { - const [field, rawDir] = sort.split(":"); - if (field) { - const direction = rawDir === "desc" ? "Desc" : "Asc"; - control.setSort(field as TFieldName, direction); - } + if (initialState.sortStates?.[0]) { + const { field, direction } = initialState.sortStates[0]; + control.setSort(field as TFieldName, direction); } - - 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) continue; - nextFilters.push({ - field: field as TFieldName, - operator, - value: decodeFilterValue(value), - } as Filter); - } - if (nextFilters.length > 0) { - control.setFilters(nextFilters); + if (initialState.filters?.length) { + control.setFilters(initialState.filters as Filter[]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -89,43 +243,15 @@ export function useUrlCollectionState< } setParams( - (prev) => { - const next = new URLSearchParams(prev); - - if (control.pageSize) { - next.set(KEY_PAGE_SIZE, String(control.pageSize)); - } else { - next.delete(KEY_PAGE_SIZE); - } - - if (control.sortStates.length > 0) { - const { field, direction } = control.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 control.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; - }, + (prev) => + writeCollectionSearchParams(prev, { + filters: control.filters, + sortStates: control.sortStates as CollectionPersistedState< + TFieldName, + TFilter + >["sortStates"], + pageSize: control.pageSize, + }), { replace: true }, ); }, [control.pageSize, control.sortStates, control.filters, setParams]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b1668d1..beed2a67 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -154,7 +154,10 @@ export { type PageInfo, type CollectionVariables, type CollectionControl, + type CollectionInitialState, + type CollectionPersistedState, type CollectionResult, + type CollectionSaver, type NodeType, type PaginationVariables, type UseCollectionOptions, @@ -186,7 +189,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useUrlCollectionState } from "./hooks/use-url-collection-state"; +export { useUrlCollectionState, withURLState } from "./hooks/use-url-collection-state"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 0627c92b..4bb8291b 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -350,6 +350,36 @@ 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>; + +/** + * Persists collection state to an external store (e.g. the URL). + */ +export interface CollectionSaver< + TFieldName extends string = string, + TFilter extends Filter = Filter, +> { + save(state: CollectionPersistedState): void; +} + /** * Options for `useCollectionVariables` hook. */ @@ -362,6 +392,8 @@ export interface UseCollectionOptions< initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; pageSize?: number; }; + initialState?: CollectionInitialState; + saver?: CollectionSaver; } /** From 1c9e201524d86b934da0c0e8fe8aeeb9d56c070b Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 19 Jun 2026 16:56:06 +0900 Subject: [PATCH 04/10] refactor(core): move collection URL state helpers to lib --- .../hooks/use-url-collection-state.test.ts | 434 ------------------ packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 235 ++++++++++ .../collection-url-state.ts} | 79 ---- 4 files changed, 236 insertions(+), 514 deletions(-) delete mode 100644 packages/core/src/hooks/use-url-collection-state.test.ts create mode 100644 packages/core/src/lib/collection-url-state.test.ts rename packages/core/src/{hooks/use-url-collection-state.ts => lib/collection-url-state.ts} (74%) diff --git a/packages/core/src/hooks/use-url-collection-state.test.ts b/packages/core/src/hooks/use-url-collection-state.test.ts deleted file mode 100644 index d64fd51a..00000000 --- a/packages/core/src/hooks/use-url-collection-state.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { CollectionControl, TableMetadataMap } from "@/types/collection"; -import { - useUrlCollectionState, - withURLState, - encodeFilterValue, - decodeFilterValue, -} from "./use-url-collection-state"; - -// --------------------------------------------------------------------------- -// Mock react-router's useSearchParams -// --------------------------------------------------------------------------- -let mockParams: URLSearchParams; -const mockSetParams = vi.fn(); - -vi.mock("react-router", () => ({ - useSearchParams: () => [mockParams, mockSetParams], -})); - -/** - * Helper: the hook now calls setParams with a function updater. - * This helper resolves the updater against mockParams to get the resulting params. - */ -function resolveSetParamsCall(callIndex: number): URLSearchParams { - const [updaterOrValue] = mockSetParams.mock.calls[callIndex]; - if (typeof updaterOrValue === "function") { - const result = updaterOrValue(mockParams); - return result instanceof URLSearchParams ? result : new URLSearchParams(result); - } - return updaterOrValue instanceof URLSearchParams - ? updaterOrValue - : new URLSearchParams(updaterOrValue); -} - -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 }, - ], - }, -} 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); -} - -function makeControl(overrides?: Partial): CollectionControl { - return { - filters: [], - addFilter: vi.fn(), - setFilters: vi.fn(), - removeFilter: vi.fn(), - clearFilters: vi.fn(), - sortStates: [], - setSort: vi.fn(), - clearSort: vi.fn(), - pageSize: 20, - setPageSize: vi.fn(), - goToNextPage: vi.fn(), - goToPrevPage: vi.fn(), - resetPage: vi.fn(), - goToFirstPage: vi.fn(), - goToLastPage: vi.fn(), - getHasPrevPage: vi.fn(), - getHasNextPage: vi.fn(), - resetCount: 0, - ...overrides, - }; -} - -describe("useUrlCollectionState", () => { - beforeEach(() => { - mockParams = new URLSearchParams(); - mockSetParams.mockClear(); - }); - - // --------------------------------------------------------------------------- - // Hydration from URL - // --------------------------------------------------------------------------- - describe("hydration from URL", () => { - it("hydrates pageSize from URL param", () => { - mockParams = new URLSearchParams("p=50"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setPageSize).toHaveBeenCalledWith(50); - }); - - it("ignores invalid pageSize values", () => { - mockParams = new URLSearchParams("p=abc"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setPageSize).not.toHaveBeenCalled(); - }); - - it("ignores non-positive pageSize values", () => { - mockParams = new URLSearchParams("p=-5"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setPageSize).not.toHaveBeenCalled(); - }); - - it("hydrates sort from URL param (asc)", () => { - mockParams = new URLSearchParams("s=name:asc"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setSort).toHaveBeenCalledWith("name", "Asc"); - }); - - it("hydrates sort from URL param (desc)", () => { - mockParams = new URLSearchParams("s=createdAt:desc"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setSort).toHaveBeenCalledWith("createdAt", "Desc"); - }); - - it("hydrates filters from URL params", () => { - mockParams = new URLSearchParams("f.status:eq=active&f.priority:gt=3"); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setFilters).toHaveBeenCalledWith([ - { field: "status", operator: "eq", value: "active" }, - { field: "priority", operator: "gt", value: "3" }, - ]); - }); - - it("hydrates array filter values (JSON format)", () => { - mockParams = new URLSearchParams('f.status:in=["active","pending","closed"]'); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setFilters).toHaveBeenCalledWith([ - { - field: "status", - operator: "in", - value: ["active", "pending", "closed"], - }, - ]); - }); - - it("hydrates object filter values (between {min,max} JSON format)", () => { - mockParams = new URLSearchParams( - 'f.createdAt:between={"min":"2026-01-01","max":"2026-12-31"}', - ); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setFilters).toHaveBeenCalledWith([ - { - field: "createdAt", - operator: "between", - value: { min: "2026-01-01", max: "2026-12-31" }, - }, - ]); - }); - - it("skips filters with missing value", () => { - mockParams = new URLSearchParams("f.status:eq="); - const control = makeControl(); - renderHook(() => useUrlCollectionState(control)); - expect(control.setFilters).not.toHaveBeenCalled(); - }); - - it("does not hydrate twice on re-render", () => { - mockParams = new URLSearchParams("p=30"); - const control = makeControl(); - const { rerender } = renderHook(() => useUrlCollectionState(control)); - rerender(); - expect(control.setPageSize).toHaveBeenCalledTimes(1); - }); - }); - - // --------------------------------------------------------------------------- - // Writing state to URL - // --------------------------------------------------------------------------- - describe("writing state to URL", () => { - it("writes pageSize to URL when control state changes", () => { - // Start with default, then simulate a user changing pageSize - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl({ pageSize: 20 }) }, - }); - // First render: write effect is skipped (skipWriteRef). - // Simulate user changing pageSize → triggers dep change → write effect fires. - rerender({ control: makeControl({ pageSize: 25 }) }); - const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); - expect(nextParams.get("p")).toBe("25"); - }); - - it("writes sort to URL when control state changes", () => { - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl() }, - }); - rerender({ - control: makeControl({ - sortStates: [{ field: "name", direction: "Desc" }], - }), - }); - const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); - expect(nextParams.get("s")).toBe("name:desc"); - }); - - it("writes filters to URL when control state changes", () => { - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl() }, - }); - rerender({ - control: makeControl({ - filters: [{ field: "status", operator: "eq" as never, value: "active" }], - }), - }); - const nextParams = resolveSetParamsCall(mockSetParams.mock.calls.length - 1); - expect(nextParams.get("f.status:eq")).toBe("active"); - }); - - it("returns prev params unchanged when URL is already in sync", () => { - mockParams = new URLSearchParams("p=20"); - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl({ pageSize: 20 }) }, - }); - // Change a non-relevant field to trigger the effect - rerender({ - control: makeControl({ - pageSize: 20, - sortStates: [], - filters: [], - }), - }); - const lastCallIndex = mockSetParams.mock.calls.length - 1; - const [updater] = mockSetParams.mock.calls[lastCallIndex]; - if (typeof updater === "function") { - const result = updater(mockParams); - // When no change, it should return the original prev instance - expect(result).toBe(mockParams); - } - }); - - it("treats reordered params as unchanged (order-insensitive compare)", () => { - // prev URL holds filters in b,a order; control emits the same multiset in - // a,b order. The delete-then-set rebuild flips key order, but the param - // multiset is identical, so the write must bail out and return prev. - // A plain `.toString()` compare would see a difference here and navigate. - mockParams = new URLSearchParams("f.b:eq=2&f.a:eq=1&p=20"); - const filters = [ - { field: "a", operator: "eq" as never, value: "1" }, - { field: "b", operator: "eq" as never, value: "2" }, - ]; - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl({ pageSize: 20, filters }) }, - }); - // Re-render with a fresh control (new array refs) to fire the write effect. - rerender({ control: makeControl({ pageSize: 20, filters: [...filters] }) }); - const lastCallIndex = mockSetParams.mock.calls.length - 1; - const [updater] = mockSetParams.mock.calls[lastCallIndex]; - expect(typeof updater).toBe("function"); - if (typeof updater === "function") { - const result = updater(mockParams); - expect(result).toBe(mockParams); - } - }); - - it("uses replace: true for setParams", () => { - const { rerender } = renderHook(({ control }) => useUrlCollectionState(control), { - initialProps: { control: makeControl({ pageSize: 20 }) }, - }); - rerender({ control: makeControl({ pageSize: 30 }) }); - const lastCallIndex = mockSetParams.mock.calls.length - 1; - const [, options] = mockSetParams.mock.calls[lastCallIndex]; - expect(options).toEqual({ replace: true }); - }); - }); -}); - -describe("withURLState", () => { - it("parses URL state and keeps params defaults intact", () => { - const setSearchParams = vi.fn(); - const options = withURLState( - { - tableMetadata: tableMetadata.task, - params: { - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }, - }, - [new URLSearchParams("p=50&f.status:eq=active"), setSearchParams], - ); - - expect(options.initialState).toEqual({ - pageSize: 50, - filters: [{ field: "status", operator: "eq", value: "active" }], - }); - expect(options.params).toEqual({ - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }); - }); - - it("filters out URL fields/operators not allowed by tableMetadata", () => { - const options = withURLState({ tableMetadata: tableMetadata.task }, [ - new URLSearchParams( - "s=amount:asc&f.amount:eq=10&f.createdAt:contains=2026&f.status:eq=active", - ), - vi.fn(), - ]); - - expect(options.initialState).toEqual({ - filters: [{ field: "status", operator: "eq", value: "active" }], - }); - }); - - it("merges existing initialState and composes saver", () => { - const setSearchParams = vi.fn(); - const baseSaver = { save: vi.fn() }; - const options = withURLState( - { - tableMetadata: tableMetadata.task, - initialState: { - sortStates: [{ field: "createdAt", direction: "Desc" }], - }, - saver: baseSaver, - }, - [new URLSearchParams("p=25"), setSearchParams], - ); - - expect(options.initialState).toEqual({ - sortStates: [{ field: "createdAt", direction: "Desc" }], - pageSize: 25, - }); - - options.saver?.save({ - filters: [{ field: "status", operator: "eq", value: "pending" }], - sortStates: [{ field: "createdAt", direction: "Desc" }], - pageSize: 30, - }); - - expect(baseSaver.save).toHaveBeenCalledWith({ - filters: [{ field: "status", operator: "eq", value: "pending" }], - sortStates: [{ 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", - ); - }); -}); - -// --------------------------------------------------------------------------- -// Utility functions -// --------------------------------------------------------------------------- -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", () => { - // These parse as JSON (number/boolean) but must stay strings so operator - // implementations keep their string-vs-numeric semantics. - 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/index.ts b/packages/core/src/index.ts index beed2a67..bfe3b32a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,7 +189,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { useUrlCollectionState, withURLState } from "./hooks/use-url-collection-state"; +export { withURLState } 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..12c318e2 --- /dev/null +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from "vitest"; +import type { CollectionPersistedState, TableMetadataMap } from "@/types/collection"; +import { + withURLState, + 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 }, + ], + }, +} 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"] }], + }); + }); +}); + +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); + }); +}); + +describe("withURLState", () => { + it("parses URL state and keeps params defaults intact", () => { + const setSearchParams = vi.fn(); + const options = withURLState( + { + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + }, + [new URLSearchParams("p=50&f.status:eq=active"), setSearchParams], + ); + + expect(options.initialState).toEqual({ + pageSize: 50, + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + expect(options.params).toEqual({ + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }); + }); + + it("merges existing initialState and composes saver", () => { + const setSearchParams = vi.fn(); + const baseSaver = { save: vi.fn() }; + const options = withURLState( + { + tableMetadata: tableMetadata.task, + initialState: { + sortStates: [{ field: "createdAt", direction: "Desc" }], + }, + saver: baseSaver, + }, + [new URLSearchParams("p=25"), setSearchParams], + ); + + expect(options.initialState).toEqual({ + sortStates: [{ field: "createdAt", direction: "Desc" }], + pageSize: 25, + }); + + options.saver?.save({ + filters: [{ field: "status", operator: "eq", value: "pending" }], + sortStates: [{ field: "createdAt", direction: "Desc" }], + pageSize: 30, + }); + + expect(baseSaver.save).toHaveBeenCalledWith({ + filters: [{ field: "status", operator: "eq", value: "pending" }], + sortStates: [{ 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("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/hooks/use-url-collection-state.ts b/packages/core/src/lib/collection-url-state.ts similarity index 74% rename from packages/core/src/hooks/use-url-collection-state.ts rename to packages/core/src/lib/collection-url-state.ts index b4bbad17..a62098b9 100644 --- a/packages/core/src/hooks/use-url-collection-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -1,10 +1,7 @@ -import { useEffect, useRef } from "react"; -import { useSearchParams } from "react-router"; import { OPERATORS_BY_FILTER_TYPE, fieldTypeToFilterConfig, fieldTypeToSortConfig, - type CollectionControl, type CollectionInitialState, type CollectionPersistedState, type Filter, @@ -29,19 +26,6 @@ export type SearchParamsBinding = readonly [ ) => void, ]; -/** - * Lifecycle phase of `useUrlCollectionState`. - */ -type SyncPhase = - /** Initial state. Hydration has not run yet. */ - | "pending" - /** Hydration complete. The first write effect should be skipped because - * control state set during hydration (via setState) won't be reflected - * until the next render. */ - | "hydrated" - /** Normal operation. The write effect actively syncs control state to the URL. */ - | "ready"; - function isValidSortField(tableMetadata: TableMetadata | undefined, field: string): boolean { if (!tableMetadata) return true; const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); @@ -194,69 +178,6 @@ export function withURLState( }; } -/** - * Persists CollectionControl state (filters, sort, page size) to the URL query - * string so pages are bookmarkable and the browser back button works. - * - * Cursor/direction state is intentionally NOT persisted — `CollectionControl` - * manages cursor state internally and no longer exposes it publicly. We accept - * the regression that a refresh resets to page 1. - */ -export function useUrlCollectionState< - TFieldName extends string, - TFilter extends Filter, ->(control: CollectionControl): void { - const [params, setParams] = useSearchParams(); - const phaseRef = useRef("pending"); - - // Hydrate control from URL on first render. - useEffect(() => { - if (phaseRef.current !== "pending") return; - phaseRef.current = "hydrated"; - - const initialState = parseCollectionSearchParams(params); - if (initialState.pageSize !== undefined) { - control.setPageSize(initialState.pageSize); - } - if (initialState.sortStates?.[0]) { - const { field, direction } = initialState.sortStates[0]; - control.setSort(field as TFieldName, direction); - } - if (initialState.filters?.length) { - control.setFilters(initialState.filters as Filter[]); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Write control state back to URL whenever it changes. - // Uses the setParams function updater to avoid depending on `params` in the - // dependency array, which would cause a feedback loop: - // setParams → params change → effect re-runs → setParams (no-op but wasteful) - useEffect(() => { - if (phaseRef.current === "pending") return; - // Skip the first write cycle that fires immediately after hydration. - // Control state set during hydration (setPageSize, setSort, etc.) is async - // and won't be reflected until the next render. - if (phaseRef.current === "hydrated") { - phaseRef.current = "ready"; - return; - } - - setParams( - (prev) => - writeCollectionSearchParams(prev, { - filters: control.filters, - sortStates: control.sortStates as CollectionPersistedState< - TFieldName, - TFilter - >["sortStates"], - pageSize: control.pageSize, - }), - { replace: true }, - ); - }, [control.pageSize, control.sortStates, control.filters, setParams]); -} - /** * Encodes a filter value for URL storage. * - Arrays are JSON-encoded to avoid ambiguity with values that contain commas. From bb9ef72effbc00b2ac0c0be3810b29672a0dd80d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 19 Jun 2026 17:13:25 +0900 Subject: [PATCH 05/10] Refine URL collection state API --- .changeset/witty-mice-drum.md | 14 ++++++++++++++ .../src/modules/pages/data-table-demo.tsx | 7 +++---- .../src/hooks/use-collection-variables.test-d.ts | 13 +++++++++++++ .../core/src/hooks/use-collection-variables.ts | 15 ++------------- packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 11 ++++++++--- packages/core/src/lib/collection-url-state.ts | 11 +++++++---- packages/core/src/types/collection.ts | 14 ++++++++++++++ 8 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 .changeset/witty-mice-drum.md diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md new file mode 100644 index 00000000..9ff9ace3 --- /dev/null +++ b/.changeset/witty-mice-drum.md @@ -0,0 +1,14 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `withURLCollectionState` as the preferred name for wiring collection state to the URL in `useCollectionVariables`. + +`withURLState` remains available as a deprecated alias. + +```tsx +const searchParams = useSearchParams(); +const { variables, control } = useCollectionVariables( + withURLCollectionState({ tableMetadata, params: { pageSize: 20 } }, searchParams), +); +``` 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 6196062f..13ada54a 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -3,11 +3,10 @@ import { DataTable, useDataTable, useCollectionVariables, - withURLState, + withURLCollectionState, useSearchParams, createColumnHelper, Layout, - type CollectionVariables, type RowAction, } from "@tailor-platform/app-shell"; import { useState } from "react"; @@ -145,7 +144,7 @@ const productRowActions: RowAction[] = [ const DataTableDemoPage = () => { const searchParams = useSearchParams(); const { variables, control } = useCollectionVariables( - withURLState( + withURLCollectionState( { params: { pageSize: 5 }, tableMetadata: productMetadata, @@ -153,7 +152,7 @@ const DataTableDemoPage = () => { searchParams, ), ); - const { data, loading } = useProductsQuery(variables as CollectionVariables); + const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); const table = useDataTable({ 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.ts b/packages/core/src/hooks/use-collection-variables.ts index f3ad3db2..0373c573 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,17 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { - BuildQueryVariables, CollectionControl, CollectionPersistedState, CollectionVariables, Filter, FilterOperator, - PaginationVariables, SortState, TableFieldName, TableMetadata, TableMetadataFilter, - TableOrderableFieldName, + TypedCollectionVariables, UseCollectionOptions, UseCollectionReturn, } from "@/types/collection"; @@ -86,16 +84,7 @@ export function useCollectionVariables( }, ): UseCollectionReturn< TableFieldName, - { - query: BuildQueryVariables | undefined; - order: - | { - field: TableOrderableFieldName; - direction: "Asc" | "Desc"; - }[] - | undefined; - pagination: PaginationVariables; - }, + TypedCollectionVariables, TableMetadataFilter >; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bfe3b32a..445c2bca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,7 +189,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { withURLState } from "./lib/collection-url-state"; +export { withURLCollectionState, withURLState } 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 12c318e2..a04a481d 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import type { CollectionPersistedState, TableMetadataMap } from "@/types/collection"; import { + withURLCollectionState, withURLState, encodeFilterValue, decodeFilterValue, @@ -101,10 +102,10 @@ describe("writeCollectionSearchParams", () => { }); }); -describe("withURLState", () => { +describe("withURLCollectionState", () => { it("parses URL state and keeps params defaults intact", () => { const setSearchParams = vi.fn(); - const options = withURLState( + const options = withURLCollectionState( { tableMetadata: tableMetadata.task, params: { @@ -128,7 +129,7 @@ describe("withURLState", () => { it("merges existing initialState and composes saver", () => { const setSearchParams = vi.fn(); const baseSaver = { save: vi.fn() }; - const options = withURLState( + const options = withURLCollectionState( { tableMetadata: tableMetadata.task, initialState: { @@ -161,6 +162,10 @@ describe("withURLState", () => { "p=30&s=createdAt%3Adesc&f.status%3Aeq=pending", ); }); + + it("keeps withURLState as a deprecated alias", () => { + expect(withURLState).toBe(withURLCollectionState); + }); }); describe("encodeFilterValue", () => { diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index a62098b9..1a7e70fc 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -139,9 +139,9 @@ export function writeCollectionSearchParams< } /** - * Decorate `useCollectionVariables` options with URL-backed initial state and saving. + * Decorate `useCollectionVariables` options with URL-backed collection state. */ -export function withURLState( +export function withURLCollectionState( options: UseCollectionOptions, TableMetadataFilter> & { tableMetadata: TTable; }, @@ -149,13 +149,13 @@ export function withURLState( ): UseCollectionOptions, TableMetadataFilter> & { tableMetadata: TTable; }; -export function withURLState( +export function withURLCollectionState( options: UseCollectionOptions & { tableMetadata?: never; }, [searchParams, setSearchParams]: SearchParamsBinding, ): UseCollectionOptions; -export function withURLState( +export function withURLCollectionState( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, [searchParams, setSearchParams]: SearchParamsBinding, ): UseCollectionOptions & { tableMetadata?: TableMetadata } { @@ -178,6 +178,9 @@ export function withURLState( }; } +/** @deprecated use withURLCollectionState */ +export const withURLState = withURLCollectionState; + /** * Encodes a filter value for URL storage. * - Arrays are JSON-encoded to avoid ambiguity with values that contain commas. diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 4bb8291b..6d57f20d 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) // ============================================================================= From 514e836afc1aa6982863eab108b4fd12487c266c Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 19 Jun 2026 17:17:55 +0900 Subject: [PATCH 06/10] Remove withURLState alias --- .changeset/witty-mice-drum.md | 4 +--- packages/core/src/index.ts | 2 +- packages/core/src/lib/collection-url-state.test.ts | 5 ----- packages/core/src/lib/collection-url-state.ts | 3 --- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md index 9ff9ace3..7c7e49fe 100644 --- a/.changeset/witty-mice-drum.md +++ b/.changeset/witty-mice-drum.md @@ -2,9 +2,7 @@ "@tailor-platform/app-shell": minor --- -Add `withURLCollectionState` as the preferred name for wiring collection state to the URL in `useCollectionVariables`. - -`withURLState` remains available as a deprecated alias. +Add `withURLCollectionState` for wiring collection state to the URL in `useCollectionVariables`. ```tsx const searchParams = useSearchParams(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 445c2bca..2eb596bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,7 +189,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { withURLCollectionState, withURLState } from "./lib/collection-url-state"; +export { 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 a04a481d..2b083b06 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest"; import type { CollectionPersistedState, TableMetadataMap } from "@/types/collection"; import { withURLCollectionState, - withURLState, encodeFilterValue, decodeFilterValue, parseCollectionSearchParams, @@ -162,10 +161,6 @@ describe("withURLCollectionState", () => { "p=30&s=createdAt%3Adesc&f.status%3Aeq=pending", ); }); - - it("keeps withURLState as a deprecated alias", () => { - expect(withURLState).toBe(withURLCollectionState); - }); }); describe("encodeFilterValue", () => { diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index 1a7e70fc..e0b0cf47 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -178,9 +178,6 @@ export function withURLCollectionState( }; } -/** @deprecated use withURLCollectionState */ -export const withURLState = withURLCollectionState; - /** * Encodes a filter value for URL storage. * - Arrays are JSON-encoded to avoid ambiguity with values that contain commas. From 1c99e572831d5f80300c3482dc98a0b8613a435e Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 22 Jun 2026 10:25:49 +0900 Subject: [PATCH 07/10] Add useURLCollectionState hook --- .changeset/witty-mice-drum.md | 11 +++--- .../src/modules/pages/data-table-demo.tsx | 18 ++++------ packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 36 +++++++++++++++++++ packages/core/src/lib/collection-url-state.ts | 30 ++++++++++++++++ 5 files changed, 79 insertions(+), 18 deletions(-) diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md index 7c7e49fe..c98b76ff 100644 --- a/.changeset/witty-mice-drum.md +++ b/.changeset/witty-mice-drum.md @@ -2,11 +2,12 @@ "@tailor-platform/app-shell": minor --- -Add `withURLCollectionState` for wiring collection state to the URL in `useCollectionVariables`. +Add `withURLCollectionState` and `useURLCollectionState` for wiring collection state to the URL in `useCollectionVariables`. ```tsx -const searchParams = useSearchParams(); -const { variables, control } = useCollectionVariables( - withURLCollectionState({ tableMetadata, params: { pageSize: 20 } }, searchParams), -); +const collectionState = useURLCollectionState({ + tableMetadata, + params: { pageSize: 20 }, +}); +const { variables, control } = useCollectionVariables(collectionState); ``` 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 13ada54a..47cc2a73 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -3,8 +3,7 @@ import { DataTable, useDataTable, useCollectionVariables, - withURLCollectionState, - useSearchParams, + useURLCollectionState, createColumnHelper, Layout, type RowAction, @@ -142,16 +141,11 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const searchParams = useSearchParams(); - const { variables, control } = useCollectionVariables( - withURLCollectionState( - { - params: { pageSize: 5 }, - tableMetadata: productMetadata, - }, - searchParams, - ), - ); + const collectionState = useURLCollectionState({ + params: { pageSize: 5 }, + tableMetadata: productMetadata, + }); + const { variables, control } = useCollectionVariables(collectionState); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2eb596bc..d4842661 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -189,7 +189,7 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; -export { withURLCollectionState } from "./lib/collection-url-state"; +export { useURLCollectionState, 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 2b083b06..4e6b11e2 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -1,6 +1,11 @@ +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 { + useURLCollectionState, withURLCollectionState, encodeFilterValue, decodeFilterValue, @@ -101,6 +106,10 @@ describe("writeCollectionSearchParams", () => { }); }); +function SearchParamsWrapper({ children }: PropsWithChildren) { + return createElement(MemoryRouter, { initialEntries: ["/?p=50&f.status:eq=active"] }, children); +} + describe("withURLCollectionState", () => { it("parses URL state and keeps params defaults intact", () => { const setSearchParams = vi.fn(); @@ -163,6 +172,33 @@ describe("withURLCollectionState", () => { }); }); +describe("useURLCollectionState", () => { + it("binds useSearchParams and returns collection options", () => { + const { result } = renderHook( + () => + useURLCollectionState({ + tableMetadata: tableMetadata.task, + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }, + }), + { + wrapper: SearchParamsWrapper, + }, + ); + + expect(result.current.initialState).toEqual({ + pageSize: 50, + filters: [{ field: "status", operator: "eq", value: "active" }], + }); + expect(result.current.params).toEqual({ + initialSort: [{ field: "createdAt", direction: "Desc" }], + pageSize: 20, + }); + }); +}); + describe("encodeFilterValue", () => { it("encodes strings", () => { expect(encodeFilterValue("hello")).toBe("hello"); diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index e0b0cf47..bfacb645 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -1,3 +1,4 @@ +import { useSearchParams } from "react-router"; import { OPERATORS_BY_FILTER_TYPE, fieldTypeToFilterConfig, @@ -156,6 +157,13 @@ export function withURLCollectionState( [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 } { @@ -178,6 +186,28 @@ export function withURLCollectionState( }; } +/** + * Hook version of `withURLCollectionState()` that binds the current router + * search params. + */ +export function useURLCollectionState( + options: UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; + }, +): UseCollectionOptions, TableMetadataFilter> & { + tableMetadata: TTable; +}; +export function useURLCollectionState( + options: UseCollectionOptions & { + tableMetadata?: never; + }, +): UseCollectionOptions; +export function useURLCollectionState( + options: UseCollectionOptions & { tableMetadata?: TableMetadata }, +): UseCollectionOptions & { tableMetadata?: TableMetadata } { + return applyURLCollectionState(options, useSearchParams()); +} + /** * Encodes a filter value for URL storage. * - Arrays are JSON-encoded to avoid ambiguity with values that contain commas. From 25fa41d5a458c227450f9a4e69f1b5f35cf4c02d Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 22 Jun 2026 10:56:46 +0900 Subject: [PATCH 08/10] Make useURLCollectionState return a decorator --- .changeset/witty-mice-drum.md | 12 ++++--- .../src/modules/pages/data-table-demo.tsx | 12 ++++--- .../core/src/lib/collection-url-state.test.ts | 28 +++++++-------- packages/core/src/lib/collection-url-state.ts | 36 +++++++++---------- 4 files changed, 45 insertions(+), 43 deletions(-) diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md index c98b76ff..0a67045f 100644 --- a/.changeset/witty-mice-drum.md +++ b/.changeset/witty-mice-drum.md @@ -5,9 +5,11 @@ Add `withURLCollectionState` and `useURLCollectionState` for wiring collection state to the URL in `useCollectionVariables`. ```tsx -const collectionState = useURLCollectionState({ - tableMetadata, - params: { pageSize: 20 }, -}); -const { variables, control } = useCollectionVariables(collectionState); +const withURLCollectionState = useURLCollectionState(); +const { variables, control } = useCollectionVariables( + withURLCollectionState({ + tableMetadata, + params: { pageSize: 20 }, + }), +); ``` 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 47cc2a73..4e959c1e 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -141,11 +141,13 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { - const collectionState = useURLCollectionState({ - params: { pageSize: 5 }, - tableMetadata: productMetadata, - }); - const { variables, control } = useCollectionVariables(collectionState); + const withURLCollectionState = useURLCollectionState(); + const { variables, control } = useCollectionVariables( + withURLCollectionState({ + params: { pageSize: 5 }, + tableMetadata: productMetadata, + }), + ); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/lib/collection-url-state.test.ts b/packages/core/src/lib/collection-url-state.test.ts index 4e6b11e2..71ac02b4 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -173,26 +173,24 @@ describe("withURLCollectionState", () => { }); describe("useURLCollectionState", () => { - it("binds useSearchParams and returns collection options", () => { - const { result } = renderHook( - () => - useURLCollectionState({ - tableMetadata: tableMetadata.task, - params: { - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }, - }), - { - wrapper: SearchParamsWrapper, + 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, }, - ); + }); - expect(result.current.initialState).toEqual({ + expect(options.initialState).toEqual({ pageSize: 50, filters: [{ field: "status", operator: "eq", value: "active" }], }); - expect(result.current.params).toEqual({ + expect(options.params).toEqual({ initialSort: [{ field: "createdAt", direction: "Desc" }], pageSize: 20, }); diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index bfacb645..fbfeb2f0 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -27,6 +27,17 @@ 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); @@ -187,25 +198,14 @@ function applyURLCollectionState( } /** - * Hook version of `withURLCollectionState()` that binds the current router - * search params. + * Hook that binds the current router search params and returns a + * `withURLCollectionState()`-compatible decorator. */ -export function useURLCollectionState( - options: UseCollectionOptions, TableMetadataFilter> & { - tableMetadata: TTable; - }, -): UseCollectionOptions, TableMetadataFilter> & { - tableMetadata: TTable; -}; -export function useURLCollectionState( - options: UseCollectionOptions & { - tableMetadata?: never; - }, -): UseCollectionOptions; -export function useURLCollectionState( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, -): UseCollectionOptions & { tableMetadata?: TableMetadata } { - return applyURLCollectionState(options, useSearchParams()); +export function useURLCollectionState(): URLCollectionStateDecorator { + const searchParamsBinding = useSearchParams(); + + return ((options: UseCollectionOptions & { tableMetadata?: TableMetadata }) => + applyURLCollectionState(options, searchParamsBinding)) as URLCollectionStateDecorator; } /** From 874480d41494ad55617596c1bab5a07fb2e0387b Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 22 Jun 2026 13:44:34 +0900 Subject: [PATCH 09/10] Align collection state params callbacks --- .changeset/witty-mice-drum.md | 2 + .../hooks/use-collection-variables.test.ts | 50 ++++++------------- .../src/hooks/use-collection-variables.ts | 21 ++++---- packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 42 +++++++--------- packages/core/src/lib/collection-url-state.ts | 40 +++++++++++---- packages/core/src/types/collection.ts | 17 +++---- 7 files changed, 84 insertions(+), 90 deletions(-) diff --git a/.changeset/witty-mice-drum.md b/.changeset/witty-mice-drum.md index 0a67045f..2a7c2148 100644 --- a/.changeset/witty-mice-drum.md +++ b/.changeset/witty-mice-drum.md @@ -4,6 +4,8 @@ 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. + ```tsx const withURLCollectionState = useURLCollectionState(); const { variables, control } = useCollectionVariables( diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index ef4ed8f6..ad0e7800 100644 --- a/packages/core/src/hooks/use-collection-variables.test.ts +++ b/packages/core/src/hooks/use-collection-variables.test.ts @@ -56,42 +56,20 @@ describe("useCollectionVariables", () => { }); }); - it("prefers initialState over params", () => { + it("applies params together", () => { const { result } = renderHook(() => useCollectionVariables({ params: { initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }], initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }, - initialState: { - filters: [{ field: "status", operator: "eq", value: "INACTIVE" }], - sortStates: [{ field: "name", direction: "Asc" }], pageSize: 50, }, }), ); expect(result.current.control.filters).toEqual([ - { field: "status", operator: "eq", value: "INACTIVE" }, + { field: "status", operator: "eq", value: "ACTIVE" }, ]); - expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]); - expect(result.current.variables.pagination).toEqual({ first: 50 }); - }); - - it("falls back to params for keys missing from initialState", () => { - const { result } = renderHook(() => - useCollectionVariables({ - params: { - initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, - }, - initialState: { - pageSize: 50, - }, - }), - ); - expect(result.current.control.sortStates).toEqual([ { field: "createdAt", direction: "Desc" }, ]); @@ -99,24 +77,26 @@ describe("useCollectionVariables", () => { }); }); - describe("saver", () => { - it("does not save on initial render", () => { - const saver = { save: vi.fn() }; - renderHook(() => useCollectionVariables({ saver })); - expect(saver.save).not.toHaveBeenCalled(); + describe("onParamsChange", () => { + it("does not notify on initial render", () => { + const onParamsChange = vi.fn(); + renderHook(() => useCollectionVariables({ onParamsChange })); + expect(onParamsChange).not.toHaveBeenCalled(); }); - it("saves persisted state after changes", () => { - const saver = { save: vi.fn() }; - const { result } = renderHook(() => useCollectionVariables({ saver })); + it("notifies with params after changes", () => { + const onParamsChange = vi.fn(); + const { result } = renderHook(() => useCollectionVariables({ onParamsChange })); act(() => { result.current.control.addFilter("status", "eq", "ACTIVE"); }); - expect(saver.save).toHaveBeenLastCalledWith({ - filters: [{ field: "status", operator: "eq", value: "ACTIVE", caseSensitive: undefined }], - sortStates: [], + 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 0373c573..3102fde3 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CollectionControl, - CollectionPersistedState, CollectionVariables, Filter, FilterOperator, @@ -120,10 +119,10 @@ export function useCollectionVariables( export function useCollectionVariables( options: UseCollectionOptions & { tableMetadata?: TableMetadata }, ): unknown { - const { params = {}, initialState, saver } = options; - const initialFilters = initialState?.filters ?? params.initialFilters ?? []; - const initialSort = initialState?.sortStates ?? params.initialSort ?? []; - const initialPageSize = initialState?.pageSize ?? params.pageSize ?? 20; + const { params = {}, onParamsChange } = options; + const initialFilters = params.initialFilters ?? []; + const initialSort = params.initialSort ?? []; + const initialPageSize = params.pageSize ?? 20; // --------------------------------------------------------------------------- // State @@ -144,7 +143,7 @@ export function useCollectionVariables( getHasNextPage, resetCount, } = useCursorPagination(initialPageSize); - const saverRef = useRef(saver); + const onParamsChangeRef = useRef(onParamsChange); const didMountRef = useRef(false); // --------------------------------------------------------------------------- @@ -257,8 +256,8 @@ export function useCollectionVariables( ); useEffect(() => { - saverRef.current = saver; - }, [saver]); + onParamsChangeRef.current = onParamsChange; + }, [onParamsChange]); useEffect(() => { if (!didMountRef.current) { @@ -266,9 +265,9 @@ export function useCollectionVariables( return; } - saverRef.current?.save({ - filters, - sortStates: sortStates as CollectionPersistedState["sortStates"], + onParamsChangeRef.current?.({ + initialFilters: filters, + initialSort: sortStates, pageSize, }); }, [filters, sortStates, pageSize]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4842661..65b836ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,9 +155,9 @@ export { type CollectionVariables, type CollectionControl, type CollectionInitialState, + type CollectionParams, type CollectionPersistedState, type CollectionResult, - type CollectionSaver, type NodeType, type PaginationVariables, type UseCollectionOptions, diff --git a/packages/core/src/lib/collection-url-state.test.ts b/packages/core/src/lib/collection-url-state.test.ts index 71ac02b4..4ec3decc 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -111,7 +111,7 @@ function SearchParamsWrapper({ children }: PropsWithChildren) { } describe("withURLCollectionState", () => { - it("parses URL state and keeps params defaults intact", () => { + it("parses URL state and merges it into params", () => { const setSearchParams = vi.fn(); const options = withURLCollectionState( { @@ -124,44 +124,41 @@ describe("withURLCollectionState", () => { [new URLSearchParams("p=50&f.status:eq=active"), setSearchParams], ); - expect(options.initialState).toEqual({ - pageSize: 50, - filters: [{ field: "status", operator: "eq", value: "active" }], - }); expect(options.params).toEqual({ + initialFilters: [{ field: "status", operator: "eq", value: "active" }], initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, + pageSize: 50, }); }); - it("merges existing initialState and composes saver", () => { + it("merges URL state into params and composes onParamsChange", () => { const setSearchParams = vi.fn(); - const baseSaver = { save: vi.fn() }; + const onParamsChange = vi.fn(); const options = withURLCollectionState( { tableMetadata: tableMetadata.task, - initialState: { - sortStates: [{ field: "createdAt", direction: "Desc" }], + params: { + initialSort: [{ field: "createdAt", direction: "Desc" }], }, - saver: baseSaver, + onParamsChange, }, [new URLSearchParams("p=25"), setSearchParams], ); - expect(options.initialState).toEqual({ - sortStates: [{ field: "createdAt", direction: "Desc" }], + expect(options.params).toEqual({ + initialSort: [{ field: "createdAt", direction: "Desc" }], pageSize: 25, }); - options.saver?.save({ - filters: [{ field: "status", operator: "eq", value: "pending" }], - sortStates: [{ field: "createdAt", direction: "Desc" }], + options.onParamsChange?.({ + initialFilters: [{ field: "status", operator: "eq", value: "pending" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], pageSize: 30, }); - expect(baseSaver.save).toHaveBeenCalledWith({ - filters: [{ field: "status", operator: "eq", value: "pending" }], - sortStates: [{ field: "createdAt", direction: "Desc" }], + expect(onParamsChange).toHaveBeenCalledWith({ + initialFilters: [{ field: "status", operator: "eq", value: "pending" }], + initialSort: [{ field: "createdAt", direction: "Desc" }], pageSize: 30, }); expect(setSearchParams).toHaveBeenCalledTimes(1); @@ -186,13 +183,10 @@ describe("useURLCollectionState", () => { }, }); - expect(options.initialState).toEqual({ - pageSize: 50, - filters: [{ field: "status", operator: "eq", value: "active" }], - }); expect(options.params).toEqual({ + initialFilters: [{ field: "status", operator: "eq", value: "active" }], initialSort: [{ field: "createdAt", direction: "Desc" }], - pageSize: 20, + pageSize: 50, }); }); }); diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index fbfeb2f0..b221796a 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -184,19 +184,41 @@ function applyURLCollectionState( return { ...options, - initialState: { - ...options.initialState, - ...initialState, - }, - saver: { - save(state) { - options.saver?.save(state); - setSearchParams((prev) => writeCollectionSearchParams(prev, state), { replace: true }); - }, + 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 that binds the current router search params and returns a * `withURLCollectionState()`-compatible decorator. diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 6d57f20d..3bfc3a0c 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -385,13 +385,15 @@ export type CollectionInitialState< > = Partial>; /** - * Persists collection state to an external store (e.g. the URL). + * Initial and change payload shape for `useCollectionVariables`. */ -export interface CollectionSaver< +export interface CollectionParams< TFieldName extends string = string, TFilter extends Filter = Filter, > { - save(state: CollectionPersistedState): void; + initialFilters?: TFilter[]; + initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; + pageSize?: number; } /** @@ -401,13 +403,8 @@ export interface UseCollectionOptions< TFieldName extends string = string, TFilter extends Filter = Filter, > { - params?: { - initialFilters?: TFilter[]; - initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; - pageSize?: number; - }; - initialState?: CollectionInitialState; - saver?: CollectionSaver; + params?: CollectionParams; + onParamsChange?(params: CollectionParams): void; } /** From ff8c0863f598e6cbf6add6d2a4c747e1eb060884 Mon Sep 17 00:00:00 2001 From: Sean Hasselback Date: Sat, 27 Jun 2026 09:21:49 +1000 Subject: [PATCH 10/10] Refine URL collection state: single `useURLCollectionVariables` hook + typed filter coercion (#335) * 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 * 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 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/witty-mice-drum.md | 19 +- .../src/modules/pages/data-table-demo.tsx | 14 +- 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 + packages/core/src/index.ts | 2 +- .../core/src/lib/collection-url-state.test.ts | 73 ++- packages/core/src/lib/collection-url-state.ts | 124 ++++- 9 files changed, 799 insertions(+), 57 deletions(-) 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/.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/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/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; + }, + ); } /**