@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.
Files changed (37) hide show
  1. package/README.md +255 -0
  2. package/package.json +47 -0
  3. package/src/component/column-selector.tsx +264 -0
  4. package/src/component/data-table.tsx +428 -0
  5. package/src/component/data-view-tab-content.tsx +324 -0
  6. package/src/component/data-viewer.tsx +280 -0
  7. package/src/component/hooks/use-accessible-tables.ts +22 -0
  8. package/src/component/hooks/use-column-state.ts +281 -0
  9. package/src/component/hooks/use-relation-data.ts +387 -0
  10. package/src/component/hooks/use-table-data.ts +317 -0
  11. package/src/component/index.ts +15 -0
  12. package/src/component/pagination.tsx +56 -0
  13. package/src/component/relation-content.tsx +250 -0
  14. package/src/component/saved-view-context.tsx +145 -0
  15. package/src/component/search-filter.tsx +319 -0
  16. package/src/component/single-record-tab-content.tsx +676 -0
  17. package/src/component/table-selector.tsx +102 -0
  18. package/src/component/types.ts +20 -0
  19. package/src/component/view-save-load.tsx +112 -0
  20. package/src/generator/metadata-generator.ts +461 -0
  21. package/src/lib/utils.ts +6 -0
  22. package/src/providers/graphql-client.ts +31 -0
  23. package/src/styles/theme.css +105 -0
  24. package/src/types/table-metadata.ts +73 -0
  25. package/src/ui/alert.tsx +66 -0
  26. package/src/ui/badge.tsx +46 -0
  27. package/src/ui/button.tsx +62 -0
  28. package/src/ui/card.tsx +92 -0
  29. package/src/ui/checkbox.tsx +30 -0
  30. package/src/ui/collapsible.tsx +31 -0
  31. package/src/ui/dialog.tsx +143 -0
  32. package/src/ui/dropdown-menu.tsx +255 -0
  33. package/src/ui/input.tsx +21 -0
  34. package/src/ui/label.tsx +24 -0
  35. package/src/ui/select.tsx +188 -0
  36. package/src/ui/table.tsx +116 -0
  37. package/src/utils/query-builder.ts +190 -0
