@izumisy-tailor/tailor-data-viewer 0.1.20 → 0.1.22
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 +55 -2
- package/dist/generator/index.d.mts +54 -31
- package/dist/generator/index.mjs +20 -13
- package/docs/app-shell-module.md +3 -3
- package/docs/compositional-api.md +366 -0
- package/package.json +1 -1
- package/src/app-shell/create-data-view-module.tsx +3 -3
- package/src/app-shell/types.ts +5 -5
- 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 +67 -138
- package/src/component/data-viewer.tsx +11 -11
- package/src/component/hooks/use-column-state.ts +2 -2
- package/src/component/index.ts +33 -1
- package/src/component/refresh-button.tsx +20 -0
- package/src/component/search-filter.tsx +19 -24
- package/src/component/single-record-tab-content.test.tsx +10 -10
- package/src/component/single-record-tab-content.tsx +62 -21
- package/src/component/view-save-load.tsx +13 -17
- package/src/generator/metadata-generator.ts +100 -67
|
@@ -3,15 +3,18 @@ import { Alert, AlertDescription } from "./ui/alert";
|
|
|
3
3
|
import type {
|
|
4
4
|
TableMetadata,
|
|
5
5
|
TableMetadataMap,
|
|
6
|
-
FieldMetadata,
|
|
7
6
|
} from "../generator/metadata-generator";
|
|
8
|
-
import { formatFieldValue } from "../graphql/query-builder";
|
|
9
7
|
import { TableSelector } from "./table-selector";
|
|
10
8
|
import { DataTable } from "./data-table";
|
|
11
9
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
12
10
|
import { Pagination } from "./pagination";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { ColumnSelector } from "./column-selector";
|
|
12
|
+
import { SearchFilterForm } from "./search-filter";
|
|
13
|
+
import { ViewSave } from "./view-save-load";
|
|
14
|
+
import { CsvButton } from "./csv-button";
|
|
15
|
+
import { RefreshButton } from "./refresh-button";
|
|
16
|
+
import { DataViewerProvider } from "./contexts";
|
|
17
|
+
import { TableDataProvider, useTableDataContext } from "./contexts";
|
|
15
18
|
import type { InitialQuery } from "./data-viewer";
|
|
16
19
|
import type { SearchFilters } from "./types";
|
|
17
20
|
import type { SavedView } from "./saved-view-context";
|
|
@@ -22,8 +25,8 @@ interface DataViewTabContentProps {
|
|
|
22
25
|
initialTable?: TableMetadata;
|
|
23
26
|
onTableConfirm: (table: TableMetadata) => void;
|
|
24
27
|
isTableLocked: boolean;
|
|
25
|
-
/** All table metadata
|
|
26
|
-
|
|
28
|
+
/** All table metadata (generated from TailorDB schema) */
|
|
29
|
+
metadata: TableMetadataMap;
|
|
27
30
|
/** Initial query condition for filtering */
|
|
28
31
|
initialQuery?: InitialQuery;
|
|
29
32
|
/** Initial saved view to load */
|
|
@@ -51,7 +54,7 @@ export function DataViewTabContent({
|
|
|
51
54
|
initialTable,
|
|
52
55
|
onTableConfirm,
|
|
53
56
|
isTableLocked,
|
|
54
|
-
|
|
57
|
+
metadata,
|
|
55
58
|
initialQuery,
|
|
56
59
|
initialView,
|
|
57
60
|
onOpenAsSheet,
|
|
@@ -86,94 +89,11 @@ export function DataViewTabContent({
|
|
|
86
89
|
}, [initialQuery, initialTable, initialView]);
|
|
87
90
|
|
|
88
91
|
// Search filters (includes initialQuery as a regular filter that can be edited/removed)
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Memoize fields to avoid creating new array reference on every render when selectedTable is null
|
|
93
|
-
const fields = useMemo(
|
|
94
|
-
() => selectedTable?.fields ?? [],
|
|
95
|
-
[selectedTable?.fields],
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
// Column state (includes relation selection)
|
|
99
|
-
// If initialView is provided, use its column selections as defaults
|
|
100
|
-
const columnState = useColumnState(
|
|
101
|
-
fields,
|
|
102
|
-
selectedTable?.relations,
|
|
103
|
-
initialView?.selectedFields,
|
|
104
|
-
initialView?.selectedRelations,
|
|
105
|
-
initialView?.expandedRelationFields,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
// Table data fetching (pass selectedRelations for FK field inclusion and search filters)
|
|
109
|
-
const tableData = useTableData(
|
|
110
|
-
appUri,
|
|
111
|
-
selectedTable,
|
|
112
|
-
columnState.selectedFields,
|
|
113
|
-
columnState.selectedRelations,
|
|
114
|
-
searchFilters,
|
|
115
|
-
tableMetadataMap,
|
|
116
|
-
columnState.expandedRelationFields,
|
|
92
|
+
const initialFilters = useMemo(
|
|
93
|
+
() => getInitialFilters(),
|
|
94
|
+
[getInitialFilters],
|
|
117
95
|
);
|
|
118
96
|
|
|
119
|
-
// Handle filter changes - reset pagination when filters change
|
|
120
|
-
const handleFiltersChange = useCallback(
|
|
121
|
-
(newFilters: SearchFilters) => {
|
|
122
|
-
setSearchFilters(newFilters);
|
|
123
|
-
tableData.resetPagination();
|
|
124
|
-
},
|
|
125
|
-
[tableData],
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
// CSV Download handler
|
|
129
|
-
const handleDownloadCsv = useCallback(() => {
|
|
130
|
-
if (!selectedTable || tableData.data.length === 0) return;
|
|
131
|
-
|
|
132
|
-
const selectedFieldsMetadata = selectedTable.fields.filter(
|
|
133
|
-
(f: FieldMetadata) => columnState.selectedFields.includes(f.name),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
// Create CSV header
|
|
137
|
-
const headers = selectedFieldsMetadata.map((f: FieldMetadata) => f.name);
|
|
138
|
-
|
|
139
|
-
// Create CSV rows
|
|
140
|
-
const rows = tableData.data.map((row: Record<string, unknown>) =>
|
|
141
|
-
selectedFieldsMetadata.map((field: FieldMetadata) => {
|
|
142
|
-
const value = row[field.name];
|
|
143
|
-
const formattedValue = formatFieldValue(value, field);
|
|
144
|
-
// Escape double quotes and wrap in quotes if contains comma, newline, or quotes
|
|
145
|
-
const stringValue = String(formattedValue ?? "");
|
|
146
|
-
if (
|
|
147
|
-
stringValue.includes(",") ||
|
|
148
|
-
stringValue.includes("\n") ||
|
|
149
|
-
stringValue.includes('"')
|
|
150
|
-
) {
|
|
151
|
-
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
152
|
-
}
|
|
153
|
-
return stringValue;
|
|
154
|
-
}),
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
// Combine headers and rows
|
|
158
|
-
const csvContent = [
|
|
159
|
-
headers.join(","),
|
|
160
|
-
...rows.map((r: string[]) => r.join(",")),
|
|
161
|
-
].join("\n");
|
|
162
|
-
|
|
163
|
-
// Create and download file
|
|
164
|
-
const blob = new Blob(["\uFEFF" + csvContent], {
|
|
165
|
-
type: "text/csv;charset=utf-8;",
|
|
166
|
-
});
|
|
167
|
-
const url = URL.createObjectURL(blob);
|
|
168
|
-
const link = document.createElement("a");
|
|
169
|
-
link.href = url;
|
|
170
|
-
link.download = `${selectedTable.name}_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
171
|
-
document.body.appendChild(link);
|
|
172
|
-
link.click();
|
|
173
|
-
document.body.removeChild(link);
|
|
174
|
-
URL.revokeObjectURL(url);
|
|
175
|
-
}, [selectedTable, tableData.data, columnState.selectedFields]);
|
|
176
|
-
|
|
177
97
|
const handleTableSelect = useCallback(
|
|
178
98
|
(table: TableMetadata) => {
|
|
179
99
|
setSelectedTable(table);
|
|
@@ -204,6 +124,52 @@ export function DataViewTabContent({
|
|
|
204
124
|
);
|
|
205
125
|
}
|
|
206
126
|
|
|
127
|
+
return (
|
|
128
|
+
<DataViewerProvider
|
|
129
|
+
appUri={appUri}
|
|
130
|
+
tableName={selectedTable.name}
|
|
131
|
+
metadata={metadata}
|
|
132
|
+
initialData={{
|
|
133
|
+
filters: initialFilters,
|
|
134
|
+
selectedFields: initialView?.selectedFields,
|
|
135
|
+
selectedRelations: initialView?.selectedRelations,
|
|
136
|
+
expandedRelationFields: initialView?.expandedRelationFields,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<TableDataProvider>
|
|
140
|
+
<DataViewTabContentInner
|
|
141
|
+
selectedTable={selectedTable}
|
|
142
|
+
onOpenAsSheet={onOpenAsSheet}
|
|
143
|
+
onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
|
|
144
|
+
/>
|
|
145
|
+
</TableDataProvider>
|
|
146
|
+
</DataViewerProvider>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface DataViewTabContentInnerProps {
|
|
151
|
+
selectedTable: TableMetadata;
|
|
152
|
+
onOpenAsSheet?: (
|
|
153
|
+
targetTableName: string,
|
|
154
|
+
filterField: string,
|
|
155
|
+
filterValue: string,
|
|
156
|
+
) => void;
|
|
157
|
+
onOpenSingleRecordAsSheet?: (
|
|
158
|
+
targetTableName: string,
|
|
159
|
+
recordId: string,
|
|
160
|
+
) => void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Inner component that uses context
|
|
165
|
+
*/
|
|
166
|
+
function DataViewTabContentInner({
|
|
167
|
+
selectedTable,
|
|
168
|
+
onOpenAsSheet,
|
|
169
|
+
onOpenSingleRecordAsSheet,
|
|
170
|
+
}: DataViewTabContentInnerProps) {
|
|
171
|
+
const tableData = useTableDataContext();
|
|
172
|
+
|
|
207
173
|
return (
|
|
208
174
|
<div className="space-y-4">
|
|
209
175
|
{/* Table name and description */}
|
|
@@ -217,40 +183,14 @@ export function DataViewTabContent({
|
|
|
217
183
|
</div>
|
|
218
184
|
|
|
219
185
|
{/* Operations row: column selector, search filter, etc. */}
|
|
220
|
-
<DataTableToolbar
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
selectedRelations: columnState.selectedRelations,
|
|
229
|
-
onToggleRelation: columnState.toggleRelation,
|
|
230
|
-
tableMetadataMap: tableMetadataMap,
|
|
231
|
-
expandedRelationFields: columnState.expandedRelationFields,
|
|
232
|
-
onToggleExpandedRelationField:
|
|
233
|
-
columnState.toggleExpandedRelationField,
|
|
234
|
-
isExpandedRelationFieldSelected:
|
|
235
|
-
columnState.isExpandedRelationFieldSelected,
|
|
236
|
-
}}
|
|
237
|
-
searchFilter={{
|
|
238
|
-
fields: selectedTable.fields,
|
|
239
|
-
filters: searchFilters,
|
|
240
|
-
onFiltersChange: handleFiltersChange,
|
|
241
|
-
}}
|
|
242
|
-
viewSave={{
|
|
243
|
-
tableName: selectedTable.name,
|
|
244
|
-
filters: searchFilters,
|
|
245
|
-
selectedFields: columnState.selectedFields,
|
|
246
|
-
selectedRelations: columnState.selectedRelations,
|
|
247
|
-
expandedRelationFields: columnState.expandedRelationFields,
|
|
248
|
-
}}
|
|
249
|
-
onDownloadCsv={handleDownloadCsv}
|
|
250
|
-
onRefresh={() => tableData.refetch()}
|
|
251
|
-
loading={tableData.loading}
|
|
252
|
-
csvDisabled={tableData.data.length === 0}
|
|
253
|
-
/>
|
|
186
|
+
<DataTableToolbar>
|
|
187
|
+
<ColumnSelector />
|
|
188
|
+
<SearchFilterForm />
|
|
189
|
+
<ViewSave />
|
|
190
|
+
<div className="flex-1" />
|
|
191
|
+
<CsvButton />
|
|
192
|
+
<RefreshButton />
|
|
193
|
+
</DataTableToolbar>
|
|
254
194
|
|
|
255
195
|
{/* Error Display */}
|
|
256
196
|
{tableData.error && (
|
|
@@ -263,19 +203,8 @@ export function DataViewTabContent({
|
|
|
263
203
|
|
|
264
204
|
{/* Data Table */}
|
|
265
205
|
<DataTable
|
|
266
|
-
data={tableData.data}
|
|
267
|
-
fields={selectedTable.fields}
|
|
268
|
-
selectedFields={columnState.selectedFields}
|
|
269
|
-
sortState={tableData.sortState}
|
|
270
|
-
onSort={tableData.setSort}
|
|
271
|
-
loading={tableData.loading}
|
|
272
|
-
tableMetadata={selectedTable}
|
|
273
|
-
tableMetadataMap={tableMetadataMap}
|
|
274
|
-
appUri={appUri}
|
|
275
|
-
selectedRelations={columnState.selectedRelations}
|
|
276
206
|
onOpenAsSheet={onOpenAsSheet}
|
|
277
207
|
onOpenSingleRecordAsSheet={onOpenSingleRecordAsSheet}
|
|
278
|
-
expandedRelationFields={columnState.expandedRelationFields}
|
|
279
208
|
/>
|
|
280
209
|
|
|
281
210
|
{/* Pagination */}
|
|
@@ -11,7 +11,7 @@ import { SingleRecordTabContent } from "./single-record-tab-content";
|
|
|
11
11
|
import { useSavedViews, type SavedView } from "./saved-view-context";
|
|
12
12
|
|
|
13
13
|
interface DataViewerProps {
|
|
14
|
-
|
|
14
|
+
metadata: TableMetadataMap;
|
|
15
15
|
appUri: string;
|
|
16
16
|
/** Initial view ID to load on mount */
|
|
17
17
|
initialViewId?: string;
|
|
@@ -52,11 +52,11 @@ function generateTabId(): string {
|
|
|
52
52
|
* Similar to Excel sheets - each tab has its own table view
|
|
53
53
|
*/
|
|
54
54
|
export function DataViewer({
|
|
55
|
-
|
|
55
|
+
metadata,
|
|
56
56
|
appUri,
|
|
57
57
|
initialViewId,
|
|
58
58
|
}: DataViewerProps) {
|
|
59
|
-
const allTables = Object.values(
|
|
59
|
+
const allTables = Object.values(metadata);
|
|
60
60
|
const { getViewById, isLoading } = useSavedViews();
|
|
61
61
|
|
|
62
62
|
// Track if we've already initialized from the saved view
|
|
@@ -84,7 +84,7 @@ export function DataViewer({
|
|
|
84
84
|
|
|
85
85
|
const view = getViewById(initialViewId);
|
|
86
86
|
if (view) {
|
|
87
|
-
const table =
|
|
87
|
+
const table = metadata[view.tableName];
|
|
88
88
|
if (table) {
|
|
89
89
|
initializedFromViewRef.current = true;
|
|
90
90
|
const newTab: Tab = {
|
|
@@ -98,7 +98,7 @@ export function DataViewer({
|
|
|
98
98
|
setActiveTabId(newTab.id);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
}, [initialViewId, isLoading, getViewById,
|
|
101
|
+
}, [initialViewId, isLoading, getViewById, metadata]);
|
|
102
102
|
|
|
103
103
|
const handleAddTab = useCallback(() => {
|
|
104
104
|
const newTab: Tab = {
|
|
@@ -154,7 +154,7 @@ export function DataViewer({
|
|
|
154
154
|
*/
|
|
155
155
|
const handleOpenAsSheet = useCallback(
|
|
156
156
|
(targetTableName: string, filterField: string, filterValue: string) => {
|
|
157
|
-
const targetTable =
|
|
157
|
+
const targetTable = metadata[targetTableName];
|
|
158
158
|
if (!targetTable) return;
|
|
159
159
|
|
|
160
160
|
const newTab: Tab = {
|
|
@@ -170,7 +170,7 @@ export function DataViewer({
|
|
|
170
170
|
setTabs((prev) => [...prev, newTab]);
|
|
171
171
|
setActiveTabId(newTab.id);
|
|
172
172
|
},
|
|
173
|
-
[
|
|
173
|
+
[metadata],
|
|
174
174
|
);
|
|
175
175
|
|
|
176
176
|
/**
|
|
@@ -178,7 +178,7 @@ export function DataViewer({
|
|
|
178
178
|
*/
|
|
179
179
|
const handleOpenSingleRecordAsSheet = useCallback(
|
|
180
180
|
(targetTableName: string, recordId: string) => {
|
|
181
|
-
const targetTable =
|
|
181
|
+
const targetTable = metadata[targetTableName];
|
|
182
182
|
if (!targetTable) return;
|
|
183
183
|
|
|
184
184
|
const newTab: Tab = {
|
|
@@ -193,7 +193,7 @@ export function DataViewer({
|
|
|
193
193
|
setTabs((prev) => [...prev, newTab]);
|
|
194
194
|
setActiveTabId(newTab.id);
|
|
195
195
|
},
|
|
196
|
-
[
|
|
196
|
+
[metadata],
|
|
197
197
|
);
|
|
198
198
|
|
|
199
199
|
return (
|
|
@@ -250,7 +250,7 @@ export function DataViewer({
|
|
|
250
250
|
{tab.singleRecordMode && tab.table ? (
|
|
251
251
|
<SingleRecordTabContent
|
|
252
252
|
tableMetadata={tab.table}
|
|
253
|
-
|
|
253
|
+
metadata={metadata}
|
|
254
254
|
appUri={appUri}
|
|
255
255
|
recordId={tab.singleRecordMode.recordId}
|
|
256
256
|
onOpenAsSheet={handleOpenAsSheet}
|
|
@@ -263,7 +263,7 @@ export function DataViewer({
|
|
|
263
263
|
initialTable={tab.table ?? undefined}
|
|
264
264
|
onTableConfirm={(table) => handleTableConfirm(tab.id, table)}
|
|
265
265
|
isTableLocked={tab.isLocked}
|
|
266
|
-
|
|
266
|
+
metadata={metadata}
|
|
267
267
|
initialQuery={tab.initialQuery}
|
|
268
268
|
initialView={tab.initialView}
|
|
269
269
|
onOpenAsSheet={handleOpenAsSheet}
|
|
@@ -51,8 +51,8 @@ export interface ColumnState {
|
|
|
51
51
|
* Manage column visibility state (fields and relations)
|
|
52
52
|
*/
|
|
53
53
|
export function useColumnState(
|
|
54
|
-
fields: FieldMetadata[],
|
|
55
|
-
relations?: RelationMetadata[],
|
|
54
|
+
fields: readonly FieldMetadata[],
|
|
55
|
+
relations?: readonly RelationMetadata[],
|
|
56
56
|
defaultFields?: string[],
|
|
57
57
|
defaultRelations?: string[],
|
|
58
58
|
defaultExpandedRelationFields?: ExpandedRelationFields,
|
package/src/component/index.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
|
+
// Main DataViewer component
|
|
1
2
|
export { DataViewer } from "./data-viewer";
|
|
2
3
|
export type { InitialQuery } from "./data-viewer";
|
|
4
|
+
|
|
5
|
+
// Toolbar component (children-based)
|
|
3
6
|
export { DataTableToolbar } from "./data-table-toolbar";
|
|
4
|
-
|
|
7
|
+
|
|
8
|
+
// Saved view context
|
|
5
9
|
export { useSavedViews, SavedViewProvider } from "./saved-view-context";
|
|
6
10
|
export type { SavedView, SaveViewInput } from "./saved-view-context";
|
|
11
|
+
|
|
12
|
+
// Context providers and hooks
|
|
13
|
+
export { DataViewerProvider, useDataViewer } from "./contexts";
|
|
14
|
+
export type {
|
|
15
|
+
DataViewerContextValue,
|
|
16
|
+
DataViewerProviderProps,
|
|
17
|
+
DataViewerInitialData,
|
|
18
|
+
} from "./contexts";
|
|
19
|
+
export { TableDataProvider, useTableDataContext } from "./contexts";
|
|
20
|
+
export type { TableDataContextValue, TableDataProviderProps } from "./contexts";
|
|
21
|
+
export { ToolbarProvider, useToolbar } from "./contexts";
|
|
22
|
+
export type {
|
|
23
|
+
ToolbarContextValue,
|
|
24
|
+
ToolbarProviderProps,
|
|
25
|
+
ToolbarActivePanel,
|
|
26
|
+
} from "./contexts";
|
|
27
|
+
|
|
28
|
+
// Individual components (context-only, must be used within DataViewerProvider)
|
|
29
|
+
export { ColumnSelector } from "./column-selector";
|
|
30
|
+
export { SearchFilterForm } from "./search-filter";
|
|
31
|
+
export { ViewSave } from "./view-save-load";
|
|
32
|
+
export { DataTable } from "./data-table";
|
|
33
|
+
export type { DataTableProps } from "./data-table";
|
|
34
|
+
export { CsvButton } from "./csv-button";
|
|
35
|
+
export { RefreshButton } from "./refresh-button";
|
|
36
|
+
|
|
37
|
+
// Types
|
|
38
|
+
export type { SearchFilter, SearchFilters } from "./types";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { RefreshCw } from "lucide-react";
|
|
2
|
+
import { Button } from "./ui/button";
|
|
3
|
+
import { useDataViewer } from "./contexts";
|
|
4
|
+
import { useTableDataContext } from "./contexts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Refresh button
|
|
8
|
+
* Must be used within DataViewer.Root and TableDataProvider context.
|
|
9
|
+
*/
|
|
10
|
+
export function RefreshButton() {
|
|
11
|
+
const { refetch } = useDataViewer();
|
|
12
|
+
const { loading } = useTableDataContext();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Button variant="outline" size="sm" onClick={refetch} disabled={loading}>
|
|
16
|
+
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
17
|
+
更新
|
|
18
|
+
</Button>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -18,17 +18,9 @@ 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
|
-
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 } from "./types";
|
|
22
|
+
import { useDataViewer } from "./contexts";
|
|
23
|
+
import { useToolbar } from "./contexts";
|
|
32
24
|
|
|
33
25
|
// Filterable field types
|
|
34
26
|
const FILTERABLE_TYPES = [
|
|
@@ -49,14 +41,17 @@ function isFilterableField(field: FieldMetadata): boolean {
|
|
|
49
41
|
/**
|
|
50
42
|
* Search filter form component
|
|
51
43
|
* Allows adding multiple AND filters for string/number/boolean/enum fields
|
|
44
|
+
* Must be used within DataViewer.Root and DataViewer.Toolbar context
|
|
52
45
|
*/
|
|
53
|
-
export function SearchFilterForm({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
export function SearchFilterForm() {
|
|
47
|
+
const { tableMetadata, filters, setFilters } = useDataViewer();
|
|
48
|
+
const { activePanel, setActivePanel } = useToolbar();
|
|
49
|
+
|
|
50
|
+
const fields = tableMetadata?.fields ?? [];
|
|
51
|
+
const open = activePanel === "search";
|
|
52
|
+
const onOpenChange = (isOpen: boolean) =>
|
|
53
|
+
setActivePanel(isOpen ? "search" : null);
|
|
54
|
+
|
|
60
55
|
const [selectedField, setSelectedField] = useState<string>("");
|
|
61
56
|
const [inputValue, setInputValue] = useState<string>("");
|
|
62
57
|
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
@@ -85,7 +80,7 @@ export function SearchFilterForm({
|
|
|
85
80
|
enumValues: selectedFieldMetadata.enumValues,
|
|
86
81
|
};
|
|
87
82
|
|
|
88
|
-
|
|
83
|
+
setFilters([...filters, newFilter]);
|
|
89
84
|
setSelectedField("");
|
|
90
85
|
setInputValue("");
|
|
91
86
|
setBooleanValue(false);
|
|
@@ -95,19 +90,19 @@ export function SearchFilterForm({
|
|
|
95
90
|
inputValue,
|
|
96
91
|
booleanValue,
|
|
97
92
|
filters,
|
|
98
|
-
|
|
93
|
+
setFilters,
|
|
99
94
|
]);
|
|
100
95
|
|
|
101
96
|
const handleRemoveFilter = useCallback(
|
|
102
97
|
(fieldName: string) => {
|
|
103
|
-
|
|
98
|
+
setFilters(filters.filter((f) => f.field !== fieldName));
|
|
104
99
|
},
|
|
105
|
-
[filters,
|
|
100
|
+
[filters, setFilters],
|
|
106
101
|
);
|
|
107
102
|
|
|
108
103
|
const handleClearAll = useCallback(() => {
|
|
109
|
-
|
|
110
|
-
}, [
|
|
104
|
+
setFilters([]);
|
|
105
|
+
}, [setFilters]);
|
|
111
106
|
|
|
112
107
|
const handleKeyDown = useCallback(
|
|
113
108
|
(e: React.KeyboardEvent) => {
|
|
@@ -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}
|