@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 +1 -1
- package/src/component/column-selector.test.tsx +120 -0
- package/src/component/column-selector.tsx +30 -32
- package/src/component/csv-button.test.tsx +122 -0
- package/src/component/csv-button.tsx +8 -7
- package/src/component/data-table/data-table-context.tsx +17 -6
- package/src/component/data-table/index.tsx +112 -2
- package/src/component/data-table/use-data-table.ts +3 -0
- package/src/component/field-helpers.ts +20 -13
- package/src/component/index.ts +0 -4
- package/src/component/pagination.test.tsx +129 -0
- package/src/component/pagination.tsx +12 -10
- package/src/component/search-filter-form.test.tsx +222 -0
- package/src/component/search-filter-form.tsx +20 -15
- package/src/component/types.ts +11 -3
- package/src/tests/helpers.tsx +131 -0
package/package.json
CHANGED
|
@@ -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
|
|
1
|
+
import { useDataTableContext } from "./data-table/data-table-context";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Column visibility toggle dropdown.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
* <
|
|
11
|
+
* <DataTable.Provider value={table}>
|
|
12
|
+
* <ColumnSelector />
|
|
13
|
+
* </DataTable.Provider>
|
|
11
14
|
* ```
|
|
12
15
|
*/
|
|
13
|
-
export function ColumnSelector
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{hideAllColumns
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
* <
|
|
12
|
+
* <DataTable.Provider value={table}>
|
|
13
|
+
* <CsvButton filename="orders-export" />
|
|
14
|
+
* </DataTable.Provider>
|
|
11
15
|
* ```
|
|
12
16
|
*/
|
|
13
|
-
export function CsvButton
|
|
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
|
|
8
|
-
* so that `DataTable.Headers` / `DataTable.Body`
|
|
9
|
-
*
|
|
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 {
|
|
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,
|
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
148
|
+
* column("title", {
|
|
149
|
+
* renderer: ({ row }) => <span>{row.title}</span>, // row: Task
|
|
150
|
+
* }),
|
|
146
151
|
* ];
|
|
147
152
|
* ```
|
|
148
153
|
*/
|
|
149
|
-
export function inferColumnHelper<
|
|
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<
|
|
162
|
+
options?: MetadataFieldOptions<TRow>,
|
|
163
|
+
) => FieldColumn<TRow>;
|
|
156
164
|
|
|
157
165
|
columns: (
|
|
158
166
|
dataKeys: TableFieldName<TTable>[],
|
|
159
167
|
options?: MetadataFieldsOptions,
|
|
160
|
-
) => FieldColumn<
|
|
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<
|
|
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<
|
|
215
|
+
): FieldColumn<TRow>[] => {
|
|
210
216
|
return dataKeys.map((dataKey) => {
|
|
211
217
|
const overrides = options?.overrides?.[dataKey as string];
|
|
212
218
|
return column(dataKey, {
|
|
213
|
-
|
|
219
|
+
label: overrides?.label,
|
|
220
|
+
width: overrides?.width,
|
|
214
221
|
sort: overrides?.sort ?? options?.sort,
|
|
215
222
|
filter: overrides?.filter ?? options?.filter,
|
|
216
223
|
});
|
package/src/component/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
* <
|
|
12
|
+
* <DataTable.Provider value={table}>
|
|
13
|
+
* <Pagination />
|
|
14
|
+
* </DataTable.Provider>
|
|
11
15
|
* ```
|
|
12
16
|
*/
|
|
13
|
-
export function Pagination({
|
|
14
|
-
pageInfo
|
|
15
|
-
nextPage,
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
* <
|
|
27
|
+
* <DataTable.Provider value={table}>
|
|
28
|
+
* <SearchFilterForm />
|
|
29
|
+
* </DataTable.Provider>
|
|
28
30
|
* ```
|
|
29
31
|
*/
|
|
30
|
-
export function SearchFilterForm
|
|
31
|
-
columns,
|
|
32
|
-
filters,
|
|
33
|
-
addFilter,
|
|
34
|
-
removeFilter,
|
|
35
|
-
clearFilters,
|
|
32
|
+
export function SearchFilterForm({
|
|
36
33
|
labels,
|
|
37
34
|
trigger,
|
|
38
|
-
}:
|
|
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
|
);
|
package/src/component/types.ts
CHANGED
|
@@ -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<
|
|
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
|
+
}
|