@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.
- package/README.md +44 -4
- package/dist/generator/index.d.mts +7 -1
- package/docs/app-shell-module.md +151 -0
- package/docs/saved-view-store.md +155 -0
- package/package.json +32 -4
- package/src/app-shell/create-data-view-module.tsx +84 -0
- package/src/app-shell/index.ts +2 -0
- package/src/app-shell/types.ts +42 -0
- package/src/component/column-selector.tsx +4 -4
- package/src/component/data-table.tsx +3 -3
- package/src/component/data-view-tab-content.tsx +4 -4
- package/src/component/data-viewer.tsx +38 -9
- package/src/component/hooks/use-accessible-tables.ts +1 -1
- package/src/component/hooks/use-column-state.ts +1 -1
- package/src/component/hooks/use-relation-data.ts +2 -2
- package/src/component/hooks/use-table-access-check.ts +103 -0
- package/src/component/hooks/use-table-data.ts +3 -3
- package/src/component/index.ts +4 -1
- package/src/component/pagination.tsx +1 -1
- package/src/component/relation-content.tsx +5 -5
- package/src/component/saved-view-context.tsx +195 -48
- package/src/component/search-filter.tsx +8 -8
- package/src/component/single-record-tab-content.tsx +7 -7
- package/src/component/table-selector.tsx +2 -2
- package/src/component/types.ts +1 -1
- package/src/component/view-save-load.tsx +4 -4
- package/src/generator/metadata-generator.ts +7 -0
- package/src/graphql/index.ts +6 -0
- package/src/graphql/query-builder.test.ts +238 -0
- package/src/{utils → graphql}/query-builder.ts +60 -52
- package/src/store/indexeddb.ts +150 -0
- package/src/store/tailordb/index.ts +204 -0
- package/src/store/tailordb/schema.ts +114 -0
- package/src/store/types.ts +85 -0
- package/src/types/table-metadata.ts +0 -72
- /package/{API.md → docs/API.md} +0 -0
- /package/src/{lib → component/lib}/utils.ts +0 -0
- /package/src/{ui → component/ui}/alert.tsx +0 -0
- /package/src/{ui → component/ui}/badge.tsx +0 -0
- /package/src/{ui → component/ui}/button.tsx +0 -0
- /package/src/{ui → component/ui}/card.tsx +0 -0
- /package/src/{ui → component/ui}/checkbox.tsx +0 -0
- /package/src/{ui → component/ui}/collapsible.tsx +0 -0
- /package/src/{ui → component/ui}/dialog.tsx +0 -0
- /package/src/{ui → component/ui}/dropdown-menu.tsx +0 -0
- /package/src/{ui → component/ui}/input.tsx +0 -0
- /package/src/{ui → component/ui}/label.tsx +0 -0
- /package/src/{ui → component/ui}/select.tsx +0 -0
- /package/src/{ui → component/ui}/table.tsx +0 -0
- /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 "
|
|
4
|
-
import { Input } from "
|
|
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 "
|
|
14
|
-
import type { ExpandedRelationFields } from "../
|
|
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,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 "../
|
|
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
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Build
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
+
}
|