Skip to content

Commit 880f2f4

Browse files
committed
feat(core): add withURLState helper for collection variables
1 parent c25039d commit 880f2f4

7 files changed

Lines changed: 437 additions & 78 deletions

File tree

examples/nextjs-app/src/modules/pages/data-table-demo.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import {
33
DataTable,
44
useDataTable,
55
useCollectionVariables,
6+
withURLState,
7+
useSearchParams,
68
createColumnHelper,
79
Layout,
10+
type CollectionVariables,
811
type RowAction,
912
} from "@tailor-platform/app-shell";
1013
import { useState } from "react";
@@ -140,11 +143,17 @@ const productRowActions: RowAction<Product>[] = [
140143
// ---------------------------------------------------------------------------
141144

142145
const DataTableDemoPage = () => {
143-
const { variables, control } = useCollectionVariables({
144-
params: { pageSize: 5 },
145-
tableMetadata: productMetadata,
146-
});
147-
const { data, loading } = useProductsQuery(variables);
146+
const searchParams = useSearchParams();
147+
const { variables, control } = useCollectionVariables(
148+
withURLState(
149+
{
150+
params: { pageSize: 5 },
151+
tableMetadata: productMetadata,
152+
},
153+
searchParams,
154+
),
155+
);
156+
const { data, loading } = useProductsQuery(variables as CollectionVariables);
148157
const [selectedIds, setSelectedIds] = useState<string[]>([]);
149158

150159
const table = useDataTable({

packages/core/src/hooks/use-collection-variables.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { renderHook, act } from "@testing-library/react";
2-
import { describe, it, expect } from "vitest";
2+
import { describe, it, expect, vi } from "vitest";
33
import type { TableMetadataMap } from "@/types/collection";
44
import { useCollectionVariables } from "./use-collection-variables";
55

@@ -55,6 +55,71 @@ describe("useCollectionVariables", () => {
5555
status: { eq: "ACTIVE" },
5656
});
5757
});
58+
59+
it("prefers initialState over params", () => {
60+
const { result } = renderHook(() =>
61+
useCollectionVariables({
62+
params: {
63+
initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
64+
initialSort: [{ field: "createdAt", direction: "Desc" }],
65+
pageSize: 20,
66+
},
67+
initialState: {
68+
filters: [{ field: "status", operator: "eq", value: "INACTIVE" }],
69+
sortStates: [{ field: "name", direction: "Asc" }],
70+
pageSize: 50,
71+
},
72+
}),
73+
);
74+
75+
expect(result.current.control.filters).toEqual([
76+
{ field: "status", operator: "eq", value: "INACTIVE" },
77+
]);
78+
expect(result.current.control.sortStates).toEqual([{ field: "name", direction: "Asc" }]);
79+
expect(result.current.variables.pagination).toEqual({ first: 50 });
80+
});
81+
82+
it("falls back to params for keys missing from initialState", () => {
83+
const { result } = renderHook(() =>
84+
useCollectionVariables({
85+
params: {
86+
initialSort: [{ field: "createdAt", direction: "Desc" }],
87+
pageSize: 20,
88+
},
89+
initialState: {
90+
pageSize: 50,
91+
},
92+
}),
93+
);
94+
95+
expect(result.current.control.sortStates).toEqual([
96+
{ field: "createdAt", direction: "Desc" },
97+
]);
98+
expect(result.current.variables.pagination).toEqual({ first: 50 });
99+
});
100+
});
101+
102+
describe("saver", () => {
103+
it("does not save on initial render", () => {
104+
const saver = { save: vi.fn() };
105+
renderHook(() => useCollectionVariables({ saver }));
106+
expect(saver.save).not.toHaveBeenCalled();
107+
});
108+
109+
it("saves persisted state after changes", () => {
110+
const saver = { save: vi.fn() };
111+
const { result } = renderHook(() => useCollectionVariables({ saver }));
112+
113+
act(() => {
114+
result.current.control.addFilter("status", "eq", "ACTIVE");
115+
});
116+
117+
expect(saver.save).toHaveBeenLastCalledWith({
118+
filters: [{ field: "status", operator: "eq", value: "ACTIVE", caseSensitive: undefined }],
119+
sortStates: [],
120+
pageSize: 20,
121+
});
122+
});
58123
});
59124

60125
// ---------------------------------------------------------------------------

