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

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.6",
4
+ version: "0.1.8",
5
5
  kind: "generator",
6
6
  description: "Generate app-local CRUD UI scaffolds from resource validators.",
7
7
  options: {
@@ -136,7 +136,7 @@ export default Object.freeze({
136
136
  mutations: {
137
137
  dependencies: {
138
138
  runtime: {
139
- "@jskit-ai/users-web": "0.1.37"
139
+ "@jskit-ai/users-web": "0.1.40"
140
140
  },
141
141
  dev: {}
142
142
  },
@@ -179,7 +179,7 @@ export default Object.freeze({
179
179
  }
180
180
  },
181
181
  {
182
- from: "templates/src/pages/admin/ui-generator/NewElement.vue",
182
+ from: "templates/src/pages/admin/ui-generator/NewWrapperElement.vue",
183
183
  toSurface: "${option:surface|lower}",
184
184
  toSurfacePath: "${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/new.vue",
185
185
  reason: "Install generated new page.",
@@ -190,12 +190,20 @@ export default Object.freeze({
190
190
  export: "buildUiTemplateContext"
191
191
  },
192
192
  when: {
193
- option: "operations",
194
- in: ["new"]
193
+ all: [
194
+ {
195
+ option: "operations",
196
+ in: ["new"]
197
+ },
198
+ {
199
+ option: "operations",
200
+ in: ["edit"]
201
+ }
202
+ ]
195
203
  }
196
204
  },
197
205
  {
198
- from: "templates/src/pages/admin/ui-generator/EditElement.vue",
206
+ from: "templates/src/pages/admin/ui-generator/EditWrapperElement.vue",
199
207
  toSurface: "${option:surface|lower}",
200
208
  toSurfacePath:
201
209
  "${option:directory-prefix|pathprefix}${option:container|pathprefix}${option:route-path|path}/[${option:id-param|trim}]/edit.vue",
@@ -207,8 +215,115 @@ export default Object.freeze({
207
215
  export: "buildUiTemplateContext"
208
216
  },
209
217
  when: {
210
- option: "operations",
211
- in: ["edit"]
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
+ ]
212
327
  }
213
328
  }
214
329
  ],
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-ui-generator",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "dependencies": {
9
- "@jskit-ai/crud-core": "0.1.31",
10
- "@jskit-ai/kernel": "0.1.23"
9
+ "@jskit-ai/crud-core": "0.1.33",
10
+ "@jskit-ai/kernel": "0.1.25"
11
11
  },
12
12
  "exports": {
13
13
  "./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
@@ -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_$]*$/;
@@ -456,6 +457,10 @@ function normalizeLookupRelation(relation = {}) {
456
457
  normalized.containerKey = containerKey;
457
458
  }
458
459
  }
460
+ const surfaceId = normalizeSurfaceId(relation.surfaceId);
461
+ if (surfaceId) {
462
+ normalized.surfaceId = surfaceId;
463
+ }
459
464
  return normalized;
460
465
  }
461
466
 
@@ -845,6 +850,7 @@ function buildFormColumns(fields = []) {
845
850
  label="${label}"
846
851
  variant="outlined"
847
852
  density="comfortable"
853
+ autocomplete="off"
848
854
  :items='resolveLookupItems(${JSON.stringify(key)}, { selectedValue: ${formAccessor}, selectedRecord: formRuntime.addEdit.resource.data })'
849
855
  ${lookupSearchBindings}
850
856
  item-title="label"
@@ -69,6 +69,25 @@ function resolveTargetFilePath(appRoot, targetFile) {
69
69
  };
70
70
  }
71
71
 
72
+ function resolvePathWithinAppRoot(appRoot, absolutePath) {
73
+ const appRootAbsolute = path.resolve(String(appRoot || ""));
74
+ const resolvedAbsolutePath = path.resolve(String(absolutePath || ""));
75
+ const relativePath = path.relative(appRootAbsolute, resolvedAbsolutePath);
76
+ if (
77
+ !relativePath ||
78
+ relativePath === ".." ||
79
+ relativePath.startsWith(`..${path.sep}`) ||
80
+ path.isAbsolute(relativePath)
81
+ ) {
82
+ throw new Error("crud-ui-generator field target file must stay within app root.");
83
+ }
84
+
85
+ return {
86
+ absolutePath: resolvedAbsolutePath,
87
+ relativePath
88
+ };
89
+ }
90
+
72
91
  function inferResourceOptionsFromSource(screenSource = "") {
73
92
  const source = String(screenSource || "");
74
93
  const importPattern = /import\s*\{\s*resource(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?\s*\}\s*from\s*["']\/([^"']+)["'];?/g;
@@ -171,22 +190,26 @@ function insertBeforeAnchor(source, { anchor = "", snippet = "" } = {}) {
171
190
  };
172
191
  }
173
192
 
174
- if (String(source || "").includes(normalizedSnippet)) {
175
- return {
176
- content: source,
177
- changed: false
178
- };
179
- }
180
-
181
193
  const sourceText = String(source || "");
182
194
  const escapedAnchor = normalizedAnchor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
183
195
  const anchorLinePattern = new RegExp(`^([ \\t]*)${escapedAnchor}[ \\t]*$`, "m");
184
- const anchorLineMatch = sourceText.match(anchorLinePattern);
196
+ const anchorLineMatch = anchorLinePattern.exec(sourceText);
185
197
  if (!anchorLineMatch) {
186
198
  throw new Error(`crud-ui-generator field could not find anchor: ${normalizedAnchor}`);
187
199
  }
200
+ const anchorLineIndex = Number(anchorLineMatch.index ?? -1);
188
201
  const anchorIndent = String(anchorLineMatch[1] || "");
189
202
  const alignedAnchorLine = `${anchorIndent}${normalizedAnchor}`;
203
+ const scopedSource = sourceText.slice(resolveAnchorScopeStart(sourceText, {
204
+ anchorIndex: anchorLineIndex,
205
+ anchor: normalizedAnchor
206
+ }), anchorLineIndex);
207
+ if (scopedSource.includes(normalizedSnippet)) {
208
+ return {
209
+ content: source,
210
+ changed: false
211
+ };
212
+ }
190
213
 
191
214
  return {
192
215
  content: sourceText.replace(anchorLinePattern, `${normalizedSnippet}\n${alignedAnchorLine}`),
@@ -194,14 +217,47 @@ function insertBeforeAnchor(source, { anchor = "", snippet = "" } = {}) {
194
217
  };
195
218
  }
196
219
 
220
+ function resolveAnchorScopeStart(source = "", { anchorIndex = -1, anchor = "" } = {}) {
221
+ const sourceText = String(source || "");
222
+ const resolvedAnchorIndex = Number.isInteger(anchorIndex) ? anchorIndex : -1;
223
+ if (resolvedAnchorIndex <= 0) {
224
+ return 0;
225
+ }
226
+
227
+ const normalizedAnchor = String(anchor || "");
228
+ let familyToken = "";
229
+ if (normalizedAnchor.includes("jskit:crud-ui-fields:")) {
230
+ familyToken = "jskit:crud-ui-fields:";
231
+ } else if (normalizedAnchor.includes("jskit:crud-ui-form-fields:")) {
232
+ familyToken = "jskit:crud-ui-form-fields:";
233
+ }
234
+ if (!familyToken) {
235
+ return 0;
236
+ }
237
+
238
+ const previousFamilyMarkerIndex = sourceText.lastIndexOf(familyToken, resolvedAnchorIndex - 1);
239
+ if (previousFamilyMarkerIndex < 0) {
240
+ return 0;
241
+ }
242
+
243
+ const previousLineEndIndex = sourceText.indexOf("\n", previousFamilyMarkerIndex);
244
+ if (previousLineEndIndex < 0) {
245
+ return 0;
246
+ }
247
+
248
+ return previousLineEndIndex + 1;
249
+ }
250
+
197
251
  function buildAnchorInsertions(operationName, field) {
198
252
  if (operationName === "list") {
199
253
  return [
200
254
  {
255
+ targetKind: "screen",
201
256
  anchor: "<!-- jskit:crud-ui-fields:list-header -->",
202
257
  snippet: buildListHeaderColumns([field])
203
258
  },
204
259
  {
260
+ targetKind: "screen",
205
261
  anchor: "<!-- jskit:crud-ui-fields:list-row -->",
206
262
  snippet: buildListRowColumns([field])
207
263
  }
@@ -211,6 +267,7 @@ function buildAnchorInsertions(operationName, field) {
211
267
  if (operationName === "view") {
212
268
  return [
213
269
  {
270
+ targetKind: "screen",
214
271
  anchor: "<!-- jskit:crud-ui-fields:view -->",
215
272
  snippet: buildViewColumns([field])
216
273
  }
@@ -220,10 +277,12 @@ function buildAnchorInsertions(operationName, field) {
220
277
  if (operationName === "new") {
221
278
  return [
222
279
  {
280
+ targetKind: "screen",
223
281
  anchor: "<!-- jskit:crud-ui-fields:new -->",
224
282
  snippet: buildFormColumns([field])
225
283
  },
226
284
  {
285
+ targetKind: "form-fields",
227
286
  anchor: "// jskit:crud-ui-form-fields:new",
228
287
  snippet: renderObjectPushLines("UI_CREATE_FORM_FIELDS", [field])
229
288
  }
@@ -232,16 +291,67 @@ function buildAnchorInsertions(operationName, field) {
232
291
 
233
292
  return [
234
293
  {
294
+ targetKind: "screen",
235
295
  anchor: "<!-- jskit:crud-ui-fields:edit -->",
236
296
  snippet: buildFormColumns([field])
237
297
  },
238
298
  {
299
+ targetKind: "form-fields",
239
300
  anchor: "// jskit:crud-ui-form-fields:edit",
240
301
  snippet: renderObjectPushLines("UI_EDIT_FORM_FIELDS", [field])
241
302
  }
242
303
  ];
243
304
  }
244
305
 
306
+ function resolveGeneratedTargetComment(source = "", commentName = "") {
307
+ const escapedCommentName = String(commentName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
+ const pattern = new RegExp(`^\\s*//\\s*jskit:${escapedCommentName}\\s+(.+?)\\s*$`, "m");
309
+ const match = String(source || "").match(pattern);
310
+ return normalizeText(match?.[1]);
311
+ }
312
+
313
+ function resolveOperationTargetFiles({
314
+ appRoot,
315
+ operationName,
316
+ targetAbsolutePath,
317
+ source = ""
318
+ } = {}) {
319
+ if (operationName !== "new" && operationName !== "edit") {
320
+ const targetFile = resolvePathWithinAppRoot(appRoot, targetAbsolutePath);
321
+ return {
322
+ screen: targetFile,
323
+ "form-fields": targetFile
324
+ };
325
+ }
326
+
327
+ const directScreenAnchor =
328
+ operationName === "new" ? "<!-- jskit:crud-ui-fields:new -->" : "<!-- jskit:crud-ui-fields:edit -->";
329
+ const directFormFieldsAnchor =
330
+ operationName === "new" ? "// jskit:crud-ui-form-fields:new" : "// jskit:crud-ui-form-fields:edit";
331
+
332
+ const sourceText = String(source || "");
333
+ if (sourceText.includes(directScreenAnchor) && sourceText.includes(directFormFieldsAnchor)) {
334
+ const targetFile = resolvePathWithinAppRoot(appRoot, targetAbsolutePath);
335
+ return {
336
+ screen: targetFile,
337
+ "form-fields": targetFile
338
+ };
339
+ }
340
+
341
+ const screenTarget = resolveGeneratedTargetComment(sourceText, "crud-ui-fields-target");
342
+ const formFieldsTarget = resolveGeneratedTargetComment(sourceText, "crud-ui-form-fields-target");
343
+ if (!screenTarget || !formFieldsTarget) {
344
+ throw new Error(
345
+ `crud-ui-generator field could not find direct ${operationName} anchors or generated shared-form target comments in ${toPosixPath(path.relative(appRoot, targetAbsolutePath))}.`
346
+ );
347
+ }
348
+
349
+ return {
350
+ screen: resolvePathWithinAppRoot(appRoot, path.resolve(path.dirname(targetAbsolutePath), screenTarget)),
351
+ "form-fields": resolvePathWithinAppRoot(appRoot, path.resolve(path.dirname(targetAbsolutePath), formFieldsTarget))
352
+ };
353
+ }
354
+
245
355
  function parseSubcommandArgs(args = []) {
246
356
  const source = Array.isArray(args) ? args : [];
247
357
  const fieldKey = normalizeText(source[0]);
@@ -289,23 +399,57 @@ async function runGeneratorSubcommand({
289
399
  const fields = resolveOperationFields(resource, operationName);
290
400
  const field = resolveFieldDefinition(fields, fieldKey);
291
401
  const insertions = buildAnchorInsertions(operationName, field);
402
+ const operationTargets = resolveOperationTargetFiles({
403
+ appRoot,
404
+ operationName,
405
+ targetAbsolutePath,
406
+ source: originalSource
407
+ });
408
+
409
+ const fileStates = new Map();
410
+ fileStates.set(targetAbsolutePath, {
411
+ source: originalSource,
412
+ changed: false,
413
+ path: resolvePathWithinAppRoot(appRoot, targetAbsolutePath)
414
+ });
292
415
 
293
- let nextSource = originalSource;
294
416
  let changed = false;
295
417
  for (const insertion of insertions) {
296
- const applied = insertBeforeAnchor(nextSource, insertion);
297
- nextSource = applied.content;
418
+ const targetFile = operationTargets[insertion.targetKind] || operationTargets.screen;
419
+ let state = fileStates.get(targetFile.absolutePath);
420
+ if (!state) {
421
+ state = {
422
+ source: await readFile(targetFile.absolutePath, "utf8"),
423
+ changed: false,
424
+ path: targetFile
425
+ };
426
+ fileStates.set(targetFile.absolutePath, state);
427
+ }
428
+
429
+ const applied = insertBeforeAnchor(state.source, insertion);
430
+ state.source = applied.content;
431
+ state.changed = state.changed || applied.changed;
298
432
  changed = changed || applied.changed;
299
433
  }
300
434
 
301
435
  if (changed && dryRun !== true) {
302
- await writeFile(targetAbsolutePath, nextSource, "utf8");
436
+ for (const state of fileStates.values()) {
437
+ if (!state.changed) {
438
+ continue;
439
+ }
440
+ await writeFile(state.path.absolutePath, state.source, "utf8");
441
+ }
303
442
  }
304
443
 
444
+ const touchedFiles = Array.from(fileStates.values())
445
+ .filter((state) => state.changed)
446
+ .map((state) => toPosixPath(state.path.relativePath))
447
+ .sort();
448
+
305
449
  return {
306
- touchedFiles: changed ? [toPosixPath(targetRelativePath)] : [],
450
+ touchedFiles,
307
451
  summary: changed
308
- ? `Added field "${field.key}" to ${operationName} in ${toPosixPath(targetRelativePath)}.`
452
+ ? `Added field "${field.key}" to ${operationName} in ${touchedFiles.join(", ")}.`
309
453
  : `Field "${field.key}" already exists in ${operationName} for ${toPosixPath(targetRelativePath)}.`
310
454
  };
311
455
  }
@@ -0,0 +1,105 @@
1
+ <template>
2
+ <section class="ui-generator-add-edit-form d-flex flex-column ga-4">
3
+ <v-card rounded="xl" elevation="1" border>
4
+ <v-card-item class="pb-2">
5
+ <div class="d-flex align-start ga-3 flex-wrap w-100">
6
+ <div>
7
+ <v-card-title class="px-0">{{ title }}</v-card-title>
8
+ <v-card-subtitle class="px-0">{{ subtitle }}</v-card-subtitle>
9
+ </div>
10
+ <v-spacer />
11
+ <div class="d-flex ga-2 flex-wrap">
12
+ <v-btn v-if="cancelTo" variant="tonal" :to="resolveCancelTo(cancelTo)">Cancel</v-btn>
13
+ <v-btn
14
+ color="primary"
15
+ :loading="formRuntime.addEdit.isSaving"
16
+ :disabled="formRuntime.addEdit.isSubmitDisabled"
17
+ @click="formRuntime.addEdit.submit"
18
+ >
19
+ {{ saveLabel }}
20
+ </v-btn>
21
+ </div>
22
+ </div>
23
+ </v-card-item>
24
+
25
+ <v-card-text class="pt-0">
26
+ <p v-if="formRuntime.addEdit.loadError" class="text-body-2 text-medium-emphasis mb-0">
27
+ {{ formRuntime.addEdit.loadError }}
28
+ </p>
29
+ <template v-else-if="formRuntime.showFormSkeleton">
30
+ <v-skeleton-loader type="heading, text@2, article" />
31
+ </template>
32
+ <v-form v-else @submit.prevent="formRuntime.addEdit.submit" novalidate>
33
+ <v-progress-linear v-if="formRuntime.addEdit.isRefetching" indeterminate class="mb-4" />
34
+ <v-row>
35
+ <template v-if="mode === 'new'">
36
+ <!-- jskit:crud-ui-fields:new -->
37
+ __JSKIT_UI_CREATE_FORM_COLUMNS__
38
+ </template>
39
+ <template v-else>
40
+ <!-- jskit:crud-ui-fields:edit -->
41
+ __JSKIT_UI_EDIT_FORM_COLUMNS__
42
+ </template>
43
+ </v-row>
44
+ </v-form>
45
+ </v-card-text>
46
+ </v-card>
47
+ </section>
48
+ </template>
49
+
50
+ <script setup>
51
+ const props = defineProps({
52
+ mode: {
53
+ type: String,
54
+ default: "new"
55
+ },
56
+ formRuntime: {
57
+ type: Object,
58
+ required: true
59
+ },
60
+ title: {
61
+ type: String,
62
+ default: ""
63
+ },
64
+ subtitle: {
65
+ type: String,
66
+ default: ""
67
+ },
68
+ saveLabel: {
69
+ type: String,
70
+ default: "Save"
71
+ },
72
+ cancelTo: {
73
+ type: [String, Object],
74
+ default: ""
75
+ },
76
+ resolveLookupItems: {
77
+ type: Function,
78
+ required: true
79
+ },
80
+ resolveLookupLoading: {
81
+ type: Function,
82
+ required: true
83
+ },
84
+ resolveLookupSearch: {
85
+ type: Function,
86
+ required: true
87
+ },
88
+ setLookupSearch: {
89
+ type: Function,
90
+ required: true
91
+ }
92
+ });
93
+
94
+ function resolveCancelTo(target) {
95
+ if (!target) {
96
+ return "";
97
+ }
98
+
99
+ if (typeof target === "string") {
100
+ return props.formRuntime.addEdit.resolveParams(target);
101
+ }
102
+
103
+ return target;
104
+ }
105
+ </script>
@@ -0,0 +1,17 @@
1
+ const UI_CREATE_FORM_FIELDS = [];
2
+
3
+ // @jskit-contract crud.ui.form-fields.${option:namespace|snake}.new.v1
4
+ void UI_CREATE_FORM_FIELDS;
5
+ // jskit:crud-ui-form-fields:new
6
+ __JSKIT_UI_CREATE_FORM_FIELD_PUSH_LINES__
7
+ Object.freeze(UI_CREATE_FORM_FIELDS);
8
+
9
+ const UI_EDIT_FORM_FIELDS = [];
10
+
11
+ // @jskit-contract crud.ui.form-fields.${option:namespace|snake}.edit.v1
12
+ void UI_EDIT_FORM_FIELDS;
13
+ // jskit:crud-ui-form-fields:edit
14
+ __JSKIT_UI_EDIT_FORM_FIELD_PUSH_LINES__
15
+ Object.freeze(UI_EDIT_FORM_FIELDS);
16
+
17
+ export { UI_CREATE_FORM_FIELDS, UI_EDIT_FORM_FIELDS };
@@ -48,7 +48,7 @@ __JSKIT_UI_EDIT_FORM_COLUMNS__
48
48
  <script setup>
49
49
  import { computed } from "vue";
50
50
  import { useRoute } from "vue-router";
51
- import { useCrudSchemaForm } from "@jskit-ai/users-web/client/composables/useCrudSchemaForm";
51
+ import { useCrudAddEdit } from "@jskit-ai/users-web/client/composables/useCrudAddEdit";
52
52
  import { createCrudLookupFieldRuntime } from "@jskit-ai/users-web/client/composables/crudLookupFieldRuntime";
53
53
  import { resource as uiResource } from "/${option:resource-file|trim}";
54
54
 
@@ -94,7 +94,7 @@ const {
94
94
  setLookupSearch
95
95
  } = lookupFieldRuntime;
96
96
 
97
- const formRuntime = useCrudSchemaForm({
97
+ const formRuntime = useCrudAddEdit({
98
98
  resource: uiResource,
99
99
  operationName: "patch",
100
100
  formFields: UI_EDIT_FORM_FIELDS,
@@ -0,0 +1,108 @@
1
+ <template>
2
+ <${option:namespace|singular|pascal|default(Record)}AddEditForm
3
+ mode="edit"
4
+ :form-runtime="formRuntime"
5
+ title="Edit ${option:namespace|singular|pascal|default(Record)}"
6
+ subtitle="Update the selected ${option:namespace|singular|default(record)}."
7
+ save-label="Save changes"
8
+ :cancel-to="cancelTo"
9
+ :resolve-lookup-items="resolveLookupItems"
10
+ :resolve-lookup-loading="resolveLookupLoading"
11
+ :resolve-lookup-search="resolveLookupSearch"
12
+ :set-lookup-search="setLookupSearch"
13
+ />
14
+ </template>
15
+
16
+ <script setup>
17
+ import { computed } from "vue";
18
+ import { useRoute } from "vue-router";
19
+ import { useCrudAddEdit } from "@jskit-ai/users-web/client/composables/useCrudAddEdit";
20
+ import { createCrudLookupFieldRuntime } from "@jskit-ai/users-web/client/composables/crudLookupFieldRuntime";
21
+ import { resource as uiResource } from "/${option:resource-file|trim}";
22
+ import ${option:namespace|singular|pascal|default(Record)}AddEditForm from "../_components/${option:namespace|singular|pascal|default(Record)}AddEditForm.vue";
23
+ import { UI_EDIT_FORM_FIELDS } from "../_components/${option:namespace|singular|pascal|default(Record)}AddEditFormFields.js";
24
+
25
+ const UI_OPERATION_ADAPTER = null;
26
+ const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
27
+ const UI_API_BASE_URL = "${option:api-path|trim}";
28
+ const UI_EDIT_API_URL = `${UI_API_BASE_URL}/:${UI_RECORD_ID_PARAM}`;
29
+ const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? "../.." : "";
30
+ const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? ".." : "";
31
+ const UI_CANCEL_URL = UI_VIEW_URL || UI_LIST_URL;
32
+ const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
33
+ const route = useRoute();
34
+
35
+ // jskit:crud-ui-fields-target ../_components/${option:namespace|singular|pascal|default(Record)}AddEditForm.vue
36
+ // jskit:crud-ui-form-fields-target ../_components/${option:namespace|singular|pascal|default(Record)}AddEditFormFields.js
37
+
38
+ const routeRecordId = computed(() => {
39
+ const source = route.params?.[UI_RECORD_ID_PARAM];
40
+ if (Array.isArray(source)) {
41
+ return String(source[0] ?? "").trim();
42
+ }
43
+
44
+ return String(source ?? "").trim();
45
+ });
46
+
47
+ const lookupFieldRuntime = createCrudLookupFieldRuntime({
48
+ formFields: UI_EDIT_FORM_FIELDS,
49
+ adapter: UI_OPERATION_ADAPTER || undefined,
50
+ recordIdParam: UI_RECORD_ID_PARAM,
51
+ lookupContainerKey: uiResource?.contract?.lookup?.containerKey,
52
+ queryKeyPrefix: ["ui-generator", "${option:namespace|kebab}", "lookup", "edit"],
53
+ placementSourcePrefix: "ui-generator.${option:namespace|kebab}.edit.lookup"
54
+ });
55
+ const {
56
+ resolveLookupItems,
57
+ resolveLookupLoading,
58
+ resolveLookupSearch,
59
+ setLookupSearch
60
+ } = lookupFieldRuntime;
61
+
62
+ const formRuntime = useCrudAddEdit({
63
+ resource: uiResource,
64
+ operationName: "patch",
65
+ formFields: UI_EDIT_FORM_FIELDS,
66
+ addEditOptions: {
67
+ adapter: UI_OPERATION_ADAPTER || undefined,
68
+ apiUrlTemplate: UI_EDIT_API_URL,
69
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
70
+ "ui-generator",
71
+ "${option:namespace|kebab}",
72
+ "edit",
73
+ String(surfaceId || ""),
74
+ String(workspaceSlug || ""),
75
+ routeRecordId.value
76
+ ],
77
+ placementSource: "ui-generator.${option:namespace|kebab}.edit",
78
+ writeMethod: "PATCH",
79
+ fallbackLoadError: "Unable to load record.",
80
+ fallbackSaveError: "Unable to save record.",
81
+ recordIdParam: UI_RECORD_ID_PARAM,
82
+ routeRecordId,
83
+ viewUrlTemplate: UI_VIEW_URL,
84
+ listUrlTemplate: UI_LIST_URL,
85
+ realtime: UI_RECORD_CHANGED_EVENT
86
+ ? {
87
+ event: UI_RECORD_CHANGED_EVENT
88
+ }
89
+ : null
90
+ },
91
+ saveSuccess: {
92
+ invalidateQueryKey: ["ui-generator", "${option:namespace|kebab}"],
93
+ listUrlTemplate: UI_LIST_URL
94
+ }
95
+ });
96
+
97
+ const cancelTo = computed(() => {
98
+ const resolvedPath = formRuntime.addEdit.resolveParams(UI_CANCEL_URL);
99
+ if (!resolvedPath) {
100
+ return "";
101
+ }
102
+
103
+ return {
104
+ path: resolvedPath,
105
+ query: route.query
106
+ };
107
+ });
108
+ </script>
@@ -4,7 +4,7 @@
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|plural|pascal}</v-card-title>
7
+ <v-card-title class="px-0">{{ listHeadingTitle }}</v-card-title>
8
8
  <v-card-subtitle class="px-0">Manage ${option:namespace|plural|default(records)}.</v-card-subtitle>
9
9
  </div>
10
10
  <v-spacer />
@@ -82,7 +82,9 @@ __JSKIT_UI_LIST_ROW_COLUMNS__
82
82
  </template>
83
83
 
84
84
  <script setup>
85
- import { useList } from "@jskit-ai/users-web/client/composables/useList";
85
+ import { computed } from "vue";
86
+ import { useCrudListParentTitle } from "@jskit-ai/users-web/client/composables/useCrudListParentTitle";
87
+ import { useCrudList } from "@jskit-ai/users-web/client/composables/useCrudList";
86
88
  import { resource as uiResource } from "/${option:resource-file|trim}";
87
89
 
88
90
  const UI_OPERATION_ADAPTER = null;
@@ -94,7 +96,7 @@ const UI_NEW_URL = __JSKIT_UI_HAS_NEW_ROUTE__ ? "./new" : "";
94
96
  const UI_RECORD_CHANGED_EVENTS = __JSKIT_UI_LIST_REALTIME_EVENTS__;
95
97
  const UI_ROUTE_QUERY_BLACKLIST = Object.freeze(["include", "cursor", "limit"]);
96
98
 
97
- const records = useList({
99
+ const records = useCrudList({
98
100
  adapter: UI_OPERATION_ADAPTER || undefined,
99
101
  resource: uiResource,
100
102
  apiSuffix: UI_LIST_API_URL,
@@ -128,6 +130,26 @@ const records = useList({
128
130
  }
129
131
  : null
130
132
  });
133
+
134
+ const parentTitle = useCrudListParentTitle({
135
+ listRuntime: records,
136
+ resource: uiResource,
137
+ adapter: UI_OPERATION_ADAPTER || undefined,
138
+ recordIdParam: UI_RECORD_ID_PARAM,
139
+ queryKeyPrefix: ["ui-generator", "${option:namespace|kebab}", "list", "parent-title"],
140
+ placementSource: "ui-generator.${option:namespace|kebab}.list.parent-title",
141
+ fallbackLoadError: "Unable to load parent record.",
142
+ notFoundMessage: "Parent record not found."
143
+ });
144
+
145
+ const listHeadingTitle = computed(() => {
146
+ const resolvedParentTitle = String(parentTitle.title || "").trim();
147
+ if (!resolvedParentTitle) {
148
+ return "${option:route-path|pascal}";
149
+ }
150
+
151
+ return `${option:route-path|pascal} for ${resolvedParentTitle}`;
152
+ });
131
153
  </script>
132
154
 
133
155
  <style scoped>
@@ -36,7 +36,7 @@ __JSKIT_UI_CREATE_FORM_COLUMNS__
36
36
  </template>
37
37
 
38
38
  <script setup>
39
- import { useCrudSchemaForm } from "@jskit-ai/users-web/client/composables/useCrudSchemaForm";
39
+ import { useCrudAddEdit } from "@jskit-ai/users-web/client/composables/useCrudAddEdit";
40
40
  import { createCrudLookupFieldRuntime } from "@jskit-ai/users-web/client/composables/crudLookupFieldRuntime";
41
41
  import { resource as uiResource } from "/${option:resource-file|trim}";
42
42
 
@@ -69,7 +69,7 @@ const {
69
69
  setLookupSearch
70
70
  } = lookupFieldRuntime;
71
71
 
72
- const formRuntime = useCrudSchemaForm({
72
+ const formRuntime = useCrudAddEdit({
73
73
  resource: uiResource,
74
74
  operationName: "create",
75
75
  formFields: UI_CREATE_FORM_FIELDS,
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <${option:namespace|singular|pascal|default(Record)}AddEditForm
3
+ mode="new"
4
+ :form-runtime="formRuntime"
5
+ title="New ${option:namespace|singular|pascal|default(Record)}"
6
+ subtitle="Create a new ${option:namespace|singular|default(record)}."
7
+ save-label="Save ${option:namespace|singular|default(record)}"
8
+ :cancel-to="UI_CANCEL_URL"
9
+ :resolve-lookup-items="resolveLookupItems"
10
+ :resolve-lookup-loading="resolveLookupLoading"
11
+ :resolve-lookup-search="resolveLookupSearch"
12
+ :set-lookup-search="setLookupSearch"
13
+ />
14
+ </template>
15
+
16
+ <script setup>
17
+ import { useCrudAddEdit } from "@jskit-ai/users-web/client/composables/useCrudAddEdit";
18
+ import { createCrudLookupFieldRuntime } from "@jskit-ai/users-web/client/composables/crudLookupFieldRuntime";
19
+ import { resource as uiResource } from "/${option:resource-file|trim}";
20
+ import ${option:namespace|singular|pascal|default(Record)}AddEditForm from "./_components/${option:namespace|singular|pascal|default(Record)}AddEditForm.vue";
21
+ import { UI_CREATE_FORM_FIELDS } from "./_components/${option:namespace|singular|pascal|default(Record)}AddEditFormFields.js";
22
+
23
+ const UI_OPERATION_ADAPTER = null;
24
+ const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
25
+ const UI_CREATE_API_URL = "${option:api-path|trim}";
26
+ const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? ".." : "";
27
+ const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? `../:${UI_RECORD_ID_PARAM}` : "";
28
+ const UI_CANCEL_URL = UI_LIST_URL;
29
+ const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
30
+
31
+ // jskit:crud-ui-fields-target ./_components/${option:namespace|singular|pascal|default(Record)}AddEditForm.vue
32
+ // jskit:crud-ui-form-fields-target ./_components/${option:namespace|singular|pascal|default(Record)}AddEditFormFields.js
33
+
34
+ const lookupFieldRuntime = createCrudLookupFieldRuntime({
35
+ formFields: UI_CREATE_FORM_FIELDS,
36
+ adapter: UI_OPERATION_ADAPTER || undefined,
37
+ recordIdParam: UI_RECORD_ID_PARAM,
38
+ lookupContainerKey: uiResource?.contract?.lookup?.containerKey,
39
+ queryKeyPrefix: ["ui-generator", "${option:namespace|kebab}", "lookup", "new"],
40
+ placementSourcePrefix: "ui-generator.${option:namespace|kebab}.new.lookup"
41
+ });
42
+ const {
43
+ resolveLookupItems,
44
+ resolveLookupLoading,
45
+ resolveLookupSearch,
46
+ setLookupSearch
47
+ } = lookupFieldRuntime;
48
+
49
+ const formRuntime = useCrudAddEdit({
50
+ resource: uiResource,
51
+ operationName: "create",
52
+ formFields: UI_CREATE_FORM_FIELDS,
53
+ addEditOptions: {
54
+ adapter: UI_OPERATION_ADAPTER || undefined,
55
+ apiSuffix: UI_CREATE_API_URL,
56
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
57
+ "ui-generator",
58
+ "${option:namespace|kebab}",
59
+ "create",
60
+ String(surfaceId || ""),
61
+ String(workspaceSlug || "")
62
+ ],
63
+ placementSource: "ui-generator.${option:namespace|kebab}.new",
64
+ readEnabled: false,
65
+ writeMethod: "POST",
66
+ fallbackSaveError: "Unable to save record.",
67
+ recordIdParam: UI_RECORD_ID_PARAM,
68
+ viewUrlTemplate: UI_VIEW_URL,
69
+ listUrlTemplate: UI_LIST_URL,
70
+ realtime: UI_RECORD_CHANGED_EVENT
71
+ ? {
72
+ event: UI_RECORD_CHANGED_EVENT
73
+ }
74
+ : null
75
+ },
76
+ saveSuccess: {
77
+ invalidateQueryKey: ["ui-generator", "${option:namespace|kebab}"],
78
+ listUrlTemplate: UI_LIST_URL
79
+ }
80
+ });
81
+ </script>
@@ -55,7 +55,7 @@ __JSKIT_UI_VIEW_COLUMNS__
55
55
  </template>
56
56
 
57
57
  <script setup>
58
- import { useView } from "@jskit-ai/users-web/client/composables/useView";
58
+ import { useCrudView } from "@jskit-ai/users-web/client/composables/useCrudView";
59
59
 
60
60
  const UI_OPERATION_ADAPTER = null;
61
61
  const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
@@ -66,7 +66,7 @@ const UI_EDIT_URL = __JSKIT_UI_HAS_EDIT_ROUTE__ ? "./edit" : "";
66
66
  const UI_VIEW_TITLE_FALLBACK_FIELD_KEY = __JSKIT_UI_VIEW_TITLE_FALLBACK_FIELD_KEY__;
67
67
  const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
68
68
 
69
- const view = useView({
69
+ const view = useCrudView({
70
70
  adapter: UI_OPERATION_ADAPTER || undefined,
71
71
  apiUrlTemplate: UI_VIEW_API_URL,
72
72
  recordIdParam: UI_RECORD_ID_PARAM,
@@ -148,6 +148,148 @@ UI_EDIT_FORM_FIELDS.push({ key: "firstName", component: "text" });
148
148
  });
149
149
  });
150
150
 
151
+ test("field patches generated shared add/edit component targets from wrapper page", async () => {
152
+ await withTempApp(async (appRoot) => {
153
+ const resourceFile = "packages/contacts/src/shared/contactResource.js";
154
+ const editFile = "src/pages/admin/crm/contacts/[recordId]/edit.vue";
155
+ const addEditFormFile = "src/pages/admin/crm/contacts/_components/ContactAddEditForm.vue";
156
+ const addEditFieldsFile = "src/pages/admin/crm/contacts/_components/ContactAddEditFormFields.js";
157
+
158
+ await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
159
+ await writeAppFile(
160
+ appRoot,
161
+ editFile,
162
+ `<template>
163
+ <ContactAddEditForm />
164
+ </template>
165
+ <script setup>
166
+ import { resource as uiResource } from "/packages/contacts/src/shared/contactResource.js";
167
+ // jskit:crud-ui-fields-target ../_components/ContactAddEditForm.vue
168
+ // jskit:crud-ui-form-fields-target ../_components/ContactAddEditFormFields.js
169
+ </script>
170
+ `
171
+ );
172
+ await writeAppFile(
173
+ appRoot,
174
+ addEditFormFile,
175
+ `<template>
176
+ <v-row>
177
+ <!-- jskit:crud-ui-fields:edit -->
178
+ </v-row>
179
+ </template>
180
+ `
181
+ );
182
+ await writeAppFile(
183
+ appRoot,
184
+ addEditFieldsFile,
185
+ `const UI_EDIT_FORM_FIELDS = [];
186
+ // jskit:crud-ui-form-fields:edit
187
+ UI_EDIT_FORM_FIELDS.push({ key: "firstName", component: "text" });
188
+ `
189
+ );
190
+
191
+ const result = await runGeneratorSubcommand({
192
+ appRoot,
193
+ subcommand: "field",
194
+ args: ["vetId", "edit", editFile],
195
+ options: {}
196
+ });
197
+
198
+ assert.deepEqual(result.touchedFiles, [addEditFormFile, addEditFieldsFile]);
199
+
200
+ const addEditFormSource = await readFile(path.join(appRoot, addEditFormFile), "utf8");
201
+ assert.match(
202
+ addEditFormSource,
203
+ /<v-autocomplete[\s\S]*resolveLookupItems\("vetId", \{ selectedValue: formRuntime\.form\.vetId, selectedRecord: formRuntime\.addEdit\.resource\.data \}\)/
204
+ );
205
+
206
+ const addEditFieldsSource = await readFile(path.join(appRoot, addEditFieldsFile), "utf8");
207
+ assert.match(addEditFieldsSource, /UI_EDIT_FORM_FIELDS\.push\(\{[\s\S]*"key": "vetId"/);
208
+ });
209
+ });
210
+
211
+ test("field patches both shared add/edit branches when markup snippets are identical", async () => {
212
+ await withTempApp(async (appRoot) => {
213
+ const resourceFile = "packages/contacts/src/shared/contactResource.js";
214
+ const newFile = "src/pages/admin/crm/contacts/new.vue";
215
+ const editFile = "src/pages/admin/crm/contacts/[recordId]/edit.vue";
216
+ const addEditFormFile = "src/pages/admin/crm/contacts/_components/ContactAddEditForm.vue";
217
+ const addEditFieldsFile = "src/pages/admin/crm/contacts/_components/ContactAddEditFormFields.js";
218
+
219
+ await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
220
+ await writeAppFile(
221
+ appRoot,
222
+ newFile,
223
+ `<template>
224
+ <ContactAddEditForm />
225
+ </template>
226
+ <script setup>
227
+ import { resource as uiResource } from "/packages/contacts/src/shared/contactResource.js";
228
+ // jskit:crud-ui-fields-target ./_components/ContactAddEditForm.vue
229
+ // jskit:crud-ui-form-fields-target ./_components/ContactAddEditFormFields.js
230
+ </script>
231
+ `
232
+ );
233
+ await writeAppFile(
234
+ appRoot,
235
+ editFile,
236
+ `<template>
237
+ <ContactAddEditForm />
238
+ </template>
239
+ <script setup>
240
+ import { resource as uiResource } from "/packages/contacts/src/shared/contactResource.js";
241
+ // jskit:crud-ui-fields-target ../_components/ContactAddEditForm.vue
242
+ // jskit:crud-ui-form-fields-target ../_components/ContactAddEditFormFields.js
243
+ </script>
244
+ `
245
+ );
246
+ await writeAppFile(
247
+ appRoot,
248
+ addEditFormFile,
249
+ `<template>
250
+ <v-row>
251
+ <template v-if="mode === 'new'">
252
+ <!-- jskit:crud-ui-fields:new -->
253
+ </template>
254
+ <template v-else>
255
+ <!-- jskit:crud-ui-fields:edit -->
256
+ </template>
257
+ </v-row>
258
+ </template>
259
+ `
260
+ );
261
+ await writeAppFile(
262
+ appRoot,
263
+ addEditFieldsFile,
264
+ `const UI_CREATE_FORM_FIELDS = [];
265
+ // jskit:crud-ui-form-fields:new
266
+ const UI_EDIT_FORM_FIELDS = [];
267
+ // jskit:crud-ui-form-fields:edit
268
+ `
269
+ );
270
+
271
+ await runGeneratorSubcommand({
272
+ appRoot,
273
+ subcommand: "field",
274
+ args: ["vetId", "new", newFile],
275
+ options: {}
276
+ });
277
+ await runGeneratorSubcommand({
278
+ appRoot,
279
+ subcommand: "field",
280
+ args: ["vetId", "edit", editFile],
281
+ options: {}
282
+ });
283
+
284
+ const addEditFormSource = await readFile(path.join(appRoot, addEditFormFile), "utf8");
285
+ assert.equal((addEditFormSource.match(/resolveLookupItems\("vetId"/g) || []).length, 2);
286
+
287
+ const addEditFieldsSource = await readFile(path.join(appRoot, addEditFieldsFile), "utf8");
288
+ assert.match(addEditFieldsSource, /UI_CREATE_FORM_FIELDS\.push\(\{[\s\S]*"key": "vetId"/);
289
+ assert.match(addEditFieldsSource, /UI_EDIT_FORM_FIELDS\.push\(\{[\s\S]*"key": "vetId"/);
290
+ });
291
+ });
292
+
151
293
  test("field patches list screen when resource-file is passed explicitly", async () => {
152
294
  await withTempApp(async (appRoot) => {
153
295
  const resourceFile = "packages/contacts/src/shared/contactResource.js";
@@ -173,6 +173,83 @@ const resource = {
173
173
  export { resource };
174
174
  `;
175
175
 
176
+ const LOOKUP_RESOURCE_SOURCE = `const recordSchema = {
177
+ type: "object",
178
+ properties: {
179
+ id: { type: "integer" },
180
+ serviceId: { type: ["integer", "null"] },
181
+ name: { type: "string" }
182
+ },
183
+ additionalProperties: false
184
+ };
185
+
186
+ const bodySchema = {
187
+ type: "object",
188
+ properties: {
189
+ serviceId: { type: ["integer", "null"] },
190
+ name: { type: "string", maxLength: 255 }
191
+ },
192
+ additionalProperties: false
193
+ };
194
+
195
+ const resource = {
196
+ operations: {
197
+ list: {
198
+ outputValidator: {
199
+ schema: {
200
+ type: "object",
201
+ properties: {
202
+ items: {
203
+ type: "array",
204
+ items: recordSchema
205
+ },
206
+ nextCursor: { type: ["string", "null"] }
207
+ },
208
+ additionalProperties: false
209
+ }
210
+ }
211
+ },
212
+ view: {
213
+ outputValidator: {
214
+ schema: recordSchema
215
+ }
216
+ },
217
+ create: {
218
+ bodyValidator: {
219
+ schema: bodySchema
220
+ },
221
+ outputValidator: {
222
+ schema: recordSchema
223
+ }
224
+ },
225
+ patch: {
226
+ bodyValidator: {
227
+ schema: bodySchema
228
+ },
229
+ outputValidator: {
230
+ schema: recordSchema
231
+ }
232
+ }
233
+ },
234
+ fieldMeta: [
235
+ {
236
+ key: "serviceId",
237
+ relation: {
238
+ kind: "lookup",
239
+ namespace: "services",
240
+ valueKey: "id",
241
+ surfaceId: "console"
242
+ },
243
+ ui: {
244
+ formControl: "autocomplete"
245
+ }
246
+ }
247
+ ]
248
+ };
249
+
250
+ export { resource };
251
+ `;
252
+
176
253
  test("buildUiTemplateContext derives list/view/new/edit placeholders from resource validators", async () => {
177
254
  await withTempApp(async (appRoot) => {
178
255
  const resourceFile = "packages/customers/src/shared/customerResource.js";
@@ -220,6 +297,31 @@ test("buildUiTemplateContext derives list/view/new/edit placeholders from resour
220
297
  });
221
298
  });
222
299
 
300
+ test("buildUiTemplateContext disables browser autofill on lookup autocomplete fields", async () => {
301
+ await withTempApp(async (appRoot) => {
302
+ const resourceFile = "packages/availabilities/src/shared/availabilityResource.js";
303
+ await writeResource(appRoot, resourceFile, LOOKUP_RESOURCE_SOURCE);
304
+
305
+ const context = await buildUiTemplateContext({
306
+ appRoot,
307
+ options: {
308
+ namespace: "availabilities",
309
+ "api-path": "/availabilities",
310
+ operations: "new,edit",
311
+ "resource-file": resourceFile
312
+ }
313
+ });
314
+
315
+ assert.match(context.__JSKIT_UI_CREATE_FORM_COLUMNS__, /<v-autocomplete[\s\S]*autocomplete="off"/);
316
+ assert.match(context.__JSKIT_UI_EDIT_FORM_COLUMNS__, /<v-autocomplete[\s\S]*autocomplete="off"/);
317
+
318
+ const createFields = JSON.parse(context.__JSKIT_UI_CREATE_FORM_FIELDS__);
319
+ const editFields = JSON.parse(context.__JSKIT_UI_EDIT_FORM_FIELDS__);
320
+ assert.equal(createFields[0].relation.surfaceId, "console");
321
+ assert.equal(editFields[0].relation.surfaceId, "console");
322
+ });
323
+ });
324
+
223
325
  test("buildUiTemplateContext includes hidden default list fields when explicitly selected", async () => {
224
326
  await withTempApp(async (appRoot) => {
225
327
  const resourceFile = "packages/customers/src/shared/customerResource.js";