@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.
@@ -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
+ }