@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.13",
4
+ "version": "0.2.15",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -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.hasPrevPage).toBe(true);
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 back to previous page", () => {
261
+ it("navigates to previous page (backward)", () => {
259
262
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
260
263
 
261
264
  act(() => {
262
- result.current.nextPage("cursor1");
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.hasPrevPage).toBe(true);
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("navigates back to first page", () => {
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.nextPage("cursor1");
280
+ result.current.prevPage("cursorB");
280
281
  });
282
+ expect(result.current.paginationDirection).toBe("backward");
283
+
281
284
  act(() => {
282
- result.current.prevPage();
285
+ result.current.nextPage("cursorA");
283
286
  });
284
-
285
- expect(result.current.cursor).toBeNull();
286
- expect(result.current.hasPrevPage).toBe(false);
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.nextPage("cursor1");
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: QueryVariables;
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<string, { query: TQuery; variables: QueryVariables }>;
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 [cursorHistory, setCursorHistory] = useState<string[]>([]);
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
- setCursorHistory([]);
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
- setCursorHistory([]);
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
- setCursorHistory([]);
181
+ setPaginationDirection("forward");
152
182
  }, []);
153
183
 
154
184
  const clearFilters = useCallback(() => {
155
185
  setFiltersState([]);
156
186
  setCursor(null);
157
- setCursorHistory([]);
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
- setCursorHistory([]);
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
- setCursorHistory([]);
214
+ setPaginationDirection("forward");
185
215
  }, []);
186
216
 
187
217
  // ---------------------------------------------------------------------------
188
218
  // Pagination operations
189
219
  // ---------------------------------------------------------------------------
190
- const nextPage = useCallback(
191
- (endCursor: string) => {
192
- setCursorHistory((prev) => [...prev, cursor ?? ""]);
193
- setCursor(endCursor);
194
- },
195
- [cursor],
196
- );
220
+ const nextPage = useCallback((endCursor: string) => {
221
+ setCursor(endCursor);
222
+ setPaginationDirection("forward");
223
+ }, []);
197
224
 
198
- const prevPage = useCallback(() => {
199
- setCursorHistory((prev) => {
200
- const newHistory = [...prev];
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
- setCursorHistory([]);
232
+ setPaginationDirection("forward");
233
+ }, []);
234
+
235
+ const setPageInfo = useCallback((pageInfo: PageInfo) => {
236
+ setCurrentPageInfo(pageInfo);
212
237
  }, []);
213
238
 
214
- const hasPrevPage = cursorHistory.length > 0;
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
- first: pageSize,
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) — no $query key in variables.
104
- // With metadata, there is no "query" key so QueryInput check is skipped.
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 ? never : 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. Mixed: $order present but no $query only OrderInput checked
263
+ // 8. Missing $query now fails (previously was "Mixed: $order only")
154
264
  // =============================================================================
155
265
 
156
- // No $query, order fields match
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 Pass8 = ValidateCollectionQuery<DocOrderOnly, "name" | "email">;
162
- type AssertPass8 = HasCollectionQueryError<Pass8> extends true ? never : true;
163
- export const assertPass8: AssertPass8 = true;
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. Mixed: $query present but no $order only QueryInput checked
279
+ // 9. Missing $order now fails (previously was "Mixed: $query only")
167
280
  // =============================================================================
168
281
 
169
- // No $order, query keys match
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 Pass9 = ValidateCollectionQuery<DocQueryOnly, "name" | "email">;
175
- type AssertPass9 = HasCollectionQueryError<Pass9> extends true ? never : true;
176
- export const assertPass9: AssertPass9 = true;
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
- // ❌ No $order, but query keys don't cover metadata fields
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 Fail9 = ValidateCollectionQuery<DocQueryOnlyMissing, "name" | "email">;
184
- type AssertFail9 = HasCollectionQueryError<Fail9> extends true ? true : never;
185
- export const assertFail9: AssertFail9 = true;
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: { hasNextPage: true, endCursor: "cursor-3" },
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 data?.pageInfo ?? { hasNextPage: false, endCursor: null };
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
- collection?.prevPage();
116
- }, [collection]);
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={prevPage}
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={!pageInfo.hasNextPage}
42
+ disabled={!hasNextPage}
38
43
  >
39
44
  Next
40
45
  </button>
@@ -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
- first: number;
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
- /** Navigate to next page */
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 — When both metadata and `$order` variable exist, metadata field
722
- * names must be assignable to the `field` enum inside the `OrderInput` type.
723
- * Skipped when `TFieldName` is the generic `string` (no metadata).
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 3 — When both metadata and `$query` variable exist, metadata field
738
- * names must be a subset of the `QueryInput` type's keys.
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. **`CheckOrderField`** — OrderInput field compatibility (metadata-aware).
761
- * 3. **`CheckQueryInput`** — QueryInput key compatibility (metadata-aware).
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 & CheckFirstVariable<TQuery> // No metadata → only check $first
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
  // =============================================================================