@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.
@@ -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
  );
@@ -511,63 +511,10 @@ export interface RowOperations<TRow extends Record<string, unknown>> {
511
511
  insertRow: (row: TRow) => { rollback: () => void };
512
512
  }
513
513
 
514
- /**
515
- * Props for `DataTable.Root` component (generated by `useDataTable`).
516
- */
517
- export interface DataTableRootProps<TRow extends Record<string, unknown>> {
518
- /** Visible column definitions */
519
- columns: Column<TRow>[];
520
- /** Row data */
521
- rows: TRow[];
522
- /** Loading state */
523
- loading?: boolean;
524
- /** Error */
525
- error?: Error | null;
526
- /** Sort handler (connected to collection) */
527
- onSort?: (field: string, direction?: "Asc" | "Desc") => void;
528
- /** Current sort states */
529
- sortStates?: SortState[];
530
- /** Row operations (provided to children via Context) */
531
- rowOperations?: RowOperations<TRow>;
532
- children: ReactNode;
533
- }
534
-
535
- /**
536
- * Props for `DataTable.Row` component.
537
- */
538
- export interface DataTableRowProps<TRow extends Record<string, unknown>> {
539
- /** The row data */
540
- row: TRow;
541
- }
542
-
543
- /**
544
- * Props for `DataTable.Cell` component.
545
- */
546
- export interface DataTableCellProps<TRow extends Record<string, unknown>> {
547
- /** The row data */
548
- row: TRow;
549
- /** The column definition */
550
- column: Column<TRow>;
551
- /** The row index */
552
- rowIndex: number;
553
- }
554
-
555
514
  /**
556
515
  * Return type of `useDataTable` hook.
557
516
  */
558
517
  export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
559
- // Props generators (for spreading)
560
- /** Props for DataTable.Root */
561
- rootProps: DataTableRootProps<TRow>;
562
- /** Get props for a DataTable.Row */
563
- getRowProps: (row: TRow) => DataTableRowProps<TRow>;
564
- /** Get props for a DataTable.Cell */
565
- getCellProps: (
566
- row: TRow,
567
- column: Column<TRow>,
568
- rowIndex: number,
569
- ) => DataTableCellProps<TRow>;
570
-
571
518
  // Data
572
519
  /** Row data extracted from collection result */
573
520
  rows: TRow[];
@@ -575,6 +522,10 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
575
522
  loading: boolean;
576
523
  /** Error */
577
524
  error: Error | null;
525
+ /** Current sort states */
526
+ sortStates: SortState[];
527
+ /** Sort handler (connected to collection) */
528
+ onSort?: (field: string, direction?: "Asc" | "Desc") => void;
578
529
 
579
530
  // Pagination (delegated from collection)
580
531
  /** Page info from GraphQL response */
@@ -609,6 +560,10 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
609
560
  deleteRow: (rowId: string) => { rollback: () => void; deletedRow: TRow };
610
561
  /** Optimistically insert a row */
611
562
  insertRow: (row: TRow) => { rollback: () => void };
563
+
564
+ // Collection (passthrough for DataTable.Provider)
565
+ /** Collection state passed through from options */
566
+ collection: UseCollectionReturn<string, unknown> | undefined;
612
567
  }
613
568
 
614
569
  // =============================================================================
@@ -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
+ }