form0-core 0.5.0 → 0.6.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/package.json +2 -2
- package/src/engine/condition-context.js +90 -0
- package/src/engine/conditions.js +59 -50
- package/src/engine/context-resolver.js +26 -10
- package/src/engine/form-engine.js +8 -6
- package/src/engine/managed-events.js +7 -1
- package/src/engine/warning-system.js +4 -0
- package/src/schema/operators.js +2 -24
- package/src/schema/schema-validator.js +20 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form0-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/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/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",
|
|
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/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",
|
|
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",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { flattenFields } from '../utilities/field-helpers.js';
|
|
2
|
+
import { ContextResolver } from './context-resolver.js';
|
|
3
|
+
|
|
4
|
+
export const CONDITION_PROPERTIES = [
|
|
5
|
+
'visible_conditions',
|
|
6
|
+
'required_conditions',
|
|
7
|
+
'read_only_conditions',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Visit every reference, independently of boolean short-circuiting. */
|
|
11
|
+
export function forEachConditionReference(conditions, visit) {
|
|
12
|
+
if (Array.isArray(conditions)) {
|
|
13
|
+
conditions.forEach((condition) => forEachConditionReference(condition, visit));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!conditions || typeof conditions !== 'object') return;
|
|
17
|
+
if (conditions.field_id) visit(conditions);
|
|
18
|
+
if (conditions.and) forEachConditionReference(conditions.and, visit);
|
|
19
|
+
if (conditions.or) forEachConditionReference(conditions.or, visit);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Engine-private index, built once and shared by all three condition maps. */
|
|
23
|
+
export function createConditionContext(schema, contextResolver = null, warningSystem = null) {
|
|
24
|
+
const fields = flattenFields(schema.elements);
|
|
25
|
+
const allFields = Object.create(null);
|
|
26
|
+
for (const field of fields) {
|
|
27
|
+
if (field.key) allFields[field.key] = field;
|
|
28
|
+
if (field.data_name) allFields[field.data_name] = field;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
fields,
|
|
32
|
+
allFields,
|
|
33
|
+
contextResolver: contextResolver || new ContextResolver(schema),
|
|
34
|
+
warningSystem,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getConditionReferenceIssues(owner, property, conditions, allFields, resolver) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const executionContext = {
|
|
41
|
+
type: 'condition',
|
|
42
|
+
fieldName: owner.data_name,
|
|
43
|
+
conditionProperty: property,
|
|
44
|
+
parentPath: [...(resolver.elementOwnership.get(owner.data_name)?.parentPath || [])],
|
|
45
|
+
};
|
|
46
|
+
forEachConditionReference(conditions, (condition) => {
|
|
47
|
+
const reference = condition.field_id;
|
|
48
|
+
const field = allFields[reference];
|
|
49
|
+
if (!field) {
|
|
50
|
+
issues.push({
|
|
51
|
+
reason: 'not_found',
|
|
52
|
+
reference,
|
|
53
|
+
executionContext,
|
|
54
|
+
message: `Condition references unknown field "${reference}" in field "${owner.data_name}"`,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const access = resolver.resolveFieldAccessInfo(executionContext, field.data_name);
|
|
59
|
+
if (access.level === 'accessible') return;
|
|
60
|
+
const ownerPath = resolver.formatRepeatableSectionPath(executionContext.parentPath);
|
|
61
|
+
const referencePath = resolver.formatRepeatableSectionPath(access.fieldInfo?.parentPath || []);
|
|
62
|
+
issues.push({
|
|
63
|
+
reason: access.level === 'not_found' ? 'not_found' : 'restricted',
|
|
64
|
+
reference: field.data_name,
|
|
65
|
+
executionContext,
|
|
66
|
+
message: `Field "${owner.data_name}" ${property} references "${reference}" outside its record scope (owner: ${ownerPath}; reference: ${referencePath})`,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return issues;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function assertConditionReferences(conditions, owner, property, context) {
|
|
73
|
+
const issues = getConditionReferenceIssues(
|
|
74
|
+
owner,
|
|
75
|
+
property,
|
|
76
|
+
conditions,
|
|
77
|
+
context.allFields,
|
|
78
|
+
context.contextResolver
|
|
79
|
+
);
|
|
80
|
+
if (!issues.length) return;
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
const warning = context.contextResolver.generateAccessWarning(
|
|
83
|
+
issue.executionContext,
|
|
84
|
+
issue.reference,
|
|
85
|
+
issue.reason
|
|
86
|
+
);
|
|
87
|
+
context.warningSystem?.emitWarning({ ...warning, message: issue.message });
|
|
88
|
+
}
|
|
89
|
+
throw new Error(issues.map((issue) => issue.message).join('\n'));
|
|
90
|
+
}
|
package/src/engine/conditions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertConditionReferences, createConditionContext } from './condition-context.js';
|
|
2
2
|
|
|
3
3
|
const valuesEqual = (a, b) => {
|
|
4
4
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
@@ -81,37 +81,50 @@ function normalizeConditionValue(field, value) {
|
|
|
81
81
|
return value;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
export function evaluateConditions(conditions, values, allFields) {
|
|
84
|
+
export function evaluateConditions(conditions, values, allFields, scope = null) {
|
|
85
|
+
if (scope) {
|
|
86
|
+
assertConditionReferences(conditions, scope.owner, scope.property, scope.context);
|
|
87
|
+
}
|
|
88
|
+
return evaluateConditionExpression(conditions, values, allFields);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function evaluateConditionExpression(conditions, values, allFields) {
|
|
85
92
|
if (Array.isArray(conditions)) {
|
|
86
|
-
return conditions.every((c) =>
|
|
93
|
+
return conditions.every((c) => evaluateConditionExpression(c, values, allFields));
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
if (conditions.and) {
|
|
90
|
-
return conditions.and.every((c) =>
|
|
97
|
+
return conditions.and.every((c) => evaluateConditionExpression(c, values, allFields));
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
if (conditions.or) {
|
|
94
|
-
return conditions.or.some((c) =>
|
|
101
|
+
return conditions.or.some((c) => evaluateConditionExpression(c, values, allFields));
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
// Map field_id (key) to data_name for value lookup
|
|
98
105
|
const field = allFields ? allFields[conditions.field_id] : undefined;
|
|
99
106
|
const dataName = field ? field.data_name : undefined;
|
|
100
107
|
const val = normalizeConditionValue(field, dataName ? values[dataName] : undefined);
|
|
101
|
-
// Debug log: show what is being checked
|
|
102
|
-
//console.log('[evaluateConditions] field_id:', conditions.field_id, 'data_name:', dataName, 'value in values:', val, 'expected:', conditions.value, 'operator:', conditions.operator);
|
|
103
108
|
const fn = OPERATORS[conditions.operator];
|
|
104
109
|
return fn ? fn(val, conditions.value) : false;
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
function evaluateElementCondition(owner, property, values, context) {
|
|
113
|
+
return evaluateConditions(owner[property], values, context.allFields, {
|
|
114
|
+
owner,
|
|
115
|
+
property,
|
|
116
|
+
context,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
/**
|
|
108
121
|
* Recursively evaluates visibility for both fields and sections in bottom-up order
|
|
109
122
|
* @param {Array} elements - Form elements to evaluate
|
|
110
123
|
* @param {Object} values - Current form values
|
|
111
124
|
* @param {Object} visible - Visibility state object to populate
|
|
112
|
-
* @param {Object}
|
|
125
|
+
* @param {Object} context - Shared field index and ownership resolver
|
|
113
126
|
*/
|
|
114
|
-
function evaluateVisibilityRecursive(elements, values, visible,
|
|
127
|
+
function evaluateVisibilityRecursive(elements, values, visible, context) {
|
|
115
128
|
elements.forEach((element) => {
|
|
116
129
|
if (
|
|
117
130
|
element.type === 'Section' ||
|
|
@@ -120,7 +133,7 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
|
|
|
120
133
|
) {
|
|
121
134
|
// First evaluate children (bottom-up approach)
|
|
122
135
|
if (element.elements && element.elements.length > 0) {
|
|
123
|
-
evaluateVisibilityRecursive(element.elements, values, visible,
|
|
136
|
+
evaluateVisibilityRecursive(element.elements, values, visible, context);
|
|
124
137
|
|
|
125
138
|
// Check if any child is visible
|
|
126
139
|
const hasVisibleChild = element.elements.some(
|
|
@@ -133,7 +146,12 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
|
|
|
133
146
|
} else {
|
|
134
147
|
// At least one child visible → respect explicit section visibility settings
|
|
135
148
|
if (element.visible_conditions) {
|
|
136
|
-
const isVisible =
|
|
149
|
+
const isVisible = evaluateElementCondition(
|
|
150
|
+
element,
|
|
151
|
+
'visible_conditions',
|
|
152
|
+
values,
|
|
153
|
+
context
|
|
154
|
+
);
|
|
137
155
|
visible[element.data_name] = isVisible;
|
|
138
156
|
} else {
|
|
139
157
|
visible[element.data_name] = element.visible === true;
|
|
@@ -142,7 +160,12 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
|
|
|
142
160
|
} else {
|
|
143
161
|
// Empty section fallback (though schema validation should prevent this)
|
|
144
162
|
if (element.visible_conditions) {
|
|
145
|
-
const isVisible =
|
|
163
|
+
const isVisible = evaluateElementCondition(
|
|
164
|
+
element,
|
|
165
|
+
'visible_conditions',
|
|
166
|
+
values,
|
|
167
|
+
context
|
|
168
|
+
);
|
|
146
169
|
visible[element.data_name] = isVisible;
|
|
147
170
|
} else {
|
|
148
171
|
visible[element.data_name] = element.visible === true;
|
|
@@ -151,7 +174,7 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
|
|
|
151
174
|
} else {
|
|
152
175
|
// Regular field - existing logic
|
|
153
176
|
if (element.visible_conditions) {
|
|
154
|
-
const isVisible =
|
|
177
|
+
const isVisible = evaluateElementCondition(element, 'visible_conditions', values, context);
|
|
155
178
|
visible[element.data_name] = isVisible;
|
|
156
179
|
} else {
|
|
157
180
|
visible[element.data_name] = element.visible === true;
|
|
@@ -160,34 +183,24 @@ function evaluateVisibilityRecursive(elements, values, visible, allFields) {
|
|
|
160
183
|
});
|
|
161
184
|
}
|
|
162
185
|
|
|
163
|
-
export function evaluateVisibility(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (field.key) allFields[field.key] = field;
|
|
171
|
-
if (field.data_name) allFields[field.data_name] = field;
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// Use recursive approach for single-pass visibility evaluation
|
|
175
|
-
evaluateVisibilityRecursive(schema.elements, values, visible, allFields);
|
|
186
|
+
export function evaluateVisibility(
|
|
187
|
+
schema,
|
|
188
|
+
values,
|
|
189
|
+
visible,
|
|
190
|
+
context = createConditionContext(schema)
|
|
191
|
+
) {
|
|
192
|
+
evaluateVisibilityRecursive(schema.elements, values, visible, context);
|
|
176
193
|
}
|
|
177
194
|
|
|
178
|
-
export function evaluateRequirement(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
fields.forEach((field) => {
|
|
185
|
-
if (field.key) allFields[field.key] = field;
|
|
186
|
-
if (field.data_name) allFields[field.data_name] = field;
|
|
187
|
-
});
|
|
188
|
-
fields.forEach((field) => {
|
|
195
|
+
export function evaluateRequirement(
|
|
196
|
+
schema,
|
|
197
|
+
values,
|
|
198
|
+
required,
|
|
199
|
+
context = createConditionContext(schema)
|
|
200
|
+
) {
|
|
201
|
+
context.fields.forEach((field) => {
|
|
189
202
|
if (field.required_conditions) {
|
|
190
|
-
const isRequired =
|
|
203
|
+
const isRequired = evaluateElementCondition(field, 'required_conditions', values, context);
|
|
191
204
|
required[field.data_name] = isRequired;
|
|
192
205
|
} else {
|
|
193
206
|
required[field.data_name] = field.required === true;
|
|
@@ -195,19 +208,15 @@ export function evaluateRequirement(schema, values, required) {
|
|
|
195
208
|
});
|
|
196
209
|
}
|
|
197
210
|
|
|
198
|
-
export function evaluateReadOnly(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
fields.forEach((field) => {
|
|
205
|
-
if (field.key) allFields[field.key] = field;
|
|
206
|
-
if (field.data_name) allFields[field.data_name] = field;
|
|
207
|
-
});
|
|
208
|
-
fields.forEach((field) => {
|
|
211
|
+
export function evaluateReadOnly(
|
|
212
|
+
schema,
|
|
213
|
+
values,
|
|
214
|
+
read_only,
|
|
215
|
+
context = createConditionContext(schema)
|
|
216
|
+
) {
|
|
217
|
+
context.fields.forEach((field) => {
|
|
209
218
|
if (field.read_only_conditions) {
|
|
210
|
-
const isReadOnly =
|
|
219
|
+
const isReadOnly = evaluateElementCondition(field, 'read_only_conditions', values, context);
|
|
211
220
|
read_only[field.data_name] = isReadOnly;
|
|
212
221
|
} else {
|
|
213
222
|
read_only[field.data_name] = field.read_only === true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context-aware field resolution for calculations and events
|
|
2
|
+
* Context-aware field resolution for calculations, conditions and events
|
|
3
3
|
* Determines which fields are accessible based on execution context
|
|
4
4
|
* Reuses tree-building logic from record-transformer.js
|
|
5
5
|
*/
|
|
@@ -10,6 +10,7 @@ export class ContextResolver {
|
|
|
10
10
|
constructor(schema) {
|
|
11
11
|
// Maps to store field context information (similar to record-transformer.js)
|
|
12
12
|
this.fieldOwnership = new Map(); // Field data_name -> { preferredKey, field, parentPath }
|
|
13
|
+
this.elementOwnership = new Map(); // Includes containers in their containing record context.
|
|
13
14
|
this.repeatableSectionTree = new Map(); // RepeatableSection data_name -> tree info
|
|
14
15
|
this.sectionFields = new Set(); // Section field names (organizational only)
|
|
15
16
|
|
|
@@ -26,6 +27,12 @@ export class ContextResolver {
|
|
|
26
27
|
if (!Array.isArray(elements)) return;
|
|
27
28
|
|
|
28
29
|
elements.forEach((element) => {
|
|
30
|
+
const preferredKey =
|
|
31
|
+
typeof element.key === 'string' && element.key.trim() !== ''
|
|
32
|
+
? element.key
|
|
33
|
+
: element.data_name;
|
|
34
|
+
const ownership = { preferredKey, field: element, parentPath: [...parentPath] };
|
|
35
|
+
this.elementOwnership.set(element.data_name, ownership);
|
|
29
36
|
if (element.type === 'Section' || element.type === 'BuildingPlanSection') {
|
|
30
37
|
this.sectionFields.add(element.data_name);
|
|
31
38
|
// Recursively process Section children with same parentPath
|
|
@@ -34,8 +41,6 @@ export class ContextResolver {
|
|
|
34
41
|
this.buildFieldContext(element.elements, parentPath);
|
|
35
42
|
}
|
|
36
43
|
} else if (element.type === 'RepeatableSection') {
|
|
37
|
-
const preferredKey =
|
|
38
|
-
element.key && element.key.trim() !== '' ? element.key : element.data_name;
|
|
39
44
|
const currentPath = [...parentPath, preferredKey];
|
|
40
45
|
|
|
41
46
|
// Store this RepeatableSection in the tree
|
|
@@ -54,13 +59,7 @@ export class ContextResolver {
|
|
|
54
59
|
}
|
|
55
60
|
} else {
|
|
56
61
|
// This is a regular field - determine its ownership
|
|
57
|
-
|
|
58
|
-
element.key && element.key.trim() !== '' ? element.key : element.data_name;
|
|
59
|
-
this.fieldOwnership.set(element.data_name, {
|
|
60
|
-
preferredKey: preferredFieldKey,
|
|
61
|
-
field: element,
|
|
62
|
-
parentPath: [...parentPath], // Copy the current path
|
|
63
|
-
});
|
|
62
|
+
this.fieldOwnership.set(element.data_name, ownership);
|
|
64
63
|
}
|
|
65
64
|
});
|
|
66
65
|
}
|
|
@@ -146,6 +145,19 @@ export class ContextResolver {
|
|
|
146
145
|
checkRepeatableSectionAccessInfo(executionContext, fieldName, fieldInfo) {
|
|
147
146
|
const { type: contextType, eventType, fieldName: contextFieldName } = executionContext;
|
|
148
147
|
|
|
148
|
+
if (contextType === 'condition') {
|
|
149
|
+
const owner = this.elementOwnership.get(contextFieldName);
|
|
150
|
+
if (owner) {
|
|
151
|
+
return this.resolveRepeatableSectionRelationship(
|
|
152
|
+
owner.parentPath,
|
|
153
|
+
fieldInfo.parentPath,
|
|
154
|
+
fieldName,
|
|
155
|
+
fieldInfo,
|
|
156
|
+
executionContext
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
149
161
|
if (contextType === 'calculation') {
|
|
150
162
|
// CalculatedField can only access fields in same RepeatableSection context
|
|
151
163
|
const calculationFieldInfo = this.fieldOwnership.get(contextFieldName);
|
|
@@ -318,6 +330,10 @@ export class ContextResolver {
|
|
|
318
330
|
buildRestrictedAccessSuggestion(executionContext, fieldName, fieldInfo, code) {
|
|
319
331
|
const { type: contextType, eventType } = executionContext;
|
|
320
332
|
|
|
333
|
+
if (contextType === 'condition') {
|
|
334
|
+
return `Conditions can only reference fields in their containing record or its ancestors. Field '${fieldName}' belongs to: ${this.formatRepeatableSectionPath(fieldInfo.parentPath)}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
321
337
|
if (contextType === 'calculation') {
|
|
322
338
|
if (code === 'different_repeatable_section') {
|
|
323
339
|
return `CalculatedField can only access fields in the same RepeatableSection or ancestor contexts. Field '${fieldName}' is in RepeatableSection: ${this.formatRepeatableSectionPath(fieldInfo.parentPath)}`;
|
|
@@ -4,7 +4,8 @@ import { evaluateRequirement } from './conditions.js';
|
|
|
4
4
|
import { evaluateReadOnly } from './conditions.js';
|
|
5
5
|
import { validateFields } from './field-validation.js';
|
|
6
6
|
import { calculationBuiltins, eventBuiltins } from '../builtins/registry.js';
|
|
7
|
-
import {
|
|
7
|
+
import { validateSchemaWithContext } from '../schema/schema-validator.js';
|
|
8
|
+
import { createConditionContext } from './condition-context.js';
|
|
8
9
|
import { flattenFields } from '../utilities/field-helpers.js';
|
|
9
10
|
import { DEFAULT_SECURITY_CONFIG } from '../security/config.js';
|
|
10
11
|
import { EventManager } from './events.js';
|
|
@@ -77,7 +78,8 @@ export function createFormEngine({
|
|
|
77
78
|
}) {
|
|
78
79
|
const { schema: preparedSchema, buildingPlanMeta } = expandBuildingPlanSchema(schema);
|
|
79
80
|
|
|
80
|
-
|
|
81
|
+
const contextResolver = new ContextResolver(preparedSchema.form);
|
|
82
|
+
validateSchemaWithContext(preparedSchema.form, contextResolver);
|
|
81
83
|
|
|
82
84
|
const { form } = preparedSchema;
|
|
83
85
|
const values = { ...initialValues };
|
|
@@ -147,9 +149,9 @@ export function createFormEngine({
|
|
|
147
149
|
const allHelpers = { ...calculationBuiltins, ...helpers };
|
|
148
150
|
|
|
149
151
|
// Initialize context resolution system
|
|
150
|
-
const contextResolver = new ContextResolver(form);
|
|
151
152
|
const calculationPlan = buildCalculationDependencyPlan(form, contextResolver);
|
|
152
153
|
const sharedWarningSystem = warningSystem || new WarningSystem();
|
|
154
|
+
const conditionContext = createConditionContext(form, contextResolver, sharedWarningSystem);
|
|
153
155
|
const calculationDiagnostics = createCalculationDiagnostics(
|
|
154
156
|
diagnostics,
|
|
155
157
|
onDiagnostics,
|
|
@@ -196,9 +198,9 @@ export function createFormEngine({
|
|
|
196
198
|
calculationPlan,
|
|
197
199
|
calculationDiagnostics
|
|
198
200
|
);
|
|
199
|
-
evaluateRequirement(form, values, required);
|
|
200
|
-
evaluateVisibility(form, values, visible);
|
|
201
|
-
evaluateReadOnly(form, values, read_only);
|
|
201
|
+
evaluateRequirement(form, values, required, conditionContext);
|
|
202
|
+
evaluateVisibility(form, values, visible, conditionContext);
|
|
203
|
+
evaluateReadOnly(form, values, read_only, conditionContext);
|
|
202
204
|
validateFields(form, values, errors);
|
|
203
205
|
} catch (error) {
|
|
204
206
|
calculationDiagnostics.abort(frame);
|
|
@@ -9,8 +9,14 @@ function copy(value, seen = new Map()) {
|
|
|
9
9
|
if (value === null || typeof value !== 'object') return value;
|
|
10
10
|
if (seen.has(value)) return seen.get(value);
|
|
11
11
|
if (value instanceof Date) return new Date(value.getTime());
|
|
12
|
+
// Media controls retain native binary handles. Copy their immutable data and standard metadata
|
|
13
|
+
// without serializing custom properties or loading the bytes into JavaScript memory.
|
|
14
|
+
if (typeof File === 'function' && value instanceof File)
|
|
15
|
+
return new File([value], value.name, { type: value.type, lastModified: value.lastModified });
|
|
16
|
+
if (typeof Blob === 'function' && value instanceof Blob)
|
|
17
|
+
return Blob.prototype.slice.call(value, 0, value.size, value.type);
|
|
12
18
|
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
|
|
13
|
-
throw new Error('Event data must use plain objects, arrays or
|
|
19
|
+
throw new Error('Event data must use plain objects, arrays, dates or native media handles.');
|
|
14
20
|
const result = Array.isArray(value) ? [] : {};
|
|
15
21
|
seen.set(value, result);
|
|
16
22
|
for (const key of Object.keys(value)) {
|
|
@@ -179,6 +179,10 @@ export class WarningSystem {
|
|
|
179
179
|
return `CalculatedField '${fieldName}'`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
if (type === 'condition') {
|
|
183
|
+
return `${executionContext.conditionProperty} on '${fieldName}'`;
|
|
184
|
+
}
|
|
185
|
+
|
|
182
186
|
if (type === 'event') {
|
|
183
187
|
if (fieldName) {
|
|
184
188
|
return `Event '${eventType}' on field '${fieldName}'`;
|
package/src/schema/operators.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FIELD_TYPES } from '../utilities/field-types.js';
|
|
2
|
+
import { forEachConditionReference } from '../engine/condition-context.js';
|
|
2
3
|
|
|
3
4
|
// Define which operators are valid for each field type
|
|
4
5
|
const FIELD_TYPE_OPERATORS = {
|
|
@@ -188,26 +189,7 @@ export function validateFieldConditions(field, conditions, allFields = {}) {
|
|
|
188
189
|
return { isValid: false, errors };
|
|
189
190
|
}
|
|
190
191
|
|
|
191
|
-
// Skip validation for container fields
|
|
192
|
-
if (
|
|
193
|
-
field.type === 'Section' ||
|
|
194
|
-
field.type === 'LabelField' ||
|
|
195
|
-
field.type === 'RepeatableSection'
|
|
196
|
-
) {
|
|
197
|
-
return { isValid: true, errors: [] };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
192
|
const validateCondition = (condition) => {
|
|
201
|
-
if (condition.and) {
|
|
202
|
-
condition.and.forEach(validateCondition);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (condition.or) {
|
|
207
|
-
condition.or.forEach(validateCondition);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
193
|
// Use field_id (normalized to key by ensure-keys.js)
|
|
212
194
|
if (condition.operator && condition.field_id) {
|
|
213
195
|
// Find the target field being referenced
|
|
@@ -236,11 +218,7 @@ export function validateFieldConditions(field, conditions, allFields = {}) {
|
|
|
236
218
|
}
|
|
237
219
|
};
|
|
238
220
|
|
|
239
|
-
|
|
240
|
-
conditions.forEach(validateCondition);
|
|
241
|
-
} else {
|
|
242
|
-
validateCondition(conditions);
|
|
243
|
-
}
|
|
221
|
+
forEachConditionReference(conditions, validateCondition);
|
|
244
222
|
|
|
245
223
|
return { isValid: errors.length === 0, errors };
|
|
246
224
|
}
|
|
@@ -4,6 +4,8 @@ import { isSupportedFieldType } from '../utilities/field-types.js';
|
|
|
4
4
|
import { validateFieldConditions } from './operators.js';
|
|
5
5
|
import { validateFormAIMetadata } from './ai-metadata-validator.js';
|
|
6
6
|
import { buildDatasetDescriptors } from '../utilities/dataset-descriptors.js';
|
|
7
|
+
import { ContextResolver } from '../engine/context-resolver.js';
|
|
8
|
+
import { CONDITION_PROPERTIES, getConditionReferenceIssues } from '../engine/condition-context.js';
|
|
7
9
|
|
|
8
10
|
const toNonEmptyString = (value) =>
|
|
9
11
|
typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
@@ -72,6 +74,11 @@ const validateScopedTitleFields = (form, errors) => {
|
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
export function validateSchema(form) {
|
|
77
|
+
return validateSchemaWithContext(form, new ContextResolver(form));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Internal entrypoint lets engine construction reuse its ownership index.
|
|
81
|
+
export function validateSchemaWithContext(form, contextResolver) {
|
|
75
82
|
const fields = flattenFields(form.elements);
|
|
76
83
|
const seenDataNames = new Set();
|
|
77
84
|
const seenKeys = new Set();
|
|
@@ -85,7 +92,7 @@ export function validateSchema(form) {
|
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
// Create a map of all fields by key and data_name for condition validation
|
|
88
|
-
const allFields =
|
|
95
|
+
const allFields = Object.create(null);
|
|
89
96
|
fields.forEach((field) => {
|
|
90
97
|
if (field.key) {
|
|
91
98
|
allFields[field.key] = field;
|
|
@@ -145,25 +152,18 @@ export function validateSchema(form) {
|
|
|
145
152
|
errors.push(...fieldValidation.errors);
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
errors.push(
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (field.read_only_conditions) {
|
|
164
|
-
const validation = validateFieldConditions(field, field.read_only_conditions, allFields);
|
|
165
|
-
if (!validation.isValid) {
|
|
166
|
-
errors.push(...validation.errors);
|
|
155
|
+
for (const property of CONDITION_PROPERTIES) {
|
|
156
|
+
if (!field[property]) continue;
|
|
157
|
+
const conditionErrors = validateFieldConditions(field, field[property], allFields).errors;
|
|
158
|
+
errors.push(...conditionErrors);
|
|
159
|
+
for (const issue of getConditionReferenceIssues(
|
|
160
|
+
field,
|
|
161
|
+
property,
|
|
162
|
+
field[property],
|
|
163
|
+
allFields,
|
|
164
|
+
contextResolver
|
|
165
|
+
)) {
|
|
166
|
+
if (!conditionErrors.includes(issue.message)) errors.push(issue.message);
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|