@jskit-ai/crud-ui-generator 0.1.15 → 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.
@@ -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.15",
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 host:position override for the generated list-page link placement."
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-host inference, tab-link inference, and relative `props.to` inference as a normal generated page. If you want the detailed host behavior, read `jskit generate ui-generator page help`."
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: ["resource-file", "operations", "display-fields", "id-param", "link-placement", "namespace", "force"],
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.47"
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 host: \"__JSKIT_UI_MENU_PLACEMENT_HOST__\",\n position: \"__JSKIT_UI_MENU_PLACEMENT_POSITION__\",\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 when: ({ auth }) => Boolean(auth?.authenticated)\n });\n}\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.15",
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.40",
10
- "@jskit-ai/kernel": "0.1.32"
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 ALLOWED_OPERATIONS = new Set(["list", "view", "new", "edit"]);
33
- const DEFAULT_OPERATIONS = "list,view,new,edit";
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('crud-ui-generator option "operations" supports only: list, view, new, edit.');
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
- return availableFields.filter((field) => !DEFAULT_LIST_HIDDEN_FIELD_KEYS.has(normalizeText(field?.key)));
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
- __JSKIT_UI_MENU_PLACEMENT_HOST__: String(pageLinkTarget?.placementTarget?.host || ""),
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 import(`${pathToFileURL(resourceModulePath).href}?t=${Date.now()}_${Math.random()}`);
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
- const normalizedFields = Array.isArray(fields) ? fields : [];
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 host="shell-layout" position="top-right" />
28
- <ShellOutlet host="shell-layout" position="primary-menu" default />
29
- <ShellOutlet host="shell-layout" position="secondary-menu" />
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.__JSKIT_UI_MENU_PLACEMENT_HOST__, "shell-layout");
329
- assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "primary-menu");
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 host="catalog" position="sub-pages" />
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.__JSKIT_UI_MENU_PLACEMENT_HOST__, "catalog");
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.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "secondary-menu");
383
- assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
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 rejects target-roots with a src/pages prefix", async () => {
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 assert.rejects(
392
- () =>
393
- buildUiTemplateContext({
394
- appRoot,
395
- options: createOptions({
396
- "target-root": "src/pages/admin/customers"
397
- })
398
- }),
399
- /must be relative to src\/pages\/, without the src\/pages\/ prefix/
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.