@izumisy-tailor/tailor-data-viewer 0.1.5 → 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/package.json +6 -3
- package/src/component/data-table.tsx +1 -1
- package/src/component/data-view-tab-content.tsx +1 -1
- package/src/component/hooks/use-relation-data.ts +1 -1
- package/src/component/hooks/use-table-data.ts +2 -2
- package/src/component/relation-content.tsx +1 -1
- package/src/component/single-record-tab-content.tsx +2 -2
- package/src/graphql/index.ts +6 -0
- package/src/graphql/query-builder.test.ts +238 -0
- package/src/{utils → graphql}/query-builder.ts +59 -51
- /package/src/{providers → graphql}/graphql-client.ts +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@izumisy-tailor/tailor-data-viewer",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Flexible data viewer component for Tailor Platform",
|
|
7
7
|
"files": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"graphql-request": "^6.1.0",
|
|
47
47
|
"idb": "^8.0.3",
|
|
48
48
|
"lucide-react": "^0.468.0",
|
|
49
|
+
"raku-ql": "^1.4.0",
|
|
49
50
|
"tailwind-merge": "^2.6.0"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
@@ -70,10 +71,12 @@
|
|
|
70
71
|
"react": "^19.0.0",
|
|
71
72
|
"react-dom": "^19.0.0",
|
|
72
73
|
"tsdown": "^0.20.1",
|
|
73
|
-
"typescript": "^5.9.3"
|
|
74
|
+
"typescript": "^5.9.3",
|
|
75
|
+
"vitest": "^4.0.18"
|
|
74
76
|
},
|
|
75
77
|
"scripts": {
|
|
76
78
|
"build": "tsdown",
|
|
77
|
-
"type-check": "tsc -b"
|
|
79
|
+
"type-check": "tsc -b",
|
|
80
|
+
"test": "vitest run"
|
|
78
81
|
}
|
|
79
82
|
}
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
TableMetadataMap,
|
|
24
24
|
ExpandedRelationFields,
|
|
25
25
|
} from "../generator/metadata-generator";
|
|
26
|
-
import { formatFieldValue } from "../
|
|
26
|
+
import { formatFieldValue } from "../graphql/query-builder";
|
|
27
27
|
import type { SortState } from "./hooks/use-table-data";
|
|
28
28
|
import { useRelationData } from "./hooks/use-relation-data";
|
|
29
29
|
import { RelationContent } from "./relation-content";
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
TableMetadataMap,
|
|
8
8
|
FieldMetadata,
|
|
9
9
|
} from "../generator/metadata-generator";
|
|
10
|
-
import { formatFieldValue } from "../
|
|
10
|
+
import { formatFieldValue } from "../graphql/query-builder";
|
|
11
11
|
import { TableSelector } from "./table-selector";
|
|
12
12
|
import { ColumnSelector } from "./column-selector";
|
|
13
13
|
import { DataTable } from "./data-table";
|
|
@@ -7,12 +7,12 @@ import type {
|
|
|
7
7
|
import {
|
|
8
8
|
createGraphQLClient,
|
|
9
9
|
executeQuery,
|
|
10
|
-
} from "../../
|
|
10
|
+
} from "../../graphql/graphql-client";
|
|
11
11
|
import {
|
|
12
12
|
buildListQuery,
|
|
13
13
|
type RelationTotalInfo,
|
|
14
14
|
type ExpandedRelationInfo,
|
|
15
|
-
} from "../../
|
|
15
|
+
} from "../../graphql/query-builder";
|
|
16
16
|
import type { SearchFilters } from "../types";
|
|
17
17
|
|
|
18
18
|
export interface SortState {
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
RelationMetadata,
|
|
15
15
|
FieldMetadata,
|
|
16
16
|
} from "../generator/metadata-generator";
|
|
17
|
-
import { formatFieldValue } from "../
|
|
17
|
+
import { formatFieldValue } from "../graphql/query-builder";
|
|
18
18
|
import type { RelationDataResult } from "./hooks/use-relation-data";
|
|
19
19
|
|
|
20
20
|
interface RelationContentProps {
|
|
@@ -16,8 +16,8 @@ import type {
|
|
|
16
16
|
TableMetadataMap,
|
|
17
17
|
FieldMetadata,
|
|
18
18
|
} from "../generator/metadata-generator";
|
|
19
|
-
import { createGraphQLClient, executeQuery } from "../
|
|
20
|
-
import { formatFieldValue } from "../
|
|
19
|
+
import { createGraphQLClient, executeQuery } from "../graphql/graphql-client";
|
|
20
|
+
import { formatFieldValue } from "../graphql/query-builder";
|
|
21
21
|
import { ColumnSelector } from "./column-selector";
|
|
22
22
|
import { useColumnState } from "./hooks/use-column-state";
|
|
23
23
|
|
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
import { QueryBuilder } from "raku-ql";
|
|
1
2
|
import type {
|
|
2
3
|
TableMetadata,
|
|
3
4
|
FieldMetadata,
|
|
@@ -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
|
/**
|
|
File without changes
|