@jskit-ai/crud-ui-generator 0.1.16 → 0.1.17
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 +27 -6
- package/package.json +3 -3
- package/src/server/buildTemplateContext.js +46 -8
- package/src/server/resourceSupport.js +18 -13
- package/test/buildTemplateContext.test.js +108 -22
- package/test/packageDescriptor.test.js +14 -0
- package/README.md +0 -67
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.17",
|
|
5
5
|
kind: "generator",
|
|
6
6
|
description: "Generate CRUD route trees from resource validators at an explicit route root relative to src/pages/.",
|
|
7
7
|
options: {
|
|
@@ -24,6 +24,8 @@ export default Object.freeze({
|
|
|
24
24
|
required: false,
|
|
25
25
|
inputType: "text",
|
|
26
26
|
defaultValue: "list,view,new,edit",
|
|
27
|
+
validationType: "csv-enum",
|
|
28
|
+
allowedValues: ["list", "view", "new", "edit"],
|
|
27
29
|
promptLabel: "Operations",
|
|
28
30
|
promptHint: "Optional comma-separated values from: list, view, new, edit. Defaults to all four."
|
|
29
31
|
},
|
|
@@ -53,7 +55,15 @@ export default Object.freeze({
|
|
|
53
55
|
inputType: "text",
|
|
54
56
|
defaultValue: "",
|
|
55
57
|
promptLabel: "Link placement",
|
|
56
|
-
promptHint: "Optional
|
|
58
|
+
promptHint: "Optional target override for the generated list-page link placement (format: host:position)."
|
|
59
|
+
},
|
|
60
|
+
"link-component-token": {
|
|
61
|
+
required: false,
|
|
62
|
+
inputType: "text",
|
|
63
|
+
defaultValue: "",
|
|
64
|
+
promptLabel: "Link component token",
|
|
65
|
+
promptHint:
|
|
66
|
+
"Optional component token override for the generated list-page link placement (example: local.main.ui.tab-link-item)."
|
|
57
67
|
},
|
|
58
68
|
namespace: {
|
|
59
69
|
required: false,
|
|
@@ -80,10 +90,11 @@ export default Object.freeze({
|
|
|
80
90
|
generatorPrimarySubcommand: "crud",
|
|
81
91
|
generatorSubcommands: {
|
|
82
92
|
crud: {
|
|
93
|
+
requiresShellWeb: true,
|
|
83
94
|
description: "Create CRUD pages at an explicit route root relative to src/pages/.",
|
|
84
95
|
longDescription: [
|
|
85
96
|
"CRUD generation follows the same page-placement model as `ui-generator page`.",
|
|
86
|
-
"That means the generated list page link uses the same nearest-parent-
|
|
97
|
+
"That means the generated list page link uses the same nearest-parent-target inference, tab-link inference, and relative `props.to` inference as a normal generated page. If you want the detailed target behavior, read `jskit generate ui-generator page help`."
|
|
87
98
|
],
|
|
88
99
|
positionalArgs: [
|
|
89
100
|
{
|
|
@@ -92,7 +103,16 @@ export default Object.freeze({
|
|
|
92
103
|
descriptionKey: "crud-target-root"
|
|
93
104
|
}
|
|
94
105
|
],
|
|
95
|
-
optionNames: [
|
|
106
|
+
optionNames: [
|
|
107
|
+
"resource-file",
|
|
108
|
+
"operations",
|
|
109
|
+
"display-fields",
|
|
110
|
+
"id-param",
|
|
111
|
+
"link-placement",
|
|
112
|
+
"link-component-token",
|
|
113
|
+
"namespace",
|
|
114
|
+
"force"
|
|
115
|
+
],
|
|
96
116
|
requiredOptionNames: ["resource-file"],
|
|
97
117
|
createTarget: {
|
|
98
118
|
pathTemplate: "src/pages/${option:target-root|trim}",
|
|
@@ -127,6 +147,7 @@ export default Object.freeze({
|
|
|
127
147
|
]
|
|
128
148
|
},
|
|
129
149
|
field: {
|
|
150
|
+
requiresShellWeb: true,
|
|
130
151
|
entrypoint: "src/server/subcommands/addField.js",
|
|
131
152
|
export: "runGeneratorSubcommand"
|
|
132
153
|
}
|
|
@@ -147,7 +168,7 @@ export default Object.freeze({
|
|
|
147
168
|
mutations: {
|
|
148
169
|
dependencies: {
|
|
149
170
|
runtime: {
|
|
150
|
-
"@jskit-ai/users-web": "0.1.
|
|
171
|
+
"@jskit-ai/users-web": "0.1.49"
|
|
151
172
|
},
|
|
152
173
|
dev: {}
|
|
153
174
|
},
|
|
@@ -332,7 +353,7 @@ export default Object.freeze({
|
|
|
332
353
|
position: "bottom",
|
|
333
354
|
skipIfContains: "__JSKIT_UI_MENU_MARKER__",
|
|
334
355
|
value:
|
|
335
|
-
"\n// __JSKIT_UI_MENU_MARKER__\n{\n addPlacement({\n id: \"__JSKIT_UI_MENU_PLACEMENT_ID__\",\n
|
|
356
|
+
"\n// __JSKIT_UI_MENU_MARKER__\n{\n addPlacement({\n id: \"__JSKIT_UI_MENU_PLACEMENT_ID__\",\n target: \"__JSKIT_UI_MENU_PLACEMENT_TARGET__\",\n surfaces: [\"__JSKIT_UI_SURFACE_ID__\"],\n order: 155,\n componentToken: \"__JSKIT_UI_MENU_COMPONENT_TOKEN__\",\n props: {\n label: \"__JSKIT_UI_MENU_LABEL__\",\n surface: \"__JSKIT_UI_SURFACE_ID__\",\n workspaceSuffix: \"__JSKIT_UI_MENU_WORKSPACE_SUFFIX__\",\n nonWorkspaceSuffix: \"__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__\",\n__JSKIT_UI_MENU_TO_PROP_LINE__ },\n__JSKIT_UI_MENU_WHEN_LINE__ });\n}\n",
|
|
336
357
|
reason: "Append generated CRUD list-page placement.",
|
|
337
358
|
category: "crud-ui-generator",
|
|
338
359
|
id: "crud-ui-placement-menu",
|
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.17",
|
|
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.42",
|
|
10
|
+
"@jskit-ai/kernel": "0.1.34"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
"./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
|
|
@@ -24,13 +24,38 @@ import {
|
|
|
24
24
|
buildListRowColumns,
|
|
25
25
|
buildViewColumns,
|
|
26
26
|
buildFormColumns,
|
|
27
|
+
resolveRecordIdFieldKey,
|
|
27
28
|
renderObjectPushLines,
|
|
28
29
|
resolveRecordChangedEventName,
|
|
29
30
|
resolveRecordIdExpression
|
|
30
31
|
} from "./resourceSupport.js";
|
|
32
|
+
import descriptor from "../../package.descriptor.mjs";
|
|
31
33
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
+
const DEFAULT_ALLOWED_OPERATIONS = Object.freeze(["list", "view", "new", "edit"]);
|
|
35
|
+
function resolveAllowedValues(schema = {}, fallbackValues = []) {
|
|
36
|
+
const resolvedValues = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const rawValue of Array.isArray(schema?.allowedValues) ? schema.allowedValues : []) {
|
|
39
|
+
const value = normalizeText(typeof rawValue === "string" ? rawValue : rawValue?.value).toLowerCase();
|
|
40
|
+
if (!value || seen.has(value)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seen.add(value);
|
|
44
|
+
resolvedValues.push(value);
|
|
45
|
+
}
|
|
46
|
+
if (resolvedValues.length > 0) {
|
|
47
|
+
return Object.freeze(resolvedValues);
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze(
|
|
50
|
+
(Array.isArray(fallbackValues) ? fallbackValues : [])
|
|
51
|
+
.map((value) => normalizeText(value).toLowerCase())
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OPERATION_VALUES = resolveAllowedValues(descriptor?.options?.operations, DEFAULT_ALLOWED_OPERATIONS);
|
|
57
|
+
const ALLOWED_OPERATIONS = new Set(OPERATION_VALUES);
|
|
58
|
+
const DEFAULT_OPERATIONS = normalizeText(descriptor?.options?.operations?.defaultValue) || OPERATION_VALUES.join(",");
|
|
34
59
|
const DEFAULT_LIST_HIDDEN_FIELD_KEYS = new Set(["createdAt", "updatedAt"]);
|
|
35
60
|
const DEFAULT_FORM_COMPONENT_FILE = "CrudAddEditForm.vue";
|
|
36
61
|
const DEFAULT_FORM_FIELDS_FILE = "CrudAddEditFormFields.js";
|
|
@@ -160,7 +185,7 @@ function parseOperationsOption(options) {
|
|
|
160
185
|
const unique = new Set();
|
|
161
186
|
for (const operation of operations) {
|
|
162
187
|
if (!ALLOWED_OPERATIONS.has(operation)) {
|
|
163
|
-
throw new Error(
|
|
188
|
+
throw new Error(`crud-ui-generator option "operations" supports only: ${OPERATION_VALUES.join(", ")}.`);
|
|
164
189
|
}
|
|
165
190
|
unique.add(operation);
|
|
166
191
|
}
|
|
@@ -233,14 +258,20 @@ function filterDisplayFields(selectedFieldKeys, fields) {
|
|
|
233
258
|
});
|
|
234
259
|
}
|
|
235
260
|
|
|
236
|
-
function filterDefaultHiddenListFields(selectedFieldKeys, fields) {
|
|
261
|
+
function filterDefaultHiddenListFields(selectedFieldKeys, fields, { recordIdFieldKey = "" } = {}) {
|
|
237
262
|
const selectedFields = Array.isArray(selectedFieldKeys) ? selectedFieldKeys : [];
|
|
238
263
|
const availableFields = Array.isArray(fields) ? fields : [];
|
|
239
264
|
if (selectedFields.length > 0) {
|
|
240
265
|
return availableFields;
|
|
241
266
|
}
|
|
242
267
|
|
|
243
|
-
|
|
268
|
+
const hiddenFieldKeys = new Set(DEFAULT_LIST_HIDDEN_FIELD_KEYS);
|
|
269
|
+
const normalizedRecordIdFieldKey = normalizeText(recordIdFieldKey);
|
|
270
|
+
if (normalizedRecordIdFieldKey) {
|
|
271
|
+
hiddenFieldKeys.add(normalizedRecordIdFieldKey);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return availableFields.filter((field) => !hiddenFieldKeys.has(normalizeText(field?.key)));
|
|
244
275
|
}
|
|
245
276
|
|
|
246
277
|
function ensureFields(fields, fallbackFields = createFieldDefinitions({})) {
|
|
@@ -408,10 +439,16 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
408
439
|
validateDisplayFieldsForOperation(selectedDisplayFields, editFieldsAll, "patch");
|
|
409
440
|
}
|
|
410
441
|
|
|
442
|
+
const listRecordIdFieldKey = hasListOperation
|
|
443
|
+
? resolveRecordIdFieldKey(listFieldsAll)
|
|
444
|
+
: "";
|
|
445
|
+
|
|
411
446
|
const listFields = hasListOperation
|
|
412
447
|
? filterDisplayFields(
|
|
413
448
|
selectedDisplayFields,
|
|
414
|
-
filterDefaultHiddenListFields(selectedDisplayFields, ensureFields(listFieldsAll)
|
|
449
|
+
filterDefaultHiddenListFields(selectedDisplayFields, ensureFields(listFieldsAll), {
|
|
450
|
+
recordIdFieldKey: listRecordIdFieldKey
|
|
451
|
+
})
|
|
415
452
|
)
|
|
416
453
|
: createFieldDefinitions({});
|
|
417
454
|
const viewFields = hasViewOperation
|
|
@@ -441,6 +478,7 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
441
478
|
pageTarget,
|
|
442
479
|
targetFile: listTargetFile,
|
|
443
480
|
placement: options?.["link-placement"],
|
|
481
|
+
componentToken: options?.["link-component-token"],
|
|
444
482
|
context: "crud-ui-generator"
|
|
445
483
|
})
|
|
446
484
|
: null;
|
|
@@ -478,11 +516,11 @@ async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
|
478
516
|
__JSKIT_UI_EDIT_FORM_FIELD_PUSH_LINES__: renderObjectPushLines("UI_EDIT_FORM_FIELDS", editFields),
|
|
479
517
|
__JSKIT_UI_MENU_MARKER__: menuMarker,
|
|
480
518
|
__JSKIT_UI_MENU_PLACEMENT_ID__: String(pageLinkTarget?.pageTarget?.placementId || ""),
|
|
481
|
-
|
|
482
|
-
__JSKIT_UI_MENU_PLACEMENT_POSITION__: String(pageLinkTarget?.placementTarget?.position || ""),
|
|
519
|
+
__JSKIT_UI_MENU_PLACEMENT_TARGET__: String(pageLinkTarget?.placementTarget?.id || ""),
|
|
483
520
|
__JSKIT_UI_MENU_COMPONENT_TOKEN__: String(pageLinkTarget?.componentToken || ""),
|
|
484
521
|
__JSKIT_UI_MENU_WORKSPACE_SUFFIX__: String(pageLinkTarget?.pageTarget?.routeUrlSuffix || ""),
|
|
485
522
|
__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__: String(pageLinkTarget?.pageTarget?.routeUrlSuffix || ""),
|
|
523
|
+
__JSKIT_UI_MENU_WHEN_LINE__: String(pageLinkTarget?.whenLine || ""),
|
|
486
524
|
__JSKIT_UI_MENU_TO_PROP_LINE__: resolveMenuToPropLine(pageLinkTarget?.linkTo || ""),
|
|
487
525
|
__JSKIT_UI_MENU_LABEL__: pageTarget.defaultName
|
|
488
526
|
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
2
|
import { resolveCrudRecordChangedEvent } from "@jskit-ai/crud-core/shared/crudNamespaceSupport";
|
|
4
3
|
import {
|
|
5
4
|
checkCrudLookupFormControl,
|
|
6
5
|
isCrudRuntimeOutputOnlyFieldKey
|
|
7
6
|
} from "@jskit-ai/crud-core/shared/crudFieldMetaSupport";
|
|
7
|
+
import { importFreshModuleFromAbsolutePath } from "@jskit-ai/kernel/server/support";
|
|
8
8
|
import {
|
|
9
9
|
normalizeCrudLookupApiPath,
|
|
10
10
|
normalizeCrudLookupNamespace,
|
|
@@ -64,7 +64,7 @@ async function loadResourceDefinition({
|
|
|
64
64
|
|
|
65
65
|
let moduleNamespace = null;
|
|
66
66
|
try {
|
|
67
|
-
moduleNamespace = await
|
|
67
|
+
moduleNamespace = await importFreshModuleFromAbsolutePath(resourceModulePath);
|
|
68
68
|
} catch (error) {
|
|
69
69
|
throw new Error(
|
|
70
70
|
`${context} could not load resource file "${resourceFile}": ${String(error?.message || error || "unknown error")}`
|
|
@@ -885,6 +885,20 @@ function buildFormColumns(fields = []) {
|
|
|
885
885
|
.join("\n");
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
function resolveRecordIdFieldKey(fields = []) {
|
|
889
|
+
const normalizedFields = Array.isArray(fields) ? fields : [];
|
|
890
|
+
const preferred =
|
|
891
|
+
normalizedFields.find((field) => normalizeText(field?.key).toLowerCase() === "id") ||
|
|
892
|
+
normalizedFields.find((field) => {
|
|
893
|
+
const key = normalizeText(field?.key).toLowerCase();
|
|
894
|
+
return key.endsWith("id") || key.endsWith("_id") || key.endsWith("-id");
|
|
895
|
+
}) ||
|
|
896
|
+
normalizedFields[0] ||
|
|
897
|
+
{ key: "id" };
|
|
898
|
+
|
|
899
|
+
return normalizeText(preferred?.key) || "id";
|
|
900
|
+
}
|
|
901
|
+
|
|
888
902
|
function renderObjectPushLines(arrayName, entries = []) {
|
|
889
903
|
const normalizedArrayName = normalizeText(arrayName);
|
|
890
904
|
if (!normalizedArrayName) {
|
|
@@ -903,17 +917,7 @@ function renderObjectPushLines(arrayName, entries = []) {
|
|
|
903
917
|
}
|
|
904
918
|
|
|
905
919
|
function resolveRecordIdExpression(fields = []) {
|
|
906
|
-
|
|
907
|
-
const preferred =
|
|
908
|
-
normalizedFields.find((field) => normalizeText(field?.key).toLowerCase() === "id") ||
|
|
909
|
-
normalizedFields.find((field) => {
|
|
910
|
-
const key = normalizeText(field?.key).toLowerCase();
|
|
911
|
-
return key.endsWith("id") || key.endsWith("_id") || key.endsWith("-id");
|
|
912
|
-
}) ||
|
|
913
|
-
normalizedFields[0] ||
|
|
914
|
-
{ key: "id" };
|
|
915
|
-
|
|
916
|
-
return toAccessorExpression("item", preferred.key);
|
|
920
|
+
return toAccessorExpression("item", resolveRecordIdFieldKey(fields));
|
|
917
921
|
}
|
|
918
922
|
|
|
919
923
|
export {
|
|
@@ -936,6 +940,7 @@ export {
|
|
|
936
940
|
buildListRowColumns,
|
|
937
941
|
buildViewColumns,
|
|
938
942
|
buildFormColumns,
|
|
943
|
+
resolveRecordIdFieldKey,
|
|
939
944
|
renderObjectPushLines,
|
|
940
945
|
resolveCrudRecordChangedEvent as resolveRecordChangedEventName,
|
|
941
946
|
resolveRecordIdExpression
|
|
@@ -24,9 +24,16 @@ async function withTempApp(run) {
|
|
|
24
24
|
path.join(appRoot, "src", "components", "ShellLayout.vue"),
|
|
25
25
|
`<template>
|
|
26
26
|
<div>
|
|
27
|
-
<ShellOutlet
|
|
28
|
-
<ShellOutlet
|
|
29
|
-
|
|
27
|
+
<ShellOutlet target="shell-layout:top-right" />
|
|
28
|
+
<ShellOutlet
|
|
29
|
+
target="shell-layout:primary-menu"
|
|
30
|
+
default
|
|
31
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
32
|
+
/>
|
|
33
|
+
<ShellOutlet
|
|
34
|
+
target="shell-layout:secondary-menu"
|
|
35
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
36
|
+
/>
|
|
30
37
|
</div>
|
|
31
38
|
</template>
|
|
32
39
|
`,
|
|
@@ -248,16 +255,50 @@ test("buildUiTemplateContext derives CRUD placeholders from the explicit target-
|
|
|
248
255
|
assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "true");
|
|
249
256
|
assert.equal(context.__JSKIT_UI_HAS_NEW_ROUTE__, "true");
|
|
250
257
|
assert.equal(context.__JSKIT_UI_HAS_EDIT_ROUTE__, "true");
|
|
258
|
+
assert.equal(context.__JSKIT_UI_MENU_WHEN_LINE__, "");
|
|
259
|
+
assert.doesNotMatch(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /Id/);
|
|
260
|
+
assert.doesNotMatch(context.__JSKIT_UI_LIST_ROW_COLUMNS__, /record\.id/);
|
|
251
261
|
assert.match(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /First Name/);
|
|
252
262
|
assert.match(context.__JSKIT_UI_LIST_ROW_COLUMNS__, /record\.firstName/);
|
|
253
263
|
assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\?\.firstName/);
|
|
254
264
|
assert.match(context.__JSKIT_UI_CREATE_FORM_COLUMNS__, /formRuntime\.form\.firstName/);
|
|
255
265
|
assert.match(context.__JSKIT_UI_EDIT_FORM_COLUMNS__, /formRuntime\.form\.email/);
|
|
256
266
|
assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
|
|
267
|
+
assert.equal(context.__JSKIT_UI_LIST_RECORD_ID_EXPR__, "item.id");
|
|
257
268
|
assert.equal(context.__JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__, "\"firstName\"");
|
|
258
269
|
});
|
|
259
270
|
});
|
|
260
271
|
|
|
272
|
+
test("buildUiTemplateContext derives menu auth visibility from the target surface policy", async () => {
|
|
273
|
+
await withTempApp(async (appRoot) => {
|
|
274
|
+
await writeFile(
|
|
275
|
+
path.join(appRoot, "config", "public.js"),
|
|
276
|
+
`export const config = {
|
|
277
|
+
surfaceAccessPolicies: {
|
|
278
|
+
authenticated: {
|
|
279
|
+
requireAuth: true
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
surfaceDefinitions: {
|
|
283
|
+
app: { id: "app", pagesRoot: "app", enabled: true, accessPolicyId: "authenticated" }
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
`,
|
|
287
|
+
"utf8"
|
|
288
|
+
);
|
|
289
|
+
await writeResource(appRoot, RESOURCE_FILE, FULL_RESOURCE_SOURCE);
|
|
290
|
+
|
|
291
|
+
const context = await buildUiTemplateContext({
|
|
292
|
+
appRoot,
|
|
293
|
+
options: createOptions({
|
|
294
|
+
"target-root": "app/customers"
|
|
295
|
+
})
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
assert.equal(context.__JSKIT_UI_MENU_WHEN_LINE__, " when: ({ auth }) => Boolean(auth?.authenticated)\n");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
261
302
|
test("buildUiTemplateContext falls back to target-root leaf for namespace when resource.resource is missing", async () => {
|
|
262
303
|
await withTempApp(async (appRoot) => {
|
|
263
304
|
await writeResource(
|
|
@@ -299,6 +340,23 @@ test("buildUiTemplateContext filters rendered fields when display-fields is prov
|
|
|
299
340
|
});
|
|
300
341
|
});
|
|
301
342
|
|
|
343
|
+
test("buildUiTemplateContext keeps an explicitly requested id display field", async () => {
|
|
344
|
+
await withTempApp(async (appRoot) => {
|
|
345
|
+
await writeResource(appRoot, RESOURCE_FILE, FULL_RESOURCE_SOURCE);
|
|
346
|
+
|
|
347
|
+
const context = await buildUiTemplateContext({
|
|
348
|
+
appRoot,
|
|
349
|
+
options: createOptions({
|
|
350
|
+
operations: "list,view",
|
|
351
|
+
"display-fields": "id,firstName"
|
|
352
|
+
})
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
assert.match(context.__JSKIT_UI_LIST_HEADER_COLUMNS__, /Id/);
|
|
356
|
+
assert.match(context.__JSKIT_UI_LIST_ROW_COLUMNS__, /record\.id/);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
302
360
|
test("buildUiTemplateContext maps lookup metadata into form field definitions", async () => {
|
|
303
361
|
await withTempApp(async (appRoot) => {
|
|
304
362
|
await writeResource(appRoot, RESOURCE_FILE, LOOKUP_RESOURCE_SOURCE);
|
|
@@ -325,9 +383,8 @@ test("buildUiTemplateContext resolves list placement from the app default shell
|
|
|
325
383
|
});
|
|
326
384
|
|
|
327
385
|
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_ID__, "ui-generator.page.admin.customers.link");
|
|
328
|
-
assert.equal(context.
|
|
329
|
-
assert.equal(context.
|
|
330
|
-
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
|
|
386
|
+
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_TARGET__, "shell-layout:primary-menu");
|
|
387
|
+
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.surface-aware-menu-link-item");
|
|
331
388
|
assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/customers");
|
|
332
389
|
assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/customers");
|
|
333
390
|
assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, "");
|
|
@@ -345,7 +402,7 @@ test("buildUiTemplateContext infers tab placement and relative link-to from the
|
|
|
345
402
|
`<template>
|
|
346
403
|
<SectionContainerShell>
|
|
347
404
|
<template #tabs>
|
|
348
|
-
<ShellOutlet
|
|
405
|
+
<ShellOutlet target="catalog:sub-pages" />
|
|
349
406
|
</template>
|
|
350
407
|
<RouterView />
|
|
351
408
|
</SectionContainerShell>
|
|
@@ -360,14 +417,44 @@ test("buildUiTemplateContext infers tab placement and relative link-to from the
|
|
|
360
417
|
})
|
|
361
418
|
});
|
|
362
419
|
|
|
363
|
-
assert.equal(context.
|
|
364
|
-
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "sub-pages");
|
|
420
|
+
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_TARGET__, "catalog:sub-pages");
|
|
365
421
|
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
|
|
366
422
|
assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./products\",\n");
|
|
367
423
|
assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/catalog/products");
|
|
368
424
|
});
|
|
369
425
|
});
|
|
370
426
|
|
|
427
|
+
test("buildUiTemplateContext prefers an outlet-declared default link token over subpage heuristics", async () => {
|
|
428
|
+
await withTempApp(async (appRoot) => {
|
|
429
|
+
await writeResource(appRoot, RESOURCE_FILE, FULL_RESOURCE_SOURCE);
|
|
430
|
+
await writeFileInApp(
|
|
431
|
+
appRoot,
|
|
432
|
+
"src/pages/admin/settings.vue",
|
|
433
|
+
`<template>
|
|
434
|
+
<section>
|
|
435
|
+
<ShellOutlet
|
|
436
|
+
target="admin-settings:primary-menu"
|
|
437
|
+
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
438
|
+
/>
|
|
439
|
+
<RouterView />
|
|
440
|
+
</section>
|
|
441
|
+
</template>
|
|
442
|
+
`
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const context = await buildUiTemplateContext({
|
|
446
|
+
appRoot,
|
|
447
|
+
options: createOptions({
|
|
448
|
+
"target-root": "admin/settings/customers"
|
|
449
|
+
})
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_TARGET__, "admin-settings:primary-menu");
|
|
453
|
+
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.surface-aware-menu-link-item");
|
|
454
|
+
assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./customers\",\n");
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
371
458
|
test("buildUiTemplateContext honors explicit link-placement override", async () => {
|
|
372
459
|
await withTempApp(async (appRoot) => {
|
|
373
460
|
await writeResource(appRoot, RESOURCE_FILE, FULL_RESOURCE_SOURCE);
|
|
@@ -379,25 +466,24 @@ test("buildUiTemplateContext honors explicit link-placement override", async ()
|
|
|
379
466
|
})
|
|
380
467
|
});
|
|
381
468
|
|
|
382
|
-
assert.equal(context.
|
|
383
|
-
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "
|
|
469
|
+
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_TARGET__, "shell-layout:secondary-menu");
|
|
470
|
+
assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.surface-aware-menu-link-item");
|
|
384
471
|
});
|
|
385
472
|
});
|
|
386
473
|
|
|
387
|
-
test("buildUiTemplateContext
|
|
474
|
+
test("buildUiTemplateContext accepts target-roots with a src/pages prefix", async () => {
|
|
388
475
|
await withTempApp(async (appRoot) => {
|
|
389
476
|
await writeResource(appRoot, RESOURCE_FILE, FULL_RESOURCE_SOURCE);
|
|
390
477
|
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
);
|
|
478
|
+
const context = await buildUiTemplateContext({
|
|
479
|
+
appRoot,
|
|
480
|
+
options: createOptions({
|
|
481
|
+
"target-root": "src/pages/admin/customers"
|
|
482
|
+
})
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
assert.equal(context.__JSKIT_UI_SURFACE_ID__, "admin");
|
|
486
|
+
assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_ID__, "ui-generator.page.admin.customers.link");
|
|
401
487
|
});
|
|
402
488
|
});
|
|
403
489
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import descriptor from "../package.descriptor.mjs";
|
|
4
|
+
|
|
5
|
+
test("crud-ui-generator operations option exposes structured csv-enum metadata", () => {
|
|
6
|
+
assert.equal(descriptor.kind, "generator");
|
|
7
|
+
assert.equal(descriptor.options?.operations?.validationType, "csv-enum");
|
|
8
|
+
assert.deepEqual(
|
|
9
|
+
descriptor.options?.operations?.allowedValues,
|
|
10
|
+
["list", "view", "new", "edit"]
|
|
11
|
+
);
|
|
12
|
+
assert.equal(descriptor.options?.operations?.defaultValue, "list,view,new,edit");
|
|
13
|
+
assert.equal(descriptor.metadata?.generatorSubcommands?.crud?.optionNames?.includes("operations"), true);
|
|
14
|
+
});
|
package/README.md
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# @jskit-ai/crud-ui-generator
|
|
2
|
-
|
|
3
|
-
Generate CRUD route trees from an explicit route root relative to `src/pages/...`.
|
|
4
|
-
|
|
5
|
-
## Mental Model
|
|
6
|
-
|
|
7
|
-
This generator follows the same file-driven model as `@jskit-ai/ui-generator`.
|
|
8
|
-
|
|
9
|
-
- You point at one explicit route root relative to `src/pages/...`.
|
|
10
|
-
- The surface is derived from that path.
|
|
11
|
-
- The visible route URL is derived from that path.
|
|
12
|
-
- The generated list-page link placement is inferred from the nearest real parent subpages host when there is one.
|
|
13
|
-
- Explicit overrides still exist, but they are not required for the normal case.
|
|
14
|
-
|
|
15
|
-
## Command
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npx jskit generate @jskit-ai/crud-ui-generator crud \
|
|
19
|
-
admin/catalog/index/products \
|
|
20
|
-
--resource-file packages/products/src/shared/productResource.js
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
That generates:
|
|
24
|
-
|
|
25
|
-
- `src/pages/admin/catalog/index/products/index.vue`
|
|
26
|
-
- `src/pages/admin/catalog/index/products/[recordId]/index.vue`
|
|
27
|
-
- `src/pages/admin/catalog/index/products/new.vue`
|
|
28
|
-
- `src/pages/admin/catalog/index/products/[recordId]/edit.vue`
|
|
29
|
-
- `src/pages/admin/catalog/index/products/_components/...`
|
|
30
|
-
|
|
31
|
-
## Defaults
|
|
32
|
-
|
|
33
|
-
From the explicit `target-root`, the generator derives:
|
|
34
|
-
|
|
35
|
-
- the owning surface
|
|
36
|
-
- the visible CRUD route
|
|
37
|
-
- the default list-page placement target
|
|
38
|
-
- the default link component token
|
|
39
|
-
- the default relative `to` for nested subpage links
|
|
40
|
-
|
|
41
|
-
The generated list-page link follows the same parent-host inference as `@jskit-ai/ui-generator page`.
|
|
42
|
-
|
|
43
|
-
If you want the detailed behavior, read:
|
|
44
|
-
|
|
45
|
-
- `npx jskit generate ui-generator page help`
|
|
46
|
-
|
|
47
|
-
That is where the parent-host, tab-link, and relative `to` behavior is explained.
|
|
48
|
-
|
|
49
|
-
## Options
|
|
50
|
-
|
|
51
|
-
- `--resource-file`: required resource module path relative to the app root
|
|
52
|
-
- `--operations`: optional comma-separated subset of `list,view,new,edit`, defaulting to all four
|
|
53
|
-
- `--display-fields`: optional comma-separated subset of fields to render
|
|
54
|
-
- `--id-param`: optional route param name for record pages, default `recordId`
|
|
55
|
-
- `--link-placement`: optional link placement override for the generated list page
|
|
56
|
-
- `--namespace`: optional CRUD namespace override when the resource module does not expose `resource.resource`
|
|
57
|
-
|
|
58
|
-
## Route Roots
|
|
59
|
-
|
|
60
|
-
Use the real route root you want in the app:
|
|
61
|
-
|
|
62
|
-
- index-route parent child: `admin/catalog/index/products`
|
|
63
|
-
- nested under a record view page: `admin/customers/[customerId]/index/pets`
|
|
64
|
-
- file-route parent child: `admin/customers/[customerId]/orders`
|
|
65
|
-
- top-level route: `admin/products`
|
|
66
|
-
|
|
67
|
-
There is no separate `surface`, `directory-prefix`, `route-path`, or `container` assembly step anymore.
|