From d2a8fdc26ef9f0e2ead78a724500bcbc8df933fd Mon Sep 17 00:00:00 2001 From: guynikan Date: Mon, 26 Jan 2026 21:49:40 +0000 Subject: [PATCH 1/5] refactor: rename formState to formValues across codebase - Update variable-resolver to use formValues instead of formState - Update middleware types and context to use formValues - Update React factories and hooks to use formValues - Update examples and tests to use formValues - Update form schemas to use $formValues in expressions --- examples/react/src/ExpressionExample.tsx | 6 +- instances/form/complex-form.json | 41 ++++++ instances/form/template-form.json | 121 ------------------ .../adapters/react/src/native-form-adapter.ts | 4 +- .../src/expressions/variable-resolver.test.ts | 106 +++++++++------ .../core/src/expressions/variable-resolver.ts | 24 ++-- .../template-expression-middleware.test.ts | 10 +- .../template-expression-middleware.ts | 6 +- packages/core/src/middleware/types.ts | 4 +- packages/factories/react/src/form-factory.tsx | 12 +- .../react/src/hooks/use-schepta-form.ts | 32 ++--- tests/e2e/react.spec.ts | 15 ++- 12 files changed, 167 insertions(+), 214 deletions(-) delete mode 100644 instances/form/template-form.json 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:

// // //
-//

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. //

// { }, 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/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/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', () => { From dc30677226becc2822d425e7864a09cd9002db35 Mon Sep 17 00:00:00 2001 From: guynikan Date: Mon, 26 Jan 2026 21:49:55 +0000 Subject: [PATCH 2/5] feat: add JEXL support for complex template expressions - Integrate JEXL evalSync for evaluating boolean and comparison expressions - Support operators: ===, !==, ==, !=, >=, <=, >, <, &&, || - Convert JS operators (===, !==) to JEXL operators (==, !=) - Preserve type (boolean/number) for single template expressions - Add comprehensive tests for JEXL expression evaluation --- .../src/expressions/template-detector.test.ts | 12 +- .../core/src/expressions/template-detector.ts | 4 +- .../expressions/template-processor.test.ts | 230 +++++++++++++++++- .../src/expressions/template-processor.ts | 115 +++++++-- 4 files changed, 326 insertions(+), 35 deletions(-) diff --git a/packages/core/src/expressions/template-detector.test.ts b/packages/core/src/expressions/template-detector.test.ts index 447dd04..cbdb03a 100644 --- a/packages/core/src/expressions/template-detector.test.ts +++ b/packages/core/src/expressions/template-detector.test.ts @@ -19,9 +19,9 @@ describe('Template Detector', () => { }); 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 From af10eea992642a8359621ef2925bfa8cd64d84d3 Mon Sep 17 00:00:00 2001 From: guynikan Date: Mon, 26 Jan 2026 21:50:07 +0000 Subject: [PATCH 3/5] feat: add x-ui.visible property to control component visibility - Add visibility check in renderer-orchestrator after template processing - Components with x-ui.visible === false are not rendered - Default visibility is true (undefined or any other value) - Supports template expressions for dynamic visibility control --- .../core/src/orchestrator/renderer-orchestrator.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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, From f0fe9b0fecc5318876cb2f3eceb3eb8dce227698 Mon Sep 17 00:00:00 2001 From: guynikan Date: Mon, 26 Jan 2026 21:50:12 +0000 Subject: [PATCH 4/5] feat: add visible property to FieldNode in schema traversal - Extract x-ui.visible from FormField wrapper or input component - Priority: input x-ui.visible > FormField x-ui.visible > default (true) - Update FieldNode interface to include visible property - Default value is true when not specified --- packages/core/src/validation/schema-traversal.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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); From 7cd2f350e1a5bbe54f12dff901163ab53d13b644 Mon Sep 17 00:00:00 2001 From: guynikan Date: Mon, 26 Jan 2026 21:50:21 +0000 Subject: [PATCH 5/5] chore: update form schema to include x-ui.visible property - Add visible property to x-ui schema for fields and sections - Support template expressions for dynamic visibility --- packages/factories/src/schemas/form-schema.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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