@izumisy-tailor/tailor-data-viewer 0.1.0

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 (37) hide show
  1. package/README.md +255 -0
  2. package/package.json +47 -0
  3. package/src/component/column-selector.tsx +264 -0
  4. package/src/component/data-table.tsx +428 -0
  5. package/src/component/data-view-tab-content.tsx +324 -0
  6. package/src/component/data-viewer.tsx +280 -0
  7. package/src/component/hooks/use-accessible-tables.ts +22 -0
  8. package/src/component/hooks/use-column-state.ts +281 -0
  9. package/src/component/hooks/use-relation-data.ts +387 -0
  10. package/src/component/hooks/use-table-data.ts +317 -0
  11. package/src/component/index.ts +15 -0
  12. package/src/component/pagination.tsx +56 -0
  13. package/src/component/relation-content.tsx +250 -0
  14. package/src/component/saved-view-context.tsx +145 -0
  15. package/src/component/search-filter.tsx +319 -0
  16. package/src/component/single-record-tab-content.tsx +676 -0
  17. package/src/component/table-selector.tsx +102 -0
  18. package/src/component/types.ts +20 -0
  19. package/src/component/view-save-load.tsx +112 -0
  20. package/src/generator/metadata-generator.ts +461 -0
  21. package/src/lib/utils.ts +6 -0
  22. package/src/providers/graphql-client.ts +31 -0
  23. package/src/styles/theme.css +105 -0
  24. package/src/types/table-metadata.ts +73 -0
  25. package/src/ui/alert.tsx +66 -0
  26. package/src/ui/badge.tsx +46 -0
  27. package/src/ui/button.tsx +62 -0
  28. package/src/ui/card.tsx +92 -0
  29. package/src/ui/checkbox.tsx +30 -0
  30. package/src/ui/collapsible.tsx +31 -0
  31. package/src/ui/dialog.tsx +143 -0
  32. package/src/ui/dropdown-menu.tsx +255 -0
  33. package/src/ui/input.tsx +21 -0
  34. package/src/ui/label.tsx +24 -0
  35. package/src/ui/select.tsx +188 -0
  36. package/src/ui/table.tsx +116 -0
  37. package/src/utils/query-builder.ts +190 -0
