@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
@@ -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, SearchFilters } from "./types";
22
-
23
- interface SearchFilterProps {
24
- fields: FieldMetadata[];
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
- fields,
55
- filters,
56
- onFiltersChange,
57
- open,
58
- onOpenChange,
59
- }: SearchFilterProps) {
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
- onFiltersChange([...filters, newFilter]);
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
- onFiltersChange,
116
+ setFilters,
99
117
  ]);
100
118
 
101
119
  const handleRemoveFilter = useCallback(
102
120
  (fieldName: string) => {
103
- onFiltersChange(filters.filter((f) => f.field !== fieldName));
121
+ setFilters(filters.filter((f) => f.field !== fieldName));
104
122
  },
105
- [filters, onFiltersChange],
123
+ [filters, setFilters],
106
124
  );
107
125
 
108
126
  const handleClearAll = useCallback(() => {
109
- onFiltersChange([]);
110
- }, [onFiltersChange]);
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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
- tableMetadataMap={mockTableMetadataMap}
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 { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
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 { useColumnState } from "./hooks/use-column-state";
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 for relation lookup */
34
- tableMetadataMap: TableMetadataMap;
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
- tableMetadataMap,
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
- // Column state for field selection
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
- columnState.selectedFields.includes(f.name),
173
+ selectedFields.includes(f.name),
126
174
  );
127
175
  const relations = (tableMetadata.relations ?? []).filter((r) =>
128
- columnState.selectedRelations.includes(r.fieldName),
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
- <ColumnSelector
149
- fields={allDisplayFields}
150
- selectedFields={columnState.selectedFields}
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 = tableMetadataMap[relation.targetTable];
236
+ const targetTable = metadata[relation.targetTable];
196
237
  const relationData = relationDataMap[relation.fieldName];
197
238
  const displayFieldsForRelation = targetTable
198
239
  ? relation.relationType === "manyToOne"
@@ -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 type { SearchFilters } from "./types";
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
- tableName,
32
- filters,
33
- selectedFields,
34
- selectedRelations,
35
- expandedRelationFields,
36
- }: ViewSaveProps) {
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