@izumisy-tailor/tailor-data-viewer 0.1.20 → 0.1.22
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/README.md +55 -2
- package/dist/generator/index.d.mts +54 -31
- package/dist/generator/index.mjs +20 -13
- package/docs/app-shell-module.md +3 -3
- package/docs/compositional-api.md +366 -0
- package/package.json +1 -1
- package/src/app-shell/create-data-view-module.tsx +3 -3
- package/src/app-shell/types.ts +5 -5
- package/src/component/column-selector.test.tsx +143 -103
- package/src/component/column-selector.tsx +121 -156
- package/src/component/contexts/data-viewer-context.test.tsx +191 -0
- package/src/component/contexts/data-viewer-context.tsx +244 -0
- package/src/component/contexts/index.ts +19 -0
- package/src/component/contexts/table-data-context.tsx +114 -0
- package/src/component/contexts/toolbar-context.tsx +62 -0
- package/src/component/csv-button.tsx +79 -0
- package/src/component/data-table-toolbar.test.tsx +127 -72
- package/src/component/data-table-toolbar.tsx +14 -151
- package/src/component/data-table.tsx +255 -225
- package/src/component/data-view-tab-content.tsx +67 -138
- package/src/component/data-viewer.tsx +11 -11
- package/src/component/hooks/use-column-state.ts +2 -2
- package/src/component/index.ts +33 -1
- package/src/component/refresh-button.tsx +20 -0
- package/src/component/search-filter.tsx +19 -24
- package/src/component/single-record-tab-content.test.tsx +10 -10
- package/src/component/single-record-tab-content.tsx +62 -21
- package/src/component/view-save-load.tsx +13 -17
- package/src/generator/metadata-generator.ts +100 -67
|
@@ -2,7 +2,18 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
2
2
|
import { render, screen, within, waitFor } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
5
|
-
import
|
|
5
|
+
import { ColumnSelector } from "./column-selector";
|
|
6
|
+
import { SearchFilterForm } from "./search-filter";
|
|
7
|
+
import { CsvButton } from "./csv-button";
|
|
8
|
+
import { RefreshButton } from "./refresh-button";
|
|
9
|
+
import { DataViewerProvider } from "./contexts";
|
|
10
|
+
import { TableDataProvider } from "./contexts";
|
|
11
|
+
import type {
|
|
12
|
+
FieldMetadata,
|
|
13
|
+
TableMetadata,
|
|
14
|
+
TableMetadataMap,
|
|
15
|
+
} from "../generator/metadata-generator";
|
|
16
|
+
import type { ReactNode } from "react";
|
|
6
17
|
|
|
7
18
|
const mockFields: FieldMetadata[] = [
|
|
8
19
|
{ name: "id", type: "uuid", required: true, description: "ID" },
|
|
@@ -11,45 +22,96 @@ const mockFields: FieldMetadata[] = [
|
|
|
11
22
|
{ name: "age", type: "number", required: false, description: "年齢" },
|
|
12
23
|
];
|
|
13
24
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const mockTableMetadata: TableMetadata = {
|
|
26
|
+
name: "TestTable",
|
|
27
|
+
pluralForm: "TestTables",
|
|
28
|
+
readAllowedRoles: [],
|
|
29
|
+
fields: mockFields,
|
|
30
|
+
relations: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const mockTableMetadataMap: TableMetadataMap = {
|
|
34
|
+
TestTable: mockTableMetadata,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
interface WrapperProps {
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
initialSelectedFields?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Mock the GraphQL client to prevent actual API calls
|
|
43
|
+
vi.mock("../graphql/graphql-client", () => ({
|
|
44
|
+
createGraphQLClient: vi.fn(() => ({
|
|
45
|
+
fetch: vi.fn(() =>
|
|
46
|
+
Promise.resolve({
|
|
47
|
+
data: {
|
|
48
|
+
testTables: { collection: [], pageInfo: { hasNextPage: false } },
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
})),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
function TestWrapper({
|
|
56
|
+
children,
|
|
57
|
+
initialSelectedFields = ["id", "name"],
|
|
58
|
+
}: WrapperProps) {
|
|
59
|
+
return (
|
|
60
|
+
<DataViewerProvider
|
|
61
|
+
appUri="https://example.com"
|
|
62
|
+
tableName={mockTableMetadata.name}
|
|
63
|
+
metadata={mockTableMetadataMap}
|
|
64
|
+
initialData={{ selectedFields: initialSelectedFields }}
|
|
65
|
+
>
|
|
66
|
+
<TableDataProvider>{children}</TableDataProvider>
|
|
67
|
+
</DataViewerProvider>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
28
70
|
|
|
29
71
|
describe("DataTableToolbar", () => {
|
|
30
72
|
describe("基本的な表示", () => {
|
|
31
73
|
it("カラム選択ボタンが表示される", () => {
|
|
32
|
-
render(
|
|
74
|
+
render(
|
|
75
|
+
<TestWrapper>
|
|
76
|
+
<DataTableToolbar>
|
|
77
|
+
<ColumnSelector />
|
|
78
|
+
</DataTableToolbar>
|
|
79
|
+
</TestWrapper>,
|
|
80
|
+
);
|
|
33
81
|
expect(
|
|
34
82
|
screen.getByRole("button", { name: /カラム選択/ }),
|
|
35
83
|
).toBeInTheDocument();
|
|
36
84
|
});
|
|
37
85
|
|
|
38
86
|
it("検索ボタンが表示される", () => {
|
|
39
|
-
render(
|
|
87
|
+
render(
|
|
88
|
+
<TestWrapper>
|
|
89
|
+
<DataTableToolbar>
|
|
90
|
+
<SearchFilterForm />
|
|
91
|
+
</DataTableToolbar>
|
|
92
|
+
</TestWrapper>,
|
|
93
|
+
);
|
|
40
94
|
expect(screen.getByRole("button", { name: /検索/ })).toBeInTheDocument();
|
|
41
95
|
});
|
|
42
96
|
|
|
43
|
-
it("CSV ボタンが表示される
|
|
97
|
+
it("CSV ボタンが表示される", () => {
|
|
44
98
|
render(
|
|
45
|
-
<
|
|
99
|
+
<TestWrapper>
|
|
100
|
+
<DataTableToolbar>
|
|
101
|
+
<CsvButton />
|
|
102
|
+
</DataTableToolbar>
|
|
103
|
+
</TestWrapper>,
|
|
46
104
|
);
|
|
47
105
|
expect(screen.getByRole("button", { name: /CSV/ })).toBeInTheDocument();
|
|
48
106
|
});
|
|
49
107
|
|
|
50
|
-
it("更新ボタンが表示される
|
|
108
|
+
it("更新ボタンが表示される", () => {
|
|
51
109
|
render(
|
|
52
|
-
<
|
|
110
|
+
<TestWrapper>
|
|
111
|
+
<DataTableToolbar>
|
|
112
|
+
<RefreshButton />
|
|
113
|
+
</DataTableToolbar>
|
|
114
|
+
</TestWrapper>,
|
|
53
115
|
);
|
|
54
116
|
expect(screen.getByRole("button", { name: /更新/ })).toBeInTheDocument();
|
|
55
117
|
});
|
|
@@ -58,7 +120,14 @@ describe("DataTableToolbar", () => {
|
|
|
58
120
|
describe("パネルの排他制御", () => {
|
|
59
121
|
it("カラム選択パネルを開くと検索パネルは開かない", async () => {
|
|
60
122
|
const user = userEvent.setup();
|
|
61
|
-
render(
|
|
123
|
+
render(
|
|
124
|
+
<TestWrapper>
|
|
125
|
+
<DataTableToolbar>
|
|
126
|
+
<ColumnSelector />
|
|
127
|
+
<SearchFilterForm />
|
|
128
|
+
</DataTableToolbar>
|
|
129
|
+
</TestWrapper>,
|
|
130
|
+
);
|
|
62
131
|
|
|
63
132
|
// カラム選択パネルを開く
|
|
64
133
|
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
@@ -76,7 +145,14 @@ describe("DataTableToolbar", () => {
|
|
|
76
145
|
|
|
77
146
|
it("検索パネルを開くとカラム選択パネルは開かない", async () => {
|
|
78
147
|
const user = userEvent.setup();
|
|
79
|
-
render(
|
|
148
|
+
render(
|
|
149
|
+
<TestWrapper>
|
|
150
|
+
<DataTableToolbar>
|
|
151
|
+
<ColumnSelector />
|
|
152
|
+
<SearchFilterForm />
|
|
153
|
+
</DataTableToolbar>
|
|
154
|
+
</TestWrapper>,
|
|
155
|
+
);
|
|
80
156
|
|
|
81
157
|
// 検索パネルを開く
|
|
82
158
|
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
@@ -94,7 +170,14 @@ describe("DataTableToolbar", () => {
|
|
|
94
170
|
|
|
95
171
|
it("カラム選択パネルが開いている状態で検索パネルを開くとカラム選択パネルが閉じる", async () => {
|
|
96
172
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
97
|
-
render(
|
|
173
|
+
render(
|
|
174
|
+
<TestWrapper>
|
|
175
|
+
<DataTableToolbar>
|
|
176
|
+
<ColumnSelector />
|
|
177
|
+
<SearchFilterForm />
|
|
178
|
+
</DataTableToolbar>
|
|
179
|
+
</TestWrapper>,
|
|
180
|
+
);
|
|
98
181
|
|
|
99
182
|
// カラム選択パネルを開く
|
|
100
183
|
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
@@ -128,7 +211,14 @@ describe("DataTableToolbar", () => {
|
|
|
128
211
|
|
|
129
212
|
it("検索パネルが開いている状態でカラム選択パネルを開くと検索パネルが閉じる", async () => {
|
|
130
213
|
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
131
|
-
render(
|
|
214
|
+
render(
|
|
215
|
+
<TestWrapper>
|
|
216
|
+
<DataTableToolbar>
|
|
217
|
+
<ColumnSelector />
|
|
218
|
+
<SearchFilterForm />
|
|
219
|
+
</DataTableToolbar>
|
|
220
|
+
</TestWrapper>,
|
|
221
|
+
);
|
|
132
222
|
|
|
133
223
|
// 検索パネルを開く
|
|
134
224
|
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
@@ -161,56 +251,21 @@ describe("DataTableToolbar", () => {
|
|
|
161
251
|
});
|
|
162
252
|
});
|
|
163
253
|
|
|
164
|
-
describe("
|
|
165
|
-
it("
|
|
166
|
-
const user = userEvent.setup();
|
|
167
|
-
const onDownloadCsv = vi.fn();
|
|
254
|
+
describe("Compositional API", () => {
|
|
255
|
+
it("children で任意のコンポーネントを配置できる", () => {
|
|
168
256
|
render(
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
257
|
+
<TestWrapper>
|
|
258
|
+
<DataTableToolbar>
|
|
259
|
+
<button data-testid="custom-button">Custom Button</button>
|
|
260
|
+
<ColumnSelector />
|
|
261
|
+
</DataTableToolbar>
|
|
262
|
+
</TestWrapper>,
|
|
173
263
|
);
|
|
174
264
|
|
|
175
|
-
|
|
176
|
-
expect(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
it("更新ボタンクリックで onRefresh が呼ばれる", async () => {
|
|
180
|
-
const user = userEvent.setup();
|
|
181
|
-
const onRefresh = vi.fn();
|
|
182
|
-
render(
|
|
183
|
-
<DataTableToolbar {...createDefaultProps()} onRefresh={onRefresh} />,
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
await user.click(screen.getByRole("button", { name: /更新/ }));
|
|
187
|
-
expect(onRefresh).toHaveBeenCalled();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("loading 中は CSV と更新ボタンが無効になる", () => {
|
|
191
|
-
render(
|
|
192
|
-
<DataTableToolbar
|
|
193
|
-
{...createDefaultProps()}
|
|
194
|
-
onDownloadCsv={vi.fn()}
|
|
195
|
-
onRefresh={vi.fn()}
|
|
196
|
-
loading={true}
|
|
197
|
-
/>,
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
201
|
-
expect(screen.getByRole("button", { name: /更新/ })).toBeDisabled();
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("csvDisabled が true の場合、CSV ボタンが無効になる", () => {
|
|
205
|
-
render(
|
|
206
|
-
<DataTableToolbar
|
|
207
|
-
{...createDefaultProps()}
|
|
208
|
-
onDownloadCsv={vi.fn()}
|
|
209
|
-
csvDisabled={true}
|
|
210
|
-
/>,
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
265
|
+
expect(screen.getByTestId("custom-button")).toBeInTheDocument();
|
|
266
|
+
expect(
|
|
267
|
+
screen.getByRole("button", { name: /カラム選択/ }),
|
|
268
|
+
).toBeInTheDocument();
|
|
214
269
|
});
|
|
215
270
|
});
|
|
216
271
|
});
|
|
@@ -1,165 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { Button } from "./ui/button";
|
|
4
|
-
import type {
|
|
5
|
-
FieldMetadata,
|
|
6
|
-
RelationMetadata,
|
|
7
|
-
TableMetadataMap,
|
|
8
|
-
ExpandedRelationFields,
|
|
9
|
-
} from "../generator/metadata-generator";
|
|
10
|
-
import { ColumnSelector } from "./column-selector";
|
|
11
|
-
import { SearchFilterForm } from "./search-filter";
|
|
12
|
-
import { ViewSave } from "./view-save-load";
|
|
13
|
-
import type { SearchFilters } from "./types";
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { ToolbarProvider } from "./contexts";
|
|
14
3
|
|
|
15
4
|
/**
|
|
16
5
|
* Active panel type - only one panel can be open at a time
|
|
6
|
+
* @deprecated Use ToolbarContext instead
|
|
17
7
|
*/
|
|
18
8
|
export type ActivePanel = "column" | "search" | null;
|
|
19
9
|
|
|
20
|
-
interface
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
onToggle: (fieldName: string) => void;
|
|
24
|
-
onSelectAll: () => void;
|
|
25
|
-
onDeselectAll: () => void;
|
|
26
|
-
relations?: RelationMetadata[];
|
|
27
|
-
selectedRelations?: string[];
|
|
28
|
-
onToggleRelation?: (fieldName: string) => void;
|
|
29
|
-
tableMetadataMap?: TableMetadataMap;
|
|
30
|
-
expandedRelationFields?: ExpandedRelationFields;
|
|
31
|
-
onToggleExpandedRelationField?: (
|
|
32
|
-
relationFieldName: string,
|
|
33
|
-
fieldName: string,
|
|
34
|
-
) => void;
|
|
35
|
-
isExpandedRelationFieldSelected?: (
|
|
36
|
-
relationFieldName: string,
|
|
37
|
-
fieldName: string,
|
|
38
|
-
) => boolean;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface SearchFilterConfig {
|
|
42
|
-
fields: FieldMetadata[];
|
|
43
|
-
filters: SearchFilters;
|
|
44
|
-
onFiltersChange: (filters: SearchFilters) => void;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface ViewSaveConfig {
|
|
48
|
-
tableName: string;
|
|
49
|
-
filters: SearchFilters;
|
|
50
|
-
selectedFields: string[];
|
|
51
|
-
selectedRelations: string[];
|
|
52
|
-
expandedRelationFields: ExpandedRelationFields;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface DataTableToolbarProps {
|
|
56
|
-
/** Column selector configuration */
|
|
57
|
-
columnSelector: ColumnSelectorConfig;
|
|
58
|
-
/** Search filter configuration */
|
|
59
|
-
searchFilter: SearchFilterConfig;
|
|
60
|
-
/** View save configuration (optional - hidden if not provided) */
|
|
61
|
-
viewSave?: ViewSaveConfig;
|
|
62
|
-
/** CSV download handler */
|
|
63
|
-
onDownloadCsv?: () => void;
|
|
64
|
-
/** Refresh handler */
|
|
65
|
-
onRefresh?: () => void;
|
|
66
|
-
/** Whether the table is loading */
|
|
67
|
-
loading?: boolean;
|
|
68
|
-
/** Whether CSV download is disabled */
|
|
69
|
-
csvDisabled?: boolean;
|
|
10
|
+
export interface DataTableToolbarProps {
|
|
11
|
+
/** Children components (uses ToolbarContext) */
|
|
12
|
+
children: ReactNode;
|
|
70
13
|
}
|
|
71
14
|
|
|
72
15
|
/**
|
|
73
16
|
* Data table toolbar component
|
|
74
|
-
*
|
|
75
|
-
*
|
|
17
|
+
*
|
|
18
|
+
* Provides ToolbarContext for exclusive panel management.
|
|
19
|
+
* Place ColumnSelector, SearchFilterForm, etc. as children.
|
|
20
|
+
* Must be used within DataViewer.Root context.
|
|
76
21
|
*/
|
|
77
|
-
export function DataTableToolbar({
|
|
78
|
-
columnSelector,
|
|
79
|
-
searchFilter,
|
|
80
|
-
viewSave,
|
|
81
|
-
onDownloadCsv,
|
|
82
|
-
onRefresh,
|
|
83
|
-
loading = false,
|
|
84
|
-
csvDisabled = false,
|
|
85
|
-
}: DataTableToolbarProps) {
|
|
86
|
-
// Active panel state - only one panel can be open at a time
|
|
87
|
-
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
|
88
|
-
|
|
89
|
-
const handleColumnPanelChange = useCallback((open: boolean) => {
|
|
90
|
-
setActivePanel(open ? "column" : null);
|
|
91
|
-
}, []);
|
|
92
|
-
|
|
93
|
-
const handleSearchPanelChange = useCallback((open: boolean) => {
|
|
94
|
-
setActivePanel(open ? "search" : null);
|
|
95
|
-
}, []);
|
|
96
|
-
|
|
22
|
+
export function DataTableToolbar({ children }: DataTableToolbarProps) {
|
|
97
23
|
return (
|
|
98
|
-
<
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
selectedFields={columnSelector.selectedFields}
|
|
102
|
-
onToggle={columnSelector.onToggle}
|
|
103
|
-
onSelectAll={columnSelector.onSelectAll}
|
|
104
|
-
onDeselectAll={columnSelector.onDeselectAll}
|
|
105
|
-
relations={columnSelector.relations}
|
|
106
|
-
selectedRelations={columnSelector.selectedRelations}
|
|
107
|
-
onToggleRelation={columnSelector.onToggleRelation}
|
|
108
|
-
tableMetadataMap={columnSelector.tableMetadataMap}
|
|
109
|
-
expandedRelationFields={columnSelector.expandedRelationFields}
|
|
110
|
-
onToggleExpandedRelationField={
|
|
111
|
-
columnSelector.onToggleExpandedRelationField
|
|
112
|
-
}
|
|
113
|
-
isExpandedRelationFieldSelected={
|
|
114
|
-
columnSelector.isExpandedRelationFieldSelected
|
|
115
|
-
}
|
|
116
|
-
open={activePanel === "column"}
|
|
117
|
-
onOpenChange={handleColumnPanelChange}
|
|
118
|
-
/>
|
|
119
|
-
|
|
120
|
-
<SearchFilterForm
|
|
121
|
-
fields={searchFilter.fields}
|
|
122
|
-
filters={searchFilter.filters}
|
|
123
|
-
onFiltersChange={searchFilter.onFiltersChange}
|
|
124
|
-
open={activePanel === "search"}
|
|
125
|
-
onOpenChange={handleSearchPanelChange}
|
|
126
|
-
/>
|
|
127
|
-
|
|
128
|
-
{viewSave && (
|
|
129
|
-
<ViewSave
|
|
130
|
-
tableName={viewSave.tableName}
|
|
131
|
-
filters={viewSave.filters}
|
|
132
|
-
selectedFields={viewSave.selectedFields}
|
|
133
|
-
selectedRelations={viewSave.selectedRelations}
|
|
134
|
-
expandedRelationFields={viewSave.expandedRelationFields}
|
|
135
|
-
/>
|
|
136
|
-
)}
|
|
137
|
-
|
|
138
|
-
<div className="flex-1" />
|
|
139
|
-
|
|
140
|
-
{onDownloadCsv && (
|
|
141
|
-
<Button
|
|
142
|
-
variant="outline"
|
|
143
|
-
size="sm"
|
|
144
|
-
onClick={onDownloadCsv}
|
|
145
|
-
disabled={loading || csvDisabled}
|
|
146
|
-
>
|
|
147
|
-
<Download className="size-4" />
|
|
148
|
-
CSV
|
|
149
|
-
</Button>
|
|
150
|
-
)}
|
|
151
|
-
|
|
152
|
-
{onRefresh && (
|
|
153
|
-
<Button
|
|
154
|
-
variant="outline"
|
|
155
|
-
size="sm"
|
|
156
|
-
onClick={onRefresh}
|
|
157
|
-
disabled={loading}
|
|
158
|
-
>
|
|
159
|
-
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
160
|
-
更新
|
|
161
|
-
</Button>
|
|
162
|
-
)}
|
|
163
|
-
</div>
|
|
24
|
+
<ToolbarProvider>
|
|
25
|
+
<div className="flex items-center gap-4">{children}</div>
|
|
26
|
+
</ToolbarProvider>
|
|
164
27
|
);
|
|
165
28
|
}
|