@@ -0,0 +1,324 @@
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { RefreshCw, Download } from "lucide-react";
3
+ import { Alert, AlertDescription } from "../ui/alert";
4
+ import { Button } from "../ui/button";
5
+ import type {
6
+ TableMetadata,
7
+ TableMetadataMap,
8
+ FieldMetadata,
9
+ } from "../types/table-metadata";
10
+ import { formatFieldValue } from "../utils/query-builder";
11
+ import { TableSelector } from "./table-selector";
12
+ import { ColumnSelector } from "./column-selector";
13
+ import { DataTable } from "./data-table";
14
+ import { Pagination } from "./pagination";
15
+ import { SearchFilterForm } from "./search-filter";
16
+ import { ViewSave } from "./view-save-load";
17
+ import { useColumnState } from "./hooks/use-column-state";
18
+ import { useTableData } from "./hooks/use-table-data";
19
+ import type { InitialQuery } from "./data-viewer";
20
+ import type { SearchFilters } from "./types";
21
+ import type { SavedView } from "./saved-view-context";
22
+
23
+ interface DataViewTabContentProps {
24
+ tables: TableMetadata[];
25
+ appUri: string;
26
+ initialTable?: TableMetadata;
27
+ onTableConfirm: (table: TableMetadata) => void;
28
+ isTableLocked: boolean;
29
+ /** All table metadata for relation lookup */
30
+ tableMetadataMap: TableMetadataMap;
31
+ /** Initial query condition for filtering */
32
+ initialQuery?: InitialQuery;
33
+ /** Initial saved view to load */
34
+ initialView?: SavedView;
35
+ /** Callback to open a relation as a new sheet */
36
+ onOpenAsSheet?: (
37
+ targetTableName: string,
38
+ filterField: string,
39
+ filterValue: string,
40
+ ) => void;
41
+ /** Callback to open a single record as a new sheet */
42
+ onOpenSingleRecordAsSheet?: (
43
+ targetTableName: string,
44
+ recordId: string,
45
+ ) => void;
46
+ }
47
+
48
+ /**
49
+ * Content for a single data view tab
50
+ * Handles table selection, column selection, and data display
51
+ */
52
+ export function DataViewTabContent({
53
+ tables,
54
+ appUri,
55
+ initialTable,
56
+ onTableConfirm,
57
+ isTableLocked,
58
+ tableMetadataMap,
59
+ initialQuery,
60
+ initialView,
61
+ onOpenAsSheet,
62
+ onOpenSingleRecordAsSheet,
63
+ }: DataViewTabContentProps) {
64
+ const [selectedTable, setSelectedTable] = useState<TableMetadata | null>(
65
+ initialTable ?? null,
66
+ );
67
+
68
+ // Convert initialQuery to SearchFilter format for initial state
69
+ const getInitialFilters = useCallback((): SearchFilters => {
70
+ // If initialView is provided, use its filters
71
+ if (initialView) {
72
+ return initialView.filters;
73
+ }
74
+
75
+ if (!initialQuery || !initialTable) return [];
76
+
77
+ const field = initialTable.fields.find(
78
+ (f) => f.name === initialQuery.field,
79
+ );
80
+ if (!field) return [];
81
+
82
+ return [
83
+ {
84
+ field: initialQuery.field,
85
+ fieldType: field.type,
86
+ value: initialQuery.value,
87
+ enumValues: field.enumValues,
88
+ },
89
+ ];
90
+ }, [initialQuery, initialTable, initialView]);
91
+
92
+ // Search filters (includes initialQuery as a regular filter that can be edited/removed)
93
+ const [searchFilters, setSearchFilters] =
94
+ useState<SearchFilters>(getInitialFilters);
95
+
96
+ // Memoize fields to avoid creating new array reference on every render when selectedTable is null
97
+ const fields = useMemo(
98
+ () => selectedTable?.fields ?? [],
99
+ [selectedTable?.fields],
100
+ );
101
+
102
+ // Column state (includes relation selection)
103
+ // If initialView is provided, use its column selections as defaults
104
+ const columnState = useColumnState(
105
+ fields,
106
+ selectedTable?.relations,
107
+ initialView?.selectedFields,
108
+ initialView?.selectedRelations,
109
+ initialView?.expandedRelationFields,
110
+ );
111
+
112
+ // Table data fetching (pass selectedRelations for FK field inclusion and search filters)
113
+ const tableData = useTableData(
114
+ appUri,
115
+ selectedTable,
116
+ columnState.selectedFields,
117
+ columnState.selectedRelations,
118
+ searchFilters,
119
+ tableMetadataMap,
120
+ columnState.expandedRelationFields,
121
+ );
122
+
123
+ // Handle filter changes - reset pagination when filters change
124
+ const handleFiltersChange = useCallback(
125
+ (newFilters: SearchFilters) => {
126
+ setSearchFilters(newFilters);
127
+ tableData.resetPagination();
128
+ },
129
+ [tableData],
130
+ );
131
+
132
+ // Track cursor history for previous page navigation
133
+ const [cursorHistory] = useState<string[]>([]);
134
+
135
+ // CSV Download handler
136
+ const handleDownloadCsv = useCallback(() => {
137
+ if (!selectedTable || tableData.data.length === 0) return;
138
+
139
+ const selectedFieldsMetadata = selectedTable.fields.filter(
140
+ (f: FieldMetadata) => columnState.selectedFields.includes(f.name),
141
+ );
142
+
143
+ // Create CSV header
144
+ const headers = selectedFieldsMetadata.map((f: FieldMetadata) => f.name);
145
+
146
+ // Create CSV rows
147
+ const rows = tableData.data.map((row: Record<string, unknown>) =>
148
+ selectedFieldsMetadata.map((field: FieldMetadata) => {
149
+ const value = row[field.name];
150
+ const formattedValue = formatFieldValue(value, field);
151
+ // Escape double quotes and wrap in quotes if contains comma, newline, or quotes
152
+ const stringValue = String(formattedValue ?? "");
153
+ if (
154
+ stringValue.includes(",") ||
155
+ stringValue.includes("\n") ||
156
+ stringValue.includes('"')
157
+ ) {
158
+ return `"${stringValue.replace(/"/g, '""')}"`;
159
+ }
160
+ return stringValue;
161
+ }),
162
+ );
163
+
164
+ // Combine headers and rows
165
+ const csvContent = [
166
+ headers.join(","),
167
+ ...rows.map((r: string[]) => r.join(",")),
168
+ ].join("\n");
169
+
170
+ // Create and download file
171
+ const blob = new Blob(["\uFEFF" + csvContent], {
172
+ type: "text/csv;charset=utf-8;",
173
+ });
174
+ const url = URL.createObjectURL(blob);
175
+ const link = document.createElement("a");
176
+ link.href = url;
177
+ link.download = `${selectedTable.name}_${new Date().toISOString().slice(0, 10)}.csv`;
178
+ document.body.appendChild(link);
179
+ link.click();
180
+ document.body.removeChild(link);
181
+ URL.revokeObjectURL(url);
182
+ }, [selectedTable, tableData.data, columnState.selectedFields]);
183
+
184
+ const handleTableSelect = useCallback(
185
+ (table: TableMetadata) => {
186
+ setSelectedTable(table);
187
+ onTableConfirm(table);
188
+ },
189
+ [onTableConfirm],
190
+ );
191
+
192
+ const hasPreviousPage = cursorHistory.length > 0;
193
+
194
+ // Show table selector centered if table is not locked yet
195
+ if (!isTableLocked) {
196
+ return (
197
+ <div className="flex justify-center py-8">
198
+ <TableSelector
199
+ tables={tables}
200
+ selectedTable={null}
201
+ onSelect={handleTableSelect}
202
+ centered
203
+ />
204
+ </div>
205
+ );
206
+ }
207
+
208
+ if (!selectedTable) {
209
+ return (
210
+ <div className="text-muted-foreground py-8 text-center">
211
+ テーブルが選択されていません
212
+ </div>
213
+ );
214
+ }
215
+
216
+ return (
217
+ <div className="space-y-4">
218
+ {/* Table name and description */}
219
+ <div className="flex items-baseline gap-2">
220
+ <span className="font-medium">{selectedTable.name}</span>
221
+ {selectedTable.description && (
222
+ <span className="text-muted-foreground text-sm">
223
+ {selectedTable.description}
224
+ </span>
225
+ )}
226
+ </div>
227
+
228
+ {/* Operations row: column selector, search filter, etc. */}
229
+ <div className="flex items-center gap-4">
230
+ <ColumnSelector
231
+ fields={selectedTable.fields}
232
+ selectedFields={columnState.selectedFields}
233
+ onToggle={columnState.toggleField}
234
+ onSelectAll={columnState.selectAll}
235
+ onDeselectAll={columnState.deselectAll}
236
+ relations={selectedTable.relations}
237
+ selectedRelations={columnState.selectedRelations}
238
+ onToggleRelation={columnState.toggleRelation}
239
+ tableMetadataMap={tableMetadataMap}
240
+ expandedRelationFields={columnState.expandedRelationFields}
241
+ onToggleExpandedRelationField={
242
+ columnState.toggleExpandedRelationField
243
+ }
244
+ isExpandedRelationFieldSelected={
245
+ columnState.isExpandedRelationFieldSelected
246
+ }
247
+ />
248
+
249
+ {/* Search filter */}
250
+ <SearchFilterForm
251
+ fields={selectedTable.fields}
252
+ filters={searchFilters}
253
+ onFiltersChange={handleFiltersChange}
254
+ />
255
+
256
+ {/* View save */}
257
+ <ViewSave
258
+ tableName={selectedTable.name}
259
+ filters={searchFilters}
260
+ selectedFields={columnState.selectedFields}
261
+ selectedRelations={columnState.selectedRelations}
262
+ expandedRelationFields={columnState.expandedRelationFields}
263
+ />
264
+
265
+ <div className="flex-1" />
266
+ <Button
267
+ variant="outline"
268
+ size="sm"
269
+ onClick={handleDownloadCsv}
270
+ disabled={tableData.loading || tableData.data.length === 0}
271
+ >
272
+ <Download className="size-4" />
273
+ CSV
274
+ </Button>
275
+ <Button
276
+ variant="outline"
277
+ size="sm"
278
+ onClick={() => tableData.refetch()}
279
+ disabled={tableData.loading}
280
+ >
281
+ <RefreshCw
282
+ className={`size-4 ${tableData.loading ? "animate-spin" : ""}`}
283
+ />
284
+ 更新
285
+ </Button>
286
+ </div>
287
+
288
+ {/* Error Display */}
289
+ {tableData.error && (
290
+ <Alert variant="destructive">
291
+ <AlertDescription>
292
+ データの取得に失敗しました: {tableData.error.message}
293
+ </AlertDescription>
294
+ </Alert>
295
+ )}
296
+
297
+ {/* Data Table */}
298
+ <DataTable
299
+ data={tableData.data}
300
+ fields={selectedTable.fields}
301
+ selectedFields={columnState.selectedFields}
302
+ sortState={tableData.sortState}
303
+ onSort={tableData.setSort}
304
+ loading={tableData.loading}
305
+ tableMetadata={selectedTable}
306
+ tableMetadataMap={tableMetadataMap}
307
+ appUri={appUri}
308
+ selectedRelations={columnState.selectedRelations}
309
+ onOpenAsSheet={onOpenAsSheet}
310
+ onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
311
+ expandedRelationFields={columnState.expandedRelationFields}
312
+ />
313
+
314
+ {/* Pagination */}
315
+ <Pagination
316
+ pagination={tableData.pagination}
317
+ currentCount={tableData.data.length}
318
+ onNext={tableData.nextPage}
319
+ onPrevious={tableData.previousPage}
320
+ hasPreviousPage={hasPreviousPage}
321
+ />
322
+ </div>
323
+ );
324
+ }
@@ -0,0 +1,280 @@
1
+ import { useState, useCallback } from "react";
2
+ import { Plus, X } from "lucide-react";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
4
+ import { Button } from "../ui/button";
5
+ import type { TableMetadata, TableMetadataMap } from "../types/table-metadata";
6
+ import { useAccessibleTables } from "./hooks/use-accessible-tables";
7
+ import { DataViewTabContent } from "./data-view-tab-content";
8
+ import { SingleRecordTabContent } from "./single-record-tab-content";
9
+ import { useSavedViews, type SavedView } from "./saved-view-context";
10
+
11
+ interface DataViewerProps {
12
+ tableMetadata: TableMetadataMap;
13
+ userRoles: string[];
14
+ appUri: string;
15
+ /** Initial view ID to load on mount */
16
+ initialViewId?: string;
17
+ }
18
+
19
+ /**
20
+ * Initial query condition for filtering table data
21
+ */
22
+ export interface InitialQuery {
23
+ /** Field name to filter by (e.g., "taskId") */
24
+ field: string;
25
+ /** Value to filter with */
26
+ value: string;
27
+ }
28
+
29
+ interface Tab {
30
+ id: string;
31
+ label: string;
32
+ table: TableMetadata | null;
33
+ isLocked: boolean;
34
+ /** Initial query condition for the tab (used when opening from relation) */
35
+ initialQuery?: InitialQuery;
36
+ /** Single record mode - shows one record with its relations */
37
+ singleRecordMode?: {
38
+ recordId: string;
39
+ };
40
+ /** Initial saved view to load */
41
+ initialView?: SavedView;
42
+ }
43
+
44
+ let tabIdCounter = 0;
45
+ function generateTabId(): string {
46
+ return `tab-${++tabIdCounter}`;
47
+ }
48
+
49
+ /**
50
+ * Main Data Viewer component with tab-based UI
51
+ * Similar to Excel sheets - each tab has its own table view
52
+ */
53
+ export function DataViewer({
54
+ tableMetadata,
55
+ userRoles,
56
+ appUri,
57
+ initialViewId,
58
+ }: DataViewerProps) {
59
+ // Get tables accessible to the current user
60
+ const accessibleTables = useAccessibleTables(tableMetadata, userRoles);
61
+ const { getViewById } = useSavedViews();
62
+
63
+ // Tab management - initialize with saved view if provided
64
+ const [tabs, setTabs] = useState<Tab[]>(() => {
65
+ if (initialViewId) {
66
+ const view = getViewById(initialViewId);
67
+ if (view) {
68
+ const table = tableMetadata[view.tableName];
69
+ if (table) {
70
+ return [
71
+ {
72
+ id: generateTabId(),
73
+ label: view.name,
74
+ table,
75
+ isLocked: true,
76
+ initialView: view,
77
+ },
78
+ ];
79
+ }
80
+ }
81
+ }
82
+ return [
83
+ {
84
+ id: generateTabId(),
85
+ label: "新規シート",
86
+ table: null,
87
+ isLocked: false,
88
+ },
89
+ ];
90
+ });
91
+ const [activeTabId, setActiveTabId] = useState<string>(tabs[0].id);
92
+
93
+ const handleAddTab = useCallback(() => {
94
+ const newTab: Tab = {
95
+ id: generateTabId(),
96
+ label: "新規シート",
97
+ table: null,
98
+ isLocked: false,
99
+ };
100
+ setTabs((prev) => [...prev, newTab]);
101
+ setActiveTabId(newTab.id);
102
+ }, []);
103
+
104
+ const handleCloseTab = useCallback(
105
+ (tabId: string) => {
106
+ setTabs((prev) => {
107
+ const newTabs = prev.filter((t) => t.id !== tabId);
108
+ // If closing active tab, switch to another tab
109
+ if (tabId === activeTabId && newTabs.length > 0) {
110
+ setActiveTabId(newTabs[newTabs.length - 1].id);
111
+ }
112
+ // Always keep at least one tab
113
+ if (newTabs.length === 0) {
114
+ const newTab: Tab = {
115
+ id: generateTabId(),
116
+ label: "新規シート",
117
+ table: null,
118
+ isLocked: false,
119
+ };
120
+ setActiveTabId(newTab.id);
121
+ return [newTab];
122
+ }
123
+ return newTabs;
124
+ });
125
+ },
126
+ [activeTabId],
127
+ );
128
+
129
+ const handleTableConfirm = useCallback(
130
+ (tabId: string, table: TableMetadata) => {
131
+ setTabs((prev) =>
132
+ prev.map((t) =>
133
+ t.id === tabId
134
+ ? { ...t, table, label: table.name, isLocked: true }
135
+ : t,
136
+ ),
137
+ );
138
+ },
139
+ [],
140
+ );
141
+
142
+ /**
143
+ * Open a relation as a new sheet with pre-applied filter
144
+ */
145
+ const handleOpenAsSheet = useCallback(
146
+ (targetTableName: string, filterField: string, filterValue: string) => {
147
+ const targetTable = tableMetadata[targetTableName];
148
+ if (!targetTable) return;
149
+
150
+ const newTab: Tab = {
151
+ id: generateTabId(),
152
+ label: `${targetTable.name} (${filterField}=${filterValue.substring(0, 8)}...)`,
153
+ table: targetTable,
154
+ isLocked: true,
155
+ initialQuery: {
156
+ field: filterField,
157
+ value: filterValue,
158
+ },
159
+ };
160
+ setTabs((prev) => [...prev, newTab]);
161
+ setActiveTabId(newTab.id);
162
+ },
163
+ [tableMetadata],
164
+ );
165
+
166
+ /**
167
+ * Open a single record as a new sheet (shows record details with relations)
168
+ */
169
+ const handleOpenSingleRecordAsSheet = useCallback(
170
+ (targetTableName: string, recordId: string) => {
171
+ const targetTable = tableMetadata[targetTableName];
172
+ if (!targetTable) return;
173
+
174
+ const newTab: Tab = {
175
+ id: generateTabId(),
176
+ label: `${targetTable.name} #${recordId}`,
177
+ table: targetTable,
178
+ isLocked: true,
179
+ singleRecordMode: {
180
+ recordId,
181
+ },
182
+ };
183
+ setTabs((prev) => [...prev, newTab]);
184
+ setActiveTabId(newTab.id);
185
+ },
186
+ [tableMetadata],
187
+ );
188
+
189
+ if (accessibleTables.length === 0) {
190
+ return (
191
+ <Card>
192
+ <CardContent className="py-8">
193
+ <div className="text-muted-foreground text-center">
194
+ アクセス可能なテーブルがありません
195
+ </div>
196
+ </CardContent>
197
+ </Card>
198
+ );
199
+ }
200
+
201
+ return (
202
+ <Card>
203
+ <CardHeader className="pb-2">
204
+ <CardTitle>Data View Explorer</CardTitle>
205
+ </CardHeader>
206
+
207
+ {/* Tab bar */}
208
+ <div className="border-b px-4">
209
+ <div className="flex items-center gap-1">
210
+ {tabs.map((tab) => (
211
+ <div
212
+ key={tab.id}
213
+ className={`group flex cursor-pointer items-center gap-1 border-b-2 px-3 py-2 text-sm transition-colors ${
214
+ tab.id === activeTabId
215
+ ? "border-primary text-primary"
216
+ : "text-muted-foreground hover:text-foreground border-transparent"
217
+ }`}
218
+ onClick={() => setActiveTabId(tab.id)}
219
+ >
220
+ <span className="max-w-32 truncate">{tab.label}</span>
221
+ {tabs.length > 1 && (
222
+ <button
223
+ className="text-muted-foreground hover:text-destructive ml-1 opacity-0 transition-opacity group-hover:opacity-100"
224
+ onClick={(e) => {
225
+ e.stopPropagation();
226
+ handleCloseTab(tab.id);
227
+ }}
228
+ >
229
+ <X className="size-3" />
230
+ </button>
231
+ )}
232
+ </div>
233
+ ))}
234
+ <Button
235
+ variant="ghost"
236
+ size="sm"
237
+ className="ml-1 h-8 w-8 p-0"
238
+ onClick={handleAddTab}
239
+ >
240
+ <Plus className="size-4" />
241
+ </Button>
242
+ </div>
243
+ </div>
244
+
245
+ {/* Tab content - all tabs stay mounted to preserve data in memory */}
246
+ <CardContent>
247
+ {tabs.map((tab) => (
248
+ <div
249
+ key={tab.id}
250
+ className={tab.id === activeTabId ? "block" : "hidden"}
251
+ >
252
+ {tab.singleRecordMode && tab.table ? (
253
+ <SingleRecordTabContent
254
+ tableMetadata={tab.table}
255
+ tableMetadataMap={tableMetadata}
256
+ appUri={appUri}
257
+ recordId={tab.singleRecordMode.recordId}
258
+ onOpenAsSheet={handleOpenAsSheet}
259
+ onOpenSingleRecordAsSheet={handleOpenSingleRecordAsSheet}
260
+ />
261
+ ) : (
262
+ <DataViewTabContent
263
+ tables={accessibleTables}
264
+ appUri={appUri}
265
+ initialTable={tab.table ?? undefined}
266
+ onTableConfirm={(table) => handleTableConfirm(tab.id, table)}
267
+ isTableLocked={tab.isLocked}
268
+ tableMetadataMap={tableMetadata}
269
+ initialQuery={tab.initialQuery}
270
+ initialView={tab.initialView}
271
+ onOpenAsSheet={handleOpenAsSheet}
272
+ onOpenSingleRecordAsSheet={handleOpenSingleRecordAsSheet}
273
+ />
274
+ )}
275
+ </div>
276
+ ))}
277
+ </CardContent>
278
+ </Card>
279
+ );
280
+ }
@@ -0,0 +1,22 @@
1
+ import { useMemo } from "react";
2
+ import type {
3
+ TableMetadata,
4
+ TableMetadataMap,
5
+ } from "../../types/table-metadata";
6
+
7
+ /**
8
+ * Filter tables based on user's roles
9
+ * Returns only tables the user has read access to
10
+ */
11
+ export function useAccessibleTables(
12
+ tableMetadata: TableMetadataMap,
13
+ userRoles: string[],
14
+ ): TableMetadata[] {
15
+ return useMemo(() => {
16
+ return Object.values(tableMetadata).filter((table) =>
17
+ table.readAllowedRoles.some((role) =>
18
+ userRoles.map((r) => r.toUpperCase()).includes(role.toUpperCase()),
19
+ ),
20
+ );
21
+ }, [tableMetadata, userRoles]);
22
+ }