form0-core 0.7.0 → 0.8.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.
@@ -17,6 +17,9 @@ import { buildCalculationDependencyPlan } from '../utilities/calculation-depende
17
17
  import { createCalculationDiagnostics } from './calculation-diagnostics.js';
18
18
  import { formatLocalDate } from '../utilities/date-utils.js';
19
19
  import { createManagedEventDispatcher } from './managed-events.js';
20
+ import { validateInteractionMode } from './event-policy.js';
21
+ import { copyRecordData } from '../utilities/record-data.js';
22
+ import { areStructuredValuesEqual } from '../utilities/value-equality.js';
20
23
 
21
24
  /**
22
25
  * @typedef {Object} CalculationDiagnostic
@@ -53,6 +56,7 @@ import { createManagedEventDispatcher } from './managed-events.js';
53
56
  * @param {Object} [options.initialValues]
54
57
  * @param {Object} [options.helpers]
55
58
  * @param {Object} [options.security]
59
+ * @param {'edit'|'readonly'} [options.mode='edit'] View preserves supplied calculations/defaults.
56
60
  * @param {WarningSystem} [options.warningSystem]
57
61
  * @param {Array} [options.runtimeDiagnostics] Deprecated append-only compatibility adapter.
58
62
  * Use getDiagnostics() for the latest snapshot or onDiagnostics for evaluation notifications.
@@ -75,15 +79,18 @@ export function createFormEngine({
75
79
  diagnostics = {},
76
80
  onDiagnostics = null,
77
81
  eventScope = { instancePath: [] },
82
+ mode = 'edit',
78
83
  }) {
84
+ let interactionMode = validateInteractionMode(mode);
79
85
  const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
80
86
 
81
87
  const contextResolver = new ContextResolver(preparedSchema.form);
82
88
  validateSchemaWithContext(preparedSchema.form, contextResolver);
83
89
 
84
90
  const { form } = preparedSchema;
85
- const values = { ...initialValues };
91
+ const values = copyRecordData(initialValues);
86
92
  const allFields = flattenFields(form.elements);
93
+ const pendingDefaults = new Map();
87
94
 
88
95
  // Create a lookup map for choice fields
89
96
  const choiceFieldMap = new Map();
@@ -104,7 +111,11 @@ export function createFormEngine({
104
111
  for (const field of allFields) {
105
112
  if (!(field.data_name in values)) {
106
113
  // Initialize field with default value if specified
107
- values[field.data_name] = getDefaultValue(field);
114
+ values[field.data_name] = getDefaultValue(
115
+ interactionMode === 'readonly' ? { ...field, default_value: null } : field
116
+ );
117
+ if (interactionMode === 'readonly')
118
+ pendingDefaults.set(field, copyRecordData(values[field.data_name]));
108
119
  } else if (field.type === 'SingleChoiceField' || field.type === 'BooleanField') {
109
120
  // Enrich SingleChoiceField and BooleanField values with labels from schema
110
121
  const fieldValue = values[field.data_name];
@@ -163,11 +174,16 @@ export function createFormEngine({
163
174
  const eventManager = new EventManager(form, contextResolver, sharedWarningSystem);
164
175
  eventManager.securityConfig = security; // Pass security config
165
176
  const eventHelpers = { ...eventBuiltins, ...helpers };
166
- const dispatchManagedEvent = createManagedEventDispatcher(eventManager, eventScope);
177
+ const getMode = () => interactionMode;
178
+ const dispatchManagedEvent = createManagedEventDispatcher(eventManager, eventScope, getMode);
167
179
 
168
180
  // Initialize event code if present
169
181
  if (form.events && form.events.code) {
170
- const eventContext = buildEventContext(values, eventHelpers, {});
182
+ const eventContext = buildEventContext(
183
+ interactionMode === 'readonly' ? copyRecordData(values) : values,
184
+ eventHelpers,
185
+ {}
186
+ );
171
187
  eventManager.initializeEventCode(form.events.code, eventContext);
172
188
  }
173
189
 
@@ -187,17 +203,18 @@ export function createFormEngine({
187
203
  function evalForm() {
188
204
  const frame = calculationDiagnostics.begin();
189
205
  try {
190
- evaluateCalculatedFields(
191
- form,
192
- values,
193
- allHelpers,
194
- security,
195
- contextResolver,
196
- sharedWarningSystem,
197
- runtimeDiagnostics,
198
- calculationPlan,
199
- calculationDiagnostics
200
- );
206
+ if (interactionMode === 'edit')
207
+ evaluateCalculatedFields(
208
+ form,
209
+ values,
210
+ allHelpers,
211
+ security,
212
+ contextResolver,
213
+ sharedWarningSystem,
214
+ runtimeDiagnostics,
215
+ calculationPlan,
216
+ calculationDiagnostics
217
+ );
201
218
  evaluateRequirement(form, values, required, conditionContext);
202
219
  evaluateVisibility(form, values, visible, conditionContext);
203
220
  evaluateReadOnly(form, values, read_only, conditionContext);
@@ -211,19 +228,35 @@ export function createFormEngine({
211
228
 
212
229
  function trigger(eventType, fieldKey, metadata = {}) {
213
230
  // Update event context with current values for event execution
214
- const eventContext = buildEventContext(values, eventHelpers, {
215
- eventType,
216
- fieldKey,
217
- ...metadata,
218
- });
231
+ const eventContext = buildEventContext(
232
+ interactionMode === 'readonly' ? copyRecordData(values) : values,
233
+ eventHelpers,
234
+ {
235
+ eventType,
236
+ fieldKey,
237
+ ...metadata,
238
+ }
239
+ );
219
240
  eventManager.eventContext = eventContext; // Update the event context
220
- return eventManager.trigger(eventType, fieldKey, metadata);
241
+ return eventManager.trigger(eventType, fieldKey, metadata, interactionMode);
221
242
  }
222
243
 
223
244
  function getState() {
224
245
  return { values, errors, visible, required, read_only };
225
246
  }
226
247
 
248
+ /** Host-owned mode changes do not replay events, restore a record or evaluate implicitly. */
249
+ function setMode(nextMode) {
250
+ interactionMode = validateInteractionMode(nextMode);
251
+ if (interactionMode === 'edit') {
252
+ for (const [field, placeholder] of pendingDefaults) {
253
+ if (areStructuredValuesEqual(values[field.data_name], placeholder))
254
+ values[field.data_name] = copyRecordData(getDefaultValue(field));
255
+ }
256
+ pendingDefaults.clear();
257
+ }
258
+ }
259
+
227
260
  /**
228
261
  * Dispatch synchronously within this engine's construction scope. No operations are applied.
229
262
  * @param {string} eventType Registered event name.
@@ -238,6 +271,8 @@ export function createFormEngine({
238
271
 
239
272
  return {
240
273
  eval: evalForm,
274
+ getMode,
275
+ setMode,
241
276
  trigger,
242
277
  triggerDetailed,
243
278
  getState,
@@ -1,6 +1,7 @@
1
1
  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
+ import { eventOperationIssue, reportEventOperationIssue } from './event-policy.js';
4
5
 
5
6
  // Portable defensive copies also work in runtimes without structuredClone (including Hermes).
6
7
  function copy(value, seen = new Map()) {
@@ -35,7 +36,7 @@ const managedFieldCaches = new WeakMap();
35
36
  const managedRepeatableCaches = new WeakMap();
36
37
 
37
38
  /** Internal adapter; the public synchronous API is engine.triggerDetailed(). */
