@jskit-ai/crud-ui-generator 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.descriptor.mjs +123 -8
- package/package.json +3 -3
- package/src/server/resourceSupport.js +6 -0
- package/src/server/subcommands/addField.js +158 -14
- package/templates/src/pages/admin/ui-generator/AddEditForm.vue +105 -0
- package/templates/src/pages/admin/ui-generator/AddEditFormFields.js +17 -0
- package/templates/src/pages/admin/ui-generator/EditElement.vue +2 -2
- package/templates/src/pages/admin/ui-generator/EditWrapperElement.vue +108 -0
- package/templates/src/pages/admin/ui-generator/ListElement.vue +25 -3
- package/templates/src/pages/admin/ui-generator/NewElement.vue +2 -2
- package/templates/src/pages/admin/ui-generator/NewWrapperElement.vue +81 -0
- package/templates/src/pages/admin/ui-generator/ViewElement.vue +2 -2
- package/test/addFieldSubcommand.test.js +142 -0
- package/test/buildTemplateContext.test.js +102 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/crud-ui-generator",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.7",
|
|
5
5
|
kind: "generator",
|
|
6
6
|
description: "Generate app-local CRUD UI scaffolds from resource validators.",
|
|
7
7
|
options: {
|
|
@@ -136,7 +136,7 @@ export default Object.freeze({
|
|
|
136
136
|
mutations: {
|
|
137
137
|
dependencies: {
|
|
138
138
|
runtime: {
|
|
139
|
-
"@jskit-ai/users-web": "0.1.
|
|
139
|
+
"@jskit-ai/users-web": "0.1.38"
|
|
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/
|
|
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
|
-
|
|
194
|
-
|
|
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/
|
|
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
|
-
|
|
211
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@jskit-ai/crud-core": "0.1.
|
|
10
|
-
"@jskit-ai/kernel": "0.1.
|
|
9
|
+
"@jskit-ai/crud-core": "0.1.32",
|
|
10
|
+
"@jskit-ai/kernel": "0.1.24"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
"./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
|
|
@@ -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 =
|
|
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
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
450
|
+
touchedFiles,
|
|
307
451
|
summary: changed
|
|
308
|
-
? `Added field "${field.key}" to ${operationName} in ${
|
|
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 {
|
|
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 =
|
|
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"
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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";
|