@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.5",
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 "../utils/query-builder";
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 "../utils/query-builder";
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,7 +7,7 @@ import type {
7
7
  import {
8
8
  createGraphQLClient,
9
9
  executeQuery,
10
- } from "../../providers/graphql-client";
10
+ } from "../../graphql/graphql-client";
11
11
 
12
12
  /**
13
13
  * Cache entry for relation data
@@ -7,12 +7,12 @@ import type {
7
7
  import {
8
8
  createGraphQLClient,
9
9
  executeQuery,
10
- } from "../../providers/graphql-client";
10
+ } from "../../graphql/graphql-client";
11
11
  import {
12
12
  buildListQuery,
13
13
  type RelationTotalInfo,
14
14
  type ExpandedRelationInfo,
15
- } from "../../utils/query-builder";
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 "../utils/query-builder";
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 "../providers/graphql-client";
20
- import { formatFieldValue } from "../utils/query-builder";
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,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,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 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
  /**
File without changes