@izumisy-tailor/tailor-data-viewer 0.1.21 → 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 CHANGED
@@ -88,7 +88,7 @@ function App() {
88
88
  return (
89
89
  <SavedViewProvider store={store}>
90
90
  <DataViewer
91
- tableMetadata={tableMetadata}
91
+ metadata={tableMetadata}
92
92
  appUri="https://your-app.tailor.tech/graphql"
93
93
  />
94
94
  </SavedViewProvider>
@@ -98,6 +98,50 @@ function App() {
98
98
 
99
99
  For store options, see [Saved View Store](./docs/saved-view-store.md).
100
100
 
101
+ ### Compositional API
102
+
103
+ For more control over the UI layout and behavior, use the compositional (compound component) API:
104
+
105
+ ```tsx
106
+ import {
107
+ DataViewerProvider,
108
+ TableDataProvider,
109
+ ToolbarProvider,
110
+ DataTable,
111
+ ColumnSelector,
112
+ SearchFilterForm,
113
+ CsvButton,
114
+ RefreshButton,
115
+ } from "@izumisy-tailor/tailor-data-viewer/component";
116
+
117
+ function CustomDataViewer() {
118
+ return (
119
+ <DataViewerProvider
120
+ tableName="User"
121
+ metadata={tableMetadata}
122
+ appUri="https://your-app.tailor.tech"
123
+ >
124
+ <TableDataProvider>
125
+ <ToolbarProvider>
126
+ <ColumnSelector />
127
+ <SearchFilterForm />
128
+ <CsvButton />
129
+ <RefreshButton />
130
+ </ToolbarProvider>
131
+ <DataTable onRecordClick={(id) => console.log(id)} />
132
+ </TableDataProvider>
133
+ </DataViewerProvider>
134
+ );
135
+ }
136
+ ```
137
+
138
+ This approach allows you to:
139
+ - Customize the layout and styling
140
+ - Add your own components alongside Data Viewer components
141
+ - Access internal state via hooks (`useDataViewer`, `useTableDataContext`)
142
+
143
+ For detailed documentation, see [Compositional API](./docs/compositional-api.md).
144
+
101
145
  ### About savedViewStore
102
146
 
103
147
  `savedViewStore` is a storage implementation for persisting views created in Data Viewer (such as filter settings and column visibility settings). Two built-in stores are provided:
@@ -7,51 +7,74 @@ type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date"
7
7
  * Metadata for a single field
8
8
  */
9
9
  interface FieldMetadata {
10
- name: string;
11
- type: FieldType;
12
- required: boolean;
13
- enumValues?: readonly string[];
14
- arrayItemType?: FieldType;
15
- description?: string;
10
+ readonly name: string;
11
+ readonly type: FieldType;
12
+ readonly required: boolean;
13
+ readonly enumValues?: readonly string[];
14
+ readonly arrayItemType?: FieldType;
15
+ readonly description?: string;
16
16
  /** manyToOne relation info (if this field is a foreign key) */
17
- relation?: {
18
- /** GraphQL field name for the related object (e.g., "task") */fieldName: string; /** Target table name in camelCase (e.g., "task") */
19
- targetTable: string;
17
+ readonly relation?: {
18
+ /** GraphQL field name for the related object (e.g., "task") */readonly fieldName: string; /** Target table name in camelCase (e.g., "task") */
19
+ readonly targetTable: string;
20
20
  };
21
21
  }
22
22
  /**
23
- * Metadata for a relation
23
+ * Base properties shared by all relation types
24
24
  */
25
- interface RelationMetadata {
26
- /** GraphQL field name (e.g., "task" for manyToOne, "taskAssignments" for oneToMany) */
27
- fieldName: string;
25
+ interface RelationMetadataBase {
26
+ /** GraphQL field name (e.g., "task" for manyToOne/oneToOne, "taskAssignments" for oneToMany) */
27
+ readonly fieldName: string;
28
28
  /** Target table name in camelCase (e.g., "task", "taskAssignment") */
29
- targetTable: string;
30
- /** Relation type */
31
- relationType: "manyToOne" | "oneToMany";
32
- /** For manyToOne: the FK field name (e.g., "taskId"). For oneToMany: the FK field on the child table */
33
- foreignKeyField: string;
34
- /** For manyToOne: the backward field name on the target type (used to generate oneToMany relation) */
35
- backwardFieldName?: string;
36
- /** True if this is a oneToOne relation (no inverse oneToMany should be generated) */
37
- isOneToOne?: boolean;
29
+ readonly targetTable: string;
30
+ /** The FK field name (e.g., "taskId") */
31
+ readonly foreignKeyField: string;
32
+ }
33
+ /**
34
+ * ManyToOne relation: this table has a FK pointing to another table
35
+ * Generates an inverse oneToMany relation on the target table
36
+ */
37
+ interface ManyToOneRelation extends RelationMetadataBase {
38
+ readonly relationType: "manyToOne";
39
+ /** The backward field name on the target type (used to generate oneToMany relation) */
40
+ readonly backwardFieldName?: string;
38
41
  }
42
+ /**
43
+ * OneToOne relation: this table has a FK pointing to another table (1:1)
44
+ * Does NOT generate an inverse oneToMany relation
45
+ */
46
+ interface OneToOneRelation extends RelationMetadataBase {
47
+ readonly relationType: "oneToOne";
48
+ }
49
+ /**
50
+ * OneToMany relation: another table has a FK pointing to this table
51
+ * Generated automatically from manyToOne relations
52
+ */
53
+ interface OneToManyRelation extends RelationMetadataBase {
54
+ readonly relationType: "oneToMany";
55
+ }
56
+ /**
57
+ * Metadata for a relation (discriminated union)
58
+ */
59
+ type RelationMetadata = ManyToOneRelation | OneToOneRelation | OneToManyRelation;
39
60
  /**
40
61
  * Metadata for a single table
41
62
  */
42
63
  interface TableMetadata {
43
- name: string;
44
- pluralForm: string;
45
- description?: string;
46
- readAllowedRoles: string[];
47
- fields: FieldMetadata[];
48
- /** Relations (manyToOne and oneToMany) */
49
- relations?: RelationMetadata[];
64
+ readonly name: string;
65
+ readonly pluralForm: string;
66
+ readonly description?: string;
67
+ readonly readAllowedRoles: readonly string[];
68
+ readonly fields: readonly FieldMetadata[];
69
+ /** Relations (manyToOne, oneToOne, and oneToMany) */
70
+ readonly relations?: readonly RelationMetadata[];
50
71
  }
51
72
  /**
52
73
  * Map of all tables
53
74
  */
54
- type TableMetadataMap = Record<string, TableMetadata>;
75
+ type TableMetadataMap = {
76
+ readonly [key: string]: TableMetadata;
77
+ };
55
78
  /**
56
79
  * Expanded relation fields configuration
57
80
  * Key: relation field name (e.g., "task")
@@ -209,4 +232,4 @@ declare const tableMetadataGenerator: {
209
232
  }): GeneratorResult;
210
233
  };
211
234
  //#endregion
212
- export { DataViewerMetadataGeneratorOptions, ExpandedRelationFields, FieldMetadata, FieldType, RelationMetadata, TableMetadata, TableMetadataMap, dataViewerMetadataGenerator, tableMetadataGenerator as default, tableMetadataGenerator };
235
+ export { DataViewerMetadataGeneratorOptions, ExpandedRelationFields, FieldMetadata, FieldType, ManyToOneRelation, OneToManyRelation, OneToOneRelation, RelationMetadata, TableMetadata, TableMetadataMap, dataViewerMetadataGenerator, tableMetadataGenerator as default, tableMetadataGenerator };
@@ -62,33 +62,41 @@ function dataViewerMetadataGenerator(options = {}) {
62
62
  for (const [fieldName, field] of Object.entries(type.fields)) {
63
63
  const config = field.config;
64
64
  const { type: fieldType, arrayItemType } = mapFieldType(config.type, config.array);
65
- const fieldMetadata = {
66
- name: fieldName,
67
- type: fieldType,
68
- required: config.required ?? false,
69
- description: config.description
70
- };
71
- if (config.allowedValues && config.allowedValues.length > 0) fieldMetadata.enumValues = config.allowedValues.map((v) => typeof v === "string" ? v : v.value);
72
- if (arrayItemType) fieldMetadata.arrayItemType = arrayItemType;
73
65
  const rawRelation = config.rawRelation;
74
66
  const isManyToOneRelation = rawRelation && (rawRelation.type === "manyToOne" || rawRelation.type === "n-1" || rawRelation.type === "oneToOne" || rawRelation.type === "1-1") && rawRelation.toward;
75
67
  const isOneToOne = rawRelation?.type === "oneToOne" || rawRelation?.type === "1-1";
68
+ let fieldRelation;
76
69
  if (isManyToOneRelation && rawRelation.toward) {
77
70
  const targetTableName = toCamelCase(rawRelation.toward.type);
78
71
  const relationFieldName = rawRelation.toward.as ?? toCamelCase(rawRelation.toward.type);
79
- fieldMetadata.relation = {
72
+ fieldRelation = {
80
73
  fieldName: relationFieldName,
81
74
  targetTable: targetTableName
82
75
  };
83
- relations.push({
76
+ if (isOneToOne) relations.push({
77
+ fieldName: relationFieldName,
78
+ targetTable: targetTableName,
79
+ relationType: "oneToOne",
80
+ foreignKeyField: fieldName
81
+ });
82
+ else relations.push({
84
83
  fieldName: relationFieldName,
85
84
  targetTable: targetTableName,
86
85
  relationType: "manyToOne",
87
86
  foreignKeyField: fieldName,
88
- backwardFieldName: rawRelation.backward,
89
- isOneToOne
87
+ backwardFieldName: rawRelation.backward
90
88
  });
91
89
  }
90
+ const enumValues = config.allowedValues && config.allowedValues.length > 0 ? config.allowedValues.map((v) => typeof v === "string" ? v : v.value) : void 0;
91
+ const fieldMetadata = {
92
+ name: fieldName,
93
+ type: fieldType,
94
+ required: config.required ?? false,
95
+ ...config.description && { description: config.description },
96
+ ...enumValues && { enumValues },
97
+ ...arrayItemType && { arrayItemType },
98
+ ...fieldRelation && { relation: fieldRelation }
99
+ };
92
100
  fields.push(fieldMetadata);
93
101
  }
94
102
  const readAllowedRoles = extractReadAllowedRoles(type.permissions.gql);
@@ -116,7 +124,6 @@ function dataViewerMetadataGenerator(options = {}) {
116
124
  const tableByOriginalName = /* @__PURE__ */ new Map();
117
125
  for (const table of allTables) tableByOriginalName.set(table.originalName, table);
118
126
  for (const table of allTables) for (const relation of table.relations) if (relation.relationType === "manyToOne") {
119
- if (relation.isOneToOne) continue;
120
127
  const targetTable = tableByOriginalName.get(relation.targetTable.charAt(0).toUpperCase() + relation.targetTable.slice(1));
121
128
  if (targetTable) {
122
129
  const oneToManyFieldName = relation.backwardFieldName ?? table.pluralForm;
@@ -0,0 +1,366 @@
1
+ # Compositional Component API
2
+
3
+ Data Viewer provides a compositional (compound component) API that allows you to build custom data viewing interfaces by composing individual components. This approach gives you full control over the layout and behavior of your data viewer.
4
+
5
+ ## Overview
6
+
7
+ The compositional API is built around React Context providers that share state between components:
8
+
9
+ ```
10
+ DataViewerProvider ← Metadata, column state, filters
11
+ └── TableDataProvider ← Data fetching, pagination, sorting
12
+ └── ToolbarProvider ← Toolbar panel state (exclusive open/close)
13
+ └── Components (ColumnSelector, SearchFilterForm, etc.)
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```tsx
19
+ import {
20
+ DataViewerProvider,
21
+ TableDataProvider,
22
+ ToolbarProvider,
23
+ DataTable,
24
+ ColumnSelector,
25
+ SearchFilterForm,
26
+ CsvButton,
27
+ RefreshButton,
28
+ } from "@izumisy-tailor/tailor-data-viewer/component";
29
+ import { tableMetadata } from "./generated/table-metadata";
30
+
31
+ function MyDataViewer() {
32
+ return (
33
+ <DataViewerProvider
34
+ tableName="User"
35
+ metadata={tableMetadata}
36
+ appUri="https://your-app.tailor.tech"
37
+ >
38
+ <TableDataProvider>
39
+ {/* Toolbar */}
40
+ <ToolbarProvider>
41
+ <div className="flex gap-2 mb-4">
42
+ <ColumnSelector />
43
+ <SearchFilterForm />
44
+ <CsvButton />
45
+ <RefreshButton />
46
+ </div>
47
+ </ToolbarProvider>
48
+
49
+ {/* Data Table */}
50
+ <DataTable onRecordClick={(id) => console.log("Clicked:", id)} />
51
+ </TableDataProvider>
52
+ </DataViewerProvider>
53
+ );
54
+ }
55
+ ```
56
+
57
+ ## Providers
58
+
59
+ ### DataViewerProvider
60
+
61
+ The root provider that manages metadata, column state, and filters.
62
+
63
+ ```tsx
64
+ interface DataViewerProviderProps {
65
+ children: ReactNode;
66
+ /** Table name to display */
67
+ tableName: string;
68
+ /** All table metadata (generated from TailorDB schema) */
69
+ metadata: TableMetadataMap;
70
+ /** App URI for GraphQL endpoint */
71
+ appUri: string;
72
+ /** Initial data for filters, selected fields, and relations */
73
+ initialData?: DataViewerInitialData;
74
+ }
75
+
76
+ interface DataViewerInitialData {
77
+ filters?: SearchFilters;
78
+ selectedFields?: string[];
79
+ selectedRelations?: string[];
80
+ expandedRelationFields?: ExpandedRelationFields;
81
+ }
82
+ ```
83
+
84
+ **Example:**
85
+
86
+ ```tsx
87
+ <DataViewerProvider
88
+ tableName="Task"
89
+ metadata={tableMetadata}
90
+ appUri="https://your-app.tailor.tech"
91
+ initialData={{
92
+ filters: [{ field: "status", fieldType: "enum", value: "active" }],
93
+ selectedFields: ["id", "title", "status", "createdAt"],
94
+ selectedRelations: ["assignee"],
95
+ expandedRelationFields: { assignee: ["name", "email"] },
96
+ }}
97
+ >
98
+ {children}
99
+ </DataViewerProvider>
100
+ ```
101
+
102
+ ### TableDataProvider
103
+
104
+ Handles data fetching, pagination, and sorting. Must be a child of `DataViewerProvider`.
105
+
106
+ ```tsx
107
+ <DataViewerProvider {...props}>
108
+ <TableDataProvider>
109
+ {/* Components that need data access */}
110
+ </TableDataProvider>
111
+ </DataViewerProvider>
112
+ ```
113
+
114
+ ### ToolbarProvider
115
+
116
+ Manages exclusive panel state for toolbar components (only one panel can be open at a time).
117
+
118
+ ```tsx
119
+ <ToolbarProvider>
120
+ <ColumnSelector /> {/* Opens column selection panel */}
121
+ <SearchFilterForm /> {/* Opens search filter panel */}
122
+ <CsvButton /> {/* No panel, just action */}
123
+ <RefreshButton /> {/* No panel, just action */}
124
+ </ToolbarProvider>
125
+ ```
126
+
127
+ ## Components
128
+
129
+ ### DataTable
130
+
131
+ Displays data in a sortable table with expandable relation cells.
132
+
133
+ ```tsx
134
+ interface DataTableProps {
135
+ /** Callback when a record row is clicked */
136
+ onRecordClick?: (recordId: string) => void;
137
+ /** Callback to open a relation as a new sheet */
138
+ onOpenAsSheet?: (tableName: string, filterField: string, filterValue: string) => void;
139
+ /** Callback to open a single record as a new sheet */
140
+ onOpenSingleRecordAsSheet?: (tableName: string, recordId: string) => void;
141
+ }
142
+ ```
143
+
144
+ **Example:**
145
+
146
+ ```tsx
147
+ <DataTable
148
+ onRecordClick={(id) => navigate(`/records/${id}`)}
149
+ onOpenAsSheet={(table, field, value) => openNewTab(table, field, value)}
150
+ />
151
+ ```
152
+
153
+ ### ColumnSelector
154
+
155
+ Dropdown for selecting visible columns and relation fields.
156
+
157
+ ```tsx
158
+ <ToolbarProvider>
159
+ <ColumnSelector />
160
+ </ToolbarProvider>
161
+ ```
162
+
163
+ Features:
164
+ - Select/deselect individual fields
165
+ - Select all / Deselect all
166
+ - ManyToOne relation field expansion (inline columns)
167
+ - OneToMany relation selection (expandable rows)
168
+
169
+ ### SearchFilterForm
170
+
171
+ Form for adding search filters with AND logic.
172
+
173
+ ```tsx
174
+ <ToolbarProvider>
175
+ <SearchFilterForm />
176
+ </ToolbarProvider>
177
+ ```
178
+
179
+ Features:
180
+ - Supports string, number, boolean, enum, and UUID fields
181
+ - Multiple filters with AND logic
182
+ - Remove individual filters
183
+
184
+ ### CsvButton
185
+
186
+ Downloads current view data as CSV.
187
+
188
+ ```tsx
189
+ <TableDataProvider>
190
+ <CsvButton />
191
+ </TableDataProvider>
192
+ ```
193
+
194
+ ### RefreshButton
195
+
196
+ Refetches current data.
197
+
198
+ ```tsx
199
+ <TableDataProvider>
200
+ <RefreshButton />
201
+ </TableDataProvider>
202
+ ```
203
+
204
+ Can also be used outside TableDataProvider with explicit props:
205
+
206
+ ```tsx
207
+ <RefreshButton loading={isLoading} refetch={handleRefetch} />
208
+ ```
209
+
210
+ ## Hooks
211
+
212
+ ### useDataViewer
213
+
214
+ Access the DataViewer context values.
215
+
216
+ ```tsx
217
+ const {
218
+ tableMetadata,
219
+ metadata,
220
+ appUri,
221
+ selectedFields,
222
+ toggleField,
223
+ selectAllFields,
224
+ deselectAllFields,
225
+ selectedRelations,
226
+ toggleRelation,
227
+ expandedRelationFields,
228
+ filters,
229
+ setFilters,
230
+ refetch,
231
+ } = useDataViewer();
232
+ ```
233
+
234
+ ### useTableDataContext
235
+
236
+ Access data fetching state and pagination controls.
237
+
238
+ ```tsx
239
+ const {
240
+ data,
241
+ loading,
242
+ error,
243
+ sortState,
244
+ setSort,
245
+ pagination,
246
+ hasPreviousPage,
247
+ nextPage,
248
+ previousPage,
249
+ } = useTableDataContext();
250
+ ```
251
+
252
+ ### useToolbar
253
+
254
+ Access toolbar panel state.
255
+
256
+ ```tsx
257
+ const {
258
+ activePanel, // "column" | "search" | null
259
+ setActivePanel,
260
+ } = useToolbar();
261
+ ```
262
+
263
+ ## Advanced Examples
264
+
265
+ ### Custom Toolbar Layout
266
+
267
+ ```tsx
268
+ function CustomToolbar() {
269
+ return (
270
+ <ToolbarProvider>
271
+ <div className="flex justify-between items-center p-4 border-b">
272
+ <div className="flex gap-2">
273
+ <ColumnSelector />
274
+ <SearchFilterForm />
275
+ </div>
276
+ <div className="flex gap-2">
277
+ <CsvButton />
278
+ <RefreshButton />
279
+ </div>
280
+ </div>
281
+ </ToolbarProvider>
282
+ );
283
+ }
284
+ ```
285
+
286
+ ### Building a Custom Filter Display
287
+
288
+ ```tsx
289
+ function ActiveFilters() {
290
+ const { filters, setFilters } = useDataViewer();
291
+
292
+ const removeFilter = (fieldName: string) => {
293
+ setFilters(filters.filter(f => f.field !== fieldName));
294
+ };
295
+
296
+ return (
297
+ <div className="flex gap-2">
298
+ {filters.map(filter => (
299
+ <Badge key={filter.field}>
300
+ {filter.field}: {filter.value}
301
+ <button onClick={() => removeFilter(filter.field)}>×</button>
302
+ </Badge>
303
+ ))}
304
+ </div>
305
+ );
306
+ }
307
+ ```
308
+
309
+ ### Custom Data Display
310
+
311
+ ```tsx
312
+ function CardView() {
313
+ const { tableMetadata, selectedFields } = useDataViewer();
314
+ const { data, loading } = useTableDataContext();
315
+
316
+ if (loading) return <Spinner />;
317
+
318
+ return (
319
+ <div className="grid grid-cols-3 gap-4">
320
+ {data.map(record => (
321
+ <Card key={record.id as string}>
322
+ {selectedFields.map(field => (
323
+ <div key={field}>
324
+ <strong>{field}:</strong> {String(record[field])}
325
+ </div>
326
+ ))}
327
+ </Card>
328
+ ))}
329
+ </div>
330
+ );
331
+ }
332
+ ```
333
+
334
+ ### Combining with External State
335
+
336
+ ```tsx
337
+ function DataViewerWithExternalState() {
338
+ const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
339
+
340
+ return (
341
+ <div className="flex">
342
+ <div className="w-2/3">
343
+ <DataViewerProvider
344
+ tableName="Task"
345
+ metadata={tableMetadata}
346
+ appUri={appUri}
347
+ >
348
+ <TableDataProvider>
349
+ <ToolbarProvider>
350
+ <ColumnSelector />
351
+ <SearchFilterForm />
352
+ </ToolbarProvider>
353
+ <DataTable onRecordClick={setSelectedRecordId} />
354
+ </TableDataProvider>
355
+ </DataViewerProvider>
356
+ </div>
357
+
358
+ <div className="w-1/3">
359
+ {selectedRecordId && (
360
+ <RecordDetailPanel recordId={selectedRecordId} />
361
+ )}
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+ ```
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.21",
4
+ "version": "0.1.22",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -67,7 +67,7 @@ export function createDataViewModule(config: DataViewModuleConfig) {
67
67
  return (
68
68
  <SavedViewProvider store={store}>
69
69
  <DataViewer
70
- tableMetadata={tableMetadata}
70
+ metadata={tableMetadata}
71
71
  appUri={appUri}
72
72
  initialViewId={viewId}
73
73
  />