@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.
@@ -11,64 +11,36 @@ import {
11
11
  DropdownMenuSeparator,
12
12
  DropdownMenuLabel,
13
13
  } from "./ui/dropdown-menu";
14
- import type {
15
- FieldMetadata,
16
- RelationMetadata,
17
- TableMetadataMap,
18
- ExpandedRelationFields,
19
- } from "../generator/metadata-generator";
20
-
21
- interface ColumnSelectorProps {
22
- fields: FieldMetadata[];
23
- selectedFields: string[];
24
- onToggle: (fieldName: string) => void;
25
- onSelectAll: () => void;
26
- onDeselectAll: () => void;
27
- /** Relations for the current table */
28
- relations?: RelationMetadata[];
29
- /** Currently selected relation field names */
30
- selectedRelations?: string[];
31
- /** Toggle relation visibility */
32
- onToggleRelation?: (fieldName: string) => void;
33
- /** Table metadata map for relation field lookups */
34
- tableMetadataMap?: TableMetadataMap;
35
- /** Expanded relation fields (manyToOne fields shown as inline columns) */
36
- expandedRelationFields?: ExpandedRelationFields;
37
- /** Toggle a field within an expanded relation */
38
- onToggleExpandedRelationField?: (
39
- relationFieldName: string,
40
- fieldName: string,
41
- ) => void;
42
- /** Check if a field is selected within an expanded relation */
43
- isExpandedRelationFieldSelected?: (
44
- relationFieldName: string,
45
- fieldName: string,
46
- ) => boolean;
47
- /** Controlled open state */
48
- open?: boolean;
49
- /** Callback when open state changes */
50
- onOpenChange?: (open: boolean) => void;
51
- }
14
+ import { useDataViewer } from "./contexts";
15
+ import { useToolbar } from "./contexts";
52
16
 
53
17
  /**
54
18
  * Column visibility selector with checkboxes
19
+ * Must be used within DataViewer.Root and DataViewer.Toolbar context
55
20
  */
