@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
@@ -3,15 +3,18 @@ import { Alert, AlertDescription } from "./ui/alert";
3
3
  import type {
4
4
  TableMetadata,
5
5
  TableMetadataMap,
6
- FieldMetadata,
7
6
  } from "../generator/metadata-generator";
8
- import { formatFieldValue } from "../graphql/query-builder";
9
7
  import { TableSelector } from "./table-selector";
10
8
  import { DataTable } from "./data-table";
11
9
  import { DataTableToolbar } from "./data-table-toolbar";
12
10
  import { Pagination } from "./pagination";
13
- import { useColumnState } from "./hooks/use-column-state";
14
- import { useTableData } from "./hooks/use-table-data";
11
+ import { ColumnSelector } from "./column-selector";
12
+ import { SearchFilterForm } from "./search-filter";
13
+ import { ViewSave } from "./view-save-load";
14
+ import { CsvButton } from "./csv-button";
15
+ import { RefreshButton } from "./refresh-button";
16
+ import { DataViewerProvider } from "./contexts";
17
+ import { TableDataProvider, useTableDataContext } from "./contexts";
15
18
  import type { InitialQuery } from "./data-viewer";
16
19
  import type { SearchFilters } from "./types";
17
20
  import type { SavedView } from "./saved-view-context";
