@izumisy-tailor/tailor-data-viewer 0.2.18 → 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.18",
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
  }
@@ -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
  // =============================================================================
@@ -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
+ }