@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.
@@ -1,5 +1,5 @@
1
1
  import { useMemo } from "react";
2
- import { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
2
+ import { Loader2, ChevronDown, ExternalLink, RefreshCw } from "lucide-react";
3
3
  import { Alert, AlertDescription } from "./ui/alert";
4
4
  import { Button } from "./ui/button";
5
5
  import { Badge } from "./ui/badge";
@@ -18,7 +18,8 @@ import type {
18
18
  import { createGraphQLClient } from "../graphql/graphql-client";
19
19
  import { formatFieldValue } from "../graphql/query-builder";
20
20
  import { ColumnSelector } from "./column-selector";
21
- import { useColumnState } from "./hooks/use-column-state";
21
+ import { DataViewerProvider, useDataViewer } from "./contexts";
22
+ import { ToolbarProvider } from "./contexts";
22
23
  import {
23
24
  useSingleRecordData,
24
25
  getDisplayFields,
@@ -30,8 +31,8 @@ import { createGraphQLFetcher } from "../graphql/graphql-fetcher";
30
31
  interface SingleRecordTabContentProps {
31
32
  /** Target table metadata */
32
33
  tableMetadata: TableMetadata;
33
- /** All table metadata for relation lookup */
34
- tableMetadataMap: TableMetadataMap;
34
+ /** All table metadata (generated from TailorDB schema) */
35
+ metadata: TableMetadataMap;
35
36
  /** App URI for GraphQL requests */
36
37
  appUri: string;
37
38
  /** Record ID to fetch */
@@ -57,13 +58,61 @@ interface SingleRecordTabContentProps {
57
58
  */
58
59
  export function SingleRecordTabContent({
59
60
  tableMetadata,
60
- tableMetadataMap,
61
+ metadata,
61
62
  appUri,
62
63
  recordId,
63
64
  onOpenAsSheet,
64
65
  onOpenSingleRecordAsSheet,
65
66
  fetcher: customFetcher,
66
67
  }: SingleRecordTabContentProps) {
68
+ return (
69
+ <DataViewerProvider
70
+ appUri={appUri}
71
+ tableName={tableMetadata.name}
72
+ metadata={metadata}
73
+ >
74
+ <SingleRecordTabContentInner
75
+ tableMetadata={tableMetadata}
76
+ metadata={metadata}
77
+ appUri={appUri}
78
+ recordId={recordId}
79
+ onOpenAsSheet={onOpenAsSheet}
80
+ onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
81
+ customFetcher={customFetcher}
82
+ />
83
+ </DataViewerProvider>
84
+ );
85
+ }
86
+
87
+ interface SingleRecordTabContentInnerProps {
88
+ tableMetadata: TableMetadata;
89
+ metadata: TableMetadataMap;
90
+ appUri: string;
91
+ recordId: string;
92
+ onOpenAsSheet?: (
93
+ targetTableName: string,
94
+ filterField: string,
95
+ filterValue: string,
96
+ ) => void;
97
+ onOpenSingleRecordAsSheet?: (
98
+ targetTableName: string,
99
+ recordId: string,
100
+ ) => void;
101
+ customFetcher?: SingleRecordDataFetcher;
102
+ }
103
+
104
+ function SingleRecordTabContentInner({
105
+ tableMetadata,
106
+ metadata,
107
+ appUri,
108
+ recordId,
109
+ onOpenAsSheet,
110
+ onOpenSingleRecordAsSheet,
111
+ customFetcher,
112
+ }: SingleRecordTabContentInnerProps) {
113
+ // Get column state from context
114
+ const { selectedFields, selectedRelations } = useDataViewer();
115
+
67
116
  // Create default fetcher from GraphQL client if not provided
68
117
  const defaultFetcher = useMemo(() => {
69
118
  const client = createGraphQLClient(appUri);
@@ -72,12 +121,11 @@ export function SingleRecordTabContent({
72
121
 
73
122
  const fetcher = customFetcher ?? defaultFetcher;
74
123
 
75
- // Column state for field selection
124
+ // All display fields for this table
76
125
  const allDisplayFields = useMemo(
77
126
  () => getDisplayFields(tableMetadata),
78
127
  [tableMetadata],
79
128
  );
80
- const columnState = useColumnState(allDisplayFields, tableMetadata.relations);
81
129
 
82
130
  // Use the custom hook for data fetching
83
131
  const {
@@ -89,7 +137,7 @@ export function SingleRecordTabContent({
89
137
  refetch,
90
138
  } = useSingleRecordData({
91
139
  tableMetadata,
92
- tableMetadataMap,
140
+ tableMetadataMap: metadata,
93
141
  recordId,
94
142
  fetcher,
95
143
  });
@@ -122,10 +170,10 @@ export function SingleRecordTabContent({
122
170
  }
123
171
 
124
172
  const displayFields = allDisplayFields.filter((f) =>
125
- columnState.selectedFields.includes(f.name),
173
+ selectedFields.includes(f.name),
126
174
  );
127
175
  const relations = (tableMetadata.relations ?? []).filter((r) =>
128
- columnState.selectedRelations.includes(r.fieldName),
176
+ selectedRelations.includes(r.fieldName),
129
177
  );
130
178
 
131
179
  return (
@@ -145,16 +193,9 @@ export function SingleRecordTabContent({
145
193
  ID: {recordId.substring(0, 8)}...
146
194
  </Badge>
147
195
 
148
- <ColumnSelector
149
- fields={allDisplayFields}
150
- selectedFields={columnState.selectedFields}
151
- onToggle={columnState.toggleField}
152
- onSelectAll={columnState.selectAll}
153
- onDeselectAll={columnState.deselectAll}
154
- relations={tableMetadata.relations}
155
- selectedRelations={columnState.selectedRelations}
156
- onToggleRelation={columnState.toggleRelation}
157
- />
196
+ <ToolbarProvider>
197
+ <ColumnSelector />
198
+ </ToolbarProvider>
158
199
 
159
200
  <div className="flex-1" />
160
201
  <Button
@@ -192,7 +233,7 @@ export function SingleRecordTabContent({
192
233
  {relations.length > 0 && (
193
234
  <div className="space-y-6">
194
235
  {relations.map((relation) => {
195
- const targetTable = tableMetadataMap[relation.targetTable];
236
+ const targetTable = metadata[relation.targetTable];
196
237
  const relationData = relationDataMap[relation.fieldName];
197
238
  const displayFieldsForRelation = targetTable
198
239
  ? relation.relationType === "manyToOne"
@@ -11,29 +11,25 @@ import {
11
11
  DialogTitle,
12
12
  DialogTrigger,
13
13
  } from "./ui/dialog";
14
- import type { ExpandedRelationFields } from "../generator/metadata-generator";
15
14
  import { useSavedViews, type SaveViewInput } from "./saved-view-context";
16
- import type { SearchFilters } from "./types";
17
-
18
- interface ViewSaveProps {
19
- tableName: string;
20
- filters: SearchFilters;
21
- selectedFields: string[];
22
- selectedRelations: string[];
23
- expandedRelationFields: ExpandedRelationFields;
24
- }
15
+ import { useDataViewer } from "./contexts";
25
16
 
26
17
  /**
27
18
  * View save control
28
19
  * Allows saving views (filters + column selections)
20
+ * Must be used within DataViewer.Root context
29
21
  */
30
- export function ViewSave({
31
- tableName,
32
- filters,
33
- selectedFields,
34
- selectedRelations,
35
- expandedRelationFields,
36
- }: ViewSaveProps) {
22
+ export function ViewSave() {
23
+ const {
24
+ tableMetadata,
25
+ filters,
26
+ selectedFields,
27
+ selectedRelations,
28
+ expandedRelationFields,
29
+ } = useDataViewer();
30
+
31
+ const tableName = tableMetadata?.name ?? "";
32
+
37
33
  const [saveDialogOpen, setSaveDialogOpen] = useState(false);
38
34
  const [viewName, setViewName] = useState("");
39
35
 
@@ -17,56 +17,84 @@ export type FieldType =
17
17
  * Metadata for a single field
18
18
  */
19
19
  export interface FieldMetadata {
20
- name: string;
21
- type: FieldType;
22
- required: boolean;
23
- enumValues?: readonly string[];
24
- arrayItemType?: FieldType;
25
- description?: string;
20
+ readonly name: string;
21
+ readonly type: FieldType;
22
+ readonly required: boolean;
23
+ readonly enumValues?: readonly string[];
24
+ readonly arrayItemType?: FieldType;
25
+ readonly description?: string;
26
26
  /** manyToOne relation info (if this field is a foreign key) */
27
- relation?: {
27
+ readonly relation?: {
28
28
  /** GraphQL field name for the related object (e.g., "task") */
29
- fieldName: string;
29
+ readonly fieldName: string;
30
30
  /** Target table name in camelCase (e.g., "task") */
31
- targetTable: string;
31
+ readonly targetTable: string;
32
32
  };
33
33
  }
34
34
 
35
35
  /**
36
- * Metadata for a relation
36
+ * Base properties shared by all relation types
37
37
  */
38
- export interface RelationMetadata {
39
- /** GraphQL field name (e.g., "task" for manyToOne, "taskAssignments" for oneToMany) */
40
- fieldName: string;
38
+ interface RelationMetadataBase {
39
+ /** GraphQL field name (e.g., "task" for manyToOne/oneToOne, "taskAssignments" for oneToMany) */
40
+ readonly fieldName: string;
41
41
  /** Target table name in camelCase (e.g., "task", "taskAssignment") */
42
- targetTable: string;
43
- /** Relation type */
44
- relationType: "manyToOne" | "oneToMany";
45
- /** For manyToOne: the FK field name (e.g., "taskId"). For oneToMany: the FK field on the child table */
46
- foreignKeyField: string;
47
- /** For manyToOne: the backward field name on the target type (used to generate oneToMany relation) */
48
- backwardFieldName?: string;
49
- /** True if this is a oneToOne relation (no inverse oneToMany should be generated) */
50
- isOneToOne?: boolean;
42
+ readonly targetTable: string;
43
+ /** The FK field name (e.g., "taskId") */
44
+ readonly foreignKeyField: string;
45
+ }
46
+
47
+ /**
48
+ * ManyToOne relation: this table has a FK pointing to another table
49
+ * Generates an inverse oneToMany relation on the target table
50
+ */
51
+ export interface ManyToOneRelation extends RelationMetadataBase {
52
+ readonly relationType: "manyToOne";
53
+ /** The backward field name on the target type (used to generate oneToMany relation) */
54
+ readonly backwardFieldName?: string;
55
+ }
56
+
57
+ /**
58
+ * OneToOne relation: this table has a FK pointing to another table (1:1)
59
+ * Does NOT generate an inverse oneToMany relation
60
+ */
61
+ export interface OneToOneRelation extends RelationMetadataBase {
62
+ readonly relationType: "oneToOne";
63
+ }
64
+
65
+ /**
66
+ * OneToMany relation: another table has a FK pointing to this table
67
+ * Generated automatically from manyToOne relations
68
+ */
69
+ export interface OneToManyRelation extends RelationMetadataBase {
70
+ readonly relationType: "oneToMany";
51
71
  }
52
72
 
73
+ /**
74
+ * Metadata for a relation (discriminated union)
75
+ */
76
+ export type RelationMetadata =
77
+ | ManyToOneRelation
78
+ | OneToOneRelation
79
+ | OneToManyRelation;
80
+
53
81
  /**
54
82
  * Metadata for a single table
55
83
  */
56
84
  export interface TableMetadata {
57
- name: string;
58
- pluralForm: string;
59
- description?: string;
60
- readAllowedRoles: string[];
61
- fields: FieldMetadata[];
62
- /** Relations (manyToOne and oneToMany) */
63
- relations?: RelationMetadata[];
85
+ readonly name: string;
86
+ readonly pluralForm: string;
87
+ readonly description?: string;
88
+ readonly readAllowedRoles: readonly string[];
89
+ readonly fields: readonly FieldMetadata[];
90
+ /** Relations (manyToOne, oneToOne, and oneToMany) */
91
+ readonly relations?: readonly RelationMetadata[];
64
92
  }
65
93
 
66
94
  /**
67
95
  * Map of all tables
68
96
  */
69
- export type TableMetadataMap = Record<string, TableMetadata>;
97
+ export type TableMetadataMap = { readonly [key: string]: TableMetadata };
70
98
 
71
99
  /**
72
100
  * Expanded relation fields configuration
@@ -304,25 +332,6 @@ export function dataViewerMetadataGenerator(
304
332
  config.array,
305
333
  );
306
334
 
307
- const fieldMetadata: FieldMetadata = {
308
- name: fieldName,
309
- type: fieldType,
310
- required: config.required ?? false,
311
- description: config.description,
312
- };
313
-
314
- // Add enum values if present
315
- if (config.allowedValues && config.allowedValues.length > 0) {
316
- fieldMetadata.enumValues = config.allowedValues.map((v) =>
317
- typeof v === "string" ? v : v.value,
318
- );
319
- }
320
-
321
- // Add array item type if it's an array field
322
- if (arrayItemType) {
323
- fieldMetadata.arrayItemType = arrayItemType;
324
- }
325
-
326
335
  // Extract manyToOne relation info from rawRelation
327
336
  // Include: manyToOne, n-1, oneToOne, 1-1 (all are treated as manyToOne for data view purposes)
328
337
  const rawRelation = config.rawRelation;
@@ -338,28 +347,56 @@ export function dataViewerMetadataGenerator(
338
347
  const isOneToOne =
339
348
  rawRelation?.type === "oneToOne" || rawRelation?.type === "1-1";
340
349
 
350
+ // Build relation info for field if applicable
351
+ let fieldRelation: FieldMetadata["relation"] | undefined;
341
352
  if (isManyToOneRelation && rawRelation.toward) {
342
353
  const targetTableName = toCamelCase(rawRelation.toward.type);
343
354
  const relationFieldName =
344
355
  rawRelation.toward.as ?? toCamelCase(rawRelation.toward.type);
345
356
 
346
- // Add relation info to the field
347
- fieldMetadata.relation = {
357
+ fieldRelation = {
348
358
  fieldName: relationFieldName,
349
359
  targetTable: targetTableName,
350
360
  };
351
361
 
352
- // Add to relations array (include backward field name for oneToMany generation)
353
- relations.push({
354
- fieldName: relationFieldName,
355
- targetTable: targetTableName,
356
- relationType: "manyToOne",
357
- foreignKeyField: fieldName,
358
- backwardFieldName: rawRelation.backward,
359
- isOneToOne,
360
- });
362
+ // Add to relations array
363
+ if (isOneToOne) {
364
+ relations.push({
365
+ fieldName: relationFieldName,
366
+ targetTable: targetTableName,
367
+ relationType: "oneToOne",
368
+ foreignKeyField: fieldName,
369
+ });
370
+ } else {
371
+ relations.push({
372
+ fieldName: relationFieldName,
373
+ targetTable: targetTableName,
374
+ relationType: "manyToOne",
375
+ foreignKeyField: fieldName,
376
+ backwardFieldName: rawRelation.backward,
377
+ });
378
+ }
361
379
  }
362
380
 
381
+ // Build enum values if present
382
+ const enumValues =
383
+ config.allowedValues && config.allowedValues.length > 0
384
+ ? config.allowedValues.map((v) =>
385
+ typeof v === "string" ? v : v.value,
386
+ )
387
+ : undefined;
388
+
389
+ // Create field metadata with all properties at once
390
+ const fieldMetadata: FieldMetadata = {
391
+ name: fieldName,
392
+ type: fieldType,
393
+ required: config.required ?? false,
394
+ ...(config.description && { description: config.description }),
395
+ ...(enumValues && { enumValues }),
396
+ ...(arrayItemType && { arrayItemType }),
397
+ ...(fieldRelation && { relation: fieldRelation }),
398
+ };
399
+
363
400
  fields.push(fieldMetadata);
364
401
  }
365
402
 
@@ -406,13 +443,9 @@ export function dataViewerMetadataGenerator(
406
443
  // Second pass: Add oneToMany relations by inverting manyToOne relations
407
444
  for (const table of allTables) {
408
445
  for (const relation of table.relations) {
446
+ // Only manyToOne relations generate inverse oneToMany
447
+ // oneToOne relations don't have a oneToMany inverse
409
448
  if (relation.relationType === "manyToOne") {
410
- // Skip oneToOne relations - they don't have a oneToMany inverse
411
- // (GraphQL generates a single object field, not a connection)
412
- if (relation.isOneToOne) {
413
- continue;
414
- }
415
-
416
449
  // Find the target table and add the inverse oneToMany relation
417
450
  const targetTable = tableByOriginalName.get(
418
451
  // Convert camelCase back to PascalCase for lookup
@@ -442,7 +475,7 @@ export function dataViewerMetadataGenerator(
442
475
  }
443
476
 
444
477
  // Build the metadata map (excluding originalName and backwardFieldName from output)
445
- const metadataMap: TableMetadataMap = {};
478
+ const metadataMap: Record<string, TableMetadata> = {};
446
479
  for (const table of allTables) {
447
480
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
448
481
  const { originalName, ...tableWithoutOriginalName } = table;