@izumisy-tailor/tailor-data-viewer 0.1.21 → 0.1.23

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.
Files changed (32) hide show
  1. package/README.md +45 -1
  2. package/dist/generator/index.d.mts +54 -31
  3. package/dist/generator/index.mjs +20 -13
  4. package/docs/compositional-api.md +366 -0
  5. package/package.json +1 -1
  6. package/src/app-shell/create-data-view-module.tsx +1 -1
  7. package/src/component/column-selector.test.tsx +143 -103
  8. package/src/component/column-selector.tsx +121 -156
  9. package/src/component/contexts/data-viewer-context.test.tsx +191 -0
  10. package/src/component/contexts/data-viewer-context.tsx +244 -0
  11. package/src/component/contexts/index.ts +19 -0
  12. package/src/component/contexts/table-data-context.tsx +114 -0
  13. package/src/component/contexts/toolbar-context.tsx +62 -0
  14. package/src/component/csv-button.tsx +79 -0
  15. package/src/component/data-table-toolbar.test.tsx +127 -72
  16. package/src/component/data-table-toolbar.tsx +14 -151
  17. package/src/component/data-table.tsx +255 -225
  18. package/src/component/data-view-tab-content.tsx +68 -138
  19. package/src/component/data-viewer.tsx +11 -11
  20. package/src/component/hooks/use-column-state.ts +2 -2
  21. package/src/component/hooks/use-table-data.test.ts +399 -0
  22. package/src/component/hooks/use-table-data.ts +24 -7
  23. package/src/component/index.ts +43 -1
  24. package/src/component/refresh-button.tsx +20 -0
  25. package/src/component/saved-view-context.tsx +31 -2
  26. package/src/component/search-filter.test.tsx +612 -0
  27. package/src/component/search-filter.tsx +168 -33
  28. package/src/component/single-record-tab-content.test.tsx +10 -10
  29. package/src/component/single-record-tab-content.tsx +62 -21
  30. package/src/component/types.ts +78 -0
  31. package/src/component/view-save-load.tsx +13 -17
  32. package/src/generator/metadata-generator.ts +100 -67
