@izumisy-tailor/tailor-data-viewer 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,8 @@ A low-level React component library for building data table interfaces with Tail
5
5
  ## Features
6
6
 
7
7
  - **Separation of Concerns**: Data fetching, query parameter management, and UI are fully decoupled
8
- - **`useCollectionParams` Hook**: Manages filter, sort, and pagination state; outputs Tailor Platform-compatible GraphQL variables
9
- - **`CollectionParams.Provider`**: Shares query parameters via React Context across sibling components
8
+ - **`useCollection` Hook**: Manages filter, sort, and pagination state; outputs Tailor Platform-compatible GraphQL variables
9
+ - **`Collection.Provider`**: Shares query parameters via React Context across sibling components
10
10
  - **`Table.*` Compound Components**: Static, unstyled table primitives (`<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`)
11
11
  - **`DataTable.*` Compound Components**: Data-bound table with sort indicators, cell renderers, and `useDataTable` integration
12
12
  - **`useDataTable` Hook**: Integrates data, column visibility, row operations (optimistic updates), and props generators
@@ -15,7 +15,7 @@ A low-level React component library for building data table interfaces with Tail
15
15
  - **Utility Components**: `ColumnSelector`, `CsvButton`, `SearchFilterForm`, `Pagination` — all props-based, spreadable from hooks
16
16
  - **Multi-sort Support**: Multiple simultaneous sort fields
17
17
  - **Optimistic Updates**: `updateRow`, `deleteRow`, `insertRow` with rollback
18
- - **Presentation Agnostic**: Same `useCollectionParams` can drive tables, kanbans, calendars, etc.
18
+ - **Presentation Agnostic**: Same `useCollection` can drive tables, kanbans, calendars, etc.
19
19
 
20
20
  ## Installation
21
21
 
@@ -47,9 +47,9 @@ npm install react react-dom
47
47
 
48
48
  ```tsx
49
49
  import {
50
- useCollectionParams,
50
+ useCollection,
51
51
  useDataTable,
52
- CollectionParams,
52
+ Collection,
53
53
  DataTable,
54
54
  Pagination,
55
55
  field,
@@ -87,73 +87,76 @@ const columns = [
87
87
 
88
88
  // 2. Build a page
89
89
  function OrdersPage() {
90
- const params = useCollectionParams({ pageSize: 20 });
91
- const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
90
+ const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
91
+ const [result] = useQuery({ ...collection.toQueryArgs() });
92
92
 
93
93
  const table = useDataTable<Order>({
94
94
  columns,
95
95
  data: result.data?.orders,
96
96
  loading: result.fetching,
97
- collectionParams: params,
97
+ collection,
98
98
  });
99
99
 
100
100
  return (
101
- <CollectionParams.Provider value={params}>
101
+ <Collection.Provider value={collection}>
102
102
  <DataTable.Root {...table.rootProps}>
103
103
  <DataTable.Headers />
104
104
  <DataTable.Body />
105
105
  </DataTable.Root>
106
106
  <Pagination {...table} />
107
- </CollectionParams.Provider>
107
+ </Collection.Provider>
108
108
  );
109
109
  }
110
110
  ```
111
111
 
112
112
  ## API Overview
113
113
 
114
- ### `useCollectionParams(options?)`
114
+ ### `useCollection(options)`
115
115
 
116
- Manages filter, sort, and pagination state. Returns Tailor Platform-compatible `variables` for GraphQL queries.
116
+ Manages filter, sort, and pagination state. Returns `toQueryArgs()` which produces Tailor Platform-compatible arguments for `useQuery()`.
117
117
 
118
118
  ```tsx
119
- const params = useCollectionParams({
120
- pageSize: 20,
121
- initialSort: [{ field: "createdAt", direction: "Desc" }],
119
+ const collection = useCollection({
120
+ query: GET_ORDERS,
121
+ params: {
122
+ pageSize: 20,
123
+ initialSort: [{ field: "createdAt", direction: "Desc" }],
124
+ },
122
125
  });
123
126
 
124
- // Pass variables to any GraphQL client
125
- const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
127
+ // Spread toQueryArgs() into useQuery
128
+ const [result] = useQuery({ ...collection.toQueryArgs() });
126
129
 
127
130
  // Filter operations
128
- params.addFilter("status", "ACTIVE", "eq");
129
- params.setFilters([{ field: "status", fieldType: "enum", operator: "eq", value: "ACTIVE" }]);
130
- params.removeFilter("status");
131
- params.clearFilters();
131
+ collection.addFilter("status", "ACTIVE", "eq");
132
+ collection.setFilters([{ field: "status", fieldType: "enum", operator: "eq", value: "ACTIVE" }]);
133
+ collection.removeFilter("status");
134
+ collection.clearFilters();
132
135
 
133
136
  // Sort operations
134
- params.setSort("createdAt", "Desc");
135
- params.setSort("name", "Asc", true); // append for multi-sort
136
- params.clearSort();
137
+ collection.setSort("createdAt", "Desc");
138
+ collection.setSort("name", "Asc", true); // append for multi-sort
139
+ collection.clearSort();
137
140
 
138
141
  // Pagination
139
- params.nextPage(endCursor);
140
- params.prevPage();
141
- params.resetPage();
142
+ collection.nextPage(endCursor);
143
+ collection.prevPage();
144
+ collection.resetPage();
142
145
  ```
143
146
 
144
- ### `CollectionParams.Provider` / `useCollectionParamsContext()`
147
+ ### `Collection.Provider` / `useCollectionContext()`
145
148
 
146
- Shares `useCollectionParams` return value via Context. Child components access it with `useCollectionParamsContext()`.
149
+ Shares `useCollection` return value via Context. Child components access it with `useCollectionContext()`.
147
150
 
