diff --git a/examples/react/src/ExpressionExample.tsx b/examples/react/src/ExpressionExample.tsx
index ff89a29..b053061 100644
--- a/examples/react/src/ExpressionExample.tsx
+++ b/examples/react/src/ExpressionExample.tsx
@@ -31,20 +31,20 @@
//
Template Expressions Features:
//
// - ✅ Use {'{{ $externalContext.* }}'} in schema values
-// - ✅ Use {'{{ $formState.* }}'} for dynamic form values
+// - ✅ Use {'{{ $formValues.* }}'} for dynamic form values
// - ✅ Automatic processing of all template expressions
// - ✅ Recursive processing in nested objects
//
//
//
-//
Template Form (with {'{{ $externalContext.* }}'} and {'{{ $formState.* }}'})
+//
Template Form (with {'{{ $externalContext.* }}'} and {'{{ $formValues.* }}'})
//
// This example demonstrates two key template expression features you can observe:
//
// 1. $externalContext expressions: The First Name label uses {'{{ $externalContext.user.name }}'} which resolves to "Test User" from the factory's externalContext prop.
//
-// 2. $formState expressions: The Email field's placeholder uses {'{{ $formState.personalInfo.firstName }}'} which dynamically updates to show the current value of the First Name field as you type.
+// 2. $formValues expressions: The Email field's placeholder uses {'{{ $formValues.personalInfo.firstName }}'} which dynamically updates to show the current value of the First Name field as you type.
//
//
{
});
it('should detect multiple template expressions', () => {
- const str = '{{ $externalContext.user.name }} and {{ $formState.email }}';
+ const str = '{{ $externalContext.user.name }} and {{ $formValues.email }}';
const result = detectTemplateExpressions(str);
- expect(result).toEqual(['$externalContext.user.name', '$formState.email']);
+ expect(result).toEqual(['$externalContext.user.name', '$formValues.email']);
});
it('should handle whitespace in templates', () => {
@@ -50,8 +50,8 @@ describe('Template Detector', () => {
});
it('should handle whitespace', () => {
- const result = extractExpression('{{ $formState.email }}');
- expect(result).toBe('$formState.email');
+ const result = extractExpression('{{ $formValues.email }}');
+ expect(result).toBe('$formValues.email');
});
it('should return original string if no template found', () => {
@@ -110,7 +110,7 @@ describe('Template Detector', () => {
label: '{{ $externalContext.user.name }}',
placeholder: 'Enter {{ $externalContext.fieldName }}',
nested: {
- text: '{{ $formState.email }}',
+ text: '{{ $formValues.email }}',
},
};
@@ -118,7 +118,7 @@ describe('Template Detector', () => {
expect(result).toHaveLength(3);
expect(result).toContain('$externalContext.user.name');
expect(result).toContain('$externalContext.fieldName');
- expect(result).toContain('$formState.email');
+ expect(result).toContain('$formValues.email');
});
it('should return unique expressions only', () => {
diff --git a/packages/core/src/expressions/template-detector.ts b/packages/core/src/expressions/template-detector.ts
index 6c4402d..61c1f87 100644
--- a/packages/core/src/expressions/template-detector.ts
+++ b/packages/core/src/expressions/template-detector.ts
@@ -7,7 +7,7 @@
/**
* Regular expression to match template expressions {{ ... }}
- * Matches: {{ $externalContext.user.name }}, {{ $formState.field }}, etc.
+ * Matches: {{ $externalContext.user.name }}, {{ $formValues.field }}, etc.
*/
const TEMPLATE_REGEX = /\{\{\s*([^}]+)\s*\}\}/g;
@@ -66,7 +66,7 @@ export function extractExpression(template: string): string {
*
* @example
* hasTemplateExpressions("{{ $externalContext.user.name }}") // true
- * hasTemplateExpressions({ label: "{{ $formState.field }}" }) // true
+ * hasTemplateExpressions({ label: "{{ $formValues.field }}" }) // true
* hasTemplateExpressions(["{{ $externalContext.api }}", "static"]) // true
* hasTemplateExpressions("static text") // false
*/
diff --git a/packages/core/src/expressions/template-processor.test.ts b/packages/core/src/expressions/template-processor.test.ts
index 7fbc764..4daee74 100644
--- a/packages/core/src/expressions/template-processor.test.ts
+++ b/packages/core/src/expressions/template-processor.test.ts
@@ -20,7 +20,7 @@ describe('Template Processor', () => {
},
api: 'https://api.example.com',
},
- formState: {
+ formValues: {
firstName: 'John',
lastName: 'Doe',
},
@@ -33,14 +33,16 @@ describe('Template Processor', () => {
const result = processTemplateString(
'Hello {{ $externalContext.user.name }}',
resolver,
+ context,
);
expect(result).toBe('Hello John Doe');
});
it('should replace multiple template expressions', () => {
const result = processTemplateString(
- '{{ $externalContext.user.name }} - {{ $formState.firstName }}',
+ '{{ $externalContext.user.name }} - {{ $formValues.firstName }}',
resolver,
+ context,
);
expect(result).toBe('John Doe - John');
});
@@ -49,6 +51,7 @@ describe('Template Processor', () => {
const result = processTemplateString(
'Hello world',
resolver,
+ context,
);
expect(result).toBe('Hello world');
});
@@ -57,23 +60,51 @@ describe('Template Processor', () => {
const result = processTemplateString(
'Hello {{ $externalContext.nonexistent }}',
resolver,
+ context,
);
expect(result).toBe('Hello ');
});
- it('should convert non-string values to string', () => {
+ it('should convert non-string values to string when interpolated', () => {
const customContext: ResolverContext = {
externalContext: { count: 42 },
- formState: {},
+ formValues: {},
};
const customResolver = createDefaultResolver(customContext);
const result = processTemplateString(
'Count: {{ $externalContext.count }}',
customResolver,
+ customContext,
);
expect(result).toBe('Count: 42');
});
+
+ it('should preserve type for single template expressions', () => {
+ const customContext: ResolverContext = {
+ externalContext: { count: 42, active: true },
+ formValues: {},
+ };
+ const customResolver = createDefaultResolver(customContext);
+
+ // Single template should preserve number type
+ const numberResult = processTemplateString(
+ '{{ $externalContext.count }}',
+ customResolver,
+ customContext,
+ );
+ expect(numberResult).toBe(42);
+ expect(typeof numberResult).toBe('number');
+
+ // Single template should preserve boolean type
+ const boolResult = processTemplateString(
+ '{{ $externalContext.active }}',
+ customResolver,
+ customContext,
+ );
+ expect(boolResult).toBe(true);
+ expect(typeof boolResult).toBe('boolean');
+ });
});
describe('processTemplateExpression', () => {
@@ -107,7 +138,7 @@ describe('Template Processor', () => {
it('should process objects recursively', () => {
const value = {
label: '{{ $externalContext.user.name }}',
- placeholder: 'Enter {{ $formState.firstName }}',
+ placeholder: 'Enter {{ $formValues.firstName }}',
static: 'unchanged',
};
@@ -123,7 +154,7 @@ describe('Template Processor', () => {
it('should process arrays recursively', () => {
const value = [
'{{ $externalContext.user.name }}',
- '{{ $formState.firstName }}',
+ '{{ $formValues.firstName }}',
'static',
];
@@ -137,7 +168,7 @@ describe('Template Processor', () => {
ui: {
label: '{{ $externalContext.user.name }}',
nested: {
- placeholder: '{{ $formState.firstName }}',
+ placeholder: '{{ $formValues.firstName }}',
},
},
};
@@ -170,7 +201,7 @@ describe('Template Processor', () => {
label: '{{ $externalContext.user.name }}',
count: 42,
items: [
- '{{ $formState.firstName }}',
+ '{{ $formValues.firstName }}',
'static',
],
nested: {
@@ -195,7 +226,7 @@ describe('Template Processor', () => {
it('should return true for values with templates', () => {
expect(needsProcessing('{{ $externalContext.user.name }}')).toBe(true);
expect(needsProcessing({
- label: '{{ $formState.firstName }}',
+ label: '{{ $formValues.firstName }}',
})).toBe(true);
});
@@ -205,5 +236,186 @@ describe('Template Processor', () => {
expect(needsProcessing(123)).toBe(false);
});
});
+
+ describe('JEXL expressions', () => {
+ it('should evaluate equality expressions and return boolean', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: {
+ status: 'active',
+ userInfo: {
+ maritalStatus: 'married',
+ },
+ },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ // Simple equality
+ const result1 = processTemplateString(
+ "{{ $formValues.status === 'active' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result1).toBe(true);
+ expect(typeof result1).toBe('boolean');
+
+ // Nested path equality
+ const result2 = processTemplateString(
+ "{{ $formValues.userInfo.maritalStatus === 'married' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result2).toBe(true);
+ expect(typeof result2).toBe('boolean');
+
+ // False case
+ const result3 = processTemplateString(
+ "{{ $formValues.status === 'inactive' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result3).toBe(false);
+ expect(typeof result3).toBe('boolean');
+ });
+
+ it('should evaluate inequality expressions', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: { status: 'active' },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const result = processTemplateString(
+ "{{ $formValues.status !== 'inactive' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should evaluate logical AND expressions', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: { a: true, b: true, c: false },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const result1 = processTemplateString(
+ '{{ $formValues.a && $formValues.b }}',
+ testResolver,
+ testContext,
+ );
+ expect(result1).toBe(true);
+
+ const result2 = processTemplateString(
+ '{{ $formValues.a && $formValues.c }}',
+ testResolver,
+ testContext,
+ );
+ expect(result2).toBe(false);
+ });
+
+ it('should evaluate logical OR expressions', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: { a: true, b: false },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const result = processTemplateString(
+ '{{ $formValues.a || $formValues.b }}',
+ testResolver,
+ testContext,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should evaluate comparison expressions', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: { age: 25, minAge: 18 },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const result1 = processTemplateString(
+ '{{ $formValues.age >= 18 }}',
+ testResolver,
+ testContext,
+ );
+ expect(result1).toBe(true);
+
+ const result2 = processTemplateString(
+ '{{ $formValues.age > $formValues.minAge }}',
+ testResolver,
+ testContext,
+ );
+ expect(result2).toBe(true);
+
+ const result3 = processTemplateString(
+ '{{ $formValues.age < 18 }}',
+ testResolver,
+ testContext,
+ );
+ expect(result3).toBe(false);
+ });
+
+ it('should evaluate complex expressions in processValue', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: {
+ userInfo: {
+ maritalStatus: 'married',
+ },
+ },
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const value = {
+ label: 'Spouse Name',
+ disabled: "{{ $formValues.userInfo.maritalStatus === 'married' }}",
+ };
+
+ const result = processValue(value, testResolver, testContext);
+
+ expect(result).toEqual({
+ label: 'Spouse Name',
+ disabled: true,
+ });
+ expect(typeof result.disabled).toBe('boolean');
+ });
+
+ it('should handle complex expressions with externalContext', () => {
+ const testContext: ResolverContext = {
+ externalContext: {
+ user: { role: 'admin' },
+ },
+ formValues: {},
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ const result = processTemplateString(
+ "{{ $externalContext.user.role === 'admin' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should handle undefined values in expressions gracefully', () => {
+ const testContext: ResolverContext = {
+ externalContext: {},
+ formValues: {},
+ };
+ const testResolver = createDefaultResolver(testContext);
+
+ // Should not throw, return undefined or false
+ const result = processTemplateString(
+ "{{ $formValues.nonexistent === 'value' }}",
+ testResolver,
+ testContext,
+ );
+ expect(result).toBe(false);
+ });
+ });
});
diff --git a/packages/core/src/expressions/template-processor.ts b/packages/core/src/expressions/template-processor.ts
index 5636b7f..e9fc123 100644
--- a/packages/core/src/expressions/template-processor.ts
+++ b/packages/core/src/expressions/template-processor.ts
@@ -3,32 +3,86 @@
*
* Framework-agnostic processing of template expressions
* Recursively processes values (strings, objects, arrays) and replaces templates
+ * Uses JEXL for complex expressions with operators (===, !==, &&, ||, etc.)
*/
+import jexl from 'jexl';
import { detectTemplateExpressions, hasTemplateExpressions } from './template-detector';
import type { VariableResolver, ResolverContext } from './variable-resolver';
+/**
+ * Check if an expression contains operators (is a complex expression)
+ * Complex expressions need JEXL evaluation instead of simple variable resolution
+ *
+ * @param expr Expression to check
+ * @returns true if expression contains operators
+ */
+function isComplexExpression(expr: string): boolean {
+ // Contains comparison or logical operators
+ return /===|!==|==|!=|>=|<=|>|<|&&|\|\|/.test(expr);
+}
+
+/**
+ * Convert JavaScript operators to JEXL operators
+ * JEXL uses == instead of ===, and != instead of !==
+ *
+ * @param expression Expression with JS operators
+ * @returns Expression with JEXL operators
+ */
+function convertToJexlOperators(expression: string): string {
+ return expression
+ .replace(/===/g, '==')
+ .replace(/!==/g, '!=');
+}
+
+/**
+ * Evaluate a complex expression using JEXL
+ *
+ * @param expression Expression to evaluate
+ * @param context Resolver context with formValues and externalContext
+ * @returns Evaluated result (boolean, number, string, etc.)
+ */
+function evaluateExpression(expression: string, context: ResolverContext): any {
+ // Build JEXL context with form values and external context
+ const jexlContext = {
+ $formValues: context.formValues,
+ $externalContext: context.externalContext,
+ };
+
+ // Convert JS operators to JEXL operators
+ const jexlExpression = convertToJexlOperators(expression);
+
+ try {
+ return jexl.evalSync(jexlExpression, jexlContext);
+ } catch (error) {
+ console.warn(`Error evaluating expression "${expression}":`, error);
+ return undefined;
+ }
+}
+
/**
* Process a single template expression in a string
* Replaces all {{ }} expressions with resolved values
+ * Uses JEXL for complex expressions with operators
*
* @param str String containing template expressions
* @param resolver Variable resolver function
- * @param context Resolver context
- * @returns String with templates replaced
+ * @param context Resolver context (required for JEXL evaluation)
+ * @returns Processed value - preserves type for single expressions (boolean, number), string for interpolated
*
- * @example
- * processTemplateString(
- * "Hello {{ $externalContext.user.name }}",
- * resolver,
- * context
- * )
+ * @example Simple variable
+ * processTemplateString("Hello {{ $externalContext.user.name }}", resolver, context)
* // Returns: "Hello John"
+ *
+ * @example Boolean expression (returns boolean, not string)
+ * processTemplateString("{{ $formValues.status === 'active' }}", resolver, context)
+ * // Returns: true (boolean)
*/
export function processTemplateString(
str: string,
resolver: VariableResolver,
-): string {
+ context: ResolverContext
+): any {
if (typeof str !== 'string') {
return str;
}
@@ -40,14 +94,36 @@ export function processTemplateString(
return str;
}
+ // Check if string is EXACTLY a single template (no surrounding text)
+ // This allows preserving the type (boolean, number) instead of converting to string
+ const singleTemplateMatch = str.match(/^\{\{\s*([^}]+)\s*\}\}$/);
+ if (singleTemplateMatch) {
+ const expr = singleTemplateMatch[1].trim();
+
+ // Complex expressions (with operators) need JEXL evaluation
+ if (isComplexExpression(expr)) {
+ return evaluateExpression(expr, context);
+ }
+
+ // Simple variable resolution - return value directly (preserves type)
+ return resolver(expr);
+ }
+
+ // Multiple templates or text around them: concatenate as string
let result = str;
- // Replace each expression with its resolved value
for (const expression of expressions) {
- const resolved = resolver(expression);
const template = `{{ ${expression} }}`;
+ let resolved: any;
- // Replace template with resolved value (convert to string if needed)
+ // Use JEXL for complex expressions
+ if (isComplexExpression(expression)) {
+ resolved = evaluateExpression(expression, context);
+ } else {
+ resolved = resolver(expression);
+ }
+
+ // Convert to string for concatenation
const replacement = resolved !== undefined && resolved !== null
? String(resolved)
: '';
@@ -80,24 +156,27 @@ export function processTemplateExpression(
/**
* Process a value recursively, replacing all template expressions
* Handles strings, objects, arrays, and nested structures
+ * Uses JEXL for complex expressions with operators
*
* @param value Value to process (can be any type)
* @param resolver Variable resolver function
- * @param context Resolver context
+ * @param context Resolver context (required for JEXL evaluation)
* @returns Processed value with templates replaced
*
* @example
* processValue({
* label: "{{ $externalContext.user.name }}",
* nested: {
- * placeholder: "Enter {{ $formState.email }}"
- * }
+ * placeholder: "Enter {{ $formValues.email }}"
+ * },
+ * disabled: "{{ $formValues.status === 'inactive' }}"
* }, resolver, context)
* // Returns: {
* // label: "John",
* // nested: {
* // placeholder: "Enter john@example.com"
- * // }
+ * // },
+ * // disabled: true (boolean, not string)
* // }
*/
export function processValue(
@@ -110,9 +189,9 @@ export function processValue(
return value;
}
- // Handle strings - process template expressions
+ // Handle strings - process template expressions (pass context for JEXL)
if (typeof value === 'string') {
- return processTemplateString(value, resolver);
+ return processTemplateString(value, resolver, context);
}
// Handle arrays - process each element recursively
diff --git a/packages/core/src/expressions/variable-resolver.test.ts b/packages/core/src/expressions/variable-resolver.test.ts
index 704ee67..aa2184f 100644
--- a/packages/core/src/expressions/variable-resolver.test.ts
+++ b/packages/core/src/expressions/variable-resolver.test.ts
@@ -18,7 +18,7 @@ describe('Variable Resolver', () => {
},
api: 'https://api.example.com',
},
- formState: {
+ formValues: {
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
@@ -34,18 +34,47 @@ describe('Variable Resolver', () => {
expect(resolver('$externalContext.api')).toBe('https://api.example.com');
});
- it('should resolve $formState.* expressions', () => {
+ it('should resolve $formValues.* expressions', () => {
const resolver = createDefaultResolver(context);
- expect(resolver('$formState.firstName')).toBe('John');
- expect(resolver('$formState.lastName')).toBe('Doe');
- expect(resolver('$formState.email')).toBe('john@example.com');
+ expect(resolver('$formValues.firstName')).toBe('John');
+ expect(resolver('$formValues.lastName')).toBe('Doe');
+ expect(resolver('$formValues.email')).toBe('john@example.com');
});
- it('should resolve $formState with nested objects', () => {
+ it('should resolve $formValues with nested objects', () => {
const nestedContext: ResolverContext = {
externalContext: {},
- formState: {
+ formValues: {
+ user: {
+ profile: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ settings: {
+ theme: 'dark',
+ },
+ },
+ },
+ };
+ const resolver = createDefaultResolver(nestedContext);
+
+ expect(resolver('$formValues.user.profile.firstName')).toBe('John');
+ expect(resolver('$formValues.user.profile.lastName')).toBe('Doe');
+ expect(resolver('$formValues.user.settings.theme')).toBe('dark');
+ });
+
+ it('should resolve $formValues without path', () => {
+ const resolver = createDefaultResolver(context);
+ const result = resolver('$formValues');
+
+ expect(result).toEqual(context.formValues);
+ });
+
+ it('should resolve $formValues with nested objects', () => {
+ const nestedContext: ResolverContext = {
+ externalContext: {},
+ formValues: {
user: {
profile: {
firstName: 'John',
@@ -60,15 +89,15 @@ describe('Variable Resolver', () => {
const resolver = createDefaultResolver(nestedContext);
const nestedResolver = createDefaultResolver(nestedContext);
- expect(nestedResolver('$formState.user.profile.firstName')).toBe('John');
- expect(nestedResolver('$formState.user.profile.lastName')).toBe('Doe');
- expect(nestedResolver('$formState.user.settings.theme')).toBe('dark');
+ expect(nestedResolver('$formValues.user.profile.firstName')).toBe('John');
+ expect(nestedResolver('$formValues.user.profile.lastName')).toBe('Doe');
+ expect(nestedResolver('$formValues.user.settings.theme')).toBe('dark');
});
- it('should resolve $formState with arrays', () => {
+ it('should resolve $formValues with arrays', () => {
const arrayContext: ResolverContext = {
externalContext: {},
- formState: {
+ formValues: {
tags: ['react', 'typescript', 'vue'],
items: [
{ id: 1, name: 'Item 1' },
@@ -79,17 +108,17 @@ describe('Variable Resolver', () => {
const resolver = createDefaultResolver(arrayContext);
const arrayResolver = createDefaultResolver(arrayContext);
- expect(arrayResolver('$formState.tags')).toEqual(['react', 'typescript', 'vue']);
- expect(arrayResolver('$formState.items')).toEqual([
+ expect(arrayResolver('$formValues.tags')).toEqual(['react', 'typescript', 'vue']);
+ expect(arrayResolver('$formValues.items')).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
]);
});
- it('should resolve $formState with primitive values', () => {
+ it('should resolve $formValues with primitive values', () => {
const primitiveContext: ResolverContext = {
externalContext: {},
- formState: {
+ formValues: {
count: 42,
isActive: true,
price: 99.99,
@@ -99,16 +128,16 @@ describe('Variable Resolver', () => {
const resolver = createDefaultResolver(primitiveContext);
const primitiveResolver = createDefaultResolver(primitiveContext);
- expect(primitiveResolver('$formState.count')).toBe(42);
- expect(primitiveResolver('$formState.isActive')).toBe(true);
- expect(primitiveResolver('$formState.price')).toBe(99.99);
- expect(primitiveResolver('$formState.description')).toBe('Test description');
+ expect(primitiveResolver('$formValues.count')).toBe(42);
+ expect(primitiveResolver('$formValues.isActive')).toBe(true);
+ expect(primitiveResolver('$formValues.price')).toBe(99.99);
+ expect(primitiveResolver('$formValues.description')).toBe('Test description');
});
- it('should resolve $formState with null and undefined values', () => {
+ it('should resolve $formValues with null and undefined values', () => {
const nullContext: ResolverContext = {
externalContext: {},
- formState: {
+ formValues: {
nullable: null,
undefinedValue: undefined,
emptyString: '',
@@ -116,15 +145,15 @@ describe('Variable Resolver', () => {
};
const nullResolver = createDefaultResolver(nullContext);
- expect(nullResolver('$formState.nullable')).toBeNull();
- expect(nullResolver('$formState.undefinedValue')).toBeUndefined();
- expect(nullResolver('$formState.emptyString')).toBe('');
+ expect(nullResolver('$formValues.nullable')).toBeNull();
+ expect(nullResolver('$formValues.undefinedValue')).toBeUndefined();
+ expect(nullResolver('$formValues.emptyString')).toBe('');
});
- it('should handle $formState with complex nested structures', () => {
+ it('should handle $formValues with complex nested structures', () => {
const complexContext: ResolverContext = {
externalContext: {},
- formState: {
+ formValues: {
address: {
street: '123 Main St',
city: 'New York',
@@ -139,10 +168,10 @@ describe('Variable Resolver', () => {
const resolver = createDefaultResolver(complexContext);
const complexResolver = createDefaultResolver(complexContext);
- expect(complexResolver('$formState.address.street')).toBe('123 Main St');
- expect(complexResolver('$formState.address.city')).toBe('New York');
- expect(complexResolver('$formState.address.coordinates.lat')).toBe(40.7128);
- expect(complexResolver('$formState.address.coordinates.lng')).toBe(-74.0060);
+ expect(complexResolver('$formValues.address.street')).toBe('123 Main St');
+ expect(complexResolver('$formValues.address.city')).toBe('New York');
+ expect(complexResolver('$formValues.address.coordinates.lat')).toBe(40.7128);
+ expect(complexResolver('$formValues.address.coordinates.lng')).toBe(-74.0060);
});
it('should resolve $externalContext without path', () => {
@@ -152,13 +181,6 @@ describe('Variable Resolver', () => {
expect(result).toEqual(context.externalContext);
});
- it('should resolve $formState without path', () => {
- const resolver = createDefaultResolver(context);
- const result = resolver('$formState');
-
- expect(result).toEqual(context.formState);
- });
-
it('should return undefined for unknown variables', () => {
const resolver = createDefaultResolver(context);
@@ -176,18 +198,18 @@ describe('Variable Resolver', () => {
const resolver = createDefaultResolver(context);
expect(resolver('$externalContext.user.nonexistent')).toBeUndefined();
- expect(resolver('$formState.nonexistent')).toBeUndefined();
+ expect(resolver('$formValues.nonexistent')).toBeUndefined();
});
it('should handle null/undefined context values', () => {
const emptyContext: ResolverContext = {
externalContext: {},
- formState: {},
+ formValues: {},
};
const emptyResolver = createDefaultResolver(emptyContext);
expect(emptyResolver('$externalContext.user.name')).toBeUndefined();
- expect(emptyResolver('$formState.firstName')).toBeUndefined();
+ expect(emptyResolver('$formValues.firstName')).toBeUndefined();
});
});
@@ -219,7 +241,7 @@ describe('Variable Resolver', () => {
// Default variables still work
expect(resolver('$externalContext.user.name')).toBe('John Doe');
- expect(resolver('$formState.firstName')).toBe('John');
+ expect(resolver('$formValues.firstName')).toBe('John');
});
it('should handle paths with leading dots', () => {
diff --git a/packages/core/src/expressions/variable-resolver.ts b/packages/core/src/expressions/variable-resolver.ts
index 7e29152..8549ce7 100644
--- a/packages/core/src/expressions/variable-resolver.ts
+++ b/packages/core/src/expressions/variable-resolver.ts
@@ -11,8 +11,8 @@
export interface ResolverContext {
/** External context (user data, API services, etc.) */
externalContext: Record;
- /** Form state (current form values) */
- formState: Record;
+ /** Form values (current form values) */
+ formValues: Record;
/** Additional context variables (extensible) */
[key: string]: any;
}
@@ -57,7 +57,7 @@ function resolveNestedPath(path: string, obj: any): any {
* Create default variable resolver
* Supports:
* - $externalContext.* - values from externalContext
- * - $formState.* - values from formState
+ * - $formValues.* - values from formValues
*
* @param context Resolver context
* @returns Variable resolver function
@@ -65,10 +65,10 @@ function resolveNestedPath(path: string, obj: any): any {
* @example
* const resolver = createDefaultResolver({
* externalContext: { user: { name: "John" } },
- * formState: { email: "john@example.com" }
+ * formValues: { email: "john@example.com" }
* });
* resolver("$externalContext.user.name") // "John"
- * resolver("$formState.email") // "john@example.com"
+ * resolver("$formValues.email") // "john@example.com"
*/
export function createDefaultResolver(context: ResolverContext): VariableResolver {
return (expression: string): any => {
@@ -84,10 +84,10 @@ export function createDefaultResolver(context: ResolverContext): VariableResolve
return resolveNestedPath(path, context.externalContext);
}
- // Handle $formState.*
- if (trimmed.startsWith('$formState.')) {
- const path = trimmed.substring('$formState.'.length);
- return resolveNestedPath(path, context.formState);
+ // Handle $formValues.*
+ if (trimmed.startsWith('$formValues.')) {
+ const path = trimmed.substring('$formValues.'.length);
+ return resolveNestedPath(path, context.formValues);
}
// Handle $externalContext (without path - returns entire object)
@@ -95,9 +95,9 @@ export function createDefaultResolver(context: ResolverContext): VariableResolve
return context.externalContext;
}
- // Handle $formState (without path - returns entire object)
- if (trimmed === '$formState') {
- return context.formState;
+ // Handle $formValues (without path - returns entire object)
+ if (trimmed === '$formValues') {
+ return context.formValues;
}
// Future: support other variables like $i18n, $now, etc.
diff --git a/packages/core/src/middleware/template-expression-middleware.test.ts b/packages/core/src/middleware/template-expression-middleware.test.ts
index 1193851..1b76c35 100644
--- a/packages/core/src/middleware/template-expression-middleware.test.ts
+++ b/packages/core/src/middleware/template-expression-middleware.test.ts
@@ -18,7 +18,7 @@ describe('Template Expression Middleware', () => {
},
api: 'https://api.example.com',
},
- formState: {
+ formValues: {
firstName: 'John',
lastName: 'Doe',
},
@@ -38,7 +38,7 @@ describe('Template Expression Middleware', () => {
const props = {
label: '{{ $externalContext.user.name }}',
- placeholder: 'Enter {{ $formState.firstName }}',
+ placeholder: 'Enter {{ $formValues.firstName }}',
};
const result = middleware(props, schema, context);
@@ -54,7 +54,7 @@ describe('Template Expression Middleware', () => {
ui: {
label: '{{ $externalContext.user.name }}',
nested: {
- placeholder: '{{ $formState.firstName }}',
+ placeholder: '{{ $formValues.firstName }}',
},
},
};
@@ -71,7 +71,7 @@ describe('Template Expression Middleware', () => {
const props = {
items: [
'{{ $externalContext.user.name }}',
- '{{ $formState.firstName }}',
+ '{{ $formValues.firstName }}',
],
};
@@ -119,7 +119,7 @@ describe('Template Expression Middleware', () => {
externalContext: {
user: { name: 'Jane Doe' },
},
- formState: { firstName: 'Jane' },
+ formValues: { firstName: 'Jane' },
};
const props = {
diff --git a/packages/core/src/middleware/template-expression-middleware.ts b/packages/core/src/middleware/template-expression-middleware.ts
index 31d51f7..5667349 100644
--- a/packages/core/src/middleware/template-expression-middleware.ts
+++ b/packages/core/src/middleware/template-expression-middleware.ts
@@ -13,7 +13,7 @@ import { createDefaultResolver } from '../expressions/variable-resolver';
* Create template expression middleware
* Processes all template expressions in props recursively
*
- * @param context Middleware context (contains externalContext and formState)
+ * @param context Middleware context (contains externalContext and formValues)
* @returns Middleware function
*
* @example
@@ -25,7 +25,7 @@ export function createTemplateExpressionMiddleware(
): MiddlewareFn {
const resolver = createDefaultResolver({
externalContext: context.externalContext || {},
- formState: context.formState || {},
+ formValues: context.formValues || {},
});
return (props: Record, schema: any, middlewareContext: MiddlewareContext): Record => {
@@ -37,7 +37,7 @@ export function createTemplateExpressionMiddleware(
// Process all props recursively
return processValue(props, resolver, {
externalContext: middlewareContext.externalContext || {},
- formState: middlewareContext.formState || {},
+ formValues: middlewareContext.formValues || {},
}) as Record;
};
}
diff --git a/packages/core/src/middleware/types.ts b/packages/core/src/middleware/types.ts
index 9f73422..33692dd 100644
--- a/packages/core/src/middleware/types.ts
+++ b/packages/core/src/middleware/types.ts
@@ -11,8 +11,8 @@ import type { DebugContextValue } from '../runtime/types';
* Middleware context - provides access to form state and external context
*/
export interface MiddlewareContext {
- /** Current form state */
- formState: Record;
+ /** Current form values */
+ formValues: Record;
/** External context (user data, API services, etc.) */
externalContext: Record;
/** Debug utilities */
diff --git a/packages/core/src/orchestrator/renderer-orchestrator.ts b/packages/core/src/orchestrator/renderer-orchestrator.ts
index 9722faf..aaa85e7 100644
--- a/packages/core/src/orchestrator/renderer-orchestrator.ts
+++ b/packages/core/src/orchestrator/renderer-orchestrator.ts
@@ -111,14 +111,22 @@ export function createRendererOrchestrator(
// in any property of the schema (x-ui, x-content, x-component-props, etc.)
const resolver = createDefaultResolver({
externalContext,
- formState: state,
+ formValues: state,
});
const processedSchema = processValue(schema, resolver, {
externalContext,
- formState: state,
+ formValues: state,
}) as any;
+ // Check visibility via x-ui.visible
+ // If visible === false, don't render this component (and its children)
+ // By default, visible is true
+ const xUi = processedSchema['x-ui'] || {};
+ if (xUi.visible === false) {
+ return null;
+ }
+
// Parse schema (now using processed schema)
const { 'x-component-props': componentProps = {} } = processedSchema;
@@ -176,7 +184,7 @@ export function createRendererOrchestrator(
// Middleware Application
const middlewareContext: MiddlewareContext = {
- formState: state,
+ formValues: state,
externalContext,
debug,
formAdapter,
diff --git a/packages/core/src/validation/schema-traversal.ts b/packages/core/src/validation/schema-traversal.ts
index f59dc94..3013985 100644
--- a/packages/core/src/validation/schema-traversal.ts
+++ b/packages/core/src/validation/schema-traversal.ts
@@ -21,6 +21,8 @@ export interface FieldNode {
component: string;
/** Label from x-component-props */
label?: string;
+ /** Visibility from x-ui.visible (default: true) */
+ visible: boolean | string;
/** All props from x-component-props */
props: Record;
}
@@ -150,11 +152,16 @@ export function traverseFormSchema(schema: FormSchema, visitor: FieldVisitor): v
// Check if this is a FormField component containing an input
if (component === 'FormField' && obj.properties) {
+ // Get x-ui from FormField wrapper (where visible is typically defined)
+ const formFieldXUi = obj['x-ui'] || {};
+
// Look for the actual input field inside the FormField
for (const [key, value] of Object.entries(obj.properties)) {
if (value && typeof value === 'object' && 'x-component' in value) {
const inputComponent = (value as any)['x-component'];
const props = (value as any)['x-component-props'] || {};
+ // Get x-ui from input (can override FormField's x-ui)
+ const inputXUi = (value as any)['x-ui'] || {};
// Check if it's an input component
if (inputComponent && isInputComponent(inputComponent)) {
@@ -162,12 +169,18 @@ export function traverseFormSchema(schema: FormSchema, visitor: FieldVisitor): v
// This matches how the orchestrator builds name paths
const fieldPath = [...currentPath, key].join('.');
+ // Resolve visible: input's x-ui.visible takes precedence, then FormField's, default is true
+ const visible = inputXUi.visible !== undefined
+ ? inputXUi.visible
+ : (formFieldXUi.visible !== undefined ? formFieldXUi.visible : true);
+
const field: FieldNode = {
name: key,
path: fieldPath,
type: getTypeForComponent(inputComponent),
component: inputComponent,
label: props.label,
+ visible,
props,
};
visitor(field);
diff --git a/packages/factories/react/src/form-factory.tsx b/packages/factories/react/src/form-factory.tsx
index 7b9d14e..9442c57 100644
--- a/packages/factories/react/src/form-factory.tsx
+++ b/packages/factories/react/src/form-factory.tsx
@@ -98,7 +98,7 @@ export const FormFactory = forwardRef(function
debug,
});
- const { formAdapter, formState, reset } = useScheptaForm(schema, {
+ const { formAdapter, formValues, reset } = useScheptaForm(schema, {
initialValues,
adapter: providedAdapter,
});
@@ -165,10 +165,10 @@ export const FormFactory = forwardRef(function
field: createFieldRenderer({ FieldWrapper: FieldWrapperComponent }),
};
- // Create template expression middleware with current form state (always first)
+ // Create template expression middleware with current form values (always first)
const templateMiddleware = createTemplateExpressionMiddleware({
externalContext: mergedConfig.externalContext,
- formState,
+ formValues,
debug: debugContext,
});
@@ -184,7 +184,7 @@ export const FormFactory = forwardRef(function
externalContext: {
...mergedConfig.externalContext,
},
- state: formState,
+ state: formValues,
middlewares: updatedMiddlewares,
onSubmit,
debug: debugContext,
@@ -202,13 +202,13 @@ export const FormFactory = forwardRef(function
formAdapter,
runtime,
onSubmit,
- formState,
+ formValues,
SubmitButtonComponent,
FieldWrapperComponent,
]);
return (
-
+
;
+ /** Current form values (for reactivity) */
+ formValues: Record;
/** Form errors */
formErrors: Record;
- /** Set form state directly */
- setFormState: React.Dispatch>>;
+ /** Set form values directly */
+ setFormValues: React.Dispatch>>;
/** Set form errors directly */
setFormErrors: React.Dispatch>>;
/** Reset form to initial values */
@@ -41,7 +41,7 @@ export interface ScheptaFormResult {
*
* @example Basic usage
* ```tsx
- * const { formAdapter, formState } = useScheptaForm(schema, {
+ * const { formAdapter, formValues } = useScheptaForm(schema, {
* initialValues: { name: 'John' },
* });
* ```
@@ -49,7 +49,7 @@ export interface ScheptaFormResult {
* @example With custom adapter
* ```tsx
* const myAdapter = createCustomAdapter();
- * const { formAdapter, formState } = useScheptaForm(schema, {
+ * const { formAdapter, formValues } = useScheptaForm(schema, {
* adapter: myAdapter,
* });
* ```
@@ -66,7 +66,7 @@ export function useScheptaForm(
return { ...schemaDefaults, ...initialValues };
}, [schema, initialValues]);
- const [formState, setFormState] = useState>(defaultValues);
+ const [formValues, setFormValues] = useState>(defaultValues);
const [formErrors, setFormErrors] = useState>({});
// Create adapter (ref to maintain identity)
@@ -77,20 +77,20 @@ export function useScheptaForm(
adapterRef.current = externalAdapter;
} else {
adapterRef.current = createNativeReactFormAdapter(
- formState,
- setFormState,
+ formValues,
+ setFormValues,
formErrors,
setFormErrors
);
}
}
- // Update native adapter's internal state reference when state changes
+ // Update native adapter's internal state reference when values change
useEffect(() => {
if (adapterRef.current instanceof NativeReactFormAdapter) {
- adapterRef.current.updateState(formState);
+ adapterRef.current.updateState(formValues);
}
- }, [formState]);
+ }, [formValues]);
// Update native adapter's internal errors reference when errors change
useEffect(() => {
@@ -106,7 +106,7 @@ export function useScheptaForm(
...buildInitialValues(schema),
...initialValues,
};
- setFormState(newDefaults);
+ setFormValues(newDefaults);
setFormErrors({});
}
}, [initialValues, schema]);
@@ -115,16 +115,16 @@ export function useScheptaForm(
const reset = useMemo(() => {
return (values?: Record) => {
const resetValues = values || defaultValues;
- setFormState(resetValues);
+ setFormValues(resetValues);
setFormErrors({});
};
}, [defaultValues]);
return {
formAdapter: adapterRef.current!,
- formState,
+ formValues,
formErrors,
- setFormState,
+ setFormValues,
setFormErrors,
reset,
};
diff --git a/packages/factories/src/schemas/form-schema.json b/packages/factories/src/schemas/form-schema.json
index 022c653..621498f 100644
--- a/packages/factories/src/schemas/form-schema.json
+++ b/packages/factories/src/schemas/form-schema.json
@@ -30,6 +30,10 @@
"order": {
"type": "integer",
"description": "Order of the field in the form (lower values appear first)"
+ },
+ "visible": {
+ "type": "string",
+ "description": "Expression to determine if the field should be visible"
}
},
"additionalProperties": false
@@ -362,6 +366,10 @@
"order": {
"type": "integer",
"description": "Order of the section in the form (lower values appear first)"
+ },
+ "visible": {
+ "type": "string",
+ "description": "Expression to determine if the section should be visible"
}
},
"additionalProperties": false
diff --git a/tests/e2e/react.spec.ts b/tests/e2e/react.spec.ts
index 4d5ee40..f2762e5 100644
--- a/tests/e2e/react.spec.ts
+++ b/tests/e2e/react.spec.ts
@@ -24,7 +24,7 @@ test.describe('React Form Factory', () => {
test('should render complex form with all field types', async ({ page, baseURL }) => {
await page.click('[data-test-id*="complex-form-tab"]');
- const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).map(field => field.name);
+ const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter(field => field.visible === true).map(field => field.name);
// Wait for form to be rendered
await page.waitForSelector('[data-test-id*="email"]', { timeout: 10000 });
@@ -36,7 +36,9 @@ test.describe('React Form Factory', () => {
});
test('should fill form fields', async ({ page }) => {
- const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter(field => field.props.disabled !== true);
+ const fields = extractFieldsFromSchema(complexFormSchema as FormSchema).filter(field => field.props.disabled !== true
+ && field.visible === true
+ );
const inputValues = {
'email': 'john.doe@example.com',
@@ -45,6 +47,7 @@ test.describe('React Form Factory', () => {
'lastName': 'Doe',
'userType': 'individual',
'birthDate': '1990-01-01',
+ 'maritalStatus': 'single',
'bio': 'I am a software engineer',
'acceptTerms': true,
}
@@ -97,6 +100,14 @@ test.describe('React Form Factory', () => {
await expect(fieldLocator).toHaveAttribute('required', '');
}
});
+
+ test('should show spouse name field when marital status is married', async ({ page }) => {
+ await page.click('[data-test-id*="complex-form-tab"]');
+ await page.waitForSelector('[data-test-id*="email"]', { timeout: 10000 });
+
+ await page.locator('[data-test-id*="maritalStatus"]').selectOption('married');
+ await expect(page.locator('[data-test-id*="spouseName"]')).toBeVisible();
+ });
});
test.describe('React Hook Form Integration', () => {