@izumisy-tailor/tailor-data-viewer 0.2.18 → 0.2.20

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.
@@ -5,8 +5,9 @@ import {
5
5
  type ReactNode,
6
6
  } from "react";
7
7
  import { cn } from "../lib/utils";
8
+ import { CollectionProvider } from "../collection/collection-provider";
8
9
  import { Table } from "../table";
9
- import type { Column, DataTableRootProps } from "../types";
10
+ import type { Column, UseDataTableReturn } from "../types";
10
11
  import {
11
12
  DataTableContext,
12
13
  type DataTableContextValue,
@@ -17,61 +18,102 @@ import {
17
18
  // =============================================================================
18
19
 
19
20
  /**
20
- * Internal noop row operations used when none are provided.
21
+ * Root container for the DataTable compound component.
22
+ *
23
+ * Must be used within `DataTable.Provider`.
24
+ * Renders a `<Table.Root>` wrapper; all data is read from context.
21
25
  */
22
- const noopRowOps = {
23
- updateRow: () => ({ rollback: () => {} }),
24
- deleteRow: () => ({
25
- rollback: () => {},
26
- deletedRow: {} as Record<string, unknown>,
27
- }),
28
- insertRow: () => ({ rollback: () => {} }),
29
- };
26
+ function DataTableRoot({
27
+ children,
28
+ className,
29
+ }: {
30
+ children: ReactNode;
31
+ className?: string;
32
+ }) {
33
+ return <Table.Root className={className}>{children}</Table.Root>;
34
+ }
35
+
36
+ // =============================================================================
37
+ // DataTable.Provider
38
+ // =============================================================================
30
39
 
31
- function DataTableRoot<TRow extends Record<string, unknown>>({
32
- columns = [] as Column<TRow>[],
33
- rows = [] as TRow[],
34
- loading = false,
35
- error = null,
36
- sortStates = [],
37
- onSort,
38
- rowOperations,
40
+ /**
41
+ * Provider that shares `useDataTable()` state via React Context.
42
+ *
43
+ * Internally provides both `DataTableContext` and `CollectionContext`
44
+ * so that utility components (`Pagination`, `ColumnSelector`,
45
+ * `SearchFilterForm`, `CsvButton`) can consume data without explicit props.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
50
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
51
+ * const table = useDataTable({ columns, data: result.data?.orders, collection });
52
+ *
53
+ * <DataTable.Provider value={table}>
54
+ * <SearchFilterForm />
55
+ * <ColumnSelector />
56
+ * <DataTable.Root>
57
+ * <DataTable.Headers />
58
+ * <DataTable.Body />
59
+ * </DataTable.Root>
60
+ * <Pagination />
61
+ * </DataTable.Provider>
62
+ * ```
63
+ */
64
+ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
65
+ value,
39
66
  children,
40
- }: DataTableRootProps<TRow>) {
41
- const contextValue: DataTableContextValue<TRow> = {
42
- updateRow: (rowOperations?.updateRow ??
43
- noopRowOps.updateRow) as DataTableContextValue<TRow>["updateRow"],
44
- deleteRow: (rowOperations?.deleteRow ??
45
- noopRowOps.deleteRow) as DataTableContextValue<TRow>["deleteRow"],
46
- insertRow: (rowOperations?.insertRow ??
47
- noopRowOps.insertRow) as DataTableContextValue<TRow>["insertRow"],
48
- columns,
49
- rows,
50
- loading,
51
- error,
52
- sortStates,
53
- onSort,
67
+ }: {
68
+ value: UseDataTableReturn<TRow>;
69
+ children: ReactNode;
70
+ }) {
71
+ const dataTableValue: DataTableContextValue<TRow> = {
72
+ columns: value.columns,
73
+ rows: value.rows,
74
+ loading: value.loading,
75
+ error: value.error,
76
+ sortStates: value.sortStates ?? [],
77
+ onSort: value.onSort,
78
+ updateRow: value.updateRow,
79
+ deleteRow: value.deleteRow,
80
+ insertRow: value.insertRow,
81
+ visibleColumns: value.visibleColumns,
82
+ isColumnVisible: value.isColumnVisible,
83
+ toggleColumn: value.toggleColumn,
84
+ showAllColumns: value.showAllColumns,
85
+ hideAllColumns: value.hideAllColumns,
86
+ pageInfo: value.pageInfo,
54
87
  };
55
88
 
56
- return (
57
- <DataTableContext.Provider value={contextValue}>
58
- <Table.Root>{children}</Table.Root>
89
+ const collectionValue = value.collection ?? null;
90
+
91
+ const inner = (
92
+ <DataTableContext.Provider value={dataTableValue}>
93
+ {children}
59
94
  </DataTableContext.Provider>
60
95
  );
96
+
97
+ // Wrap with CollectionContext when collection is available
98
+ if (collectionValue) {
99
+ return (
100
+ <CollectionProvider value={collectionValue}>{inner}</CollectionProvider>
101
+ );
102
+ }
103
+
104
+ return inner;
61
105
  }
62
106
 
63
107
  // =============================================================================
64
108
  // DataTable.Headers
65
109
  // =============================================================================
66
110
 
67
- interface DataTableHeadersProps {
68
- className?: string;
69
- }
70
-
71
- function DataTableHeaders({ className }: DataTableHeadersProps) {
111
+ function DataTableHeaders({ className }: { className?: string }) {
72
112
  const ctx = useContext(DataTableContext);
73
113
  if (!ctx) {
74
- throw new Error("<DataTable.Headers> must be used within <DataTable.Root>");
114
+ throw new Error(
115
+ "<DataTable.Headers> must be used within <DataTable.Provider>",
116
+ );
75
117
  }
76
118
  const { columns, sortStates, onSort } = ctx;
77
119
 
@@ -129,15 +171,18 @@ function SortIndicator({ direction }: { direction: "Asc" | "Desc" }) {
129
171
  // DataTable.Body
130
172
  // =============================================================================
131
173
 
132
- interface DataTableBodyProps {
174
+ function DataTableBody({
175
+ children,
176
+ className,
177
+ }: {
133
178
  children?: ReactNode;
134
179
  className?: string;
135
- }
136
-
137
- function DataTableBody({ children, className }: DataTableBodyProps) {
180
+ }) {
138
181
  const ctx = useContext(DataTableContext);
139
182
  if (!ctx) {
140
- throw new Error("<DataTable.Body> must be used within <DataTable.Root>");
183
+ throw new Error(
184
+ "<DataTable.Body> must be used within <DataTable.Provider>",
185
+ );
141
186
  }
142
187
  const { columns, rows, loading, error } = ctx;
143
188
 
@@ -180,19 +225,19 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
180
225
  </Table.Row>
181
226
  )}
182
227
  {rows?.map((row, rowIndex) => (
183
- <DataTableRow key={rowIndex} row={row}>
228
+ <Table.Row key={rowIndex}>
184
229
  {columns?.map((col) => {
185
230
  const key = col.kind === "field" ? col.dataKey : col.id;
186
231
  return (
187
- <DataTableCell
232
+ <Table.Cell
188
233
  key={key}
189
- row={row}
190
- column={col}
191
- rowIndex={rowIndex}
192
- />
234
+ style={col.width ? { width: col.width } : undefined}
235
+ >
236
+ {resolveContent(row, col, rowIndex)}
237
+ </Table.Cell>
193
238
  );
194
239
  })}
195
- </DataTableRow>
240
+ </Table.Row>
196
241
  ))}
197
242
  </Table.Body>
198
243
  );
@@ -202,82 +247,54 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
202
247
  // DataTable.Row
203
248
  // =============================================================================
204
249
 
205
- interface DataTableRowComponentProps<
206
- TRow extends Record<string, unknown>,
207
- > extends Omit<ComponentProps<"tr">, "children"> {
208
- row?: TRow;
209
- children?: ReactNode;
210
- }
211
-
212
- function DataTableRow<TRow extends Record<string, unknown>>({
213
- children,
214
- ...restProps
215
- }: DataTableRowComponentProps<TRow>) {
216
- return <Table.Row {...restProps}>{children}</Table.Row>;
250
+ /**
251
+ * Thin wrapper around `Table.Row` for use in custom rendering within
252
+ * `DataTable.Body`.
253
+ */
254
+ function DataTableRow(props: ComponentProps<"tr">) {
255
+ return <Table.Row {...props} />;
217
256
  }
218
257
 
219
258
  // =============================================================================
220
259
  // DataTable.Cell
221
260
  // =============================================================================
222
261
 
223
- interface DataTableCellComponentProps<
224
- TRow extends Record<string, unknown>,
225
- > extends ComponentProps<"td"> {
226
- row?: TRow;
227
- column?: Column<TRow>;
228
- rowIndex?: number;
262
+ /**
263
+ * Thin wrapper around `Table.Cell` for use in custom rendering within
264
+ * `DataTable.Body`.
265
+ */
266
+ function DataTableCell(props: ComponentProps<"td">) {
267
+ return <Table.Cell {...props} />;
229
268
  }
230
269
 
231
- function DataTableCell<TRow extends Record<string, unknown>>({
232
- row,
233
- column,
234
- rowIndex = 0,
235
- children,
236
- className,
237
- ...restProps
238
- }: DataTableCellComponentProps<TRow>) {
239
- const content = resolveContent(children, row, column, rowIndex);
240
-
241
- return (
242
- <Table.Cell
243
- className={className}
244
- style={column?.width ? { width: column.width } : undefined}
245
- {...restProps}
246
- >
247
- {content}
248
- </Table.Cell>
249
- );
250
- }
270
+ // =============================================================================
271
+ // Helpers
272
+ // =============================================================================
251
273
 
252
274
  /**
253
- * Resolve cell content from children, row data, and column definition.
275
+ * Resolve cell content from row data and column definition.
254
276
  */
255
277
  function resolveContent<TRow extends Record<string, unknown>>(
256
- children: ReactNode,
257
- row: TRow | undefined,
258
- column: Column<TRow> | undefined,
278
+ row: TRow,
279
+ column: Column<TRow>,
259
280
  rowIndex: number,
260
281
  ): ReactNode {
261
- if (!children && row && column) {
262
- switch (column.kind) {
263
- case "field": {
264
- const value = row[column.dataKey];
265
- if (column.renderer) {
266
- return createElement(column.renderer, {
267
- value,
268
- row,
269
- rowIndex,
270
- column,
271
- });
272
- }
273
- return formatValue(value);
282
+ switch (column.kind) {
283
+ case "field": {
284
+ const value = row[column.dataKey];
285
+ if (column.renderer) {
286
+ return createElement(column.renderer, {
287
+ value,
288
+ row,
289
+ rowIndex,
290
+ column,
291
+ });
274
292
  }
275
- case "display":
276
- return column.render(row);
293
+ return formatValue(value);
277
294
  }
295
+ case "display":
296
+ return column.render(row);
278
297
  }
279
-
280
- return children;
281
298
  }
282
299
 
283
300
  /**
@@ -298,19 +315,26 @@ function formatValue(value: unknown): ReactNode {
298
315
  /**
299
316
  * Data-bound table compound component.
300
317
  *
301
- * Use with `useDataTable()` hook which provides `rootProps` to spread.
318
+ * Use with `useDataTable()` hook and wrap with `DataTable.Provider`
319
+ * for context-based data flow.
302
320
  *
303
321
  * @example
304
322
  * ```tsx
305
323
  * const table = useDataTable({ columns, data, loading, collection });
306
324
  *
307
- * <DataTable.Root {...table.rootProps}>
308
- * <DataTable.Headers />
309
- * <DataTable.Body />
310
- * </DataTable.Root>
325
+ * <DataTable.Provider value={table}>
326
+ * <SearchFilterForm />
327
+ * <ColumnSelector />
328
+ * <DataTable.Root>
329
+ * <DataTable.Headers />
330
+ * <DataTable.Body />
331
+ * </DataTable.Root>
332
+ * <Pagination />
333
+ * </DataTable.Provider>
311
334
  * ```
312
335
  */
313
336
  export const DataTable = {
337
+ Provider: DataTableProviderComponent,
314
338
  Root: DataTableRoot,
315
339
  Headers: DataTableHeaders,
316
340
  Body: DataTableBody,
@@ -234,46 +234,23 @@ describe("useDataTable", () => {
234
234
  });
235
235
 
236
236
  // ---------------------------------------------------------------------------
237
- // Props generators
237
+ // Sort state
238
238
  // ---------------------------------------------------------------------------
239
- describe("props generators", () => {
240
- it("rootProps contains columns, rows, loading, error", () => {
241
- const { result } = renderHook(() =>
242
- useDataTable<TestRow>({
243
- columns: testColumns,
244
- data: testData,
245
- loading: true,
246
- }),
247
- );
248
-
249
- const { rootProps } = result.current;
250
- expect(rootProps.columns).toHaveLength(4);
251
- expect(rootProps.rows).toHaveLength(3);
252
- expect(rootProps.loading).toBe(true);
253
- });
254
-
255
- it("getRowProps returns row", () => {
239
+ describe("sort state", () => {
240
+ it("sortStates is empty when no collection is provided", () => {
256
241
  const { result } = renderHook(() =>
257
242
  useDataTable<TestRow>({ columns: testColumns, data: testData }),
258
243
  );
259
244
 
260
- const rowProps = result.current.getRowProps(result.current.rows[0]);
261
- expect(rowProps.row.name).toBe("Alice");
245
+ expect(result.current.sortStates).toEqual([]);
262
246
  });
263
247
 
264
- it("getCellProps returns row, column, rowIndex", () => {
248
+ it("onSort is undefined when no collection is provided", () => {
265
249
  const { result } = renderHook(() =>
266
250
  useDataTable<TestRow>({ columns: testColumns, data: testData }),
267
251
  );
268
252
 
269
- const cellProps = result.current.getCellProps(
270
- result.current.rows[0],
271
- testColumns[0],
272
- 0,
273
- );
274
- expect(cellProps.row.name).toBe("Alice");
275
- expect(cellProps.column).toBe(testColumns[0]);
276
- expect(cellProps.rowIndex).toBe(0);
253
+ expect(result.current.onSort).toBeUndefined();
277
254
  });
278
255
  });
279
256
  });
@@ -1,17 +1,15 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import type {
3
3
  Column,
4
- DataTableCellProps,
5
- DataTableRootProps,
6
- DataTableRowProps,
7
4
  PageInfo,
5
+ SortState,
8
6
  UseDataTableOptions,
9
7
  UseDataTableReturn,
10
8
  } from "../types";
11
9
 
12
10
  /**
13
11
  * Hook that integrates data management, column visibility, row operations, and
14
- * props generators for the `DataTable.*` compound component.
12
+ * sort/pagination state for the `DataTable.*` compound component.
15
13
  *
16
14
  * @example
17
15
  * ```tsx
@@ -25,10 +23,12 @@ import type {
25
23
  * collection,
26
24
  * });
27
25
  *
28
- * <DataTable.Root {...table.rootProps}>
29
- * <DataTable.Headers />
30
- * <DataTable.Body />
31
- * </DataTable.Root>
26
+ * <DataTable.Provider value={table}>
27
+ * <DataTable.Root>
28
+ * <DataTable.Headers />
29
+ * <DataTable.Body />
30
+ * </DataTable.Root>
31
+ * </DataTable.Provider>
32
32
  * ```
33
33
  */
34
34
  export function useDataTable<TRow extends Record<string, unknown>>(
@@ -194,64 +194,30 @@ export function useDataTable<TRow extends Record<string, unknown>>(
194
194
  );
195
195
 
196
196
  // ---------------------------------------------------------------------------
197
- // Props generators
197
+ // Sort (delegated from collection)
198
198
  // ---------------------------------------------------------------------------
199
- const rootProps = useMemo<DataTableRootProps<TRow>>(() => {
200
- return {
201
- columns: visibleColumns,
202
- rows,
203
- loading,
204
- error,
205
- onSort: collection
206
- ? (field: string, direction?: "Asc" | "Desc") =>
207
- collection.setSort(field, direction)
208
- : undefined,
209
- sortStates: collection?.sortStates,
210
- rowOperations: { updateRow, deleteRow, insertRow },
211
- children: null,
212
- };
213
- }, [
214
- visibleColumns,
215
- rows,
216
- loading,
217
- error,
218
- collection,
219
- updateRow,
220
- deleteRow,
221
- insertRow,
222
- ]);
223
-
224
- const getRowProps = useCallback(
225
- (row: TRow): DataTableRowProps<TRow> => ({ row }),
226
- [],
227
- );
228
-
229
- const getCellProps = useCallback(
230
- (
231
- row: TRow,
232
- column: Column<TRow>,
233
- rowIndex: number,
234
- ): DataTableCellProps<TRow> => ({
235
- row,
236
- column,
237
- rowIndex,
238
- }),
239
- [],
240
- );
199
+ const sortStates = useMemo<SortState[]>(() => {
200
+ return collection?.sortStates ?? [];
201
+ }, [collection?.sortStates]);
202
+
203
+ const onSort = useMemo<
204
+ ((field: string, direction?: "Asc" | "Desc") => void) | undefined
205
+ >(() => {
206
+ if (!collection) return undefined;
207
+ return (field: string, direction?: "Asc" | "Desc") =>
208
+ collection.setSort(field, direction);
209
+ }, [collection]);
241
210
 
242
211
  // ---------------------------------------------------------------------------
243
212
  // Return
244
213
  // ---------------------------------------------------------------------------
245
214
  return {
246
- // Props generators
247
- rootProps,
248
- getRowProps,
249
- getCellProps,
250
-
251
215
  // Data
252
216
  rows,
253
217
  loading,
254
218
  error,
219
+ sortStates,
220
+ onSort,
255
221
 
256
222
  // Pagination
257
223
  pageInfo,
@@ -272,5 +238,8 @@ export function useDataTable<TRow extends Record<string, unknown>>(
272
238
  updateRow,
273
239
  deleteRow,
274
240
  insertRow,
241
+
242
+ // Collection (passthrough for DataTable.Provider)
243
+ collection,
275
244
  };
276
245
  }
@@ -23,9 +23,6 @@ export type {
23
23
  UseDataTableOptions,
24
24
  UseDataTableReturn,
25
25
  RowOperations,
26
- DataTableRootProps,
27
- DataTableRowProps,
28
- DataTableCellProps,
29
26
  FieldName,
30
27
  TableFieldName,
31
28
  OrderableFieldName,
@@ -40,11 +37,7 @@ export type {
40
37
  MetadataFieldsOptions,
41
38
  MetadataFilter,
42
39
  TableMetadataFilter,
43
- ColumnSelectorProps,
44
- CsvButtonProps,
45
- SearchFilterFormProps,
46
40
  SearchFilterLabels,
47
- PaginationProps,
48
41
  } from "./types";
49
42
 
50
43
  export {
@@ -66,7 +59,7 @@ export {
66
59
  export { Table } from "./table";
67
60
 
68
61
  // DataTable (data-bound)
69
- export { DataTable } from "./data-table/index";
62
+ export { DataTable } from "./data-table/data-table";
70
63
  export { useDataTable } from "./data-table/use-data-table";
71
64
  export { useDataTableContext } from "./data-table/data-table-context";
72
65
 
@@ -0,0 +1,129 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import type { Column } from "./types";
4
+ import { createTestProviders } from "../tests/helpers";
5
+ import { Pagination } from "./pagination";
6
+
7
+ // =============================================================================
8
+ // Fixtures
9
+ // =============================================================================
10
+
11
+ type TestRow = { id: string; name: string; status: string };
12
+
13
+ const testColumns: Column<TestRow>[] = [
14
+ { kind: "field", dataKey: "name", label: "Name" },
15
+ { kind: "field", dataKey: "status", label: "Status" },
16
+ ];
17
+
18
+ const testRows: TestRow[] = [
19
+ { id: "1", name: "Alice", status: "active" },
20
+ { id: "2", name: "Bob", status: "inactive" },
21
+ ];
22
+
23
+ const TestProviders = createTestProviders({
24
+ columns: testColumns,
25
+ rows: testRows,
26
+ dataTableDefaults: {
27
+ pageInfo: {
28
+ hasNextPage: true,
29
+ endCursor: "cursor-end",
30
+ hasPreviousPage: true,
31
+ startCursor: "cursor-start",
32
+ },
33
+ },
34
+ collectionDefaults: {
35
+ hasPrevPage: true,
36
+ hasNextPage: true,
37
+ },
38
+ });
39
+
40
+ // =============================================================================
41
+ // Tests
42
+ // =============================================================================
43
+
44
+ describe("Pagination", () => {
45
+ it("renders Previous and Next buttons", () => {
46
+ render(
47
+ <TestProviders>
48
+ <Pagination />
49
+ </TestProviders>,
50
+ );
51
+
52
+ expect(screen.getByText("Previous")).toBeInTheDocument();
53
+ expect(screen.getByText("Next")).toBeInTheDocument();
54
+ });
55
+
56
+ it("disables Previous when hasPrevPage is false", () => {
57
+ render(
58
+ <TestProviders collection={{ hasPrevPage: false }}>
59
+ <Pagination />
60
+ </TestProviders>,
61
+ );
62
+
63
+ expect(screen.getByText("Previous")).toBeDisabled();
64
+ expect(screen.getByText("Next")).toBeEnabled();
65
+ });
66
+
67
+ it("disables Next when hasNextPage is false", () => {
68
+ render(
69
+ <TestProviders collection={{ hasNextPage: false }}>
70
+ <Pagination />
71
+ </TestProviders>,
72
+ );
73
+
74
+ expect(screen.getByText("Previous")).toBeEnabled();
75
+ expect(screen.getByText("Next")).toBeDisabled();
76
+ });
77
+
78
+ it("calls nextPage with endCursor when Next is clicked", () => {
79
+ const nextPage = vi.fn();
80
+ render(
81
+ <TestProviders collection={{ nextPage }}>
82
+ <Pagination />
83
+ </TestProviders>,
84
+ );
85
+
86
+ fireEvent.click(screen.getByText("Next"));
87
+ expect(nextPage).toHaveBeenCalledWith("cursor-end");
88
+ });
89
+
90
+ it("calls prevPage with startCursor when Previous is clicked", () => {
91
+ const prevPage = vi.fn();
92
+ render(
93
+ <TestProviders collection={{ prevPage }}>
94
+ <Pagination />
95
+ </TestProviders>,
96
+ );
97
+
98
+ fireEvent.click(screen.getByText("Previous"));
99
+ expect(prevPage).toHaveBeenCalledWith("cursor-start");
100
+ });
101
+
102
+ it("does not call nextPage when endCursor is null", () => {
103
+ const nextPage = vi.fn();
104
+ render(
105
+ <TestProviders
106
+ dataTable={{
107
+ pageInfo: {
108
+ hasNextPage: true,
109
+ endCursor: null,
110
+ hasPreviousPage: false,
111
+ startCursor: null,
112
+ },
113
+ }}
114
+ collection={{ nextPage, hasNextPage: true }}
115
+ >
116
+ <Pagination />
117
+ </TestProviders>,
118
+ );
119
+
120
+ fireEvent.click(screen.getByText("Next"));
121
+ expect(nextPage).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("throws when rendered outside provider", () => {
125
+ console.error = vi.fn();
126
+ expect(() => render(<Pagination />)).toThrow();
127
+ console.error = globalThis.console.error;
128
+ });
129
+ });