@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,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
use,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import type { ExpandedRelationFields } from "../types/table-metadata";
|
|
9
|
+
import type { SearchFilters } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Saved view configuration
|
|
13
|
+
*/
|
|
14
|
+
export interface SavedView {
|
|
15
|
+
/** Unique identifier */
|
|
16
|
+
id: string;
|
|
17
|
+
/** User-defined name for the view */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Table name this view applies to */
|
|
20
|
+
tableName: string;
|
|
21
|
+
/** The filter conditions */
|
|
22
|
+
filters: SearchFilters;
|
|
23
|
+
/** Selected field names */
|
|
24
|
+
selectedFields: string[];
|
|
25
|
+
/** Selected relation field names */
|
|
26
|
+
selectedRelations: string[];
|
|
27
|
+
/** Expanded relation fields (manyToOne fields shown as inline columns) */
|
|
28
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
29
|
+
/** When this view was created */
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SaveViewInput {
|
|
34
|
+
name: string;
|
|
35
|
+
tableName: string;
|
|
36
|
+
filters: SearchFilters;
|
|
37
|
+
selectedFields: string[];
|
|
38
|
+
selectedRelations: string[];
|
|
39
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SavedViewContextValue {
|
|
43
|
+
views: SavedView[];
|
|
44
|
+
saveView: (input: SaveViewInput) => SavedView;
|
|
45
|
+
deleteView: (id: string) => boolean;
|
|
46
|
+
renameView: (id: string, newName: string) => boolean;
|
|
47
|
+
getViewById: (id: string) => SavedView | undefined;
|
|
48
|
+
getViewsByTable: (tableName: string) => SavedView[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SavedViewContext = createContext<SavedViewContextValue | null>(null);
|
|
52
|
+
|
|
53
|
+
let idCounter = 0;
|
|
54
|
+
function generateId(): string {
|
|
55
|
+
return `saved-view-${++idCounter}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SavedViewProviderProps {
|
|
59
|
+
children: ReactNode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function SavedViewProvider({ children }: SavedViewProviderProps) {
|
|
63
|
+
const [views, setViews] = useState<SavedView[]>([]);
|
|
64
|
+
|
|
65
|
+
const saveView = useCallback((input: SaveViewInput): SavedView => {
|
|
66
|
+
const newView: SavedView = {
|
|
67
|
+
id: generateId(),
|
|
68
|
+
name: input.name,
|
|
69
|
+
tableName: input.tableName,
|
|
70
|
+
filters: [...input.filters],
|
|
71
|
+
selectedFields: [...input.selectedFields],
|
|
72
|
+
selectedRelations: [...input.selectedRelations],
|
|
73
|
+
expandedRelationFields: { ...input.expandedRelationFields },
|
|
74
|
+
createdAt: new Date(),
|
|
75
|
+
};
|
|
76
|
+
setViews((prev) => [newView, ...prev]);
|
|
77
|
+
return newView;
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const deleteView = useCallback((id: string): boolean => {
|
|
81
|
+
let deleted = false;
|
|
82
|
+
setViews((prev) => {
|
|
83
|
+
const newViews = prev.filter((v) => {
|
|
84
|
+
if (v.id === id) {
|
|
85
|
+
deleted = true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
return newViews;
|
|
91
|
+
});
|
|
92
|
+
return deleted;
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const renameView = useCallback((id: string, newName: string): boolean => {
|
|
96
|
+
let renamed = false;
|
|
97
|
+
setViews((prev) =>
|
|
98
|
+
prev.map((v) => {
|
|
99
|
+
if (v.id === id) {
|
|
100
|
+
renamed = true;
|
|
101
|
+
return { ...v, name: newName };
|
|
102
|
+
}
|
|
103
|
+
return v;
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
return renamed;
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const getViewById = useCallback(
|
|
110
|
+
(id: string): SavedView | undefined => {
|
|
111
|
+
return views.find((v) => v.id === id);
|
|
112
|
+
},
|
|
113
|
+
[views],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const getViewsByTable = useCallback(
|
|
117
|
+
(tableName: string): SavedView[] => {
|
|
118
|
+
return views.filter((v) => v.tableName === tableName);
|
|
119
|
+
},
|
|
120
|
+
[views],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<SavedViewContext
|
|
125
|
+
value={{
|
|
126
|
+
views,
|
|
127
|
+
saveView,
|
|
128
|
+
deleteView,
|
|
129
|
+
renameView,
|
|
130
|
+
getViewById,
|
|
131
|
+
getViewsByTable,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{children}
|
|
135
|
+
</SavedViewContext>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useSavedViews(): SavedViewContextValue {
|
|
140
|
+
const context = use(SavedViewContext);
|
|
141
|
+
if (!context) {
|
|
142
|
+
throw new Error("useSavedViews must be used within a SavedViewProvider");
|
|
143
|
+
}
|
|
144
|
+
return context;
|
|
145
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Search,
|
|
4
|
+
Plus,
|
|
5
|
+
X,
|
|
6
|
+
Filter,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { Button } from "../ui/button";
|
|
11
|
+
import { Input } from "../ui/input";
|
|
12
|
+
import { Checkbox } from "../ui/checkbox";
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "../ui/select";
|
|
20
|
+
import {
|
|
21
|
+
Collapsible,
|
|
22
|
+
CollapsibleContent,
|
|
23
|
+
CollapsibleTrigger,
|
|
24
|
+
} from "../ui/collapsible";
|
|
25
|
+
import { Badge } from "../ui/badge";
|
|
26
|
+
import { Label } from "../ui/label";
|
|
27
|
+
import type { FieldMetadata } from "../types/table-metadata";
|
|
28
|
+
import type { SearchFilter, SearchFilters } from "./types";
|
|
29
|
+
|
|
30
|
+
interface SearchFilterProps {
|
|
31
|
+
fields: FieldMetadata[];
|
|
32
|
+
filters: SearchFilters;
|
|
33
|
+
onFiltersChange: (filters: SearchFilters) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Filterable field types
|
|
37
|
+
const FILTERABLE_TYPES = [
|
|
38
|
+
"string",
|
|
39
|
+
"number",
|
|
40
|
+
"boolean",
|
|
41
|
+
"enum",
|
|
42
|
+
"uuid",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if a field is filterable
|
|
47
|
+
*/
|
|
48
|
+
function isFilterableField(field: FieldMetadata): boolean {
|
|
49
|
+
return (FILTERABLE_TYPES as readonly string[]).includes(field.type);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Search filter form component
|
|
54
|
+
* Allows adding multiple AND filters for string/number/boolean/enum fields
|
|
55
|
+
*/
|
|
56
|
+
export function SearchFilterForm({
|
|
57
|
+
fields,
|
|
58
|
+
filters,
|
|
59
|
+
onFiltersChange,
|
|
60
|
+
}: SearchFilterProps) {
|
|
61
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
62
|
+
const [selectedField, setSelectedField] = useState<string>("");
|
|
63
|
+
const [inputValue, setInputValue] = useState<string>("");
|
|
64
|
+
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
65
|
+
|
|
66
|
+
// Get filterable fields excluding already-filtered fields
|
|
67
|
+
const filterableFields = fields.filter(
|
|
68
|
+
(f) =>
|
|
69
|
+
isFilterableField(f) &&
|
|
70
|
+
!filters.some((filter) => filter.field === f.name),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const selectedFieldMetadata = fields.find((f) => f.name === selectedField);
|
|
74
|
+
|
|
75
|
+
const handleAddFilter = useCallback(() => {
|
|
76
|
+
if (!selectedFieldMetadata) return;
|
|
77
|
+
|
|
78
|
+
const value =
|
|
79
|
+
selectedFieldMetadata.type === "boolean" ? booleanValue : inputValue;
|
|
80
|
+
|
|
81
|
+
if (selectedFieldMetadata.type !== "boolean" && !inputValue.trim()) return;
|
|
82
|
+
|
|
83
|
+
const newFilter: SearchFilter = {
|
|
84
|
+
field: selectedField,
|
|
85
|
+
fieldType: selectedFieldMetadata.type,
|
|
86
|
+
value,
|
|
87
|
+
enumValues: selectedFieldMetadata.enumValues,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
onFiltersChange([...filters, newFilter]);
|
|
91
|
+
setSelectedField("");
|
|
92
|
+
setInputValue("");
|
|
93
|
+
setBooleanValue(false);
|
|
94
|
+
}, [
|
|
95
|
+
selectedField,
|
|
96
|
+
selectedFieldMetadata,
|
|
97
|
+
inputValue,
|
|
98
|
+
booleanValue,
|
|
99
|
+
filters,
|
|
100
|
+
onFiltersChange,
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const handleRemoveFilter = useCallback(
|
|
104
|
+
(fieldName: string) => {
|
|
105
|
+
onFiltersChange(filters.filter((f) => f.field !== fieldName));
|
|
106
|
+
},
|
|
107
|
+
[filters, onFiltersChange],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const handleClearAll = useCallback(() => {
|
|
111
|
+
onFiltersChange([]);
|
|
112
|
+
}, [onFiltersChange]);
|
|
113
|
+
|
|
114
|
+
const handleKeyDown = useCallback(
|
|
115
|
+
(e: React.KeyboardEvent) => {
|
|
116
|
+
if (e.key === "Enter") {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
handleAddFilter();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[handleAddFilter],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Render input based on field type
|
|
125
|
+
const renderFilterInput = () => {
|
|
126
|
+
if (!selectedFieldMetadata) return null;
|
|
127
|
+
|
|
128
|
+
switch (selectedFieldMetadata.type) {
|
|
129
|
+
case "boolean":
|
|
130
|
+
return (
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
<Checkbox
|
|
133
|
+
id="boolean-filter-value"
|
|
134
|
+
checked={booleanValue}
|
|
135
|
+
onCheckedChange={(checked) => setBooleanValue(checked === true)}
|
|
136
|
+
/>
|
|
137
|
+
<Label htmlFor="boolean-filter-value">
|
|
138
|
+
{booleanValue ? "true" : "false"}
|
|
139
|
+
</Label>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
case "enum":
|
|
144
|
+
return (
|
|
145
|
+
<Select value={inputValue} onValueChange={setInputValue}>
|
|
146
|
+
<SelectTrigger className="w-full">
|
|
147
|
+
<SelectValue placeholder="値を選択" />
|
|
148
|
+
</SelectTrigger>
|
|
149
|
+
<SelectContent>
|
|
150
|
+
{selectedFieldMetadata.enumValues?.map((enumValue) => (
|
|
151
|
+
<SelectItem key={enumValue} value={enumValue}>
|
|
152
|
+
{enumValue}
|
|
153
|
+
</SelectItem>
|
|
154
|
+
))}
|
|
155
|
+
</SelectContent>
|
|
156
|
+
</Select>
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
case "number":
|
|
160
|
+
return (
|
|
161
|
+
<Input
|
|
162
|
+
type="number"
|
|
163
|
+
placeholder="値を入力"
|
|
164
|
+
value={inputValue}
|
|
165
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
166
|
+
onKeyDown={handleKeyDown}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
default:
|
|
171
|
+
// string, uuid
|
|
172
|
+
return (
|
|
173
|
+
<Input
|
|
174
|
+
type="text"
|
|
175
|
+
placeholder="値を入力"
|
|
176
|
+
value={inputValue}
|
|
177
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
178
|
+
onKeyDown={handleKeyDown}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const activeFilterCount = filters.length;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="relative">
|
|
188
|
+
<CollapsibleTrigger asChild>
|
|
189
|
+
<Button variant="outline" size="sm" className="gap-1">
|
|
190
|
+
{isOpen ? (
|
|
191
|
+
<ChevronDown className="size-4" />
|
|
192
|
+
) : (
|
|
193
|
+
<ChevronRight className="size-4" />
|
|
194
|
+
)}
|
|
195
|
+
<Search className="size-4" />
|
|
196
|
+
検索
|
|
197
|
+
{activeFilterCount > 0 && (
|
|
198
|
+
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">
|
|
199
|
+
{activeFilterCount}
|
|
200
|
+
</Badge>
|
|
201
|
+
)}
|
|
202
|
+
</Button>
|
|
203
|
+
</CollapsibleTrigger>
|
|
204
|
+
|
|
205
|
+
<CollapsibleContent className="absolute top-full left-0 z-10 mt-2">
|
|
206
|
+
<div className="bg-background w-96 rounded-md border p-4 shadow-md">
|
|
207
|
+
<div className="space-y-4">
|
|
208
|
+
<div className="flex items-center justify-between">
|
|
209
|
+
<div className="flex items-center gap-2 text-sm font-medium">
|
|
210
|
+
<Filter className="size-4" />
|
|
211
|
+
検索フィルター
|
|
212
|
+
</div>
|
|
213
|
+
{activeFilterCount > 0 && (
|
|
214
|
+
<Button
|
|
215
|
+
variant="ghost"
|
|
216
|
+
size="sm"
|
|
217
|
+
className="h-auto p-1 text-xs"
|
|
218
|
+
onClick={handleClearAll}
|
|
219
|
+
>
|
|
220
|
+
すべてクリア
|
|
221
|
+
</Button>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Active filters */}
|
|
226
|
+
{filters.length > 0 && (
|
|
227
|
+
<div className="space-y-2">
|
|
228
|
+
<div className="text-muted-foreground text-xs">
|
|
229
|
+
適用中のフィルター (AND)
|
|
230
|
+
</div>
|
|
231
|
+
<div className="flex flex-wrap gap-1">
|
|
232
|
+
{filters.map((filter) => (
|
|
233
|
+
<Badge
|
|
234
|
+
key={filter.field}
|
|
235
|
+
variant="secondary"
|
|
236
|
+
className="flex items-center gap-1 pr-1"
|
|
237
|
+
>
|
|
238
|
+
<span>
|
|
239
|
+
{filter.field}=
|
|
240
|
+
{typeof filter.value === "boolean"
|
|
241
|
+
? filter.value
|
|
242
|
+
? "true"
|
|
243
|
+
: "false"
|
|
244
|
+
: filter.value}
|
|
245
|
+
</span>
|
|
246
|
+
<button
|
|
247
|
+
className="text-muted-foreground hover:text-foreground ml-1"
|
|
248
|
+
onClick={() => handleRemoveFilter(filter.field)}
|
|
249
|
+
>
|
|
250
|
+
<X className="size-3" />
|
|
251
|
+
</button>
|
|
252
|
+
</Badge>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Add new filter */}
|
|
259
|
+
{filterableFields.length > 0 && (
|
|
260
|
+
<div className="space-y-3">
|
|
261
|
+
<div className="text-muted-foreground text-xs">
|
|
262
|
+
フィルターを追加
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* Field selector */}
|
|
266
|
+
<Select value={selectedField} onValueChange={setSelectedField}>
|
|
267
|
+
<SelectTrigger className="w-full">
|
|
268
|
+
<SelectValue placeholder="フィールドを選択" />
|
|
269
|
+
</SelectTrigger>
|
|
270
|
+
<SelectContent>
|
|
271
|
+
{filterableFields.map((field) => (
|
|
272
|
+
<SelectItem key={field.name} value={field.name}>
|
|
273
|
+
{field.name}{" "}
|
|
274
|
+
<span className="text-muted-foreground">
|
|
275
|
+
({field.type})
|
|
276
|
+
</span>
|
|
277
|
+
</SelectItem>
|
|
278
|
+
))}
|
|
279
|
+
</SelectContent>
|
|
280
|
+
</Select>
|
|
281
|
+
|
|
282
|
+
{/* Value input - changes based on field type */}
|
|
283
|
+
{selectedField && (
|
|
284
|
+
<div className="space-y-2">
|
|
285
|
+
{renderFilterInput()}
|
|
286
|
+
<Button
|
|
287
|
+
size="sm"
|
|
288
|
+
onClick={handleAddFilter}
|
|
289
|
+
disabled={
|
|
290
|
+
selectedFieldMetadata?.type !== "boolean" &&
|
|
291
|
+
!inputValue.trim()
|
|
292
|
+
}
|
|
293
|
+
className="w-full"
|
|
294
|
+
>
|
|
295
|
+
<Plus className="mr-1 size-3" />
|
|
296
|
+
追加
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{filterableFields.length === 0 && filters.length === 0 && (
|
|
304
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
305
|
+
フィルター可能なフィールドがありません
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
|
|
309
|
+
{filterableFields.length === 0 && filters.length > 0 && (
|
|
310
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
311
|
+
すべてのフィールドにフィルターが適用されています
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</CollapsibleContent>
|
|
317
|
+
</Collapsible>
|
|
318
|
+
);
|
|
319
|
+
}
|