@izumisy-tailor/tailor-data-viewer 0.1.4 → 0.1.6

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 (50) hide show
  1. package/README.md +44 -4
  2. package/dist/generator/index.d.mts +7 -1
  3. package/docs/app-shell-module.md +151 -0
  4. package/docs/saved-view-store.md +155 -0
  5. package/package.json +32 -4
  6. package/src/app-shell/create-data-view-module.tsx +84 -0
  7. package/src/app-shell/index.ts +2 -0
  8. package/src/app-shell/types.ts +42 -0
  9. package/src/component/column-selector.tsx +4 -4
  10. package/src/component/data-table.tsx +3 -3
  11. package/src/component/data-view-tab-content.tsx +4 -4
  12. package/src/component/data-viewer.tsx +38 -9
  13. package/src/component/hooks/use-accessible-tables.ts +1 -1
  14. package/src/component/hooks/use-column-state.ts +1 -1
  15. package/src/component/hooks/use-relation-data.ts +2 -2
  16. package/src/component/hooks/use-table-access-check.ts +103 -0
  17. package/src/component/hooks/use-table-data.ts +3 -3
  18. package/src/component/index.ts +4 -1
  19. package/src/component/pagination.tsx +1 -1
  20. package/src/component/relation-content.tsx +5 -5
  21. package/src/component/saved-view-context.tsx +195 -48
  22. package/src/component/search-filter.tsx +8 -8
  23. package/src/component/single-record-tab-content.tsx +7 -7
  24. package/src/component/table-selector.tsx +2 -2
  25. package/src/component/types.ts +1 -1
  26. package/src/component/view-save-load.tsx +4 -4
  27. package/src/generator/metadata-generator.ts +7 -0
  28. package/src/graphql/index.ts +6 -0
  29. package/src/graphql/query-builder.test.ts +238 -0
  30. package/src/{utils → graphql}/query-builder.ts +60 -52
  31. package/src/store/indexeddb.ts +150 -0
  32. package/src/store/tailordb/index.ts +204 -0
  33. package/src/store/tailordb/schema.ts +114 -0
  34. package/src/store/types.ts +85 -0
  35. package/src/types/table-metadata.ts +0 -72
  36. /package/{API.md → docs/API.md} +0 -0
  37. /package/src/{lib → component/lib}/utils.ts +0 -0
  38. /package/src/{ui → component/ui}/alert.tsx +0 -0
  39. /package/src/{ui → component/ui}/badge.tsx +0 -0
  40. /package/src/{ui → component/ui}/button.tsx +0 -0
  41. /package/src/{ui → component/ui}/card.tsx +0 -0
  42. /package/src/{ui → component/ui}/checkbox.tsx +0 -0
  43. /package/src/{ui → component/ui}/collapsible.tsx +0 -0
  44. /package/src/{ui → component/ui}/dialog.tsx +0 -0
  45. /package/src/{ui → component/ui}/dropdown-menu.tsx +0 -0
  46. /package/src/{ui → component/ui}/input.tsx +0 -0
  47. /package/src/{ui → component/ui}/label.tsx +0 -0
  48. /package/src/{ui → component/ui}/select.tsx +0 -0
  49. /package/src/{ui → component/ui}/table.tsx +0 -0
  50. /package/src/{providers → graphql}/graphql-client.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback } from "react";
2
2
  import { Save, Filter, Columns } from "lucide-react";
3
- import { Button } from "../ui/button";
4
- import { Input } from "../ui/input";
3
+ import { Button } from "./ui/button";
4
+ import { Input } from "./ui/input";
5
5
  import {
6
6
  Dialog,
7
7
  DialogContent,
@@ -10,8 +10,8 @@ import {
10
10
  DialogHeader,
11
11
  DialogTitle,
12
12
  DialogTrigger,
13
- } from "../ui/dialog";
14
- import type { ExpandedRelationFields } from "../types/table-metadata";
13
+ } from "./ui/dialog";
14
+ import type { ExpandedRelationFields } from "../generator/metadata-generator";
15
15
  import { useSavedViews, type SaveViewInput } from "./saved-view-context";