@@ -22,8 +25,8 @@ interface DataViewTabContentProps {
22
25
  initialTable?: TableMetadata;
23
26
  onTableConfirm: (table: TableMetadata) => void;
24
27
  isTableLocked: boolean;
25
- /** All table metadata for relation lookup */
26
- tableMetadataMap: TableMetadataMap;
28
+ /** All table metadata (generated from TailorDB schema) */
29
+ metadata: TableMetadataMap;
27
30
  /** Initial query condition for filtering */
28
31
  initialQuery?: InitialQuery;
29
32
  /** Initial saved view to load */
@@ -51,7 +54,7 @@ export function DataViewTabContent({
51
54
  initialTable,
52
55
  onTableConfirm,
53
56
  isTableLocked,
54
- tableMetadataMap,
57
+ metadata,
55
58
  initialQuery,
56
59
  initialView,
57
60
  onOpenAsSheet,
@@ -79,6 +82,7 @@ export function DataViewTabContent({
79
82
  {
80
83
  field: initialQuery.field,
81
84
  fieldType: field.type,
85
+ operator: "eq" as const,
82
86
  value: initialQuery.value,
83
87
  enumValues: field.enumValues,
84
88
  },
@@ -86,94 +90,11 @@ export function DataViewTabContent({
86
90
  }, [initialQuery, initialTable, initialView]);
87
91
 
88
92
  // Search filters (includes initialQuery as a regular filter that can be edited/removed)
89
- const [searchFilters, setSearchFilters] =
90
- useState<SearchFilters>(getInitialFilters);
91
-
92
- // Memoize fields to avoid creating new array reference on every render when selectedTable is null
93
- const fields = useMemo(
94
- () => selectedTable?.fields ?? [],
95
- [selectedTable?.fields],
96
- );
97
-
98
- // Column state (includes relation selection)
99
- // If initialView is provided, use its column selections as defaults
100
- const columnState = useColumnState(
101
- fields,
102
- selectedTable?.relations,
103
- initialView?.selectedFields,
104
- initialView?.selectedRelations,
105
- initialView?.expandedRelationFields,
106
- );
107
-
108
- // Table data fetching (pass selectedRelations for FK field inclusion and search filters)
109
- const tableData = useTableData(
110
- appUri,
111
- selectedTable,
112
- columnState.selectedFields,
113
- columnState.selectedRelations,
114
- searchFilters,
115
- tableMetadataMap,
116
- columnState.expandedRelationFields,
93
+ const initialFilters = useMemo(
94
+ () => getInitialFilters(),
95
+ [getInitialFilters],
117
96
  );
118
97
 
119
- // Handle filter changes - reset pagination when filters change
120
- const handleFiltersChange = useCallback(
121
- (newFilters: SearchFilters) => {
122
- setSearchFilters(newFilters);
123
- tableData.resetPagination();
124
- },
125
- [tableData],
126
- );
127
-
128
- // CSV Download handler
129
- const handleDownloadCsv = useCallback(() => {
130
- if (!selectedTable || tableData.data.length === 0) return;
131
-
132
- const selectedFieldsMetadata = selectedTable.fields.filter(
133
- (f: FieldMetadata) => columnState.selectedFields.includes(f.name),
134
- );
135
-
136
- // Create CSV header
137
- const headers = selectedFieldsMetadata.map((f: FieldMetadata) => f.name);
138
-
139
- // Create CSV rows
140
- const rows = tableData.data.map((row: Record<string, unknown>) =>
141
- selectedFieldsMetadata.map((field: FieldMetadata) => {
142
- const value = row[field.name];
143
- const formattedValue = formatFieldValue(value, field);
144
- // Escape double quotes and wrap in quotes if contains comma, newline, or quotes
145
- const stringValue = String(formattedValue ?? "");
146
- if (
147
- stringValue.includes(",") ||
148
- stringValue.includes("\n") ||
149
- stringValue.includes('"')
150
- ) {
151
- return `"${stringValue.replace(/"/g, '""')}"`;
152
- }
153
- return stringValue;
154
- }),
155
- );
156
-
157
- // Combine headers and rows
158
- const csvContent = [
159
- headers.join(","),
160
- ...rows.map((r: string[]) => r.join(",")),
161
- ].join("\n");
162
-
163
- // Create and download file
164
- const blob = new Blob(["\uFEFF" + csvContent], {
165
- type: "text/csv;charset=utf-8;",
166
- });
167
- const url = URL.createObjectURL(blob);
168
- const link = document.createElement("a");
169
- link.href = url;
170
- link.download = `${selectedTable.name}_${new Date().toISOString().slice(0, 10)}.csv`;
171
- document.body.appendChild(link);
172
- link.click();
173
- document.body.removeChild(link);
174
- URL.revokeObjectURL(url);
175
- }, [selectedTable, tableData.data, columnState.selectedFields]);
176
-
177
98
  const handleTableSelect = useCallback(
178
99
  (table: TableMetadata) => {
179
100
  setSelectedTable(table);
@@ -204,6 +125,52 @@ export function DataViewTabContent({
204
125
  );
205
126
  }
206
127
 
128
+ return (
129
+ <DataViewerProvider
130
+ appUri={appUri}
131
+ tableName={selectedTable.name}
132
+ metadata={metadata}
133
+ initialData={{
134
+ filters: initialFilters,
135
+ selectedFields: initialView?.selectedFields,
136
+ selectedRelations: initialView?.selectedRelations,
137
+ expandedRelationFields: initialView?.expandedRelationFields,
138
+ }}
139
+ >
140
+ <TableDataProvider>
141
+ <DataViewTabContentInner
142
+ selectedTable={selectedTable}
143
+ onOpenAsSheet={onOpenAsSheet}
144
+ onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
145
+ />
146
+ </TableDataProvider>
147
+ </DataViewerProvider>
148
+ );
149
+ }
150
+
151
+ interface DataViewTabContentInnerProps {
152
+ selectedTable: TableMetadata;
153
+ onOpenAsSheet?: (
154
+ targetTableName: string,
155
+ filterField: string,
156
+ filterValue: string,
157
+ ) => void;
158
+ onOpenSingleRecordAsSheet?: (
159
+ targetTableName: string,
160
+ recordId: string,
161
+ ) => void;
162
+ }
163
+
164
+ /**
165
+ * Inner component that uses context
166
+ */
167
+ function DataViewTabContentInner({
168
+ selectedTable,
169
+ onOpenAsSheet,
170
+ onOpenSingleRecordAsSheet,
171
+ }: DataViewTabContentInnerProps) {
172
+ const tableData = useTableDataContext();
173
+
207
174
  return (
208
175
  <div className="space-y-4">
209
176
  {/* Table name and description */}
@@ -217,40 +184,14 @@ export function DataViewTabContent({
217
184
  </div>
218
185
 
219
186
  {/* Operations row: column selector, search filter, etc. */}
220
- <DataTableToolbar
221
- columnSelector={{
222
- fields: selectedTable.fields,
223
- selectedFields: columnState.selectedFields,
224
- onToggle: columnState.toggleField,
225
- onSelectAll: columnState.selectAll,
226
- onDeselectAll: columnState.deselectAll,
227
- relations: selectedTable.relations,
228
- selectedRelations: columnState.selectedRelations,
229
- onToggleRelation: columnState.toggleRelation,
230
- tableMetadataMap: tableMetadataMap,
231
- expandedRelationFields: columnState.expandedRelationFields,
232
- onToggleExpandedRelationField:
233
- columnState.toggleExpandedRelationField,
234
- isExpandedRelationFieldSelected:
235
- columnState.isExpandedRelationFieldSelected,
236
- }}
237
- searchFilter={{
238
- fields: selectedTable.fields,
239
- filters: searchFilters,
240
- onFiltersChange: handleFiltersChange,
241
- }}
242
- viewSave={{
243
- tableName: selectedTable.name,
244
- filters: searchFilters,
245
- selectedFields: columnState.selectedFields,
246
- selectedRelations: columnState.selectedRelations,
247
- expandedRelationFields: columnState.expandedRelationFields,
248
- }}
249
- onDownloadCsv={handleDownloadCsv}
250
- onRefresh={() => tableData.refetch()}
251
- loading={tableData.loading}
252
- csvDisabled={tableData.data.length === 0}
253
- />
187
+ <DataTableToolbar>
188
+ <ColumnSelector />
189
+ <SearchFilterForm />
190
+ <ViewSave />
191
+ <div className="flex-1" />
192
+ <CsvButton />
193
+ <RefreshButton />
194
+ </DataTableToolbar>
254
195
 
255
196
  {/* Error Display */}
256
197
  {tableData.error && (
@@ -263,19 +204,8 @@ export function DataViewTabContent({
263
204
 
264
205
  {/* Data Table */}
265
206
  <DataTable
266
- data={tableData.data}
267
- fields={selectedTable.fields}
268
- selectedFields={columnState.selectedFields}
269
- sortState={tableData.sortState}
270
- onSort={tableData.setSort}
271
- loading={tableData.loading}
272
- tableMetadata={selectedTable}
273
- tableMetadataMap={tableMetadataMap}
274
- appUri={appUri}
275
- selectedRelations={columnState.selectedRelations}
276
207
  onOpenAsSheet={onOpenAsSheet}
277
208
  onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
278
- expandedRelationFields={columnState.expandedRelationFields}
279
209
  />
280
210
 
281
211
  {/* Pagination */}
@@ -11,7 +11,7 @@ import { SingleRecordTabContent } from "./single-record-tab-content";
11
11
  import { useSavedViews, type SavedView } from "./saved-view-context";
12
12
 
13
13
  interface DataViewerProps {
14
- tableMetadata: TableMetadataMap;
14
+ metadata: TableMetadataMap;
15
15
  appUri: string;
16
16
  /** Initial view ID to load on mount */
17
17
  initialViewId?: string;
@@ -52,11 +52,11 @@ function generateTabId(): string {
52
52
  * Similar to Excel sheets - each tab has its own table view
53
53
  */
54
54
  export function DataViewer({
55
- tableMetadata,
55
+ metadata,
56
56
  appUri,
57
57
  initialViewId,
58
58
  }: DataViewerProps) {
59
- const allTables = Object.values(tableMetadata);
59
+ const allTables = Object.values(metadata);
60
60
  const { getViewById, isLoading } = useSavedViews();
61
61
 
62
62
  // Track if we've already initialized from the saved view
@@ -84,7 +84,7 @@ export function DataViewer({
84
84
 
85
85
  const view = getViewById(initialViewId);
86
86
  if (view) {
87
- const table = tableMetadata[view.tableName];
87
+ const table = metadata[view.tableName];
88
88
  if (table) {
89
89
  initializedFromViewRef.current = true;
90
90
  const newTab: Tab = {
@@ -98,7 +98,7 @@ export function DataViewer({
98
98
  setActiveTabId(newTab.id);
99
99
  }
100
100
  }
101
- }, [initialViewId, isLoading, getViewById, tableMetadata]);
101
+ }, [initialViewId, isLoading, getViewById, metadata]);
102
102
 
103
103
  const handleAddTab = useCallback(() => {
104
104
  const newTab: Tab = {
@@ -154,7 +154,7 @@ export function DataViewer({
154
154
  */
155
155
  const handleOpenAsSheet = useCallback(
156
156
  (targetTableName: string, filterField: string, filterValue: string) => {
157
- const targetTable = tableMetadata[targetTableName];
157
+ const targetTable = metadata[targetTableName];
158
158
  if (!targetTable) return;
159
159
 
160
160
  const newTab: Tab = {
@@ -170,7 +170,7 @@ export function DataViewer({
170
170
  setTabs((prev) => [...prev, newTab]);
171
171
  setActiveTabId(newTab.id);
172
172
  },
173
- [tableMetadata],
173
+ [metadata],
174
174
  );
175
175
 
176
176
  /**
@@ -178,7 +178,7 @@ export function DataViewer({
178
178
  */
179
179
  const handleOpenSingleRecordAsSheet = useCallback(
180
180
  (targetTableName: string, recordId: string) => {
181
- const targetTable = tableMetadata[targetTableName];
181
+ const targetTable = metadata[targetTableName];
182
182
  if (!targetTable) return;
183
183
 
184
184
  const newTab: Tab = {
@@ -193,7 +193,7 @@ export function DataViewer({
193
193
  setTabs((prev) => [...prev, newTab]);
194
194
  setActiveTabId(newTab.id);
195
195
  },
196
- [tableMetadata],
196
+ [metadata],
197
197
  );
198
198
 
199
199
  return (
@@ -250,7 +250,7 @@ export function DataViewer({
250
250
  {tab.singleRecordMode && tab.table ? (
251
251
  <SingleRecordTabContent
252
252
  tableMetadata={tab.table}
253
- tableMetadataMap={tableMetadata}
253
+ metadata={metadata}
254
254
  appUri={appUri}
255
255
  recordId={tab.singleRecordMode.recordId}
256
256
  onOpenAsSheet={handleOpenAsSheet}
@@ -263,7 +263,7 @@ export function DataViewer({
263
263
  initialTable={tab.table ?? undefined}
264
264
  onTableConfirm={(table) => handleTableConfirm(tab.id, table)}
265
265
  isTableLocked={tab.isLocked}
266
- tableMetadataMap={tableMetadata}
266
+ metadata={metadata}
267
267
  initialQuery={tab.initialQuery}
268
268
  initialView={tab.initialView}
269
269
  onOpenAsSheet={handleOpenAsSheet}
@@ -51,8 +51,8 @@ export interface ColumnState {
51
51
  * Manage column visibility state (fields and relations)
52
52
  */
53
53
  export function useColumnState(
54
- fields: FieldMetadata[],
55
- relations?: RelationMetadata[],
54
+ fields: readonly FieldMetadata[],
55
+ relations?: readonly RelationMetadata[],
56
56
  defaultFields?: string[],
57
57
  defaultRelations?: string[],
58
58
  defaultExpandedRelationFields?: ExpandedRelationFields,