38
- export function createManagedEventDispatcher(manager, constructionScope) {
39
+ export function createManagedEventDispatcher(manager, constructionScope, getMode = () => 'edit') {
39
40
  const instancePath = copy(constructionScope?.instancePath);
40
41
  const scopeId = constructionScope?.scopeId;
41
42
  if (scopeId !== undefined && (typeof scopeId !== 'string' || !scopeId.trim()))
@@ -86,6 +87,7 @@ export function createManagedEventDispatcher(manager, constructionScope) {
86
87
  if (!managedReferenceCaches.has(manager)) managedReferenceCaches.set(manager, new WeakMap());
87
88
  const referenceCache = managedReferenceCaches.get(manager);
88
89
  return function dispatch(eventType, fieldKey, metadata = {}) {
90
+ const mode = getMode();
89
91
  const result = { operations: [], validationIssues: [], issues: [] };
90
92
  const issue = (code, message) =>
91
93
  result.issues.push({
@@ -136,13 +138,13 @@ export function createManagedEventDispatcher(manager, constructionScope) {
136
138
  timestamp: Date.now(),
137
139
  instancePath: copy(instancePath),
138
140
  scopeId,
141
+ mode,
139
142
  };
140
143
  } catch {
141
144
  issue('invalid_request', 'Event metadata must contain portable data.');
142
145
  return result;
143
146
  }
144
- const validation = eventType === 'validate-record' || eventType === 'validate-repeatable';
145
- const closing = /^(cancel|unload)-/.test(eventType);
147
+ const registrations = [];
146
148
  const eventMap = manager.listeners.get(eventType);
147
149
  const listeners = [...(eventMap?.get(fieldKey) || []), ...(eventMap?.get('*') || [])];
148
150
  for (const callback of listeners) {
@@ -212,26 +214,31 @@ export function createManagedEventDispatcher(manager, constructionScope) {
212
214
  collected = closeEventOperationScope(scope);
213
215
  }
214
216
  try {
215
- if (returned?.type === 'UI_OPERATION' && !collected.includes(returned))
217
+ if (
218
+ typeof returned?.type === 'string' &&
219
+ returned.type.endsWith('_OPERATION') &&
220
+ !collected.includes(returned)
221
+ )
216
222
  collected.push(copy(returned));
217
223
  } catch {
218
224
  issue('invalid_operation', 'The returned event operation must contain portable data.');
219
225
  }
220
226
  for (const operation of collected) {
221
- if (operation?.type === 'EVENT_OPERATION') continue;
222
- if (operation?.operation === 'INVALID') {
223
- if (!validation)
224
- issue('invalid_context', 'INVALID() is only available during managed validation.');
225
- else
226
- result.validationIssues.push({
227
- message: operation.params.message,
228
- instancePath: copy(instancePath),
229
- });
230
- } else if (validation || closing) {
231
- issue(
232
- 'operation_not_allowed',
233
- 'Validation and close handlers cannot perform value or UI operations.'
234
- );
227
+ const rejected = eventOperationIssue(operation, mode, eventType);
228
+ if (rejected) {
229
+ issue(rejected.code, rejected.message);
230
+ if (
231
+ rejected.code === 'readonly_operation_not_allowed' ||
232
+ rejected.code === 'invalid_operation'
233
+ )
234
+ reportEventOperationIssue(manager.warningSystem, rejected, eventType);
235
+ } else if (operation.type === 'EVENT_OPERATION') {
236
+ registrations.push(operation);
237
+ } else if (operation.operation === 'INVALID') {
238
+ result.validationIssues.push({
239
+ message: operation.params.message,
240
+ instancePath: copy(instancePath),
241
+ });
235
242
  } else if (operation?.type === 'FIELD_OPERATION' && operation.operation === 'SETVALUE') {
236
243
  const field = fields.get(operation.params?.fieldDataName);
237
244
  if (!field || !same(field.parentPath))
@@ -257,6 +264,7 @@ export function createManagedEventDispatcher(manager, constructionScope) {
257
264
  }
258
265
  // A failed managed dispatch is not a partially applicable operation batch.
259
266
  if (result.issues.length) result.operations = [];
267
+ else manager.processEventOperations(registrations);
260
268
  return copy(result);
261
269
  };
262
270
  }
@@ -77,10 +77,15 @@ export function prepareRecordContext(inputSchema, warningSystem = null) {
77
77
  }
78
78
 
79
79
  export function visitRecordScopes(snapshot, context, visit) {
80
- function walk(record, path, instancePath, ancestors, participating) {
80
+ for (const scope of iterateRecordScopes(snapshot, context)) visit(scope);
81
+ }
82
+
83
+ /** Same traversal for synchronous validation and budgeted private-tree preparation. */
84
+ export function* iterateRecordScopes(snapshot, context) {
85
+ function* walk(record, path, instancePath, ancestors, participating) {
81
86
  const template = context.templates.get(JSON.stringify(path));
82
87
  const scope = { record, template, instancePath, ancestors, participating };
83
- visit(scope);
88
+ yield scope;
84
89
  for (const field of template.fields) {
85
90
  if (field.type !== 'RepeatableSection') continue;
86
91
  const info = context.resolver.repeatableSectionTree.get(field.data_name);
@@ -88,7 +93,9 @@ export function visitRecordScopes(snapshot, context, visit) {
88
93
  if (!Array.isArray(rows))
89
94
  throw new Error(`RepeatableSection '${field.data_name}' must contain an array of rows.`);
90
95
  const identities = new Set();
91
- rows.forEach((row, index) => {
96
+ for (let index = 0; index < rows.length; index++) {
97
+ if (!(index in rows)) continue;
98
+ const row = rows[index];
92
99
  if (!row || typeof row !== 'object' || Array.isArray(row))
93
100
  throw new Error('Repeatable rows must be record objects.');
94
101
  const id = row.id ?? row.record_id;
@@ -112,15 +119,15 @@ export function visitRecordScopes(snapshot, context, visit) {
112
119
  (template.chains.get(field.data_name) || []).every(
113
120
  (name) => record.visible?.[name] !== false
114
121
  );
115
- walk(
122
+ yield* walk(
116
123
  row,
117
124
  info.currentPath,
118
125
  [...instancePath, segment],
119
126
  [...ancestors, scope],
120
127
  participating && visible
121
128
  );
122
- });
129
+ }
123
130
  }
124
131
  }
125
- walk(snapshot, [], [], [], true);
132
+ yield* walk(snapshot, [], [], [], true);
126
133
  }