form0-core 0.8.1 → 0.9.0
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 +24 -0
- package/package.json +2 -2
- package/src/engine/events.js +6 -1
- package/src/engine/form-engine.js +44 -51
- package/src/engine/managed-events.js +4 -2
- package/src/index.js +1 -0
- package/src/utilities/field-value-normalization.js +106 -0
package/README.md
CHANGED
|
@@ -87,6 +87,30 @@ Schema event callbacks retain their authored source, so ordinary functions and a
|
|
|
87
87
|
runs once. Local initialization variables are not captured. Callbacks supplied by host helpers still
|
|
88
88
|
require executable `toString()` output; unavailable source produces an event warning.
|
|
89
89
|
|
|
90
|
+
## Updating values
|
|
91
|
+
|
|
92
|
+
Use `engine.setValue(dataName, value)` or atomic `engine.setValues(updates)`. Nonempty batches
|
|
93
|
+
evaluate once and return the current engine state; setters never dispatch events automatically.
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
engine.setValues({ name: 'Grace' });
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Choice input uses string IDs: SingleChoice/Boolean accept a string or `{ choice, other }`, while
|
|
100
|
+
MultiChoice accepts an array of strings or `{ choices, other }`. Labels and metadata in explicit
|
|
101
|
+
objects are preserved; missing labels come from the schema. Null/undefined clears a choice.
|
|
102
|
+
Unknown IDs remain invalid selections, not Other values. Schema defaults and submitted
|
|
103
|
+
`choice_value`/`choices_value` and `other_value` formats do not change.
|
|
104
|
+
|
|
105
|
+
Malformed calls, unknown/unowned names, calculated/display/container targets and writes in record
|
|
106
|
+
readonly mode throw `TypeError` before applying any part of a batch. Ordinary inputs marked
|
|
107
|
+
`read_only` can still be updated by programmatic code. Initialization accepts shorthand and retains
|
|
108
|
+
malformed loaded drafts for validation. Existing direct state mutation remains compatible.
|
|
109
|
+
|
|
110
|
+
Adapters can use pure `normalizeFieldValue(field, value)` before their own coordinated evaluation.
|
|
111
|
+
See [value updates and normalization](https://docs.form0.dev/core/engine/value-updates) for scope,
|
|
112
|
+
loading, event operations and the deliberate removal of input aliases and choice-ID coercion.
|
|
113
|
+
|
|
90
114
|
## Managed events and custom validation
|
|
91
115
|
|
|
92
116
|
Use synchronous `engine.triggerDetailed(eventType, fieldDataName, metadata)` to receive defensive
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"SECURITY.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/expression-compilation.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js && node tests/record-evaluator.test.js && node tests/complex-record-evaluator.test.js && node tests/record-scope.test.js && node tests/consumer-compatibility.test.js",
|
|
15
|
+
"test": "node tests/form0-core.test.js && node tests/conditions.test.js && node tests/condition-scope.test.js && node tests/numeric-field-schema.test.js && node tests/dataset-descriptors.test.js && node tests/structured-record.test.js && node tests/title-fields.test.js && node tests/builtin-metadata.test.js && node tests/builtin-import-isolation.test.js && node tests/builtin-catalog-examples.test.js && node tests/date-builtins.test.js && node tests/calculation-expression-utils.test.js && node tests/calculation-editor.test.js && node tests/form-event-editor.test.js && node tests/form-authoring.test.js && node tests/calculation-dependency-engine.test.js && node tests/calculation-diagnostics.characterization.test.js && node tests/calculation-diagnostics.test.js && node tests/calculation-missing-result.test.js && node tests/evaluator-reentry.test.js && node tests/expression-compilation.test.js && node tests/event-compatibility.characterization.test.js && node tests/event-hermes.test.js && node tests/event-async-isolation.test.js && node tests/managed-events.test.js && node tests/security-test.js && node tests/record-evaluator.test.js && node tests/complex-record-evaluator.test.js && node tests/record-scope.test.js && node tests/consumer-compatibility.test.js && node tests/field-value-registry.test.js && node tests/value-updates.test.js",
|
|
16
16
|
"format": "prettier --write \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
17
17
|
"format:check": "prettier --check \"src/**/*.{js,jsx}\" \"tests/**/*.{js,jsx}\" \"scripts/**/*.{js,mjs}\" package.json README.md EVENT_CONTRACT.md SECURITY.md SUPPORT.md CONTRIBUTING.md eslint.config.js .github/workflows/ci.yml .github/workflows/publish.yml",
|
|
18
18
|
"lint:check": "eslint src tests",
|
package/src/engine/events.js
CHANGED
|
@@ -547,6 +547,7 @@ export class EventManager {
|
|
|
547
547
|
|
|
548
548
|
// Validate operations against context restrictions before adding them
|
|
549
549
|
const validOps = this.validateOperations(runtimeOps, executionContext);
|
|
550
|
+
if (validOps.length !== runtimeOps.length) rejectedBatch = true;
|
|
550
551
|
operations.push(...validOps);
|
|
551
552
|
|
|
552
553
|
// Listener updates are committed after the entire permitted dispatch, never sent to hosts.
|
|
@@ -565,7 +566,11 @@ export class EventManager {
|
|
|
565
566
|
rejectedBatch = true;
|
|
566
567
|
reportEventOperationIssue(this.warningSystem, rejected, eventType);
|
|
567
568
|
} else if (result.type === 'EVENT_OPERATION') registrations.push(result);
|
|
568
|
-
else
|
|
569
|
+
else {
|
|
570
|
+
const validOps = this.validateOperations([result], executionContext);
|
|
571
|
+
if (!validOps.length) rejectedBatch = true;
|
|
572
|
+
operations.push(...validOps);
|
|
573
|
+
}
|
|
569
574
|
}
|
|
570
575
|
} catch (error) {
|
|
571
576
|
console.warn(`[form0] Event listener failed: ${error.message}`);
|
|
@@ -20,6 +20,10 @@ import { createManagedEventDispatcher } from './managed-events.js';
|
|
|
20
20
|
import { validateInteractionMode } from './event-policy.js';
|
|
21
21
|
import { copyRecordData } from '../utilities/record-data.js';
|
|
22
22
|
import { areStructuredValuesEqual } from '../utilities/value-equality.js';
|
|
23
|
+
import {
|
|
24
|
+
normalizeLoadedFieldValue,
|
|
25
|
+
prepareValueUpdates,
|
|
26
|
+
} from '../utilities/field-value-normalization.js';
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* @typedef {Object} CalculationDiagnostic
|
|
@@ -92,22 +96,6 @@ export function createFormEngine({
|
|
|
92
96
|
const allFields = flattenFields(form.elements);
|
|
93
97
|
const pendingDefaults = new Map();
|
|
94
98
|
|
|
95
|
-
// Create a lookup map for choice fields
|
|
96
|
-
const choiceFieldMap = new Map();
|
|
97
|
-
for (const field of allFields) {
|
|
98
|
-
if (
|
|
99
|
-
field.type === 'SingleChoiceField' ||
|
|
100
|
-
field.type === 'MultiChoiceField' ||
|
|
101
|
-
field.type === 'BooleanField'
|
|
102
|
-
) {
|
|
103
|
-
const valueToLabelMap = new Map();
|
|
104
|
-
field.choices.forEach((choice) => {
|
|
105
|
-
valueToLabelMap.set(choice.value, choice.label);
|
|
106
|
-
});
|
|
107
|
-
choiceFieldMap.set(field.data_name, valueToLabelMap);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
99
|
for (const field of allFields) {
|
|
112
100
|
if (!(field.data_name in values)) {
|
|
113
101
|
// Initialize field with default value if specified
|
|
@@ -116,42 +104,11 @@ export function createFormEngine({
|
|
|
116
104
|
);
|
|
117
105
|
if (interactionMode === 'readonly')
|
|
118
106
|
pendingDefaults.set(field, copyRecordData(values[field.data_name]));
|
|
119
|
-
} else
|
|
120
|
-
|
|
121
|
-
const fieldValue = values[field.data_name];
|
|
122
|
-
if (fieldValue && typeof fieldValue === 'object' && Array.isArray(fieldValue.choice)) {
|
|
123
|
-
const labelMap = choiceFieldMap.get(field.data_name);
|
|
124
|
-
fieldValue.choice = fieldValue.choice.map((choice) => {
|
|
125
|
-
if (choice && choice.value && !choice.label) {
|
|
126
|
-
// Auto-populate label from schema
|
|
127
|
-
const label = labelMap.get(choice.value);
|
|
128
|
-
return {
|
|
129
|
-
...choice,
|
|
130
|
-
label: label || choice.value, // fallback to value if label not found
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return choice;
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
} else if (field.type === 'MultiChoiceField') {
|
|
137
|
-
// Enrich MultiChoiceField values with labels from schema
|
|
138
|
-
const fieldValue = values[field.data_name];
|
|
139
|
-
if (fieldValue && typeof fieldValue === 'object' && Array.isArray(fieldValue.choices)) {
|
|
140
|
-
const labelMap = choiceFieldMap.get(field.data_name);
|
|
141
|
-
fieldValue.choices = fieldValue.choices.map((choice) => {
|
|
142
|
-
if (choice && choice.value && !choice.label) {
|
|
143
|
-
// Auto-populate label from schema
|
|
144
|
-
const label = labelMap.get(choice.value);
|
|
145
|
-
return {
|
|
146
|
-
...choice,
|
|
147
|
-
label: label || choice.value, // fallback to value if label not found
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
return choice;
|
|
151
|
-
});
|
|
152
|
-
}
|
|
107
|
+
} else {
|
|
108
|
+
values[field.data_name] = normalizeLoadedFieldValue(field, values[field.data_name]);
|
|
153
109
|
}
|
|
154
110
|
}
|
|
111
|
+
|
|
155
112
|
const errors = {};
|
|
156
113
|
const visible = {};
|
|
157
114
|
const required = {};
|
|
@@ -245,6 +202,24 @@ export function createFormEngine({
|
|
|
245
202
|
return { values, errors, visible, required, read_only };
|
|
246
203
|
}
|
|
247
204
|
|
|
205
|
+
/** Apply one input update and evaluate once; event dispatch stays host-owned. */
|
|
206
|
+
function setValue(dataName, value) {
|
|
207
|
+
if (typeof dataName !== 'string' || !dataName)
|
|
208
|
+
throw new TypeError('A value-update field must be a nonempty data_name string.');
|
|
209
|
+
return engine.setValues({ [dataName]: value });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Preflight/copy every update before committing the complete batch. */
|
|
213
|
+
function setValues(updates) {
|
|
214
|
+
const prepared = prepareValueUpdates(writableScopeFields, updates, interactionMode);
|
|
215
|
+
if (Object.keys(prepared).length) {
|
|
216
|
+
Object.defineProperties(values, Object.getOwnPropertyDescriptors(prepared));
|
|
217
|
+
// Bindings can decorate evaluation with their existing record-scope coordinator.
|
|
218
|
+
engine.eval();
|
|
219
|
+
}
|
|
220
|
+
return getState();
|
|
221
|
+
}
|
|
222
|
+
|
|
248
223
|
/** Host-owned mode changes do not replay events, restore a record or evaluate implicitly. */
|
|
249
224
|
function setMode(nextMode) {
|
|
250
225
|
interactionMode = validateInteractionMode(nextMode);
|
|
@@ -269,8 +244,25 @@ export function createFormEngine({
|
|
|
269
244
|
return dispatchManagedEvent(eventType, fieldKey, metadata);
|
|
270
245
|
}
|
|
271
246
|
|
|
272
|
-
|
|
247
|
+
const ownershipPath = eventScope.instancePath.map((part) => part.repeatableKey);
|
|
248
|
+
const writableScopeFields = new Map(
|
|
249
|
+
[...contextResolver.elementOwnership]
|
|
250
|
+
.filter(
|
|
251
|
+
([, entry]) =>
|
|
252
|
+
entry.parentPath.length === ownershipPath.length &&
|
|
253
|
+
entry.parentPath.every((key, index) => key === ownershipPath[index])
|
|
254
|
+
)
|
|
255
|
+
.map(([name, entry]) => [name, entry.field])
|
|
256
|
+
);
|
|
257
|
+
if (!ownershipPath.length) {
|
|
258
|
+
if (form.status_field?.enabled)
|
|
259
|
+
writableScopeFields.set(form.status_field.data_name, form.status_field);
|
|
260
|
+
if (form.title_field) writableScopeFields.set(form.title_field.data_name, form.title_field);
|
|
261
|
+
}
|
|
262
|
+
const engine = {
|
|
273
263
|
eval: evalForm,
|
|
264
|
+
setValue,
|
|
265
|
+
setValues,
|
|
274
266
|
getMode,
|
|
275
267
|
setMode,
|
|
276
268
|
trigger,
|
|
@@ -282,6 +274,7 @@ export function createFormEngine({
|
|
|
282
274
|
getBuildingPlanMeta: () => buildingPlanMeta,
|
|
283
275
|
getPreparedSchema: () => preparedSchema,
|
|
284
276
|
};
|
|
277
|
+
return engine;
|
|
285
278
|
}
|
|
286
279
|
|
|
287
280
|
export function getDefaultValue(field) {
|
|
@@ -2,6 +2,7 @@ import { getEventInfo, isValidEventType } from './event-registry.js';
|
|
|
2
2
|
import { closeEventOperationScope, createEventOperationScope } from './event-operation-scope.js';
|
|
3
3
|
import { getEventFieldReferences } from './event-field-references.js';
|
|
4
4
|
import { eventOperationIssue, reportEventOperationIssue } from './event-policy.js';
|
|
5
|
+
import { assertWritableField } from '../utilities/field-value-normalization.js';
|
|
5
6
|
|
|
6
7
|
// Portable defensive copies also work in runtimes without structuredClone (including Hermes).
|
|
7
8
|
function copy(value, seen = new Map()) {
|
|
@@ -248,9 +249,10 @@ export function createManagedEventDispatcher(manager, constructionScope, getMode
|
|
|
248
249
|
);
|
|
249
250
|
else {
|
|
250
251
|
try {
|
|
252
|
+
assertWritableField(field.field);
|
|
251
253
|
result.operations.push(copy(operation));
|
|
252
|
-
} catch {
|
|
253
|
-
issue('invalid_operation',
|
|
254
|
+
} catch (error) {
|
|
255
|
+
issue('invalid_operation', error.message);
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
258
|
} else {
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { copyRecordData } from './record-data.js';
|
|
2
|
+
|
|
3
|
+
const CHOICE_TYPES = new Set(['SingleChoiceField', 'BooleanField', 'MultiChoiceField']);
|
|
4
|
+
const PROTECTED_TYPES = new Set([
|
|
5
|
+
'CalculatedField',
|
|
6
|
+
'LabelField',
|
|
7
|
+
'TitleField',
|
|
8
|
+
'Section',
|
|
9
|
+
'RepeatableSection',
|
|
10
|
+
'BuildingPlanSection',
|
|
11
|
+
]);
|
|
12
|
+
const isObject = (value) =>
|
|
13
|
+
value !== null &&
|
|
14
|
+
typeof value === 'object' &&
|
|
15
|
+
!Array.isArray(value) &&
|
|
16
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
17
|
+
|
|
18
|
+
const invalid = (field, message) =>
|
|
19
|
+
new TypeError(`Invalid value update for "${field?.data_name || 'unknown field'}": ${message}`);
|
|
20
|
+
|
|
21
|
+
export function assertWritableField(field) {
|
|
22
|
+
if (!field || PROTECTED_TYPES.has(field.type))
|
|
23
|
+
throw invalid(field, 'the target must be an input field.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copy a writable field's live value, expanding string choice shorthand without changing schema
|
|
28
|
+
* or stored-record formats. Invalid selections remain data; malformed calls throw TypeError.
|
|
29
|
+
* @param {Object} field Schema field definition.
|
|
30
|
+
* @param {*} value Live value or type-appropriate choice shorthand.
|
|
31
|
+
* @returns {*} Defensive live value; immutable native media handles are retained.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeFieldValue(field, value) {
|
|
34
|
+
assertWritableField(field);
|
|
35
|
+
try {
|
|
36
|
+
if (!CHOICE_TYPES.has(field.type)) return copyRecordData(value);
|
|
37
|
+
const multiple = field.type === 'MultiChoiceField';
|
|
38
|
+
const key = multiple ? 'choices' : 'choice';
|
|
39
|
+
const opposite = multiple ? 'choice' : 'choices';
|
|
40
|
+
let live;
|
|
41
|
+
if (value == null) return { [key]: [], other: [] };
|
|
42
|
+
if (!multiple && typeof value === 'string') {
|
|
43
|
+
live = { choice: [{ value }], other: [] };
|
|
44
|
+
} else if (multiple && Array.isArray(value)) {
|
|
45
|
+
if (value.some((entry) => typeof entry !== 'string'))
|
|
46
|
+
throw invalid(field, 'multiple-choice shorthand must contain string IDs.');
|
|
47
|
+
live = { choices: value.map((entry) => ({ value: entry })), other: [] };
|
|
48
|
+
} else {
|
|
49
|
+
if (!isObject(value) || Object.hasOwn(value, opposite) || !Array.isArray(value[key]))
|
|
50
|
+
throw invalid(
|
|
51
|
+
field,
|
|
52
|
+
`use ${multiple ? 'an array of string IDs' : 'a string ID'} or a { ${key}, other } object.`
|
|
53
|
+
);
|
|
54
|
+
live = copyRecordData(value);
|
|
55
|
+
if (!Object.hasOwn(live, 'other')) live.other = [];
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(live.other)) throw invalid(field, 'other must be an array.');
|
|
58
|
+
for (const entry of live[key]) {
|
|
59
|
+
if (!isObject(entry) || typeof entry.value !== 'string')
|
|
60
|
+
throw invalid(field, `${key} entries must be objects with a string value.`);
|
|
61
|
+
if (entry.label == null) {
|
|
62
|
+
const defined = field.choices?.find((choice) => choice.value === entry.value);
|
|
63
|
+
entry.label = defined?.label ?? entry.value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const entry of live.other)
|
|
67
|
+
if (!isObject(entry) || !Object.hasOwn(entry, 'label'))
|
|
68
|
+
throw invalid(field, 'other entries must be objects with a label.');
|
|
69
|
+
return live;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof TypeError) throw error;
|
|
72
|
+
throw invalid(field, error.message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Loaded drafts remain inspectable even if their existing field structures are invalid. */
|
|
77
|
+
export function normalizeLoadedFieldValue(field, value) {
|
|
78
|
+
if (CHOICE_TYPES.has(field.type)) {
|
|
79
|
+
try {
|
|
80
|
+
return normalizeFieldValue(field, value);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (!(error instanceof TypeError)) throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return copyRecordData(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Preflight the entire batch before any caller changes live or optimistic state. */
|
|
89
|
+
export function prepareValueUpdates(fields, updates, mode = 'edit') {
|
|
90
|
+
if (!isObject(updates)) throw new TypeError('Value updates must be a plain object.');
|
|
91
|
+
const entries = Object.entries(updates);
|
|
92
|
+
if (entries.length && mode === 'readonly')
|
|
93
|
+
throw new TypeError('Cannot set values while the record is readonly.');
|
|
94
|
+
const prepared = {};
|
|
95
|
+
for (const [name, value] of entries) {
|
|
96
|
+
const field = fields.get(name);
|
|
97
|
+
if (!field) throw new TypeError(`Unknown or unowned value-update field "${name}".`);
|
|
98
|
+
Object.defineProperty(prepared, name, {
|
|
99
|
+
value: normalizeFieldValue(field, value),
|
|
100
|
+
enumerable: true,
|
|
101
|
+
configurable: true,
|
|
102
|
+
writable: true,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return prepared;
|
|
106
|
+
}
|