56
- export function ColumnSelector({
57
- fields,
58
- selectedFields,
59
- onToggle,
60
- onSelectAll,
61
- onDeselectAll,
62
- relations = [],
63
- selectedRelations = [],
64
- onToggleRelation,
65
- tableMetadataMap,
66
- expandedRelationFields = {},
67
- onToggleExpandedRelationField,
68
- isExpandedRelationFieldSelected,
69
- open,
70
- onOpenChange,
71
- }: ColumnSelectorProps) {
21
+ export function ColumnSelector() {
22
+ const {
23
+ tableMetadata,
24
+ metadata,
25
+ selectedFields,
26
+ toggleField,
27
+ selectAllFields,
28
+ deselectAllFields,
29
+ selectedRelations,
30
+ toggleRelation,
31
+ expandedRelationFields,
32
+ toggleExpandedRelationField,
33
+ isExpandedRelationFieldSelected,
34
+ } = useDataViewer();
35
+ const { activePanel, setActivePanel } = useToolbar();
36
+
37
+ const fields = tableMetadata?.fields ?? [];
38
+ const relations = tableMetadata?.relations ?? [];
39
+
40
+ const open = activePanel === "column";
41
+ const onOpenChange = (isOpen: boolean) =>
42
+ setActivePanel(isOpen ? "column" : null);
43
+
72
44
  // Filter out nested fields as they're not directly selectable
73
45
  const selectableFields = fields.filter((field) => field.type !== "nested");
74
46
 
@@ -102,10 +74,10 @@ export function ColumnSelector({
102
74
 
103
75
  <DropdownMenuContent align="start" className="w-64">
104
76
  <div className="flex gap-1 border-b px-2 py-1.5">
105
- <Button variant="ghost" size="sm" onClick={onSelectAll}>
77
+ <Button variant="ghost" size="sm" onClick={selectAllFields}>
106
78
  全選択
107
79
  </Button>
108
- <Button variant="ghost" size="sm" onClick={onDeselectAll}>
80
+ <Button variant="ghost" size="sm" onClick={deselectAllFields}>
109
81
  全解除
110
82
  </Button>
111
83
  </div>
@@ -119,7 +91,7 @@ export function ColumnSelector({
119
91
  >
120
92
  <Checkbox
121
93
  checked={selectedFields.includes(field.name)}
122
- onCheckedChange={() => onToggle(field.name)}
94
+ onCheckedChange={() => toggleField(field.name)}
123
95
  />
124
96
  <span
125
97
  className="truncate"
@@ -135,108 +107,103 @@ export function ColumnSelector({
135
107
  </div>
136
108
 
137
109
  {/* ManyToOne Relations with submenu for field expansion */}
138
- {manyToOneRelations.length > 0 &&
139
- onToggleRelation &&
140
- tableMetadataMap && (
141
- <>
142
- <DropdownMenuSeparator />
143
- <DropdownMenuLabel className="text-muted-foreground text-xs">
144
- リレーション (1対1)
145
- </DropdownMenuLabel>
146
- <div className="space-y-1">
147
- {manyToOneRelations.map((relation) => {
148
- const targetTable = tableMetadataMap[relation.targetTable];
149
- const targetFields =
150
- targetTable?.fields.filter(
151
- (f) => f.type !== "nested" && f.name !== "id",
152
- ) ?? [];
153
- const selectedExpandedFields =
154
- expandedRelationFields[relation.fieldName] ?? [];
110
+ {manyToOneRelations.length > 0 && metadata && (
111
+ <>
112
+ <DropdownMenuSeparator />
113
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
114
+ リレーション (1対1)
115
+ </DropdownMenuLabel>
116
+ <div className="space-y-1">
117
+ {manyToOneRelations.map((relation) => {
118
+ const targetTable = metadata[relation.targetTable];
119
+ const targetFields =
120
+ targetTable?.fields.filter(
121
+ (f) => f.type !== "nested" && f.name !== "id",
122
+ ) ?? [];
123
+ const selectedExpandedFields =
124
+ expandedRelationFields[relation.fieldName] ?? [];
155
125
 
156
- return (
157
- <div
158
- key={relation.fieldName}
159
- className="flex items-center gap-1"
160
- >
161
- {/* Inline toggle checkbox */}
162
- <label className="hover:bg-accent flex flex-1 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm">
163
- <Checkbox
164
- checked={selectedRelations.includes(
165
- relation.fieldName,
166
- )}
167
- onCheckedChange={() =>
168
- onToggleRelation(relation.fieldName)
169
- }
170
- />
171
- <span className="truncate" title={relation.fieldName}>
172
- {relation.fieldName}
173
- </span>
174
- <span className="text-muted-foreground text-xs">
175
- (1)
176
- </span>
177
- </label>
126
+ return (
127
+ <div
128
+ key={relation.fieldName}
129
+ className="flex items-center gap-1"
130
+ >
131
+ {/* Inline toggle checkbox */}
132
+ <label className="hover:bg-accent flex flex-1 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm">
133
+ <Checkbox
134
+ checked={selectedRelations.includes(
135
+ relation.fieldName,
136
+ )}
137
+ onCheckedChange={() =>
138
+ toggleRelation(relation.fieldName)
139
+ }
140
+ />
141
+ <span className="truncate" title={relation.fieldName}>
142
+ {relation.fieldName}
143
+ </span>
144
+ <span className="text-muted-foreground text-xs">
145
+ (1)
146
+ </span>
147
+ </label>
178
148
 
179
- {/* Submenu for field expansion */}
180
- {targetTable && onToggleExpandedRelationField && (
181
- <DropdownMenuSub>
182
- <DropdownMenuSubTrigger className="h-7 px-2">
183
- <Columns3 className="size-3" />
184
- {selectedExpandedFields.length > 0 && (
185
- <span className="text-muted-foreground ml-1 text-xs">
186
- ({selectedExpandedFields.length})
187
- </span>
188
- )}
189
- </DropdownMenuSubTrigger>
190
- <DropdownMenuSubContent className="w-56">
191
- <DropdownMenuLabel className="text-muted-foreground text-xs">
192
- {relation.targetTable}{" "}
193
- のフィールドを列として展開
194
- </DropdownMenuLabel>
195
- <DropdownMenuSeparator />
196
- <div className="max-h-60 overflow-auto p-1">
197
- <div className="space-y-1">
198
- {targetFields.map((field) => (
199
- <label
200
- key={field.name}
201
- className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
149
+ {/* Submenu for field expansion */}
150
+ {targetTable && (
151
+ <DropdownMenuSub>
152
+ <DropdownMenuSubTrigger className="h-7 px-2">
153
+ <Columns3 className="size-3" />
154
+ {selectedExpandedFields.length > 0 && (
155
+ <span className="text-muted-foreground ml-1 text-xs">
156
+ ({selectedExpandedFields.length})
157
+ </span>
158
+ )}
159
+ </DropdownMenuSubTrigger>
160
+ <DropdownMenuSubContent className="w-56">
161
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
162
+ {relation.targetTable} のフィールドを列として展開
163
+ </DropdownMenuLabel>
164
+ <DropdownMenuSeparator />
165
+ <div className="max-h-60 overflow-auto p-1">
166
+ <div className="space-y-1">
167
+ {targetFields.map((field) => (
168
+ <label
169
+ key={field.name}
170
+ className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
171
+ >
172
+ <Checkbox
173
+ checked={isExpandedRelationFieldSelected(
174
+ relation.fieldName,
175
+ field.name,
176
+ )}
177
+ onCheckedChange={() =>
178
+ toggleExpandedRelationField(
179
+ relation.fieldName,
180
+ field.name,
181
+ )
182
+ }
183
+ className="size-3.5"
184
+ />
185
+ <span
186
+ className="truncate"
187
+ title={field.description ?? field.name}
202
188
  >
203
- <Checkbox
204
- checked={
205
- isExpandedRelationFieldSelected?.(
206
- relation.fieldName,
207
- field.name,
208
- ) ?? false
209
- }
210
- onCheckedChange={() =>
211
- onToggleExpandedRelationField(
212
- relation.fieldName,
213
- field.name,
214
- )
215
- }
216
- className="size-3.5"
217
- />
218
- <span
219
- className="truncate"
220
- title={field.description ?? field.name}
221
- >
222
- {field.name}
223
- </span>
224
- </label>
225
- ))}
226
- </div>
189
+ {field.name}
190
+ </span>
191
+ </label>
192
+ ))}
227
193
  </div>
228
- </DropdownMenuSubContent>
229
- </DropdownMenuSub>
230
- )}
231
- </div>
232
- );
233
- })}
234
- </div>
235
- </>
236
- )}
194
+ </div>
195
+ </DropdownMenuSubContent>
196
+ </DropdownMenuSub>
197
+ )}
198
+ </div>
199
+ );
200
+ })}
201
+ </div>
202
+ </>
203
+ )}
237
204
 
238
205
  {/* OneToMany Relations (inline toggle only) */}
239
- {oneToManyRelations.length > 0 && onToggleRelation && (
206
+ {oneToManyRelations.length > 0 && (
240
207
  <>
241
208
  <DropdownMenuSeparator />
242
209
  <DropdownMenuLabel className="text-muted-foreground text-xs">
@@ -250,9 +217,7 @@ export function ColumnSelector({
250
217
  >
251
218
  <Checkbox
252
219
  checked={selectedRelations.includes(relation.fieldName)}
253
- onCheckedChange={() =>
254
- onToggleRelation(relation.fieldName)
255
- }
220
+ onCheckedChange={() => toggleRelation(relation.fieldName)}
256
221
  />
257
222
  <span className="truncate" title={relation.fieldName}>
258
223
  {relation.fieldName}
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ type DataViewerInitialData,
4
+ type DataViewerProviderProps,
5
+ } from "./data-viewer-context";
6
+ import type { TableMetadataMap } from "../../generator/metadata-generator";
7
+
8
+ // =============================================================================
9
+ // Mock metadata with `as const` for type-safe tests
10
+ // =============================================================================
11
+
12
+ const mockMetadata = {
13
+ user: {
14
+ name: "user",
15
+ pluralForm: "users",
16
+ readAllowedRoles: ["admin"],
17
+ fields: [
18
+ { name: "id", type: "uuid", required: true },
19
+ { name: "name", type: "string", required: true },
20
+ { name: "email", type: "string", required: true },
21
+ {
22
+ name: "role",
23
+ type: "enum",
24
+ required: true,
25
+ enumValues: ["admin", "member"],
26
+ },
27
+ ],
28
+ relations: [
29
+ {
30
+ fieldName: "posts",
31
+ targetTable: "post",
32
+ relationType: "oneToMany",
33
+ foreignKeyField: "authorId",
34
+ },
35
+ ],
36
+ },
37
+ post: {
38
+ name: "post",
39
+ pluralForm: "posts",
40
+ readAllowedRoles: ["admin", "member"],
41
+ fields: [
42
+ { name: "id", type: "uuid", required: true },
43
+ { name: "title", type: "string", required: true },
44
+ { name: "content", type: "string", required: false },
45
+ { name: "authorId", type: "uuid", required: true },
46
+ ],
47
+ relations: [
48
+ {
49
+ fieldName: "author",
50
+ targetTable: "user",
51
+ relationType: "manyToOne",
52
+ foreignKeyField: "authorId",
53
+ },
54
+ {
55
+ fieldName: "comments",
56
+ targetTable: "comment",
57
+ relationType: "oneToMany",
58
+ foreignKeyField: "postId",
59
+ },
60
+ ],
61
+ },
62
+ comment: {
63
+ name: "comment",
64
+ pluralForm: "comments",
65
+ readAllowedRoles: ["admin", "member"],
66
+ fields: [
67
+ { name: "id", type: "uuid", required: true },
68
+ { name: "body", type: "string", required: true },
69
+ { name: "postId", type: "uuid", required: true },
70
+ ],
71
+ relations: [
72
+ {
73
+ fieldName: "post",
74
+ targetTable: "post",
75
+ relationType: "manyToOne",
76
+ foreignKeyField: "postId",
77
+ },
78
+ ],
79
+ },
80
+ } as const;
81
+
82
+ type MockMetadata = typeof mockMetadata;
83
+
84
+ // =============================================================================
85
+ // Type-level tests for type safety
86
+ // These tests verify that the types compile correctly.
87
+ // If the types are incorrect, TypeScript will fail to compile this file.
88
+ // =============================================================================
89
+
90
+ describe("DataViewerProvider type safety", () => {
91
+ describe("type-level tests (compile-time checks)", () => {
92
+ it("should correctly extract field names from metadata", () => {
93
+ // These are compile-time checks - if they compile, the types are correct
94
+ // TypeScript will error if invalid field names are used
95
+
96
+ // Valid user fields
97
+ const userInitialData: DataViewerInitialData<MockMetadata, "user"> = {
98
+ selectedFields: ["id", "name", "email", "role"],
99
+ };
100
+
101
+ // Valid post fields
102
+ const postInitialData: DataViewerInitialData<MockMetadata, "post"> = {
103
+ selectedFields: ["id", "title", "content", "authorId"],
104
+ };
105
+
106
+ // Valid comment fields
107
+ const commentInitialData: DataViewerInitialData<MockMetadata, "comment"> =
108
+ {
109
+ selectedFields: ["id", "body", "postId"],
110
+ };
111
+
112
+ expect(userInitialData.selectedFields).toHaveLength(4);
113
+ expect(postInitialData.selectedFields).toHaveLength(4);
114
+ expect(commentInitialData.selectedFields).toHaveLength(3);
115
+ });
116
+
117
+ it("should correctly extract relation names from metadata", () => {
118
+ // User has "posts" relation
119
+ const userInitialData: DataViewerInitialData<MockMetadata, "user"> = {
120
+ selectedRelations: ["posts"],
121
+ };
122
+
123
+ // Post has "author" and "comments" relations
124
+ const postInitialData: DataViewerInitialData<MockMetadata, "post"> = {
125
+ selectedRelations: ["author", "comments"],
126
+ };
127
+
128
+ expect(userInitialData.selectedRelations).toEqual(["posts"]);
129
+ expect(postInitialData.selectedRelations).toEqual(["author", "comments"]);
130
+ });
131
+
132
+ it("should correctly type tableName in props", () => {
133
+ // Valid table names
134
+ const userProps: DataViewerProviderProps<MockMetadata, "user"> = {
135
+ children: null,
136
+ tableName: "user",
137
+ metadata: mockMetadata,
138
+ appUri: "https://example.com",
139
+ };
140
+
141
+ const postProps: DataViewerProviderProps<MockMetadata, "post"> = {
142
+ children: null,
143
+ tableName: "post",
144
+ metadata: mockMetadata,
145
+ appUri: "https://example.com",
146
+ };
147
+
148
+ expect(userProps.tableName).toBe("user");
149
+ expect(postProps.tableName).toBe("post");
150
+ });
151
+
152
+ it("should allow valid field names in initialData", () => {
153
+ // This should compile without errors
154
+ const validInitialData: DataViewerInitialData<MockMetadata, "user"> = {
155
+ selectedFields: ["id", "name", "email"],
156
+ selectedRelations: ["posts"],
157
+ };
158
+
159
+ expect(validInitialData.selectedFields).toEqual(["id", "name", "email"]);
160
+ });
161
+
162
+ it("should allow valid expanded relation fields", () => {
163
+ // Post has relations: author (-> user) and comments (-> comment)
164
+ const validInitialData: DataViewerInitialData<MockMetadata, "post"> = {
165
+ selectedFields: ["title", "content"],
166
+ selectedRelations: ["author", "comments"],
167
+ expandedRelationFields: {
168
+ author: ["name", "email"], // user fields
169
+ },
170
+ };
171
+
172
+ expect(validInitialData.expandedRelationFields?.author).toEqual([
173
+ "name",
174
+ "email",
175
+ ]);
176
+ });
177
+
178
+ it("should work with regular TableMetadataMap (non-const)", () => {
179
+ // When using regular TableMetadataMap (not `as const`), it should accept any string
180
+ type RegularInitialData = DataViewerInitialData<TableMetadataMap, string>;
181
+
182
+ // Should accept any string array
183
+ const initialData: RegularInitialData = {
184
+ selectedFields: ["anyField", "anotherField"],
185
+ selectedRelations: ["anyRelation"],
186
+ };
187
+
188
+ expect(initialData.selectedFields).toBeDefined();
189
+ });
190
+ });
191
+ });