@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
173
|
+
selectedFields.includes(f.name),
|
|
126
174
|
);
|
|
127
175
|
const relations = (tableMetadata.relations ?? []).filter((r) =>
|
|
128
|
-
|
|
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
|
-
<
|
|
149
|
-
|
|
150
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
36
|
+
* Base properties shared by all relation types
|
|
37
37
|
*/
|
|
38
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 =
|
|
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
|
-
|
|
347
|
-
fieldMetadata.relation = {
|
|
357
|
+
fieldRelation = {
|
|
348
358
|
fieldName: relationFieldName,
|
|
349
359
|
targetTable: targetTableName,
|
|
350
360
|
};
|
|
351
361
|
|
|
352
|
-
// Add to relations array
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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:
|
|
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;
|