@jskit-ai/crud-ui-generator 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.descriptor.mjs +166 -11
- package/package.json +3 -3
- package/src/server/buildTemplateContext.js +71 -12
- package/src/server/resourceSupport.js +112 -16
- package/src/server/subcommands/addField.js +158 -14
- package/templates/src/pages/admin/ui-generator/AddEditForm.vue +105 -0
- package/templates/src/pages/admin/ui-generator/AddEditFormFields.js +17 -0
- package/templates/src/pages/admin/ui-generator/EditElement.vue +4 -8
- package/templates/src/pages/admin/ui-generator/EditWrapperElement.vue +108 -0
- package/templates/src/pages/admin/ui-generator/ListElement.vue +37 -7
- package/templates/src/pages/admin/ui-generator/NewElement.vue +3 -7
- package/templates/src/pages/admin/ui-generator/NewWrapperElement.vue +81 -0
- package/templates/src/pages/admin/ui-generator/ViewElement.vue +26 -5
- package/test/addFieldSubcommand.test.js +142 -0
- package/test/buildTemplateContext.test.js +325 -1
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/crud-ui-generator",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.7",
|
|
5
5
|
kind: "generator",
|
|
6
6
|
description: "Generate app-local CRUD UI scaffolds from resource validators.",
|
|
7
7
|
options: {
|
|
@@ -81,6 +81,22 @@ export default Object.freeze({
|
|
|
81
81
|
defaultValue: "",
|
|
82
82
|
promptLabel: "Menu placement",
|
|
83
83
|
promptHint: "Optional host:position target (defaults to ShellLayout default outlet)."
|
|
84
|
+
},
|
|
85
|
+
"placement-component-token": {
|
|
86
|
+
required: false,
|
|
87
|
+
inputType: "text",
|
|
88
|
+
defaultValue: "",
|
|
89
|
+
promptLabel: "Placement component token",
|
|
90
|
+
promptHint:
|
|
91
|
+
"Optional component token override for generated menu placement. Use local.main.ui.tab-link-item for routed tab links (auto-provisions src/components/TabLinkItem.vue + MainClientProvider registration)."
|
|
92
|
+
},
|
|
93
|
+
"placement-to": {
|
|
94
|
+
required: false,
|
|
95
|
+
inputType: "text",
|
|
96
|
+
defaultValue: "",
|
|
97
|
+
promptLabel: "Placement to",
|
|
98
|
+
promptHint:
|
|
99
|
+
"Optional explicit props.to value for generated menu placement (example: ./pets). Required when adding placement for dynamic directory-prefix/route-path values."
|
|
84
100
|
}
|
|
85
101
|
},
|
|
86
102
|
dependsOn: [],
|
|
@@ -120,7 +136,7 @@ export default Object.freeze({
|
|
|
120
136
|
mutations: {
|
|
121
137
|
dependencies: {
|
|
122
138
|
runtime: {
|
|
123
|
-
"@jskit-ai/users-web": "0.1.
|
|
139
|
+
"@jskit-ai/users-web": "0.1.38"
|
|
124
140
|
},
|
|
125
141
|
dev: {}
|
|
126
142
|
},
|
|
@@ -163,7 +179,7 @@ export default Object.freeze({
|
|
|
163
179
|
}
|
|
164
180
|
},
|
|
165
181
|
{
|
|
166
|
-
from: "templates/src/pages/admin/ui-generator/
|
|
182
|
+
from: "templates/src/pages/admin/ui-generator/NewWrapperElement.vue",
|
|
167
183
|
toSurface: "${option:surface|lower}",
|
|
168
184
|
toSurfacePath: "${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/new.vue",
|
|
169
185
|
reason: "Install generated new page.",
|
|
@@ -174,12 +190,20 @@ export default Object.freeze({
|
|
|
174
190
|
export: "buildUiTemplateContext"
|
|
175
191
|
},
|
|
176
192
|
when: {
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
all: [
|
|
194
|
+
{
|
|
195
|
+
option: "operations",
|
|
196
|
+
in: ["new"]
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
option: "operations",
|
|
200
|
+
in: ["edit"]
|
|
201
|
+
}
|
|
202
|
+
]
|
|
179
203
|
}
|
|
180
204
|
},
|
|
181
205
|
{
|
|
182
|
-
from: "templates/src/pages/admin/ui-generator/
|
|
206
|
+
from: "templates/src/pages/admin/ui-generator/EditWrapperElement.vue",
|
|
183
207
|
toSurface: "${option:surface|lower}",
|
|
184
208
|
toSurfacePath:
|
|
185
209
|
"${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/[${option:id-param|trim}]/edit.vue",
|
|
@@ -191,8 +215,115 @@ export default Object.freeze({
|
|
|
191
215
|
export: "buildUiTemplateContext"
|
|
192
216
|
},
|
|
193
217
|
when: {
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
all: [
|
|
219
|
+
{
|
|
220
|
+
option: "operations",
|
|
221
|
+
in: ["new"]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
option: "operations",
|
|
225
|
+
in: ["edit"]
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
from: "templates/src/pages/admin/ui-generator/AddEditForm.vue",
|
|
232
|
+
toSurface: "${option:surface|lower}",
|
|
233
|
+
toSurfacePath:
|
|
234
|
+
"${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/_components/${option:namespace|singular|pascal|default(Record)}AddEditForm.vue",
|
|
235
|
+
reason: "Install generated shared add/edit form component.",
|
|
236
|
+
category: "ui-generator",
|
|
237
|
+
id: "ui-generator-page-add-edit-form-${option:namespace|snake}",
|
|
238
|
+
templateContext: {
|
|
239
|
+
entrypoint: "src/server/buildTemplateContext.js",
|
|
240
|
+
export: "buildUiTemplateContext"
|
|
241
|
+
},
|
|
242
|
+
when: {
|
|
243
|
+
all: [
|
|
244
|
+
{
|
|
245
|
+
option: "operations",
|
|
246
|
+
in: ["new"]
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
option: "operations",
|
|
250
|
+
in: ["edit"]
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
from: "templates/src/pages/admin/ui-generator/AddEditFormFields.js",
|
|
257
|
+
toSurface: "${option:surface|lower}",
|
|
258
|
+
toSurfacePath:
|
|
259
|
+
"${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/_components/${option:namespace|singular|pascal|default(Record)}AddEditFormFields.js",
|
|
260
|
+
reason: "Install generated shared add/edit form field definitions.",
|
|
261
|
+
category: "ui-generator",
|
|
262
|
+
id: "ui-generator-page-add-edit-form-fields-${option:namespace|snake}",
|
|
263
|
+
templateContext: {
|
|
264
|
+
entrypoint: "src/server/buildTemplateContext.js",
|
|
265
|
+
export: "buildUiTemplateContext"
|
|
266
|
+
},
|
|
267
|
+
when: {
|
|
268
|
+
all: [
|
|
269
|
+
{
|
|
270
|
+
option: "operations",
|
|
271
|
+
in: ["new"]
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
option: "operations",
|
|
275
|
+
in: ["edit"]
|
|
276
|
+
}
|
|
277
|
+
]
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
from: "templates/src/pages/admin/ui-generator/NewElement.vue",
|
|
282
|
+
toSurface: "${option:surface|lower}",
|
|
283
|
+
toSurfacePath: "${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/new.vue",
|
|
284
|
+
reason: "Install generated new page.",
|
|
285
|
+
category: "ui-generator",
|
|
286
|
+
id: "ui-generator-page-new-standalone-${option:namespace|snake}",
|
|
287
|
+
templateContext: {
|
|
288
|
+
entrypoint: "src/server/buildTemplateContext.js",
|
|
289
|
+
export: "buildUiTemplateContext"
|
|
290
|
+
},
|
|
291
|
+
when: {
|
|
292
|
+
all: [
|
|
293
|
+
{
|
|
294
|
+
option: "operations",
|
|
295
|
+
in: ["new"]
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
option: "operations",
|
|
299
|
+
notIn: ["edit"]
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
from: "templates/src/pages/admin/ui-generator/EditElement.vue",
|
|
306
|
+
toSurface: "${option:surface|lower}",
|
|
307
|
+
toSurfacePath:
|
|
308
|
+
"${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/[${option:id-param|trim}]/edit.vue",
|
|
309
|
+
reason: "Install generated edit page.",
|
|
310
|
+
category: "ui-generator",
|
|
311
|
+
id: "ui-generator-page-edit-standalone-${option:namespace|snake}",
|
|
312
|
+
templateContext: {
|
|
313
|
+
entrypoint: "src/server/buildTemplateContext.js",
|
|
314
|
+
export: "buildUiTemplateContext"
|
|
315
|
+
},
|
|
316
|
+
when: {
|
|
317
|
+
all: [
|
|
318
|
+
{
|
|
319
|
+
option: "operations",
|
|
320
|
+
in: ["edit"]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
option: "operations",
|
|
324
|
+
notIn: ["new"]
|
|
325
|
+
}
|
|
326
|
+
]
|
|
196
327
|
}
|
|
197
328
|
}
|
|
198
329
|
],
|
|
@@ -204,7 +335,7 @@ export default Object.freeze({
|
|
|
204
335
|
skipIfContains:
|
|
205
336
|
"jskit:ui-generator.menu:${option:namespace|kebab}:${option:directory-prefix|path}:${option:container|path}:${option:route-path|path}",
|
|
206
337
|
value:
|
|
207
|
-
"\n// jskit:ui-generator.menu:${option:namespace|kebab}:${option:directory-prefix|path}:${option:container|path}:${option:route-path|path}\n{\n addPlacement({\n id: \"ui-generator.${option:namespace|kebab}.menu\",\n host: \"__JSKIT_UI_MENU_PLACEMENT_HOST__\",\n position: \"__JSKIT_UI_MENU_PLACEMENT_POSITION__\",\n surfaces: [\"${option:surface|lower}\"],\n order: 155,\n componentToken: \"__JSKIT_UI_MENU_COMPONENT_TOKEN__\",\n props: {\n label: \"${option:namespace|plural|pascal}\",\n surface: \"${option:surface|lower}\",\n workspaceSuffix: \"
|
|
338
|
+
"\n// jskit:ui-generator.menu:${option:namespace|kebab}:${option:directory-prefix|path}:${option:container|path}:${option:route-path|path}\n{\n addPlacement({\n id: \"ui-generator.${option:namespace|kebab}.menu\",\n host: \"__JSKIT_UI_MENU_PLACEMENT_HOST__\",\n position: \"__JSKIT_UI_MENU_PLACEMENT_POSITION__\",\n surfaces: [\"${option:surface|lower}\"],\n order: 155,\n componentToken: \"__JSKIT_UI_MENU_COMPONENT_TOKEN__\",\n props: {\n label: \"${option:namespace|plural|pascal}\",\n surface: \"${option:surface|lower}\",\n workspaceSuffix: \"__JSKIT_UI_MENU_WORKSPACE_SUFFIX__\",\n nonWorkspaceSuffix: \"__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__\",\n__JSKIT_UI_MENU_TO_PROP_LINE__ },\n when: ({ auth }) => Boolean(auth?.authenticated)\n });\n}\n",
|
|
208
339
|
reason: "Append generated UI menu placement.",
|
|
209
340
|
category: "ui-generator",
|
|
210
341
|
id: "ui-generator-placement-menu",
|
|
@@ -219,8 +350,32 @@ export default Object.freeze({
|
|
|
219
350
|
in: ["list"]
|
|
220
351
|
},
|
|
221
352
|
{
|
|
222
|
-
|
|
223
|
-
|
|
353
|
+
any: [
|
|
354
|
+
{
|
|
355
|
+
all: [
|
|
356
|
+
{
|
|
357
|
+
option: "route-path",
|
|
358
|
+
notContains: "["
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
option: "directory-prefix",
|
|
362
|
+
notContains: "["
|
|
363
|
+
}
|
|
364
|
+
]
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
all: [
|
|
368
|
+
{
|
|
369
|
+
option: "placement",
|
|
370
|
+
contains: ":"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
option: "placement-to",
|
|
374
|
+
notEquals: ""
|
|
375
|
+
}
|
|
376
|
+
]
|
|
377
|
+
}
|
|
378
|
+
]
|
|
224
379
|
}
|
|
225
380
|
]
|
|
226
381
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-ui-generator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@jskit-ai/crud-core": "0.1.
|
|
10
|
-
"@jskit-ai/kernel": "0.1.
|
|
9
|
+
"@jskit-ai/crud-core": "0.1.32",
|
|
10
|
+
"@jskit-ai/kernel": "0.1.24"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
"./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
|
|
@@ -27,7 +27,24 @@ const ALLOWED_OPERATIONS = new Set(["list", "view", "new", "edit"]);
|
|
|
27
27
|
const DEFAULT_LIST_HIDDEN_FIELD_KEYS = new Set(["createdAt", "updatedAt"]);
|
|
28
28
|
const CONTAINER_TOKEN_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
|
29
29
|
const DEFAULT_MENU_COMPONENT_TOKEN = "users.web.shell.surface-aware-menu-link-item";
|
|
30
|
-
const CONTAINER_MENU_COMPONENT_TOKEN = "local.main.ui.
|
|
30
|
+
const CONTAINER_MENU_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
|
|
31
|
+
|
|
32
|
+
function splitPathSegments(value = "") {
|
|
33
|
+
return normalizeText(value)
|
|
34
|
+
.replaceAll("\\", "/")
|
|
35
|
+
.split("/")
|
|
36
|
+
.map((entry) => normalizeText(entry))
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isRouteGroupSegment(value = "") {
|
|
41
|
+
const source = normalizeText(value);
|
|
42
|
+
return source.startsWith("(") && source.endsWith(")");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function joinPathSegments(segments = []) {
|
|
46
|
+
return (Array.isArray(segments) ? segments : []).join("/");
|
|
47
|
+
}
|
|
31
48
|
|
|
32
49
|
function resolveContainerOption(options = {}) {
|
|
33
50
|
const container = normalizeText(options?.container).toLowerCase();
|
|
@@ -45,31 +62,50 @@ function resolveContainerOption(options = {}) {
|
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
function resolveRoutePathWithContainer(options = {}) {
|
|
48
|
-
const routePath = normalizeText(options?.["route-path"]);
|
|
49
|
-
if (!routePath) {
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
65
|
const container = resolveContainerOption(options);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
const routeSegments = [
|
|
67
|
+
...splitPathSegments(options?.["directory-prefix"]),
|
|
68
|
+
...splitPathSegments(container),
|
|
69
|
+
...splitPathSegments(options?.["route-path"])
|
|
70
|
+
];
|
|
71
|
+
return joinPathSegments(routeSegments);
|
|
72
|
+
}
|
|
57
73
|
|
|
58
|
-
|
|
74
|
+
function resolvePlacementUrlSuffix(options = {}) {
|
|
75
|
+
const routeSegments = splitPathSegments(resolveRoutePathWithContainer(options))
|
|
76
|
+
.filter((segment) => !isRouteGroupSegment(segment));
|
|
77
|
+
if (routeSegments.length < 1) {
|
|
78
|
+
return "/";
|
|
79
|
+
}
|
|
80
|
+
return `/${joinPathSegments(routeSegments)}`;
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
function resolveMenuComponentToken(options = {}) {
|
|
84
|
+
const explicitToken = normalizeText(options?.["placement-component-token"]);
|
|
85
|
+
if (explicitToken) {
|
|
86
|
+
return explicitToken;
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
const container = resolveContainerOption(options);
|
|
63
90
|
return container ? CONTAINER_MENU_COMPONENT_TOKEN : DEFAULT_MENU_COMPONENT_TOKEN;
|
|
64
91
|
}
|
|
65
92
|
|
|
93
|
+
function resolveMenuToPropLine(options = {}) {
|
|
94
|
+
const placementTo = normalizeText(options?.["placement-to"]);
|
|
95
|
+
if (!placementTo) {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return ` to: ${JSON.stringify(placementTo)},\n`;
|
|
100
|
+
}
|
|
101
|
+
|
|
66
102
|
async function resolveMenuPlacementTarget({ appRoot, options, hasListOperation } = {}) {
|
|
67
103
|
if (hasListOperation !== true) {
|
|
68
104
|
return null;
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
const routePath = resolveRoutePathWithContainer(options);
|
|
72
|
-
if (!routePath
|
|
108
|
+
if (!routePath) {
|
|
73
109
|
return null;
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -209,6 +245,22 @@ function ensureFields(fields, fallbackFields = createFieldDefinitions({})) {
|
|
|
209
245
|
return fallbackFields;
|
|
210
246
|
}
|
|
211
247
|
|
|
248
|
+
function resolveViewTitleFallbackFieldKey(fields = []) {
|
|
249
|
+
const sourceFields = Array.isArray(fields) ? fields : [];
|
|
250
|
+
for (const field of sourceFields) {
|
|
251
|
+
if (normalizeText(field?.type).toLowerCase() !== "string") {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const key = normalizeText(field?.key);
|
|
256
|
+
if (key) {
|
|
257
|
+
return key;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return "";
|
|
262
|
+
}
|
|
263
|
+
|
|
212
264
|
async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
213
265
|
const selectedOperations = parseOperationsOption(options);
|
|
214
266
|
const selectedDisplayFields = parseDisplayFieldsOption(options);
|
|
@@ -298,6 +350,9 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
298
350
|
const viewFields = hasViewOperation
|
|
299
351
|
? filterDisplayFields(selectedDisplayFields, ensureFields(viewFieldsAll))
|
|
300
352
|
: createFieldDefinitions({});
|
|
353
|
+
const viewTitleFallbackFieldKey = hasViewOperation
|
|
354
|
+
? resolveViewTitleFallbackFieldKey(viewFieldsAll)
|
|
355
|
+
: "";
|
|
301
356
|
const createFields = hasNewOperation
|
|
302
357
|
? filterDisplayFields(selectedDisplayFields, createFieldsAll)
|
|
303
358
|
: [];
|
|
@@ -325,6 +380,7 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
325
380
|
__JSKIT_UI_LIST_REALTIME_EVENTS__: JSON.stringify(listRealtimeEvents),
|
|
326
381
|
__JSKIT_UI_LIST_RECORD_ID_EXPR__: resolveRecordIdExpression(recordIdFields),
|
|
327
382
|
__JSKIT_UI_VIEW_COLUMNS__: buildViewColumns(viewFields),
|
|
383
|
+
__JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__: JSON.stringify(viewTitleFallbackFieldKey),
|
|
328
384
|
__JSKIT_UI_RECORD_CHANGED_EVENT__: JSON.stringify(defaultRecordChangedEvent),
|
|
329
385
|
__JSKIT_UI_HAS_LIST_ROUTE__: hasListOperation ? "true" : "false",
|
|
330
386
|
__JSKIT_UI_HAS_VIEW_ROUTE__: hasViewOperation ? "true" : "false",
|
|
@@ -338,7 +394,10 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
338
394
|
__JSKIT_UI_EDIT_FORM_FIELD_PUSH_LINES__: renderObjectPushLines("UI_EDIT_FORM_FIELDS", editFields),
|
|
339
395
|
__JSKIT_UI_MENU_PLACEMENT_HOST__: normalizeText(menuPlacementTarget?.host),
|
|
340
396
|
__JSKIT_UI_MENU_PLACEMENT_POSITION__: normalizeText(menuPlacementTarget?.position),
|
|
341
|
-
__JSKIT_UI_MENU_COMPONENT_TOKEN__: resolveMenuComponentToken(options)
|
|
397
|
+
__JSKIT_UI_MENU_COMPONENT_TOKEN__: resolveMenuComponentToken(options),
|
|
398
|
+
__JSKIT_UI_MENU_WORKSPACE_SUFFIX__: resolvePlacementUrlSuffix(options),
|
|
399
|
+
__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__: resolvePlacementUrlSuffix(options),
|
|
400
|
+
__JSKIT_UI_MENU_TO_PROP_LINE__: resolveMenuToPropLine(options)
|
|
342
401
|
};
|
|
343
402
|
}
|
|
344
403
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveCrudLookupApiPathFromNamespace,
|
|
13
13
|
resolveCrudLookupContainerKey
|
|
14
14
|
} from "@jskit-ai/kernel/shared/support/crudLookup";
|
|
15
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
15
16
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
16
17
|
|
|
17
18
|
const JS_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
@@ -281,6 +282,62 @@ function toFieldLabel(key) {
|
|
|
281
282
|
.join(" ");
|
|
282
283
|
}
|
|
283
284
|
|
|
285
|
+
function isSupportedSelectOptionValue(value) {
|
|
286
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function toSelectOptionLabel(value) {
|
|
290
|
+
if (typeof value === "string") {
|
|
291
|
+
const normalizedValue = normalizeText(value);
|
|
292
|
+
return normalizedValue ? toFieldLabel(normalizedValue) : "";
|
|
293
|
+
}
|
|
294
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
295
|
+
return String(value);
|
|
296
|
+
}
|
|
297
|
+
return "";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function toSelectOptionIdentity(value) {
|
|
301
|
+
return `${typeof value}:${String(value)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeFieldUiOptions(rawOptions, { context = "resource fieldMeta ui.options" } = {}) {
|
|
305
|
+
if (rawOptions === undefined || rawOptions === null) {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
if (!Array.isArray(rawOptions)) {
|
|
309
|
+
throw new Error(`${context} must be an array of { value, label? } entries.`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const options = [];
|
|
313
|
+
const seenValues = new Set();
|
|
314
|
+
for (const [index, rawEntry] of rawOptions.entries()) {
|
|
315
|
+
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
|
|
316
|
+
throw new Error(`${context}[${index}] must be an object.`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const value = rawEntry.value;
|
|
320
|
+
if (!isSupportedSelectOptionValue(value)) {
|
|
321
|
+
throw new Error(`${context}[${index}].value must be a string, number, or boolean.`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const identity = toSelectOptionIdentity(value);
|
|
325
|
+
if (seenValues.has(identity)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
seenValues.add(identity);
|
|
329
|
+
|
|
330
|
+
const explicitLabel = normalizeText(rawEntry.label);
|
|
331
|
+
const fallbackLabel = toSelectOptionLabel(value);
|
|
332
|
+
options.push({
|
|
333
|
+
value,
|
|
334
|
+
label: explicitLabel || fallbackLabel || String(value)
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return options;
|
|
339
|
+
}
|
|
340
|
+
|
|
284
341
|
function stripLookupIdSuffix(key = "") {
|
|
285
342
|
const normalizedKey = normalizeText(key);
|
|
286
343
|
if (!normalizedKey) {
|
|
@@ -400,6 +457,10 @@ function normalizeLookupRelation(relation = {}) {
|
|
|
400
457
|
normalized.containerKey = containerKey;
|
|
401
458
|
}
|
|
402
459
|
}
|
|
460
|
+
const surfaceId = normalizeSurfaceId(relation.surfaceId);
|
|
461
|
+
if (surfaceId) {
|
|
462
|
+
normalized.surfaceId = surfaceId;
|
|
463
|
+
}
|
|
403
464
|
return normalized;
|
|
404
465
|
}
|
|
405
466
|
|
|
@@ -425,6 +486,9 @@ function buildResourceFieldMetaMap(resource = {}) {
|
|
|
425
486
|
}
|
|
426
487
|
|
|
427
488
|
const relation = normalizeLookupRelation(rawEntry.relation);
|
|
489
|
+
const fieldUiOptions = normalizeFieldUiOptions(rawEntry?.ui?.options, {
|
|
490
|
+
context: `resource.fieldMeta["${key}"].ui.options`
|
|
491
|
+
});
|
|
428
492
|
if (relation) {
|
|
429
493
|
nextEntry.relation = relation;
|
|
430
494
|
const formControl = checkCrudLookupFormControl(rawEntry?.ui?.formControl, {
|
|
@@ -437,6 +501,12 @@ function buildResourceFieldMetaMap(resource = {}) {
|
|
|
437
501
|
};
|
|
438
502
|
}
|
|
439
503
|
}
|
|
504
|
+
if (fieldUiOptions.length > 0) {
|
|
505
|
+
nextEntry.ui = {
|
|
506
|
+
...(nextEntry.ui || {}),
|
|
507
|
+
options: fieldUiOptions
|
|
508
|
+
};
|
|
509
|
+
}
|
|
440
510
|
|
|
441
511
|
map[key] = nextEntry;
|
|
442
512
|
}
|
|
@@ -559,6 +629,16 @@ function createFormFieldDefinitions(
|
|
|
559
629
|
|
|
560
630
|
const schemaType = resolveSchemaType(schema);
|
|
561
631
|
const relation = toLookupRelation(fieldMetaMap, key, { lookupContainerKey });
|
|
632
|
+
const fieldUiOptions = Array.isArray(fieldMetaMap?.[key]?.ui?.options)
|
|
633
|
+
? fieldMetaMap[key].ui.options
|
|
634
|
+
: [];
|
|
635
|
+
const schemaEnumValues = Array.isArray(schemaType.schema?.enum) ? schemaType.schema.enum : [];
|
|
636
|
+
if (!relation && schemaEnumValues.length > 0 && fieldUiOptions.length < 1) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`resource form field "${key}" defines schema enum values but is missing resource.fieldMeta["${key}"].ui.options.`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
const selectOptions = relation ? [] : fieldUiOptions;
|
|
562
642
|
const lookupFormControl = relation
|
|
563
643
|
? checkCrudLookupFormControl(fieldMetaMap?.[key]?.ui?.formControl, {
|
|
564
644
|
context: `resource.fieldMeta["${key}"].ui.formControl`,
|
|
@@ -573,9 +653,14 @@ function createFormFieldDefinitions(
|
|
|
573
653
|
nullable: schemaType.nullable,
|
|
574
654
|
relation,
|
|
575
655
|
inputType: resolveFormInputType(schemaType.type, schemaType.format),
|
|
576
|
-
component:
|
|
656
|
+
component: selectOptions.length > 0
|
|
657
|
+
? "select"
|
|
658
|
+
: resolveFormFieldComponent(schemaType.type, relation),
|
|
577
659
|
maxLength: toPositiveInteger(schemaType.schema?.maxLength)
|
|
578
660
|
};
|
|
661
|
+
if (selectOptions.length > 0) {
|
|
662
|
+
fieldDefinition.options = selectOptions;
|
|
663
|
+
}
|
|
579
664
|
if (normalizedParentRouteParamKey && key === normalizedParentRouteParamKey) {
|
|
580
665
|
fieldDefinition.hidden = true;
|
|
581
666
|
fieldDefinition.routeParamKey = normalizedParentRouteParamKey;
|
|
@@ -622,6 +707,10 @@ function escapeHtml(value) {
|
|
|
622
707
|
.replaceAll("'", "'");
|
|
623
708
|
}
|
|
624
709
|
|
|
710
|
+
function serializeTemplateBindingValue(value) {
|
|
711
|
+
return JSON.stringify(value).replaceAll("'", "\\u0027");
|
|
712
|
+
}
|
|
713
|
+
|
|
625
714
|
function buildListHeaderColumns(fields = []) {
|
|
626
715
|
return (Array.isArray(fields) ? fields : [])
|
|
627
716
|
.map((field) => ` <th>${escapeHtml(field.label)}</th>`)
|
|
@@ -723,11 +812,25 @@ function buildFormColumns(fields = []) {
|
|
|
723
812
|
label="${label}"
|
|
724
813
|
color="primary"
|
|
725
814
|
hide-details="auto"
|
|
726
|
-
:disabled="
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
815
|
+
:disabled="formRuntime.addEdit.isFieldLocked"
|
|
816
|
+
:error-messages='${fieldErrorExpression}'
|
|
817
|
+
/>
|
|
818
|
+
</v-col>`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (component === "select") {
|
|
822
|
+
const selectOptions = Array.isArray(field?.options) ? field.options : [];
|
|
823
|
+
return ` <v-col cols="12" md="6">
|
|
824
|
+
<v-select
|
|
825
|
+
v-model="${formAccessor}"
|
|
826
|
+
label="${label}"
|
|
827
|
+
variant="outlined"
|
|
828
|
+
density="comfortable"
|
|
829
|
+
:items='${serializeTemplateBindingValue(selectOptions)}'
|
|
830
|
+
item-title="label"
|
|
831
|
+
item-value="value"
|
|
832
|
+
:disabled="formRuntime.addEdit.isFieldLocked"
|
|
833
|
+
:clearable="${field.nullable === true ? "true" : "false"}"
|
|
731
834
|
:error-messages='${fieldErrorExpression}'
|
|
732
835
|
/>
|
|
733
836
|
</v-col>`;
|
|
@@ -747,17 +850,14 @@ function buildFormColumns(fields = []) {
|
|
|
747
850
|
label="${label}"
|
|
748
851
|
variant="outlined"
|
|
749
852
|
density="comfortable"
|
|
853
|
+
autocomplete="off"
|
|
750
854
|
:items='resolveLookupItems(${JSON.stringify(key)}, { selectedValue: ${formAccessor}, selectedRecord: formRuntime.addEdit.resource.data })'
|
|
751
855
|
${lookupSearchBindings}
|
|
752
856
|
item-title="label"
|
|
753
857
|
item-value="value"
|
|
754
858
|
${lookupNoFilterLine}
|
|
755
859
|
:loading='resolveLookupLoading(${JSON.stringify(key)})'
|
|
756
|
-
:disabled="
|
|
757
|
-
!formRuntime.addEdit.canSave ||
|
|
758
|
-
formRuntime.addEdit.isSaving ||
|
|
759
|
-
formRuntime.addEdit.isRefetching
|
|
760
|
-
"
|
|
860
|
+
:disabled="formRuntime.addEdit.isFieldLocked"
|
|
761
861
|
:clearable="${field.nullable === true ? "true" : "false"}"
|
|
762
862
|
:error-messages='${fieldErrorExpression}'
|
|
763
863
|
/>
|
|
@@ -776,11 +876,7 @@ function buildFormColumns(fields = []) {
|
|
|
776
876
|
variant="outlined"
|
|
777
877
|
density="comfortable"
|
|
778
878
|
:maxlength="${maxLength}"
|
|
779
|
-
:readonly="
|
|
780
|
-
!formRuntime.addEdit.canSave ||
|
|
781
|
-
formRuntime.addEdit.isSaving ||
|
|
782
|
-
formRuntime.addEdit.isRefetching
|
|
783
|
-
"
|
|
879
|
+
:readonly="formRuntime.addEdit.isFieldLocked"
|
|
784
880
|
:error-messages='${fieldErrorExpression}'
|
|
785
881
|
/>
|
|
786
882
|
</v-col>`;
|