@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,228 @@
1
+ /**
2
+ * Visibility Engine
3
+ *
4
+ * Determines which fields should be visible based on form data
5
+ * and Forma visibility rules.
6
+ */
7
+
8
+ import { evaluateBoolean } from "../feel/index.js";
9
+ import type {
10
+ Forma,
11
+ FieldDefinition,
12
+ EvaluationContext,
13
+ VisibilityResult,
14
+ } from "../types.js";
15
+ import { calculate } from "./calculate.js";
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ export interface VisibilityOptions {
22
+ /** Pre-calculated computed values (avoids recalculation) */
23
+ computed?: Record<string, unknown>;
24
+ }
25
+
26
+ // ============================================================================
27
+ // Main Function
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Determine visibility for all fields in a form
32
+ *
33
+ * Returns a map of field paths to boolean visibility states.
34
+ * Fields without visibleWhen expressions are always visible.
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 visibility states
40
+ *
41
+ * @example
42
+ * const visibility = getVisibility(
43
+ * { age: 21, hasLicense: true },
44
+ * forma
45
+ * );
46
+ * // => { age: true, hasLicense: true, vehicleType: true, ... }
47
+ */
48
+ export function getVisibility(
49
+ data: Record<string, unknown>,
50
+ spec: Forma,
51
+ options: VisibilityOptions = {}
52
+ ): VisibilityResult {
53
+ // Calculate computed values if not provided
54
+ const computed = options.computed ?? calculate(data, spec);
55
+
56
+ // Build base evaluation context
57
+ const baseContext: EvaluationContext = {
58
+ data,
59
+ computed,
60
+ referenceData: spec.referenceData,
61
+ };
62
+
63
+ const result: VisibilityResult = {};
64
+
65
+ // Process all fields in field order
66
+ for (const fieldPath of spec.fieldOrder) {
67
+ const fieldDef = spec.fields[fieldPath];
68
+ if (fieldDef) {
69
+ evaluateFieldVisibility(fieldPath, fieldDef, data, baseContext, result);
70
+ }
71
+ }
72
+
73
+ return result;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Field Evaluation
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Evaluate visibility for a single field and its nested fields
82
+ */
83
+ function evaluateFieldVisibility(
84
+ path: string,
85
+ fieldDef: FieldDefinition,
86
+ data: Record<string, unknown>,
87
+ context: EvaluationContext,
88
+ result: VisibilityResult
89
+ ): void {
90
+ // Evaluate the field's own visibility
91
+ if (fieldDef.visibleWhen) {
92
+ result[path] = evaluateBoolean(fieldDef.visibleWhen, context);
93
+ } else {
94
+ result[path] = true; // No condition = always visible
95
+ }
96
+
97
+ // If not visible, children are implicitly not visible
98
+ if (!result[path]) {
99
+ return;
100
+ }
101
+
102
+ // Handle array fields with item visibility
103
+ if (fieldDef.itemFields) {
104
+ const arrayData = data[path];
105
+ if (Array.isArray(arrayData)) {
106
+ evaluateArrayItemVisibility(path, fieldDef, arrayData, context, result);
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Evaluate visibility for array item fields
113
+ *
114
+ * For each item in the array, evaluates the visibility of each
115
+ * item field using the $item context.
116
+ */
117
+ function evaluateArrayItemVisibility(
118
+ arrayPath: string,
119
+ fieldDef: FieldDefinition,
120
+ arrayData: unknown[],
121
+ baseContext: EvaluationContext,
122
+ result: VisibilityResult
123
+ ): void {
124
+ if (!fieldDef.itemFields) return;
125
+
126
+ for (let i = 0; i < arrayData.length; i++) {
127
+ const item = arrayData[i] as Record<string, unknown>;
128
+
129
+ // Create item-specific context
130
+ const itemContext: EvaluationContext = {
131
+ ...baseContext,
132
+ item,
133
+ itemIndex: i,
134
+ };
135
+
136
+ // Evaluate each item field's visibility
137
+ for (const [fieldName, itemFieldDef] of Object.entries(fieldDef.itemFields)) {
138
+ const itemFieldPath = `${arrayPath}[${i}].${fieldName}`;
139
+
140
+ if (itemFieldDef.visibleWhen) {
141
+ result[itemFieldPath] = evaluateBoolean(itemFieldDef.visibleWhen, itemContext);
142
+ } else {
143
+ result[itemFieldPath] = true;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // ============================================================================
150
+ // Individual Field Visibility
151
+ // ============================================================================
152
+
153
+ /**
154
+ * Check if a single field is visible
155
+ *
156
+ * Useful for checking visibility of one field without computing all.
157
+ *
158
+ * @param fieldPath - Field path to check
159
+ * @param data - Current form data
160
+ * @param spec - Form specification
161
+ * @param options - Optional pre-calculated computed values
162
+ * @returns True if the field is visible
163
+ */
164
+ export function isFieldVisible(
165
+ fieldPath: string,
166
+ data: Record<string, unknown>,
167
+ spec: Forma,
168
+ options: VisibilityOptions = {}
169
+ ): boolean {
170
+ const fieldDef = spec.fields[fieldPath];
171
+ if (!fieldDef) {
172
+ return true; // Unknown fields are visible by default
173
+ }
174
+
175
+ if (!fieldDef.visibleWhen) {
176
+ return true; // No condition = always visible
177
+ }
178
+
179
+ const computed = options.computed ?? calculate(data, spec);
180
+ const context: EvaluationContext = {
181
+ data,
182
+ computed,
183
+ referenceData: spec.referenceData,
184
+ };
185
+
186
+ return evaluateBoolean(fieldDef.visibleWhen, context);
187
+ }
188
+
189
+ // ============================================================================
190
+ // Page Visibility
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Determine which pages are visible in a wizard form
195
+ *
196
+ * @param data - Current form data
197
+ * @param spec - Form specification with pages
198
+ * @param options - Optional pre-calculated computed values
199
+ * @returns Map of page IDs to visibility states
200
+ */
201
+ export function getPageVisibility(
202
+ data: Record<string, unknown>,
203
+ spec: Forma,
204
+ options: VisibilityOptions = {}
205
+ ): Record<string, boolean> {
206
+ if (!spec.pages) {
207
+ return {};
208
+ }
209
+
210
+ const computed = options.computed ?? calculate(data, spec);
211
+ const context: EvaluationContext = {
212
+ data,
213
+ computed,
214
+ referenceData: spec.referenceData,
215
+ };
216
+
217
+ const result: Record<string, boolean> = {};
218
+
219
+ for (const page of spec.pages) {
220
+ if (page.visibleWhen) {
221
+ result[page.id] = evaluateBoolean(page.visibleWhen, context);
222
+ } else {
223
+ result[page.id] = true;
224
+ }
225
+ }
226
+
227
+ return result;
228
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * FEEL Expression Evaluator
3
+ *
4
+ * Wraps the feelin library to provide FEEL expression evaluation
5
+ * with Forma context conventions.
6
+ *
7
+ * Context variable conventions:
8
+ * - `fieldName` - Direct field value access
9
+ * - `computed.name` - Computed value access
10
+ * - `ref.path` - Reference data lookup (external lookup tables)
11
+ * - `item.fieldName` - Array item field access (within array context)
12
+ * - `itemIndex` - Current array item index (0-based)
13
+ * - `value` - Current field value (in validation expressions)
14
+ */
15
+
16
+ import { evaluate as feelinEvaluate } from "feelin";
17
+ import type { EvaluationContext, FEELExpression } from "../types.js";
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface EvaluateResult<T = unknown> {
24
+ success: true;
25
+ value: T;
26
+ }
27
+
28
+ export interface EvaluateError {
29
+ success: false;
30
+ error: string;
31
+ expression: string;
32
+ }
33
+
34
+ export type EvaluationOutcome<T = unknown> = EvaluateResult<T> | EvaluateError;
35
+
36
+ // ============================================================================
37
+ // Context Building
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Build the FEEL evaluation context from our EvaluationContext
42
+ *
43
+ * Maps our context conventions to feelin context format:
44
+ * - Form data fields are spread directly
45
+ * - computed becomes an object (accessed as computed.fieldName)
46
+ * - ref becomes an object for reference data (accessed as ref.path.to.data)
47
+ * - item becomes an object for array context (accessed as item.fieldName)
48
+ * - itemIndex is a number for array context
49
+ * - value is the current field value
50
+ */
51
+ function buildFeelContext(ctx: EvaluationContext): Record<string, unknown> {
52
+ const feelContext: Record<string, unknown> = {
53
+ // Spread form data directly so fields are accessible by name
54
+ ...ctx.data,
55
+ };
56
+
57
+ // Add computed values under 'computed' (accessed as computed.fieldName)
58
+ if (ctx.computed) {
59
+ feelContext["computed"] = ctx.computed;
60
+ }
61
+
62
+ // Add reference data under 'ref' (accessed as ref.path.to.data)
63
+ if (ctx.referenceData) {
64
+ feelContext["ref"] = ctx.referenceData;
65
+ }
66
+
67
+ // Add array item context (accessed as item.fieldName)
68
+ if (ctx.item !== undefined) {
69
+ feelContext["item"] = ctx.item;
70
+ }
71
+
72
+ // Add array index
73
+ if (ctx.itemIndex !== undefined) {
74
+ feelContext["itemIndex"] = ctx.itemIndex;
75
+ }
76
+
77
+ // Add current field value for validation expressions
78
+ if (ctx.value !== undefined) {
79
+ feelContext["value"] = ctx.value;
80
+ }
81
+
82
+ return feelContext;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Expression Evaluation
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Evaluate a FEEL expression and return the result
91
+ *
92
+ * @param expression - FEEL expression string
93
+ * @param context - Evaluation context with form data, computed values, etc.
94
+ * @returns Evaluation outcome with success/value or error
95
+ *
96
+ * @example
97
+ * // Simple field comparison
98
+ * evaluate("age >= 18", { data: { age: 21 } })
99
+ * // => { success: true, value: true }
100
+ *
101
+ * @example
102
+ * // Computed value reference
103
+ * evaluate("computed.bmi > 30", { data: {}, computed: { bmi: 32.5 } })
104
+ * // => { success: true, value: true }
105
+ *
106
+ * @example
107
+ * // Array item context
108
+ * evaluate("item.frequency = \"daily\"", { data: {}, item: { frequency: "daily" } })
109
+ * // => { success: true, value: true }
110
+ */
111
+ export function evaluate<T = unknown>(
112
+ expression: FEELExpression,
113
+ context: EvaluationContext
114
+ ): EvaluationOutcome<T> {
115
+ try {
116
+ const feelContext = buildFeelContext(context);
117
+ const result = feelinEvaluate(expression, feelContext);
118
+ return {
119
+ success: true,
120
+ value: result as T,
121
+ };
122
+ } catch (error) {
123
+ return {
124
+ success: false,
125
+ error: error instanceof Error ? error.message : String(error),
126
+ expression,
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Evaluate a FEEL expression expecting a boolean result
133
+ *
134
+ * Used for visibility, required, and enabled conditions.
135
+ * Returns false on error or non-boolean result for safety.
136
+ *
137
+ * @param expression - FEEL expression that should return boolean
138
+ * @param context - Evaluation context
139
+ * @returns Boolean result (false on error)
140
+ */
141
+ export function evaluateBoolean(
142
+ expression: FEELExpression,
143
+ context: EvaluationContext
144
+ ): boolean {
145
+ const result = evaluate<boolean>(expression, context);
146
+
147
+ if (!result.success) {
148
+ console.warn(
149
+ `FEEL expression error: ${result.error}\nExpression: ${result.expression}`
150
+ );
151
+ return false;
152
+ }
153
+
154
+ // FEEL uses three-valued logic where comparisons with null return null.
155
+ // For form visibility/required/enabled conditions, we treat null as false.
156
+ if (result.value === null || result.value === undefined) {
157
+ return false;
158
+ }
159
+
160
+ if (typeof result.value !== "boolean") {
161
+ console.warn(
162
+ `FEEL expression did not return boolean: ${expression}\nGot: ${typeof result.value}`
163
+ );
164
+ return false;
165
+ }
166
+
167
+ return result.value;
168
+ }
169
+
170
+ /**
171
+ * Evaluate a FEEL expression expecting a numeric result
172
+ *
173
+ * Used for computed values that return numbers.
174
+ *
175
+ * @param expression - FEEL expression that should return number
176
+ * @param context - Evaluation context
177
+ * @returns Numeric result or null on error
178
+ */
179
+ export function evaluateNumber(
180
+ expression: FEELExpression,
181
+ context: EvaluationContext
182
+ ): number | null {
183
+ const result = evaluate<number>(expression, context);
184
+
185
+ if (!result.success) {
186
+ console.warn(
187
+ `FEEL expression error: ${result.error}\nExpression: ${result.expression}`
188
+ );
189
+ return null;
190
+ }
191
+
192
+ if (typeof result.value !== "number") {
193
+ console.warn(
194
+ `FEEL expression did not return number: ${expression}\nGot: ${typeof result.value}`
195
+ );
196
+ return null;
197
+ }
198
+
199
+ return result.value;
200
+ }
201
+
202
+ /**
203
+ * Evaluate a FEEL expression expecting a string result
204
+ *
205
+ * @param expression - FEEL expression that should return string
206
+ * @param context - Evaluation context
207
+ * @returns String result or null on error
208
+ */
209
+ export function evaluateString(
210
+ expression: FEELExpression,
211
+ context: EvaluationContext
212
+ ): string | null {
213
+ const result = evaluate<string>(expression, context);
214
+
215
+ if (!result.success) {
216
+ console.warn(
217
+ `FEEL expression error: ${result.error}\nExpression: ${result.expression}`
218
+ );
219
+ return null;
220
+ }
221
+
222
+ if (typeof result.value !== "string") {
223
+ console.warn(
224
+ `FEEL expression did not return string: ${expression}\nGot: ${typeof result.value}`
225
+ );
226
+ return null;
227
+ }
228
+
229
+ return result.value;
230
+ }
231
+
232
+ // ============================================================================
233
+ // Batch Evaluation
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Evaluate multiple FEEL expressions at once
238
+ *
239
+ * Useful for evaluating all visibility conditions in a form.
240
+ *
241
+ * @param expressions - Map of field names to FEEL expressions
242
+ * @param context - Evaluation context
243
+ * @returns Map of field names to boolean results
244
+ */
245
+ export function evaluateBooleanBatch(
246
+ expressions: Record<string, FEELExpression>,
247
+ context: EvaluationContext
248
+ ): Record<string, boolean> {
249
+ const results: Record<string, boolean> = {};
250
+
251
+ for (const [key, expression] of Object.entries(expressions)) {
252
+ results[key] = evaluateBoolean(expression, context);
253
+ }
254
+
255
+ return results;
256
+ }
257
+
258
+ // ============================================================================
259
+ // Expression Validation
260
+ // ============================================================================
261
+
262
+ /**
263
+ * Check if a FEEL expression is syntactically valid
264
+ *
265
+ * @param expression - FEEL expression to validate
266
+ * @returns True if the expression can be parsed
267
+ */
268
+ export function isValidExpression(expression: FEELExpression): boolean {
269
+ try {
270
+ // Try to evaluate with empty context - we just want to check parsing
271
+ feelinEvaluate(expression, {});
272
+ return true;
273
+ } catch {
274
+ // Expression failed to parse or evaluate
275
+ return false;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Validate a FEEL expression and return any parsing errors
281
+ *
282
+ * @param expression - FEEL expression to validate
283
+ * @returns Null if valid, error message if invalid
284
+ */
285
+ export function validateExpression(expression: FEELExpression): string | null {
286
+ try {
287
+ // Evaluate with minimal context to catch parse errors
288
+ feelinEvaluate(expression, {});
289
+ return null;
290
+ } catch (error) {
291
+ // Only return actual parsing errors, not runtime errors
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ if (message.includes("parse") || message.includes("syntax")) {
294
+ return message;
295
+ }
296
+ // Runtime errors (missing variables, etc.) are OK for validation
297
+ return null;
298
+ }
299
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @fogpipe/forma-core
3
+ *
4
+ * Core runtime for Forma dynamic forms.
5
+ * Provides types, FEEL expression evaluation, and form state engines.
6
+ */
7
+
8
+ // Types
9
+ export * from "./types.js";
10
+
11
+ // FEEL expression evaluation
12
+ export * from "./feel/index.js";
13
+
14
+ // Form state engines
15
+ export * from "./engine/index.js";