package/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # @tailor-platform/data-viewer
2
+
3
+ A React component library for building data exploration interfaces with GraphQL backend support. Provides a tab-based spreadsheet-like UI for viewing and managing table data with relation navigation.
4
+
5
+ ## Features
6
+
7
+ - **Tab-based Interface**: Excel-like sheet management with multiple tabs
8
+ - **Table Selection**: Role-based access control for tables
9
+ - **Column Selection**: Dynamic column visibility with manyToOne relation expansion
10
+ - **Relation Navigation**: Expandable inline relations with "Open as Sheet" functionality
11
+ - **Search & Filter**: AND-based filtering on string, number, boolean, and enum fields
12
+ - **View Persistence**: Save and load views with filter and column configurations
13
+ - **Sorting & Pagination**: Full cursor-based pagination support
14
+ - **CSV Export**: Download current view as CSV
15
+ - **Single Record View**: Detailed single record view with all relations
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @tailor-platform/data-viewer
21
+ # or
22
+ pnpm add @tailor-platform/data-viewer
23
+ # or
24
+ yarn add @tailor-platform/data-viewer
25
+ ```
26
+
27
+ ### Peer Dependencies
28
+
29
+ This library requires the following peer dependencies:
30
+
31
+ ```bash
32
+ npm install react react-dom
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ### Basic Setup
38
+
39
+ ```tsx
40
+ import { DataViewer, SavedViewProvider } from "@tailor-platform/data-viewer/component";
41
+ import type { TableMetadataMap } from "@tailor-platform/data-viewer/generator";
42
+ import "@tailor-platform/data-viewer/styles/theme.css";
43
+
44
+ // Your table metadata (generated from Tailor Platform schema)
45
+ const tableMetadata: TableMetadataMap = {
46
+ user: {
47
+ name: "user",
48
+ pluralForm: "users",
49
+ fields: [
50
+ { name: "id", type: "uuid", nullable: false },
51
+ { name: "name", type: "string", nullable: false },
52
+ { name: "email", type: "string", nullable: false },
53
+ ],
54
+ allowedRoles: ["admin", "viewer"],
55
+ },
56
+ // ... more tables
57
+ };
58
+
59
+ function App() {
60
+ return (
61
+ <SavedViewProvider>
62
+ <DataViewer
63
+ tableMetadata={tableMetadata}
64
+ userRoles={["admin"]}
65
+ appUri="https://your-app.tailor.tech/graphql"
66
+ />
67
+ </SavedViewProvider>
68
+ );
69
+ }
70
+ ```
71
+
72
+ ### Using Individual Components
73
+
74
+ ```tsx
75
+ import {
76
+ DataTable,
77
+ TableSelector,
78
+ ColumnSelector,
79
+ Pagination,
80
+ SearchFilterForm,
81
+ useTableData,
82
+ useColumnState,
83
+ } from "@tailor-platform/data-viewer/component";
84
+ ```
85
+
86
+ ### Generating Table Metadata
87
+
88
+ Use the metadata generator to create type-safe metadata from your Tailor Platform schema:
89
+
90
+ ```tsx
91
+ import { generateTableMetadata } from "@tailor-platform/data-viewer/generator";
92
+
93
+ // From your GraphQL schema types
94
+ const metadata = generateTableMetadata(schemaTypes);
95
+ ```
96
+
97
+ ## API Reference
98
+
99
+ ### `<DataViewer />`
100
+
101
+ Main component with tab-based interface.
102
+
103
+ ```tsx
104
+ interface DataViewerProps {
105
+ /** Map of table name to metadata */
106
+ tableMetadata: TableMetadataMap;
107
+ /** User's roles for access control */
108
+ userRoles: string[];
109
+ /** GraphQL endpoint URI */
110
+ appUri: string;
111
+ /** Optional initial view ID to load */
112
+ initialViewId?: string;
113
+ }
114
+ ```
115
+
116
+ ### `<SavedViewProvider />`
117
+
118
+ Context provider for view persistence (localStorage-based).
119
+
120
+ ```tsx
121
+ <SavedViewProvider>
122
+ <DataViewer {...props} />
123
+ </SavedViewProvider>
124
+ ```
125
+
126
+ ### `<DataTable />`
127
+
128
+ Core data table component with sortable columns and expandable relations.
129
+
130
+ ```tsx
131
+ interface DataTableProps {
132
+ data: Record<string, unknown>[];
133
+ fields: FieldMetadata[];
134
+ selectedFields: string[];
135
+ sortState: SortState | null;
136
+ onSort: (field: string) => void;
137
+ loading?: boolean;
138
+ tableMetadata?: TableMetadata;
139
+ tableMetadataMap?: TableMetadataMap;
140
+ appUri?: string;
141
+ selectedRelations?: string[];
142
+ onOpenAsSheet?: (tableName: string, filterField: string, filterValue: string) => void;
143
+ onOpenSingleRecordAsSheet?: (tableName: string, recordId: string) => void;
144
+ expandedRelationFields?: ExpandedRelationFields;
145
+ }
146
+ ```
147
+
148
+ ### Hooks
149
+
150
+ #### `useTableData`
151
+
152
+ Manages table data fetching with pagination, sorting, and filtering.
153
+
154
+ ```tsx
155
+ const {
156
+ data,
157
+ loading,
158
+ error,
159
+ pagination,
160
+ sortState,
161
+ setSort,
162
+ nextPage,
163
+ previousPage,
164
+ resetPagination,
165
+ refetch,
166
+ } = useTableData(appUri, table, selectedFields, selectedRelations, filters, metadataMap, expandedFields);
167
+ ```
168
+
169
+ #### `useColumnState`
170
+
171
+ Manages column visibility and relation field expansion.
172
+
173
+ ```tsx
174
+ const {
175
+ selectedFields,
176
+ selectedRelations,
177
+ expandedRelationFields,
178
+ toggleField,
179
+ toggleRelation,
180
+ toggleExpandedRelationField,
181
+ selectAll,
182
+ deselectAll,
183
+ } = useColumnState(fields, relations);
184
+ ```
185
+
186
+ #### `useAccessibleTables`
187
+
188
+ Filters tables based on user roles.
189
+
190
+ ```tsx
191
+ const tables = useAccessibleTables(tableMetadataMap, userRoles);
192
+ ```
193
+
194
+ ## Types
195
+
196
+ ### TableMetadata
197
+
198
+ ```tsx
199
+ interface TableMetadata {
200
+ name: string;
201
+ pluralForm: string;
202
+ description?: string;
203
+ fields: FieldMetadata[];
204
+ relations?: RelationMetadata[];
205
+ allowedRoles: string[];
206
+ }
207
+ ```
208
+
209
+ ### FieldMetadata
210
+
211
+ ```tsx
212
+ interface FieldMetadata {
213
+ name: string;
214
+ type: FieldType;
215
+ nullable: boolean;
216
+ enumValues?: string[];
217
+ }
218
+
219
+ type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date" | "time" | "json" | "enum" | "nested" | "array";
220
+ ```
221
+
222
+ ### RelationMetadata
223
+
224
+ ```tsx
225
+ interface RelationMetadata {
226
+ fieldName: string;
227
+ targetTable: string;
228
+ relationType: "manyToOne" | "oneToMany";
229
+ foreignKeyField: string;
230
+ }
231
+ ```
232
+
233
+ ## Styling
234
+
235
+ The library uses Tailwind CSS classes. Import the base theme or provide your own CSS variables:
236
+
237
+ ```css
238
+ /* Option 1: Import included theme */
239
+ @import "@tailor-platform/data-viewer/styles/theme.css";
240
+
241
+ /* Option 2: Use with Tailor app-shell */
242
+ @import "@tailor-platform/app-shell/theme.css";
243
+
244
+ /* Option 3: Define your own CSS variables */
245
+ :root {
246
+ --background: 0 0% 100%;
247
+ --foreground: 222.2 84% 4.9%;
248
+ --primary: 222.2 47.4% 11.2%;
249
+ /* ... see theme.css for full list */
250
+ }
251
+ ```
252
+
253
+ ## License
254
+
255
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@izumisy-tailor/tailor-data-viewer",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "Flexible data viewer component for Tailor Platform",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "exports": {
10
+ "./component": {
11
+ "default": "./src/component/index.ts"
12
+ },
13
+ "./generator": {
14
+ "default": "./src/generator/metadata-generator.ts"
15
+ },
16
+ "./styles": {
17
+ "default": "./src/styles/theme.css"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "graphql-request": "^6.1.0",
22
+ "lucide-react": "^0.468.0",
23
+ "@radix-ui/react-checkbox": "^1.1.4",
24
+ "@radix-ui/react-collapsible": "^1.1.3",
25
+ "@radix-ui/react-dialog": "^1.1.6",
26
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
27
+ "@radix-ui/react-label": "^2.1.2",
28
+ "@radix-ui/react-select": "^2.1.6",
29
+ "@radix-ui/react-slot": "^1.1.2",
30
+ "class-variance-authority": "^0.7.1",
31
+ "clsx": "^2.1.1",
32
+ "tailwind-merge": "^2.6.0"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "react": "^19.0.0",
40
+ "react-dom": "^19.0.0",
41
+ "@types/react": "^19.0.0",
42
+ "@types/react-dom": "^19.0.0"
43
+ },
44
+ "scripts": {
45
+ "type-check": "tsc -b"
46
+ }
47
+ }
@@ -0,0 +1,264 @@
1
+ import { Columns3 } from "lucide-react";
2
+ import { Checkbox } from "../ui/checkbox";
3
+ import { Button } from "../ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuTrigger,
7
+ DropdownMenuContent,
8
+ DropdownMenuSub,
9
+ DropdownMenuSubTrigger,
10
+ DropdownMenuSubContent,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuLabel,
13
+ } from "../ui/dropdown-menu";
14
+ import type {
15
+ FieldMetadata,
16
+ RelationMetadata,
17
+ TableMetadataMap,
18
+ ExpandedRelationFields,
19
+ } from "../types/table-metadata";
20
+
21
+ interface ColumnSelectorProps {
22
+ fields: FieldMetadata[];
23
+ selectedFields: string[];
24
+ onToggle: (fieldName: string) => void;
25
+ onSelectAll: () => void;
26
+ onDeselectAll: () => void;
27
+ /** Relations for the current table */
28
+ relations?: RelationMetadata[];
29
+ /** Currently selected relation field names */
30
+ selectedRelations?: string[];
31
+ /** Toggle relation visibility */
32
+ onToggleRelation?: (fieldName: string) => void;
33
+ /** Table metadata map for relation field lookups */
34
+ tableMetadataMap?: TableMetadataMap;
35
+ /** Expanded relation fields (manyToOne fields shown as inline columns) */
36
+ expandedRelationFields?: ExpandedRelationFields;
37
+ /** Toggle a field within an expanded relation */
38
+ onToggleExpandedRelationField?: (
39
+ relationFieldName: string,
40
+ fieldName: string,
41
+ ) => void;
42
+ /** Check if a field is selected within an expanded relation */
43
+ isExpandedRelationFieldSelected?: (
44
+ relationFieldName: string,
45
+ fieldName: string,
46
+ ) => boolean;
47
+ }
48
+
49
+ /**
50
+ * Column visibility selector with checkboxes
51
+ */
52
+ export function ColumnSelector({
53
+ fields,
54
+ selectedFields,
55
+ onToggle,
56
+ onSelectAll,
57
+ onDeselectAll,
58
+ relations = [],
59
+ selectedRelations = [],
60
+ onToggleRelation,
61
+ tableMetadataMap,
62
+ expandedRelationFields = {},
63
+ onToggleExpandedRelationField,
64
+ isExpandedRelationFieldSelected,
65
+ }: ColumnSelectorProps) {
66
+ // Filter out nested fields as they're not directly selectable
67
+ const selectableFields = fields.filter((field) => field.type !== "nested");
68
+
69
+ // Separate manyToOne and oneToMany relations
70
+ const manyToOneRelations = relations.filter(
71
+ (r) => r.relationType === "manyToOne",
72
+ );
73
+ const oneToManyRelations = relations.filter(
74
+ (r) => r.relationType === "oneToMany",
75
+ );
76
+
77
+ // Count expanded relation fields
78
+ const expandedFieldCount = Object.values(expandedRelationFields).reduce(
79
+ (sum, fields) => sum + fields.length,
80
+ 0,
81
+ );
82
+
83
+ const totalSelectable =
84
+ selectableFields.length + relations.length + expandedFieldCount;
85
+ const totalSelected =
86
+ selectedFields.length + selectedRelations.length + expandedFieldCount;
87
+
88
+ return (
89
+ <DropdownMenu>
90
+ <DropdownMenuTrigger asChild>
91
+ <Button variant="outline" size="sm" className="gap-1">
92
+ <Columns3 className="size-4" />
93
+ カラム選択 ({totalSelected}/{totalSelectable})
94
+ </Button>
95
+ </DropdownMenuTrigger>
96
+
97
+ <DropdownMenuContent align="start" className="w-64">
98
+ <div className="flex gap-1 border-b px-2 py-1.5">
99
+ <Button variant="ghost" size="sm" onClick={onSelectAll}>
100
+ 全選択
101
+ </Button>
102
+ <Button variant="ghost" size="sm" onClick={onDeselectAll}>
103
+ 全解除
104
+ </Button>
105
+ </div>
106
+ <div className="max-h-80 overflow-auto p-2">
107
+ {/* Regular fields */}
108
+ <div className="space-y-1">
109
+ {selectableFields.map((field) => (
110
+ <label
111
+ key={field.name}
112
+ className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
113
+ >
114
+ <Checkbox
115
+ checked={selectedFields.includes(field.name)}
116
+ onCheckedChange={() => onToggle(field.name)}
117
+ />
118
+ <span
119
+ className="truncate"
120
+ title={field.description ?? field.name}
121
+ >
122
+ {field.name}
123
+ </span>
124
+ {field.required && (
125
+ <span className="text-destructive text-xs">*</span>
126
+ )}
127
+ </label>
128
+ ))}
129
+ </div>
130
+
131
+ {/* ManyToOne Relations with submenu for field expansion */}
132
+ {manyToOneRelations.length > 0 &&
133
+ onToggleRelation &&
134
+ tableMetadataMap && (
135
+ <>
136
+ <DropdownMenuSeparator />
137
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
138
+ リレーション (1対1)
139
+ </DropdownMenuLabel>
140
+ <div className="space-y-1">
141
+ {manyToOneRelations.map((relation) => {
142
+ const targetTable = tableMetadataMap[relation.targetTable];
143
+ const targetFields =
144
+ targetTable?.fields.filter(
145
+ (f) => f.type !== "nested" && f.name !== "id",
146
+ ) ?? [];
147
+ const selectedExpandedFields =
148
+ expandedRelationFields[relation.fieldName] ?? [];
149
+
150
+ return (
151
+ <div
152
+ key={relation.fieldName}
153
+ className="flex items-center gap-1"
154
+ >
155
+ {/* Inline toggle checkbox */}
156
+ <label className="hover:bg-accent flex flex-1 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm">
157
+ <Checkbox
158
+ checked={selectedRelations.includes(
159
+ relation.fieldName,
160
+ )}
161
+ onCheckedChange={() =>
162
+ onToggleRelation(relation.fieldName)
163
+ }
164
+ />
165
+ <span className="truncate" title={relation.fieldName}>
166
+ {relation.fieldName}
167
+ </span>
168
+ <span className="text-muted-foreground text-xs">
169
+ (1)
170
+ </span>
171
+ </label>
172
+
173
+ {/* Submenu for field expansion */}
174
+ {targetTable && onToggleExpandedRelationField && (
175
+ <DropdownMenuSub>
176
+ <DropdownMenuSubTrigger className="h-7 px-2">
177
+ <Columns3 className="size-3" />
178
+ {selectedExpandedFields.length > 0 && (
179
+ <span className="text-muted-foreground ml-1 text-xs">
180
+ ({selectedExpandedFields.length})
181
+ </span>
182
+ )}
183
+ </DropdownMenuSubTrigger>
184
+ <DropdownMenuSubContent className="w-56">
185
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
186
+ {relation.targetTable}{" "}
187
+ のフィールドを列として展開
188
+ </DropdownMenuLabel>
189
+ <DropdownMenuSeparator />
190
+ <div className="max-h-60 overflow-auto p-1">
191
+ <div className="space-y-1">
192
+ {targetFields.map((field) => (
193
+ <label
194
+ key={field.name}
195
+ className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
196
+ >
197
+ <Checkbox
198
+ checked={
199
+ isExpandedRelationFieldSelected?.(
200
+ relation.fieldName,
201
+ field.name,
202
+ ) ?? false
203
+ }
204
+ onCheckedChange={() =>
205
+ onToggleExpandedRelationField(
206
+ relation.fieldName,
207
+ field.name,
208
+ )
209
+ }
210
+ className="size-3.5"
211
+ />
212
+ <span
213
+ className="truncate"
214
+ title={field.description ?? field.name}
215
+ >
216
+ {field.name}
217
+ </span>
218
+ </label>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ </DropdownMenuSubContent>
223
+ </DropdownMenuSub>
224
+ )}
225
+ </div>
226
+ );
227
+ })}
228
+ </div>
229
+ </>
230
+ )}
231
+
232
+ {/* OneToMany Relations (inline toggle only) */}
233
+ {oneToManyRelations.length > 0 && onToggleRelation && (
234
+ <>
235
+ <DropdownMenuSeparator />
236
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
237
+ リレーション (1対多)
238
+ </DropdownMenuLabel>
239
+ <div className="space-y-1">
240
+ {oneToManyRelations.map((relation) => (
241
+ <label
242
+ key={relation.fieldName}
243
+ className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
244
+ >
245
+ <Checkbox
246
+ checked={selectedRelations.includes(relation.fieldName)}
247
+ onCheckedChange={() =>
248
+ onToggleRelation(relation.fieldName)
249
+ }
250
+ />
251
+ <span className="truncate" title={relation.fieldName}>
252
+ {relation.fieldName}
253
+ </span>
254
+ <span className="text-muted-foreground text-xs">(N)</span>
255
+ </label>
256
+ ))}
257
+ </div>
258
+ </>
259
+ )}
260
+ </div>
261
+ </DropdownMenuContent>
262
+ </DropdownMenu>
263
+ );
264
+ }