@izumisy-tailor/tailor-data-viewer 0.2.17 → 0.2.19

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.17",
4
+ "version": "0.2.19",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -0,0 +1,120 @@
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 { ColumnSelector } from "./column-selector";
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
+ kind: "display",
18
+ id: "actions",
19
+ label: "Actions",
20
+ render: (row) => <button>Edit {row.name}</button>,
21
+ },
22
+ ];
23
+
24
+ const testRows: TestRow[] = [
25
+ { id: "1", name: "Alice", status: "active" },
26
+ { id: "2", name: "Bob", status: "inactive" },
27
+ ];
28
+
29
+ const TestProviders = createTestProviders({
30
+ columns: testColumns,
31
+ rows: testRows,
32
+ });
33
+
34
+ // =============================================================================
35
+ // Tests
36
+ // =============================================================================
37
+
38
+ describe("ColumnSelector", () => {
39
+ it("renders column names with checkboxes", () => {
40
+ render(
41
+ <TestProviders>
42
+ <ColumnSelector />
43
+ </TestProviders>,
44
+ );
45
+
46
+ expect(screen.getByText("Columns")).toBeInTheDocument();
47
+ expect(screen.getByText("Name")).toBeInTheDocument();
48
+ expect(screen.getByText("Status")).toBeInTheDocument();
49
+ expect(screen.getByText("Actions")).toBeInTheDocument();
50
+ });
51
+
52
+ it("shows checkboxes as checked when columns are visible", () => {
53
+ render(
54
+ <TestProviders dataTable={{ isColumnVisible: vi.fn(() => true) }}>
55
+ <ColumnSelector />
56
+ </TestProviders>,
57
+ );
58
+
59
+ const checkboxes = screen.getAllByRole("checkbox");
60
+ for (const cb of checkboxes) {
61
+ expect(cb).toBeChecked();
62
+ }
63
+ });
64
+
65
+ it("shows checkboxes as unchecked when columns are hidden", () => {
66
+ render(
67
+ <TestProviders dataTable={{ isColumnVisible: vi.fn(() => false) }}>
68
+ <ColumnSelector />
69
+ </TestProviders>,
70
+ );
71
+
72
+ const checkboxes = screen.getAllByRole("checkbox");
73
+ for (const cb of checkboxes) {
74
+ expect(cb).not.toBeChecked();
75
+ }
76
+ });
77
+
78
+ it("calls toggleColumn when a checkbox is clicked", () => {
79
+ const toggleColumn = vi.fn();
80
+ render(
81
+ <TestProviders dataTable={{ toggleColumn }}>
82
+ <ColumnSelector />
83
+ </TestProviders>,
84
+ );
85
+
86
+ const checkboxes = screen.getAllByRole("checkbox");
87
+ fireEvent.click(checkboxes[0]);
88
+ expect(toggleColumn).toHaveBeenCalledWith("name");
89
+ });
90
+
91
+ it("calls showAllColumns when Select all is clicked", () => {
92
+ const showAllColumns = vi.fn();
93
+ render(
94
+ <TestProviders dataTable={{ showAllColumns }}>
95
+ <ColumnSelector />
96
+ </TestProviders>,
97
+ );
98
+
99
+ fireEvent.click(screen.getByText("Select all"));
100
+ expect(showAllColumns).toHaveBeenCalled();
101
+ });
102
+
103
+ it("calls hideAllColumns when Deselect all is clicked", () => {
104
+ const hideAllColumns = vi.fn();
105
+ render(
106
+ <TestProviders dataTable={{ hideAllColumns }}>
107
+ <ColumnSelector />
108
+ </TestProviders>,
109
+ );
110
+
111
+ fireEvent.click(screen.getByText("Deselect all"));
112
+ expect(hideAllColumns).toHaveBeenCalled();
113
+ });
114
+
115
+ it("throws when rendered outside provider", () => {
116
+ console.error = vi.fn();
117
+ expect(() => render(<ColumnSelector />)).toThrow();
118
+ console.error = globalThis.console.error;
119
+ });
120
+ });
@@ -1,22 +1,26 @@
1
- import type { ColumnSelectorProps } from "./types";
1
+ import { useDataTableContext } from "./data-table/data-table-context";
2
2
 
3
3
  /**
4
4
  * Column visibility toggle dropdown.
5
5
  *
6
- * Designed to receive props via spread from `useDataTable()`.
6
+ * Reads column definitions and visibility state from `DataTableContext`.
7
+ * Must be rendered inside `DataTable.Provider`.
7
8
  *
8
9
  * @example
9
10
  * ```tsx
10
- * <ColumnSelector {...table} />
11
+ * <DataTable.Provider value={table}>
12
+ * <ColumnSelector />
13
+ * </DataTable.Provider>
11
14
  * ```
12
15
  */
