@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type {
|
|
10
|
+
TableMetadata,
|
|
11
|
+
TableMetadataMap,
|
|
12
|
+
ExpandedRelationFields,
|
|
13
|
+
} from "../../generator/metadata-generator";
|
|
14
|
+
import { useColumnState } from "../hooks/use-column-state";
|
|
15
|
+
import type { SearchFilters } from "../types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Helper type to extract field names from a table in metadata
|
|
19
|
+
* Supports both `as const` metadata (literal types) and regular metadata (string type)
|
|
20
|
+
*/
|
|
21
|
+
type ExtractFieldNames<
|
|
22
|
+
TMetadata extends TableMetadataMap,
|
|
23
|
+
TTableName extends keyof TMetadata,
|
|
24
|
+
> = TMetadata[TTableName] extends { fields: readonly { name: infer TName }[] }
|
|
25
|
+
? TName extends string
|
|
26
|
+
? TName
|
|
27
|
+
: string
|
|
28
|
+
: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Helper type to extract relation field names from a table in metadata
|
|
32
|
+
* Supports both `as const` metadata (literal types) and regular metadata (string type)
|
|
33
|
+
*/
|
|
34
|
+
type ExtractRelationNames<
|
|
35
|
+
TMetadata extends TableMetadataMap,
|
|
36
|
+
TTableName extends keyof TMetadata,
|
|
37
|
+
> = TMetadata[TTableName] extends {
|
|
38
|
+
relations: readonly { fieldName: infer TName }[];
|
|
39
|
+
}
|
|
40
|
+
? TName extends string
|
|
41
|
+
? TName
|
|
42
|
+
: string
|
|
43
|
+
: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Helper type to extract expanded relation fields configuration
|
|
47
|
+
* Maps relation names to arrays of field names from related tables
|
|
48
|
+
*/
|
|
49
|
+
type ExtractExpandedRelationFields<
|
|
50
|
+
TMetadata extends TableMetadataMap,
|
|
51
|
+
TTableName extends keyof TMetadata,
|
|
52
|
+
> = TMetadata[TTableName] extends {
|
|
53
|
+
relations: readonly {
|
|
54
|
+
fieldName: infer TFieldName;
|
|
55
|
+
targetTable: infer TTargetTable;
|
|
56
|
+
}[];
|
|
57
|
+
}
|
|
58
|
+
? {
|
|
59
|
+
[K in TFieldName & string]?: TTargetTable extends keyof TMetadata
|
|
60
|
+
? ExtractFieldNames<TMetadata, TTargetTable & keyof TMetadata>[]
|
|
61
|
+
: string[];
|
|
62
|
+
}
|
|
63
|
+
: Record<string, string[]>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* DataViewer context value interface
|
|
67
|
+
* Contains metadata, column state, filters, and actions
|
|
68
|
+
*/
|
|
69
|
+
export interface DataViewerContextValue {
|
|
70
|
+
// Metadata
|
|
71
|
+
tableMetadata: TableMetadata | null;
|
|
72
|
+
metadata: TableMetadataMap;
|
|
73
|
+
appUri: string;
|
|
74
|
+
|
|
75
|
+
// Column state
|
|
76
|
+
selectedFields: string[];
|
|
77
|
+
toggleField: (fieldName: string) => void;
|
|
78
|
+
selectAllFields: () => void;
|
|
79
|
+
deselectAllFields: () => void;
|
|
80
|
+
selectedRelations: string[];
|
|
81
|
+
toggleRelation: (fieldName: string) => void;
|
|
82
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
83
|
+
toggleExpandedRelationField: (
|
|
84
|
+
relationFieldName: string,
|
|
85
|
+
fieldName: string,
|
|
86
|
+
) => void;
|
|
87
|
+
isExpandedRelationFieldSelected: (
|
|
88
|
+
relationFieldName: string,
|
|
89
|
+
fieldName: string,
|
|
90
|
+
) => boolean;
|
|
91
|
+
setColumns: (fields: string[], relations: string[]) => void;
|
|
92
|
+
|
|
93
|
+
// Filters
|
|
94
|
+
filters: SearchFilters;
|
|
95
|
+
setFilters: (filters: SearchFilters) => void;
|
|
96
|
+
|
|
97
|
+
// Actions (set by TableDataProvider)
|
|
98
|
+
refetch: () => Promise<void>;
|
|
99
|
+
|
|
100
|
+
// Internal setters for TableDataProvider
|
|
101
|
+
_setRefetch: (fn: () => Promise<void>) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DataViewerContext = createContext<DataViewerContextValue | null>(null);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initial data for DataViewer (type-safe version)
|
|
108
|
+
* When metadata is provided with `as const`, field and relation names are type-checked
|
|
109
|
+
*/
|
|
110
|
+
export interface DataViewerInitialData<
|
|
111
|
+
TMetadata extends TableMetadataMap = TableMetadataMap,
|
|
112
|
+
TTableName extends keyof TMetadata = keyof TMetadata,
|
|
113
|
+
> {
|
|
114
|
+
/** Initial filters */
|
|
115
|
+
filters?: SearchFilters;
|
|
116
|
+
/** Initial selected fields (type-safe when metadata is `as const`) */
|
|
117
|
+
selectedFields?: ExtractFieldNames<TMetadata, TTableName>[];
|
|
118
|
+
/** Initial selected relations (type-safe when metadata is `as const`) */
|
|
119
|
+
selectedRelations?: ExtractRelationNames<TMetadata, TTableName>[];
|
|
120
|
+
/** Initial expanded relation fields (type-safe when metadata is `as const`) */
|
|
121
|
+
expandedRelationFields?: ExtractExpandedRelationFields<TMetadata, TTableName>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Props for DataViewerProvider (type-safe version)
|
|
126
|
+
* When metadata is provided with `as const`, tableName and initialData are type-checked
|
|
127
|
+
*/
|
|
128
|
+
export interface DataViewerProviderProps<
|
|
129
|
+
TMetadata extends TableMetadataMap = TableMetadataMap,
|
|
130
|
+
TTableName extends keyof TMetadata & string = keyof TMetadata & string,
|
|
131
|
+
> {
|
|
132
|
+
children: ReactNode;
|
|
133
|
+
/** Table name to display (type-safe when metadata is `as const`) */
|
|
134
|
+
tableName: TTableName;
|
|
135
|
+
/** All table metadata (generated from TailorDB schema) */
|
|
136
|
+
metadata: TMetadata;
|
|
137
|
+
/** App URI for GraphQL endpoint */
|
|
138
|
+
appUri: string;
|
|
139
|
+
/** Initial data for filters, selected fields, and relations (type-safe when metadata is `as const`) */
|
|
140
|
+
initialData?: DataViewerInitialData<TMetadata, TTableName>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* DataViewer context provider
|
|
145
|
+
* Provides metadata, column state, filters, and actions to child components
|
|
146
|
+
*/
|
|
147
|
+
export function DataViewerProvider<
|
|
148
|
+
TMetadata extends TableMetadataMap,
|
|
149
|
+
TTableName extends keyof TMetadata & string,
|
|
150
|
+
>({
|
|
151
|
+
children,
|
|
152
|
+
tableName,
|
|
153
|
+
metadata,
|
|
154
|
+
appUri,
|
|
155
|
+
initialData,
|
|
156
|
+
}: DataViewerProviderProps<TMetadata, TTableName>) {
|
|
157
|
+
// Get table metadata from the map
|
|
158
|
+
const tableMetadata = metadata[tableName] ?? null;
|
|
159
|
+
|
|
160
|
+
// Filters state
|
|
161
|
+
const [filters, setFilters] = useState<SearchFilters>(
|
|
162
|
+
initialData?.filters ?? [],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Column state
|
|
166
|
+
const columnState = useColumnState(
|
|
167
|
+
tableMetadata?.fields ?? [],
|
|
168
|
+
tableMetadata?.relations,
|
|
169
|
+
initialData?.selectedFields as string[] | undefined,
|
|
170
|
+
initialData?.selectedRelations as string[] | undefined,
|
|
171
|
+
initialData?.expandedRelationFields as ExpandedRelationFields | undefined,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Action stubs (will be set by TableDataProvider)
|
|
175
|
+
const [refetch, setRefetchFn] = useState<() => Promise<void>>(
|
|
176
|
+
() => async () => {},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const _setRefetch = useCallback((fn: () => Promise<void>) => {
|
|
180
|
+
setRefetchFn(() => fn);
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
const value = useMemo<DataViewerContextValue>(
|
|
184
|
+
() => ({
|
|
185
|
+
tableMetadata,
|
|
186
|
+
metadata,
|
|
187
|
+
appUri,
|
|
188
|
+
selectedFields: columnState.selectedFields,
|
|
189
|
+
toggleField: columnState.toggleField,
|
|
190
|
+
selectAllFields: columnState.selectAll,
|
|
191
|
+
deselectAllFields: columnState.deselectAll,
|
|
192
|
+
selectedRelations: columnState.selectedRelations,
|
|
193
|
+
toggleRelation: columnState.toggleRelation,
|
|
194
|
+
expandedRelationFields: columnState.expandedRelationFields,
|
|
195
|
+
toggleExpandedRelationField: columnState.toggleExpandedRelationField,
|
|
196
|
+
isExpandedRelationFieldSelected:
|
|
197
|
+
columnState.isExpandedRelationFieldSelected,
|
|
198
|
+
setColumns: columnState.setColumns,
|
|
199
|
+
filters,
|
|
200
|
+
setFilters,
|
|
201
|
+
refetch,
|
|
202
|
+
_setRefetch,
|
|
203
|
+
}),
|
|
204
|
+
[
|
|
205
|
+
tableMetadata,
|
|
206
|
+
metadata,
|
|
207
|
+
appUri,
|
|
208
|
+
columnState.selectedFields,
|
|
209
|
+
columnState.toggleField,
|
|
210
|
+
columnState.selectAll,
|
|
211
|
+
columnState.deselectAll,
|
|
212
|
+
columnState.selectedRelations,
|
|
213
|
+
columnState.toggleRelation,
|
|
214
|
+
columnState.expandedRelationFields,
|
|
215
|
+
columnState.toggleExpandedRelationField,
|
|
216
|
+
columnState.isExpandedRelationFieldSelected,
|
|
217
|
+
columnState.setColumns,
|
|
218
|
+
filters,
|
|
219
|
+
refetch,
|
|
220
|
+
_setRefetch,
|
|
221
|
+
],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<DataViewerContext.Provider value={value}>
|
|
226
|
+
{children}
|
|
227
|
+
</DataViewerContext.Provider>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Hook to access DataViewer context
|
|
233
|
+
* @throws Error if used outside DataViewerProvider
|
|
234
|
+
*/
|
|
235
|
+
export function useDataViewer(): DataViewerContextValue {
|
|
236
|
+
const context = useContext(DataViewerContext);
|
|
237
|
+
if (!context) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
"useDataViewer must be used within DataViewer.Root. " +
|
|
240
|
+
"Wrap your component with <DataViewer.Root>.",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return context;
|
|
244
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { DataViewerProvider, useDataViewer } from "./data-viewer-context";
|
|
2
|
+
export type {
|
|
3
|
+
DataViewerContextValue,
|
|
4
|
+
DataViewerProviderProps,
|
|
5
|
+
DataViewerInitialData,
|
|
6
|
+
} from "./data-viewer-context";
|
|
7
|
+
|
|
8
|
+
export { TableDataProvider, useTableDataContext } from "./table-data-context";
|
|
9
|
+
export type {
|
|
10
|
+
TableDataContextValue,
|
|
11
|
+
TableDataProviderProps,
|
|
12
|
+
} from "./table-data-context";
|
|
13
|
+
|
|
14
|
+
export { ToolbarProvider, useToolbar } from "./toolbar-context";
|
|
15
|
+
export type {
|
|
16
|
+
ToolbarContextValue,
|
|
17
|
+
ToolbarProviderProps,
|
|
18
|
+
ActivePanel as ToolbarActivePanel,
|
|
19
|
+
} from "./toolbar-context";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import {
|
|
9
|
+
useTableData,
|
|
10
|
+
type SortState,
|
|
11
|
+
type PaginationState,
|
|
12
|
+
} from "../hooks/use-table-data";
|
|
13
|
+
import { useDataViewer } from "./data-viewer-context";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TableData context value interface
|
|
17
|
+
* Contains data fetching results, loading state, sort, and pagination
|
|
18
|
+
*/
|
|
19
|
+
export interface TableDataContextValue {
|
|
20
|
+
data: Record<string, unknown>[];
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: Error | null;
|
|
23
|
+
sortState: SortState | null;
|
|
24
|
+
setSort: (field: string) => void;
|
|
25
|
+
pagination: PaginationState;
|
|
26
|
+
hasPreviousPage: boolean;
|
|
27
|
+
nextPage: () => void;
|
|
28
|
+
previousPage: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TableDataContext = createContext<TableDataContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
export interface TableDataProviderProps {
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* TableData context provider
|
|
39
|
+
* Fetches data using DataViewerContext and provides data state to children
|
|
40
|
+
*/
|
|
41
|
+
export function TableDataProvider({ children }: TableDataProviderProps) {
|
|
42
|
+
const {
|
|
43
|
+
appUri,
|
|
44
|
+
tableMetadata,
|
|
45
|
+
metadata,
|
|
46
|
+
selectedFields,
|
|
47
|
+
selectedRelations,
|
|
48
|
+
expandedRelationFields,
|
|
49
|
+
filters,
|
|
50
|
+
_setRefetch,
|
|
51
|
+
} = useDataViewer();
|
|
52
|
+
|
|
53
|
+
// Fetch table data
|
|
54
|
+
const tableData = useTableData(
|
|
55
|
+
appUri,
|
|
56
|
+
tableMetadata,
|
|
57
|
+
selectedFields,
|
|
58
|
+
selectedRelations,
|
|
59
|
+
filters,
|
|
60
|
+
metadata,
|
|
61
|
+
expandedRelationFields,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Register refetch action in DataViewerContext
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
_setRefetch(tableData.refetch);
|
|
67
|
+
}, [_setRefetch, tableData.refetch]);
|
|
68
|
+
|
|
69
|
+
const value = useMemo<TableDataContextValue>(
|
|
70
|
+
() => ({
|
|
71
|
+
data: tableData.data,
|
|
72
|
+
loading: tableData.loading,
|
|
73
|
+
error: tableData.error,
|
|
74
|
+
sortState: tableData.sortState,
|
|
75
|
+
setSort: tableData.setSort,
|
|
76
|
+
pagination: tableData.pagination,
|
|
77
|
+
hasPreviousPage: tableData.hasPreviousPage,
|
|
78
|
+
nextPage: tableData.nextPage,
|
|
79
|
+
previousPage: tableData.previousPage,
|
|
80
|
+
}),
|
|
81
|
+
[
|
|
82
|
+
tableData.data,
|
|
83
|
+
tableData.loading,
|
|
84
|
+
tableData.error,
|
|
85
|
+
tableData.sortState,
|
|
86
|
+
tableData.setSort,
|
|
87
|
+
tableData.pagination,
|
|
88
|
+
tableData.hasPreviousPage,
|
|
89
|
+
tableData.nextPage,
|
|
90
|
+
tableData.previousPage,
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<TableDataContext.Provider value={value}>
|
|
96
|
+
{children}
|
|
97
|
+
</TableDataContext.Provider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Hook to access TableData context
|
|
103
|
+
* @throws Error if used outside TableDataProvider
|
|
104
|
+
*/
|
|
105
|
+
export function useTableDataContext(): TableDataContextValue {
|
|
106
|
+
const context = useContext(TableDataContext);
|
|
107
|
+
if (!context) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"useTableDataContext must be used within DataViewer.Root. " +
|
|
110
|
+
"Wrap your component with <DataViewer.Root>.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return context;
|
|
114
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useMemo,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Active panel type - only one panel can be open at a time within a Toolbar
|
|
11
|
+
*/
|
|
12
|
+
export type ActivePanel = "column" | "search" | null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Toolbar context value interface
|
|
16
|
+
* Manages exclusive panel state within a toolbar
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolbarContextValue {
|
|
19
|
+
activePanel: ActivePanel;
|
|
20
|
+
setActivePanel: (panel: ActivePanel) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ToolbarContext = createContext<ToolbarContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
export interface ToolbarProviderProps {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Toolbar context provider
|
|
31
|
+
* Manages exclusive panel open state (only one panel can be open at a time)
|
|
32
|
+
*/
|
|
33
|
+
export function ToolbarProvider({ children }: ToolbarProviderProps) {
|
|
34
|
+
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
|
35
|
+
|
|
36
|
+
const value = useMemo<ToolbarContextValue>(
|
|
37
|
+
() => ({
|
|
38
|
+
activePanel,
|
|
39
|
+
setActivePanel,
|
|
40
|
+
}),
|
|
41
|
+
[activePanel],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<ToolbarContext.Provider value={value}>{children}</ToolbarContext.Provider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook to access Toolbar context
|
|
51
|
+
* @throws Error if used outside ToolbarProvider
|
|
52
|
+
*/
|
|
53
|
+
export function useToolbar(): ToolbarContextValue {
|
|
54
|
+
const context = useContext(ToolbarContext);
|
|
55
|
+
if (!context) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"useToolbar must be used within DataViewer.Toolbar. " +
|
|
58
|
+
"Wrap your component with <DataViewer.Toolbar>.",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { Download } from "lucide-react";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import { useDataViewer } from "./contexts";
|
|
5
|
+
import { useTableDataContext } from "./contexts";
|
|
6
|
+
import { formatFieldValue } from "../graphql/query-builder";
|
|
7
|
+
import type { FieldMetadata } from "../generator/metadata-generator";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CSV download button
|
|
11
|
+
* Must be used within DataViewer.Root and TableDataProvider context
|
|
12
|
+
* Contains CSV generation and download logic
|
|
13
|
+
*/
|
|
14
|
+
export function CsvButton() {
|
|
15
|
+
const { tableMetadata, selectedFields } = useDataViewer();
|
|
16
|
+
const { loading, data } = useTableDataContext();
|
|
17
|
+
|
|
18
|
+
const handleDownloadCsv = useCallback(() => {
|
|
19
|
+
if (!tableMetadata || data.length === 0) return;
|
|
20
|
+
|
|
21
|
+
const selectedFieldsMetadata = tableMetadata.fields.filter(
|
|
22
|
+
(f: FieldMetadata) => selectedFields.includes(f.name),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Create CSV header
|
|
26
|
+
const headers = selectedFieldsMetadata.map((f: FieldMetadata) => f.name);
|
|
27
|
+
|
|
28
|
+
// Create CSV rows
|
|
29
|
+
const rows = data.map((row: Record<string, unknown>) =>
|
|
30
|
+
selectedFieldsMetadata.map((field: FieldMetadata) => {
|
|
31
|
+
const value = row[field.name];
|
|
32
|
+
const formattedValue = formatFieldValue(value, field);
|
|
33
|
+
// Escape double quotes and wrap in quotes if contains comma, newline, or quotes
|
|
34
|
+
const stringValue = String(formattedValue ?? "");
|
|
35
|
+
if (
|
|
36
|
+
stringValue.includes(",") ||
|
|
37
|
+
stringValue.includes("\n") ||
|
|
38
|
+
stringValue.includes('"')
|
|
39
|
+
) {
|
|
40
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
41
|
+
}
|
|
42
|
+
return stringValue;
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Combine headers and rows
|
|
47
|
+
const csvContent = [
|
|
48
|
+
headers.join(","),
|
|
49
|
+
...rows.map((r: string[]) => r.join(",")),
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
// Create and download file
|
|
53
|
+
const blob = new Blob(["\uFEFF" + csvContent], {
|
|
54
|
+
type: "text/csv;charset=utf-8;",
|
|
55
|
+
});
|
|
56
|
+
const url = URL.createObjectURL(blob);
|
|
57
|
+
const link = document.createElement("a");
|
|
58
|
+
link.href = url;
|
|
59
|
+
link.download = `${tableMetadata.name}_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
60
|
+
document.body.appendChild(link);
|
|
61
|
+
link.click();
|
|
62
|
+
document.body.removeChild(link);
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
}, [tableMetadata, data, selectedFields]);
|
|
65
|
+
|
|
66
|
+
const csvDisabled = data.length === 0;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Button
|
|
70
|
+
variant="outline"
|
|
71
|
+
size="sm"
|
|
72
|
+
onClick={handleDownloadCsv}
|
|
73
|
+
disabled={loading || csvDisabled}
|
|
74
|
+
>
|
|
75
|
+
<Download className="size-4" />
|
|
76
|
+
CSV
|
|
77
|
+
</Button>
|
|
78
|
+
);
|
|
79
|
+
}
|