@izumisy-tailor/tailor-data-viewer 0.1.5 → 0.1.7
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/app-shell/create-data-view-module.tsx +137 -10
- 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.7",
|
|
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
|
}
|
|
@@ -1,9 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
defineModule,
|
|
3
|
+
defineResource,
|
|
4
|
+
useNavigate,
|
|
5
|
+
useSearchParams,
|
|
6
|
+
Link,
|
|
7
|
+
} from "@tailor-platform/app-shell";
|
|
8
|
+
import {
|
|
9
|
+
Database,
|
|
10
|
+
Trash2,
|
|
11
|
+
Calendar,
|
|
12
|
+
Filter,
|
|
13
|
+
Columns,
|
|
14
|
+
LayoutGrid,
|
|
15
|
+
} from "lucide-react";
|
|
3
16
|
import type { DataViewModuleConfig } from "./types";
|
|
4
17
|
import { isStoreFactory } from "./types";
|
|
5
18
|
import { DataViewer } from "../component/data-viewer";
|
|
6
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
SavedViewProvider,
|
|
21
|
+
useSavedViews,
|
|
22
|
+
} from "../component/saved-view-context";
|
|
23
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../component/ui/card";
|
|
24
|
+
import { Button } from "../component/ui/button";
|
|
25
|
+
import { Badge } from "../component/ui/badge";
|
|
7
26
|
|
|
8
27
|
/**
|
|
9
28
|
* Create a DataView module for use with AppShell
|
|
@@ -42,22 +61,130 @@ export function createDataViewModule(config: DataViewModuleConfig) {
|
|
|
42
61
|
|
|
43
62
|
// Create the explorer resource page component
|
|
44
63
|
const ExplorerPage = () => {
|
|
64
|
+
const [searchParams] = useSearchParams();
|
|
65
|
+
const viewId = searchParams.get("viewId") ?? undefined;
|
|
66
|
+
|
|
45
67
|
return (
|
|
46
68
|
<SavedViewProvider store={store}>
|
|
47
|
-
<DataViewer
|
|
69
|
+
<DataViewer
|
|
70
|
+
tableMetadata={tableMetadata}
|
|
71
|
+
appUri={appUri}
|
|
72
|
+
initialViewId={viewId}
|
|
73
|
+
/>
|
|
48
74
|
</SavedViewProvider>
|
|
49
75
|
);
|
|
50
76
|
};
|
|
51
77
|
|
|
78
|
+
// Saved views list component (used inside ModulePage)
|
|
79
|
+
const SavedViewsList = ({
|
|
80
|
+
explorerFullPath,
|
|
81
|
+
}: {
|
|
82
|
+
explorerFullPath: string;
|
|
83
|
+
}) => {
|
|
84
|
+
const { views, deleteView } = useSavedViews();
|
|
85
|
+
const navigate = useNavigate();
|
|
86
|
+
|
|
87
|
+
const handleOpenView = (viewId: string) => {
|
|
88
|
+
navigate(`${explorerFullPath}?viewId=${viewId}`);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Card>
|
|
93
|
+
<CardHeader>
|
|
94
|
+
<CardTitle className="flex items-center gap-2">
|
|
95
|
+
<LayoutGrid className="size-5" />
|
|
96
|
+
保存済みビュー
|
|
97
|
+
</CardTitle>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent>
|
|
100
|
+
{views.length === 0 ? (
|
|
101
|
+
<div className="py-8 text-center">
|
|
102
|
+
<div className="text-muted-foreground mb-4">
|
|
103
|
+
保存されたビューはありません
|
|
104
|
+
</div>
|
|
105
|
+
<Link to={explorerFullPath}>
|
|
106
|
+
<Button variant="outline">
|
|
107
|
+
<Database className="mr-2 size-4" />
|
|
108
|
+
Data View Explorer を開く
|
|
109
|
+
</Button>
|
|
110
|
+
</Link>
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="space-y-3">
|
|
114
|
+
{views.map((savedView) => (
|
|
115
|
+
<div
|
|
116
|
+
key={savedView.id}
|
|
117
|
+
className="hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg border p-4 transition-colors"
|
|
118
|
+
onClick={() => handleOpenView(savedView.id)}
|
|
119
|
+
>
|
|
120
|
+
<div className="flex-1">
|
|
121
|
+
<div className="flex items-center gap-2">
|
|
122
|
+
<span className="font-medium">{savedView.name}</span>
|
|
123
|
+
<Badge variant="secondary">{savedView.tableName}</Badge>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="text-muted-foreground mt-1 flex items-center gap-4 text-sm">
|
|
126
|
+
<span className="flex items-center gap-1">
|
|
127
|
+
<Filter className="size-3" />
|
|
128
|
+
{savedView.filters.length} フィルター
|
|
129
|
+
</span>
|
|
130
|
+
<span className="flex items-center gap-1">
|
|
131
|
+
<Columns className="size-3" />
|
|
132
|
+
{savedView.selectedFields.length} カラム
|
|
133
|
+
</span>
|
|
134
|
+
<span className="flex items-center gap-1">
|
|
135
|
+
<Calendar className="size-3" />
|
|
136
|
+
{savedView.createdAt.toLocaleString()}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
{savedView.filters.length > 0 && (
|
|
140
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
141
|
+
{savedView.filters.map((filter) => (
|
|
142
|
+
<Badge
|
|
143
|
+
key={filter.field}
|
|
144
|
+
variant="outline"
|
|
145
|
+
className="text-xs"
|
|
146
|
+
>
|
|
147
|
+
{filter.field}=
|
|
148
|
+
{typeof filter.value === "boolean"
|
|
149
|
+
? filter.value
|
|
150
|
+
? "true"
|
|
151
|
+
: "false"
|
|
152
|
+
: filter.value}
|
|
153
|
+
</Badge>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
<Button
|
|
159
|
+
variant="ghost"
|
|
160
|
+
size="sm"
|
|
161
|
+
className="text-destructive hover:text-destructive"
|
|
162
|
+
onClick={(e) => {
|
|
163
|
+
e.stopPropagation();
|
|
164
|
+
deleteView(savedView.id);
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<Trash2 className="size-4" />
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</CardContent>
|
|
174
|
+
</Card>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
52
178
|
// Create the module page component
|
|
179
|
+
const explorerFullPath = `/${basePath}/${explorerPath}`;
|
|
180
|
+
|
|
53
181
|
const ModulePage = () => {
|
|
54
182
|
return (
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</div>
|
|
183
|
+
<SavedViewProvider store={store}>
|
|
184
|
+
<div className="space-y-6">
|
|
185
|
+
<SavedViewsList explorerFullPath={explorerFullPath} />
|
|
186
|
+
</div>
|
|
187
|
+
</SavedViewProvider>
|
|
61
188
|
);
|
|
62
189
|
};
|
|
63
190
|
|
|
@@ -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
|