13
- export function ColumnSelector<TRow extends Record<string, unknown>>({
14
- columns,
15
- isColumnVisible,
16
- toggleColumn,
17
- showAllColumns,
18
- hideAllColumns,
19
- }: ColumnSelectorProps<TRow>) {
16
+ export function ColumnSelector() {
17
+ const {
18
+ columns,
19
+ isColumnVisible,
20
+ toggleColumn,
21
+ showAllColumns,
22
+ hideAllColumns,
23
+ } = useDataTableContext();
20
24
  return (
21
25
  <div className="relative inline-block">
22
26
  <details className="group">
@@ -24,28 +28,22 @@ export function ColumnSelector<TRow extends Record<string, unknown>>({
24
28
  Columns
25
29
  </summary>
26
30
  <div className="absolute right-0 z-50 mt-1 min-w-[180px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md">
27
- {(showAllColumns || hideAllColumns) && (
28
- <div className="flex gap-2 border-b pb-2 mb-2">
29
- {showAllColumns && (
30
- <button
31
- type="button"
32
- className="text-xs text-muted-foreground hover:text-foreground"
33
- onClick={showAllColumns}
34
- >
35
- Select all
36
- </button>
37
- )}
38
- {hideAllColumns && (
39
- <button
40
- type="button"
41
- className="text-xs text-muted-foreground hover:text-foreground"
42
- onClick={hideAllColumns}
43
- >
44
- Deselect all
45
- </button>
46
- )}
47
- </div>
48
- )}
31
+ <div className="flex gap-2 border-b pb-2 mb-2">
32
+ <button
33
+ type="button"
34
+ className="text-xs text-muted-foreground hover:text-foreground"
35
+ onClick={showAllColumns}
36
+ >
37
+ Select all
38
+ </button>
39
+ <button
40
+ type="button"
41
+ className="text-xs text-muted-foreground hover:text-foreground"
42
+ onClick={hideAllColumns}
43
+ >
44
+ Deselect all
45
+ </button>
46
+ </div>
49
47
  {columns.map((col) => {
50
48
  const key = col.kind === "field" ? col.dataKey : col.id;
51
49
  const label =
@@ -0,0 +1,122 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import type { Column } from "./types";
4
+ import { createTestProviders } from "../tests/helpers";
5
+ import { CsvButton } from "./csv-button";
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
+ });
27
+
28
+ // =============================================================================
29
+ // Tests
30
+ // =============================================================================
31
+
32
+ describe("CsvButton", () => {
33
+ // Save originals before each test to avoid cross-contamination
34
+ const originalCreateObjectURL = globalThis.URL.createObjectURL;
35
+ const originalRevokeObjectURL = globalThis.URL.revokeObjectURL;
36
+ let originalCreateElement: typeof document.createElement;
37
+
38
+ beforeEach(() => {
39
+ originalCreateElement = document.createElement.bind(document);
40
+ });
41
+
42
+ afterEach(() => {
43
+ globalThis.URL.createObjectURL = originalCreateObjectURL;
44
+ globalThis.URL.revokeObjectURL = originalRevokeObjectURL;
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ it("renders Export CSV button", () => {
49
+ render(
50
+ <TestProviders>
51
+ <CsvButton />
52
+ </TestProviders>,
53
+ );
54
+
55
+ expect(screen.getByText("Export CSV")).toBeInTheDocument();
56
+ });
57
+
58
+ it("triggers CSV download on click", () => {
59
+ const createObjectURL = vi.fn(() => "blob:mock-url");
60
+ const revokeObjectURL = vi.fn();
61
+ globalThis.URL.createObjectURL = createObjectURL;
62
+ globalThis.URL.revokeObjectURL = revokeObjectURL;
63
+
64
+ const clickSpy = vi.fn();
65
+ vi.spyOn(document, "createElement").mockImplementation(
66
+ (tag: string, options?: ElementCreationOptions) => {
67
+ if (tag === "a") {
68
+ return {
69
+ href: "",
70
+ download: "",
71
+ click: clickSpy,
72
+ } as unknown as HTMLAnchorElement;
73
+ }
74
+ return originalCreateElement(tag, options);
75
+ },
76
+ );
77
+
78
+ render(
79
+ <TestProviders>
80
+ <CsvButton filename="test-export" />
81
+ </TestProviders>,
82
+ );
83
+
84
+ fireEvent.click(screen.getByText("Export CSV"));
85
+
86
+ expect(createObjectURL).toHaveBeenCalled();
87
+ expect(clickSpy).toHaveBeenCalled();
88
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
89
+ });
90
+
91
+ it("uses default filename when not specified", () => {
92
+ globalThis.URL.createObjectURL = vi.fn(() => "blob:url");
93
+ globalThis.URL.revokeObjectURL = vi.fn();
94
+
95
+ const links: { download: string }[] = [];
96
+ vi.spyOn(document, "createElement").mockImplementation(
97
+ (tag: string, options?: ElementCreationOptions) => {
98
+ if (tag === "a") {
99
+ const link = { href: "", download: "", click: vi.fn() };
100
+ links.push(link);
101
+ return link as unknown as HTMLAnchorElement;
102
+ }
103
+ return originalCreateElement(tag, options);
104
+ },
105
+ );
106
+
107
+ render(
108
+ <TestProviders>
109
+ <CsvButton />
110
+ </TestProviders>,
111
+ );
112
+
113
+ fireEvent.click(screen.getByText("Export CSV"));
114
+ expect(links[0].download).toBe("export.csv");
115
+ });
116
+
117
+ it("throws when rendered outside provider", () => {
118
+ console.error = vi.fn();
119
+ expect(() => render(<CsvButton />)).toThrow();
120
+ console.error = globalThis.console.error;
121
+ });
122
+ });
@@ -1,20 +1,21 @@
1
- import type { CsvButtonProps } from "./types";
1
+ import { useDataTableContext } from "./data-table/data-table-context";
2
2
 
3
3
  /**
4
4
  * CSV export button.
5
5
  *
6
6
  * Exports visible columns and rows to a CSV file download.
7
+ * Reads `visibleColumns` and `rows` from `DataTableContext`.
8
+ * Must be rendered inside `DataTable.Provider`.
7
9
  *
8
10
  * @example
9
11
  * ```tsx
10
- * <CsvButton {...table} filename="orders-export" />
12
+ * <DataTable.Provider value={table}>
13
+ * <CsvButton filename="orders-export" />
14
+ * </DataTable.Provider>
11
15
  * ```
12
16
  */
13
- export function CsvButton<TRow extends Record<string, unknown>>({
14
- visibleColumns,
15
- rows,
16
- filename = "export",
17
- }: CsvButtonProps<TRow>) {
17
+ export function CsvButton({ filename = "export" }: { filename?: string }) {
18
+ const { visibleColumns, rows } = useDataTableContext();
18
19
  const handleExport = () => {
19
20
  // Build header row
20
21
  const headers = visibleColumns.map((col) => {
@@ -1,25 +1,36 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { Column, RowOperations, SortState } from "../types";
2
+ import type { Column, PageInfo, RowOperations, SortState } from "../types";
3
3
 
4
4
  /**
5
- * Context value provided by `DataTable.Root`.
5
+ * Context value provided by `DataTable.Root` or `DataTable.Provider`.
6
6
  *
7
- * Exposes row operations for optimistic updates and table state
8
- * so that `DataTable.Headers` / `DataTable.Body` can read them
9
- * without explicit props.
7
+ * Exposes row operations for optimistic updates, table state, column
8
+ * visibility, and page info so that `DataTable.Headers` / `DataTable.Body`
9
+ * and utility components (`Pagination`, `ColumnSelector`, etc.) can read
10
+ * them without explicit props.
10
11
  */
11
12
  export interface DataTableContextValue<TRow extends Record<string, unknown>> {
12
13
  updateRow: RowOperations<TRow>["updateRow"];
13
14
  deleteRow: RowOperations<TRow>["deleteRow"];
14
15
  insertRow: RowOperations<TRow>["insertRow"];
15
16
 
16
- // Table state propagated from DataTable.Root
17
+ // Table state propagated from DataTable.Root / DataTable.Provider
17
18
  columns: Column<TRow>[];
18
19
  rows: TRow[];
19
20
  loading: boolean;
20
21
  error: Error | null;
21
22
  sortStates: SortState[];
22
23
  onSort?: (field: string, direction?: "Asc" | "Desc") => void;
24
+
25
+ // Column visibility (populated by DataTable.Provider)
26
+ visibleColumns: Column<TRow>[];
27
+ isColumnVisible: (fieldOrId: string) => boolean;
28
+ toggleColumn: (fieldOrId: string) => void;
29
+ showAllColumns: () => void;
30
+ hideAllColumns: () => void;
31
+
32
+ // Page info from GraphQL response (populated by DataTable.Provider)
33
+ pageInfo: PageInfo;
23
34
  }
24
35
 
25
36
  // Using `any` for the context default since generic contexts need a base type.
@@ -5,8 +5,14 @@ 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 {
11
+ Column,
12
+ DataTableRootProps,
13
+ PageInfo,
14
+ UseDataTableReturn,
15
+ } from "../types";
10
16
  import {
11
17
  DataTableContext,
12
18
  type DataTableContextValue,
@@ -28,6 +34,13 @@ const noopRowOps = {
28
34
  insertRow: () => ({ rollback: () => {} }),
29
35
  };
30
36
 
37
+ const defaultPageInfo: PageInfo = {
38
+ hasNextPage: false,
39
+ endCursor: null,
40
+ hasPreviousPage: false,
41
+ startCursor: null,
42
+ };
43
+
31
44
  function DataTableRoot<TRow extends Record<string, unknown>>({
32
45
  columns = [] as Column<TRow>[],
33
46
  rows = [] as TRow[],
@@ -38,6 +51,11 @@ function DataTableRoot<TRow extends Record<string, unknown>>({
38
51
  rowOperations,
39
52
  children,
40
53
  }: DataTableRootProps<TRow>) {
54
+ // When rendered inside DataTable.Provider, inherit missing fields from context
55
+ const parentCtx = useContext(
56
+ DataTableContext,
57
+ ) as DataTableContextValue<TRow> | null;
58
+
41
59
  const contextValue: DataTableContextValue<TRow> = {
42
60
  updateRow: (rowOperations?.updateRow ??
43
61
  noopRowOps.updateRow) as DataTableContextValue<TRow>["updateRow"],
@@ -51,6 +69,13 @@ function DataTableRoot<TRow extends Record<string, unknown>>({
51
69
  error,
52
70
  sortStates,
53
71
  onSort,
72
+ // Inherit column visibility & pageInfo from parent context (DataTable.Provider)
73
+ visibleColumns: parentCtx?.visibleColumns ?? columns,
74
+ isColumnVisible: parentCtx?.isColumnVisible ?? (() => true),
75
+ toggleColumn: parentCtx?.toggleColumn ?? (() => {}),
76
+ showAllColumns: parentCtx?.showAllColumns ?? (() => {}),
77
+ hideAllColumns: parentCtx?.hideAllColumns ?? (() => {}),
78
+ pageInfo: parentCtx?.pageInfo ?? defaultPageInfo,
54
79
  };
55
80
 
56
81
  return (
@@ -60,6 +85,77 @@ function DataTableRoot<TRow extends Record<string, unknown>>({
60
85
  );
61
86
  }
62
87
 
88
+ // =============================================================================
89
+ // DataTable.Provider
90
+ // =============================================================================
91
+
92
+ /**
93
+ * Provider that shares `useDataTable()` state via React Context.
94
+ *
95
+ * Internally provides both `DataTableContext` and `CollectionContext`
96
+ * so that utility components (`Pagination`, `ColumnSelector`,
97
+ * `SearchFilterForm`, `CsvButton`) can consume data without explicit props.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * const collection = useCollection({ query: GET_ORDERS, params: { pageSize: 20 } });
102
+ * const [result] = useQuery({ ...collection.toQueryArgs() });
103
+ * const table = useDataTable({ columns, data: result.data?.orders, collection });
104
+ *
105
+ * <DataTable.Provider value={table}>
106
+ * <SearchFilterForm />
107
+ * <ColumnSelector />
108
+ * <DataTable.Root>
109
+ * <DataTable.Headers />
110
+ * <DataTable.Body />
111
+ * </DataTable.Root>
112
+ * <Pagination />
113
+ * </DataTable.Provider>
114
+ * ```
115
+ */
116
+ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
117
+ value,
118
+ children,
119
+ }: {
120
+ value: UseDataTableReturn<TRow>;
121
+ children: ReactNode;
122
+ }) {
123
+ const dataTableValue: DataTableContextValue<TRow> = {
124
+ columns: value.columns,
125
+ rows: value.rows,
126
+ loading: value.loading,
127
+ error: value.error,
128
+ sortStates: value.rootProps.sortStates ?? [],
129
+ onSort: value.rootProps.onSort,
130
+ updateRow: value.updateRow,
131
+ deleteRow: value.deleteRow,
132
+ insertRow: value.insertRow,
133
+ visibleColumns: value.visibleColumns,
134
+ isColumnVisible: value.isColumnVisible,
135
+ toggleColumn: value.toggleColumn,
136
+ showAllColumns: value.showAllColumns,
137
+ hideAllColumns: value.hideAllColumns,
138
+ pageInfo: value.pageInfo,
139
+ };
140
+
141
+ const collectionValue = value.collection ?? null;
142
+
143
+ const inner = (
144
+ <DataTableContext.Provider value={dataTableValue}>
145
+ {children}
146
+ </DataTableContext.Provider>
147
+ );
148
+
149
+ // Wrap with CollectionContext when collection is available
150
+ if (collectionValue) {
151
+ return (
152
+ <CollectionProvider value={collectionValue}>{inner}</CollectionProvider>
153
+ );
154
+ }
155
+
156
+ return inner;
157
+ }
158
+
63
159
  // =============================================================================
64
160
  // DataTable.Headers
65
161
  // =============================================================================
@@ -298,12 +394,25 @@ function formatValue(value: unknown): ReactNode {
298
394
  /**
299
395
  * Data-bound table compound component.
300
396
  *
301
- * Use with `useDataTable()` hook which provides `rootProps` to spread.
397
+ * Use with `useDataTable()` hook which provides `rootProps` to spread,
398
+ * or wrap with `DataTable.Provider` for context-based usage.
302
399
  *
303
400
  * @example
304
401
  * ```tsx
402
+ * // Context-based (recommended)
305
403
  * const table = useDataTable({ columns, data, loading, collection });
306
404
  *
405
+ * <DataTable.Provider value={table}>
406
+ * <SearchFilterForm />
407
+ * <ColumnSelector />
408
+ * <DataTable.Root>
409
+ * <DataTable.Headers />
410
+ * <DataTable.Body />
411
+ * </DataTable.Root>
412
+ * <Pagination />
413
+ * </DataTable.Provider>
414
+ *
415
+ * // Props-based (still supported)
307
416
  * <DataTable.Root {...table.rootProps}>
308
417
  * <DataTable.Headers />
309
418
  * <DataTable.Body />
@@ -311,6 +420,7 @@ function formatValue(value: unknown): ReactNode {
311
420
  * ```
312
421
  */
313
422
  export const DataTable = {
423
+ Provider: DataTableProviderComponent,
314
424
  Root: DataTableRoot,
315
425
  Headers: DataTableHeaders,
316
426
  Body: DataTableBody,
@@ -272,5 +272,8 @@ export function useDataTable<TRow extends Record<string, unknown>>(
272
272
  updateRow,
273
273
  deleteRow,
274
274
  insertRow,
275
+
276
+ // Collection (passthrough for DataTable.Provider)
277
+ collection,
275
278
  };
276
279
  }
@@ -130,41 +130,49 @@ export function createColumnHelper<TRow extends Record<string, unknown>>(): {
130
130
  * Automatically infers sort/filter configuration from field types,
131
131
  * including enum options.
132
132
  *
133
+ * @typeParam TRow - The row type for type-safe renderer access.
133
134
  * @param tableMetadata - A single table metadata object from the generated map.
134
135
  *
135
136
  * @example
136
137
  * ```tsx
137
138
  * import { tableMetadata } from "./generated/data-viewer-metadata.generated";
138
139
  *
139
- * const { column, columns } = inferColumnHelper(tableMetadata.task);
140
+ * type Task = { id: string; title: string; status: string; dueDate: string };
141
+ *
142
+ * const { column, columns } = inferColumnHelper<Task>(tableMetadata.task);
140
143
  *
141
144
  * const taskColumns = [
142
145
  * column("title"), // sort/filter auto-detected
143
146
  * column("status"), // enum options auto-populated
144
147
  * column("dueDate"), // date type auto-recognized
145
- * display("actions", { render: (row) => <ActionMenu row={row} /> }),
148
+ * column("title", {
149
+ * renderer: ({ row }) => <span>{row.title}</span>, // row: Task
150
+ * }),
146
151
  * ];
147
152
  * ```
148
153
  */
149
- export function inferColumnHelper<const TTable extends TableMetadata>(
154
+ export function inferColumnHelper<
155
+ TRow extends Record<string, unknown>,
156
+ const TTable extends TableMetadata = TableMetadata,
157
+ >(
150
158
  tableMetadata: TTable,
151
159
  ): {
152
160
  column: (
153
161
  dataKey: TableFieldName<TTable>,
154
- options?: MetadataFieldOptions,
155
- ) => FieldColumn<Record<string, unknown>>;
162
+ options?: MetadataFieldOptions<TRow>,
163
+ ) => FieldColumn<TRow>;
156
164
 
157
165
  columns: (
158
166
  dataKeys: TableFieldName<TTable>[],
159
167
  options?: MetadataFieldsOptions,
160
- ) => FieldColumn<Record<string, unknown>>[];
168
+ ) => FieldColumn<TRow>[];
161
169
  } {
162
170
  const fields = tableMetadata.fields;
163
171
 
164
172
  const column = (
165
173
  dataKey: TableFieldName<TTable>,
166
- columnOptions?: MetadataFieldOptions,
167
- ): FieldColumn<Record<string, unknown>> => {
174
+ columnOptions?: MetadataFieldOptions<TRow>,
175
+ ): FieldColumn<TRow> => {
168
176
  const fieldMeta = fields.find((f) => f.name === dataKey);
169
177
  if (!fieldMeta) {
170
178
  throw new Error(
@@ -197,20 +205,19 @@ export function inferColumnHelper<const TTable extends TableMetadata>(
197
205
  width: columnOptions?.width,
198
206
  sort,
199
207
  filter,
200
- renderer: columnOptions?.renderer as
201
- | CellRenderer<Record<string, unknown>>
202
- | undefined,
208
+ renderer: columnOptions?.renderer as CellRenderer<TRow> | undefined,
203
209
  };
204
210
  };
205
211
 
206
212
  const columnsHelper = (
207
213
  dataKeys: TableFieldName<TTable>[],
208
214
  options?: MetadataFieldsOptions,
209
- ): FieldColumn<Record<string, unknown>>[] => {
215
+ ): FieldColumn<TRow>[] => {
210
216
  return dataKeys.map((dataKey) => {
211
217
  const overrides = options?.overrides?.[dataKey as string];
212
218
  return column(dataKey, {
213
- ...overrides,
219
+ label: overrides?.label,
220
+ width: overrides?.width,
214
221
  sort: overrides?.sort ?? options?.sort,
215
222
  filter: overrides?.filter ?? options?.filter,
216
223
  });
@@ -40,11 +40,7 @@ export type {
40
40
  MetadataFieldsOptions,
41
41
  MetadataFilter,
42
42
  TableMetadataFilter,
43
- ColumnSelectorProps,
44
- CsvButtonProps,
45
- SearchFilterFormProps,
46
43
  SearchFilterLabels,
47
- PaginationProps,
48
44
  } from "./types";
49
45
 
50
46
  export {
@@ -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
+ });
@@ -1,22 +1,24 @@
1
- import type { PaginationProps } from "./types";
1
+ import { useDataTableContext } from "./data-table/data-table-context";
2
+ import { useCollectionContext } from "./collection/collection-provider";
2
3
 
3
4
  /**
4
5
  * Pagination controls for cursor-based navigation.
5
6
  *
6
- * Designed to receive props via spread from `useDataTable()`.
7
+ * Reads `pageInfo` from `DataTableContext` and pagination actions
8
+ * from `CollectionContext`. Must be rendered inside `DataTable.Provider`.
7
9
  *
8
10
  * @example
9
11
  * ```tsx
10
- * <Pagination {...table} />
12
+ * <DataTable.Provider value={table}>
13
+ * <Pagination />
14
+ * </DataTable.Provider>
11
15
  * ```
12
16
  */
13
- export function Pagination({
14
- pageInfo,
15
- nextPage,
16
- prevPage,
17
- hasPrevPage,
18
- hasNextPage,
19
- }: PaginationProps) {
17
+ export function Pagination() {
18
+ const { pageInfo } = useDataTableContext();
19
+ const { nextPage, prevPage, hasPrevPage, hasNextPage } =
20
+ useCollectionContext();
21
+
20
22
  return (
21
23
  <div className="flex items-center justify-end gap-2 py-2">
22
24
  <button
@@ -0,0 +1,222 @@
1
+ import { render, screen, fireEvent } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import type { Column, Filter } from "./types";
5
+ import { createTestProviders } from "../tests/helpers";
6
+ import { SearchFilterForm } from "./search-filter-form";
7
+
8
+ // =============================================================================
9
+ // Fixtures
10
+ // =============================================================================
11
+
12
+ type TestRow = { id: string; name: string; status: string; priority: number };
13
+
14
+ const testColumns: Column<TestRow>[] = [
15
+ {
16
+ kind: "field",
17
+ dataKey: "name",
18
+ label: "Name",
19
+ sort: { type: "string" },
20
+ filter: { type: "string" },
21
+ },
22
+ {
23
+ kind: "field",
24
+ dataKey: "status",
25
+ label: "Status",
26
+ filter: {
27
+ type: "enum",
28
+ options: [
29
+ { value: "active", label: "Active" },
30
+ { value: "inactive", label: "Inactive" },
31
+ ],
32
+ },
33
+ },
34
+ {
35
+ kind: "field",
36
+ dataKey: "priority",
37
+ label: "Priority",
38
+ sort: { type: "number" },
39
+ filter: { type: "number" },
40
+ },
41
+ {
42
+ kind: "display",
43
+ id: "actions",
44
+ label: "Actions",
45
+ render: (row) => <button>Edit {row.name}</button>,
46
+ },
47
+ ];
48
+
49
+ const testRows: TestRow[] = [
50
+ { id: "1", name: "Alice", status: "active", priority: 1 },
51
+ { id: "2", name: "Bob", status: "inactive", priority: 2 },
52
+ ];
53
+
54
+ const TestProviders = createTestProviders({
55
+ columns: testColumns,
56
+ rows: testRows,
57
+ });
58
+
59
+ // =============================================================================
60
+ // Tests
61
+ // =============================================================================
62
+
63
+ describe("SearchFilterForm", () => {
64
+ it("renders the search trigger button", () => {
65
+ render(
66
+ <TestProviders>
67
+ <SearchFilterForm />
68
+ </TestProviders>,
69
+ );
70
+
71
+ expect(screen.getByText("Search")).toBeInTheDocument();
72
+ });
73
+
74
+ it("shows active filter count badge", () => {
75
+ const filters: Filter[] = [
76
+ { field: "name", operator: "eq", value: "Alice" },
77
+ ];
78
+ render(
79
+ <TestProviders collection={{ filters }}>
80
+ <SearchFilterForm />
81
+ </TestProviders>,
82
+ );
83
+
84
+ expect(screen.getByText("1")).toBeInTheDocument();
85
+ });
86
+
87
+ it("renders custom trigger when provided", () => {
88
+ render(
89
+ <TestProviders>
90
+ <SearchFilterForm trigger={<span>Custom Trigger</span>} />
91
+ </TestProviders>,
92
+ );
93
+
94
+ expect(screen.getByText("Custom Trigger")).toBeInTheDocument();
95
+ });
96
+
97
+ it("shows filterable field options in the dropdown", () => {
98
+ render(
99
+ <TestProviders>
100
+ <SearchFilterForm />
101
+ </TestProviders>,
102
+ );
103
+
104
+ expect(screen.getByText("Select field...")).toBeInTheDocument();
105
+ const options = screen.getAllByRole("option");
106
+ const optionTexts = options.map((o) => o.textContent);
107
+ expect(optionTexts).toContain("Name");
108
+ expect(optionTexts).toContain("Status");
109
+ expect(optionTexts).toContain("Priority");
110
+ });
111
+
112
+ it("displays active filters with remove buttons", () => {
113
+ const filters: Filter[] = [
114
+ { field: "name", operator: "eq", value: "Alice" },
115
+ ];
116
+ const removeFilter = vi.fn();
117
+ render(
118
+ <TestProviders collection={{ filters, removeFilter }}>
119
+ <SearchFilterForm />
120
+ </TestProviders>,
121
+ );
122
+
123
+ expect(screen.getByText("Active filters")).toBeInTheDocument();
124
+ expect(screen.getByText("Name = Alice")).toBeInTheDocument();
125
+
126
+ fireEvent.click(screen.getByText("✕"));
127
+ expect(removeFilter).toHaveBeenCalledWith("name");
128
+ });
129
+
130
+ it("calls clearFilters when Clear all is clicked", () => {
131
+ const filters: Filter[] = [
132
+ { field: "name", operator: "eq", value: "test" },
133
+ ];
134
+ const clearFilters = vi.fn();
135
+ render(
136
+ <TestProviders collection={{ filters, clearFilters }}>
137
+ <SearchFilterForm />
138
+ </TestProviders>,
139
+ );
140
+
141
+ fireEvent.click(screen.getByText("Clear all"));
142
+ expect(clearFilters).toHaveBeenCalled();
143
+ });
144
+
145
+ it("calls addFilter when a filter is submitted", async () => {
146
+ const user = userEvent.setup();
147
+ const addFilter = vi.fn();
148
+ render(
149
+ <TestProviders collection={{ addFilter }}>
150
+ <SearchFilterForm />
151
+ </TestProviders>,
152
+ );
153
+
154
+ // Select field
155
+ await user.selectOptions(
156
+ screen.getByDisplayValue("Select field..."),
157
+ "name",
158
+ );
159
+
160
+ // Type value
161
+ await user.type(screen.getByPlaceholderText("Enter value..."), "Alice");
162
+
163
+ // Click Add
164
+ await user.click(screen.getByText("Add"));
165
+
166
+ expect(addFilter).toHaveBeenCalledWith("name", "eq", "Alice");
167
+ });
168
+
169
+ it("shows 'No filterable fields' when no columns have filter config", () => {
170
+ const columnsWithoutFilter: Column<TestRow>[] = [
171
+ { kind: "field", dataKey: "name", label: "Name" },
172
+ ];
173
+ render(
174
+ <TestProviders dataTable={{ columns: columnsWithoutFilter }}>
175
+ <SearchFilterForm />
176
+ </TestProviders>,
177
+ );
178
+
179
+ expect(screen.getByText("No filterable fields")).toBeInTheDocument();
180
+ });
181
+
182
+ it("shows 'All fields have filters applied' when every field is filtered", () => {
183
+ const filters: Filter[] = [
184
+ { field: "name", operator: "eq", value: "a" },
185
+ { field: "status", operator: "eq", value: "active" },
186
+ { field: "priority", operator: "eq", value: "1" },
187
+ ];
188
+ render(
189
+ <TestProviders collection={{ filters }}>
190
+ <SearchFilterForm />
191
+ </TestProviders>,
192
+ );
193
+
194
+ expect(
195
+ screen.getByText("All fields have filters applied"),
196
+ ).toBeInTheDocument();
197
+ });
198
+
199
+ it("supports custom labels", () => {
200
+ render(
201
+ <TestProviders>
202
+ <SearchFilterForm
203
+ labels={{
204
+ search: "検索",
205
+ searchFilter: "検索フィルタ",
206
+ selectField: "フィールド選択...",
207
+ }}
208
+ />
209
+ </TestProviders>,
210
+ );
211
+
212
+ expect(screen.getByText("検索")).toBeInTheDocument();
213
+ expect(screen.getByText("検索フィルタ")).toBeInTheDocument();
214
+ expect(screen.getByText("フィールド選択...")).toBeInTheDocument();
215
+ });
216
+
217
+ it("throws when rendered outside provider", () => {
218
+ console.error = vi.fn();
219
+ expect(() => render(<SearchFilterForm />)).toThrow();
220
+ console.error = globalThis.console.error;
221
+ });
222
+ });
@@ -4,38 +4,43 @@ import {
4
4
  useEffect,
5
5
  useRef,
6
6
  type KeyboardEvent,
7
+ type ReactNode,
7
8
  } from "react";
8
- import type {
9
- FilterOperator,
10
- SearchFilterFormProps,
11
- FilterConfig,
12
- } from "./types";
9
+ import type { FilterOperator, FilterConfig, SearchFilterLabels } from "./types";
13
10
  import { OPERATORS_BY_FILTER_TYPE, DEFAULT_OPERATOR_LABELS } from "./types";
11
+ import { useDataTableContext } from "./data-table/data-table-context";
12
+ import { useCollectionContext } from "./collection/collection-provider";
14
13
 
15
14
  /**
16
15
  * Composite search filter form.
17
16
  *
18
17
  * Renders a dropdown panel with type-specific filter inputs, operator
19
- * selectors, and active filter badges. Designed to receive props via
20
- * spread from `useDataTable()` / `useCollection()`.
18
+ * selectors, and active filter badges. Reads `columns` from
19
+ * `DataTableContext` and filter state from `CollectionContext`.
20
+ * Must be rendered inside `DataTable.Provider`.
21
21
  *
22
22
  * All text is customisable through the optional `labels` prop (defaults
23
23
  * to English).
24
24
  *
25
25
  * @example
26
26
  * ```tsx
27
- * <SearchFilterForm {...table} {...params} />
27
+ * <DataTable.Provider value={table}>
28
+ * <SearchFilterForm />
29
+ * </DataTable.Provider>
28
30
  * ```
29
31
  */
30
- export function SearchFilterForm<TRow extends Record<string, unknown>>({
31
- columns,
32
- filters,
33
- addFilter,
34
- removeFilter,
35
- clearFilters,
32
+ export function SearchFilterForm({
36
33
  labels,
37
34
  trigger,
38
- }: SearchFilterFormProps<TRow>) {
35
+ }: {
36
+ /** Localizable labels (defaults to English) */
37
+ labels?: SearchFilterLabels;
38
+ /** Custom trigger element. When provided, replaces the default trigger button content. */
39
+ trigger?: ReactNode;
40
+ } = {}) {
41
+ const { columns } = useDataTableContext();
42
+ const { filters, addFilter, removeFilter, clearFilters } =
43
+ useCollectionContext();
39
44
  const filterableColumns = columns.filter(
40
45
  (col) => col.kind === "field" && col.filter,
41
46
  );
@@ -609,6 +609,10 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
609
609
  deleteRow: (rowId: string) => { rollback: () => void; deletedRow: TRow };
610
610
  /** Optimistically insert a row */
611
611
  insertRow: (row: TRow) => { rollback: () => void };
612
+
613
+ // Collection (passthrough for DataTable.Provider)
614
+ /** Collection state passed through from options */
615
+ collection: UseCollectionReturn<string, unknown> | undefined;
612
616
  }
613
617
 
614
618
  // =============================================================================
@@ -942,14 +946,18 @@ export type MatchingTableName<
942
946
 
943
947
  /**
944
948
  * Options for metadata-based single field definition.
949
+ *
950
+ * @typeParam TRow - The row type for type-safe renderer access.
945
951
  */
946
- export interface MetadataFieldOptions {
952
+ export interface MetadataFieldOptions<
953
+ TRow extends Record<string, unknown> = Record<string, unknown>,
954
+ > {
947
955
  /** Label override (defaults to metadata description or field name) */
948
956
  label?: string;
949
957
  /** Column width */
950
958
  width?: number;
951
- /** Custom cell renderer */
952
- renderer?: CellRenderer<Record<string, unknown>>;
959
+ /** Custom cell renderer with typed row data */
960
+ renderer?: CellRenderer<TRow>;
953
961
  /** Enable/disable sort (default: auto-detected from type) */
954
962
  sort?: boolean;
955
963
  /** Enable/disable filter (default: auto-detected from type) */
@@ -0,0 +1,131 @@
1
+ import type { ReactNode } from "react";
2
+ import { vi } from "vitest";
3
+ import type { Column, UseCollectionReturn } from "../component/types";
4
+ import { DataTableContext } from "../component/data-table/data-table-context";
5
+ import type { DataTableContextValue } from "../component/data-table/data-table-context";
6
+ import { CollectionProvider } from "../component/collection/collection-provider";
7
+
8
+ // =============================================================================
9
+ // Mock factory: DataTableContext
10
+ // =============================================================================
11
+
12
+ export function createMockDataTableContext<T extends Record<string, unknown>>(
13
+ defaults: { columns: Column<T>[]; rows: T[] },
14
+ overrides?: Partial<DataTableContextValue<T>>,
15
+ ): DataTableContextValue<T> {
16
+ return {
17
+ columns: defaults.columns,
18
+ rows: defaults.rows,
19
+ loading: false,
20
+ error: null,
21
+ sortStates: [],
22
+ onSort: vi.fn(),
23
+ updateRow: vi.fn(() => ({ rollback: vi.fn() })),
24
+ deleteRow: vi.fn(() => ({
25
+ rollback: vi.fn(),
26
+ deletedRow: defaults.rows[0],
27
+ })),
28
+ insertRow: vi.fn(() => ({ rollback: vi.fn() })),
29
+ visibleColumns: defaults.columns,
30
+ isColumnVisible: vi.fn(() => true),
31
+ toggleColumn: vi.fn(),
32
+ showAllColumns: vi.fn(),
33
+ hideAllColumns: vi.fn(),
34
+ pageInfo: {
35
+ hasNextPage: false,
36
+ endCursor: null,
37
+ hasPreviousPage: false,
38
+ startCursor: null,
39
+ },
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ // =============================================================================
45
+ // Mock factory: CollectionContext
46
+ // =============================================================================
47
+
48
+ export function createMockCollectionContext(
49
+ overrides?: Partial<UseCollectionReturn<string, unknown>>,
50
+ ): UseCollectionReturn<string, unknown> {
51
+ return {
52
+ toQueryArgs: vi.fn(() => ({ query: null, variables: {} })),
53
+ filters: [],
54
+ addFilter: vi.fn(),
55
+ setFilters: vi.fn(),
56
+ removeFilter: vi.fn(),
57
+ clearFilters: vi.fn(),
58
+ sortStates: [],
59
+ setSort: vi.fn(),
60
+ clearSort: vi.fn(),
61
+ pageSize: 20,
62
+ cursor: null,
63
+ paginationDirection: "forward",
64
+ nextPage: vi.fn(),
65
+ prevPage: vi.fn(),
66
+ resetPage: vi.fn(),
67
+ hasPrevPage: false,
68
+ hasNextPage: false,
69
+ setPageInfo: vi.fn(),
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ // =============================================================================
75
+ // TestProviders factory
76
+ // =============================================================================
77
+
78
+ /**
79
+ * Creates a `TestProviders` wrapper component bound to specific fixture data.
80
+ *
81
+ * ```tsx
82
+ * const TestProviders = createTestProviders({
83
+ * columns: testColumns,
84
+ * rows: testRows,
85
+ * dataTableDefaults: { pageInfo: { hasNextPage: true, ... } },
86
+ * collectionDefaults: { hasPrevPage: true },
87
+ * });
88
+ *
89
+ * render(
90
+ * <TestProviders dataTable={{ loading: true }}>
91
+ * <MyComponent />
92
+ * </TestProviders>,
93
+ * );
94
+ * ```
95
+ */
96
+ export function createTestProviders<
97
+ T extends Record<string, unknown>,
98
+ >(defaults: {
99
+ columns: Column<T>[];
100
+ rows: T[];
101
+ dataTableDefaults?: Partial<DataTableContextValue<T>>;
102
+ collectionDefaults?: Partial<UseCollectionReturn<string, unknown>>;
103
+ }) {
104
+ return function TestProviders({
105
+ children,
106
+ dataTable,
107
+ collection,
108
+ }: {
109
+ children: ReactNode;
110
+ dataTable?: Partial<DataTableContextValue<T>>;
111
+ collection?: Partial<UseCollectionReturn<string, unknown>>;
112
+ }) {
113
+ return (
114
+ <CollectionProvider
115
+ value={createMockCollectionContext({
116
+ ...defaults.collectionDefaults,
117
+ ...collection,
118
+ })}
119
+ >
120
+ <DataTableContext.Provider
121
+ value={createMockDataTableContext(defaults, {
122
+ ...defaults.dataTableDefaults,
123
+ ...dataTable,
124
+ })}
125
+ >
126
+ {children}
127
+ </DataTableContext.Provider>
128
+ </CollectionProvider>
129
+ );
130
+ };
131
+ }