16
16
  import type { SearchFilters } from "./types";
17
17
 
@@ -68,6 +68,13 @@ export interface TableMetadata {
68
68
  */
69
69
  export type TableMetadataMap = Record<string, TableMetadata>;
70
70
 
71
+ /**
72
+ * Expanded relation fields configuration
73
+ * Key: relation field name (e.g., "task")
74
+ * Value: array of selected field names from the related table (e.g., ["name", "status"])
75
+ */
76
+ export type ExpandedRelationFields = Record<string, string[]>;
77
+
71
78
  /**
72
79
  * Intermediate type for processed table data
73
80
  */
@@ -0,0 +1,6 @@
1
+ /**
2
+ * GraphQL utilities and client
3
+ */
4
+
5
+ export * from "./graphql-client";
6
+ export * from "./query-builder";
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildListQuery,
4
+ getDefaultSelectedFields,
5
+ getSelectableFields,
6
+ formatFieldValue,
7
+ } from "./query-builder";
8
+ import type {
9
+ FieldMetadata,
10
+ TableMetadata,
11
+ RelationMetadata,
12
+ } from "../generator/metadata-generator";
13
+
14
+ describe("query-builder", () => {
15
+ describe("buildListQuery", () => {
16
+ it("should build a basic list query", () => {
17
+ const query = buildListQuery({
18
+ tableName: "task",
19
+ pluralForm: "tasks",
20
+ selectedFields: ["name", "status", "createdAt"],
21
+ });
22
+
23
+ // gql-query-builder outputs with spaces, so we use regex patterns
24
+ expect(query).toMatch(/query\s+TaskList\s*\(\$query:\s*TaskQueryInput\)/);
25
+ expect(query).toContain("tasks(query: $query)");
26
+ expect(query).toContain("name");
27
+ expect(query).toContain("status");
28
+ expect(query).toContain("createdAt");
29
+ expect(query).toContain("pageInfo");
30
+ expect(query).toContain("hasNextPage");
31
+ expect(query).toContain("endCursor");
32
+ });
33
+
34
+ it("should include orderBy when specified", () => {
35
+ const query = buildListQuery({
36
+ tableName: "task",
37
+ pluralForm: "tasks",
38
+ selectedFields: ["name"],
39
+ orderBy: { field: "createdAt", direction: "Desc" },
40
+ });
41
+
42
+ // Variable should include order with array type
43
+ expect(query).toMatch(/\$order:\s*\[TaskOrderInput!\]/);
44
+ // Operation should use variable reference
45
+ expect(query).toContain("order: $order");
46
+ });
47
+
48
+ it("should include pagination arguments", () => {
49
+ const query = buildListQuery({
50
+ tableName: "task",
51
+ pluralForm: "tasks",
52
+ selectedFields: ["name"],
53
+ first: 10,
54
+ after: "cursor123",
55
+ });
56
+
57
+ // Variables should include first and after
58
+ expect(query).toMatch(/\$first:\s*Int/);
59
+ expect(query).toMatch(/\$after:\s*String/);
60
+ // Operation should use variable references
61
+ expect(query).toContain("first: $first");
62
+ expect(query).toContain("after: $after");
63
+ });
64
+
65
+ it("should include oneToMany relation totals", () => {
66
+ const relation: RelationMetadata = {
67
+ fieldName: "taskAssignments",
68
+ targetTable: "taskAssignment",
69
+ relationType: "oneToMany",
70
+ foreignKeyField: "taskId",
71
+ };
72
+
73
+ const query = buildListQuery({
74
+ tableName: "task",
75
+ pluralForm: "tasks",
76
+ selectedFields: ["name"],
77
+ oneToManyRelationTotals: [
78
+ { relation, targetPluralForm: "taskAssignments" },
79
+ ],
80
+ });
81
+
82
+ expect(query).toContain("taskAssignments(first: 0)");
83
+ expect(query).toContain("total");
84
+ });
85
+
86
+ it("should include expanded manyToOne relations", () => {
87
+ const relation: RelationMetadata = {
88
+ fieldName: "user",
89
+ targetTable: "user",
90
+ relationType: "manyToOne",
91
+ foreignKeyField: "userId",
92
+ };
93
+
94
+ const query = buildListQuery({
95
+ tableName: "task",
96
+ pluralForm: "tasks",
97
+ selectedFields: ["name"],
98
+ expandedManyToOneRelations: [
99
+ { relation, selectedFields: ["email", "displayName"] },
100
+ ],
101
+ });
102
+
103
+ expect(query).toContain("user {");
104
+ expect(query).toContain("email");
105
+ expect(query).toContain("displayName");
106
+ });
107
+
108
+ it("should handle PascalCase conversion for table names", () => {
109
+ const query = buildListQuery({
110
+ tableName: "taskAssignment",
111
+ pluralForm: "taskAssignments",
112
+ selectedFields: ["id"],
113
+ });
114
+
115
+ expect(query).toMatch(
116
+ /query\s+TaskAssignmentList\s*\(\$query:\s*TaskAssignmentQueryInput\)/,
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("getDefaultSelectedFields", () => {
122
+ it("should exclude nested and array fields", () => {
123
+ const table: TableMetadata = {
124
+ name: "task",
125
+ pluralForm: "tasks",
126
+ readAllowedRoles: ["admin"],
127
+ fields: [
128
+ { name: "name", type: "string", required: true },
129
+ { name: "metadata", type: "nested", required: false },
130
+ { name: "tags", type: "array", required: false },
131
+ ],
132
+ };
133
+
134
+ const fields = getDefaultSelectedFields(table);
135
+ expect(fields).toEqual(["name"]);
136
+ });
137
+
138
+ it("should exclude id fields", () => {
139
+ const table: TableMetadata = {
140
+ name: "task",
141
+ pluralForm: "tasks",
142
+ readAllowedRoles: ["admin"],
143
+ fields: [
144
+ { name: "id", type: "uuid", required: true },
145
+ { name: "name", type: "string", required: true },
146
+ { name: "userId", type: "uuid", required: false },
147
+ { name: "status", type: "string", required: true },
148
+ ],
149
+ };
150
+
151
+ const fields = getDefaultSelectedFields(table);
152
+ expect(fields).toEqual(["name", "status"]);
153
+ });
154
+ });
155
+
156
+ describe("getSelectableFields", () => {
157
+ it("should return all fields except nested types", () => {
158
+ const table: TableMetadata = {
159
+ name: "task",
160
+ pluralForm: "tasks",
161
+ readAllowedRoles: ["admin"],
162
+ fields: [
163
+ { name: "id", type: "uuid", required: true },
164
+ { name: "name", type: "string", required: true },
165
+ { name: "metadata", type: "nested", required: false },
166
+ { name: "count", type: "number", required: false },
167
+ ],
168
+ };
169
+
170
+ const fields = getSelectableFields(table);
171
+ expect(fields.map((f) => f.name)).toEqual(["id", "name", "count"]);
172
+ });
173
+ });
174
+
175
+ describe("formatFieldValue", () => {
176
+ it("should return '-' for null or undefined", () => {
177
+ const field: FieldMetadata = {
178
+ name: "test",
179
+ type: "string",
180
+ required: false,
181
+ };
182
+ expect(formatFieldValue(null, field)).toBe("-");
183
+ expect(formatFieldValue(undefined, field)).toBe("-");
184
+ });
185
+
186
+ it("should format boolean as Japanese text", () => {
187
+ const field: FieldMetadata = {
188
+ name: "active",
189
+ type: "boolean",
190
+ required: false,
191
+ };
192
+ expect(formatFieldValue(true, field)).toBe("はい");
193
+ expect(formatFieldValue(false, field)).toBe("いいえ");
194
+ });
195
+
196
+ it("should format number with locale", () => {
197
+ const field: FieldMetadata = {
198
+ name: "count",
199
+ type: "number",
200
+ required: false,
201
+ };
202
+ expect(formatFieldValue(1234567, field)).toBe("1,234,567");
203
+ });
204
+
205
+ it("should truncate UUID", () => {
206
+ const field: FieldMetadata = { name: "id", type: "uuid", required: true };
207
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
208
+ expect(formatFieldValue(uuid, field)).toBe("550e8400...");
209
+ });
210
+
211
+ it("should format array values", () => {
212
+ const field: FieldMetadata = {
213
+ name: "tags",
214
+ type: "array",
215
+ required: false,
216
+ };
217
+ expect(formatFieldValue(["a", "b", "c"], field)).toBe("a, b, c");
218
+ });
219
+
220
+ it("should format time as string", () => {
221
+ const field: FieldMetadata = {
222
+ name: "startTime",
223
+ type: "time",
224
+ required: false,
225
+ };
226
+ expect(formatFieldValue("14:30:00", field)).toBe("14:30:00");
227
+ });
228
+
229
+ it("should return string as-is for string type", () => {
230
+ const field: FieldMetadata = {
231
+ name: "name",
232
+ type: "string",
233
+ required: true,
234
+ };
235
+ expect(formatFieldValue("Hello World", field)).toBe("Hello World");
236
+ });
237
+ });
238
+ });
@@ -1,8 +1,9 @@
1
+ import { QueryBuilder } from "raku-ql";
1
2
  import type {
2
3
  TableMetadata,
3
4
  FieldMetadata,
4
5
  RelationMetadata,
5
- } from "../types/table-metadata";
6
+ } from "../generator/metadata-generator";
6
7
 
7
8
  /**
8
9
  * Convert camelCase to PascalCase
@@ -65,63 +66,70 @@ export function buildListQuery(options: QueryOptions): string {
65
66
  // GraphQL type names need PascalCase
66
67
  const typeName = toPascalCase(tableName);
67
68
 
68
- // Build order clause if specified
69
- const orderArg = orderBy
70
- ? `, order: [{ field: ${typeName}OrderFields.${orderBy.field}, direction: OrderDirection.${orderBy.direction} }]`
71
- : "";
72
-
73
- // Build pagination arguments
74
- const firstArg = first !== undefined ? `, first: ${first}` : "";
75
- const afterArg = after !== undefined ? `, after: "${after}"` : "";
76
-
77
- // Build oneToMany relation total fields (fetching only total for display before expansion)
78
- // These are nested connection queries that get total count for each parent record
79
- const relationTotalFields =
80
- oneToManyRelationTotals
81
- ?.map((info) => {
82
- return `${info.relation.fieldName}(first: 0) {
83
- total
84
- }`;
85
- })
86
- .join("\n ") ?? "";
87
-
88
- // Build expanded manyToOne relation fields (inline object fetching)
89
- const expandedRelationFields =
90
- expandedManyToOneRelations
91
- ?.filter((info) => info.selectedFields.length > 0)
92
- .map((info) => {
93
- const nestedFields = info.selectedFields.join("\n ");
94
- return `${info.relation.fieldName} {
95
- ${nestedFields}
96
- }`;
97
- })
98
- .join("\n ") ?? "";
99
-
100
- // Format fields with proper indentation
101
- const fieldsStr = selectedFields.join("\n ");
102
- let allFieldsStr = fieldsStr;
103
- if (relationTotalFields) {
104
- allFieldsStr = `${allFieldsStr}\n ${relationTotalFields}`;
69
+ // Build variables definition
70
+ const variables: Record<string, string> = {
71
+ query: `${typeName}QueryInput`,
72
+ };
73
+
74
+ // Build operation arguments using variable references
75
+ const operationArgs: Record<string, string> = {
76
+ query: "$query",
77
+ };
78
+
79
+ if (orderBy) {
80
+ // Use a single order variable with the full array type
81
+ variables.order = `[${typeName}OrderInput!]`;
82
+ operationArgs.order = "$order";
105
83
  }
106
- if (expandedRelationFields) {
107
- allFieldsStr = `${allFieldsStr}\n ${expandedRelationFields}`;
84
+
85
+ if (first !== undefined) {
86
+ variables.first = "Int";
87
+ operationArgs.first = "$first";
88
+ }
89
+
90
+ if (after !== undefined) {
91
+ variables.after = "String";
92
+ operationArgs.after = "$after";
108
93
  }
109
94
 
110
- return `
111
- query ${typeName}List($query: ${typeName}QueryInput) {
112
- ${pluralForm}(query: $query${orderArg}${firstArg}${afterArg}) {
113
- edges {
114
- node {
115
- ${allFieldsStr}
95
+ // Build the query using raku-ql
96
+ const builder = QueryBuilder.query(`${typeName}List`).variables(variables);
97
+
98
+ // Use operation method for root-level query with arguments
99
+ builder.operation(pluralForm, operationArgs, (op) => {
100
+ op.object("edges", (edges) => {
101
+ edges.object("node", (node) => {
102
+ // Add selected fields
103
+ node.fields(...(selectedFields as []));
104
+
105
+ // Add oneToMany relation totals with arguments
106
+ if (oneToManyRelationTotals) {
107
+ for (const info of oneToManyRelationTotals) {
108
+ node.object(info.relation.fieldName, { first: 0 }, (rel) => {
109
+ rel.fields("total");
110
+ });
116
111
  }
117
112
  }
118
- pageInfo {
119
- hasNextPage
120
- endCursor
113
+
114
+ // Add expanded manyToOne relations
115
+ if (expandedManyToOneRelations) {
116
+ for (const info of expandedManyToOneRelations) {
117
+ if (info.selectedFields.length > 0) {
118
+ node.object(info.relation.fieldName, (rel) => {
119
+ rel.fields(...(info.selectedFields as []));
120
+ });
121
+ }
122
+ }
121
123
  }
122
- }
123
- }
124
- `.trim();
124
+ });
125
+ });
126
+
127
+ op.object("pageInfo", (pageInfo) => {
128
+ pageInfo.fields("hasNextPage", "endCursor");
129
+ });
130
+ });
131
+
132
+ return builder.build();
125
133
  }
126
134
 
127
135
  /**
@@ -0,0 +1,150 @@
1
+ import { openDB, type DBSchema, type IDBPDatabase } from "idb";
2
+ import type { SavedView, SavedViewInput, SavedViewStore } from "./types";
3
+
4
+ const DEFAULT_DB_NAME = "data-viewer";
5
+ const DEFAULT_STORE_NAME = "savedViews";
6
+
7
+ /**
8
+ * Stored view format in IndexedDB (dates as ISO strings)
9
+ */
10
+ interface StoredView extends Omit<SavedView, "createdAt" | "updatedAt"> {
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ }
14
+
15
+ /**
16
+ * IndexedDB schema definition for type safety
17
+ */
18
+ interface DataViewerDBSchema extends DBSchema {
19
+ savedViews: {
20
+ key: string;
21
+ value: StoredView;
22
+ indexes: { tableName: string };
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Configuration for IndexedDB store
28
+ */
29
+ export interface IndexedDBStoreConfig {
30
+ /** Database name (default: "data-viewer") */
31
+ dbName?: string;
32
+ /** Object store name (default: "savedViews") - Note: changing this requires schema migration */
33
+ storeName?: string;
34
+ }
35
+
36
+ function generateId(): string {
37
+ return `view-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
38
+ }
39
+
40
+ function serializeView(view: SavedView): StoredView {
41
+ return {
42
+ ...view,
43
+ createdAt: view.createdAt.toISOString(),
44
+ updatedAt: view.updatedAt.toISOString(),
45
+ };
46
+ }
47
+
48
+ function deserializeView(stored: StoredView): SavedView {
49
+ return {
50
+ ...stored,
51
+ createdAt: new Date(stored.createdAt),
52
+ updatedAt: new Date(stored.updatedAt),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Create an IndexedDB-based saved view store
58
+ * Stores views locally in the browser using the `idb` library
59
+ */
60
+ export function createIndexedDBStore(
61
+ config?: IndexedDBStoreConfig,
62
+ ): SavedViewStore {
63
+ const dbName = config?.dbName ?? DEFAULT_DB_NAME;
64
+ const storeName = config?.storeName ?? DEFAULT_STORE_NAME;
65
+
66
+ let dbInstance: IDBPDatabase<DataViewerDBSchema> | null = null;
67
+
68
+ async function getDB(): Promise<IDBPDatabase<DataViewerDBSchema>> {
69
+ if (dbInstance) return dbInstance;
70
+
71
+ dbInstance = await openDB<DataViewerDBSchema>(dbName, 1, {
72
+ upgrade(db) {
73
+ if (!db.objectStoreNames.contains(storeName as "savedViews")) {
74
+ const store = db.createObjectStore(storeName as "savedViews", {
75
+ keyPath: "id",
76
+ });
77
+ store.createIndex("tableName", "tableName");
78
+ }
79
+ },
80
+ });
81
+
82
+ return dbInstance;
83
+ }
84
+
85
+ return {
86
+ async listViews(): Promise<SavedView[]> {
87
+ const db = await getDB();
88
+ const stored = await db.getAll(storeName as "savedViews");
89
+ const views = stored.map(deserializeView);
90
+ // Sort by updatedAt descending (newest first)
91
+ return views.sort(
92
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
93
+ );
94
+ },
95
+
96
+ async saveView(input: SavedViewInput): Promise<SavedView> {
97
+ const db = await getDB();
98
+ const now = new Date();
99
+
100
+ let view: SavedView;
101
+
102
+ if (input.id) {
103
+ // Update existing
104
+ const existing = await this.getView(input.id);
105
+ if (!existing) {
106
+ throw new Error(`View not found: ${input.id}`);
107
+ }
108
+ view = {
109
+ ...existing,
110
+ name: input.name,
111
+ tableName: input.tableName,
112
+ columns: input.columns,
113
+ filters: input.filters,
114
+ sortOrder: input.sortOrder,
115
+ selectedRelations: input.selectedRelations,
116
+ expandedRelationFields: input.expandedRelationFields,
117
+ updatedAt: now,
118
+ };
119
+ } else {
120
+ // Create new
121
+ view = {
122
+ id: generateId(),
123
+ name: input.name,
124
+ tableName: input.tableName,
125
+ columns: input.columns,
126
+ filters: input.filters,
127
+ sortOrder: input.sortOrder,
128
+ selectedRelations: input.selectedRelations,
129
+ expandedRelationFields: input.expandedRelationFields,
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ };
133
+ }
134
+
135
+ await db.put(storeName as "savedViews", serializeView(view));
136
+ return view;
137
+ },
138
+
139
+ async deleteView(viewId: string): Promise<void> {
140
+ const db = await getDB();
141
+ await db.delete(storeName as "savedViews", viewId);
142
+ },
143
+
144
+ async getView(viewId: string): Promise<SavedView | null> {
145
+ const db = await getDB();
146
+ const stored = await db.get(storeName as "savedViews", viewId);
147
+ return stored ? deserializeView(stored) : null;
148
+ },
149
+ };
150
+ }