@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 +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/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 +4 -0
- 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,
|
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
|
// =============================================================================
|
|
@@ -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
|
+
}
|