packages/core/src/hooks/use-collection-variables.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useCallback, useMemo, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import type {
33
BuildQueryVariables,
44
CollectionControl,
5+
CollectionPersistedState,
56
CollectionVariables,
67
Filter,
78
FilterOperator,
@@ -130,8 +131,10 @@ export function useCollectionVariables(
130131
export function useCollectionVariables(
131132
options: UseCollectionOptions & { tableMetadata?: TableMetadata },
132133
): unknown {
133-
const { params = {} } = options;
134-
const { initialFilters = [], initialSort = [], pageSize: initialPageSize = 20 } = params;
134+
const { params = {}, initialState, saver } = options;
135+
const initialFilters = initialState?.filters ?? params.initialFilters ?? [];
136+
const initialSort = initialState?.sortStates ?? params.initialSort ?? [];
137+
const initialPageSize = initialState?.pageSize ?? params.pageSize ?? 20;
135138

136139
// ---------------------------------------------------------------------------
137140
// State
@@ -152,6 +155,8 @@ export function useCollectionVariables(
152155
getHasNextPage,
153156
resetCount,
154157
} = useCursorPagination(initialPageSize);
158+
const saverRef = useRef(saver);
159+
const didMountRef = useRef(false);
155160

156161
// ---------------------------------------------------------------------------
157162
// Filter operations
@@ -262,6 +267,23 @@ export function useCollectionVariables(
262267
[queryVars, orderVars, paginationVariables],
263268
);
264269

270+
useEffect(() => {
271+
saverRef.current = saver;
272+
}, [saver]);
273+
274+
useEffect(() => {
275+
if (!didMountRef.current) {
276+
didMountRef.current = true;
277+
return;
278+
}
279+
280+
saverRef.current?.save({
281+
filters,
282+
sortStates: sortStates as CollectionPersistedState["sortStates"],
283+
pageSize,
284+
});
285+
}, [filters, sortStates, pageSize]);
286+
265287
// ---------------------------------------------------------------------------
266288
// Return
267289
// ---------------------------------------------------------------------------

packages/core/src/hooks/use-url-collection-state.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { renderHook } from "@testing-library/react";
22
import { describe, it, expect, vi, beforeEach } from "vitest";
3-
import type { CollectionControl } from "@/types/collection";
3+
import type { CollectionControl, TableMetadataMap } from "@/types/collection";
44
import {
55
useUrlCollectionState,
6+
withURLState,
67
encodeFilterValue,
78
decodeFilterValue,
89
} from "./use-url-collection-state";
@@ -32,6 +33,32 @@ function resolveSetParamsCall(callIndex: number): URLSearchParams {
3233
: new URLSearchParams(updaterOrValue);
3334
}
3435

36+
const tableMetadata = {
37+
task: {
38+
name: "task",
39+
pluralForm: "tasks",
40+
fields: [
41+
{ name: "status", type: "enum", required: true, enumValues: ["active", "pending", "closed"] },
42+
{ name: "createdAt", type: "datetime", required: true },
43+
{ name: "title", type: "string", required: true },
44+
],
45+
},
46+
} as const satisfies TableMetadataMap;
47+
48+
function resolveSearchParamsBindingCall(
49+
setSearchParams: ReturnType<typeof vi.fn>,
50+
prev = new URLSearchParams(),
51+
): URLSearchParams {
52+
const [updaterOrValue] = setSearchParams.mock.calls[0];
53+
if (typeof updaterOrValue === "function") {
54+
const result = updaterOrValue(prev);
55+
return result instanceof URLSearchParams ? result : new URLSearchParams(result);
56+
}
57+
return updaterOrValue instanceof URLSearchParams
58+
? updaterOrValue
59+
: new URLSearchParams(updaterOrValue);
60+
}
61+
3562
function makeControl(overrides?: Partial<CollectionControl>): CollectionControl {
3663
return {
3764
filters: [],
@@ -255,6 +282,81 @@ describe("useUrlCollectionState", () => {
255282
});
256283
});
257284

285+
describe("withURLState", () => {
286+
it("parses URL state and keeps params defaults intact", () => {
287+
const setSearchParams = vi.fn();
288+
const options = withURLState(
289+
{
290+
tableMetadata: tableMetadata.task,
291+
params: {
292+
initialSort: [{ field: "createdAt", direction: "Desc" }],
293+
pageSize: 20,
294+
},
295+
},
296+
[new URLSearchParams("p=50&f.status:eq=active"), setSearchParams],
297+
);
298+
299+
expect(options.initialState).toEqual({
300+
pageSize: 50,
301+
filters: [{ field: "status", operator: "eq", value: "active" }],
302+
});
303+
expect(options.params).toEqual({
304+
initialSort: [{ field: "createdAt", direction: "Desc" }],
305+
pageSize: 20,
306+
});
307+
});
308+
309+
it("filters out URL fields/operators not allowed by tableMetadata", () => {
310+
const options = withURLState({ tableMetadata: tableMetadata.task }, [
311+
new URLSearchParams(
312+
"s=amount:asc&f.amount:eq=10&f.createdAt:contains=2026&f.status:eq=active",
313+
),
314+
vi.fn(),
315+
]);
316+
317+
expect(options.initialState).toEqual({
318+
filters: [{ field: "status", operator: "eq", value: "active" }],
319+
});
320+
});
321+
322+
it("merges existing initialState and composes saver", () => {
323+
const setSearchParams = vi.fn();
324+
const baseSaver = { save: vi.fn() };
325+
const options = withURLState(
326+
{
327+
tableMetadata: tableMetadata.task,
328+
initialState: {
329+
sortStates: [{ field: "createdAt", direction: "Desc" }],
330+
},
331+
saver: baseSaver,
332+
},
333+
[new URLSearchParams("p=25"), setSearchParams],
334+
);
335+
336+
expect(options.initialState).toEqual({
337+
sortStates: [{ field: "createdAt", direction: "Desc" }],
338+
pageSize: 25,
339+
});
340+
341+
options.saver?.save({
342+
filters: [{ field: "status", operator: "eq", value: "pending" }],
343+
sortStates: [{ field: "createdAt", direction: "Desc" }],
344+
pageSize: 30,
345+
});
346+
347+
expect(baseSaver.save).toHaveBeenCalledWith({
348+
filters: [{ field: "status", operator: "eq", value: "pending" }],
349+
sortStates: [{ field: "createdAt", direction: "Desc" }],
350+
pageSize: 30,
351+
});
352+
expect(setSearchParams).toHaveBeenCalledTimes(1);
353+
expect(setSearchParams.mock.calls[0][1]).toEqual({ replace: true });
354+
expect(resolveSearchParamsBindingCall(setSearchParams).toString()).toBe(
355+
"p=30&s=createdAt%3Adesc&f.status%3Aeq=pending",
356+
);
357+
});
358+
});
359+
258360
// ---------------------------------------------------------------------------
259361
// Utility functions
260362
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)