148
151
  ```tsx
149
- <CollectionParams.Provider value={params}>
150
- <StatusFilter /> {/* useCollectionParamsContext() inside */}
152
+ <Collection.Provider value={collection}>
153
+ <StatusFilter /> {/* useCollectionContext() inside */}
151
154
  <DataTable.Root {...table.rootProps}>
152
155
  <DataTable.Headers />
153
156
  <DataTable.Body />
154
157
  </DataTable.Root>
155
158
  <Pagination {...table} />
156
- </CollectionParams.Provider>
159
+ </Collection.Provider>
157
160
  ```
158
161
 
159
162
  Provider is optional — for simple cases, pass params directly via props.
@@ -246,7 +249,7 @@ const table = useDataTable<Order>({
246
249
  data: result.data?.orders, // CollectionResult<Order>
247
250
  loading: result.fetching,
248
251
  error: result.error,
249
- collectionParams: params,
252
+ collection,
250
253
  });
251
254
 
252
255
  // Spread props to components
@@ -320,17 +323,17 @@ Pair with `useDataTable` for automatic header sorting, cell rendering, and row o
320
323
 
321
324
  ### Utility Components
322
325
 
323
- All utility components are props-based and designed to be used with spread from `useDataTable` / `useCollectionParams`.
326
+ All utility components are props-based and designed to be used with spread from `useDataTable` / `useCollection`.
324
327
 
325
328
  | Component | Spread from | Description |
326
329
  |-----------|------------|-------------|
327
330
  | `ColumnSelector` | `{...table}` | Column visibility toggle UI |
328
331
  | `CsvButton` | `{...table}` | Export visible data as CSV |
329
- | `SearchFilterForm` | `{...table, ...params}` | Multi-field filter form with operator selection |
332
+ | `SearchFilterForm` | `{...table, ...collection}` | Multi-field filter form with operator selection |
330
333
  | `Pagination` | `{...table}` | Previous/Next page controls |
331
334
 
332
335
  ```tsx
333
- <SearchFilterForm {...table} {...params} />
336
+ <SearchFilterForm {...table} {...collection} />
334
337
  <ColumnSelector {...table} />
335
338
  <CsvButton {...table} filename="orders-export" />
336
339
  <Pagination {...table} />
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.7",
4
+ "version": "0.2.8",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -0,0 +1,90 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { UseCollectionReturn } from "../types";
3
+
4
+ const CollectionContext = createContext<UseCollectionReturn<
5
+ string,
6
+ unknown,
7
+ unknown
8
+ > | null>(null);
9
+
10
+ /**
11
+ * Provider that shares collection query parameters via React Context.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * const collection = useCollection({ params: { pageSize: 20 } });
16
+ *
17
+ * <CollectionProvider value={collection}>
18
+ * <FilterPanel />
19
+ * <DataTable.Root {...table.rootProps}>...</DataTable.Root>
20
+ * <Pagination {...table} />
21
+ * </CollectionProvider>
22
+ * ```
23
+ */
24
+ export function CollectionProvider({
25
+ value,
26
+ children,
27
+ }: {
28
+ value: UseCollectionReturn<string, unknown, unknown>;
29
+ children: ReactNode;
30
+ }) {
31
+ return (
32
+ <CollectionContext.Provider value={value}>
33
+ {children}
34
+ </CollectionContext.Provider>
35
+ );
36
+ }
37
+
38
+
39
+
40
+ /**
41
+ * Hook to access collection state from the nearest `Collection.Provider`.
42
+ *
43
+ * Returns the same interface as `useCollection()`. Pass a `TFieldName`
44
+ * type parameter to narrow method arguments like `addFilter` / `setSort`.
45
+ *
46
+ * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
47
+ *
48
+ * @throws Error if used outside of `Collection.Provider`.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * function StatusFilter() {
53
+ * const { filters, addFilter, removeFilter } = useCollectionContext();
54
+ * // ...
55
+ * }
56
+ *
57
+ * // With typed field names:
58
+ * type TaskField = FieldName<typeof tableMetadata, "task">;
59
+ * const { addFilter } = useCollectionContext<TaskField>();
60
+ * ```
61
+ */
62
+ export function useCollectionContext<
63
+ TFieldName extends string = string,
64
+ >(): UseCollectionReturn<TFieldName> {
65
+ const ctx = useContext(CollectionContext);
66
+ if (!ctx) {
67
+ throw new Error(
68
+ "useCollectionContext must be used within <Collection.Provider>",
69
+ );
70
+ }
71
+ return ctx as UseCollectionReturn<TFieldName>;
72
+ }
73
+
74
+
75
+
76
+ /**
77
+ * `Collection` namespace object providing the Provider component.
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * <Collection.Provider value={collection}>
82
+ * ...
83
+ * </Collection.Provider>
84
+ * ```
85
+ */
86
+ export const Collection = {
87
+ Provider: CollectionProvider,
88
+ } as const;
89
+
90
+
@@ -1,16 +1,18 @@
1
1
  import { renderHook, act } from "@testing-library/react";
2
2
  import { describe, it, expect } from "vitest";
3
3
  import type { TableMetadataMap } from "../../generator/metadata-generator";
4
- import { useCollectionParams } from "./use-collection-params";
4
+ import { useCollection } from "./use-collection";
5
5
 
