@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.
- package/README.md +255 -0
- package/package.json +47 -0
- package/src/component/column-selector.tsx +264 -0
- package/src/component/data-table.tsx +428 -0
- package/src/component/data-view-tab-content.tsx +324 -0
- package/src/component/data-viewer.tsx +280 -0
- package/src/component/hooks/use-accessible-tables.ts +22 -0
- package/src/component/hooks/use-column-state.ts +281 -0
- package/src/component/hooks/use-relation-data.ts +387 -0
- package/src/component/hooks/use-table-data.ts +317 -0
- package/src/component/index.ts +15 -0
- package/src/component/pagination.tsx +56 -0
- package/src/component/relation-content.tsx +250 -0
- package/src/component/saved-view-context.tsx +145 -0
- package/src/component/search-filter.tsx +319 -0
- package/src/component/single-record-tab-content.tsx +676 -0
- package/src/component/table-selector.tsx +102 -0
- package/src/component/types.ts +20 -0
- package/src/component/view-save-load.tsx +112 -0
- package/src/generator/metadata-generator.ts +461 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/graphql-client.ts +31 -0
- package/src/styles/theme.css +105 -0
- package/src/types/table-metadata.ts +73 -0
- package/src/ui/alert.tsx +66 -0
- package/src/ui/badge.tsx +46 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/checkbox.tsx +30 -0
- package/src/ui/collapsible.tsx +31 -0
- package/src/ui/dialog.tsx +143 -0
- package/src/ui/dropdown-menu.tsx +255 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/select.tsx +188 -0
- package/src/ui/table.tsx +116 -0
- 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
|
+
}
|