@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.
- package/README.md +45 -1
- package/dist/generator/index.d.mts +54 -31
- package/dist/generator/index.mjs +20 -13
- package/docs/compositional-api.md +366 -0
- package/package.json +1 -1
- package/src/app-shell/create-data-view-module.tsx +1 -1
- 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 +68 -138
- package/src/component/data-viewer.tsx +11 -11
- package/src/component/hooks/use-column-state.ts +2 -2
- package/src/component/hooks/use-table-data.test.ts +399 -0
- package/src/component/hooks/use-table-data.ts +24 -7
- package/src/component/index.ts +43 -1
- package/src/component/refresh-button.tsx +20 -0
- package/src/component/saved-view-context.tsx +31 -2
- package/src/component/search-filter.test.tsx +612 -0
- package/src/component/search-filter.tsx +168 -33
- package/src/component/single-record-tab-content.test.tsx +10 -10
- package/src/component/single-record-tab-content.tsx +62 -21
- package/src/component/types.ts +78 -0
- package/src/component/view-save-load.tsx +13 -17
- package/src/generator/metadata-generator.ts +100 -67
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
2
|
import { Search, Plus, X, Filter } from "lucide-react";
|
|
3
3
|
import { Button } from "./ui/button";
|
|
4
4
|
import { Input } from "./ui/input";
|
|
@@ -18,17 +18,10 @@ import {
|
|
|
18
18
|
import { Badge } from "./ui/badge";
|
|
19
19
|
import { Label } from "./ui/label";
|
|
20
20
|
import type { FieldMetadata } from "../generator/metadata-generator";
|
|
21
|
-
import type { SearchFilter,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
filters: SearchFilters;
|
|
26
|
-
onFiltersChange: (filters: SearchFilters) => void;
|
|
27
|
-
/** Controlled open state */
|
|
28
|
-
open?: boolean;
|
|
29
|
-
/** Callback when open state changes */
|
|
30
|
-
onOpenChange?: (open: boolean) => void;
|
|
31
|
-
}
|
|
21
|
+
import type { SearchFilter, FilterOperator } from "./types";
|
|
22
|
+
import { OPERATOR_LABELS, OPERATORS_BY_FIELD_TYPE } from "./types";
|
|
23
|
+
import { useDataViewer } from "./contexts";
|
|
24
|
+
import { useToolbar } from "./contexts";
|
|
32
25
|
|
|
33
26
|
// Filterable field types
|
|
34
27
|
const FILTERABLE_TYPES = [
|
|
@@ -37,6 +30,8 @@ const FILTERABLE_TYPES = [
|
|
|
37
30
|
"boolean",
|
|
38
31
|
"enum",
|
|
39
32
|
"uuid",
|
|
33
|
+
"datetime",
|
|
34
|
+
"date",
|
|
40
35
|
] as const;
|
|
41
36
|
|
|
42
37
|
/**
|
|
@@ -49,15 +44,20 @@ function isFilterableField(field: FieldMetadata): boolean {
|
|
|
49
44
|
/**
|
|
50
45
|
* Search filter form component
|
|
51
46
|
* Allows adding multiple AND filters for string/number/boolean/enum fields
|
|
47
|
+
* Must be used within DataViewer.Root and DataViewer.Toolbar context
|
|
52
48
|
*/
|
|
53
|
-
export function SearchFilterForm({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
export function SearchFilterForm() {
|
|
50
|
+
const { tableMetadata, filters, setFilters } = useDataViewer();
|
|
51
|
+
const { activePanel, setActivePanel } = useToolbar();
|
|
52
|
+
|
|
53
|
+
const fields = tableMetadata?.fields ?? [];
|
|
54
|
+
const open = activePanel === "search";
|
|
55
|
+
const onOpenChange = (isOpen: boolean) =>
|
|
56
|
+
setActivePanel(isOpen ? "search" : null);
|
|
57
|
+
|
|
60
58
|
const [selectedField, setSelectedField] = useState<string>("");
|
|
59
|
+
const [selectedOperator, setSelectedOperator] =
|
|
60
|
+
useState<FilterOperator>("eq");
|
|
61
61
|
const [inputValue, setInputValue] = useState<string>("");
|
|
62
62
|
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
63
63
|
|
|
@@ -70,6 +70,21 @@ export function SearchFilterForm({
|
|
|
70
70
|
|
|
71
71
|
const selectedFieldMetadata = fields.find((f) => f.name === selectedField);
|
|
72
72
|
|
|
73
|
+
// Get available operators for the selected field type
|
|
74
|
+
const availableOperators = selectedFieldMetadata
|
|
75
|
+
? OPERATORS_BY_FIELD_TYPE[selectedFieldMetadata.type]
|
|
76
|
+
: [];
|
|
77
|
+
|
|
78
|
+
// Reset operator when field changes
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (selectedFieldMetadata) {
|
|
81
|
+
const operators = OPERATORS_BY_FIELD_TYPE[selectedFieldMetadata.type];
|
|
82
|
+
if (operators.length > 0 && !operators.includes(selectedOperator)) {
|
|
83
|
+
setSelectedOperator(operators[0]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}, [selectedField, selectedFieldMetadata, selectedOperator]);
|
|
87
|
+
|
|
73
88
|
const handleAddFilter = useCallback(() => {
|
|
74
89
|
if (!selectedFieldMetadata) return;
|
|
75
90
|
|
|
@@ -81,33 +96,36 @@ export function SearchFilterForm({
|
|
|
81
96
|
const newFilter: SearchFilter = {
|
|
82
97
|
field: selectedField,
|
|
83
98
|
fieldType: selectedFieldMetadata.type,
|
|
99
|
+
operator: selectedOperator,
|
|
84
100
|
value,
|
|
85
101
|
enumValues: selectedFieldMetadata.enumValues,
|
|
86
102
|
};
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
setFilters([...filters, newFilter]);
|
|
89
105
|
setSelectedField("");
|
|
106
|
+
setSelectedOperator("eq");
|
|
90
107
|
setInputValue("");
|
|
91
108
|
setBooleanValue(false);
|
|
92
109
|
}, [
|
|
93
110
|
selectedField,
|
|
94
111
|
selectedFieldMetadata,
|
|
112
|
+
selectedOperator,
|
|
95
113
|
inputValue,
|
|
96
114
|
booleanValue,
|
|
97
115
|
filters,
|
|
98
|
-
|
|
116
|
+
setFilters,
|
|
99
117
|
]);
|
|
100
118
|
|
|
101
119
|
const handleRemoveFilter = useCallback(
|
|
102
120
|
(fieldName: string) => {
|
|
103
|
-
|
|
121
|
+
setFilters(filters.filter((f) => f.field !== fieldName));
|
|
104
122
|
},
|
|
105
|
-
[filters,
|
|
123
|
+
[filters, setFilters],
|
|
106
124
|
);
|
|
107
125
|
|
|
108
126
|
const handleClearAll = useCallback(() => {
|
|
109
|
-
|
|
110
|
-
}, [
|
|
127
|
+
setFilters([]);
|
|
128
|
+
}, [setFilters]);
|
|
111
129
|
|
|
112
130
|
const handleKeyDown = useCallback(
|
|
113
131
|
(e: React.KeyboardEvent) => {
|
|
@@ -165,6 +183,28 @@ export function SearchFilterForm({
|
|
|
165
183
|
/>
|
|
166
184
|
);
|
|
167
185
|
|
|
186
|
+
case "datetime":
|
|
187
|
+
return (
|
|
188
|
+
<Input
|
|
189
|
+
type="datetime-local"
|
|
190
|
+
placeholder="日時を選択"
|
|
191
|
+
value={inputValue}
|
|
192
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
193
|
+
onKeyDown={handleKeyDown}
|
|
194
|
+
/>
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
case "date":
|
|
198
|
+
return (
|
|
199
|
+
<Input
|
|
200
|
+
type="date"
|
|
201
|
+
placeholder="日付を選択"
|
|
202
|
+
value={inputValue}
|
|
203
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
204
|
+
onKeyDown={handleKeyDown}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
|
|
168
208
|
default:
|
|
169
209
|
// string, uuid
|
|
170
210
|
return (
|
|
@@ -179,6 +219,105 @@ export function SearchFilterForm({
|
|
|
179
219
|
}
|
|
180
220
|
};
|
|
181
221
|
|
|
222
|
+
// Render operator selector
|
|
223
|
+
const renderOperatorSelector = () => {
|
|
224
|
+
if (!selectedFieldMetadata || availableOperators.length <= 1) return null;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Select
|
|
228
|
+
value={selectedOperator}
|
|
229
|
+
onValueChange={(value) => setSelectedOperator(value as FilterOperator)}
|
|
230
|
+
>
|
|
231
|
+
<SelectTrigger className="w-full">
|
|
232
|
+
<SelectValue placeholder="条件を選択" />
|
|
233
|
+
</SelectTrigger>
|
|
234
|
+
<SelectContent>
|
|
235
|
+
{availableOperators.map((op) => (
|
|
236
|
+
<SelectItem key={op} value={op}>
|
|
237
|
+
{getOperatorLabel(selectedFieldMetadata.type, op)}
|
|
238
|
+
</SelectItem>
|
|
239
|
+
))}
|
|
240
|
+
</SelectContent>
|
|
241
|
+
</Select>
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Get operator label based on field type
|
|
246
|
+
const getOperatorLabel = (
|
|
247
|
+
fieldType: string,
|
|
248
|
+
operator: FilterOperator,
|
|
249
|
+
): string => {
|
|
250
|
+
// For date/datetime fields, use more descriptive labels
|
|
251
|
+
if (fieldType === "datetime" || fieldType === "date") {
|
|
252
|
+
switch (operator) {
|
|
253
|
+
case "eq":
|
|
254
|
+
return "= (等しい)";
|
|
255
|
+
case "gt":
|
|
256
|
+
return "> (より後)";
|
|
257
|
+
case "lt":
|
|
258
|
+
return "< (より前)";
|
|
259
|
+
default:
|
|
260
|
+
return OPERATOR_LABELS[operator];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// For number fields
|
|
264
|
+
if (fieldType === "number") {
|
|
265
|
+
switch (operator) {
|
|
266
|
+
case "eq":
|
|
267
|
+
return "= (等しい)";
|
|
268
|
+
case "gt":
|
|
269
|
+
return "> (より大きい)";
|
|
270
|
+
case "lt":
|
|
271
|
+
return "< (より小さい)";
|
|
272
|
+
default:
|
|
273
|
+
return OPERATOR_LABELS[operator];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// For string fields
|
|
277
|
+
if (fieldType === "string") {
|
|
278
|
+
switch (operator) {
|
|
279
|
+
case "eq":
|
|
280
|
+
return "= (等しい)";
|
|
281
|
+
case "contains":
|
|
282
|
+
return "含む";
|
|
283
|
+
case "hasPrefix":
|
|
284
|
+
return "で始まる";
|
|
285
|
+
case "hasSuffix":
|
|
286
|
+
return "で終わる";
|
|
287
|
+
default:
|
|
288
|
+
return OPERATOR_LABELS[operator];
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return OPERATOR_LABELS[operator];
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Format filter display for badge
|
|
295
|
+
const formatFilterDisplay = (filter: SearchFilter): string => {
|
|
296
|
+
const value =
|
|
297
|
+
typeof filter.value === "boolean"
|
|
298
|
+
? filter.value
|
|
299
|
+
? "true"
|
|
300
|
+
: "false"
|
|
301
|
+
: filter.value;
|
|
302
|
+
|
|
303
|
+
switch (filter.operator) {
|
|
304
|
+
case "eq":
|
|
305
|
+
return `${filter.field} = ${value}`;
|
|
306
|
+
case "gt":
|
|
307
|
+
return `${filter.field} > ${value}`;
|
|
308
|
+
case "lt":
|
|
309
|
+
return `${filter.field} < ${value}`;
|
|
310
|
+
case "contains":
|
|
311
|
+
return `${filter.field} 含む "${value}"`;
|
|
312
|
+
case "hasPrefix":
|
|
313
|
+
return `${filter.field} "${value}" で始まる`;
|
|
314
|
+
case "hasSuffix":
|
|
315
|
+
return `${filter.field} "${value}" で終わる`;
|
|
316
|
+
default:
|
|
317
|
+
return `${filter.field} = ${value}`;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
182
321
|
const activeFilterCount = filters.length;
|
|
183
322
|
|
|
184
323
|
return (
|
|
@@ -227,14 +366,7 @@ export function SearchFilterForm({
|
|
|
227
366
|
variant="secondary"
|
|
228
367
|
className="flex items-center gap-1 pr-1"
|
|
229
368
|
>
|
|
230
|
-
<span>
|
|
231
|
-
{filter.field}=
|
|
232
|
-
{typeof filter.value === "boolean"
|
|
233
|
-
? filter.value
|
|
234
|
-
? "true"
|
|
235
|
-
: "false"
|
|
236
|
-
: filter.value}
|
|
237
|
-
</span>
|
|
369
|
+
<span>{formatFilterDisplay(filter)}</span>
|
|
238
370
|
<button
|
|
239
371
|
className="text-muted-foreground hover:text-foreground ml-1"
|
|
240
372
|
onClick={() => handleRemoveFilter(filter.field)}
|
|
@@ -274,6 +406,9 @@ export function SearchFilterForm({
|
|
|
274
406
|
{/* Value input - changes based on field type */}
|
|
275
407
|
{selectedField && (
|
|
276
408
|
<div className="space-y-2">
|
|
409
|
+
{/* Operator selector (only shown if multiple operators available) */}
|
|
410
|
+
{renderOperatorSelector()}
|
|
411
|
+
{/* Value input */}
|
|
277
412
|
{renderFilterInput()}
|
|
278
413
|
<Button
|
|
279
414
|
size="sm"
|
|
@@ -127,7 +127,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
127
127
|
render(
|
|
128
128
|
<SingleRecordTabContent
|
|
129
129
|
tableMetadata={mockTaskTable}
|
|
130
|
-
|
|
130
|
+
metadata={mockTableMetadataMap}
|
|
131
131
|
appUri="https://test.example.com"
|
|
132
132
|
recordId="task-123-456-789"
|
|
133
133
|
fetcher={mockFetcher}
|
|
@@ -141,7 +141,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
141
141
|
render(
|
|
142
142
|
<SingleRecordTabContent
|
|
143
143
|
tableMetadata={mockTaskTable}
|
|
144
|
-
|
|
144
|
+
metadata={mockTableMetadataMap}
|
|
145
145
|
appUri="https://test.example.com"
|
|
146
146
|
recordId="task-123-456-789"
|
|
147
147
|
fetcher={mockFetcher}
|
|
@@ -160,7 +160,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
160
160
|
render(
|
|
161
161
|
<SingleRecordTabContent
|
|
162
162
|
tableMetadata={mockTaskTable}
|
|
163
|
-
|
|
163
|
+
metadata={mockTableMetadataMap}
|
|
164
164
|
appUri="https://test.example.com"
|
|
165
165
|
recordId="task-123-456-789"
|
|
166
166
|
fetcher={mockFetcher}
|
|
@@ -193,7 +193,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
193
193
|
render(
|
|
194
194
|
<SingleRecordTabContent
|
|
195
195
|
tableMetadata={mockTaskTable}
|
|
196
|
-
|
|
196
|
+
metadata={mockTableMetadataMap}
|
|
197
197
|
appUri="https://test.example.com"
|
|
198
198
|
recordId="task-123-456-789"
|
|
199
199
|
fetcher={mockFetcher}
|
|
@@ -213,7 +213,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
213
213
|
render(
|
|
214
214
|
<SingleRecordTabContent
|
|
215
215
|
tableMetadata={mockTaskTable}
|
|
216
|
-
|
|
216
|
+
metadata={mockTableMetadataMap}
|
|
217
217
|
appUri="https://test.example.com"
|
|
218
218
|
recordId="nonexistent-id"
|
|
219
219
|
fetcher={mockFetcher}
|
|
@@ -233,7 +233,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
233
233
|
render(
|
|
234
234
|
<SingleRecordTabContent
|
|
235
235
|
tableMetadata={mockTaskTable}
|
|
236
|
-
|
|
236
|
+
metadata={mockTableMetadataMap}
|
|
237
237
|
appUri="https://test.example.com"
|
|
238
238
|
recordId="task-123-456-789"
|
|
239
239
|
fetcher={mockFetcher}
|
|
@@ -257,7 +257,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
257
257
|
render(
|
|
258
258
|
<SingleRecordTabContent
|
|
259
259
|
tableMetadata={mockTaskTable}
|
|
260
|
-
|
|
260
|
+
metadata={mockTableMetadataMap}
|
|
261
261
|
appUri="https://test.example.com"
|
|
262
262
|
recordId="task-123-456-789"
|
|
263
263
|
fetcher={mockFetcher}
|
|
@@ -277,7 +277,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
277
277
|
render(
|
|
278
278
|
<SingleRecordTabContent
|
|
279
279
|
tableMetadata={mockTaskTable}
|
|
280
|
-
|
|
280
|
+
metadata={mockTableMetadataMap}
|
|
281
281
|
appUri="https://test.example.com"
|
|
282
282
|
recordId="task-123-456-789"
|
|
283
283
|
fetcher={mockFetcher}
|
|
@@ -298,7 +298,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
298
298
|
render(
|
|
299
299
|
<SingleRecordTabContent
|
|
300
300
|
tableMetadata={mockTaskTable}
|
|
301
|
-
|
|
301
|
+
metadata={mockTableMetadataMap}
|
|
302
302
|
appUri="https://test.example.com"
|
|
303
303
|
recordId="task-123-456-789"
|
|
304
304
|
fetcher={mockFetcher}
|
|
@@ -326,7 +326,7 @@ describe("SingleRecordTabContent", () => {
|
|
|
326
326
|
render(
|
|
327
327
|
<SingleRecordTabContent
|
|
328
328
|
tableMetadata={mockTaskTable}
|
|
329
|
-
|
|
329
|
+
metadata={mockTableMetadataMap}
|
|
330
330
|
appUri="https://test.example.com"
|
|
331
331
|
recordId="task-123-456-789"
|
|
332
332
|
fetcher={mockFetcher}
|
|
@@ -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"
|
package/src/component/types.ts
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
import type { FieldType } from "../generator/metadata-generator";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Filter operators for String fields
|
|
5
|
+
*/
|
|
6
|
+
export type StringFilterOperator =
|
|
7
|
+
| "eq" // Equals
|
|
8
|
+
| "contains" // Contains
|
|
9
|
+
| "hasPrefix" // Starts with
|
|
10
|
+
| "hasSuffix"; // Ends with
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filter operators for Number fields
|
|
14
|
+
*/
|
|
15
|
+
export type NumberFilterOperator =
|
|
16
|
+
| "eq" // Equals
|
|
17
|
+
| "gt" // Greater than
|
|
18
|
+
| "lt"; // Less than
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Filter operators for Date/DateTime fields
|
|
22
|
+
*/
|
|
23
|
+
export type DateFilterOperator =
|
|
24
|
+
| "eq" // Equals
|
|
25
|
+
| "gt" // After (newer than)
|
|
26
|
+
| "lt"; // Before (older than)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Filter operators for Boolean fields
|
|
30
|
+
*/
|
|
31
|
+
export type BooleanFilterOperator = "eq";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Filter operators for Enum and UUID fields
|
|
35
|
+
*/
|
|
36
|
+
export type EnumFilterOperator = "eq";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Union of all filter operators
|
|
40
|
+
*/
|
|
41
|
+
export type FilterOperator =
|
|
42
|
+
| StringFilterOperator
|
|
43
|
+
| NumberFilterOperator
|
|
44
|
+
| DateFilterOperator
|
|
45
|
+
| BooleanFilterOperator
|
|
46
|
+
| EnumFilterOperator;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Operator labels for display (Japanese)
|
|
50
|
+
*/
|
|
51
|
+
export const OPERATOR_LABELS: Record<FilterOperator, string> = {
|
|
52
|
+
eq: "=",
|
|
53
|
+
contains: "含む",
|
|
54
|
+
hasPrefix: "で始まる",
|
|
55
|
+
hasSuffix: "で終わる",
|
|
56
|
+
gt: ">",
|
|
57
|
+
lt: "<",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Available operators by field type
|
|
62
|
+
*/
|
|
63
|
+
export const OPERATORS_BY_FIELD_TYPE: Record<
|
|
64
|
+
FieldType,
|
|
65
|
+
readonly FilterOperator[]
|
|
66
|
+
> = {
|
|
67
|
+
string: ["eq", "contains", "hasPrefix", "hasSuffix"],
|
|
68
|
+
number: ["eq", "gt", "lt"],
|
|
69
|
+
boolean: ["eq"],
|
|
70
|
+
uuid: ["eq"],
|
|
71
|
+
datetime: ["eq", "gt", "lt"],
|
|
72
|
+
date: ["eq", "gt", "lt"],
|
|
73
|
+
time: ["eq"],
|
|
74
|
+
enum: ["eq"],
|
|
75
|
+
array: ["eq"],
|
|
76
|
+
nested: ["eq"],
|
|
77
|
+
};
|
|
78
|
+
|
|
3
79
|
/**
|
|
4
80
|
* Search filter condition for a single field
|
|
5
81
|
*/
|
|
@@ -8,6 +84,8 @@ export interface SearchFilter {
|
|
|
8
84
|
field: string;
|
|
9
85
|
/** Field type (determines UI input type and query format) */
|
|
10
86
|
fieldType: FieldType;
|
|
87
|
+
/** Filter operator */
|
|
88
|
+
operator: FilterOperator;
|
|
11
89
|
/** Filter value (string for string/number/enum, boolean for boolean) */
|
|
12
90
|
value: string | boolean;
|
|
13
91
|
/** Enum values (if fieldType is "enum") */
|
|
@@ -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
|
|