@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.
@@ -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
+ }