@jskit-ai/crud-ui-generator 0.1.1
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 +201 -0
- package/package.json +11 -0
- package/src/server/buildTemplateContext.js +537 -0
- package/templates/src/pages/admin/ui-generator/EditElement.vue +141 -0
- package/templates/src/pages/admin/ui-generator/ListElement.vue +103 -0
- package/templates/src/pages/admin/ui-generator/NewElement.vue +114 -0
- package/templates/src/pages/admin/ui-generator/ViewElement.vue +71 -0
- package/test/buildTemplateContext.test.js +331 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
const JS_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
5
|
+
const DATE_FORMATS = new Set(["date", "date-time", "time"]);
|
|
6
|
+
const ALLOWED_OPERATIONS = new Set(["list", "view", "new", "edit"]);
|
|
7
|
+
|
|
8
|
+
function normalizeText(value) {
|
|
9
|
+
return String(value || "").trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requireOption(options, optionName) {
|
|
13
|
+
const value = normalizeText(options?.[optionName]);
|
|
14
|
+
if (!value) {
|
|
15
|
+
throw new Error(`ui-generator requires option "${optionName}".`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseOperationsOption(options) {
|
|
22
|
+
const rawValue = requireOption(options, "operations");
|
|
23
|
+
const operations = rawValue
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((entry) => normalizeText(entry).toLowerCase())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
if (operations.length < 1) {
|
|
28
|
+
throw new Error('ui-generator option "operations" must include at least one value: list, view, new, or edit.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const unique = new Set();
|
|
32
|
+
for (const operation of operations) {
|
|
33
|
+
if (!ALLOWED_OPERATIONS.has(operation)) {
|
|
34
|
+
throw new Error('ui-generator option "operations" supports only: list, view, new, edit.');
|
|
35
|
+
}
|
|
36
|
+
unique.add(operation);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return unique;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseDisplayFieldsOption(options) {
|
|
43
|
+
const rawValue = normalizeText(options?.["display-fields"]);
|
|
44
|
+
if (!rawValue) {
|
|
45
|
+
return Object.freeze([]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fieldKeys = rawValue
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((entry) => normalizeText(entry))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
if (fieldKeys.length < 1) {
|
|
53
|
+
throw new Error('ui-generator option "display-fields" must include at least one field key.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const unique = [];
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const fieldKey of fieldKeys) {
|
|
59
|
+
if (seen.has(fieldKey)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
seen.add(fieldKey);
|
|
64
|
+
unique.push(fieldKey);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Object.freeze(unique);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveResourceNamespaceOption(options = {}) {
|
|
71
|
+
const rawApiPath = normalizeText(options?.["api-path"]);
|
|
72
|
+
const apiPathSegments = rawApiPath
|
|
73
|
+
.replace(/\\/g, "/")
|
|
74
|
+
.replace(/\/{2,}/g, "/")
|
|
75
|
+
.split("/")
|
|
76
|
+
.map((entry) => normalizeText(entry))
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
|
|
79
|
+
const apiPathNamespace = normalizeText(apiPathSegments[apiPathSegments.length - 1]);
|
|
80
|
+
const fallbackNamespace = normalizeText(options?.namespace).toLowerCase();
|
|
81
|
+
const resolvedNamespace = normalizeText(apiPathNamespace || fallbackNamespace || "crud").toLowerCase();
|
|
82
|
+
if (!resolvedNamespace) {
|
|
83
|
+
throw new Error('ui-generator could not resolve namespace from "api-path" or "namespace".');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return resolvedNamespace;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveResourceModulePath(appRoot, resourceFile) {
|
|
90
|
+
const normalizedFile = normalizeText(resourceFile);
|
|
91
|
+
if (!normalizedFile) {
|
|
92
|
+
throw new Error('ui-generator requires option "resource-file".');
|
|
93
|
+
}
|
|
94
|
+
if (path.isAbsolute(normalizedFile)) {
|
|
95
|
+
throw new Error('ui-generator option "resource-file" must be a path relative to app root.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const appRootAbsolute = path.resolve(String(appRoot || ""));
|
|
99
|
+
if (!appRootAbsolute) {
|
|
100
|
+
throw new Error("ui-generator template context requires appRoot.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const absolutePath = path.resolve(appRootAbsolute, normalizedFile);
|
|
104
|
+
const relativePath = path.relative(appRootAbsolute, absolutePath);
|
|
105
|
+
if (
|
|
106
|
+
!relativePath ||
|
|
107
|
+
relativePath === ".." ||
|
|
108
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
109
|
+
path.isAbsolute(relativePath)
|
|
110
|
+
) {
|
|
111
|
+
throw new Error('ui-generator option "resource-file" must stay within app root.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return absolutePath;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function deriveDefaultResourceExport(resourceFile = "") {
|
|
118
|
+
const fileName = normalizeText(path.parse(String(resourceFile || "")).name);
|
|
119
|
+
if (!fileName) {
|
|
120
|
+
throw new Error('ui-generator option "resource-export" is required when it cannot be derived from "resource-file".');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fileName;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadResourceDefinition({ appRoot, options }) {
|
|
127
|
+
const resourceFile = requireOption(options, "resource-file");
|
|
128
|
+
const resourceExport = normalizeText(options?.["resource-export"]) || deriveDefaultResourceExport(resourceFile);
|
|
129
|
+
const resourceModulePath = resolveResourceModulePath(appRoot, resourceFile);
|
|
130
|
+
|
|
131
|
+
let moduleNamespace = null;
|
|
132
|
+
try {
|
|
133
|
+
moduleNamespace = await import(`${pathToFileURL(resourceModulePath).href}?t=${Date.now()}_${Math.random()}`);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`ui-generator could not load resource file "${resourceFile}": ${String(error?.message || error || "unknown error")}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resource = moduleNamespace?.[resourceExport];
|
|
141
|
+
if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`ui-generator could not find resource export "${resourceExport}" in "${resourceFile}".`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return resource;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requireOperation(resource, operationName) {
|
|
151
|
+
const operations = resource?.operations;
|
|
152
|
+
if (!operations || typeof operations !== "object" || Array.isArray(operations)) {
|
|
153
|
+
throw new Error("ui-generator resource must expose operations.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const operation = operations[operationName];
|
|
157
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
|
|
158
|
+
throw new Error(`ui-generator resource is missing operations.${operationName}.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return operation;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function requireOutputSchema(operation, operationName) {
|
|
165
|
+
const outputValidator = operation?.outputValidator;
|
|
166
|
+
if (!outputValidator || typeof outputValidator !== "object" || Array.isArray(outputValidator)) {
|
|
167
|
+
throw new Error(`ui-generator resource operations.${operationName} is missing outputValidator.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const schema = outputValidator.schema;
|
|
171
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
172
|
+
throw new Error(`ui-generator resource operations.${operationName}.outputValidator is missing schema.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return schema;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function requireBodySchema(operation, operationName) {
|
|
179
|
+
const bodyValidator = operation?.bodyValidator;
|
|
180
|
+
if (!bodyValidator || typeof bodyValidator !== "object" || Array.isArray(bodyValidator)) {
|
|
181
|
+
throw new Error(`ui-generator resource operations.${operationName} is missing bodyValidator.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const schema = bodyValidator.schema;
|
|
185
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
186
|
+
throw new Error(`ui-generator resource operations.${operationName}.bodyValidator is missing schema.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return schema;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function requireObjectProperties(schema, contextLabel) {
|
|
193
|
+
const properties = schema?.properties;
|
|
194
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
|
195
|
+
throw new Error(`ui-generator expected ${contextLabel} to be an object schema with properties.`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return properties;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveListItemProperties(listOutputSchema) {
|
|
202
|
+
const listProperties = requireObjectProperties(listOutputSchema, "operations.list output");
|
|
203
|
+
const itemsSchema = listProperties.items;
|
|
204
|
+
if (!itemsSchema || typeof itemsSchema !== "object" || Array.isArray(itemsSchema)) {
|
|
205
|
+
throw new Error("ui-generator expected operations.list output schema to include object items schema.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const itemSchema = Array.isArray(itemsSchema.items) ? itemsSchema.items[0] : itemsSchema.items;
|
|
209
|
+
if (!itemSchema || typeof itemSchema !== "object" || Array.isArray(itemSchema)) {
|
|
210
|
+
throw new Error("ui-generator expected operations.list output schema items.items to be an object schema.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return requireObjectProperties(itemSchema, "operations.list output items");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveSchemaType(schema) {
|
|
217
|
+
const source = schema && typeof schema === "object" && !Array.isArray(schema) ? schema : {};
|
|
218
|
+
const rawType = source.type;
|
|
219
|
+
const normalizedType = Array.isArray(rawType)
|
|
220
|
+
? normalizeText(rawType.find((entry) => normalizeText(entry).toLowerCase() !== "null"))
|
|
221
|
+
: normalizeText(rawType);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
type: normalizedType.toLowerCase(),
|
|
225
|
+
format: normalizeText(source.format).toLowerCase(),
|
|
226
|
+
schema: source
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function toFieldLabel(key) {
|
|
231
|
+
const normalizedKey = normalizeText(key);
|
|
232
|
+
if (!normalizedKey) {
|
|
233
|
+
return "Field";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const words = normalizedKey
|
|
237
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
238
|
+
.replace(/[_\-.]+/g, " ")
|
|
239
|
+
.split(/\s+/)
|
|
240
|
+
.map((entry) => normalizeText(entry))
|
|
241
|
+
.filter(Boolean);
|
|
242
|
+
if (words.length < 1) {
|
|
243
|
+
return "Field";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return words
|
|
247
|
+
.map((entry) => `${entry.slice(0, 1).toUpperCase()}${entry.slice(1)}`)
|
|
248
|
+
.join(" ");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function createFieldDefinitions(properties = {}) {
|
|
252
|
+
const fields = [];
|
|
253
|
+
|
|
254
|
+
for (const [rawKey, schema] of Object.entries(properties)) {
|
|
255
|
+
const key = normalizeText(rawKey);
|
|
256
|
+
if (!key) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const schemaType = resolveSchemaType(schema);
|
|
261
|
+
fields.push({
|
|
262
|
+
key,
|
|
263
|
+
label: toFieldLabel(key),
|
|
264
|
+
type: schemaType.type,
|
|
265
|
+
format: schemaType.format
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (fields.length > 0) {
|
|
270
|
+
return fields;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return [
|
|
274
|
+
{
|
|
275
|
+
key: "id",
|
|
276
|
+
label: "Id",
|
|
277
|
+
type: "string",
|
|
278
|
+
format: ""
|
|
279
|
+
}
|
|
280
|
+
];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function toAccessorExpression(baseName, fieldKey) {
|
|
284
|
+
const key = normalizeText(fieldKey);
|
|
285
|
+
if (!key) {
|
|
286
|
+
return baseName;
|
|
287
|
+
}
|
|
288
|
+
if (JS_IDENTIFIER_PATTERN.test(key)) {
|
|
289
|
+
return `${baseName}.${key}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return `${baseName}[${JSON.stringify(key)}]`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function toOptionalAccessorExpression(baseName, fieldKey) {
|
|
296
|
+
const key = normalizeText(fieldKey);
|
|
297
|
+
if (!key) {
|
|
298
|
+
return baseName;
|
|
299
|
+
}
|
|
300
|
+
if (JS_IDENTIFIER_PATTERN.test(key)) {
|
|
301
|
+
return `${baseName}?.${key}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return `${baseName}?.[${JSON.stringify(key)}]`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function escapeHtml(value) {
|
|
308
|
+
return String(value || "")
|
|
309
|
+
.replaceAll("&", "&")
|
|
310
|
+
.replaceAll("<", "<")
|
|
311
|
+
.replaceAll(">", ">")
|
|
312
|
+
.replaceAll('"', """)
|
|
313
|
+
.replaceAll("'", "'");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildListHeaderColumns(fields) {
|
|
317
|
+
return fields
|
|
318
|
+
.map((field) => ` <th>${escapeHtml(field.label)}</th>`)
|
|
319
|
+
.join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function buildListRowColumns(fields) {
|
|
323
|
+
return fields
|
|
324
|
+
.map(
|
|
325
|
+
(field) =>
|
|
326
|
+
` <td>{{ ${toAccessorExpression("record", field.key)} }}</td>`
|
|
327
|
+
)
|
|
328
|
+
.join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function buildViewColumns(fields) {
|
|
332
|
+
return fields
|
|
333
|
+
.map(
|
|
334
|
+
(field) => ` <v-col cols="12" md="6">
|
|
335
|
+
<div class="text-caption text-medium-emphasis">${escapeHtml(field.label)}</div>
|
|
336
|
+
<div class="text-body-1">{{ ${toOptionalAccessorExpression("view.record.value", field.key)} }}</div>
|
|
337
|
+
</v-col>`
|
|
338
|
+
)
|
|
339
|
+
.join("\n");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function resolveRecordChangedEventName(namespace = "") {
|
|
343
|
+
const normalizedNamespace = normalizeText(namespace).toLowerCase();
|
|
344
|
+
if (!normalizedNamespace) {
|
|
345
|
+
return "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return `${normalizedNamespace.replace(/-/g, "_")}.record.changed`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function resolveRecordIdExpression(fields) {
|
|
352
|
+
const normalizedFields = Array.isArray(fields) ? fields : [];
|
|
353
|
+
const preferred =
|
|
354
|
+
normalizedFields.find((field) => normalizeText(field?.key).toLowerCase() === "id") ||
|
|
355
|
+
normalizedFields.find((field) => {
|
|
356
|
+
const key = normalizeText(field?.key).toLowerCase();
|
|
357
|
+
return key.endsWith("id") || key.endsWith("_id") || key.endsWith("-id");
|
|
358
|
+
}) ||
|
|
359
|
+
normalizedFields[0] ||
|
|
360
|
+
{ key: "id" };
|
|
361
|
+
|
|
362
|
+
return toAccessorExpression("item", preferred.key);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateDisplayFieldsForOperation(selectedFieldKeys, fields, operationName) {
|
|
366
|
+
const selectedFields = Array.isArray(selectedFieldKeys) ? selectedFieldKeys : [];
|
|
367
|
+
if (selectedFields.length < 1) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const availableFieldKeys = new Set(
|
|
372
|
+
(Array.isArray(fields) ? fields : [])
|
|
373
|
+
.map((field) => normalizeText(field?.key))
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const invalidFieldKeys = selectedFields.filter((fieldKey) => !availableFieldKeys.has(fieldKey));
|
|
378
|
+
if (invalidFieldKeys.length < 1) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
throw new Error(
|
|
383
|
+
`ui-generator option "display-fields" includes unsupported field(s) for operations.${operationName}: ${invalidFieldKeys.join(", ")}.`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function filterDisplayFields(selectedFieldKeys, fields) {
|
|
388
|
+
const selectedFields = Array.isArray(selectedFieldKeys) ? selectedFieldKeys : [];
|
|
389
|
+
const availableFields = Array.isArray(fields) ? fields : [];
|
|
390
|
+
if (selectedFields.length < 1) {
|
|
391
|
+
return availableFields;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const selectedFieldSet = new Set(selectedFields);
|
|
395
|
+
return availableFields.filter((field) => selectedFieldSet.has(normalizeText(field?.key)));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function resolveFormInputType(fieldType, fieldFormat) {
|
|
399
|
+
if (fieldType === "integer" || fieldType === "number") {
|
|
400
|
+
return "number";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (DATE_FORMATS.has(fieldFormat)) {
|
|
404
|
+
return "date";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return "text";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function resolveFormFieldComponent(fieldType) {
|
|
411
|
+
if (fieldType === "boolean") {
|
|
412
|
+
return "switch";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return "text";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function toPositiveInteger(value) {
|
|
419
|
+
const parsed = Number(value);
|
|
420
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function createFormFieldDefinitions(properties = {}) {
|
|
424
|
+
const fields = [];
|
|
425
|
+
|
|
426
|
+
for (const [rawKey, schema] of Object.entries(properties)) {
|
|
427
|
+
const key = normalizeText(rawKey);
|
|
428
|
+
if (!key) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const schemaType = resolveSchemaType(schema);
|
|
433
|
+
fields.push({
|
|
434
|
+
key,
|
|
435
|
+
label: toFieldLabel(key),
|
|
436
|
+
type: schemaType.type || "string",
|
|
437
|
+
format: schemaType.format || "",
|
|
438
|
+
inputType: resolveFormInputType(schemaType.type, schemaType.format),
|
|
439
|
+
component: resolveFormFieldComponent(schemaType.type),
|
|
440
|
+
maxLength: toPositiveInteger(schemaType.schema?.maxLength)
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return fields;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function ensureFields(fields, fallbackFields = createFieldDefinitions({})) {
|
|
448
|
+
const normalizedFields = Array.isArray(fields) ? fields : [];
|
|
449
|
+
if (normalizedFields.length > 0) {
|
|
450
|
+
return normalizedFields;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return fallbackFields;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function buildUiTemplateContext({ appRoot, options } = {}) {
|
|
457
|
+
const selectedOperations = parseOperationsOption(options);
|
|
458
|
+
const selectedDisplayFields = parseDisplayFieldsOption(options);
|
|
459
|
+
const resourceNamespace = resolveResourceNamespaceOption(options);
|
|
460
|
+
|
|
461
|
+
const hasListOperation = selectedOperations.has("list");
|
|
462
|
+
const hasViewOperation = selectedOperations.has("view");
|
|
463
|
+
const hasNewOperation = selectedOperations.has("new");
|
|
464
|
+
const hasEditOperation = selectedOperations.has("edit");
|
|
465
|
+
|
|
466
|
+
const resource = await loadResourceDefinition({ appRoot, options });
|
|
467
|
+
|
|
468
|
+
let listFieldsAll = [];
|
|
469
|
+
if (hasListOperation) {
|
|
470
|
+
const listOperation = requireOperation(resource, "list");
|
|
471
|
+
const listOutputSchema = requireOutputSchema(listOperation, "list");
|
|
472
|
+
listFieldsAll = createFieldDefinitions(resolveListItemProperties(listOutputSchema));
|
|
473
|
+
validateDisplayFieldsForOperation(selectedDisplayFields, listFieldsAll, "list");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let viewFieldsAll = [];
|
|
477
|
+
if (hasViewOperation) {
|
|
478
|
+
const viewOperation = requireOperation(resource, "view");
|
|
479
|
+
const viewOutputSchema = requireOutputSchema(viewOperation, "view");
|
|
480
|
+
viewFieldsAll = createFieldDefinitions(requireObjectProperties(viewOutputSchema, "operations.view output"));
|
|
481
|
+
validateDisplayFieldsForOperation(selectedDisplayFields, viewFieldsAll, "view");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let createFieldsAll = [];
|
|
485
|
+
if (hasNewOperation) {
|
|
486
|
+
const createOperation = requireOperation(resource, "create");
|
|
487
|
+
const createBodySchema = requireBodySchema(createOperation, "create");
|
|
488
|
+
createFieldsAll = createFormFieldDefinitions(requireObjectProperties(createBodySchema, "operations.create body"));
|
|
489
|
+
validateDisplayFieldsForOperation(selectedDisplayFields, createFieldsAll, "create");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let editFieldsAll = [];
|
|
493
|
+
if (hasEditOperation) {
|
|
494
|
+
const patchOperation = requireOperation(resource, "patch");
|
|
495
|
+
const patchBodySchema = requireBodySchema(patchOperation, "patch");
|
|
496
|
+
editFieldsAll = createFormFieldDefinitions(requireObjectProperties(patchBodySchema, "operations.patch body"));
|
|
497
|
+
validateDisplayFieldsForOperation(selectedDisplayFields, editFieldsAll, "patch");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const listFields = hasListOperation
|
|
501
|
+
? filterDisplayFields(selectedDisplayFields, ensureFields(listFieldsAll))
|
|
502
|
+
: createFieldDefinitions({});
|
|
503
|
+
const viewFields = hasViewOperation
|
|
504
|
+
? filterDisplayFields(selectedDisplayFields, ensureFields(viewFieldsAll))
|
|
505
|
+
: createFieldDefinitions({});
|
|
506
|
+
const createFields = hasNewOperation
|
|
507
|
+
? filterDisplayFields(selectedDisplayFields, createFieldsAll)
|
|
508
|
+
: [];
|
|
509
|
+
const editFields = hasEditOperation
|
|
510
|
+
? filterDisplayFields(selectedDisplayFields, editFieldsAll)
|
|
511
|
+
: [];
|
|
512
|
+
|
|
513
|
+
const recordIdFields =
|
|
514
|
+
listFieldsAll.length > 0
|
|
515
|
+
? listFieldsAll
|
|
516
|
+
: viewFieldsAll.length > 0
|
|
517
|
+
? viewFieldsAll
|
|
518
|
+
: editFieldsAll.length > 0
|
|
519
|
+
? editFieldsAll
|
|
520
|
+
: createFieldDefinitions({});
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
__JSKIT_UI_LIST_HEADER_COLUMNS__: buildListHeaderColumns(listFields),
|
|
524
|
+
__JSKIT_UI_LIST_ROW_COLUMNS__: buildListRowColumns(listFields),
|
|
525
|
+
__JSKIT_UI_LIST_RECORD_ID_EXPR__: resolveRecordIdExpression(recordIdFields),
|
|
526
|
+
__JSKIT_UI_VIEW_COLUMNS__: buildViewColumns(viewFields),
|
|
527
|
+
__JSKIT_UI_RECORD_CHANGED_EVENT__: JSON.stringify(resolveRecordChangedEventName(resourceNamespace)),
|
|
528
|
+
__JSKIT_UI_HAS_LIST_ROUTE__: hasListOperation ? "true" : "false",
|
|
529
|
+
__JSKIT_UI_HAS_VIEW_ROUTE__: hasViewOperation ? "true" : "false",
|
|
530
|
+
__JSKIT_UI_HAS_NEW_ROUTE__: hasNewOperation ? "true" : "false",
|
|
531
|
+
__JSKIT_UI_HAS_EDIT_ROUTE__: hasEditOperation ? "true" : "false",
|
|
532
|
+
__JSKIT_UI_CREATE_FORM_FIELDS__: JSON.stringify(createFields),
|
|
533
|
+
__JSKIT_UI_EDIT_FORM_FIELDS__: JSON.stringify(editFields)
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export { buildUiTemplateContext };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="ui-generator-edit-element d-flex flex-column ga-4">
|
|
3
|
+
<v-card rounded="lg" elevation="1" border>
|
|
4
|
+
<v-card-item>
|
|
5
|
+
<div class="d-flex align-center ga-3 flex-wrap w-100">
|
|
6
|
+
<div>
|
|
7
|
+
<v-card-title class="px-0">Edit ${option:namespace|singular|pascal|default(Record)}</v-card-title>
|
|
8
|
+
<v-card-subtitle class="px-0">Update the selected ${option:namespace|singular|default(record)}.</v-card-subtitle>
|
|
9
|
+
</div>
|
|
10
|
+
<v-spacer />
|
|
11
|
+
<v-btn
|
|
12
|
+
v-if="UI_CANCEL_URL"
|
|
13
|
+
variant="text"
|
|
14
|
+
:to="formRuntime.addEdit.resolveParams(UI_CANCEL_URL)"
|
|
15
|
+
>
|
|
16
|
+
Cancel
|
|
17
|
+
</v-btn>
|
|
18
|
+
<v-btn
|
|
19
|
+
color="primary"
|
|
20
|
+
:loading="formRuntime.addEdit.isSaving"
|
|
21
|
+
:disabled="
|
|
22
|
+
formRuntime.addEdit.isInitialLoading ||
|
|
23
|
+
formRuntime.addEdit.isRefetching ||
|
|
24
|
+
!formRuntime.addEdit.canSave
|
|
25
|
+
"
|
|
26
|
+
@click="formRuntime.addEdit.submit"
|
|
27
|
+
>
|
|
28
|
+
Save changes
|
|
29
|
+
</v-btn>
|
|
30
|
+
</div>
|
|
31
|
+
</v-card-item>
|
|
32
|
+
<v-divider />
|
|
33
|
+
<v-card-text class="pt-4">
|
|
34
|
+
<p v-if="formRuntime.addEdit.loadError" class="text-body-2 text-medium-emphasis mb-0">
|
|
35
|
+
{{ formRuntime.addEdit.loadError }}
|
|
36
|
+
</p>
|
|
37
|
+
<template v-else-if="formRuntime.showFormSkeleton">
|
|
38
|
+
<v-skeleton-loader type="text@2, list-item-two-line@4, button" />
|
|
39
|
+
</template>
|
|
40
|
+
<v-form v-else @submit.prevent="formRuntime.addEdit.submit" novalidate>
|
|
41
|
+
<v-progress-linear v-if="formRuntime.addEdit.isRefetching" indeterminate class="mb-4" />
|
|
42
|
+
<v-row>
|
|
43
|
+
<v-col v-for="field in formRuntime.formFields" :key="field.key" cols="12" md="6">
|
|
44
|
+
<v-switch
|
|
45
|
+
v-if="field.component === 'switch'"
|
|
46
|
+
v-model="formRuntime.form[field.key]"
|
|
47
|
+
:label="field.label"
|
|
48
|
+
color="primary"
|
|
49
|
+
hide-details="auto"
|
|
50
|
+
:disabled="
|
|
51
|
+
!formRuntime.addEdit.canSave ||
|
|
52
|
+
formRuntime.addEdit.isSaving ||
|
|
53
|
+
formRuntime.addEdit.isRefetching
|
|
54
|
+
"
|
|
55
|
+
:error-messages="formRuntime.resolveFieldErrors(field.key)"
|
|
56
|
+
/>
|
|
57
|
+
<v-text-field
|
|
58
|
+
v-else
|
|
59
|
+
v-model="formRuntime.form[field.key]"
|
|
60
|
+
:label="field.label"
|
|
61
|
+
:type="field.inputType"
|
|
62
|
+
variant="outlined"
|
|
63
|
+
density="comfortable"
|
|
64
|
+
:maxlength="field.maxLength || undefined"
|
|
65
|
+
:readonly="
|
|
66
|
+
!formRuntime.addEdit.canSave ||
|
|
67
|
+
formRuntime.addEdit.isSaving ||
|
|
68
|
+
formRuntime.addEdit.isRefetching
|
|
69
|
+
"
|
|
70
|
+
:error-messages="formRuntime.resolveFieldErrors(field.key)"
|
|
71
|
+
/>
|
|
72
|
+
</v-col>
|
|
73
|
+
</v-row>
|
|
74
|
+
</v-form>
|
|
75
|
+
</v-card-text>
|
|
76
|
+
</v-card>
|
|
77
|
+
</section>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup>
|
|
81
|
+
import { computed } from "vue";
|
|
82
|
+
import { useRoute } from "vue-router";
|
|
83
|
+
import { useCrudSchemaForm } from "@jskit-ai/users-web/client/composables/useCrudSchemaForm";
|
|
84
|
+
import { ${option:resource-export|trim} as uiResource } from "/${option:resource-file|trim}";
|
|
85
|
+
|
|
86
|
+
const UI_OPERATION_ADAPTER = null;
|
|
87
|
+
const UI_RECORD_ID_PARAM = "${option:id-param|trim}";
|
|
88
|
+
const UI_API_BASE_URL = "${option:api-path|trim}";
|
|
89
|
+
const UI_EDIT_API_URL = `${UI_API_BASE_URL}/:${UI_RECORD_ID_PARAM}`;
|
|
90
|
+
const UI_LIST_URL = __JSKIT_UI_HAS_LIST_ROUTE__ ? "../.." : "";
|
|
91
|
+
const UI_VIEW_URL = __JSKIT_UI_HAS_VIEW_ROUTE__ ? ".." : "";
|
|
92
|
+
const UI_CANCEL_URL = UI_VIEW_URL || UI_LIST_URL;
|
|
93
|
+
const UI_RECORD_CHANGED_EVENT = __JSKIT_UI_RECORD_CHANGED_EVENT__;
|
|
94
|
+
const UI_EDIT_FORM_FIELDS = Object.freeze(__JSKIT_UI_EDIT_FORM_FIELDS__);
|
|
95
|
+
|
|
96
|
+
const route = useRoute();
|
|
97
|
+
|
|
98
|
+
const routeRecordId = computed(() => {
|
|
99
|
+
const source = route.params?.[UI_RECORD_ID_PARAM];
|
|
100
|
+
if (Array.isArray(source)) {
|
|
101
|
+
return String(source[0] ?? "").trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return String(source ?? "").trim();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const formRuntime = useCrudSchemaForm({
|
|
108
|
+
resource: uiResource,
|
|
109
|
+
operationName: "patch",
|
|
110
|
+
formFields: UI_EDIT_FORM_FIELDS,
|
|
111
|
+
addEditOptions: {
|
|
112
|
+
adapter: UI_OPERATION_ADAPTER || undefined,
|
|
113
|
+
apiUrlTemplate: UI_EDIT_API_URL,
|
|
114
|
+
queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
|
|
115
|
+
"ui-generator",
|
|
116
|
+
"${option:namespace|kebab}",
|
|
117
|
+
"edit",
|
|
118
|
+
String(surfaceId || ""),
|
|
119
|
+
String(workspaceSlug || ""),
|
|
120
|
+
routeRecordId.value
|
|
121
|
+
],
|
|
122
|
+
placementSource: "ui-generator.${option:namespace|kebab}.edit",
|
|
123
|
+
writeMethod: "PATCH",
|
|
124
|
+
fallbackLoadError: "Unable to load record.",
|
|
125
|
+
fallbackSaveError: "Unable to save record.",
|
|
126
|
+
recordIdParam: UI_RECORD_ID_PARAM,
|
|
127
|
+
routeRecordId,
|
|
128
|
+
viewUrlTemplate: UI_VIEW_URL,
|
|
129
|
+
listUrlTemplate: UI_LIST_URL,
|
|
130
|
+
realtime: UI_RECORD_CHANGED_EVENT
|
|
131
|
+
? {
|
|
132
|
+
event: UI_RECORD_CHANGED_EVENT
|
|
133
|
+
}
|
|
134
|
+
: null
|
|
135
|
+
},
|
|
136
|
+
saveSuccess: {
|
|
137
|
+
invalidateQueryKey: ["ui-generator", "${option:namespace|kebab}"],
|
|
138
|
+
listUrlTemplate: UI_LIST_URL
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
</script>
|