6
- describe("useCollectionParams", () => {
6
+ const FAKE_QUERY = { kind: "Document" } as const;
7
+
8
+ describe("useCollection", () => {
7
9
  // ---------------------------------------------------------------------------
8
10
  // Initial state
9
11
  // ---------------------------------------------------------------------------
10
12
  describe("initial state", () => {
11
13
  it("returns default variables with pageSize 20", () => {
12
- const { result } = renderHook(() => useCollectionParams());
13
- expect(result.current.variables).toEqual({ first: 20 });
14
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
15
+ expect(result.current.toQueryArgs().variables).toEqual({ first: 20 });
14
16
  expect(result.current.filters).toEqual([]);
15
17
  expect(result.current.sortStates).toEqual([]);
16
18
  expect(result.current.cursor).toBeNull();
@@ -19,39 +21,45 @@ describe("useCollectionParams", () => {
19
21
 
20
22
  it("uses custom pageSize", () => {
21
23
  const { result } = renderHook(() =>
22
- useCollectionParams({ pageSize: 50 }),
24
+ useCollection({ query: FAKE_QUERY, params: { pageSize: 50 } }),
23
25
  );
24
- expect(result.current.variables.first).toBe(50);
26
+ expect(result.current.toQueryArgs().variables.first).toBe(50);
25
27
  });
26
28
 
27
29
  it("applies initial sort", () => {
28
30
  const { result } = renderHook(() =>
29
- useCollectionParams({
30
- initialSort: [{ field: "createdAt", direction: "Desc" }],
31
+ useCollection({
32
+ query: FAKE_QUERY,
33
+ params: {
34
+ initialSort: [{ field: "createdAt", direction: "Desc" }],
35
+ },
31
36
  }),
32
37
  );
33
38
  expect(result.current.sortStates).toEqual([
34
39
  { field: "createdAt", direction: "Desc" },
35
40
  ]);
36
- expect(result.current.variables.order).toEqual([
41
+ expect(result.current.toQueryArgs().variables.order).toEqual([
37
42
  { field: "createdAt", direction: "Desc" },
38
43
  ]);
39
44
  });
40
45
 
41
46
  it("applies initial filters", () => {
42
47
  const { result } = renderHook(() =>
43
- useCollectionParams({
44
- initialFilters: [
45
- {
46
- field: "status",
47
- operator: "eq",
48
- value: "ACTIVE",
49
- },
50
- ],
48
+ useCollection({
49
+ query: FAKE_QUERY,
50
+ params: {
51
+ initialFilters: [
52
+ {
53
+ field: "status",
54
+ operator: "eq",
55
+ value: "ACTIVE",
56
+ },
57
+ ],
58
+ },
51
59
  }),
52
60
  );
53
61
  expect(result.current.filters).toHaveLength(1);
54
- expect(result.current.variables.query).toEqual({
62
+ expect(result.current.toQueryArgs().variables.query).toEqual({
55
63
  status: { eq: "ACTIVE" },
56
64
  });
57
65
  });
@@ -62,7 +70,7 @@ describe("useCollectionParams", () => {
62
70
  // ---------------------------------------------------------------------------
63
71
  describe("filter operations", () => {
64
72
  it("adds a filter", () => {
65
- const { result } = renderHook(() => useCollectionParams());
73
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
66
74
 
67
75
  act(() => {
68
76
  result.current.addFilter("status", "ACTIVE", "eq");
@@ -74,13 +82,13 @@ describe("useCollectionParams", () => {
74
82
  operator: "eq",
75
83
  value: "ACTIVE",
76
84
  });
77
- expect(result.current.variables.query).toEqual({
85
+ expect(result.current.toQueryArgs().variables.query).toEqual({
78
86
  status: { eq: "ACTIVE" },
79
87
  });
80
88
  });
81
89
 
82
90
  it("replaces filter for same field", () => {
83
- const { result } = renderHook(() => useCollectionParams());
91
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
84
92
 
85
93
  act(() => {
86
94
  result.current.addFilter("status", "ACTIVE", "eq");
@@ -94,7 +102,7 @@ describe("useCollectionParams", () => {
94
102
  });
95
103
 
96
104
  it("sets filters in bulk", () => {
97
- const { result } = renderHook(() => useCollectionParams());
105
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
98
106
 
99
107
  act(() => {
100
108
  result.current.setFilters([
@@ -112,14 +120,14 @@ describe("useCollectionParams", () => {
112
120
  });
113
121
 
114
122
  expect(result.current.filters).toHaveLength(2);
115
- expect(result.current.variables.query).toEqual({
123
+ expect(result.current.toQueryArgs().variables.query).toEqual({
116
124
  status: { eq: "ACTIVE" },
117
125
  amount: { gte: 1000 },
118
126
  });
119
127
  });
120
128
 
121
129
  it("removes a filter", () => {
122
- const { result } = renderHook(() => useCollectionParams());
130
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
123
131
 
124
132
  act(() => {
125
133
  result.current.addFilter("status", "ACTIVE", "eq");
@@ -134,7 +142,7 @@ describe("useCollectionParams", () => {
134
142
  });
135
143
 
136
144
  it("clears all filters", () => {
137
- const { result } = renderHook(() => useCollectionParams());
145
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
138
146
 
139
147
  act(() => {
140
148
  result.current.addFilter("status", "ACTIVE", "eq");
@@ -145,11 +153,11 @@ describe("useCollectionParams", () => {
145
153
  });
146
154
 
147
155
  expect(result.current.filters).toHaveLength(0);
148
- expect(result.current.variables.query).toBeUndefined();
156
+ expect(result.current.toQueryArgs().variables.query).toBeUndefined();
149
157
  });
150
158
 
151
159
  it("resets pagination when filters change", () => {
152
- const { result } = renderHook(() => useCollectionParams());
160
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
153
161
 
154
162
  // Navigate to next page
155
163
  act(() => {
@@ -171,7 +179,7 @@ describe("useCollectionParams", () => {
171
179
  // ---------------------------------------------------------------------------
172
180
  describe("sort operations", () => {
173
181
  it("sets sort", () => {
174
- const { result } = renderHook(() => useCollectionParams());
182
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
175
183
 
176
184
  act(() => {
177
185
  result.current.setSort("createdAt", "Desc");
@@ -180,13 +188,13 @@ describe("useCollectionParams", () => {
180
188
  expect(result.current.sortStates).toEqual([
181
189
  { field: "createdAt", direction: "Desc" },
182
190
  ]);
183
- expect(result.current.variables.order).toEqual([
191
+ expect(result.current.toQueryArgs().variables.order).toEqual([
184
192
  { field: "createdAt", direction: "Desc" },
185
193
  ]);
186
194
  });
187
195
 
188
196
  it("replaces sort by default", () => {
189
- const { result } = renderHook(() => useCollectionParams());
197
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
190
198
 
191
199
  act(() => {
192
200
  result.current.setSort("createdAt", "Desc");
@@ -201,7 +209,7 @@ describe("useCollectionParams", () => {
201
209
  });
202
210
 
203
211
  it("appends sort with append=true (multi-sort)", () => {
204
- const { result } = renderHook(() => useCollectionParams());
212
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
205
213
 
206
214
  act(() => {
207
215
  result.current.setSort("createdAt", "Desc");
@@ -217,7 +225,7 @@ describe("useCollectionParams", () => {
217
225
  });
218
226
 
219
227
  it("clears sort", () => {
220
- const { result } = renderHook(() => useCollectionParams());
228
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
221
229
 
222
230
  act(() => {
223
231
  result.current.setSort("createdAt", "Desc");
@@ -227,7 +235,7 @@ describe("useCollectionParams", () => {
227
235
  });
228
236
 
229
237
  expect(result.current.sortStates).toEqual([]);
230
- expect(result.current.variables.order).toBeUndefined();
238
+ expect(result.current.toQueryArgs().variables.order).toBeUndefined();
231
239
  });
232
240
  });
233
241
 
@@ -236,7 +244,7 @@ describe("useCollectionParams", () => {
236
244
  // ---------------------------------------------------------------------------
237
245
  describe("pagination operations", () => {
238
246
  it("navigates to next page", () => {
239
- const { result } = renderHook(() => useCollectionParams());
247
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
240
248
 
241
249
  act(() => {
242
250
  result.current.nextPage("cursor1");
@@ -244,11 +252,11 @@ describe("useCollectionParams", () => {
244
252
 
245
253
  expect(result.current.cursor).toBe("cursor1");
246
254
  expect(result.current.hasPrevPage).toBe(true);
247
- expect(result.current.variables.after).toBe("cursor1");
255
+ expect(result.current.toQueryArgs().variables.after).toBe("cursor1");
248
256
  });
249
257
 
250
258
  it("navigates back to previous page", () => {
251
- const { result } = renderHook(() => useCollectionParams());
259
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
252
260
 
253
261
  act(() => {
254
262
  result.current.nextPage("cursor1");
@@ -265,7 +273,7 @@ describe("useCollectionParams", () => {
265
273
  });
266
274
 
267
275
  it("navigates back to first page", () => {
268
- const { result } = renderHook(() => useCollectionParams());
276
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
269
277
 
270
278
  act(() => {
271
279
  result.current.nextPage("cursor1");
@@ -279,7 +287,7 @@ describe("useCollectionParams", () => {
279
287
  });
280
288
 
281
289
  it("resets page", () => {
282
- const { result } = renderHook(() => useCollectionParams());
290
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
283
291
 
284
292
  act(() => {
285
293
  result.current.nextPage("cursor1");
@@ -295,21 +303,24 @@ describe("useCollectionParams", () => {
295
303
  });
296
304
 
297
305
  // ---------------------------------------------------------------------------
298
- // variables generation
306
+ // toQueryArgs
299
307
  // ---------------------------------------------------------------------------
300
- describe("variables generation", () => {
308
+ describe("toQueryArgs", () => {
301
309
  it("generates complete variables with filters, sort, and cursor", () => {
302
310
  const { result } = renderHook(() =>
303
- useCollectionParams({
304
- pageSize: 10,
305
- initialFilters: [
306
- {
307
- field: "status",
308
- operator: "eq",
309
- value: "ACTIVE",
310
- },
311
- ],
312
- initialSort: [{ field: "createdAt", direction: "Desc" }],
311
+ useCollection({
312
+ query: FAKE_QUERY,
313
+ params: {
314
+ pageSize: 10,
315
+ initialFilters: [
316
+ {
317
+ field: "status",
318
+ operator: "eq",
319
+ value: "ACTIVE",
320
+ },
321
+ ],
322
+ initialSort: [{ field: "createdAt", direction: "Desc" }],
323
+ },
313
324
  }),
314
325
  );
315
326
 
@@ -317,21 +328,37 @@ describe("useCollectionParams", () => {
317
328
  result.current.nextPage("abc123");
318
329
  });
319
330
 
320
- expect(result.current.variables).toEqual({
321
- first: 10,
322
- query: { status: { eq: "ACTIVE" } },
323
- order: [{ field: "createdAt", direction: "Desc" }],
324
- after: "abc123",
331
+ expect(result.current.toQueryArgs()).toEqual({
332
+ query: FAKE_QUERY,
333
+ variables: {
334
+ first: 10,
335
+ query: { status: { eq: "ACTIVE" } },
336
+ order: [{ field: "createdAt", direction: "Desc" }],
337
+ after: "abc123",
338
+ },
325
339
  });
326
340
  });
327
341
 
328
342
  it("omits undefined fields from variables", () => {
329
- const { result } = renderHook(() => useCollectionParams());
330
- const vars = result.current.variables;
331
- expect(vars).toEqual({ first: 20 });
332
- expect("query" in vars).toBe(false);
333
- expect("order" in vars).toBe(false);
334
- expect("after" in vars).toBe(false);
343
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
344
+ const { variables } = result.current.toQueryArgs();
345
+ expect(variables).toEqual({ first: 20 });
346
+ expect("query" in variables).toBe(false);
347
+ expect("order" in variables).toBe(false);
348
+ expect("after" in variables).toBe(false);
349
+ });
350
+
351
+ it("includes query in toQueryArgs result", () => {
352
+ const fakeQuery = { kind: "Document" } as const;
353
+ const { result } = renderHook(() =>
354
+ useCollection({ query: fakeQuery, params: { pageSize: 10 } }),
355
+ );
356
+
357
+ const args = result.current.toQueryArgs();
358
+ expect(args).toEqual({
359
+ query: fakeQuery,
360
+ variables: { first: 10 },
361
+ });
335
362
  });
336
363
  });
337
364
 
@@ -361,21 +388,25 @@ describe("useCollectionParams", () => {
361
388
 
362
389
  it("works with metadata and tableName", () => {
363
390
  const { result } = renderHook(() =>
364
- useCollectionParams({
391
+ useCollection({
365
392
  metadata: testMetadata,
366
393
  tableName: "task",
367
- pageSize: 10,
394
+ query: FAKE_QUERY,
395
+ params: { pageSize: 10 },
368
396
  }),
369
397
  );
370
- expect(result.current.variables).toEqual({ first: 10 });
398
+ expect(result.current.toQueryArgs().variables).toEqual({ first: 10 });
371
399
  });
372
400
 
373
401
  it("applies typed initialSort", () => {
374
402
  const { result } = renderHook(() =>
375
- useCollectionParams({
403
+ useCollection({
376
404
  metadata: testMetadata,
377
405
  tableName: "task",
378
- initialSort: [{ field: "dueDate", direction: "Desc" }],
406
+ query: FAKE_QUERY,
407
+ params: {
408
+ initialSort: [{ field: "dueDate", direction: "Desc" }],
409
+ },
379
410
  }),
380
411
  );
381
412
 
@@ -6,12 +6,22 @@ import type {
6
6
  MetadataFilter,
7
7
  QueryVariables,
8
8
  SortState,
9
- UseCollectionParamsOptions,
10
- UseCollectionParamsReturn,
9
+ UseCollectionOptions,
10
+ UseCollectionReturn,
11
11
  ExtractQueryVariables,
12
12
  } from "../types";
13
13
  import type { FieldName } from "../types";
14
14
 
15
+ /**
16
+ * Resolves query variables type: uses `ExtractQueryVariables<TQuery>` when
17
+ * the query document carries a `__variablesType` brand (e.g. gql-tada),
18
+ * otherwise falls back to `QueryVariables<TFieldName>`.
19
+ */
20
+ type ResolveVariables<TQuery, TFieldName extends string = string> =
21
+ ExtractQueryVariables<TQuery> extends never
22
+ ? QueryVariables<TFieldName>
23
+ : ExtractQueryVariables<TQuery>;
24
+
15
25
  // -----------------------------------------------------------------------------
16
26
  // Overload signatures
17
27
  // -----------------------------------------------------------------------------
@@ -20,93 +30,91 @@ import type { FieldName } from "../types";
20
30
  * Hook for managing collection query parameters (filters, sort, pagination)
21
31
  * with metadata-based field name typing and automatic `fieldType` detection.
22
32
  *
23
- * When `query` is provided, the output `variables` is typed to match
24
- * the GraphQL query's expected variables (e.g. `VariablesOf<typeof QUERY>`).
25
- * The `query` value is only used for type inference and ignored at runtime.
33
+ * `toQueryArgs()` returns `{ query, variables }` so the result can be
34
+ * spread directly into `useQuery()`.
26
35
  *
27
36
  * @example
28
37
  * ```tsx
29
38
  * import { tableMetadata } from "./generated/data-viewer-metadata.generated";
30
39
  *
31
- * // Without query — variables typed as QueryVariables<FieldName>
32
- * const params = useCollectionParams({
33
- * metadata: tableMetadata,
34
- * tableName: "task",
35
- * pageSize: 20,
36
- * });
37
- *
38
- * // With query — variables typed as VariablesOf<typeof GET_TASKS>
39
- * const params = useCollectionParams({
40
+ * const collection = useCollection({
40
41
  * metadata: tableMetadata,
41
42
  * tableName: "task",
42
43
  * query: GET_TASKS,
43
- * pageSize: 20,
44
+ * params: { pageSize: 20 },
44
45
  * });
46
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
45
47
  * ```
46
48
  */
47
- export function useCollectionParams<
49
+ export function useCollection<
48
50
  const TMetadata extends TableMetadataMap,
49
51
  TTableName extends string & keyof TMetadata,
50
- TQuery = never,
52
+ TQuery,
51
53
  >(
52
- options: UseCollectionParamsOptions<
54
+ options: UseCollectionOptions<
53
55
  FieldName<TMetadata, TTableName>,
54
56
  MetadataFilter<TMetadata, TTableName>
55
57
  > & {
56
58
  metadata: TMetadata;
57
59
  tableName: TTableName;
58
- query?: TQuery;
60
+ query: TQuery;
59
61
  },
60
- ): UseCollectionParamsReturn<
62
+ ): UseCollectionReturn<
61
63
  FieldName<TMetadata, TTableName>,
62
- [TQuery] extends [never]
63
- ? QueryVariables<FieldName<TMetadata, TTableName>>
64
- : ExtractQueryVariables<TQuery>
64
+ ResolveVariables<TQuery, FieldName<TMetadata, TTableName>>,
65
+ {
66
+ query: TQuery;
67
+ variables: ResolveVariables<TQuery, FieldName<TMetadata, TTableName>>;
68
+ }
65
69
  >;
66
70
 
67
71
  /**
68
72
  * Hook for managing collection query parameters (filters, sort, pagination).
69
73
  *
70
74
  * Produces `variables` in Tailor Platform format that can be passed directly
71
- * to a GraphQL query (e.g. urql's `useQuery`).
75
+ * to a GraphQL query (e.g. urql's `useQuery`) via `toQueryArgs()`.
72
76
  *
73
- * When `query` is provided, the output `variables` is typed to match
74
- * the GraphQL query's expected variables.
77
+ * `toQueryArgs()` returns `{ query, variables }` so the result can be spread
78
+ * directly into `useQuery()`.
75
79
  *
76
80
  * @example
77
81
  * ```tsx
78
- * const params = useCollectionParams({ pageSize: 20 });
79
- * const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
80
- *
81
- * // With query — variables auto-typed
82
- * const params = useCollectionParams({ query: GET_ORDERS, pageSize: 20 });
82
+ * const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
83
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
83
84
  * ```
84
85
  */
85
- export function useCollectionParams<TQuery = never>(
86
- options?: UseCollectionParamsOptions & {
87
- query?: TQuery;
86
+ export function useCollection<TQuery>(
87
+ options: UseCollectionOptions & {
88
+ query: TQuery;
88
89
  metadata?: never;
89
90
  tableName?: never;
90
91
  },
91
- ): UseCollectionParamsReturn<
92
+ ): UseCollectionReturn<
92
93
  string,
93
- [TQuery] extends [never] ? QueryVariables : ExtractQueryVariables<TQuery>
94
+ ResolveVariables<TQuery>,
95
+ { query: TQuery; variables: ResolveVariables<TQuery> }
94
96
  >;
95
97
 
96
98
  // -----------------------------------------------------------------------------
97
99
  // Implementation
98
100
  // -----------------------------------------------------------------------------
99
- export function useCollectionParams(
100
- options: UseCollectionParamsOptions & {
101
+ export function useCollection(
102
+ options: UseCollectionOptions & {
101
103
  metadata?: TableMetadataMap;
102
104
  tableName?: string;
103
- } = {},
104
- ): UseCollectionParamsReturn {
105
+ query: unknown;
106
+ },
107
+ ): UseCollectionReturn<
108
+ string,
109
+ QueryVariables,
110
+ { query: unknown; variables: QueryVariables }
111
+ > {
112
+ const { params = {}, query: queryDocument } = options;
105
113
  const {
106
114
  initialFilters = [],
107
115
  initialSort = [],
108
116
  pageSize: initialPageSize = 20,
109
- } = options;
117
+ } = params;
110
118
 
111
119
  // ---------------------------------------------------------------------------
112
120
  // State
@@ -229,11 +237,11 @@ export function useCollectionParams(
229
237
 
230
238
  // Build query (filters)
231
239
  if (filters.length > 0) {
232
- const query: Record<string, unknown> = {};
240
+ const filterQuery: Record<string, unknown> = {};
233
241
  for (const filter of filters) {
234
- query[filter.field] = { [filter.operator]: filter.value };
242
+ filterQuery[filter.field] = { [filter.operator]: filter.value };
235
243
  }
236
- vars.query = query;
244
+ vars.query = filterQuery;
237
245
  }
238
246
 
239
247
  // Build order (sort)
@@ -252,11 +260,18 @@ export function useCollectionParams(
252
260
  return vars;
253
261
  }, [filters, sortStates, pageSize, cursor]);
254
262
 
263
+ // ---------------------------------------------------------------------------
264
+ // toQueryArgs
265
+ // ---------------------------------------------------------------------------
266
+ const toQueryArgs = useCallback(() => {
267
+ return { query: queryDocument, variables };
268
+ }, [queryDocument, variables]);
269
+
255
270
  // ---------------------------------------------------------------------------
256
271
  // Return
257
272
  // ---------------------------------------------------------------------------
258
273
  return {
259
- variables,
274
+ toQueryArgs,
260
275
  filters,
261
276
  addFilter,
262
277
  setFilters,
@@ -302,7 +302,7 @@ function formatValue(value: unknown): ReactNode {
302
302
  *
303
303
  * @example
304
304
  * ```tsx
305
- * const table = useDataTable({ columns, data, loading, collectionParams: params });
305
+ * const table = useDataTable({ columns, data, loading, collection });
306
306
  *
307
307
  * <DataTable.Root {...table.rootProps}>
308
308
  * <DataTable.Headers />
@@ -15,14 +15,14 @@ import type {
15
15
  *
16
16
  * @example
17
17
  * ```tsx
18
- * const params = useCollectionParams({ pageSize: 20 });
19
- * const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
18
+ * const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
19
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
20
20
  *
21
21
  * const table = useDataTable<Order>({
22
22
  * columns,
23
23
  * data: result.data?.orders,
24
24
  * loading: result.fetching,
25
- * collectionParams: params,
25
+ * collection,
26
26
  * });
27
27
  *
28
28
  * <DataTable.Root {...table.rootProps}>
@@ -39,7 +39,7 @@ export function useDataTable<TRow extends Record<string, unknown>>(
39
39
  data,
40
40
  loading = false,
41
41
  error = null,
42
- collectionParams,
42
+ collection,
43
43
  } = options;
44
44
 
45
45
  // ---------------------------------------------------------------------------
@@ -102,20 +102,20 @@ export function useDataTable<TRow extends Record<string, unknown>>(
102
102
  );
103
103
 
104
104
  // ---------------------------------------------------------------------------
105
- // Pagination (delegated from collectionParams)
105
+ // Pagination (delegated from collection)
106
106
  // ---------------------------------------------------------------------------
107
107
  const nextPage = useCallback(
108
108
  (endCursor: string) => {
109
- collectionParams?.nextPage(endCursor);
109
+ collection?.nextPage(endCursor);
110
110
  },
111
- [collectionParams],
111
+ [collection],
112
112
  );
113
113
 
114
114
  const prevPage = useCallback(() => {
115
- collectionParams?.prevPage();
116
- }, [collectionParams]);
115
+ collection?.prevPage();
116
+ }, [collection]);
117
117
 
118
- const hasPrevPage = collectionParams?.hasPrevPage ?? false;
118
+ const hasPrevPage = collection?.hasPrevPage ?? false;
119
119
 
120
120
  // ---------------------------------------------------------------------------
121
121
  // Row Operations (Optimistic Updates)
@@ -184,11 +184,11 @@ export function useDataTable<TRow extends Record<string, unknown>>(
184
184
  rows,
185
185
  loading,
186
186
  error,
187
- onSort: collectionParams
187
+ onSort: collection
188
188
  ? (field: string, direction?: "Asc" | "Desc") =>
189
- collectionParams.setSort(field, direction)
189
+ collection.setSort(field, direction)
190
190
  : undefined,
191
- sortStates: collectionParams?.sortStates,
191
+ sortStates: collection?.sortStates,
192
192
  rowOperations: { updateRow, deleteRow, insertRow },
193
193
  children: null,
194
194
  };
@@ -197,7 +197,7 @@ export function useDataTable<TRow extends Record<string, unknown>>(
197
197
  rows,
198
198
  loading,
199
199
  error,
200
- collectionParams,
200
+ collection,
201
201
  updateRow,
202
202
  deleteRow,
203
203
  insertRow,
@@ -19,8 +19,8 @@ export type {
19
19
  DisplayColumn,
20
20
  Column,
21
21
  ColumnDefinition,
22
- UseCollectionParamsOptions,
23
- UseCollectionParamsReturn,
22
+ UseCollectionOptions,
23
+ UseCollectionReturn,
24
24
  UseDataTableOptions,
25
25
  UseDataTableReturn,
26
26
  RowOperations,
@@ -47,13 +47,13 @@ export {
47
47
  fieldTypeToFilterConfig,
48
48
  } from "./types";
49
49
 
50
- // Collection Params
51
- export { useCollectionParams } from "./collection-params/use-collection-params";
50
+ // Collection
51
+ export { useCollection } from "./collection/use-collection";
52
52
  export {
53
- CollectionParams,
54
- CollectionParamsProvider,
55
- useCollectionParamsContext,
56
- } from "./collection-params/collection-params-provider";
53
+ Collection,
54
+ CollectionProvider,
55
+ useCollectionContext,
56
+ } from "./collection/collection-provider";
57
57
 
58
58
  // Table (static)
59
59
  export { Table } from "./table";
@@ -17,7 +17,7 @@ import { OPERATORS_BY_FILTER_TYPE, DEFAULT_OPERATOR_LABELS } from "./types";
17
17
  *
18
18
  * Renders a dropdown panel with type-specific filter inputs, operator
19
19
  * selectors, and active filter badges. Designed to receive props via
20
- * spread from `useDataTable()` / `useCollectionParams()`.
20
+ * spread from `useDataTable()` / `useCollection()`.
21
21
  *
22
22
  * All text is customisable through the optional `labels` prop (defaults
23
23
  * to English).
@@ -172,7 +172,7 @@ export interface PageInfo {
172
172
  * GraphQL query variables in Tailor Platform format.
173
173
  *
174
174
  * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
175
- * When metadata is provided to `useCollectionParams`, this
175
+ * When metadata is provided to `useCollection`, this
176
176
  * narrows `order[].field` to match the table's field names,
177
177
  * making the output directly compatible with gql-tada's
178
178
  * `VariablesOf<>` types.
@@ -320,47 +320,62 @@ export type ColumnDefinition<TRow extends Record<string, unknown>> =
320
320
  Column<TRow>;
321
321
 
322
322
  // =============================================================================
323
- // useCollectionParams Types
323
+ // useCollection Types
324
324
  // =============================================================================
325
325
 
326
326
  /**
327
- * Options for `useCollectionParams` hook.
327
+ * Options for `useCollection` hook.
328
328
  *
329
329
  * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
330
330
  */
331
- export interface UseCollectionParamsOptions<
331
+ export interface UseCollectionOptions<
332
332
  TFieldName extends string = string,
333
333
  TFilter extends Filter<TFieldName> = Filter<TFieldName>,
334
334
  > {
335
- /** Initial filters to apply */
336
- initialFilters?: TFilter[];
337
- /** Initial sort states */
338
- initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[];
339
- /** Number of items per page (default: 20) */
340
- pageSize?: number;
335
+ /** Collection parameters (pagination, sort, filters) */
336
+ params?: {
337
+ /** Initial filters to apply */
338
+ initialFilters?: TFilter[];
339
+ /** Initial sort states */
340
+ initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[];
341
+ /** Number of items per page (default: 20) */
342
+ pageSize?: number;
343
+ };
341
344
  }
342
345
 
343
346
  /**
344
- * Return type of `useCollectionParams` hook.
347
+ * Return type of `useCollection` hook.
345
348
  *
346
349
  * Methods that accept a field name are typed with `TFieldName` so that
347
350
  * auto-completion works when a concrete union is supplied.
348
351
  *
349
352
  * **Note:** Methods use *method syntax* (not property syntax) intentionally
350
- * so that `UseCollectionParamsReturn<"a" | "b">` remains assignable to
351
- * `UseCollectionParamsReturn<string>` (bivariant method check).
353
+ * so that `UseCollectionReturn<"a" | "b">` remains assignable to
354
+ * `UseCollectionReturn<string>` (bivariant method check).
352
355
  *
353
356
  * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
354
357
  * @typeParam TVariables - Type of the `variables` output (default: `QueryVariables<TFieldName>`).
355
358
  * Pass `VariablesOf<typeof YOUR_QUERY>` (gql-tada) to get
356
359
  * exact type compatibility with your GraphQL client.
360
+ * @typeParam TQueryArgs - Type returned by `toQueryArgs()`. Contains both
361
+ * `query` and `variables`.
357
362
  */
358
- export interface UseCollectionParamsReturn<
363
+ export interface UseCollectionReturn<
359
364
  TFieldName extends string = string,
360
365
  TVariables = QueryVariables<TFieldName>,
366
+ TQueryArgs = { query: unknown; variables: TVariables },
361
367
  > {
362
- /** Query variables in Tailor Platform format */
363
- variables: TVariables;
368
+ /**
369
+ * Returns query arguments (`{ query, variables }`) that can be spread
370
+ * directly into `useQuery()`.
371
+ *
372
+ * @example
373
+ * ```tsx
374
+ * const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
375
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
376
+ * ```
377
+ */
378
+ toQueryArgs(): TQueryArgs;
364
379
 
365
380
  // Filter operations
366
381
  /** Current active filters */
@@ -417,8 +432,8 @@ export interface UseDataTableOptions<TRow extends Record<string, unknown>> {
417
432
  loading?: boolean;
418
433
  /** Error */
419
434
  error?: Error | null;
420
- /** Collection params for sort integration */
421
- collectionParams?: UseCollectionParamsReturn<string, unknown>;
435
+ /** Collection state for sort/pagination integration */
436
+ collection?: UseCollectionReturn<string, unknown, unknown>;
422
437
  }
423
438
 
424
439
  /**
@@ -445,7 +460,7 @@ export interface DataTableRootProps<TRow extends Record<string, unknown>> {
445
460
  loading?: boolean;
446
461
  /** Error */
447
462
  error?: Error | null;
448
- /** Sort handler (connected to collectionParams) */
463
+ /** Sort handler (connected to collection) */
449
464
  onSort?: (field: string, direction?: "Asc" | "Desc") => void;
450
465
  /** Current sort states */
451
466
  sortStates?: SortState[];
@@ -498,7 +513,7 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
498
513
  /** Error */
499
514
  error: Error | null;
500
515
 
501
- // Pagination (delegated from collectionParams)
516
+ // Pagination (delegated from collection)
502
517
  /** Page info from GraphQL response */
503
518
  pageInfo: PageInfo;
504
519
  /** Navigate to next page */
@@ -1,83 +0,0 @@
1
- import { createContext, useContext, type ReactNode } from "react";
2
- import type { UseCollectionParamsReturn } from "../types";
3
-
4
- const CollectionParamsContext = createContext<UseCollectionParamsReturn<
5
- string,
6
- unknown
7
- > | null>(null);
8
-
9
- /**
10
- * Provider that shares collection query parameters via React Context.
11
- *
12
- * @example
13
- * ```tsx
14
- * const params = useCollectionParams({ pageSize: 20 });
15
- *
16
- * <CollectionParamsProvider value={params}>
17
- * <FilterPanel />
18
- * <DataTable.Root {...table.rootProps}>...</DataTable.Root>
19
- * <Pagination {...table} />
20
- * </CollectionParamsProvider>
21
- * ```
22
- */
23
- export function CollectionParamsProvider({
24
- value,
25
- children,
26
- }: {
27
- value: UseCollectionParamsReturn<string, unknown>;
28
- children: ReactNode;
29
- }) {
30
- return (
31
- <CollectionParamsContext.Provider value={value}>
32
- {children}
33
- </CollectionParamsContext.Provider>
34
- );
35
- }
36
-
37
- /**
38
- * Hook to access collection params from the nearest `CollectionParams.Provider`.
39
- *
40
- * Returns the same interface as `useCollectionParams()`. Pass a `TFieldName`
41
- * type parameter to narrow method arguments like `addFilter` / `setSort`.
42
- *
43
- * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
44
- *
45
- * @throws Error if used outside of `CollectionParams.Provider`.
46
- *
47
- * @example
48
- * ```tsx
49
- * function StatusFilter() {
50
- * const { filters, addFilter, removeFilter } = useCollectionParamsContext();
51
- * // ...
52
- * }
53
- *
54
- * // With typed field names:
55
- * type TaskField = FieldName<typeof tableMetadata, "task">;
56
- * const { addFilter } = useCollectionParamsContext<TaskField>();
57
- * ```
58
- */
59
- export function useCollectionParamsContext<
60
- TFieldName extends string = string,
61
- >(): UseCollectionParamsReturn<TFieldName> {
62
- const ctx = useContext(CollectionParamsContext);
63
- if (!ctx) {
64
- throw new Error(
65
- "useCollectionParamsContext must be used within <CollectionParams.Provider>",
66
- );
67
- }
68
- return ctx as UseCollectionParamsReturn<TFieldName>;
69
- }
70
-
71
- /**
72
- * `CollectionParams` namespace object providing the Provider component.
73
- *
74
- * @example
75
- * ```tsx
76
- * <CollectionParams.Provider value={params}>
77
- * ...
78
- * </CollectionParams.Provider>
79
- * ```
80
- */
81
- export const CollectionParams = {
82
- Provider: CollectionParamsProvider,
83
- } as const;