@fogpipe/forma-core 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/README.md +169 -0
- package/dist/chunk-IRLYWN3R.js +790 -0
- package/dist/chunk-IRLYWN3R.js.map +1 -0
- package/dist/chunk-QTLXVG6P.js +135 -0
- package/dist/chunk-QTLXVG6P.js.map +1 -0
- package/dist/engine/index.cjs +885 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +258 -0
- package/dist/engine/index.d.ts +258 -0
- package/dist/engine/index.js +32 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/feel/index.cjs +165 -0
- package/dist/feel/index.cjs.map +1 -0
- package/dist/feel/index.d.cts +105 -0
- package/dist/feel/index.d.ts +105 -0
- package/dist/feel/index.js +19 -0
- package/dist/feel/index.js.map +1 -0
- package/dist/index.cjs +962 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/types-Bs3CG9JZ.d.cts +283 -0
- package/dist/types-Bs3CG9JZ.d.ts +283 -0
- package/package.json +82 -0
- package/src/engine/calculate.ts +363 -0
- package/src/engine/enabled.ts +147 -0
- package/src/engine/index.ts +54 -0
- package/src/engine/required.ts +151 -0
- package/src/engine/validate.ts +647 -0
- package/src/engine/visibility.ts +228 -0
- package/src/feel/index.ts +299 -0
- package/src/index.ts +15 -0
- package/src/types.ts +364 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculation Engine
|
|
3
|
+
*
|
|
4
|
+
* Evaluates computed fields based on form data.
|
|
5
|
+
* Computed values are derived from form data using FEEL expressions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { evaluate } from "../feel/index.js";
|
|
9
|
+
import type {
|
|
10
|
+
Forma,
|
|
11
|
+
ComputedField,
|
|
12
|
+
EvaluationContext,
|
|
13
|
+
CalculationResult,
|
|
14
|
+
CalculationError,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Main Function
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calculate all computed values from form data
|
|
23
|
+
*
|
|
24
|
+
* Evaluates each computed field's FEEL expression and returns the results.
|
|
25
|
+
* Errors are collected rather than thrown, allowing partial results.
|
|
26
|
+
*
|
|
27
|
+
* @param data - Current form data
|
|
28
|
+
* @param spec - Form specification with computed fields
|
|
29
|
+
* @returns Computed values and any calculation errors
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const spec = {
|
|
33
|
+
* computed: {
|
|
34
|
+
* bmi: {
|
|
35
|
+
* expression: "weight / (height / 100) ** 2",
|
|
36
|
+
* label: "BMI",
|
|
37
|
+
* format: "decimal(1)"
|
|
38
|
+
* },
|
|
39
|
+
* isObese: {
|
|
40
|
+
* expression: "$computed.bmi >= 30"
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* };
|
|
44
|
+
*
|
|
45
|
+
* const result = calculate({ weight: 85, height: 175 }, spec);
|
|
46
|
+
* // => { values: { bmi: 27.76, isObese: false }, errors: [] }
|
|
47
|
+
*/
|
|
48
|
+
export function calculate(
|
|
49
|
+
data: Record<string, unknown>,
|
|
50
|
+
spec: Forma
|
|
51
|
+
): Record<string, unknown> {
|
|
52
|
+
const result = calculateWithErrors(data, spec);
|
|
53
|
+
return result.values;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculate computed values with error reporting
|
|
58
|
+
*
|
|
59
|
+
* Same as calculate() but also returns any errors that occurred.
|
|
60
|
+
*
|
|
61
|
+
* @param data - Current form data
|
|
62
|
+
* @param spec - Form specification
|
|
63
|
+
* @returns Values and errors
|
|
64
|
+
*/
|
|
65
|
+
export function calculateWithErrors(
|
|
66
|
+
data: Record<string, unknown>,
|
|
67
|
+
spec: Forma
|
|
68
|
+
): CalculationResult {
|
|
69
|
+
if (!spec.computed) {
|
|
70
|
+
return { values: {}, errors: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const values: Record<string, unknown> = {};
|
|
74
|
+
const errors: CalculationError[] = [];
|
|
75
|
+
|
|
76
|
+
// Get computation order (handles dependencies)
|
|
77
|
+
const orderedFields = getComputationOrder(spec.computed);
|
|
78
|
+
|
|
79
|
+
// Evaluate each computed field in dependency order
|
|
80
|
+
for (const fieldName of orderedFields) {
|
|
81
|
+
const fieldDef = spec.computed[fieldName];
|
|
82
|
+
if (!fieldDef) continue;
|
|
83
|
+
|
|
84
|
+
const result = evaluateComputedField(
|
|
85
|
+
fieldName,
|
|
86
|
+
fieldDef,
|
|
87
|
+
data,
|
|
88
|
+
values, // Pass already-computed values for dependencies
|
|
89
|
+
spec.referenceData // Pass reference data for lookups
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (result.success) {
|
|
93
|
+
values[fieldName] = result.value;
|
|
94
|
+
} else {
|
|
95
|
+
errors.push({
|
|
96
|
+
field: fieldName,
|
|
97
|
+
message: result.error,
|
|
98
|
+
expression: fieldDef.expression,
|
|
99
|
+
});
|
|
100
|
+
// Set to null so dependent fields can still evaluate
|
|
101
|
+
values[fieldName] = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { values, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// Field Evaluation
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
interface ComputeSuccess {
|
|
113
|
+
success: true;
|
|
114
|
+
value: unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ComputeFailure {
|
|
118
|
+
success: false;
|
|
119
|
+
error: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type ComputeResult = ComputeSuccess | ComputeFailure;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Evaluate a single computed field
|
|
126
|
+
*/
|
|
127
|
+
function evaluateComputedField(
|
|
128
|
+
_name: string,
|
|
129
|
+
fieldDef: ComputedField,
|
|
130
|
+
data: Record<string, unknown>,
|
|
131
|
+
computedSoFar: Record<string, unknown>,
|
|
132
|
+
referenceData?: Record<string, unknown>
|
|
133
|
+
): ComputeResult {
|
|
134
|
+
// Check if any referenced computed field is null - propagate null to dependents
|
|
135
|
+
// This prevents issues like: bmi is null, but bmiCategory still evaluates to "obese"
|
|
136
|
+
// because `null < 18.5` is false in comparisons
|
|
137
|
+
const referencedComputed = findComputedReferences(fieldDef.expression);
|
|
138
|
+
for (const ref of referencedComputed) {
|
|
139
|
+
if (computedSoFar[ref] === null) {
|
|
140
|
+
return {
|
|
141
|
+
success: true,
|
|
142
|
+
value: null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const context: EvaluationContext = {
|
|
148
|
+
data,
|
|
149
|
+
computed: computedSoFar,
|
|
150
|
+
referenceData,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const result = evaluate(fieldDef.expression, context);
|
|
154
|
+
|
|
155
|
+
if (!result.success) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
error: result.error,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Treat NaN and Infinity as null - prevents unexpected behavior in conditional expressions
|
|
163
|
+
// (e.g., NaN < 18.5 is false, causing fallthrough in if-else chains)
|
|
164
|
+
if (typeof result.value === "number" && (!Number.isFinite(result.value))) {
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
value: null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
success: true,
|
|
173
|
+
value: result.value,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Find computed field references in an expression (e.g., computed.bmi)
|
|
179
|
+
*/
|
|
180
|
+
function findComputedReferences(expression: string): string[] {
|
|
181
|
+
const refs: string[] = [];
|
|
182
|
+
const regex = /computed\.(\w+)/g;
|
|
183
|
+
let match;
|
|
184
|
+
while ((match = regex.exec(expression)) !== null) {
|
|
185
|
+
refs.push(match[1]);
|
|
186
|
+
}
|
|
187
|
+
return refs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ============================================================================
|
|
191
|
+
// Dependency Resolution
|
|
192
|
+
// ============================================================================
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Determine the order to evaluate computed fields based on dependencies
|
|
196
|
+
*
|
|
197
|
+
* Computed fields can reference other computed fields via $computed.name.
|
|
198
|
+
* This function performs topological sort to ensure dependencies are
|
|
199
|
+
* evaluated first.
|
|
200
|
+
*/
|
|
201
|
+
function getComputationOrder(
|
|
202
|
+
computed: Record<string, ComputedField>
|
|
203
|
+
): string[] {
|
|
204
|
+
const fieldNames = Object.keys(computed);
|
|
205
|
+
|
|
206
|
+
// Build dependency graph
|
|
207
|
+
const deps = new Map<string, Set<string>>();
|
|
208
|
+
for (const name of fieldNames) {
|
|
209
|
+
deps.set(name, findComputedDependencies(computed[name].expression, fieldNames));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Topological sort
|
|
213
|
+
const sorted: string[] = [];
|
|
214
|
+
const visited = new Set<string>();
|
|
215
|
+
const visiting = new Set<string>();
|
|
216
|
+
|
|
217
|
+
function visit(name: string): void {
|
|
218
|
+
if (visited.has(name)) return;
|
|
219
|
+
if (visiting.has(name)) {
|
|
220
|
+
// Circular dependency - just add it and let evaluation fail gracefully
|
|
221
|
+
console.warn(`Circular dependency detected in computed field: ${name}`);
|
|
222
|
+
sorted.push(name);
|
|
223
|
+
visited.add(name);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
visiting.add(name);
|
|
228
|
+
|
|
229
|
+
const fieldDeps = deps.get(name) ?? new Set();
|
|
230
|
+
for (const dep of fieldDeps) {
|
|
231
|
+
visit(dep);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
visiting.delete(name);
|
|
235
|
+
visited.add(name);
|
|
236
|
+
sorted.push(name);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const name of fieldNames) {
|
|
240
|
+
visit(name);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return sorted;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Find which computed fields are referenced in an expression
|
|
248
|
+
*/
|
|
249
|
+
function findComputedDependencies(
|
|
250
|
+
expression: string,
|
|
251
|
+
availableFields: string[]
|
|
252
|
+
): Set<string> {
|
|
253
|
+
const deps = new Set<string>();
|
|
254
|
+
|
|
255
|
+
// Look for computed.fieldName patterns (without $ prefix)
|
|
256
|
+
const regex = /computed\.(\w+)/g;
|
|
257
|
+
let match;
|
|
258
|
+
while ((match = regex.exec(expression)) !== null) {
|
|
259
|
+
const fieldName = match[1];
|
|
260
|
+
if (availableFields.includes(fieldName)) {
|
|
261
|
+
deps.add(fieldName);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return deps;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// Formatted Output
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get a computed value formatted according to its format specification
|
|
274
|
+
*
|
|
275
|
+
* @param fieldName - Name of the computed field
|
|
276
|
+
* @param data - Current form data
|
|
277
|
+
* @param spec - Form specification
|
|
278
|
+
* @returns Formatted string or null if not displayable
|
|
279
|
+
*/
|
|
280
|
+
export function getFormattedValue(
|
|
281
|
+
fieldName: string,
|
|
282
|
+
data: Record<string, unknown>,
|
|
283
|
+
spec: Forma
|
|
284
|
+
): string | null {
|
|
285
|
+
if (!spec.computed?.[fieldName]) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const fieldDef = spec.computed[fieldName];
|
|
290
|
+
const computed = calculate(data, spec);
|
|
291
|
+
const value = computed[fieldName];
|
|
292
|
+
|
|
293
|
+
if (value === null || value === undefined) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return formatValue(value, fieldDef.format);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Format a value according to a format specification
|
|
302
|
+
*
|
|
303
|
+
* Supported formats:
|
|
304
|
+
* - decimal(n) - Number with n decimal places
|
|
305
|
+
* - currency - Number formatted as currency
|
|
306
|
+
* - percent - Number formatted as percentage
|
|
307
|
+
* - (none) - Default string conversion
|
|
308
|
+
*/
|
|
309
|
+
function formatValue(value: unknown, format?: string): string {
|
|
310
|
+
if (!format) {
|
|
311
|
+
return String(value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle decimal(n) format
|
|
315
|
+
const decimalMatch = format.match(/^decimal\((\d+)\)$/);
|
|
316
|
+
if (decimalMatch) {
|
|
317
|
+
const decimals = parseInt(decimalMatch[1], 10);
|
|
318
|
+
return typeof value === "number" ? value.toFixed(decimals) : String(value);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Handle currency format
|
|
322
|
+
if (format === "currency") {
|
|
323
|
+
return typeof value === "number"
|
|
324
|
+
? new Intl.NumberFormat("en-US", {
|
|
325
|
+
style: "currency",
|
|
326
|
+
currency: "USD",
|
|
327
|
+
}).format(value)
|
|
328
|
+
: String(value);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Handle percent format
|
|
332
|
+
if (format === "percent") {
|
|
333
|
+
return typeof value === "number"
|
|
334
|
+
? new Intl.NumberFormat("en-US", {
|
|
335
|
+
style: "percent",
|
|
336
|
+
minimumFractionDigits: 1,
|
|
337
|
+
}).format(value)
|
|
338
|
+
: String(value);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return String(value);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ============================================================================
|
|
345
|
+
// Single Field Calculation
|
|
346
|
+
// ============================================================================
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Calculate a single computed field
|
|
350
|
+
*
|
|
351
|
+
* @param fieldName - Name of the computed field
|
|
352
|
+
* @param data - Current form data
|
|
353
|
+
* @param spec - Form specification
|
|
354
|
+
* @returns Computed value or null if calculation failed
|
|
355
|
+
*/
|
|
356
|
+
export function calculateField(
|
|
357
|
+
fieldName: string,
|
|
358
|
+
data: Record<string, unknown>,
|
|
359
|
+
spec: Forma
|
|
360
|
+
): unknown {
|
|
361
|
+
const computed = calculate(data, spec);
|
|
362
|
+
return computed[fieldName] ?? null;
|
|
363
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enabled Fields Engine
|
|
3
|
+
*
|
|
4
|
+
* Determines which fields are currently enabled (editable) based on
|
|
5
|
+
* conditional enabledWhen expressions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { evaluateBoolean } from "../feel/index.js";
|
|
9
|
+
import type {
|
|
10
|
+
Forma,
|
|
11
|
+
FieldDefinition,
|
|
12
|
+
EvaluationContext,
|
|
13
|
+
EnabledResult,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
import { calculate } from "./calculate.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface EnabledOptions {
|
|
22
|
+
/** Pre-calculated computed values */
|
|
23
|
+
computed?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Main Function
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine which fields are currently enabled (editable)
|
|
32
|
+
*
|
|
33
|
+
* Returns a map of field paths to boolean enabled states.
|
|
34
|
+
* Fields without enabledWhen expressions are always enabled.
|
|
35
|
+
*
|
|
36
|
+
* @param data - Current form data
|
|
37
|
+
* @param spec - Form specification
|
|
38
|
+
* @param options - Optional pre-calculated computed values
|
|
39
|
+
* @returns Map of field paths to enabled states
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const enabled = getEnabled(
|
|
43
|
+
* { isLocked: true },
|
|
44
|
+
* forma
|
|
45
|
+
* );
|
|
46
|
+
* // => { isLocked: true, lockedField: false, ... }
|
|
47
|
+
*/
|
|
48
|
+
export function getEnabled(
|
|
49
|
+
data: Record<string, unknown>,
|
|
50
|
+
spec: Forma,
|
|
51
|
+
options: EnabledOptions = {}
|
|
52
|
+
): EnabledResult {
|
|
53
|
+
const computed = options.computed ?? calculate(data, spec);
|
|
54
|
+
const context: EvaluationContext = {
|
|
55
|
+
data,
|
|
56
|
+
computed,
|
|
57
|
+
referenceData: spec.referenceData,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const result: EnabledResult = {};
|
|
61
|
+
|
|
62
|
+
// Evaluate each field's enabled status
|
|
63
|
+
for (const fieldPath of spec.fieldOrder) {
|
|
64
|
+
const fieldDef = spec.fields[fieldPath];
|
|
65
|
+
if (fieldDef) {
|
|
66
|
+
result[fieldPath] = isFieldEnabled(fieldDef, context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Also check array item fields
|
|
71
|
+
for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
|
|
72
|
+
if (fieldDef.itemFields) {
|
|
73
|
+
const arrayData = data[fieldPath];
|
|
74
|
+
if (Array.isArray(arrayData)) {
|
|
75
|
+
for (let i = 0; i < arrayData.length; i++) {
|
|
76
|
+
const item = arrayData[i] as Record<string, unknown>;
|
|
77
|
+
const itemContext: EvaluationContext = {
|
|
78
|
+
data,
|
|
79
|
+
computed,
|
|
80
|
+
referenceData: spec.referenceData,
|
|
81
|
+
item,
|
|
82
|
+
itemIndex: i,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const [itemFieldName, itemFieldDef] of Object.entries(fieldDef.itemFields)) {
|
|
86
|
+
const itemFieldPath = `${fieldPath}[${i}].${itemFieldName}`;
|
|
87
|
+
result[itemFieldPath] = isFieldEnabled(itemFieldDef, itemContext);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Field Enabled Check
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if a field is enabled based on its definition
|
|
103
|
+
*/
|
|
104
|
+
function isFieldEnabled(
|
|
105
|
+
fieldDef: FieldDefinition,
|
|
106
|
+
context: EvaluationContext
|
|
107
|
+
): boolean {
|
|
108
|
+
// If field has enabledWhen, evaluate it
|
|
109
|
+
if (fieldDef.enabledWhen) {
|
|
110
|
+
return evaluateBoolean(fieldDef.enabledWhen, context);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// No condition = always enabled
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a single field is currently enabled
|
|
119
|
+
*
|
|
120
|
+
* @param fieldPath - Path to the field
|
|
121
|
+
* @param data - Current form data
|
|
122
|
+
* @param spec - Form specification
|
|
123
|
+
* @returns True if the field is enabled
|
|
124
|
+
*/
|
|
125
|
+
export function isEnabled(
|
|
126
|
+
fieldPath: string,
|
|
127
|
+
data: Record<string, unknown>,
|
|
128
|
+
spec: Forma
|
|
129
|
+
): boolean {
|
|
130
|
+
const fieldDef = spec.fields[fieldPath];
|
|
131
|
+
if (!fieldDef) {
|
|
132
|
+
return true; // Unknown fields are enabled by default
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!fieldDef.enabledWhen) {
|
|
136
|
+
return true; // No condition = always enabled
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const computed = calculate(data, spec);
|
|
140
|
+
const context: EvaluationContext = {
|
|
141
|
+
data,
|
|
142
|
+
computed,
|
|
143
|
+
referenceData: spec.referenceData,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return evaluateBoolean(fieldDef.enabledWhen, context);
|
|
147
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine module exports
|
|
3
|
+
*
|
|
4
|
+
* Core form logic engines for visibility, validation, calculation, etc.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Calculate
|
|
8
|
+
export {
|
|
9
|
+
calculate,
|
|
10
|
+
calculateWithErrors,
|
|
11
|
+
calculateField,
|
|
12
|
+
getFormattedValue,
|
|
13
|
+
} from "./calculate.js";
|
|
14
|
+
|
|
15
|
+
// Visibility
|
|
16
|
+
export {
|
|
17
|
+
getVisibility,
|
|
18
|
+
isFieldVisible,
|
|
19
|
+
getPageVisibility,
|
|
20
|
+
} from "./visibility.js";
|
|
21
|
+
|
|
22
|
+
export type {
|
|
23
|
+
VisibilityOptions,
|
|
24
|
+
} from "./visibility.js";
|
|
25
|
+
|
|
26
|
+
// Required
|
|
27
|
+
export {
|
|
28
|
+
getRequired,
|
|
29
|
+
isRequired,
|
|
30
|
+
} from "./required.js";
|
|
31
|
+
|
|
32
|
+
export type {
|
|
33
|
+
RequiredOptions,
|
|
34
|
+
} from "./required.js";
|
|
35
|
+
|
|
36
|
+
// Enabled
|
|
37
|
+
export {
|
|
38
|
+
getEnabled,
|
|
39
|
+
isEnabled,
|
|
40
|
+
} from "./enabled.js";
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
EnabledOptions,
|
|
44
|
+
} from "./enabled.js";
|
|
45
|
+
|
|
46
|
+
// Validate
|
|
47
|
+
export {
|
|
48
|
+
validate,
|
|
49
|
+
validateSingleField,
|
|
50
|
+
} from "./validate.js";
|
|
51
|
+
|
|
52
|
+
export type {
|
|
53
|
+
ValidateOptions,
|
|
54
|
+
} from "./validate.js";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Required Fields Engine
|
|
3
|
+
*
|
|
4
|
+
* Determines which fields are currently required based on
|
|
5
|
+
* conditional requiredWhen expressions and schema required array.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { evaluateBoolean } from "../feel/index.js";
|
|
9
|
+
import type {
|
|
10
|
+
Forma,
|
|
11
|
+
FieldDefinition,
|
|
12
|
+
EvaluationContext,
|
|
13
|
+
RequiredFieldsResult,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
import { calculate } from "./calculate.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface RequiredOptions {
|
|
22
|
+
/** Pre-calculated computed values */
|
|
23
|
+
computed?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Main Function
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine which fields are currently required
|
|
32
|
+
*
|
|
33
|
+
* Returns a map of field paths to boolean required states.
|
|
34
|
+
* Evaluates requiredWhen expressions for conditional requirements.
|
|
35
|
+
*
|
|
36
|
+
* @param data - Current form data
|
|
37
|
+
* @param spec - Form specification
|
|
38
|
+
* @param options - Optional pre-calculated computed values
|
|
39
|
+
* @returns Map of field paths to required states
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* const required = getRequired(
|
|
43
|
+
* { hasInsurance: true },
|
|
44
|
+
* forma
|
|
45
|
+
* );
|
|
46
|
+
* // => { hasInsurance: true, insuranceProvider: true, policyNumber: true }
|
|
47
|
+
*/
|
|
48
|
+
export function getRequired(
|
|
49
|
+
data: Record<string, unknown>,
|
|
50
|
+
spec: Forma,
|
|
51
|
+
options: RequiredOptions = {}
|
|
52
|
+
): RequiredFieldsResult {
|
|
53
|
+
const computed = options.computed ?? calculate(data, spec);
|
|
54
|
+
const context: EvaluationContext = {
|
|
55
|
+
data,
|
|
56
|
+
computed,
|
|
57
|
+
referenceData: spec.referenceData,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const result: RequiredFieldsResult = {};
|
|
61
|
+
|
|
62
|
+
// Evaluate each field's required status
|
|
63
|
+
for (const fieldPath of spec.fieldOrder) {
|
|
64
|
+
const fieldDef = spec.fields[fieldPath];
|
|
65
|
+
if (fieldDef) {
|
|
66
|
+
result[fieldPath] = isFieldRequired(fieldPath, fieldDef, spec, context);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Also check array item fields
|
|
71
|
+
for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
|
|
72
|
+
if (fieldDef.itemFields) {
|
|
73
|
+
const arrayData = data[fieldPath];
|
|
74
|
+
if (Array.isArray(arrayData)) {
|
|
75
|
+
for (let i = 0; i < arrayData.length; i++) {
|
|
76
|
+
const item = arrayData[i] as Record<string, unknown>;
|
|
77
|
+
const itemContext: EvaluationContext = {
|
|
78
|
+
data,
|
|
79
|
+
computed,
|
|
80
|
+
referenceData: spec.referenceData,
|
|
81
|
+
item,
|
|
82
|
+
itemIndex: i,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const [itemFieldName, itemFieldDef] of Object.entries(fieldDef.itemFields)) {
|
|
86
|
+
const itemFieldPath = `${fieldPath}[${i}].${itemFieldName}`;
|
|
87
|
+
result[itemFieldPath] = isFieldRequired(
|
|
88
|
+
itemFieldPath,
|
|
89
|
+
itemFieldDef,
|
|
90
|
+
spec,
|
|
91
|
+
itemContext
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Field Required Check
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a single field is required based on requiredWhen or schema
|
|
108
|
+
* @internal Exported for use by validate.ts
|
|
109
|
+
*/
|
|
110
|
+
export function isFieldRequired(
|
|
111
|
+
fieldPath: string,
|
|
112
|
+
fieldDef: FieldDefinition,
|
|
113
|
+
spec: Forma,
|
|
114
|
+
context: EvaluationContext
|
|
115
|
+
): boolean {
|
|
116
|
+
// If field has requiredWhen, evaluate it
|
|
117
|
+
if (fieldDef.requiredWhen) {
|
|
118
|
+
return evaluateBoolean(fieldDef.requiredWhen, context);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Otherwise, check schema required array
|
|
122
|
+
return spec.schema.required?.includes(fieldPath) ?? false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if a single field is currently required
|
|
127
|
+
*
|
|
128
|
+
* @param fieldPath - Path to the field
|
|
129
|
+
* @param data - Current form data
|
|
130
|
+
* @param spec - Form specification
|
|
131
|
+
* @returns True if the field is required
|
|
132
|
+
*/
|
|
133
|
+
export function isRequired(
|
|
134
|
+
fieldPath: string,
|
|
135
|
+
data: Record<string, unknown>,
|
|
136
|
+
spec: Forma
|
|
137
|
+
): boolean {
|
|
138
|
+
const fieldDef = spec.fields[fieldPath];
|
|
139
|
+
if (!fieldDef) {
|
|
140
|
+
return spec.schema.required?.includes(fieldPath) ?? false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const computed = calculate(data, spec);
|
|
144
|
+
const context: EvaluationContext = {
|
|
145
|
+
data,
|
|
146
|
+
computed,
|
|
147
|
+
referenceData: spec.referenceData,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return isFieldRequired(fieldPath, fieldDef, spec, context);
|
|
151
|
+
}
|