@jskit-ai/crud-ui-generator 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.5",
4
+ version: "0.1.6",
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.36"
139
+ "@jskit-ai/users-web": "0.1.37"
124
140
  },
125
141
  dev: {}
126
142
  },
@@ -204,7 +220,7 @@ export default Object.freeze({
204
220
  skipIfContains:
205
221
  "jskit:ui-generator.menu:${option:namespace|kebab}:${option:directory-prefix|path}:${option:container|path}:${option:route-path|path}",
206
222
  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: \"/${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}\",\n nonWorkspaceSuffix: \"/${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n });\n}\n",
223
+ "\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
224
  reason: "Append generated UI menu placement.",
209
225
  category: "ui-generator",
210
226
  id: "ui-generator-placement-menu",
@@ -219,8 +235,32 @@ export default Object.freeze({
219
235
  in: ["list"]
220
236
  },
221
237
  {
222
- option: "route-path",
223
- notContains: "["
238
+ any: [
239
+ {
240
+ all: [
241
+ {
242
+ option: "route-path",
243
+ notContains: "["
244
+ },
245
+ {
246
+ option: "directory-prefix",
247
+ notContains: "["
248
+ }
249
+ ]
250
+ },
251
+ {
252
+ all: [
253
+ {
254
+ option: "placement",
255
+ contains: ":"
256
+ },
257
+ {
258
+ option: "placement-to",
259
+ notEquals: ""
260
+ }
261
+ ]
262
+ }
263
+ ]
224
264
  }
225
265
  ]
226
266
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-ui-generator",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "dependencies": {
9
- "@jskit-ai/crud-core": "0.1.30",
10
- "@jskit-ai/kernel": "0.1.22"
9
+ "@jskit-ai/crud-core": "0.1.31",
10
+ "@jskit-ai/kernel": "0.1.23"
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.section-shell.tab-link-item";
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
- if (!container) {
55
- return routePath;
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
- return `${container}/${routePath}`;
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 || routePath.includes("[")) {
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
 
@@ -281,6 +281,62 @@ function toFieldLabel(key) {
281
281
  .join(" ");
282
282
  }
283
283
 
284
+ function isSupportedSelectOptionValue(value) {
285
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
286
+ }
287
+
288
+ function toSelectOptionLabel(value) {
289
+ if (typeof value === "string") {
290
+ const normalizedValue = normalizeText(value);
291
+ return normalizedValue ? toFieldLabel(normalizedValue) : "";
292
+ }
293
+ if (typeof value === "number" || typeof value === "boolean") {
294
+ return String(value);
295
+ }
296
+ return "";
297
+ }
298
+
299
+ function toSelectOptionIdentity(value) {
300
+ return `${typeof value}:${String(value)}`;
301
+ }
302
+
303
+ function normalizeFieldUiOptions(rawOptions, { context = "resource fieldMeta ui.options" } = {}) {
304
+ if (rawOptions === undefined || rawOptions === null) {
305
+ return [];
306
+ }
307
+ if (!Array.isArray(rawOptions)) {
308
+ throw new Error(`${context} must be an array of { value, label? } entries.`);
309
+ }
310
+
311
+ const options = [];
312
+ const seenValues = new Set();
313
+ for (const [index, rawEntry] of rawOptions.entries()) {
314
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
315
+ throw new Error(`${context}[${index}] must be an object.`);
316
+ }
317
+
318
+ const value = rawEntry.value;
319
+ if (!isSupportedSelectOptionValue(value)) {
320
+ throw new Error(`${context}[${index}].value must be a string, number, or boolean.`);
321
+ }
322
+
323
+ const identity = toSelectOptionIdentity(value);
324
+ if (seenValues.has(identity)) {
325
+ continue;
326
+ }
327
+ seenValues.add(identity);
328
+
329
+ const explicitLabel = normalizeText(rawEntry.label);
330
+ const fallbackLabel = toSelectOptionLabel(value);
331
+ options.push({
332
+ value,
333
+ label: explicitLabel || fallbackLabel || String(value)
334
+ });
335
+ }
336
+
337
+ return options;
338
+ }
339
+
284
340
  function stripLookupIdSuffix(key = "") {
285
341
  const normalizedKey = normalizeText(key);
286
342
  if (!normalizedKey) {
@@ -425,6 +481,9 @@ function buildResourceFieldMetaMap(resource = {}) {
425
481
  }
426
482
 
427
483
  const relation = normalizeLookupRelation(rawEntry.relation);
484
+ const fieldUiOptions = normalizeFieldUiOptions(rawEntry?.ui?.options, {
485
+ context: `resource.fieldMeta["${key}"].ui.options`
486
+ });
428
487
  if (relation) {
429
488
  nextEntry.relation = relation;
430
489
  const formControl = checkCrudLookupFormControl(rawEntry?.ui?.formControl, {
@@ -437,6 +496,12 @@ function buildResourceFieldMetaMap(resource = {}) {
437
496
  };
438
497
  }
439
498
  }
499
+ if (fieldUiOptions.length > 0) {
500
+ nextEntry.ui = {
501
+ ...(nextEntry.ui || {}),
502
+ options: fieldUiOptions
503
+ };
504
+ }
440
505
 
441
506
  map[key] = nextEntry;
442
507
  }
@@ -559,6 +624,16 @@ function createFormFieldDefinitions(
559
624
 
560
625
  const schemaType = resolveSchemaType(schema);
561
626
  const relation = toLookupRelation(fieldMetaMap, key, { lookupContainerKey });
627
+ const fieldUiOptions = Array.isArray(fieldMetaMap?.[key]?.ui?.options)
628
+ ? fieldMetaMap[key].ui.options
629
+ : [];
630
+ const schemaEnumValues = Array.isArray(schemaType.schema?.enum) ? schemaType.schema.enum : [];
631
+ if (!relation && schemaEnumValues.length > 0 && fieldUiOptions.length < 1) {
632
+ throw new Error(
633
+ `resource form field "${key}" defines schema enum values but is missing resource.fieldMeta["${key}"].ui.options.`
634
+ );
635
+ }
636
+ const selectOptions = relation ? [] : fieldUiOptions;
562
637
  const lookupFormControl = relation
563
638
  ? checkCrudLookupFormControl(fieldMetaMap?.[key]?.ui?.formControl, {
564
639
  context: `resource.fieldMeta["${key}"].ui.formControl`,
@@ -573,9 +648,14 @@ function createFormFieldDefinitions(
573
648
  nullable: schemaType.nullable,
574
649
  relation,
575
650
  inputType: resolveFormInputType(schemaType.type, schemaType.format),
576
- component: resolveFormFieldComponent(schemaType.type, relation),
651
+ component: selectOptions.length > 0
652
+ ? "select"
653
+ : resolveFormFieldComponent(schemaType.type, relation),
577
654
  maxLength: toPositiveInteger(schemaType.schema?.maxLength)
578
655
  };
656
+ if (selectOptions.length > 0) {
657
+ fieldDefinition.options = selectOptions;
658
+ }
579
659
  if (normalizedParentRouteParamKey && key === normalizedParentRouteParamKey) {
580
660
  fieldDefinition.hidden = true;
581
661
  fieldDefinition.routeParamKey = normalizedParentRouteParamKey;
@@ -622,6 +702,10 @@ function escapeHtml(value) {
622
702
  .replaceAll("'", "&#39;");
623
703
  }
624
704
 
705
+ function serializeTemplateBindingValue(value) {
706
+ return JSON.stringify(value).replaceAll("'", "\\u0027");
707
+ }
708
+
625
709
  function buildListHeaderColumns(fields = []) {
626
710
  return (Array.isArray(fields) ? fields : [])
627
711
  .map((field) => ` <th>${escapeHtml(field.label)}</th>`)
@@ -723,11 +807,25 @@ function buildFormColumns(fields = []) {
723
807
  label="${label}"
724
808
  color="primary"
725
809
  hide-details="auto"
726
- :disabled="
727
- !formRuntime.addEdit.canSave ||
728
- formRuntime.addEdit.isSaving ||
729
- formRuntime.addEdit.isRefetching
730
- "
810
+ :disabled="formRuntime.addEdit.isFieldLocked"
811
+ :error-messages='${fieldErrorExpression}'
812
+ />
813
+ </v-col>`;
814
+ }
815
+
816
+ if (component === "select") {
817
+ const selectOptions = Array.isArray(field?.options) ? field.options : [];
818
+ return ` <v-col cols="12" md="6">
819
+ <v-select
820
+ v-model="${formAccessor}"
821
+ label="${label}"
822
+ variant="outlined"
823
+ density="comfortable"
824
+ :items='${serializeTemplateBindingValue(selectOptions)}'
825
+ item-title="label"
826
+ item-value="value"
827
+ :disabled="formRuntime.addEdit.isFieldLocked"
828
+ :clearable="${field.nullable === true ? "true" : "false"}"
731
829
  :error-messages='${fieldErrorExpression}'
732
830
  />
733
831
  </v-col>`;
@@ -753,11 +851,7 @@ function buildFormColumns(fields = []) {
753
851
  item-value="value"
754
852
  ${lookupNoFilterLine}
755
853
  :loading='resolveLookupLoading(${JSON.stringify(key)})'
756
- :disabled="
757
- !formRuntime.addEdit.canSave ||
758
- formRuntime.addEdit.isSaving ||
759
- formRuntime.addEdit.isRefetching
760
- "
854
+ :disabled="formRuntime.addEdit.isFieldLocked"
761
855
  :clearable="${field.nullable === true ? "true" : "false"}"
762
856
  :error-messages='${fieldErrorExpression}'
763
857
  />
@@ -776,11 +870,7 @@ function buildFormColumns(fields = []) {
776
870
  variant="outlined"
777
871
  density="comfortable"
778
872
  :maxlength="${maxLength}"
779
- :readonly="
780
- !formRuntime.addEdit.canSave ||
781
- formRuntime.addEdit.isSaving ||
782
- formRuntime.addEdit.isRefetching
783
- "
873
+ :readonly="formRuntime.addEdit.isFieldLocked"
784
874
  :error-messages='${fieldErrorExpression}'
785
875
  />
786
876
  </v-col>`;
@@ -11,18 +11,14 @@
11
11
  <v-btn
12
12
  v-if="UI_CANCEL_URL"
13
13
  variant="text"
14
- :to="formRuntime.addEdit.resolveParams(UI_CANCEL_URL)"
14
+ :to="{ path: formRuntime.addEdit.resolveParams(UI_CANCEL_URL), query: $route.query }"
15
15
  >
16
16
  Cancel
17
17
  </v-btn>
18
18
  <v-btn
19
19
  color="primary"
20
20
  :loading="formRuntime.addEdit.isSaving"
21
- :disabled="
22
- formRuntime.addEdit.isInitialLoading ||
23
- formRuntime.addEdit.isRefetching ||
24
- !formRuntime.addEdit.canSave
25
- "
21
+ :disabled="formRuntime.addEdit.isSubmitDisabled"
26
22
  @click="formRuntime.addEdit.submit"
27
23
  >
28
24
  Save changes
@@ -40,8 +40,8 @@
40
40
  <tr>
41
41
  __JSKIT_UI_LIST_HEADER_COLUMNS__
42
42
  <!-- jskit:crud-ui-fields:list-header -->
43
- <th v-if="UI_VIEW_URL" class="text-right">Open</th>
44
- <th v-if="UI_EDIT_URL" class="text-right">Edit</th>
43
+ <th v-if="UI_VIEW_URL" class="text-right"></th>
44
+ <th v-if="UI_EDIT_URL" class="text-right"></th>
45
45
  </tr>
46
46
  </thead>
47
47
  <tbody>
@@ -52,7 +52,7 @@ __JSKIT_UI_LIST_ROW_COLUMNS__
52
52
  <v-btn
53
53
  size="small"
54
54
  variant="text"
55
- :to="records.resolveViewUrl(record)"
55
+ :to="{ path: records.resolveViewUrl(record), query: $route.query }"
56
56
  :disabled="!records.resolveViewUrl(record)"
57
57
  >
58
58
  Open
@@ -62,7 +62,7 @@ __JSKIT_UI_LIST_ROW_COLUMNS__
62
62
  <v-btn
63
63
  size="small"
64
64
  variant="text"
65
- :to="records.resolveEditUrl(record)"
65
+ :to="{ path: records.resolveEditUrl(record), query: $route.query }"
66
66
  :disabled="!records.resolveEditUrl(record)"
67
67
  >
68
68
  Edit
@@ -92,6 +92,7 @@ const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? `./:${UI_RECORD_ID_PARAM}` : "
92
92
  const UI_EDIT_URL = __JSKIT_UI_HAS_EDIT_ROUTE__ ? `./:${UI_RECORD_ID_PARAM}/edit` : "";
93
93
  const UI_NEW_URL = __JSKIT_UI_HAS_NEW_ROUTE__ ? "./new" : "";
94
94
  const UI_RECORD_CHANGED_EVENTS = __JSKIT_UI_LIST_REALTIME_EVENTS__;
95
+ const UI_ROUTE_QUERY_BLACKLIST = Object.freeze(["include", "cursor", "limit"]);
95
96
 
96
97
  const records = useList({
97
98
  adapter: UI_OPERATION_ADAPTER || undefined,
@@ -108,6 +109,13 @@ const records = useList({
108
109
  enabled: true,
109
110
  mode: "query"
110
111
  },
112
+ syncToRoute: {
113
+ enabled: true,
114
+ mode: "replace",
115
+ search: true,
116
+ queryParams: true,
117
+ queryParamBlacklist: UI_ROUTE_QUERY_BLACKLIST
118
+ },
111
119
  placementSource: "ui-generator.${option:namespace|kebab}.list",
112
120
  fallbackLoadError: "Unable to load records.",
113
121
  recordIdParam: UI_RECORD_ID_PARAM,
@@ -12,11 +12,7 @@
12
12
  <v-btn
13
13
  color="primary"
14
14
  :loading="formRuntime.addEdit.isSaving"
15
- :disabled="
16
- formRuntime.addEdit.isInitialLoading ||
17
- formRuntime.addEdit.isRefetching ||
18
- !formRuntime.addEdit.canSave
19
- "
15
+ :disabled="formRuntime.addEdit.isSubmitDisabled"
20
16
  @click="formRuntime.addEdit.submit"
21
17
  >
22
18
  Save ${option:namespace|singular|default(record)}
@@ -4,12 +4,32 @@
4
4
  <v-card-item>
5
5
  <div class="d-flex align-center ga-3 flex-wrap w-100">
6
6
  <div>
7
- <v-card-title class="px-0">${option:namespace|singular|pascal|default(Record)}</v-card-title>
7
+ <v-card-title class="px-0">
8
+ {{
9
+ view.resolveRecordTitle(view.record, {
10
+ fallbackKey: UI_VIEW_TITLE_FALLBACK_FIELD_KEY,
11
+ defaultValue: "${option:namespace|singular|pascal|default(Record)}"
12
+ })
13
+ }}
14
+ </v-card-title>
8
15
  <v-card-subtitle class="px-0">View and manage this ${option:namespace|singular|default(record)}.</v-card-subtitle>
9
16
  </div>
10
17
  <v-spacer />
11
- <v-btn v-if="UI_LIST_URL" variant="text" :to="view.resolveParams(UI_LIST_URL)">Back to ${option:namespace|plural|default(records)}</v-btn>
12
- <v-btn v-if="UI_EDIT_URL" color="primary" variant="outlined" :to="view.resolveParams(UI_EDIT_URL)">Edit</v-btn>
18
+ <v-btn
19
+ v-if="UI_LIST_URL"
20
+ variant="text"
21
+ :to="{ path: view.resolveParams(UI_LIST_URL), query: $route.query }"
22
+ >
23
+ Back to ${option:namespace|plural|default(records)}
24
+ </v-btn>
25
+ <v-btn
26
+ v-if="UI_EDIT_URL"
27
+ color="primary"
28
+ variant="outlined"
29
+ :to="{ path: view.resolveParams(UI_EDIT_URL), query: $route.query }"
30
+ >
31
+ Edit
32
+ </v-btn>
13
33
  </div>
14
34
  </v-card-item>
15
35
  <v-divider />
@@ -43,6 +63,7 @@ const UI_API_BASE_URL = "${option:api-path|trim}";
43
63
  const UI_VIEW_API_URL = `${UI_API_BASE_URL}/:${UI_RECORD_ID_PARAM}`;
44
64
  const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? ".." : "";
45
65
  const UI_EDIT_URL = __JSKIT_UI_HAS_EDIT_ROUTE__ ? "./edit" : "";
66
+ const UI_VIEW_TITLE_FALLBACK_FIELD_KEY = __JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__;
46
67
  const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
47
68
 
48
69
  const view = useView({
@@ -193,6 +193,7 @@ test("buildUiTemplateContext derives list/view/new/edit placeholders from resour
193
193
  assert.doesNotMatch(context.__JSKIT_UI_LIST_ROW_COLUMNS__, /record\.updatedAt/);
194
194
  assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\?\.vip/);
195
195
  assert.match(context.__JSKIT_UI_VIEW_COLUMNS__, /view\.record\?\.updatedAt/);
196
+ assert.equal(context.__JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__, "\"firstName\"");
196
197
  assert.equal(context.__JSKIT_UI_LIST_RECORD_ID_EXPR__, "item.id");
197
198
  assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
198
199
  assert.equal(context.__JSKIT_UI_LIST_REALTIME_EVENTS__, "[\"customers.record.changed\"]");
@@ -495,10 +496,149 @@ export { resource };
495
496
  assert.equal(context.__JSKIT_UI_HAS_VIEW_ROUTE__, "true");
496
497
  assert.equal(context.__JSKIT_UI_HAS_NEW_ROUTE__, "false");
497
498
  assert.equal(context.__JSKIT_UI_HAS_EDIT_ROUTE__, "false");
499
+ assert.equal(context.__JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__, "\"fullName\"");
498
500
  assert.equal(context.__JSKIT_UI_RECORD_CHANGED_EVENT__, "\"customers.record.changed\"");
499
501
  });
500
502
  });
501
503
 
504
+ test("buildUiTemplateContext renders enum fields as select controls in forms", async () => {
505
+ await withTempApp(async (appRoot) => {
506
+ const resourceFile = "packages/pets/src/shared/petResource.js";
507
+ await writeResource(
508
+ appRoot,
509
+ resourceFile,
510
+ `const resource = {
511
+ operations: {
512
+ create: {
513
+ bodyValidator: {
514
+ schema: {
515
+ type: "object",
516
+ properties: {
517
+ temperament: { type: "string", enum: ["relaxed", "friendly_excitable", "unknown"] }
518
+ }
519
+ }
520
+ },
521
+ outputValidator: {
522
+ schema: {
523
+ type: "object",
524
+ properties: {
525
+ id: { type: "integer" }
526
+ }
527
+ }
528
+ }
529
+ },
530
+ patch: {
531
+ bodyValidator: {
532
+ schema: {
533
+ type: "object",
534
+ properties: {
535
+ temperament: { type: "string", enum: ["relaxed", "friendly_excitable", "unknown"] }
536
+ }
537
+ }
538
+ },
539
+ outputValidator: {
540
+ schema: {
541
+ type: "object",
542
+ properties: {
543
+ id: { type: "integer" }
544
+ }
545
+ }
546
+ }
547
+ }
548
+ },
549
+ fieldMeta: [
550
+ {
551
+ key: "temperament",
552
+ ui: {
553
+ formControl: "select",
554
+ options: [
555
+ { value: "relaxed", label: "Relaxed" },
556
+ { value: "friendly_excitable", label: "Friendly / Excitable" },
557
+ { value: "unknown", label: "Unknown temperament" }
558
+ ]
559
+ }
560
+ }
561
+ ]
562
+ };
563
+
564
+ export { resource };
565
+ `
566
+ );
567
+
568
+ const context = await buildUiTemplateContext({
569
+ appRoot,
570
+ options: {
571
+ namespace: "pets-ui",
572
+ "api-path": "/pets",
573
+ operations: "new,edit",
574
+ "resource-file": resourceFile
575
+ }
576
+ });
577
+
578
+ const createFields = JSON.parse(context.__JSKIT_UI_CREATE_FORM_FIELDS__);
579
+ const temperament = createFields.find((field) => field.key === "temperament");
580
+ assert.ok(temperament);
581
+ assert.equal(temperament.component, "select");
582
+ assert.deepEqual(temperament.options, [
583
+ { value: "relaxed", label: "Relaxed" },
584
+ { value: "friendly_excitable", label: "Friendly / Excitable" },
585
+ { value: "unknown", label: "Unknown temperament" }
586
+ ]);
587
+ assert.match(context.__JSKIT_UI_CREATE_FORM_COLUMNS__, /<v-select/);
588
+ assert.match(context.__JSKIT_UI_CREATE_FORM_COLUMNS__, /:disabled="formRuntime\.addEdit\.isFieldLocked"/);
589
+ assert.doesNotMatch(context.__JSKIT_UI_CREATE_FORM_COLUMNS__, /!formRuntime\.addEdit\.canSave/);
590
+ });
591
+ });
592
+
593
+ test("buildUiTemplateContext fails when enum schema field omits resource fieldMeta ui.options", async () => {
594
+ await withTempApp(async (appRoot) => {
595
+ const resourceFile = "packages/pets/src/shared/petResource.js";
596
+ await writeResource(
597
+ appRoot,
598
+ resourceFile,
599
+ `const resource = {
600
+ operations: {
601
+ create: {
602
+ bodyValidator: {
603
+ schema: {
604
+ type: "object",
605
+ properties: {
606
+ temperament: { type: "string", enum: ["relaxed", "unknown"] }
607
+ }
608
+ }
609
+ },
610
+ outputValidator: {
611
+ schema: {
612
+ type: "object",
613
+ properties: {
614
+ id: { type: "integer" }
615
+ }
616
+ }
617
+ }
618
+ }
619
+ }
620
+ };
621
+
622
+ export { resource };
623
+ `
624
+ );
625
+
626
+ await assert.rejects(
627
+ () =>
628
+ buildUiTemplateContext({
629
+ appRoot,
630
+ options: {
631
+ namespace: "pets-ui",
632
+ "api-path": "/pets",
633
+ operations: "new",
634
+ "resource-file": resourceFile
635
+ }
636
+ }),
637
+ /schema enum values but is missing resource\.fieldMeta\["temperament"\]\.ui\.options/
638
+ );
639
+ });
640
+ });
641
+
502
642
  test("buildUiTemplateContext maps lookup relations from resource fieldMeta into lookup form fields", async () => {
503
643
  await withTempApp(async (appRoot) => {
504
644
  const resourceFile = "packages/contacts/src/shared/contactResource.js";
@@ -1049,7 +1189,7 @@ test("buildUiTemplateContext defaults list placement to container host when cont
1049
1189
 
1050
1190
  assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "practice");
1051
1191
  assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "sub-pages");
1052
- assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.section-shell.tab-link-item");
1192
+ assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
1053
1193
  });
1054
1194
  });
1055
1195
 
@@ -1105,3 +1245,85 @@ test("buildUiTemplateContext applies explicit placement override and validates t
1105
1245
  );
1106
1246
  });
1107
1247
  });
1248
+
1249
+ test("buildUiTemplateContext allows placement component token override", async () => {
1250
+ await withTempApp(async (appRoot) => {
1251
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
1252
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
1253
+ await writeShellLayout(appRoot);
1254
+
1255
+ const context = await buildUiTemplateContext({
1256
+ appRoot,
1257
+ options: {
1258
+ namespace: "customers-ui",
1259
+ "api-path": "/crud/customers",
1260
+ "route-path": "ops/customers",
1261
+ operations: "list",
1262
+ placement: "shell-layout:secondary-menu",
1263
+ "placement-component-token": "local.main.ui.tab-link-item",
1264
+ "resource-file": resourceFile,
1265
+ }
1266
+ });
1267
+
1268
+ assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
1269
+ });
1270
+ });
1271
+
1272
+ test("buildUiTemplateContext renders optional placement-to props line", async () => {
1273
+ await withTempApp(async (appRoot) => {
1274
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
1275
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
1276
+ await writeShellLayout(appRoot);
1277
+
1278
+ const defaultContext = await buildUiTemplateContext({
1279
+ appRoot,
1280
+ options: {
1281
+ namespace: "customers-ui",
1282
+ "api-path": "/crud/customers",
1283
+ "route-path": "ops/customers",
1284
+ operations: "list",
1285
+ placement: "shell-layout:secondary-menu",
1286
+ "resource-file": resourceFile,
1287
+ }
1288
+ });
1289
+ assert.equal(defaultContext.__JSKIT_UI_MENU_TO_PROP_LINE__, "");
1290
+
1291
+ const explicitToContext = await buildUiTemplateContext({
1292
+ appRoot,
1293
+ options: {
1294
+ namespace: "customers-ui",
1295
+ "api-path": "/crud/customers",
1296
+ "route-path": "ops/customers",
1297
+ operations: "list",
1298
+ placement: "shell-layout:secondary-menu",
1299
+ "placement-to": "./pets",
1300
+ "resource-file": resourceFile,
1301
+ }
1302
+ });
1303
+ assert.equal(explicitToContext.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./pets\",\n");
1304
+ });
1305
+ });
1306
+
1307
+ test("buildUiTemplateContext strips route-group filesystem segments from menu URL suffix", async () => {
1308
+ await withTempApp(async (appRoot) => {
1309
+ const resourceFile = "packages/customers/src/shared/customerResource.js";
1310
+ await writeResource(appRoot, resourceFile, FULL_RESOURCE_SOURCE);
1311
+ await writeShellLayout(appRoot);
1312
+
1313
+ const context = await buildUiTemplateContext({
1314
+ appRoot,
1315
+ options: {
1316
+ namespace: "customers-ui",
1317
+ "api-path": "/crud/customers",
1318
+ "directory-prefix": "ops/(nestedChildren)",
1319
+ "route-path": "customers",
1320
+ operations: "list",
1321
+ placement: "shell-layout:secondary-menu",
1322
+ "resource-file": resourceFile
1323
+ }
1324
+ });
1325
+
1326
+ assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/ops/customers");
1327
+ assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/ops/customers");
1328
+ });
1329
+ });