form0-core 0.4.0 → 0.4.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/README.md +12 -0
- package/package.json +1 -1
- package/src/authoring/form-authoring.js +124 -13
package/README.md
CHANGED
|
@@ -172,6 +172,18 @@ Record utilities follow this contract:
|
|
|
172
172
|
|
|
173
173
|
Record status remains top-level as `@status`; it is not stored inside `form_values`.
|
|
174
174
|
|
|
175
|
+
## Authoring mutation validation
|
|
176
|
+
|
|
177
|
+
`getFormAuthoringContext({ schema })` includes `mutationOperationCatalog`: the installed operation
|
|
178
|
+
formats, required parameters, and examples. `mutationOperations` remains the list of supported names.
|
|
179
|
+
Use these formats with `applyFormMutationBatch`; for example, a rename uses
|
|
180
|
+
`{ op: 'updateForm', changes: { name: 'Renamed form' } }`, not a `patch` parameter.
|
|
181
|
+
|
|
182
|
+
Malformed operations, unknown operation parameters, and empty change objects are rejected atomically
|
|
183
|
+
with diagnostics, rather than silently ignored. Clearing a title/status field, calculation, or event
|
|
184
|
+
code requires its explicit parameter (for example, `field: null`), not an omitted value. Valid
|
|
185
|
+
idempotent batches remain valid in core; consumers should check actual changes before offering to save.
|
|
186
|
+
|
|
175
187
|
## ✅ Requirements
|
|
176
188
|
|
|
177
189
|
- Node.js 22 or newer
|
package/package.json
CHANGED
|
@@ -16,17 +16,77 @@ import {
|
|
|
16
16
|
} from '../utilities/form-event-editor.js';
|
|
17
17
|
|
|
18
18
|
const CONTAINER_TYPES = new Set(['Section', 'RepeatableSection', 'BuildingPlanSection']);
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
const FIELD_KEY_PARAMETER = Object.freeze({ type: 'string', minLength: 1 });
|
|
20
|
+
const CHANGES_PARAMETER = Object.freeze({ type: 'object', minProperties: 1 });
|
|
21
|
+
const PLACEMENT_PARAMETERS = Object.freeze({
|
|
22
|
+
parentKey: { type: ['string', 'null'], minLength: 1 },
|
|
23
|
+
position: {
|
|
24
|
+
type: ['object', 'null'],
|
|
25
|
+
properties: { beforeKey: FIELD_KEY_PARAMETER, afterKey: FIELD_KEY_PARAMETER },
|
|
26
|
+
maxProperties: 1,
|
|
27
|
+
description: 'Use one sibling key, or omit position to append in the target container.',
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const MUTATION_OPERATION_CATALOG = Object.freeze([
|
|
31
|
+
{
|
|
32
|
+
op: 'updateForm',
|
|
33
|
+
description:
|
|
34
|
+
'Update form attributes; elements, events, title_field and status_field use dedicated operations.',
|
|
35
|
+
required: ['changes'],
|
|
36
|
+
parameters: { changes: CHANGES_PARAMETER },
|
|
37
|
+
example: { op: 'updateForm', changes: { name: 'Renamed form' } },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
op: 'addField',
|
|
41
|
+
description: 'Add a complete field matching fieldSpecs; missing keys are generated.',
|
|
42
|
+
required: ['field'],
|
|
43
|
+
parameters: { field: { type: 'object', minProperties: 1 }, ...PLACEMENT_PARAMETERS },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
op: 'updateField',
|
|
47
|
+
description: 'Update an existing field by key; type changes require remove/add.',
|
|
48
|
+
required: ['fieldKey', 'changes'],
|
|
49
|
+
parameters: { fieldKey: FIELD_KEY_PARAMETER, changes: CHANGES_PARAMETER },
|
|
50
|
+
example: { op: 'updateField', fieldKey: 'existing-field-key', changes: { label: 'New label' } },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
op: 'moveField',
|
|
54
|
+
description: 'Move an existing field by key to a container/sibling position.',
|
|
55
|
+
required: ['fieldKey'],
|
|
56
|
+
parameters: { fieldKey: FIELD_KEY_PARAMETER, ...PLACEMENT_PARAMETERS },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
op: 'removeField',
|
|
60
|
+
description: 'Remove an existing field by key only if it is unreferenced.',
|
|
61
|
+
required: ['fieldKey'],
|
|
62
|
+
parameters: { fieldKey: FIELD_KEY_PARAMETER },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
op: 'setTitleField',
|
|
66
|
+
description: 'Set a complete title field, or explicitly pass field: null to clear it.',
|
|
67
|
+
required: ['field'],
|
|
68
|
+
parameters: { field: { type: ['object', 'null'] } },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
op: 'setStatusField',
|
|
72
|
+
description: 'Set a complete status field, or explicitly pass field: null to clear it.',
|
|
73
|
+
required: ['field'],
|
|
74
|
+
parameters: { field: { type: ['object', 'null'] } },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
op: 'setCalculation',
|
|
78
|
+
description: 'Set a CalculatedField expression by key; null explicitly clears it.',
|
|
79
|
+
required: ['fieldKey', 'expression'],
|
|
80
|
+
parameters: { fieldKey: FIELD_KEY_PARAMETER, expression: { type: ['string', 'null'] } },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
op: 'setFormEventCode',
|
|
84
|
+
description: 'Set form event code; an empty string or null explicitly clears it.',
|
|
85
|
+
required: ['code'],
|
|
86
|
+
parameters: { code: { type: ['string', 'null'] } },
|
|
87
|
+
},
|
|
29
88
|
]);
|
|
89
|
+
const MUTATION_OPERATIONS = Object.freeze(MUTATION_OPERATION_CATALOG.map(({ op }) => op));
|
|
30
90
|
|
|
31
91
|
const CALCULATION_GUIDANCE = Object.freeze({
|
|
32
92
|
version: 1,
|
|
@@ -306,16 +366,66 @@ function mutationError(index, operation, error) {
|
|
|
306
366
|
};
|
|
307
367
|
}
|
|
308
368
|
|
|
369
|
+
function validateMutationParameter(value, spec, path) {
|
|
370
|
+
const type = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
371
|
+
const types = Array.isArray(spec.type) ? spec.type : [spec.type];
|
|
372
|
+
if (!types.includes(type)) throw new Error(`${path} must be ${types.join(' or ')}`);
|
|
373
|
+
if (value === null) return;
|
|
374
|
+
if (spec.minLength && value.length < spec.minLength) {
|
|
375
|
+
throw new Error(`${path} must not be empty`);
|
|
376
|
+
}
|
|
377
|
+
if (spec.minProperties && Object.keys(value).length < spec.minProperties) {
|
|
378
|
+
throw new Error(`${path} must be a non-empty object`);
|
|
379
|
+
}
|
|
380
|
+
if (spec.maxProperties && Object.keys(value).length > spec.maxProperties) {
|
|
381
|
+
throw new Error(`${path} must specify at most one property`);
|
|
382
|
+
}
|
|
383
|
+
if (spec.properties) {
|
|
384
|
+
for (const key of Object.keys(value)) {
|
|
385
|
+
if (!Object.hasOwn(spec.properties, key)) {
|
|
386
|
+
throw new Error(`Unexpected mutation parameter '${path}.${key}'`);
|
|
387
|
+
}
|
|
388
|
+
validateMutationParameter(value[key], spec.properties[key], `${path}.${key}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validateMutationOperation(operation) {
|
|
394
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) {
|
|
395
|
+
throw new Error('A mutation operation must be an object with an op property');
|
|
396
|
+
}
|
|
397
|
+
const definition = MUTATION_OPERATION_CATALOG.find(({ op }) => op === operation.op);
|
|
398
|
+
if (!definition) throw new Error(`Unsupported mutation operation '${operation.op}'`);
|
|
399
|
+
for (const name of definition.required) {
|
|
400
|
+
if (!Object.hasOwn(operation, name) || operation[name] === undefined) {
|
|
401
|
+
const example = definition.example ? ` Example: ${JSON.stringify(definition.example)}` : '';
|
|
402
|
+
throw new Error(`${operation.op} requires '${name}'.${example}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const name of Object.keys(operation)) {
|
|
406
|
+
if (name === 'op') continue;
|
|
407
|
+
if (!Object.hasOwn(definition.parameters, name)) {
|
|
408
|
+
throw new Error(`Unexpected ${operation.op} parameter '${name}'`);
|
|
409
|
+
}
|
|
410
|
+
validateMutationParameter(
|
|
411
|
+
operation[name],
|
|
412
|
+
definition.parameters[name],
|
|
413
|
+
`${operation.op}.${name}`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
309
418
|
function applyMutation(root, operation) {
|
|
419
|
+
validateMutationOperation(operation);
|
|
310
420
|
const form = root.form;
|
|
311
421
|
const fields = indexFields(form.elements);
|
|
312
422
|
switch (operation.op) {
|
|
313
423
|
case 'updateForm': {
|
|
314
424
|
const forbidden = new Set(['elements', 'events', 'title_field', 'status_field']);
|
|
315
|
-
for (const key of Object.keys(operation.changes
|
|
425
|
+
for (const key of Object.keys(operation.changes)) {
|
|
316
426
|
if (forbidden.has(key)) throw new Error(`updateForm cannot change '${key}'`);
|
|
317
427
|
}
|
|
318
|
-
Object.assign(form, deepClone(operation.changes
|
|
428
|
+
Object.assign(form, deepClone(operation.changes));
|
|
319
429
|
return `Updated form attributes`;
|
|
320
430
|
}
|
|
321
431
|
case 'addField': {
|
|
@@ -340,7 +450,7 @@ function applyMutation(root, operation) {
|
|
|
340
450
|
}
|
|
341
451
|
const original = deepClone(target.field);
|
|
342
452
|
const children = target.field.elements;
|
|
343
|
-
Object.assign(target.field, deepClone(operation.changes
|
|
453
|
+
Object.assign(target.field, deepClone(operation.changes));
|
|
344
454
|
if (
|
|
345
455
|
target.field.data_name !== original.data_name &&
|
|
346
456
|
[...fields.values()].some(
|
|
@@ -473,6 +583,7 @@ export function getFormAuthoringContext({ schema, coreVersion = null }) {
|
|
|
473
583
|
revision: getFormSchemaRevision(root),
|
|
474
584
|
schema: deepClone(root),
|
|
475
585
|
mutationOperations: [...MUTATION_OPERATIONS],
|
|
586
|
+
mutationOperationCatalog: deepClone(MUTATION_OPERATION_CATALOG),
|
|
476
587
|
fieldSpecs,
|
|
477
588
|
operators,
|
|
478
589
|
eventTypes: getAllEventTypes(),
|