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.
- package/README.md +17 -0
- package/package.json +3 -2
- package/src/builtins/builtin-definitions.js +5 -0
- package/src/builtins/builtin-metadata.js +12 -0
- package/src/engine/calculation-bindings.js +76 -0
- package/src/engine/calculation.js +15 -2
- package/src/engine/evaluator.js +10 -12
- package/src/engine/event-operation-scope.js +7 -3
- package/src/engine/event-policy.js +61 -0
- package/src/engine/event-registration.js +38 -0
- package/src/engine/event-source.js +15 -12
- package/src/engine/events.js +36 -12
- package/src/engine/expression-compilation.js +39 -0
- package/src/engine/form-engine.js +56 -21
- package/src/engine/managed-events.js +26 -18
- package/src/engine/record-context.js +13 -6
- package/src/engine/record-evaluator.js +282 -56
- package/src/engine/submission-validation.js +5 -0
- package/src/index.js +4 -1
- package/src/security/validation.js +4 -4
- package/src/utilities/form-event-editor.js +4 -0
- package/src/utilities/record-data.js +50 -4
- package/src/utilities/record-transformer.js +110 -39
|
@@ -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 =
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|