@izumisy-tailor/tailor-data-viewer 0.1.51 → 0.1.53

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 CHANGED
@@ -27,6 +27,16 @@ pnpm add @izumisy-tailor/tailor-data-viewer
27
27
  yarn add @izumisy-tailor/tailor-data-viewer
28
28
  ```
29
29
 
30
+ ### For AI Users
31
+
32
+ If you're using AI coding assistants (Claude Code, Cursor, GitHub Copilot, etc.), we strongly recommend installing the Data Viewer skill:
33
+
34
+ ```bash
35
+ npx skills add git@github.com:tailor-sandbox/tailor-data-viewer.git
36
+ ```
37
+
38
+ This provides AI-optimized documentation for better code generation and assistance.
39
+
30
40
  ### Peer Dependencies
31
41
 
32
42
  This library requires the following peer dependencies:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.51",
4
+ "version": "0.1.53",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback } from "react";
2
2
  import { Check, X, Copy, CheckCircle, FileIcon } from "lucide-react";
3
- import type { CellRenderer } from "./types";
4
- import { isFileValue, type FileValue } from "./types";
3
+ import type { CellRenderer, RelationFieldMetadata } from "./types";
4
+ import { isFileValue, type FileValue, isRelationFieldMetadata } from "./types";
5
5
  import { formatFileSize } from "./lib/format-file-size";
6
6
 
7
7
  // =============================================================================
@@ -428,6 +428,100 @@ function FileImageThumbnail(
428
428
  };
429
429
  }
430
430
 
431
+ // =============================================================================
432
+ // Relation Column Renderers (for rel() API)
433
+ // =============================================================================
434
+
435
+ /**
436
+ * Default renderer for relation total (count) columns.
437
+ */
438
+ const RelationTotal: CellRenderer = ({ value }) => {
439
+ if (value == null) {
440
+ return <span className="text-muted-foreground">-</span>;
441
+ }
442
+ const num = Number(value);
443
+ return <span>{num}</span>;
444
+ };
445
+
446
+ /**
447
+ * Default renderer for relation fields with a single field.
448
+ * For hasMany: displays as comma-separated list.
449
+ * For hasOne: displays the single value.
450
+ */
451
+ const RelationSingleField: CellRenderer = ({ value, field }) => {
452
+ if (value == null) {
453
+ return <span className="text-muted-foreground">-</span>;
454
+ }
455
+
456
+ // Check if this is a relation field with result type info
457
+ if (isRelationFieldMetadata(field)) {
458
+ // For hasMany (array of values)
459
+ if (Array.isArray(value)) {
460
+ const items = value as unknown[];
461
+ if (items.length === 0) {
462
+ return <span className="text-muted-foreground">-</span>;
463
+ }
464
+
465
+ // Single field: items are objects with one key, extract the value
466
+ const strings = items.map((item) => {
467
+ if (typeof item === "object" && item !== null) {
468
+ const vals = Object.values(item as Record<string, unknown>);
469
+ return vals.length === 1
470
+ ? String(vals[0] ?? "")
471
+ : JSON.stringify(item);
472
+ }
473
+ return String(item ?? "");
474
+ });
475
+
476
+ return <span>{strings.join(", ")}</span>;
477
+ }
478
+
479
+ // For hasOne (single object)
480
+ if (typeof value === "object") {
481
+ const vals = Object.values(value as Record<string, unknown>);
482
+ return (
483
+ <span>
484
+ {vals.length === 1 ? String(vals[0] ?? "-") : JSON.stringify(value)}
485
+ </span>
486
+ );
487
+ }
488
+ }
489
+
490
+ return <span>{String(value)}</span>;
491
+ };
492
+
493
+ /**
494
+ * Default renderer for relation fields with multiple fields.
495
+ * Displays as JSON for complex objects.
496
+ */
497
+ const RelationMultipleFields: CellRenderer = ({ value }) => {
498
+ if (value == null) {
499
+ return <span className="text-muted-foreground">-</span>;
500
+ }
501
+
502
+ // For arrays (hasMany) or objects (hasOne), display as JSON
503
+ return <span className="font-mono text-xs">{JSON.stringify(value)}</span>;
504
+ };
505
+
506
+ /**
507
+ * Get the appropriate default renderer for a relation column based on its metadata.
508
+ * This is used internally to select the right renderer.
509
+ */
510
+ export function getDefaultRelationRenderer(
511
+ field: RelationFieldMetadata,
512
+ ): CellRenderer {
513
+ if (field.resultType === "total") {
514
+ return RelationTotal;
515
+ }
516
+
517
+ // For fields, check if single or multiple
518
+ if (field.selectedFields && field.selectedFields.length === 1) {
519
+ return RelationSingleField;
520
+ }
521
+
522
+ return RelationMultipleFields;
523
+ }
524
+
431
525
  // =============================================================================
432
526
  // Export
433
527
  // =============================================================================
@@ -450,6 +544,12 @@ export const builtInRenderers = {
450
544
  FileLink,
451
545
  /** Factory: Creates a file thumbnail renderer with size and click options */
452
546
  FileImageThumbnail,
547
+ /** Renders relation total (count) as a number */
548
+ RelationTotal,
549
+ /** Renders relation fields (single field) as comma-separated list */
550
+ RelationSingleField,
551
+ /** Renders relation fields (multiple fields) as JSON */
552
+ RelationMultipleFields,
453
553
  } as const;
454
554
 
455
555
  export type BuiltInRenderers = typeof builtInRenderers;
@@ -242,3 +242,154 @@ describe("createColumnDefMap", () => {
242
242
  expect(map.get("assignee.name")?.label).toBe("担当者");
243
243
  });
244
244
  });
245
+
246
+ describe("relation column support", () => {
247
+ describe("normalizeColumnDef with RelationColumnFn", () => {
248
+ it("should normalize a relation total column function", () => {
249
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
250
+ rel("taskAssignments").total;
251
+ const result = normalizeColumnDef(columnFn);
252
+
253
+ expect(result.field).toBe("taskAssignments.total");
254
+ expect(result.isRelationField).toBe(true);
255
+ expect(result.baseField).toBe("taskAssignments");
256
+ expect(result.relationRef).toBeDefined();
257
+ expect(result.relationRef?.__type).toBe("relationTotal");
258
+ expect(result.label).toBe("taskAssignments.total");
259
+ });
260
+
261
+ it("should normalize a relation fields column function", () => {
262
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
263
+ rel("taskAssignments").fields(["name", "email"]);
264
+ const result = normalizeColumnDef(columnFn);
265
+
266
+ expect(result.field).toBe("taskAssignments.fields:name,email");
267
+ expect(result.isRelationField).toBe(true);
268
+ expect(result.baseField).toBe("taskAssignments");
269
+ expect(result.relationRef).toBeDefined();
270
+ expect(result.relationRef?.__type).toBe("relationFields");
271
+ expect(result.label).toBe("taskAssignments");
272
+ });
273
+
274
+ it("should normalize a relation column with filters", () => {
275
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
276
+ rel("taskAssignments", [
277
+ { field: "answered", operator: "eq", value: false },
278
+ ]).total;
279
+ const result = normalizeColumnDef(columnFn);
280
+
281
+ expect(result.field).toBe("taskAssignments.total:answered=false");
282
+ expect(result.relationRef?.filters).toEqual([
283
+ { field: "answered", operator: "eq", value: false },
284
+ ]);
285
+ });
286
+ });
287
+
288
+ describe("normalizeColumnDef with tuple containing RelationColumnFn", () => {
289
+ it("should normalize [RelationColumnFn, renderer] tuple", () => {
290
+ const mockRenderer: CellRenderer = ({ value }) => String(value);
291
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
292
+ rel("taskAssignments").total;
293
+ const result = normalizeColumnDef([columnFn, mockRenderer]);
294
+
295
+ expect(result.field).toBe("taskAssignments.total");
296
+ expect(result.renderer).toBe(mockRenderer);
297
+ expect(result.relationRef?.__type).toBe("relationTotal");
298
+ });
299
+
300
+ it("should normalize [RelationColumnFn, ColumnOptions] tuple", () => {
301
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
302
+ rel("taskAssignments").total;
303
+ const result = normalizeColumnDef([
304
+ columnFn,
305
+ { label: "担当者数", width: 100 },
306
+ ]);
307
+
308
+ expect(result.field).toBe("taskAssignments.total");
309
+ expect(result.label).toBe("担当者数");
310
+ expect(result.width).toBe(100);
311
+ expect(result.relationRef?.__type).toBe("relationTotal");
312
+ });
313
+ });
314
+
315
+ describe("normalizeColumnDef with serialized RelationRef", () => {
316
+ it("should normalize a serialized RelationTotalRef", () => {
317
+ const serializedRef = {
318
+ __type: "relationTotal" as const,
319
+ id: "taskAssignments.total",
320
+ relationName: "taskAssignments",
321
+ };
322
+ const result = normalizeColumnDef(serializedRef);
323
+
324
+ expect(result.field).toBe("taskAssignments.total");
325
+ expect(result.isRelationField).toBe(true);
326
+ expect(result.relationRef).toEqual(serializedRef);
327
+ });
328
+
329
+ it("should normalize a serialized RelationFieldsRef", () => {
330
+ const serializedRef = {
331
+ __type: "relationFields" as const,
332
+ id: "taskAssignments.fields:name",
333
+ relationName: "taskAssignments",
334
+ fields: ["name"],
335
+ };
336
+ const result = normalizeColumnDef(serializedRef);
337
+
338
+ expect(result.field).toBe("taskAssignments.fields:name");
339
+ expect(result.isRelationField).toBe(true);
340
+ expect(result.relationRef).toEqual(serializedRef);
341
+ });
342
+
343
+ it("should normalize [RelationRef, ColumnOptions] tuple", () => {
344
+ const serializedRef = {
345
+ __type: "relationTotal" as const,
346
+ id: "taskAssignments.total",
347
+ relationName: "taskAssignments",
348
+ };
349
+ const result = normalizeColumnDef([
350
+ serializedRef,
351
+ { label: "カスタムラベル" },
352
+ ]);
353
+
354
+ expect(result.field).toBe("taskAssignments.total");
355
+ expect(result.label).toBe("カスタムラベル");
356
+ expect(result.relationRef).toEqual(serializedRef);
357
+ });
358
+ });
359
+
360
+ describe("extractFieldsFromColumns with relationRefs", () => {
361
+ it("should extract relationRefs from normalized columns", () => {
362
+ const columnFn = (rel: import("./relation-column").RelationBuilder) =>
363
+ rel("taskAssignments").total;
364
+ const columns: UntypedColumnDef[] = ["name", "assignee.name", columnFn];
365
+ const normalized = normalizeColumnDefs(columns);
366
+ const result = extractFieldsFromColumns(normalized);
367
+
368
+ expect(result.selectedFields).toEqual(["name"]);
369
+ expect(result.selectedRelations).toEqual(["assignee"]);
370
+ expect(result.expandedRelationFields).toEqual({ assignee: ["name"] });
371
+ expect(result.relationRefs).toHaveLength(1);
372
+ expect(result.relationRefs[0].__type).toBe("relationTotal");
373
+ });
374
+
375
+ it("should extract multiple relationRefs", () => {
376
+ const columns: UntypedColumnDef[] = [
377
+ (rel: import("./relation-column").RelationBuilder) =>
378
+ rel("taskAssignments").total,
379
+ (rel: import("./relation-column").RelationBuilder) =>
380
+ rel("taskAssignments", [
381
+ { field: "answered", operator: "eq", value: false },
382
+ ]).total,
383
+ (rel: import("./relation-column").RelationBuilder) =>
384
+ rel("comments").fields(["text"]),
385
+ ];
386
+ const normalized = normalizeColumnDefs(columns);
387
+ const result = extractFieldsFromColumns(normalized);
388
+
389
+ expect(result.relationRefs).toHaveLength(3);
390
+ expect(result.relationRefs[0].relationName).toBe("taskAssignments");
391
+ expect(result.relationRefs[1].filters).toHaveLength(1);
392
+ expect(result.relationRefs[2].__type).toBe("relationFields");
393
+ });
394
+ });
395
+ });
@@ -6,6 +6,14 @@ import type {
6
6
  CellRenderer,
7
7
  NormalizedColumnDefinition,
8
8
  } from "./types";
9
+ import {
10
+ isRelationRef,
11
+ isRelationColumnFn,
12
+ executeRelationColumnFn,
13
+ getDefaultRelationLabel,
14
+ type RelationRef,
15
+ type RelationColumnFn,
16
+ } from "./relation-column";
9
17
 
10
18
  /**
11
19
  * Check if a value is a CellRenderer function
@@ -23,7 +31,14 @@ function isColumnOptions(value: unknown): value is ColumnOptions {
23
31
  return false;
24
32
  }
25
33
  // CellRenderer is a function, ColumnOptions is an object
26
- return !isCellRenderer(value);
34
+ // RelationRef has __type, ColumnOptions does not
35
+ if (isCellRenderer(value)) {
36
+ return false;
37
+ }
38
+ if (isRelationRef(value)) {
39
+ return false;
40
+ }
41
+ return true;
27
42
  }
28
43
 
29
44
  /**
@@ -63,12 +78,45 @@ function parseFieldPath(fieldPath: string): {
63
78
  };
64
79
  }
65
80
 
81
+ /**
82
+ * Normalize a RelationRef to NormalizedColumnDefinition
83
+ */
84
+ function normalizeRelationRef(
85
+ ref: RelationRef,
86
+ options?: ColumnOptions,
87
+ ): NormalizedColumnDefinition {
88
+ return {
89
+ field: ref.id,
90
+ width: options?.width,
91
+ minWidth: options?.minWidth,
92
+ maxWidth: options?.maxWidth,
93
+ formatter: options?.formatter,
94
+ renderer: options?.renderer,
95
+ label: options?.label ?? getDefaultRelationLabel(ref),
96
+ isRelationField: true,
97
+ baseField: ref.relationName,
98
+ nestedField: undefined,
99
+ relationRef: ref,
100
+ };
101
+ }
102
+
66
103
  /**
67
104
  * Normalize a single ColumnDef to NormalizedColumnDefinition
68
105
  */
69
106
  export function normalizeColumnDef(
70
107
  columnDef: UntypedColumnDef,
71
108
  ): NormalizedColumnDefinition {
109
+ // Handle RelationColumnFn: (rel) => rel("taskAssignments").total
110
+ if (isRelationColumnFn(columnDef)) {
111
+ const ref = executeRelationColumnFn(columnDef);
112
+ return normalizeRelationRef(ref);
113
+ }
114
+
115
+ // Handle serialized RelationRef (from saved views)
116
+ if (isRelationRef(columnDef)) {
117
+ return normalizeRelationRef(columnDef);
118
+ }
119
+
72
120
  // Level 1: Field path only ("email")
73
121
  if (typeof columnDef === "string") {
74
122
  const { baseField, nestedField, isRelationField } =
@@ -91,6 +139,7 @@ export function normalizeColumnDef(
91
139
  width: columnDef.width,
92
140
  minWidth: columnDef.minWidth,
93
141
  maxWidth: columnDef.maxWidth,
142
+ formatter: columnDef.formatter,
94
143
  renderer: columnDef.renderer,
95
144
  label: columnDef.label,
96
145
  isRelationField,
@@ -99,9 +148,35 @@ export function normalizeColumnDef(
99
148
  };
100
149
  }
101
150
 
102
- // Level 2: Tuple format (["email", renderer] or ["email", { width: 300 }])
151
+ // Level 2: Tuple format
103
152
  if (Array.isArray(columnDef) && columnDef.length === 2) {
104
- const [fieldPath, second] = columnDef;
153
+ const [first, second] = columnDef;
154
+
155
+ // Handle tuple with RelationColumnFn: [(rel) => ..., options]
156
+ if (isRelationColumnFn(first)) {
157
+ const ref = executeRelationColumnFn(first as RelationColumnFn);
158
+ if (isCellRenderer(second)) {
159
+ return normalizeRelationRef(ref, { renderer: second });
160
+ }
161
+ if (isColumnOptions(second)) {
162
+ return normalizeRelationRef(ref, second);
163
+ }
164
+ return normalizeRelationRef(ref);
165
+ }
166
+
167
+ // Handle tuple with RelationRef: [RelationRef, options]
168
+ if (isRelationRef(first)) {
169
+ if (isCellRenderer(second)) {
170
+ return normalizeRelationRef(first, { renderer: second });
171
+ }
172
+ if (isColumnOptions(second)) {
173
+ return normalizeRelationRef(first, second);
174
+ }
175
+ return normalizeRelationRef(first);
176
+ }
177
+
178
+ // Handle tuple with field path string: ["email", options]
179
+ const fieldPath = first as string;
105
180
  const { baseField, nestedField, isRelationField } =
106
181
  parseFieldPath(fieldPath);
107
182
 
@@ -123,6 +198,7 @@ export function normalizeColumnDef(
123
198
  width: second.width,
124
199
  minWidth: second.minWidth,
125
200
  maxWidth: second.maxWidth,
201
+ formatter: second.formatter,
126
202
  renderer: second.renderer,
127
203
  label: second.label,
128
204
  isRelationField,
@@ -158,14 +234,16 @@ export function normalizeColumnDefs(
158
234
  export interface ExtractedFields {
159
235
  /** List of direct field names (non-relation fields) */
160
236
  selectedFields: string[];
161
- /** List of relation field names */
237
+ /** List of relation field names (manyToOne/hasOne) */
162
238
  selectedRelations: string[];
163
- /** Map of relation field names to their expanded field names */
239
+ /** Map of relation field names to their expanded field names (manyToOne/hasOne) */
164
240
  expandedRelationFields: ExpandedRelationFields;
241
+ /** List of relation refs from rel() columns (oneToMany/hasMany) */
242
+ relationRefs: RelationRef[];
165
243
  }
166
244
 
167
245
  /**
168
- * Extract selectedFields, selectedRelations, and expandedRelationFields
246
+ * Extract selectedFields, selectedRelations, expandedRelationFields, and relationRefs
169
247
  * from normalized column definitions
170
248
  */
171
249
  export function extractFieldsFromColumns(
@@ -174,10 +252,17 @@ export function extractFieldsFromColumns(
174
252
  const selectedFields: string[] = [];
175
253
  const selectedRelations: string[] = [];
176
254
  const expandedRelationFields: ExpandedRelationFields = {};
255
+ const relationRefs: RelationRef[] = [];
177
256
 
178
257
  for (const column of columns) {
258
+ // Handle rel() columns (relationRef present)
259
+ if (column.relationRef) {
260
+ relationRefs.push(column.relationRef);
261
+ continue;
262
+ }
263
+
179
264
  if (column.isRelationField) {
180
- // Relation field (e.g., "assignee.name")
265
+ // Relation field (e.g., "assignee.name") - manyToOne/hasOne
181
266
  if (!selectedRelations.includes(column.baseField)) {
182
267
  selectedRelations.push(column.baseField);
183
268
  }
@@ -203,6 +288,7 @@ export function extractFieldsFromColumns(
203
288
  selectedFields,
204
289
  selectedRelations,
205
290
  expandedRelationFields,
291
+ relationRefs,
206
292
  };
207
293
  }
208
294