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 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.8.1",
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",
@@ -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 operations.push(...this.validateOperations([result], executionContext));
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 if (field.type === 'SingleChoiceField' || field.type === 'BooleanField') {
120
- // Enrich SingleChoiceField and BooleanField values with labels from schema
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
- return {
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', 'Event operations must contain portable data.');
254
+ } catch (error) {
255
+ issue('invalid_operation', error.message);
254
256
  }
255
257
  }
256
258
  } else {
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createFormEngine } from './engine/form-engine.js';
2
+ export { normalizeFieldValue } from './utilities/field-value-normalization.js';
2
3
  export { WarningSystem } from './engine/warning-system.js';
3
4
  export {
4
5
  createStructuredRecord,
@@ -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
+ }