@izumisy-tailor/tailor-data-viewer 0.2.18 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/component/collection/collection-provider.tsx +1 -1
- package/src/component/column-selector.test.tsx +120 -0
- package/src/component/column-selector.tsx +30 -32
- package/src/component/components.test.tsx +88 -36
- 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 +22 -9
- package/src/component/data-table/{index.tsx → data-table.tsx} +141 -117
- package/src/component/data-table/use-data-table.test.ts +6 -29
- package/src/component/data-table/use-data-table.ts +25 -56
- package/src/component/index.ts +1 -8
- 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 +8 -53
- package/src/tests/helpers.tsx +131 -0
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ const CollectionContext = createContext<UseCollectionReturn<
|
|
|
15
15
|
*
|
|
16
16
|
* <CollectionProvider value={collection}>
|
|
17
17
|
* <FilterPanel />
|
|
18
|
-
* <DataTable.Root
|
|
18
|
+
* <DataTable.Root>...</DataTable.Root>
|
|
19
19
|
* <Pagination {...table} />
|
|
20
20
|
* </CollectionProvider>
|
|
21
21
|
* ```
|
|
@@ -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 =
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
3
|
import { Table } from "./table";
|
|
4
|
-
import { DataTable } from "./data-table/
|
|
4
|
+
import { DataTable } from "./data-table/data-table";
|
|
5
|
+
import { DataTableContext } from "./data-table/data-table-context";
|
|
6
|
+
import type { DataTableContextValue } from "./data-table/data-table-context";
|
|
5
7
|
import type { Column } from "./types";
|
|
6
8
|
|
|
7
9
|
describe("Table (static)", () => {
|
|
@@ -65,13 +67,51 @@ const testRows: TestRow[] = [
|
|
|
65
67
|
{ id: "2", name: "Bob", status: "Inactive" },
|
|
66
68
|
];
|
|
67
69
|
|
|
70
|
+
const noopRowOps = {
|
|
71
|
+
updateRow: () => ({ rollback: () => {} }),
|
|
72
|
+
deleteRow: () => ({
|
|
73
|
+
rollback: () => {},
|
|
74
|
+
deletedRow: {} as TestRow,
|
|
75
|
+
}),
|
|
76
|
+
insertRow: () => ({ rollback: () => {} }),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const defaultPageInfo = {
|
|
80
|
+
hasNextPage: false,
|
|
81
|
+
endCursor: null,
|
|
82
|
+
hasPreviousPage: false,
|
|
83
|
+
startCursor: null,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function createCtx(
|
|
87
|
+
overrides: Partial<DataTableContextValue<TestRow>> = {},
|
|
88
|
+
): DataTableContextValue<TestRow> {
|
|
89
|
+
return {
|
|
90
|
+
columns: testColumns,
|
|
91
|
+
rows: testRows,
|
|
92
|
+
loading: false,
|
|
93
|
+
error: null,
|
|
94
|
+
sortStates: [],
|
|
95
|
+
visibleColumns: testColumns,
|
|
96
|
+
isColumnVisible: () => true,
|
|
97
|
+
toggleColumn: () => {},
|
|
98
|
+
showAllColumns: () => {},
|
|
99
|
+
hideAllColumns: () => {},
|
|
100
|
+
pageInfo: defaultPageInfo,
|
|
101
|
+
...noopRowOps,
|
|
102
|
+
...overrides,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
describe("DataTable", () => {
|
|
69
107
|
it("renders data-bound table with auto-generated rows", () => {
|
|
70
108
|
render(
|
|
71
|
-
<
|
|
72
|
-
<DataTable.
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
110
|
+
<DataTable.Root>
|
|
111
|
+
<DataTable.Headers />
|
|
112
|
+
<DataTable.Body />
|
|
113
|
+
</DataTable.Root>
|
|
114
|
+
</DataTableContext.Provider>,
|
|
75
115
|
);
|
|
76
116
|
|
|
77
117
|
expect(screen.getByText("Name")).toBeInTheDocument();
|
|
@@ -83,10 +123,12 @@ describe("DataTable", () => {
|
|
|
83
123
|
|
|
84
124
|
it("renders display columns via render function", () => {
|
|
85
125
|
render(
|
|
86
|
-
<
|
|
87
|
-
<DataTable.
|
|
88
|
-
|
|
89
|
-
|
|
126
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
127
|
+
<DataTable.Root>
|
|
128
|
+
<DataTable.Headers />
|
|
129
|
+
<DataTable.Body />
|
|
130
|
+
</DataTable.Root>
|
|
131
|
+
</DataTableContext.Provider>,
|
|
90
132
|
);
|
|
91
133
|
|
|
92
134
|
expect(screen.getByText("Edit Alice")).toBeInTheDocument();
|
|
@@ -95,10 +137,12 @@ describe("DataTable", () => {
|
|
|
95
137
|
|
|
96
138
|
it("shows loading state", () => {
|
|
97
139
|
render(
|
|
98
|
-
<
|
|
99
|
-
<DataTable.
|
|
100
|
-
|
|
101
|
-
|
|
140
|
+
<DataTableContext.Provider value={createCtx({ rows: [], loading: true })}>
|
|
141
|
+
<DataTable.Root>
|
|
142
|
+
<DataTable.Headers />
|
|
143
|
+
<DataTable.Body />
|
|
144
|
+
</DataTable.Root>
|
|
145
|
+
</DataTableContext.Provider>,
|
|
102
146
|
);
|
|
103
147
|
|
|
104
148
|
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
|
@@ -107,10 +151,12 @@ describe("DataTable", () => {
|
|
|
107
151
|
it("shows error state", () => {
|
|
108
152
|
const err = new Error("Something went wrong");
|
|
109
153
|
render(
|
|
110
|
-
<
|
|
111
|
-
<DataTable.
|
|
112
|
-
|
|
113
|
-
|
|
154
|
+
<DataTableContext.Provider value={createCtx({ rows: [], error: err })}>
|
|
155
|
+
<DataTable.Root>
|
|
156
|
+
<DataTable.Headers />
|
|
157
|
+
<DataTable.Body />
|
|
158
|
+
</DataTable.Root>
|
|
159
|
+
</DataTableContext.Provider>,
|
|
114
160
|
);
|
|
115
161
|
|
|
116
162
|
expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
|
|
@@ -118,10 +164,12 @@ describe("DataTable", () => {
|
|
|
118
164
|
|
|
119
165
|
it("shows empty state", () => {
|
|
120
166
|
render(
|
|
121
|
-
<
|
|
122
|
-
<DataTable.
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
<DataTableContext.Provider value={createCtx({ rows: [] })}>
|
|
168
|
+
<DataTable.Root>
|
|
169
|
+
<DataTable.Headers />
|
|
170
|
+
<DataTable.Body />
|
|
171
|
+
</DataTable.Root>
|
|
172
|
+
</DataTableContext.Provider>,
|
|
125
173
|
);
|
|
126
174
|
|
|
127
175
|
expect(screen.getByText("No data")).toBeInTheDocument();
|
|
@@ -129,14 +177,16 @@ describe("DataTable", () => {
|
|
|
129
177
|
|
|
130
178
|
it("renders sort indicator on sorted column", () => {
|
|
131
179
|
render(
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
180
|
+
<DataTableContext.Provider
|
|
181
|
+
value={createCtx({
|
|
182
|
+
sortStates: [{ field: "name", direction: "Asc" }],
|
|
183
|
+
})}
|
|
136
184
|
>
|
|
137
|
-
<DataTable.
|
|
138
|
-
|
|
139
|
-
|
|
185
|
+
<DataTable.Root>
|
|
186
|
+
<DataTable.Headers />
|
|
187
|
+
<DataTable.Body />
|
|
188
|
+
</DataTable.Root>
|
|
189
|
+
</DataTableContext.Provider>,
|
|
140
190
|
);
|
|
141
191
|
|
|
142
192
|
expect(screen.getByText("▲")).toBeInTheDocument();
|
|
@@ -144,14 +194,16 @@ describe("DataTable", () => {
|
|
|
144
194
|
|
|
145
195
|
it("supports custom rendering with children", () => {
|
|
146
196
|
render(
|
|
147
|
-
<
|
|
148
|
-
<DataTable.
|
|
149
|
-
|
|
150
|
-
<DataTable.
|
|
151
|
-
<DataTable.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
197
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
198
|
+
<DataTable.Root>
|
|
199
|
+
<DataTable.Headers />
|
|
200
|
+
<DataTable.Body>
|
|
201
|
+
<DataTable.Row>
|
|
202
|
+
<DataTable.Cell>Custom Cell</DataTable.Cell>
|
|
203
|
+
</DataTable.Row>
|
|
204
|
+
</DataTable.Body>
|
|
205
|
+
</DataTable.Root>
|
|
206
|
+
</DataTableContext.Provider>,
|
|
155
207
|
);
|
|
156
208
|
|
|
157
209
|
expect(screen.getByText("Custom Cell")).toBeInTheDocument();
|
|
@@ -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.
|
|
5
|
+
* Context value provided by `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.
|
|
17
|
+
// Table state propagated from 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.
|
|
@@ -29,9 +40,9 @@ const DataTableContext = createContext<DataTableContextValue<any> | null>(null);
|
|
|
29
40
|
export { DataTableContext };
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
|
-
* Hook to access row operations from the nearest `DataTable.
|
|
43
|
+
* Hook to access row operations from the nearest `DataTable.Provider`.
|
|
33
44
|
*
|
|
34
|
-
* @throws Error if used outside of `DataTable.
|
|
45
|
+
* @throws Error if used outside of `DataTable.Provider`.
|
|
35
46
|
*
|
|
36
47
|
* @example
|
|
37
48
|
* ```tsx
|
|
@@ -46,7 +57,9 @@ export function useDataTableContext<
|
|
|
46
57
|
>(): DataTableContextValue<TRow> {
|
|
47
58
|
const ctx = useContext(DataTableContext);
|
|
48
59
|
if (!ctx) {
|
|
49
|
-
throw new Error(
|
|
60
|
+
throw new Error(
|
|
61
|
+
"useDataTableContext must be used within <DataTable.Provider>",
|
|
62
|
+
);
|
|
50
63
|
}
|
|
51
64
|
return ctx as DataTableContextValue<TRow>;
|
|
52
65
|
}
|