Skip to content

Commit 5decd69

Browse files
committed
refactor(core): replace persistence interface with params + onChange API
- Remove CollectionStatePersistence interface - Add onChange callback to UseCollectionOptions - Make CollectionSnapshot fields required - Rename useCollectionURLPersistence to useCollectionURLState - useCollectionURLState returns read/write accessor (read() for params, write for onChange) - Merge persistence tests into use-collection-variables.test.ts
1 parent 0b510f6 commit 5decd69

8 files changed

Lines changed: 264 additions & 299 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Layout,
88
type RowAction,
99
type TableFieldName,
10-
useCollectionURLPersistence,
10+
useCollectionURLState,
1111
} from "@tailor-platform/app-shell";
1212
import { useState } from "react";
1313
import { type Product, useProductsQuery } from "./mock-data";
@@ -142,13 +142,14 @@ const productRowActions: RowAction<Product>[] = [
142142
// ---------------------------------------------------------------------------
143143

144144
const DataTableDemoPage = () => {
145-
const persistence = useCollectionURLPersistence<TableFieldName<typeof productMetadata>>({
145+
const urlState = useCollectionURLState<TableFieldName<typeof productMetadata>>({
146146
prefix: "dt1",
147147
});
148+
const urlParams = urlState.read();
148149
const { variables, control } = useCollectionVariables({
149-
params: { pageSize: 5 },
150+
params: { ...urlParams, pageSize: urlParams.pageSize ?? 5 },
150151
tableMetadata: productMetadata,
151-
persistence,
152+
onChange: urlState.write,
152153
});
153154
const { data, loading } = useProductsQuery(variables);
154155
const [selectedIds, setSelectedIds] = useState<string[]>([]);

packages/core/src/hooks/use-collection-url-persistence.test.tsx

Lines changed: 55 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react";
22
import type { ReactNode } from "react";
33
import { describe, it, expect, vi, beforeEach } from "vitest";
44
import { MemoryRouter } from "react-router";
5-
import { useCollectionURLPersistence } from "./use-collection-url-persistence";
5+
import { useCollectionURLState } from "./use-collection-url-persistence";
66

77
beforeEach(() => {
88
vi.useRealTimers();
@@ -14,100 +14,97 @@ function createWrapper(initialEntry: string = "/") {
1414
};
1515
}
1616

17-
function renderPersistence(
18-
options?: Parameters<typeof useCollectionURLPersistence>[0],
17+
function renderURLState(
18+
options?: Parameters<typeof useCollectionURLState>[0],
1919
initialEntry: string = "/",
2020
) {
21-
return renderHook(() => useCollectionURLPersistence(options), {
21+
return renderHook(() => useCollectionURLState(options), {
2222
wrapper: createWrapper(initialEntry),
2323
});
2424
}
2525

26-
/** Helper: read current snapshot */
27-
function readSnapshot(result: { current: ReturnType<typeof useCollectionURLPersistence> }) {
28-
return result.current.read();
29-
}
30-
31-
describe("useCollectionURLPersistence", () => {
26+
describe("useCollectionURLState", () => {
3227
// ---------------------------------------------------------------------------
33-
// read (initial)
28+
// read()
3429
// ---------------------------------------------------------------------------
35-
describe("read (initial)", () => {
36-
it("returns undefined when no search params exist", () => {
37-
const { result } = renderPersistence();
30+
describe("read", () => {
31+
it("returns empty object when no search params exist", () => {
32+
const { result } = renderURLState();
3833

39-
expect(readSnapshot(result)).toBeUndefined();
34+
expect(result.current.read()).toEqual({});
4035
});
4136

4237
it("returns pageSize from URL", () => {
43-
const { result } = renderPersistence(undefined, "/?p=50");
38+
const { result } = renderURLState(undefined, "/?p=50");
4439

45-
expect(readSnapshot(result)?.pageSize).toBe(50);
40+
expect(result.current.read().pageSize).toBe(50);
4641
});
4742

48-
it("returns sort from URL", () => {
49-
const { result } = renderPersistence(undefined, "/?s=name:asc");
43+
it("returns sort from URL (initialSort format)", () => {
44+
const { result } = renderURLState(undefined, "/?s=name:asc");
5045

51-
expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Asc" }]);
46+
expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Asc" }]);
5247
});
5348

5449
it("returns sort desc from URL", () => {
55-
const { result } = renderPersistence(undefined, "/?s=createdAt:desc");
50+
const { result } = renderURLState(undefined, "/?s=createdAt:desc");
5651

57-
expect(readSnapshot(result)?.sort).toEqual([{ field: "createdAt", direction: "Desc" }]);
52+
expect(result.current.read().initialSort).toEqual([
53+
{ field: "createdAt", direction: "Desc" },
54+
]);
5855
});
5956

60-
it("returns single-value filter from URL", () => {
61-
const { result } = renderPersistence(undefined, "/?f.status:eq=ACTIVE");
57+
it("returns single-value filter from URL (initialFilters format)", () => {
58+
const { result } = renderURLState(undefined, "/?f.status:eq=ACTIVE");
6259

63-
expect(readSnapshot(result)?.filters).toEqual([
60+
expect(result.current.read().initialFilters).toEqual([
6461
{ field: "status", operator: "eq", value: "ACTIVE" },
6562
]);
6663
});
6764

6865
it("returns multi-value filter from URL (repeated params)", () => {
69-
const { result } = renderPersistence(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING");
66+
const { result } = renderURLState(undefined, "/?f.status:in=ACTIVE&f.status:in=PENDING");
7067

71-
expect(readSnapshot(result)?.filters).toEqual([
68+
expect(result.current.read().initialFilters).toEqual([
7269
{ field: "status", operator: "in", value: ["ACTIVE", "PENDING"] },
7370
]);
7471
});
7572

7673
it("returns JSON object filter value (e.g. between)", () => {
7774
const url = `/?f.amount:between=${encodeURIComponent(JSON.stringify({ min: 10, max: 100 }))}`;
78-
const { result } = renderPersistence(undefined, url);
75+
const { result } = renderURLState(undefined, url);
7976

80-
expect(readSnapshot(result)?.filters).toEqual([
77+
expect(result.current.read().initialFilters).toEqual([
8178
{ field: "amount", operator: "between", value: { min: 10, max: 100 } },
8279
]);
8380
});
8481

8582
it("returns all state together", () => {
86-
const { result } = renderPersistence(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE");
83+
const { result } = renderURLState(undefined, "/?p=25&s=name:desc&f.status:eq=ACTIVE");
8784

88-
expect(readSnapshot(result)).toEqual({
85+
expect(result.current.read()).toEqual({
8986
pageSize: 25,
90-
sort: [{ field: "name", direction: "Desc" }],
91-
filters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
87+
initialSort: [{ field: "name", direction: "Desc" }],
88+
initialFilters: [{ field: "status", operator: "eq", value: "ACTIVE" }],
9289
});
9390
});
9491

9592
it("respects prefix option", () => {
96-
const { result } = renderPersistence({ prefix: "t1" }, "/?t1.p=30&p=10");
93+
const { result } = renderURLState({ prefix: "t1" }, "/?t1.p=30&p=10");
9794

98-
expect(readSnapshot(result)?.pageSize).toBe(30);
95+
expect(result.current.read().pageSize).toBe(30);
9996
});
10097

10198
it("ignores invalid pageSize values", () => {
102-
const { result } = renderPersistence(undefined, "/?p=abc");
99+
const { result } = renderURLState(undefined, "/?p=abc");
103100

104-
expect(readSnapshot(result)).toBeUndefined();
101+
expect(result.current.read()).toEqual({});
105102
});
106103

107104
it("ignores filter keys without operator", () => {
108-
const { result } = renderPersistence(undefined, "/?f.status=ACTIVE");
105+
const { result } = renderURLState(undefined, "/?f.status=ACTIVE");
109106

110-
expect(readSnapshot(result)).toBeUndefined();
107+
expect(result.current.read()).toEqual({});
111108
});
112109
});
113110

@@ -116,17 +113,17 @@ describe("useCollectionURLPersistence", () => {
116113
// ---------------------------------------------------------------------------
117114
describe("write", () => {
118115
it("writes pageSize to URL", () => {
119-
const { result } = renderPersistence();
116+
const { result } = renderURLState();
120117

121118
act(() => {
122119
result.current.write({ filters: [], sort: [], pageSize: 50 });
123120
});
124121

125-
expect(readSnapshot(result)?.pageSize).toBe(50);
122+
expect(result.current.read().pageSize).toBe(50);
126123
});
127124

128125
it("writes sort to URL", () => {
129-
const { result } = renderPersistence();
126+
const { result } = renderURLState();
130127

131128
act(() => {
132129
result.current.write({
@@ -136,11 +133,11 @@ describe("useCollectionURLPersistence", () => {
136133
});
137134
});
138135

139-
expect(readSnapshot(result)?.sort).toEqual([{ field: "name", direction: "Desc" }]);
136+
expect(result.current.read().initialSort).toEqual([{ field: "name", direction: "Desc" }]);
140137
});
141138

142139
it("writes single-value filter to URL", () => {
143-
const { result } = renderPersistence();
140+
const { result } = renderURLState();
144141

145142
act(() => {
146143
result.current.write({
@@ -150,13 +147,13 @@ describe("useCollectionURLPersistence", () => {
150147
});
151148
});
152149

153-
expect(readSnapshot(result)?.filters).toEqual([
150+
expect(result.current.read().initialFilters).toEqual([
154151
{ field: "status", operator: "eq", value: "ACTIVE" },
155152
]);
156153
});
157154

158155
it("writes multi-value filter as repeated params", () => {
159-
const { result } = renderPersistence();
156+
const { result } = renderURLState();
160157

161158
act(() => {
162159
result.current.write({
@@ -166,13 +163,13 @@ describe("useCollectionURLPersistence", () => {
166163
});
167164
});
168165

169-
expect(readSnapshot(result)?.filters).toEqual([
166+
expect(result.current.read().initialFilters).toEqual([
170167
{ field: "status", operator: "in", value: ["A", "B", "C"] },
171168
]);
172169
});
173170

174171
it("writes object filter value as JSON", () => {
175-
const { result } = renderPersistence();
172+
const { result } = renderURLState();
176173

177174
act(() => {
178175
result.current.write({
@@ -188,13 +185,13 @@ describe("useCollectionURLPersistence", () => {
188185
});
189186
});
190187

191-
expect(readSnapshot(result)?.filters).toEqual([
188+
expect(result.current.read().initialFilters).toEqual([
192189
{ field: "amount", operator: "between", value: { min: 1, max: 99 } },
193190
]);
194191
});
195192

196193
it("clears old filters when writing new state", () => {
197-
const { result } = renderPersistence(undefined, "/?f.status:eq=OLD");
194+
const { result } = renderURLState(undefined, "/?f.status:eq=OLD");
198195

199196
act(() => {
200197
result.current.write({
@@ -204,19 +201,19 @@ describe("useCollectionURLPersistence", () => {
204201
});
205202
});
206203

207-
expect(readSnapshot(result)?.filters).toEqual([
204+
expect(result.current.read().initialFilters).toEqual([
208205
{ field: "name", operator: "contains", value: "test" },
209206
]);
210207
});
211208

212209
it("uses prefix when writing", () => {
213-
const { result } = renderPersistence({ prefix: "t1" });
210+
const { result } = renderURLState({ prefix: "t1" });
214211

215212
act(() => {
216213
result.current.write({ filters: [], sort: [], pageSize: 40 });
217214
});
218215

219-
expect(readSnapshot(result)?.pageSize).toBe(40);
216+
expect(result.current.read().pageSize).toBe(40);
220217
});
221218
});
222219

@@ -227,29 +224,29 @@ describe("useCollectionURLPersistence", () => {
227224
it("debounces write when debounceMs is set", () => {
228225
vi.useFakeTimers();
229226

230-
const { result } = renderPersistence({ debounceMs: 100 });
227+
const { result } = renderURLState({ debounceMs: 100 });
231228

232229
act(() => {
233230
result.current.write({ filters: [], sort: [], pageSize: 50 });
234231
});
235232

236233
// Not yet written
237-
expect(readSnapshot(result)).toBeUndefined();
234+
expect(result.current.read()).toEqual({});
238235

239236
act(() => {
240237
vi.advanceTimersByTime(100);
241238
});
242239

243240
// Now written
244-
expect(readSnapshot(result)?.pageSize).toBe(50);
241+
expect(result.current.read().pageSize).toBe(50);
245242

246243
vi.useRealTimers();
247244
});
248245

249246
it("only applies the last write within debounce window", () => {
250247
vi.useFakeTimers();
251248

252-
const { result } = renderPersistence({ debounceMs: 100 });
249+
const { result } = renderURLState({ debounceMs: 100 });
253250

254251
act(() => {
255252
result.current.write({ filters: [], sort: [], pageSize: 10 });
@@ -261,7 +258,7 @@ describe("useCollectionURLPersistence", () => {
261258
vi.advanceTimersByTime(100);
262259
});
263260

264-
expect(readSnapshot(result)?.pageSize).toBe(50);
261+
expect(result.current.read().pageSize).toBe(50);
265262

266263
vi.useRealTimers();
267264
});

0 commit comments

Comments
 (0)