@izumisy-tailor/tailor-data-viewer 0.2.13 → 0.2.15
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/package.json +1 -1
- package/src/component/collection/use-collection.test.ts +43 -20
- package/src/component/collection/use-collection.ts +71 -36
- package/src/component/collection/use-collection.typetest.ts +140 -21
- package/src/component/data-table/use-data-table.test.ts +8 -1
- package/src/component/data-table/use-data-table.ts +23 -4
- package/src/component/pagination.tsx +7 -2
- package/src/component/types.ts +103 -16
package/package.json
CHANGED
|
@@ -243,7 +243,7 @@ describe("useCollection", () => {
|
|
|
243
243
|
// Pagination operations
|
|
244
244
|
// ---------------------------------------------------------------------------
|
|
245
245
|
describe("pagination operations", () => {
|
|
246
|
-
it("navigates to next page", () => {
|
|
246
|
+
it("navigates to next page (forward)", () => {
|
|
247
247
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
248
248
|
|
|
249
249
|
act(() => {
|
|
@@ -251,54 +251,75 @@ describe("useCollection", () => {
|
|
|
251
251
|
});
|
|
252
252
|
|
|
253
253
|
expect(result.current.cursor).toBe("cursor1");
|
|
254
|
-
expect(result.current.
|
|
254
|
+
expect(result.current.paginationDirection).toBe("forward");
|
|
255
255
|
expect(result.current.toQueryArgs().variables.after).toBe("cursor1");
|
|
256
|
+
expect(result.current.toQueryArgs().variables.first).toBe(20);
|
|
257
|
+
expect(result.current.toQueryArgs().variables.last).toBeUndefined();
|
|
258
|
+
expect(result.current.toQueryArgs().variables.before).toBeUndefined();
|
|
256
259
|
});
|
|
257
260
|
|
|
258
|
-
it("navigates
|
|
261
|
+
it("navigates to previous page (backward)", () => {
|
|
259
262
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
260
263
|
|
|
261
264
|
act(() => {
|
|
262
|
-
result.current.
|
|
263
|
-
});
|
|
264
|
-
act(() => {
|
|
265
|
-
result.current.nextPage("cursor2");
|
|
266
|
-
});
|
|
267
|
-
act(() => {
|
|
268
|
-
result.current.prevPage();
|
|
265
|
+
result.current.prevPage("cursor1");
|
|
269
266
|
});
|
|
270
267
|
|
|
271
268
|
expect(result.current.cursor).toBe("cursor1");
|
|
272
|
-
expect(result.current.
|
|
269
|
+
expect(result.current.paginationDirection).toBe("backward");
|
|
270
|
+
expect(result.current.toQueryArgs().variables.before).toBe("cursor1");
|
|
271
|
+
expect(result.current.toQueryArgs().variables.last).toBe(20);
|
|
272
|
+
expect(result.current.toQueryArgs().variables.first).toBeUndefined();
|
|
273
|
+
expect(result.current.toQueryArgs().variables.after).toBeUndefined();
|
|
273
274
|
});
|
|
274
275
|
|
|
275
|
-
it("
|
|
276
|
+
it("switches direction on nextPage after prevPage", () => {
|
|
276
277
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
277
278
|
|
|
278
279
|
act(() => {
|
|
279
|
-
result.current.
|
|
280
|
+
result.current.prevPage("cursorB");
|
|
280
281
|
});
|
|
282
|
+
expect(result.current.paginationDirection).toBe("backward");
|
|
283
|
+
|
|
281
284
|
act(() => {
|
|
282
|
-
result.current.
|
|
285
|
+
result.current.nextPage("cursorA");
|
|
283
286
|
});
|
|
284
|
-
|
|
285
|
-
expect(result.current.
|
|
286
|
-
expect(result.current.
|
|
287
|
+
expect(result.current.paginationDirection).toBe("forward");
|
|
288
|
+
expect(result.current.toQueryArgs().variables.after).toBe("cursorA");
|
|
289
|
+
expect(result.current.toQueryArgs().variables.first).toBe(20);
|
|
287
290
|
});
|
|
288
291
|
|
|
289
|
-
it("resets page", () => {
|
|
292
|
+
it("resets page to forward direction", () => {
|
|
290
293
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
291
294
|
|
|
292
295
|
act(() => {
|
|
293
|
-
result.current.
|
|
294
|
-
result.current.nextPage("cursor2");
|
|
296
|
+
result.current.prevPage("cursor1");
|
|
295
297
|
});
|
|
296
298
|
act(() => {
|
|
297
299
|
result.current.resetPage();
|
|
298
300
|
});
|
|
299
301
|
|
|
300
302
|
expect(result.current.cursor).toBeNull();
|
|
303
|
+
expect(result.current.paginationDirection).toBe("forward");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("tracks hasPrevPage and hasNextPage from setPageInfo", () => {
|
|
307
|
+
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
308
|
+
|
|
301
309
|
expect(result.current.hasPrevPage).toBe(false);
|
|
310
|
+
expect(result.current.hasNextPage).toBe(false);
|
|
311
|
+
|
|
312
|
+
act(() => {
|
|
313
|
+
result.current.setPageInfo({
|
|
314
|
+
hasNextPage: true,
|
|
315
|
+
endCursor: "end",
|
|
316
|
+
hasPreviousPage: true,
|
|
317
|
+
startCursor: "start",
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
expect(result.current.hasPrevPage).toBe(true);
|
|
322
|
+
expect(result.current.hasNextPage).toBe(true);
|
|
302
323
|
});
|
|
303
324
|
});
|
|
304
325
|
|
|
@@ -346,6 +367,8 @@ describe("useCollection", () => {
|
|
|
346
367
|
expect("query" in variables).toBe(false);
|
|
347
368
|
expect("order" in variables).toBe(false);
|
|
348
369
|
expect("after" in variables).toBe(false);
|
|
370
|
+
expect("last" in variables).toBe(false);
|
|
371
|
+
expect("before" in variables).toBe(false);
|
|
349
372
|
});
|
|
350
373
|
|
|
351
374
|
it("includes query in toQueryArgs result", () => {
|
|
@@ -4,14 +4,33 @@ import type {
|
|
|
4
4
|
Filter,
|
|
5
5
|
FilterOperator,
|
|
6
6
|
MetadataFilter,
|
|
7
|
+
PageInfo,
|
|
7
8
|
QueryVariables,
|
|
8
9
|
SortState,
|
|
9
10
|
UseCollectionOptions,
|
|
10
11
|
UseCollectionReturn,
|
|
12
|
+
ExtractQueryVariables,
|
|
11
13
|
ValidateCollectionQuery,
|
|
12
14
|
} from "../types";
|
|
13
15
|
import type { FieldName, OrderableFieldName } from "../types";
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the variables type for `toQueryArgs()` return value.
|
|
19
|
+
*
|
|
20
|
+
* When the query document carries gql-tada type information
|
|
21
|
+
* (`ExtractQueryVariables` resolves to a concrete type), uses that type
|
|
22
|
+
* so the result is directly compatible with `useQuery()` from urql.
|
|
23
|
+
* Otherwise falls back to `QueryVariables`.
|
|
24
|
+
*
|
|
25
|
+
* Note: The runtime value is always `QueryVariables`, but the gql-tada
|
|
26
|
+
* variables type is structurally compatible (a supertype), so the cast
|
|
27
|
+
* is safe.
|
|
28
|
+
*/
|
|
29
|
+
type ResolveVariables<TQuery> =
|
|
30
|
+
ExtractQueryVariables<TQuery> extends never
|
|
31
|
+
? QueryVariables
|
|
32
|
+
: ExtractQueryVariables<TQuery>;
|
|
33
|
+
|
|
15
34
|
// -----------------------------------------------------------------------------
|
|
16
35
|
// Overload signatures
|
|
17
36
|
// -----------------------------------------------------------------------------
|
|
@@ -57,7 +76,7 @@ export function useCollection<
|
|
|
57
76
|
FieldName<TMetadata, TTableName>,
|
|
58
77
|
{
|
|
59
78
|
query: TQuery;
|
|
60
|
-
variables:
|
|
79
|
+
variables: ResolveVariables<TQuery>;
|
|
61
80
|
},
|
|
62
81
|
MetadataFilter<TMetadata, TTableName>
|
|
63
82
|
>;
|
|
@@ -83,7 +102,10 @@ export function useCollection<TQuery>(
|
|
|
83
102
|
metadata?: never;
|
|
84
103
|
tableName?: never;
|
|
85
104
|
},
|
|
86
|
-
): UseCollectionReturn<
|
|
105
|
+
): UseCollectionReturn<
|
|
106
|
+
string,
|
|
107
|
+
{ query: TQuery; variables: ResolveVariables<TQuery> }
|
|
108
|
+
>;
|
|
87
109
|
|
|
88
110
|
// -----------------------------------------------------------------------------
|
|
89
111
|
// Implementation
|
|
@@ -109,7 +131,15 @@ export function useCollection(
|
|
|
109
131
|
const [sortStates, setSortStates] = useState<SortState[]>(initialSort);
|
|
110
132
|
const [pageSize] = useState(initialPageSize);
|
|
111
133
|
const [cursor, setCursor] = useState<string | null>(null);
|
|
112
|
-
const [
|
|
134
|
+
const [paginationDirection, setPaginationDirection] = useState<
|
|
135
|
+
"forward" | "backward"
|
|
136
|
+
>("forward");
|
|
137
|
+
const [currentPageInfo, setCurrentPageInfo] = useState<PageInfo>({
|
|
138
|
+
hasNextPage: false,
|
|
139
|
+
endCursor: null,
|
|
140
|
+
hasPreviousPage: false,
|
|
141
|
+
startCursor: null,
|
|
142
|
+
});
|
|
113
143
|
|
|
114
144
|
// ---------------------------------------------------------------------------
|
|
115
145
|
// Filter operations
|
|
@@ -132,7 +162,7 @@ export function useCollection(
|
|
|
132
162
|
});
|
|
133
163
|
// Reset pagination when filters change
|
|
134
164
|
setCursor(null);
|
|
135
|
-
|
|
165
|
+
setPaginationDirection("forward");
|
|
136
166
|
},
|
|
137
167
|
[],
|
|
138
168
|
);
|
|
@@ -141,20 +171,20 @@ export function useCollection(
|
|
|
141
171
|
setFiltersState(newFilters);
|
|
142
172
|
// Reset pagination when filters change
|
|
143
173
|
setCursor(null);
|
|
144
|
-
|
|
174
|
+
setPaginationDirection("forward");
|
|
145
175
|
}, []);
|
|
146
176
|
|
|
147
177
|
const removeFilter = useCallback((field: string) => {
|
|
148
178
|
setFiltersState((prev) => prev.filter((f) => f.field !== field));
|
|
149
179
|
// Reset pagination when filters change
|
|
150
180
|
setCursor(null);
|
|
151
|
-
|
|
181
|
+
setPaginationDirection("forward");
|
|
152
182
|
}, []);
|
|
153
183
|
|
|
154
184
|
const clearFilters = useCallback(() => {
|
|
155
185
|
setFiltersState([]);
|
|
156
186
|
setCursor(null);
|
|
157
|
-
|
|
187
|
+
setPaginationDirection("forward");
|
|
158
188
|
}, []);
|
|
159
189
|
|
|
160
190
|
// ---------------------------------------------------------------------------
|
|
@@ -173,7 +203,7 @@ export function useCollection(
|
|
|
173
203
|
});
|
|
174
204
|
// Reset pagination when sort changes
|
|
175
205
|
setCursor(null);
|
|
176
|
-
|
|
206
|
+
setPaginationDirection("forward");
|
|
177
207
|
},
|
|
178
208
|
[],
|
|
179
209
|
);
|
|
@@ -181,45 +211,52 @@ export function useCollection(
|
|
|
181
211
|
const clearSort = useCallback(() => {
|
|
182
212
|
setSortStates([]);
|
|
183
213
|
setCursor(null);
|
|
184
|
-
|
|
214
|
+
setPaginationDirection("forward");
|
|
185
215
|
}, []);
|
|
186
216
|
|
|
187
217
|
// ---------------------------------------------------------------------------
|
|
188
218
|
// Pagination operations
|
|
189
219
|
// ---------------------------------------------------------------------------
|
|
190
|
-
const nextPage = useCallback(
|
|
191
|
-
(endCursor
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
},
|
|
195
|
-
[cursor],
|
|
196
|
-
);
|
|
220
|
+
const nextPage = useCallback((endCursor: string) => {
|
|
221
|
+
setCursor(endCursor);
|
|
222
|
+
setPaginationDirection("forward");
|
|
223
|
+
}, []);
|
|
197
224
|
|
|
198
|
-
const prevPage = useCallback(() => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const previousCursor = newHistory.pop();
|
|
202
|
-
setCursor(
|
|
203
|
-
previousCursor && previousCursor !== "" ? previousCursor : null,
|
|
204
|
-
);
|
|
205
|
-
return newHistory;
|
|
206
|
-
});
|
|
225
|
+
const prevPage = useCallback((startCursor: string) => {
|
|
226
|
+
setCursor(startCursor);
|
|
227
|
+
setPaginationDirection("backward");
|
|
207
228
|
}, []);
|
|
208
229
|
|
|
209
230
|
const resetPage = useCallback(() => {
|
|
210
231
|
setCursor(null);
|
|
211
|
-
|
|
232
|
+
setPaginationDirection("forward");
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
const setPageInfo = useCallback((pageInfo: PageInfo) => {
|
|
236
|
+
setCurrentPageInfo(pageInfo);
|
|
212
237
|
}, []);
|
|
213
238
|
|
|
214
|
-
const hasPrevPage =
|
|
239
|
+
const hasPrevPage = currentPageInfo.hasPreviousPage;
|
|
240
|
+
const hasNextPage = currentPageInfo.hasNextPage;
|
|
215
241
|
|
|
216
242
|
// ---------------------------------------------------------------------------
|
|
217
243
|
// Build query variables (Tailor Platform format)
|
|
218
244
|
// ---------------------------------------------------------------------------
|
|
219
245
|
const variables = useMemo<QueryVariables>(() => {
|
|
220
|
-
const vars: QueryVariables = {
|
|
221
|
-
|
|
222
|
-
|
|
246
|
+
const vars: QueryVariables = {};
|
|
247
|
+
|
|
248
|
+
// Pagination direction determines first/after vs last/before
|
|
249
|
+
if (paginationDirection === "forward") {
|
|
250
|
+
vars.first = pageSize;
|
|
251
|
+
if (cursor) {
|
|
252
|
+
vars.after = cursor;
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
vars.last = pageSize;
|
|
256
|
+
if (cursor) {
|
|
257
|
+
vars.before = cursor;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
223
260
|
|
|
224
261
|
// Build query (filters)
|
|
225
262
|
if (filters.length > 0) {
|
|
@@ -238,13 +275,8 @@ export function useCollection(
|
|
|
238
275
|
}));
|
|
239
276
|
}
|
|
240
277
|
|
|
241
|
-
// Cursor
|
|
242
|
-
if (cursor) {
|
|
243
|
-
vars.after = cursor;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
278
|
return vars;
|
|
247
|
-
}, [filters, sortStates, pageSize, cursor]);
|
|
279
|
+
}, [filters, sortStates, pageSize, cursor, paginationDirection]);
|
|
248
280
|
|
|
249
281
|
// ---------------------------------------------------------------------------
|
|
250
282
|
// toQueryArgs
|
|
@@ -270,9 +302,12 @@ export function useCollection(
|
|
|
270
302
|
clearSort,
|
|
271
303
|
pageSize,
|
|
272
304
|
cursor,
|
|
305
|
+
paginationDirection,
|
|
273
306
|
nextPage,
|
|
274
307
|
prevPage,
|
|
275
308
|
resetPage,
|
|
276
309
|
hasPrevPage,
|
|
310
|
+
hasNextPage,
|
|
311
|
+
setPageInfo,
|
|
277
312
|
};
|
|
278
313
|
}
|
|
@@ -34,6 +34,8 @@ type FakeDoc<Variables> = {
|
|
|
34
34
|
type DocOk = FakeDoc<{
|
|
35
35
|
first?: number | null;
|
|
36
36
|
after?: string | null;
|
|
37
|
+
last?: number | null;
|
|
38
|
+
before?: string | null;
|
|
37
39
|
query?: { name?: unknown } | null;
|
|
38
40
|
order?: readonly { field?: "name" | null }[] | null;
|
|
39
41
|
}>;
|
|
@@ -44,12 +46,80 @@ export const assert1: Assert1 = true;
|
|
|
44
46
|
// ❌ Query missing $first should fail
|
|
45
47
|
type DocNoFirst = FakeDoc<{
|
|
46
48
|
after?: string | null;
|
|
49
|
+
last?: number | null;
|
|
50
|
+
before?: string | null;
|
|
47
51
|
query?: { name?: unknown } | null;
|
|
52
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
48
53
|
}>;
|
|
49
54
|
type Fail1 = ValidateCollectionQuery<DocNoFirst, "name">;
|
|
50
55
|
type AssertFail1 = HasCollectionQueryError<Fail1> extends true ? true : never;
|
|
51
56
|
export const assertFail1: AssertFail1 = true;
|
|
52
57
|
|
|
58
|
+
// ❌ Query missing $after should fail
|
|
59
|
+
type DocNoAfter = FakeDoc<{
|
|
60
|
+
first?: number | null;
|
|
61
|
+
last?: number | null;
|
|
62
|
+
before?: string | null;
|
|
63
|
+
query?: { name?: unknown } | null;
|
|
64
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
65
|
+
}>;
|
|
66
|
+
type FailNoAfter = ValidateCollectionQuery<DocNoAfter, "name">;
|
|
67
|
+
type AssertFailNoAfter =
|
|
68
|
+
HasCollectionQueryError<FailNoAfter> extends true ? true : never;
|
|
69
|
+
export const assertFailNoAfter: AssertFailNoAfter = true;
|
|
70
|
+
|
|
71
|
+
// ❌ Query missing $last should fail
|
|
72
|
+
type DocNoLast = FakeDoc<{
|
|
73
|
+
first?: number | null;
|
|
74
|
+
after?: string | null;
|
|
75
|
+
before?: string | null;
|
|
76
|
+
query?: { name?: unknown } | null;
|
|
77
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
78
|
+
}>;
|
|
79
|
+
type FailNoLast = ValidateCollectionQuery<DocNoLast, "name">;
|
|
80
|
+
type AssertFailNoLast =
|
|
81
|
+
HasCollectionQueryError<FailNoLast> extends true ? true : never;
|
|
82
|
+
export const assertFailNoLast: AssertFailNoLast = true;
|
|
83
|
+
|
|
84
|
+
// ❌ Query missing $before should fail
|
|
85
|
+
type DocNoBefore = FakeDoc<{
|
|
86
|
+
first?: number | null;
|
|
87
|
+
after?: string | null;
|
|
88
|
+
last?: number | null;
|
|
89
|
+
query?: { name?: unknown } | null;
|
|
90
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
91
|
+
}>;
|
|
92
|
+
type FailNoBefore = ValidateCollectionQuery<DocNoBefore, "name">;
|
|
93
|
+
type AssertFailNoBefore =
|
|
94
|
+
HasCollectionQueryError<FailNoBefore> extends true ? true : never;
|
|
95
|
+
export const assertFailNoBefore: AssertFailNoBefore = true;
|
|
96
|
+
|
|
97
|
+
// ❌ Query missing $query should fail
|
|
98
|
+
type DocNoQuery = FakeDoc<{
|
|
99
|
+
first?: number | null;
|
|
100
|
+
after?: string | null;
|
|
101
|
+
last?: number | null;
|
|
102
|
+
before?: string | null;
|
|
103
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
104
|
+
}>;
|
|
105
|
+
type FailNoQuery = ValidateCollectionQuery<DocNoQuery, "name">;
|
|
106
|
+
type AssertFailNoQuery =
|
|
107
|
+
HasCollectionQueryError<FailNoQuery> extends true ? true : never;
|
|
108
|
+
export const assertFailNoQuery: AssertFailNoQuery = true;
|
|
109
|
+
|
|
110
|
+
// ❌ Query missing $order should fail
|
|
111
|
+
type DocNoOrder = FakeDoc<{
|
|
112
|
+
first?: number | null;
|
|
113
|
+
after?: string | null;
|
|
114
|
+
last?: number | null;
|
|
115
|
+
before?: string | null;
|
|
116
|
+
query?: { name?: unknown } | null;
|
|
117
|
+
}>;
|
|
118
|
+
type FailNoOrder = ValidateCollectionQuery<DocNoOrder, "name">;
|
|
119
|
+
type AssertFailNoOrder =
|
|
120
|
+
HasCollectionQueryError<FailNoOrder> extends true ? true : never;
|
|
121
|
+
export const assertFailNoOrder: AssertFailNoOrder = true;
|
|
122
|
+
|
|
53
123
|
// =============================================================================
|
|
54
124
|
// 2. OrderInput field compatibility
|
|
55
125
|
// =============================================================================
|
|
@@ -57,6 +127,10 @@ export const assertFail1: AssertFail1 = true;
|
|
|
57
127
|
// ❌ Metadata has "name" | "email" but OrderInput only allows "name"
|
|
58
128
|
type DocNarrowOrder = FakeDoc<{
|
|
59
129
|
first?: number | null;
|
|
130
|
+
after?: string | null;
|
|
131
|
+
last?: number | null;
|
|
132
|
+
before?: string | null;
|
|
133
|
+
query?: { name?: unknown; email?: unknown } | null;
|
|
60
134
|
order?: readonly { field?: "name" | null }[] | null;
|
|
61
135
|
}>;
|
|
62
136
|
type Fail2 = ValidateCollectionQuery<DocNarrowOrder, "name" | "email">;
|
|
@@ -66,6 +140,10 @@ export const assertFail2: AssertFail2 = true;
|
|
|
66
140
|
// ✅ OrderInput allows all metadata fields
|
|
67
141
|
type DocWideOrder = FakeDoc<{
|
|
68
142
|
first?: number | null;
|
|
143
|
+
after?: string | null;
|
|
144
|
+
last?: number | null;
|
|
145
|
+
before?: string | null;
|
|
146
|
+
query?: { name?: unknown; email?: unknown } | null;
|
|
69
147
|
order?: readonly { field?: "name" | "email" | "phone" | null }[] | null;
|
|
70
148
|
}>;
|
|
71
149
|
type Pass2 = ValidateCollectionQuery<DocWideOrder, "name" | "email">;
|
|
@@ -79,6 +157,9 @@ export const assertPass2: AssertPass2 = true;
|
|
|
79
157
|
// ❌ Metadata has "name" | "phone" but QueryInput only has "name"
|
|
80
158
|
type DocNarrowQuery = FakeDoc<{
|
|
81
159
|
first?: number | null;
|
|
160
|
+
after?: string | null;
|
|
161
|
+
last?: number | null;
|
|
162
|
+
before?: string | null;
|
|
82
163
|
query?: { name?: unknown } | null;
|
|
83
164
|
order?: readonly { field?: "name" | "phone" | null }[] | null;
|
|
84
165
|
}>;
|
|
@@ -89,6 +170,9 @@ export const assertFail3: AssertFail3 = true;
|
|
|
89
170
|
// ✅ QueryInput has all metadata fields
|
|
90
171
|
type DocWideQuery = FakeDoc<{
|
|
91
172
|
first?: number | null;
|
|
173
|
+
after?: string | null;
|
|
174
|
+
last?: number | null;
|
|
175
|
+
before?: string | null;
|
|
92
176
|
query?: { name?: unknown; phone?: unknown; email?: unknown } | null;
|
|
93
177
|
order?: readonly { field?: "name" | "phone" | "email" | null }[] | null;
|
|
94
178
|
}>;
|
|
@@ -100,20 +184,33 @@ export const assertPass3: AssertPass3 = true;
|
|
|
100
184
|
// 4. Variable name typo detection
|
|
101
185
|
// =============================================================================
|
|
102
186
|
|
|
103
|
-
// Query uses $filter instead of $query (typo) —
|
|
104
|
-
//
|
|
105
|
-
// But if the order is also correct, this particular typo would not be caught
|
|
106
|
-
// at the useCollection level. It WILL be caught when spreading into useQuery()
|
|
107
|
-
// because urql checks that variables match the document.
|
|
187
|
+
// ❌ Query uses $filter instead of $query (typo) — now caught because $query
|
|
188
|
+
// is required.
|
|
108
189
|
type DocFilterTypo = FakeDoc<{
|
|
109
190
|
first?: number | null;
|
|
191
|
+
after?: string | null;
|
|
192
|
+
last?: number | null;
|
|
193
|
+
before?: string | null;
|
|
110
194
|
filter?: { name?: unknown } | null;
|
|
111
195
|
order?: readonly { field?: "name" | null }[] | null;
|
|
112
196
|
}>;
|
|
113
197
|
type Typo1 = ValidateCollectionQuery<DocFilterTypo, "name">;
|
|
114
|
-
type AssertTypo1 = HasCollectionQueryError<Typo1> extends true ?
|
|
198
|
+
type AssertTypo1 = HasCollectionQueryError<Typo1> extends true ? true : never;
|
|
115
199
|
export const assertTypo1: AssertTypo1 = true;
|
|
116
200
|
|
|
201
|
+
// ❌ Query uses $queryy instead of $query (typo) — now caught
|
|
202
|
+
type DocQueryTypo = FakeDoc<{
|
|
203
|
+
first?: number | null;
|
|
204
|
+
after?: string | null;
|
|
205
|
+
last?: number | null;
|
|
206
|
+
before?: string | null;
|
|
207
|
+
queryy?: { name?: unknown } | null;
|
|
208
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
209
|
+
}>;
|
|
210
|
+
type Typo2 = ValidateCollectionQuery<DocQueryTypo, "name">;
|
|
211
|
+
type AssertTypo2 = HasCollectionQueryError<Typo2> extends true ? true : never;
|
|
212
|
+
export const assertTypo2: AssertTypo2 = true;
|
|
213
|
+
|
|
117
214
|
// =============================================================================
|
|
118
215
|
// 5. No gql-tada (plain DocumentNode) — all checks skipped
|
|
119
216
|
// =============================================================================
|
|
@@ -124,13 +221,26 @@ type AssertPass5 = HasCollectionQueryError<Pass5> extends true ? never : true;
|
|
|
124
221
|
export const assertPass5: AssertPass5 = true;
|
|
125
222
|
|
|
126
223
|
// =============================================================================
|
|
127
|
-
// 6. No metadata (string field names) — field-level checks skipped
|
|
224
|
+
// 6. No metadata (string field names) — field-level checks skipped but
|
|
225
|
+
// variable existence is still required
|
|
128
226
|
// =============================================================================
|
|
129
227
|
|
|
130
228
|
type Pass6 = ValidateCollectionQuery<DocOk>;
|
|
131
229
|
type AssertPass6 = HasCollectionQueryError<Pass6> extends true ? never : true;
|
|
132
230
|
export const assertPass6: AssertPass6 = true;
|
|
133
231
|
|
|
232
|
+
// ❌ No metadata but missing $query — still fails
|
|
233
|
+
type DocNoMetaNoQuery = FakeDoc<{
|
|
234
|
+
first?: number | null;
|
|
235
|
+
after?: string | null;
|
|
236
|
+
last?: number | null;
|
|
237
|
+
before?: string | null;
|
|
238
|
+
order?: readonly { field?: "name" | null }[] | null;
|
|
239
|
+
}>;
|
|
240
|
+
type Fail6 = ValidateCollectionQuery<DocNoMetaNoQuery>;
|
|
241
|
+
type AssertFail6 = HasCollectionQueryError<Fail6> extends true ? true : never;
|
|
242
|
+
export const assertFail6: AssertFail6 = true;
|
|
243
|
+
|
|
134
244
|
// =============================================================================
|
|
135
245
|
// 7. Helper type tests
|
|
136
246
|
// =============================================================================
|
|
@@ -150,36 +260,45 @@ export const qk1: QIKeys1 = "name";
|
|
|
150
260
|
export const qk2: QIKeys1 = "email";
|
|
151
261
|
|
|
152
262
|
// =============================================================================
|
|
153
|
-
// 8.
|
|
263
|
+
// 8. Missing $query — now fails (previously was "Mixed: $order only")
|
|
154
264
|
// =============================================================================
|
|
155
265
|
|
|
156
|
-
//
|
|
266
|
+
// ❌ Missing $query variable
|
|
157
267
|
type DocOrderOnly = FakeDoc<{
|
|
158
268
|
first?: number | null;
|
|
269
|
+
after?: string | null;
|
|
270
|
+
last?: number | null;
|
|
271
|
+
before?: string | null;
|
|
159
272
|
order?: readonly { field?: "name" | "email" | null }[] | null;
|
|
160
273
|
}>;
|
|
161
|
-
type
|
|
162
|
-
type
|
|
163
|
-
export const
|
|
274
|
+
type Fail8 = ValidateCollectionQuery<DocOrderOnly, "name" | "email">;
|
|
275
|
+
type AssertFail8 = HasCollectionQueryError<Fail8> extends true ? true : never;
|
|
276
|
+
export const assertFail8: AssertFail8 = true;
|
|
164
277
|
|
|
165
278
|
// =============================================================================
|
|
166
|
-
// 9.
|
|
279
|
+
// 9. Missing $order — now fails (previously was "Mixed: $query only")
|
|
167
280
|
// =============================================================================
|
|
168
281
|
|
|
169
|
-
//
|
|
282
|
+
// ❌ Missing $order variable
|
|
170
283
|
type DocQueryOnly = FakeDoc<{
|
|
171
284
|
first?: number | null;
|
|
285
|
+
after?: string | null;
|
|
286
|
+
last?: number | null;
|
|
287
|
+
before?: string | null;
|
|
172
288
|
query?: { name?: unknown; email?: unknown } | null;
|
|
173
289
|
}>;
|
|
174
|
-
type
|
|
175
|
-
type
|
|
176
|
-
export const
|
|
290
|
+
type Fail9 = ValidateCollectionQuery<DocQueryOnly, "name" | "email">;
|
|
291
|
+
type AssertFail9 = HasCollectionQueryError<Fail9> extends true ? true : never;
|
|
292
|
+
export const assertFail9: AssertFail9 = true;
|
|
177
293
|
|
|
178
|
-
// ❌
|
|
294
|
+
// ❌ Missing $order, and query keys don't cover metadata fields
|
|
179
295
|
type DocQueryOnlyMissing = FakeDoc<{
|
|
180
296
|
first?: number | null;
|
|
297
|
+
after?: string | null;
|
|
298
|
+
last?: number | null;
|
|
299
|
+
before?: string | null;
|
|
181
300
|
query?: { name?: unknown } | null;
|
|
182
301
|
}>;
|
|
183
|
-
type
|
|
184
|
-
type
|
|
185
|
-
export const
|
|
302
|
+
type Fail9b = ValidateCollectionQuery<DocQueryOnlyMissing, "name" | "email">;
|
|
303
|
+
type AssertFail9b = HasCollectionQueryError<Fail9b> extends true ? true : never;
|
|
304
|
+
export const assertFail9b: AssertFail9b = true;
|
|
@@ -51,7 +51,12 @@ const testData: CollectionResult<TestRow> = {
|
|
|
51
51
|
{ node: { id: "2", name: "Bob", status: "INACTIVE", amount: 200 } },
|
|
52
52
|
{ node: { id: "3", name: "Charlie", status: "ACTIVE", amount: 300 } },
|
|
53
53
|
],
|
|
54
|
-
pageInfo: {
|
|
54
|
+
pageInfo: {
|
|
55
|
+
hasNextPage: true,
|
|
56
|
+
endCursor: "cursor-3",
|
|
57
|
+
hasPreviousPage: false,
|
|
58
|
+
startCursor: "cursor-1",
|
|
59
|
+
},
|
|
55
60
|
};
|
|
56
61
|
|
|
57
62
|
describe("useDataTable", () => {
|
|
@@ -95,6 +100,8 @@ describe("useDataTable", () => {
|
|
|
95
100
|
expect(result.current.pageInfo).toEqual({
|
|
96
101
|
hasNextPage: true,
|
|
97
102
|
endCursor: "cursor-3",
|
|
103
|
+
hasPreviousPage: false,
|
|
104
|
+
startCursor: "cursor-1",
|
|
98
105
|
});
|
|
99
106
|
});
|
|
100
107
|
});
|
|
@@ -59,9 +59,23 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
59
59
|
}, [sourceRows]);
|
|
60
60
|
|
|
61
61
|
const pageInfo = useMemo<PageInfo>(() => {
|
|
62
|
-
return
|
|
62
|
+
return (
|
|
63
|
+
data?.pageInfo ?? {
|
|
64
|
+
hasNextPage: false,
|
|
65
|
+
endCursor: null,
|
|
66
|
+
hasPreviousPage: false,
|
|
67
|
+
startCursor: null,
|
|
68
|
+
}
|
|
69
|
+
);
|
|
63
70
|
}, [data]);
|
|
64
71
|
|
|
72
|
+
// Sync pageInfo to collection so hasPrevPage/hasNextPage are up-to-date
|
|
73
|
+
useMemo(() => {
|
|
74
|
+
if (data?.pageInfo) {
|
|
75
|
+
collection?.setPageInfo(data.pageInfo);
|
|
76
|
+
}
|
|
77
|
+
}, [data?.pageInfo, collection]);
|
|
78
|
+
|
|
65
79
|
// ---------------------------------------------------------------------------
|
|
66
80
|
// Column visibility management
|
|
67
81
|
// ---------------------------------------------------------------------------
|
|
@@ -111,11 +125,15 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
111
125
|
[collection],
|
|
112
126
|
);
|
|
113
127
|
|
|
114
|
-
const prevPage = useCallback(
|
|
115
|
-
|
|
116
|
-
|
|
128
|
+
const prevPage = useCallback(
|
|
129
|
+
(startCursor: string) => {
|
|
130
|
+
collection?.prevPage(startCursor);
|
|
131
|
+
},
|
|
132
|
+
[collection],
|
|
133
|
+
);
|
|
117
134
|
|
|
118
135
|
const hasPrevPage = collection?.hasPrevPage ?? false;
|
|
136
|
+
const hasNextPage = collection?.hasNextPage ?? false;
|
|
119
137
|
|
|
120
138
|
// ---------------------------------------------------------------------------
|
|
121
139
|
// Row Operations (Optimistic Updates)
|
|
@@ -240,6 +258,7 @@ export function useDataTable<TRow extends Record<string, unknown>>(
|
|
|
240
258
|
nextPage,
|
|
241
259
|
prevPage,
|
|
242
260
|
hasPrevPage,
|
|
261
|
+
hasNextPage,
|
|
243
262
|
|
|
244
263
|
// Column management
|
|
245
264
|
columns: allColumns,
|
|
@@ -15,13 +15,18 @@ export function Pagination({
|
|
|
15
15
|
nextPage,
|
|
16
16
|
prevPage,
|
|
17
17
|
hasPrevPage,
|
|
18
|
+
hasNextPage,
|
|
18
19
|
}: PaginationProps) {
|
|
19
20
|
return (
|
|
20
21
|
<div className="flex items-center justify-end gap-2 py-2">
|
|
21
22
|
<button
|
|
22
23
|
type="button"
|
|
23
24
|
className="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
|
|
24
|
-
onClick={
|
|
25
|
+
onClick={() => {
|
|
26
|
+
if (pageInfo.startCursor) {
|
|
27
|
+
prevPage(pageInfo.startCursor);
|
|
28
|
+
}
|
|
29
|
+
}}
|
|
25
30
|
disabled={!hasPrevPage}
|
|
26
31
|
>
|
|
27
32
|
Previous
|
|
@@ -34,7 +39,7 @@ export function Pagination({
|
|
|
34
39
|
nextPage(pageInfo.endCursor);
|
|
35
40
|
}
|
|
36
41
|
}}
|
|
37
|
-
disabled={!
|
|
42
|
+
disabled={!hasNextPage}
|
|
38
43
|
>
|
|
39
44
|
Next
|
|
40
45
|
</button>
|
package/src/component/types.ts
CHANGED
|
@@ -177,6 +177,8 @@ export interface SortState {
|
|
|
177
177
|
export interface PageInfo {
|
|
178
178
|
hasNextPage: boolean;
|
|
179
179
|
endCursor: string | null;
|
|
180
|
+
hasPreviousPage: boolean;
|
|
181
|
+
startCursor: string | null;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
// =============================================================================
|
|
@@ -211,8 +213,14 @@ export interface QueryVariables {
|
|
|
211
213
|
* performed by `ValidateCollectionQuery` (specifically `CheckOrderField`).
|
|
212
214
|
*/
|
|
213
215
|
order?: { field: string; direction: "Asc" | "Desc" }[];
|
|
214
|
-
|
|
216
|
+
/** Forward pagination: number of items to fetch */
|
|
217
|
+
first?: number | null;
|
|
218
|
+
/** Forward pagination: cursor to start after */
|
|
215
219
|
after?: string | null;
|
|
220
|
+
/** Backward pagination: number of items to fetch from the end */
|
|
221
|
+
last?: number | null;
|
|
222
|
+
/** Backward pagination: cursor to fetch before */
|
|
223
|
+
before?: string | null;
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
// =============================================================================
|
|
@@ -438,14 +446,20 @@ export interface UseCollectionReturn<
|
|
|
438
446
|
pageSize: number;
|
|
439
447
|
/** Current cursor position */
|
|
440
448
|
cursor: string | null;
|
|
441
|
-
/**
|
|
449
|
+
/** Current pagination direction */
|
|
450
|
+
paginationDirection: "forward" | "backward";
|
|
451
|
+
/** Navigate to next page using endCursor from pageInfo */
|
|
442
452
|
nextPage(endCursor: string): void;
|
|
443
|
-
/** Navigate to previous page */
|
|
444
|
-
prevPage(): void;
|
|
453
|
+
/** Navigate to previous page using startCursor from pageInfo */
|
|
454
|
+
prevPage(startCursor: string): void;
|
|
445
455
|
/** Reset to first page */
|
|
446
456
|
resetPage(): void;
|
|
447
|
-
/** Whether there is a previous page */
|
|
457
|
+
/** Whether there is a previous page (from GraphQL pageInfo) */
|
|
448
458
|
hasPrevPage: boolean;
|
|
459
|
+
/** Whether there is a next page (from GraphQL pageInfo) */
|
|
460
|
+
hasNextPage: boolean;
|
|
461
|
+
/** Set pageInfo from graphql result to track hasPrevPage/hasNextPage */
|
|
462
|
+
setPageInfo(pageInfo: PageInfo): void;
|
|
449
463
|
}
|
|
450
464
|
|
|
451
465
|
// =============================================================================
|
|
@@ -551,9 +565,11 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
|
|
|
551
565
|
/** Navigate to next page */
|
|
552
566
|
nextPage: (endCursor: string) => void;
|
|
553
567
|
/** Navigate to previous page */
|
|
554
|
-
prevPage: () => void;
|
|
568
|
+
prevPage: (startCursor: string) => void;
|
|
555
569
|
/** Whether a previous page exists */
|
|
556
570
|
hasPrevPage: boolean;
|
|
571
|
+
/** Whether a next page exists */
|
|
572
|
+
hasNextPage: boolean;
|
|
557
573
|
|
|
558
574
|
// Column management
|
|
559
575
|
/** All column definitions */
|
|
@@ -718,9 +734,58 @@ type CheckFirstVariable<TQuery> =
|
|
|
718
734
|
};
|
|
719
735
|
|
|
720
736
|
/**
|
|
721
|
-
* Rule 2 —
|
|
722
|
-
|
|
723
|
-
|
|
737
|
+
* Rule 2 — The query must declare `$after` as a variable.
|
|
738
|
+
*/
|
|
739
|
+
type CheckAfterVariable<TQuery> =
|
|
740
|
+
"after" extends keyof ExtractQueryVariables<TQuery>
|
|
741
|
+
? Pass
|
|
742
|
+
: {
|
|
743
|
+
__afterVariableError: `Query must declare an $after variable (e.g. $after: String).`;
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Rule 3 — The query must declare `$last` as a variable.
|
|
748
|
+
*/
|
|
749
|
+
type CheckLastVariable<TQuery> =
|
|
750
|
+
"last" extends keyof ExtractQueryVariables<TQuery>
|
|
751
|
+
? Pass
|
|
752
|
+
: {
|
|
753
|
+
__lastVariableError: `Query must declare a $last variable (e.g. $last: Int).`;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Rule 4 — The query must declare `$before` as a variable.
|
|
758
|
+
*/
|
|
759
|
+
type CheckBeforeVariable<TQuery> =
|
|
760
|
+
"before" extends keyof ExtractQueryVariables<TQuery>
|
|
761
|
+
? Pass
|
|
762
|
+
: {
|
|
763
|
+
__beforeVariableError: `Query must declare a $before variable (e.g. $before: String).`;
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Rule 5 — The query must declare `$query` as a variable.
|
|
768
|
+
*/
|
|
769
|
+
type CheckQueryVariable<TQuery> =
|
|
770
|
+
"query" extends keyof ExtractQueryVariables<TQuery>
|
|
771
|
+
? Pass
|
|
772
|
+
: {
|
|
773
|
+
__queryVariableError: `Query must declare a $query variable (e.g. $query: XxxQueryInput!).`;
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Rule 6 — The query must declare `$order` as a variable.
|
|
778
|
+
*/
|
|
779
|
+
type CheckOrderVariable<TQuery> =
|
|
780
|
+
"order" extends keyof ExtractQueryVariables<TQuery>
|
|
781
|
+
? Pass
|
|
782
|
+
: {
|
|
783
|
+
__orderVariableError: `Query must declare an $order variable (e.g. $order: [XxxOrderInput]).`;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Rule 7 — When metadata is provided, metadata field names must be
|
|
788
|
+
* assignable to the `field` enum inside the `OrderInput` type.
|
|
724
789
|
*/
|
|
725
790
|
type CheckOrderField<
|
|
726
791
|
TQuery,
|
|
@@ -734,9 +799,8 @@ type CheckOrderField<
|
|
|
734
799
|
: Pass;
|
|
735
800
|
|
|
736
801
|
/**
|
|
737
|
-
* Rule
|
|
738
|
-
*
|
|
739
|
-
* Skipped when `TFieldName` is the generic `string` (no metadata).
|
|
802
|
+
* Rule 8 — When metadata is provided, metadata field names must be a
|
|
803
|
+
* subset of the `QueryInput` type's keys.
|
|
740
804
|
*/
|
|
741
805
|
type CheckQueryInput<
|
|
742
806
|
TQuery,
|
|
@@ -757,8 +821,13 @@ type CheckQueryInput<
|
|
|
757
821
|
* intersecting independent rule types:
|
|
758
822
|
*
|
|
759
823
|
* 1. **`CheckFirstVariable`** — `$first` must exist.
|
|
760
|
-
* 2. **`
|
|
761
|
-
* 3. **`
|
|
824
|
+
* 2. **`CheckAfterVariable`** — `$after` must exist.
|
|
825
|
+
* 3. **`CheckLastVariable`** — `$last` must exist.
|
|
826
|
+
* 4. **`CheckBeforeVariable`** — `$before` must exist.
|
|
827
|
+
* 5. **`CheckQueryVariable`** — `$query` must exist.
|
|
828
|
+
* 6. **`CheckOrderVariable`** — `$order` must exist.
|
|
829
|
+
* 7. **`CheckOrderField`** — OrderInput field compatibility (metadata-aware).
|
|
830
|
+
* 8. **`CheckQueryInput`** — QueryInput key compatibility (metadata-aware).
|
|
762
831
|
*
|
|
763
832
|
* Each rule resolves to `{}` on pass or `{ __xxxError: "…" }` on fail.
|
|
764
833
|
* A failing rule adds a phantom error property that makes `TQuery`
|
|
@@ -778,9 +847,20 @@ export type ValidateCollectionQuery<
|
|
|
778
847
|
ExtractQueryVariables<TQuery> extends never
|
|
779
848
|
? TQuery // No gql-tada type info → skip validation
|
|
780
849
|
: string extends TFieldName
|
|
781
|
-
? TQuery &
|
|
850
|
+
? TQuery &
|
|
851
|
+
CheckFirstVariable<TQuery> &
|
|
852
|
+
CheckAfterVariable<TQuery> &
|
|
853
|
+
CheckLastVariable<TQuery> &
|
|
854
|
+
CheckBeforeVariable<TQuery> &
|
|
855
|
+
CheckQueryVariable<TQuery> &
|
|
856
|
+
CheckOrderVariable<TQuery>
|
|
782
857
|
: TQuery &
|
|
783
858
|
CheckFirstVariable<TQuery> &
|
|
859
|
+
CheckAfterVariable<TQuery> &
|
|
860
|
+
CheckLastVariable<TQuery> &
|
|
861
|
+
CheckBeforeVariable<TQuery> &
|
|
862
|
+
CheckQueryVariable<TQuery> &
|
|
863
|
+
CheckOrderVariable<TQuery> &
|
|
784
864
|
CheckOrderField<TQuery, TOrderableFieldName> &
|
|
785
865
|
CheckQueryInput<TQuery, TFieldName>;
|
|
786
866
|
|
|
@@ -791,6 +871,11 @@ export type ValidateCollectionQuery<
|
|
|
791
871
|
*/
|
|
792
872
|
export type HasCollectionQueryError<T> = T extends
|
|
793
873
|
| { __firstVariableError: string }
|
|
874
|
+
| { __afterVariableError: string }
|
|
875
|
+
| { __lastVariableError: string }
|
|
876
|
+
| { __beforeVariableError: string }
|
|
877
|
+
| { __queryVariableError: string }
|
|
878
|
+
| { __orderVariableError: string }
|
|
794
879
|
| { __orderFieldError: string }
|
|
795
880
|
| { __queryInputError: string }
|
|
796
881
|
? true
|
|
@@ -982,9 +1067,11 @@ export interface PaginationProps {
|
|
|
982
1067
|
/** Navigate to next page */
|
|
983
1068
|
nextPage: (endCursor: string) => void;
|
|
984
1069
|
/** Navigate to previous page */
|
|
985
|
-
prevPage: () => void;
|
|
1070
|
+
prevPage: (startCursor: string) => void;
|
|
986
1071
|
/** Whether a previous page exists */
|
|
987
1072
|
hasPrevPage: boolean;
|
|
1073
|
+
/** Whether a next page exists */
|
|
1074
|
+
hasNextPage: boolean;
|
|
988
1075
|
}
|
|
989
1076
|
|
|
990
1077
|
// =============================================================================
|