@@ -0,0 +1,612 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, within, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { SearchFilterForm } from "./search-filter";
5
+ import { DataViewerProvider } from "./contexts";
6
+ import { ToolbarProvider } from "./contexts";
7
+ import type {
8
+ FieldMetadata,
9
+ TableMetadata,
10
+ TableMetadataMap,
11
+ } from "../generator/metadata-generator";
12
+ import type { ReactNode } from "react";
13
+ import type { SearchFilters } from "./types";
14
+
15
+ // Mock fields covering all filterable types
16
+ const mockFields: FieldMetadata[] = [
17
+ { name: "id", type: "uuid", required: true, description: "ID" },
18
+ { name: "name", type: "string", required: true, description: "名前" },
19
+ { name: "email", type: "string", required: false, description: "メール" },
20
+ { name: "age", type: "number", required: false, description: "年齢" },
21
+ { name: "isActive", type: "boolean", required: false, description: "有効" },
22
+ {
23
+ name: "status",
24
+ type: "enum",
25
+ required: false,
26
+ description: "ステータス",
27
+ enumValues: ["ACTIVE", "INACTIVE", "PENDING"],
28
+ },
29
+ {
30
+ name: "createdAt",
31
+ type: "datetime",
32
+ required: false,
33
+ description: "作成日時",
34
+ },
35
+ { name: "birthDate", type: "date", required: false, description: "生年月日" },
36
+ // Non-filterable types
37
+ { name: "tags", type: "array", required: false, description: "タグ" },
38
+ {
39
+ name: "metadata",
40
+ type: "nested",
41
+ required: false,
42
+ description: "メタデータ",
43
+ },
44
+ ];
45
+
46
+ const mockTableMetadata: TableMetadata = {
47
+ name: "TestTable",
48
+ pluralForm: "TestTables",
49
+ readAllowedRoles: [],
50
+ fields: mockFields,
51
+ relations: [],
52
+ };
53
+
54
+ const mockTableMetadataMap: TableMetadataMap = {
55
+ TestTable: mockTableMetadata,
56
+ };
57
+
58
+ interface WrapperProps {
59
+ children: ReactNode;
60
+ initialFilters?: SearchFilters;
61
+ }
62
+
63
+ function TestWrapper({ children, initialFilters = [] }: WrapperProps) {
64
+ return (
65
+ <DataViewerProvider
66
+ appUri="https://example.com"
67
+ tableName={mockTableMetadata.name}
68
+ metadata={mockTableMetadataMap}
69
+ initialData={{
70
+ selectedFields: ["id", "name"],
71
+ filters: initialFilters,
72
+ }}
73
+ >
74
+ <ToolbarProvider>{children}</ToolbarProvider>
75
+ </DataViewerProvider>
76
+ );
77
+ }
78
+
79
+ describe("SearchFilterForm", () => {
80
+ describe("基本的な表示", () => {
81
+ it("検索ボタンが表示される", () => {
82
+ render(
83
+ <TestWrapper>
84
+ <SearchFilterForm />
85
+ </TestWrapper>,
86
+ );
87
+
88
+ expect(screen.getByRole("button", { name: /検索/ })).toBeInTheDocument();
89
+ });
90
+
91
+ it("フィルターがない場合はバッジが表示されない", () => {
92
+ render(
93
+ <TestWrapper>
94
+ <SearchFilterForm />
95
+ </TestWrapper>,
96
+ );
97
+
98
+ const button = screen.getByRole("button", { name: /検索/ });
99
+ expect(within(button).queryByText("1")).not.toBeInTheDocument();
100
+ });
101
+
102
+ it("フィルターがある場合はバッジにフィルター数が表示される", () => {
103
+ render(
104
+ <TestWrapper
105
+ initialFilters={[
106
+ {
107
+ field: "name",
108
+ fieldType: "string",
109
+ operator: "eq",
110
+ value: "test",
111
+ },
112
+ ]}
113
+ >
114
+ <SearchFilterForm />
115
+ </TestWrapper>,
116
+ );
117
+
118
+ const button = screen.getByRole("button", { name: /検索/ });
119
+ expect(within(button).getByText("1")).toBeInTheDocument();
120
+ });
121
+
122
+ it("複数フィルターがある場合は正しいフィルター数が表示される", () => {
123
+ render(
124
+ <TestWrapper
125
+ initialFilters={[
126
+ {
127
+ field: "name",
128
+ fieldType: "string",
129
+ operator: "eq",
130
+ value: "test",
131
+ },
132
+ { field: "age", fieldType: "number", operator: "gt", value: "18" },
133
+ {
134
+ field: "isActive",
135
+ fieldType: "boolean",
136
+ operator: "eq",
137
+ value: true,
138
+ },
139
+ ]}
140
+ >
141
+ <SearchFilterForm />
142
+ </TestWrapper>,
143
+ );
144
+
145
+ const button = screen.getByRole("button", { name: /検索/ });
146
+ expect(within(button).getByText("3")).toBeInTheDocument();
147
+ });
148
+ });
149
+
150
+ describe("ドロップダウンメニュー", () => {
151
+ it("ボタンクリックでドロップダウンが開く", async () => {
152
+ const user = userEvent.setup();
153
+ render(
154
+ <TestWrapper>
155
+ <SearchFilterForm />
156
+ </TestWrapper>,
157
+ );
158
+
159
+ await user.click(screen.getByRole("button", { name: /検索/ }));
160
+
161
+ await waitFor(() => {
162
+ const body = within(document.body);
163
+ expect(body.getByText("検索フィルター")).toBeInTheDocument();
164
+ });
165
+ });
166
+
167
+ it("フィルターを追加セクションが表示される", async () => {
168
+ const user = userEvent.setup();
169
+ render(
170
+ <TestWrapper>
171
+ <SearchFilterForm />
172
+ </TestWrapper>,
173
+ );
174
+
175
+ await user.click(screen.getByRole("button", { name: /検索/ }));
176
+
177
+ await waitFor(() => {
178
+ const body = within(document.body);
179
+ expect(body.getByText("フィルターを追加")).toBeInTheDocument();
180
+ expect(body.getByText("フィールドを選択")).toBeInTheDocument();
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("フィルターの表示", () => {
186
+ it("適用中のフィルターが表示される", async () => {
187
+ const user = userEvent.setup();
188
+ render(
189
+ <TestWrapper
190
+ initialFilters={[
191
+ {
192
+ field: "name",
193
+ fieldType: "string",
194
+ operator: "eq",
195
+ value: "test",
196
+ },
197
+ ]}
198
+ >
199
+ <SearchFilterForm />
200
+ </TestWrapper>,
201
+ );
202
+
203
+ await user.click(screen.getByRole("button", { name: /検索/ }));
204
+
205
+ await waitFor(() => {
206
+ const body = within(document.body);
207
+ expect(body.getByText("適用中のフィルター (AND)")).toBeInTheDocument();
208
+ expect(body.getByText("name = test")).toBeInTheDocument();
209
+ });
210
+ });
211
+
212
+ it("すべてクリアボタンが表示される", async () => {
213
+ const user = userEvent.setup();
214
+ render(
215
+ <TestWrapper
216
+ initialFilters={[
217
+ {
218
+ field: "name",
219
+ fieldType: "string",
220
+ operator: "eq",
221
+ value: "test",
222
+ },
223
+ ]}
224
+ >
225
+ <SearchFilterForm />
226
+ </TestWrapper>,
227
+ );
228
+
229
+ await user.click(screen.getByRole("button", { name: /検索/ }));
230
+
231
+ await waitFor(() => {
232
+ const body = within(document.body);
233
+ expect(body.getByText("すべてクリア")).toBeInTheDocument();
234
+ });
235
+ });
236
+
237
+ it("フィルターがない場合はすべてクリアボタンが表示されない", async () => {
238
+ const user = userEvent.setup();
239
+ render(
240
+ <TestWrapper>
241
+ <SearchFilterForm />
242
+ </TestWrapper>,
243
+ );
244
+
245
+ await user.click(screen.getByRole("button", { name: /検索/ }));
246
+
247
+ await waitFor(() => {
248
+ const body = within(document.body);
249
+ expect(body.getByText("検索フィルター")).toBeInTheDocument();
250
+ });
251
+
252
+ const body = within(document.body);
253
+ expect(body.queryByText("すべてクリア")).not.toBeInTheDocument();
254
+ });
255
+ });
256
+
257
+ describe("フィルター表示形式", () => {
258
+ it("eq 演算子は「=」で表示される", async () => {
259
+ render(
260
+ <TestWrapper
261
+ initialFilters={[
262
+ {
263
+ field: "name",
264
+ fieldType: "string",
265
+ operator: "eq",
266
+ value: "test",
267
+ },
268
+ ]}
269
+ >
270
+ <SearchFilterForm />
271
+ </TestWrapper>,
272
+ );
273
+
274
+ const user = userEvent.setup();
275
+ await user.click(screen.getByRole("button", { name: /検索/ }));
276
+
277
+ await waitFor(() => {
278
+ const body = within(document.body);
279
+ expect(body.getByText("name = test")).toBeInTheDocument();
280
+ });
281
+ });
282
+
283
+ it("gt 演算子は「>」で表示される", async () => {
284
+ render(
285
+ <TestWrapper
286
+ initialFilters={[
287
+ { field: "age", fieldType: "number", operator: "gt", value: "18" },
288
+ ]}
289
+ >
290
+ <SearchFilterForm />
291
+ </TestWrapper>,
292
+ );
293
+
294
+ const user = userEvent.setup();
295
+ await user.click(screen.getByRole("button", { name: /検索/ }));
296
+
297
+ await waitFor(() => {
298
+ const body = within(document.body);
299
+ expect(body.getByText("age > 18")).toBeInTheDocument();
300
+ });
301
+ });
302
+
303
+ it("lt 演算子は「<」で表示される", async () => {
304
+ render(
305
+ <TestWrapper
306
+ initialFilters={[
307
+ { field: "age", fieldType: "number", operator: "lt", value: "65" },
308
+ ]}
309
+ >
310
+ <SearchFilterForm />
311
+ </TestWrapper>,
312
+ );
313
+
314
+ const user = userEvent.setup();
315
+ await user.click(screen.getByRole("button", { name: /検索/ }));
316
+
317
+ await waitFor(() => {
318
+ const body = within(document.body);
319
+ expect(body.getByText("age < 65")).toBeInTheDocument();
320
+ });
321
+ });
322
+
323
+ it("contains 演算子は「含む」で表示される", async () => {
324
+ render(
325
+ <TestWrapper
326
+ initialFilters={[
327
+ {
328
+ field: "name",
329
+ fieldType: "string",
330
+ operator: "contains",
331
+ value: "test",
332
+ },
333
+ ]}
334
+ >
335
+ <SearchFilterForm />
336
+ </TestWrapper>,
337
+ );
338
+
339
+ const user = userEvent.setup();
340
+ await user.click(screen.getByRole("button", { name: /検索/ }));
341
+
342
+ await waitFor(() => {
343
+ const body = within(document.body);
344
+ expect(body.getByText('name 含む "test"')).toBeInTheDocument();
345
+ });
346
+ });
347
+
348
+ it("hasPrefix 演算子は「で始まる」で表示される", async () => {
349
+ render(
350
+ <TestWrapper
351
+ initialFilters={[
352
+ {
353
+ field: "name",
354
+ fieldType: "string",
355
+ operator: "hasPrefix",
356
+ value: "pre",
357
+ },
358
+ ]}
359
+ >
360
+ <SearchFilterForm />
361
+ </TestWrapper>,
362
+ );
363
+
364
+ const user = userEvent.setup();
365
+ await user.click(screen.getByRole("button", { name: /検索/ }));
366
+
367
+ await waitFor(() => {
368
+ const body = within(document.body);
369
+ expect(body.getByText('name "pre" で始まる')).toBeInTheDocument();
370
+ });
371
+ });
372
+
373
+ it("hasSuffix 演算子は「で終わる」で表示される", async () => {
374
+ render(
375
+ <TestWrapper
376
+ initialFilters={[
377
+ {
378
+ field: "name",
379
+ fieldType: "string",
380
+ operator: "hasSuffix",
381
+ value: "suf",
382
+ },
383
+ ]}
384
+ >
385
+ <SearchFilterForm />
386
+ </TestWrapper>,
387
+ );
388
+
389
+ const user = userEvent.setup();
390
+ await user.click(screen.getByRole("button", { name: /検索/ }));
391
+
392
+ await waitFor(() => {
393
+ const body = within(document.body);
394
+ expect(body.getByText('name "suf" で終わる')).toBeInTheDocument();
395
+ });
396
+ });
397
+
398
+ it("boolean true は「true」で表示される", async () => {
399
+ render(
400
+ <TestWrapper
401
+ initialFilters={[
402
+ {
403
+ field: "isActive",
404
+ fieldType: "boolean",
405
+ operator: "eq",
406
+ value: true,
407
+ },
408
+ ]}
409
+ >
410
+ <SearchFilterForm />
411
+ </TestWrapper>,
412
+ );
413
+
414
+ const user = userEvent.setup();
415
+ await user.click(screen.getByRole("button", { name: /検索/ }));
416
+
417
+ await waitFor(() => {
418
+ const body = within(document.body);
419
+ expect(body.getByText("isActive = true")).toBeInTheDocument();
420
+ });
421
+ });
422
+
423
+ it("boolean false は「false」で表示される", async () => {
424
+ render(
425
+ <TestWrapper
426
+ initialFilters={[
427
+ {
428
+ field: "isActive",
429
+ fieldType: "boolean",
430
+ operator: "eq",
431
+ value: false,
432
+ },
433
+ ]}
434
+ >
435
+ <SearchFilterForm />
436
+ </TestWrapper>,
437
+ );
438
+
439
+ const user = userEvent.setup();
440
+ await user.click(screen.getByRole("button", { name: /検索/ }));
441
+
442
+ await waitFor(() => {
443
+ const body = within(document.body);
444
+ expect(body.getByText("isActive = false")).toBeInTheDocument();
445
+ });
446
+ });
447
+
448
+ it("日時フィルターの値が表示される", async () => {
449
+ render(
450
+ <TestWrapper
451
+ initialFilters={[
452
+ {
453
+ field: "createdAt",
454
+ fieldType: "datetime",
455
+ operator: "gt",
456
+ value: "2024-01-01T00:00",
457
+ },
458
+ ]}
459
+ >
460
+ <SearchFilterForm />
461
+ </TestWrapper>,
462
+ );
463
+
464
+ const user = userEvent.setup();
465
+ await user.click(screen.getByRole("button", { name: /検索/ }));
466
+
467
+ await waitFor(() => {
468
+ const body = within(document.body);
469
+ expect(
470
+ body.getByText("createdAt > 2024-01-01T00:00"),
471
+ ).toBeInTheDocument();
472
+ });
473
+ });
474
+
475
+ it("日付フィルターの値が表示される", async () => {
476
+ render(
477
+ <TestWrapper
478
+ initialFilters={[
479
+ {
480
+ field: "birthDate",
481
+ fieldType: "date",
482
+ operator: "lt",
483
+ value: "2000-01-01",
484
+ },
485
+ ]}
486
+ >
487
+ <SearchFilterForm />
488
+ </TestWrapper>,
489
+ );
490
+
491
+ const user = userEvent.setup();
492
+ await user.click(screen.getByRole("button", { name: /検索/ }));
493
+
494
+ await waitFor(() => {
495
+ const body = within(document.body);
496
+ expect(body.getByText("birthDate < 2000-01-01")).toBeInTheDocument();
497
+ });
498
+ });
499
+ });
500
+
501
+ describe("フィルターの削除", () => {
502
+ it("すべてクリアボタンで全フィルターを削除できる", async () => {
503
+ const user = userEvent.setup();
504
+ render(
505
+ <TestWrapper
506
+ initialFilters={[
507
+ {
508
+ field: "name",
509
+ fieldType: "string",
510
+ operator: "eq",
511
+ value: "test",
512
+ },
513
+ { field: "age", fieldType: "number", operator: "eq", value: "25" },
514
+ ]}
515
+ >
516
+ <SearchFilterForm />
517
+ </TestWrapper>,
518
+ );
519
+
520
+ await user.click(screen.getByRole("button", { name: /検索/ }));
521
+
522
+ const body = within(document.body);
523
+ await user.click(body.getByText("すべてクリア"));
524
+
525
+ // Verify all filters are removed
526
+ await waitFor(() => {
527
+ expect(body.queryByText(/name = test/)).not.toBeInTheDocument();
528
+ expect(body.queryByText(/age = 25/)).not.toBeInTheDocument();
529
+ });
530
+ });
531
+
532
+ it("個別のフィルターを削除できる", async () => {
533
+ const user = userEvent.setup();
534
+ render(
535
+ <TestWrapper
536
+ initialFilters={[
537
+ {
538
+ field: "name",
539
+ fieldType: "string",
540
+ operator: "eq",
541
+ value: "test",
542
+ },
543
+ { field: "age", fieldType: "number", operator: "eq", value: "25" },
544
+ ]}
545
+ >
546
+ <SearchFilterForm />
547
+ </TestWrapper>,
548
+ );
549
+
550
+ await user.click(screen.getByRole("button", { name: /検索/ }));
551
+
552
+ const body = within(document.body);
553
+
554
+ // Find the badge containing "name = test" and get the button within it
555
+ const nameBadge = body
556
+ .getByText(/name = test/)
557
+ .closest("span[data-slot='badge']") as HTMLElement;
558
+ expect(nameBadge).not.toBeNull();
559
+ const removeButton = within(nameBadge).getByRole("button");
560
+ await user.click(removeButton);
561
+
562
+ // Verify only the age filter remains
563
+ await waitFor(() => {
564
+ expect(body.queryByText(/name = test/)).not.toBeInTheDocument();
565
+ expect(body.getByText(/age = 25/)).toBeInTheDocument();
566
+ });
567
+ });
568
+ });
569
+
570
+ describe("複数フィルター表示", () => {
571
+ it("複数のフィルターがすべて表示される", async () => {
572
+ const user = userEvent.setup();
573
+ render(
574
+ <TestWrapper
575
+ initialFilters={[
576
+ {
577
+ field: "name",
578
+ fieldType: "string",
579
+ operator: "contains",
580
+ value: "test",
581
+ },
582
+ { field: "age", fieldType: "number", operator: "gt", value: "18" },
583
+ {
584
+ field: "isActive",
585
+ fieldType: "boolean",
586
+ operator: "eq",
587
+ value: true,
588
+ },
589
+ {
590
+ field: "status",
591
+ fieldType: "enum",
592
+ operator: "eq",
593
+ value: "ACTIVE",
594
+ },
595
+ ]}
596
+ >
597
+ <SearchFilterForm />
598
+ </TestWrapper>,
599
+ );
600
+
601
+ await user.click(screen.getByRole("button", { name: /検索/ }));
602
+
603
+ await waitFor(() => {
604
+ const body = within(document.body);
605
+ expect(body.getByText('name 含む "test"')).toBeInTheDocument();
606
+ expect(body.getByText("age > 18")).toBeInTheDocument();
607
+ expect(body.getByText("isActive = true")).toBeInTheDocument();
608
+ expect(body.getByText("status = ACTIVE")).toBeInTheDocument();
609
+ });
610
+ });
611
+ });
612
+ });