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 6b7b5182..5ad25638 100644 --- a/examples/nextjs-app/src/modules/pages/data-table-demo.tsx +++ b/examples/nextjs-app/src/modules/pages/data-table-demo.tsx @@ -6,6 +6,8 @@ import { createColumnHelper, Layout, type RowAction, + type TableFieldName, + useCollectionURLState, } from "@tailor-platform/app-shell"; import { useState } from "react"; import { type Product, useProductsQuery } from "./mock-data"; @@ -140,9 +142,14 @@ const productRowActions: RowAction[] = [ // --------------------------------------------------------------------------- const DataTableDemoPage = () => { + const urlState = useCollectionURLState>({ + prefix: "dt1", + }); + const urlParams = urlState.read(); const { variables, control } = useCollectionVariables({ - params: { pageSize: 5 }, + params: { ...urlParams, pageSize: urlParams.pageSize ?? 5 }, tableMetadata: productMetadata, + onChange: urlState.write, }); const { data, loading } = useProductsQuery(variables); const [selectedIds, setSelectedIds] = useState([]); diff --git a/packages/core/src/hooks/use-collection-url-persistence.test.tsx b/packages/core/src/hooks/use-collection-url-persistence.test.tsx new file mode 100644 index 00000000..59a90323 --- /dev/null +++ b/packages/core/src/hooks/use-collection-url-persistence.test.tsx @@ -0,0 +1,266 @@ +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router"; +import { useCollectionURLState } from "./use-collection-url-persistence"; + +beforeEach(() => { + vi.useRealTimers(); +}); + +function createWrapper(initialEntry: string = "/") { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function renderURLState( + options?: Parameters[0], + initialEntry: string = "/", +) { + return renderHook(() => useCollectionURLState(options), { + wrapper: createWrapper(initialEntry), + }); +} + +describe("useCollectionURLState", () => { + // --------------------------------------------------------------------------- + // read() + // --------------------------------------------------------------------------- + describe("read", () => { + it("returns empty object when no search params exist", () => { + const { result } = renderURLState(); + + expect(result.current.read()).toEqual({}); + }); + + it("returns pageSize from URL", () => { + const { result } = renderURLState(undefined, "/?p=50"); + + expect(result.current.read().pageSize).toBe(50); + }); + + it("returns sort from URL (initialSort format)", () => { + const { result } = renderURLState(undefined, "/?s=name:asc"); + + expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Asc" }]); + }); + + it("returns sort desc from URL", () => { + const { result } = renderURLState(undefined, "/?s=createdAt:desc"); + + expect(result.current.read().initialSort).toEqual([ + { field: "createdAt", direction: "Desc" }, + ]); + }); + + it("returns single-value filter from URL (initialFilters format)", () => { + const { result } = renderURLState(undefined, "/?f.status:eq=ACTIVE"); + + expect(result.current.read().initialFilters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); + }); + + it("returns multi-value filter from URL (repeated params)", () => { + const { result } = renderURLState(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING"); + + expect(result.current.read().initialFilters).toEqual([ + { field: "status", operator: "in", value: ["ACTIVE", "PENDING"] }, + ]); + }); + + it("returns JSON object filter value (e.g. between)", () => { + const url = `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`; + const { result } = renderURLState(undefined, url); + + expect(result.current.read().initialFilters).toEqual([ + { field: "amount", operator: "between", value: { min: 10, max: 100 } }, + ]); + }); + + it("returns all state together", () => { + const { result } = renderURLState(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE"); + + expect(result.current.read()).toEqual({ + pageSize: 25, + initialSort: [{ field: "name", direction: "Desc" }], + initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + }); + }); + + it("respects prefix option", () => { + const { result } = renderURLState({ prefix: "t1" }, "/?t1.p=30&p=10"); + + expect(result.current.read().pageSize).toBe(30); + }); + + it("ignores invalid pageSize values", () => { + const { result } = renderURLState(undefined, "/?p=abc"); + + expect(result.current.read()).toEqual({}); + }); + + it("ignores filter keys without operator", () => { + const { result } = renderURLState(undefined, "/?f.status=ACTIVE"); + + expect(result.current.read()).toEqual({}); + }); + }); + + // --------------------------------------------------------------------------- + // write() + // --------------------------------------------------------------------------- + describe("write", () => { + it("writes pageSize to URL", () => { + const { result } = renderURLState(); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + expect(result.current.read().pageSize).toBe(50); + }); + + it("writes sort to URL", () => { + const { result } = renderURLState(); + + act(() => { + result.current.write({ + filters: [], + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }); + }); + + expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Desc" }]); + }); + + it("writes single-value filter to URL", () => { + const { result } = renderURLState(); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + sort: [], + pageSize: 20, + }); + }); + + expect(result.current.read().initialFilters).toEqual([ + { field: "status", operator: "eq", value: "ACTIVE" }, + ]); + }); + + it("writes multi-value filter as repeated params", () => { + const { result } = renderURLState(); + + act(() => { + result.current.write({ + filters: [{ field: "status", operator: "in", value: ["A", "B", "C"] }], + sort: [], + pageSize: 20, + }); + }); + + expect(result.current.read().initialFilters).toEqual([ + { field: "status", operator: "in", value: ["A", "B", "C"] }, + ]); + }); + + it("writes object filter value as JSON", () => { + const { result } = renderURLState(); + + act(() => { + result.current.write({ + filters: [ + { + field: "amount", + operator: "between", + value: { min: 1, max: 99 }, + }, + ], + sort: [], + pageSize: 20, + }); + }); + + expect(result.current.read().initialFilters).toEqual([ + { field: "amount", operator: "between", value: { min: 1, max: 99 } }, + ]); + }); + + it("clears old filters when writing new state", () => { + const { result } = renderURLState(undefined, "/?f.status:eq=OLD"); + + act(() => { + result.current.write({ + filters: [{ field: "name", operator: "contains", value: "test" }], + sort: [], + pageSize: 20, + }); + }); + + expect(result.current.read().initialFilters).toEqual([ + { field: "name", operator: "contains", value: "test" }, + ]); + }); + + it("uses prefix when writing", () => { + const { result } = renderURLState({ prefix: "t1" }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 40 }); + }); + + expect(result.current.read().pageSize).toBe(40); + }); + }); + + // --------------------------------------------------------------------------- + // debounce + // --------------------------------------------------------------------------- + describe("debounce", () => { + it("debounces write when debounceMs is set", () => { + vi.useFakeTimers(); + + const { result } = renderURLState({ debounceMs: 100 }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + // Not yet written + expect(result.current.read()).toEqual({}); + + act(() => { + vi.advanceTimersByTime(100); + }); + + // Now written + expect(result.current.read().pageSize).toBe(50); + + vi.useRealTimers(); + }); + + it("only applies the last write within debounce window", () => { + vi.useFakeTimers(); + + const { result } = renderURLState({ debounceMs: 100 }); + + act(() => { + result.current.write({ filters: [], sort: [], pageSize: 10 }); + result.current.write({ filters: [], sort: [], pageSize: 30 }); + result.current.write({ filters: [], sort: [], pageSize: 50 }); + }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current.read().pageSize).toBe(50); + + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/core/src/hooks/use-collection-url-persistence.ts b/packages/core/src/hooks/use-collection-url-persistence.ts new file mode 100644 index 00000000..51988a19 --- /dev/null +++ b/packages/core/src/hooks/use-collection-url-persistence.ts @@ -0,0 +1,240 @@ +import { useCallback, useMemo, useRef } from "react"; +import { useSearchParams } from "react-router"; +import type { CollectionSnapshot, Filter, SortState } from "@/types/collection"; + +const KEY_PAGE_SIZE = "p"; +const KEY_SORT = "s"; +const FILTER_PREFIX = "f."; + +export interface UseCollectionURLStateOptions { + /** Key prefix to avoid collisions when multiple tables share a page. */ + prefix?: string; + /** Debounce interval in ms for URL writes. Default: no debounce. */ + debounceMs?: number; +} + +/** + * Accessor object returned by `useCollectionURLState`. + * + * - `read()` returns params-compatible initial state parsed from the URL. + * - `write()` encodes collection state into URL search params. + * + * Designed to be wired directly into `useCollectionVariables`: + * ```tsx + * const urlState = useCollectionURLState(); + * const { variables, control } = useCollectionVariables({ + * params: urlState.read(), + * onChange: urlState.write, + * }); + * ``` + */ +export interface CollectionURLStateAccessor { + /** Parse current URL search params into initial state for `params`. */ + read(): { + initialFilters?: Filter[]; + initialSort?: SortState[]; + pageSize?: number; + }; + /** Encode collection state into URL search params. Suitable for `onChange`. */ + write: (state: CollectionSnapshot) => void; +} + +/** + * Hook that provides read/write access to collection state stored in URL search params. + * + * URL format: + * - Page size: `p=20` + * - Sort: `s=name:asc` + * - Filters: `f.field:operator=value` (repeated params for multi-value) + * + * @example + * ```tsx + * const urlState = useCollectionURLState(); + * const { variables, control } = useCollectionVariables({ + * params: urlState.read(), + * onChange: urlState.write, + * }); + * ``` + */ +export function useCollectionURLState( + options: UseCollectionURLStateOptions = {}, +): CollectionURLStateAccessor { + const { prefix = "", debounceMs } = options; + const timerRef = useRef | null>(null); + const [searchParams, setSearchParams] = useSearchParams(); + const prefixedKey = useCallback((key: string) => (prefix ? `${prefix}.${key}` : key), [prefix]); + + const read = useCallback((): { + initialFilters?: Filter[]; + initialSort?: SortState[]; + pageSize?: number; + } => { + const snapshot = readFromParams(searchParams, prefixedKey); + if (!snapshot) return {}; + return { + initialFilters: snapshot.filters, + initialSort: snapshot.sort, + pageSize: snapshot.pageSize, + }; + }, [searchParams, prefixedKey]); + + const write = useCallback( + (state: CollectionSnapshot): void => { + const doWrite = () => { + setSearchParams( + (currentParams) => { + const params = new URLSearchParams(currentParams); + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + // Page size + if (state.pageSize) { + params.set(pageSizeKey, String(state.pageSize)); + } else { + params.delete(pageSizeKey); + } + + // Sort + if (state.sort && state.sort.length > 0) { + const { field, direction } = state.sort[0]; + params.set(sortKey, `${field}:${direction === "Desc" ? "desc" : "asc"}`); + } else { + params.delete(sortKey); + } + + // Clear existing filters with this prefix + for (const key of Array.from(params.keys())) { + if (key.startsWith(filterPrefix)) params.delete(key); + } + + // Write filters + if (state.filters) { + for (const filter of state.filters) { + const key = `${filterPrefix}${filter.field}:${filter.operator}`; + if (Array.isArray(filter.value)) { + for (const v of filter.value) { + const encoded = stringifyPrimitive(v); + if (encoded !== "") params.append(key, encoded); + } + } else if (filter.value != null && filter.value !== "") { + params.set(key, encodeFilterValue(filter.value)); + } + } + } + + return params; + }, + { replace: true }, + ); + }; + + if (debounceMs != null && debounceMs > 0) { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(doWrite, debounceMs); + } else { + doWrite(); + } + }, + [prefixedKey, debounceMs, setSearchParams], + ); + + return useMemo(() => ({ read, write }), [read, write]); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Partial snapshot used internally for URL parsing (fields may be absent). */ +interface ParsedSnapshot { + filters?: Filter[]; + sort?: SortState[]; + pageSize?: number; +} + +function readFromParams( + searchParams: URLSearchParams, + prefixedKey: (key: string) => string, +): ParsedSnapshot | undefined { + const pageSizeKey = prefixedKey(KEY_PAGE_SIZE); + const sortKey = prefixedKey(KEY_SORT); + const filterPrefix = prefixedKey(FILTER_PREFIX); + + let hasAny = false; + const snapshot: ParsedSnapshot = {}; + + // Page size + const pageSize = searchParams.get(pageSizeKey); + if (pageSize) { + const n = Number(pageSize); + if (Number.isFinite(n) && n > 0) { + snapshot.pageSize = n; + hasAny = true; + } + } + + // Sort + const sort = searchParams.get(sortKey); + if (sort) { + const [field, rawDir] = sort.split(":"); + if (field) { + const direction = rawDir === "desc" ? "Desc" : "Asc"; + snapshot.sort = [{ field, direction } as SortState]; + hasAny = true; + } + } + + // Filters + const filterKeys = new Set(); + for (const key of searchParams.keys()) { + if (key.startsWith(filterPrefix)) filterKeys.add(key); + } + if (filterKeys.size > 0) { + const filters: Filter[] = []; + for (const key of filterKeys) { + const values = searchParams.getAll(key).filter((v) => v !== ""); + if (values.length === 0) continue; + const remainder = key.slice(filterPrefix.length); + const [field, operator] = remainder.split(":"); + if (!field || !operator) continue; + filters.push({ + field: field as TFieldName, + operator: operator as Filter["operator"], + value: values.length === 1 ? parseFilterValue(values[0]) : values.map(parseFilterValue), + }); + } + if (filters.length > 0) { + snapshot.filters = filters; + hasAny = true; + } + } + + return hasAny ? snapshot : undefined; +} + +function parseFilterValue(raw: string): unknown { + if (raw.startsWith("{") || raw.startsWith("[")) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} + +function encodeFilterValue(value: unknown): string { + 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); +} diff --git a/packages/core/src/hooks/use-collection-variables.test.ts b/packages/core/src/hooks/use-collection-variables.test.ts index 8d0b5ae7..341eb427 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"; @@ -626,4 +626,120 @@ describe("useCollectionVariables", () => { expect(result.current.control.sortStates).toEqual([{ field: "dueDate", direction: "Desc" }]); }); }); + + // --------------------------------------------------------------------------- + // onChange + // --------------------------------------------------------------------------- + describe("onChange", () => { + it("does not call onChange on initial mount", () => { + const onChange = vi.fn(); + + renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it("calls onChange on filter change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [{ field: "status", operator: "eq", value: "ACTIVE" }], + pageSize: 20, + }), + ); + }); + + it("calls onChange on sort change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.setSort("name", "Desc"); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + sort: [{ field: "name", direction: "Desc" }], + pageSize: 20, + }), + ); + }); + + it("calls onChange on pageSize change", () => { + const onChange = vi.fn(); + + const { result } = renderHook(() => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + ); + + act(() => { + result.current.control.setPageSize(50); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 50, + }), + ); + }); + + it("does not crash when onChange is not provided", () => { + const { result } = renderHook(() => useCollectionVariables({ params: { pageSize: 20 } })); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(result.current.control.filters).toHaveLength(1); + }); + + it("uses latest onChange reference (no stale closure)", () => { + const onChange1 = vi.fn(); + const onChange2 = vi.fn(); + + const { result, rerender } = renderHook( + ({ onChange }) => + useCollectionVariables({ + params: { pageSize: 20 }, + onChange, + }), + { initialProps: { onChange: onChange1 } }, + ); + + rerender({ onChange: onChange2 }); + + act(() => { + result.current.control.addFilter("status", "eq", "ACTIVE"); + }); + + expect(onChange1).not.toHaveBeenCalled(); + expect(onChange2).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/hooks/use-collection-variables.ts b/packages/core/src/hooks/use-collection-variables.ts index 52c44f7c..65152e6e 100644 --- a/packages/core/src/hooks/use-collection-variables.ts +++ b/packages/core/src/hooks/use-collection-variables.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { BuildQueryVariables, CollectionControl, @@ -80,7 +80,7 @@ function toCaseInsensitiveRegex(operator: FilterOperator, value: string): string * ``` */ export function useCollectionVariables( - options: UseCollectionOptions, TableMetadataFilter> & { + options: UseCollectionOptions> & { tableMetadata: TTable; }, ): UseCollectionReturn< @@ -127,10 +127,11 @@ export function useCollectionVariables( // incompatible via CollectionControl's contravariant TFieldName) are assignable // to it — every type is assignable to `unknown`. Callers always see the narrower // type from the overload signatures above, never `unknown`. -export function useCollectionVariables( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, -): unknown { - const { params = {} } = options; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCollectionVariables(options: any): unknown { + const { params = {}, onChange } = options as UseCollectionOptions & { + tableMetadata?: TableMetadata; + }; const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params; // --------------------------------------------------------------------------- @@ -153,6 +154,25 @@ export function useCollectionVariables( resetCount, } = useCursorPagination(initialPageSize); + // --------------------------------------------------------------------------- + // onChange notification (skip initial render) + // --------------------------------------------------------------------------- + const isFirstRenderRef = useRef(true); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + onChangeRef.current?.({ + filters, + sort: sortStates, + pageSize, + }); + }, [filters, sortStates, pageSize]); + // --------------------------------------------------------------------------- // Filter operations // --------------------------------------------------------------------------- diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6c26da3c..e905a405 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -156,6 +156,7 @@ export { type PaginationVariables, type UseCollectionOptions, type UseCollectionReturn, + type CollectionSnapshot, type FieldType, type FieldMetadata, type TableMetadata, @@ -183,6 +184,11 @@ export { type DataTableContextValue, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; +export { + useCollectionURLState, + type CollectionURLStateAccessor, + type UseCollectionURLStateOptions, +} from "./hooks/use-collection-url-persistence"; export { CollectionControlProvider, useCollectionControl, diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 0627c92b..686ceb38 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -346,6 +346,19 @@ export type TableOrderableFieldName = : never : never; +// ============================================================================= +// Collection State Snapshot +// ============================================================================= + +/** + * Snapshot of collection state passed to `onChange`. + */ +export interface CollectionSnapshot { + filters: Filter[]; + sort: SortState[]; + pageSize: number; +} + // ============================================================================= // Collection Control & Options // ============================================================================= @@ -353,15 +366,14 @@ export type TableOrderableFieldName = /** * Options for `useCollectionVariables` hook. */ -export interface UseCollectionOptions< - TFieldName extends string = string, - TFilter extends Filter = Filter, -> { +export interface UseCollectionOptions { params?: { - initialFilters?: TFilter[]; - initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[]; + initialFilters?: Filter[]; + initialSort?: SortState[]; pageSize?: number; }; + /** Called on every user-initiated state change (filter, sort, pageSize). */ + onChange?: (state: CollectionSnapshot) => void; } /**