From 04d904dcb643fe55ddc4aebedabdc908e710fcb4 Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 9 Feb 2026 16:36:23 +0300 Subject: [PATCH 1/4] feat(formplayer): enable AJV $data validation and computed fields engine --- formulus-formplayer/src/App.tsx | 105 ++++- .../src/DynamicEnumControl.tsx | 95 +++-- .../src/FormEvaluationContext.tsx | 13 +- formulus-formplayer/src/builtinExtensions.ts | 368 ++++++++++-------- .../src/services/ExtensionsLoader.ts | 18 +- .../src/types/FormulusInterfaceDefinition.ts | 10 + 6 files changed, 397 insertions(+), 212 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a7d387561..a706df42d 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -69,7 +69,10 @@ import { draftService } from './services/DraftService'; import DraftSelector from './components/DraftSelector'; import { loadExtensions } from './services/ExtensionsLoader'; import { getBuiltinExtensions } from './builtinExtensions'; -import { FormEvaluationProvider } from './FormEvaluationContext'; +import { + FormEvaluationProvider, + ExtensionFunction, +} from './FormEvaluationContext'; // Import development dependencies (Vite will tree-shake these in production) import { webViewMock } from './mocks/webview-mock'; @@ -262,15 +265,20 @@ function App() { const [extensionRenderers, setExtensionRenderers] = useState< JsonFormsRendererRegistryEntry[] >([]); - // Store extension functions for potential future use (e.g., validation context injection) - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // Store extension functions for computed fields and evaluation context const [extensionFunctions, setExtensionFunctions] = useState< - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - Map + Map >(new Map()); const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); + // Store computed field configurations keyed by formType + const [extensionComputedFields, setExtensionComputedFields] = useState< + Record< + string, + Record + > + >({}); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -295,7 +303,10 @@ function App() { try { const properties = (formSchema as any)?.properties || {}; const dynamicEnumFields = Object.entries(properties) - .filter(([, propSchema]: [string, any]) => !!propSchema?.['x-dynamicEnum']) + .filter( + ([, propSchema]: [string, any]) => + !!propSchema?.['x-dynamicEnum'], + ) .map(([key]) => key); console.log('[Formplayer] Form init received', { @@ -306,7 +317,10 @@ function App() { dynamicEnumFields, }); } catch (schemaLogError) { - console.warn('[Formplayer] Failed to log schema details', schemaLogError); + console.warn( + '[Formplayer] Failed to log schema details', + schemaLogError, + ); } // Extract dark mode preference from params @@ -320,17 +334,27 @@ function App() { if (extensions) { try { const extensionResult = await loadExtensions(extensions); - + // Merge loaded functions with built-ins (loaded functions take precedence) extensionResult.functions.forEach((func, name) => { allFunctions.set(name, func); }); - + setExtensionRenderers(extensionResult.renderers); setExtensionFunctions(allFunctions); setExtensionDefinitions(extensionResult.definitions); + setExtensionComputedFields(extensionResult.computedFields); - console.log('[Formplayer] Final extension functions:', Array.from(allFunctions.keys())); + console.log( + '[Formplayer] Final extension functions:', + Array.from(allFunctions.keys()), + ); + if (Object.keys(extensionResult.computedFields).length > 0) { + console.log( + '[Formplayer] Computed fields registered:', + extensionResult.computedFields, + ); + } // Log errors but don't fail form initialization if (extensionResult.errors.length > 0) { @@ -342,12 +366,14 @@ function App() { setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); + setExtensionComputedFields({}); } } else { // No extensions provided, just use built-ins setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); + setExtensionComputedFields({}); console.log('[Formplayer] Using only built-in extensions'); } @@ -732,14 +758,57 @@ function App() { const handleDataChange = useCallback( ({ data }: { data: FormData }) => { - setData(data); + let finalData = data; + + // Process computed fields if available for this form type + const formType = formInitData?.formType; + const computedFieldConfigs = formType + ? extensionComputedFields[formType] + : undefined; + + if ( + computedFieldConfigs && + Object.keys(computedFieldConfigs).length > 0 + ) { + let hasComputedChanges = false; + const updatedData: Record = { ...data }; + + for (const [fieldName, config] of Object.entries( + computedFieldConfigs, + )) { + const fn = extensionFunctions.get(config.function); + if (typeof fn === 'function') { + try { + const computedValue = fn(data); + if (updatedData[fieldName] !== computedValue) { + updatedData[fieldName] = computedValue; + hasComputedChanges = true; + } + } catch (err) { + console.warn( + `[Formplayer] Error computing field "${fieldName}":`, + err, + ); + } + } + } + + if (hasComputedChanges) { + finalData = updatedData as FormData; + } + } + + setData(finalData); + + // Expose current form data globally for extension renderers + (window as any).formulusCurrentFormData = finalData; // Save draft data whenever form data changes if (formInitData) { - draftService.saveDraft(formInitData.formType, data, formInitData); + draftService.saveDraft(formInitData.formType, finalData, formInitData); } }, - [formInitData], + [formInitData, extensionComputedFields, extensionFunctions], ); // Create AJV instance with extension definitions support @@ -747,6 +816,7 @@ function App() { const instance = new Ajv({ allErrors: true, strict: false, // Allow custom keywords like x-formulus-validation + $data: true, // Enable cross-field validation via $data references (e.g., {"const": {"$data": "1/otherField"}}) }); addErrors(instance); addFormats(instance); @@ -836,10 +906,15 @@ function App() { p: 3, backgroundColor: 'background.paper', }}> - + Error Loading Form - + {loadError} diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 84e42d495..2c00bd91b 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -1,6 +1,6 @@ /** * DynamicEnumControl.tsx - * + * * Custom JSON Forms renderer for dynamic choice lists. * Supports x-dynamicEnum schema property to populate enum/oneOf values * from database queries at runtime. @@ -11,7 +11,14 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { ControlProps, rankWith } from '@jsonforms/core'; import { useFormEvaluation } from './FormEvaluationContext'; import { useJsonForms } from '@jsonforms/react'; -import { Autocomplete, TextField, Box, Typography, Alert, CircularProgress } from '@mui/material'; +import { + Autocomplete, + TextField, + Box, + Typography, + Alert, + CircularProgress, +} from '@mui/material'; /** * Interface for x-dynamicEnum configuration @@ -29,12 +36,15 @@ interface DynamicEnumConfig { * Helper to resolve the actual field schema from a scope path * Example: scope="#/properties/test_village" -> schema.properties.test_village */ -function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any { +function resolveSchemaFromScope( + scope: string | undefined, + rootSchema: any, +): any { if (!scope || !rootSchema) return rootSchema; - + // Parse scope like "#/properties/field_name" or "#/properties/nested/properties/field" const parts = scope.split('/').filter(p => p && p !== '#'); - + let resolved = rootSchema; for (const part of parts) { if (resolved && typeof resolved === 'object') { @@ -43,7 +53,7 @@ function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any return rootSchema; // Fallback to root if path invalid } } - + return resolved || rootSchema; } @@ -52,7 +62,7 @@ function resolveSchemaFromScope(scope: string | undefined, rootSchema: any): any */ export const dynamicEnumTester = rankWith( 100, // High priority for x-dynamicEnum fields - (uischema: any, schema: any, context: any) => { + (uischema: any, schema: any, _context: any) => { // Resolve the actual field schema from the scope const fieldSchema = resolveSchemaFromScope(uischema?.scope, schema); return !!(fieldSchema as any)?.['x-dynamicEnum']; @@ -65,18 +75,22 @@ export const dynamicEnumTester = rankWith( */ function resolveTemplateParams( params: Record, - formData: Record + formData: Record, ): Record { const resolved: Record = {}; - + for (const [key, value] of Object.entries(params)) { - if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + if ( + typeof value === 'string' && + value.startsWith('{{') && + value.endsWith('}}') + ) { // Extract path: {{data.village}} -> data.village const path = value.slice(2, -2).trim(); - + // Remove "data." prefix if present (form data is already the data object) const dataPath = path.startsWith('data.') ? path.slice(5) : path; - + // Get nested value const pathParts = dataPath.split('.'); let resolvedValue: any = formData; @@ -94,7 +108,7 @@ function resolveTemplateParams( resolved[key] = value; } } - + return resolved; } @@ -113,17 +127,19 @@ const DynamicEnumControl: React.FC = ({ }) => { const { functions } = useFormEvaluation(); const ctx = useJsonForms(); - - const [choices, setChoices] = useState>([]); + + const [choices, setChoices] = useState>( + [], + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [localSchema, setLocalSchema] = useState(schema); - + // Get x-dynamicEnum configuration first const dynamicConfig = useMemo(() => { return (schema as any)?.['x-dynamicEnum'] as DynamicEnumConfig | undefined; }, [schema]); - + // Get current form data for template parameter resolution const currentFormData = ctx?.core?.data || {}; @@ -132,7 +148,7 @@ const DynamicEnumControl: React.FC = ({ (_event: any, newValue: { const: any; title: string } | null) => { handleChange(path, newValue ? newValue.const : ''); }, - [handleChange, path] + [handleChange, path], ); // Find selected option based on current data value - must be before early returns @@ -142,9 +158,14 @@ const DynamicEnumControl: React.FC = ({ // Get display label from schema or uischema - computed before early returns const label = useMemo(() => { - return (uischema as any)?.label || schema.title || path.split('.').pop() || 'Field'; + return ( + (uischema as any)?.label || + schema.title || + path.split('.').pop() || + 'Field' + ); }, [uischema, schema, path]); - + const description = schema.description; const hasValidationErrors = errors && errors.length > 0; @@ -167,7 +188,7 @@ const DynamicEnumControl: React.FC = ({ if (!func) { const availableFunctions = Array.from(functions.keys()).join(', '); setError( - `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.` + `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.`, ); return; } @@ -178,7 +199,10 @@ const DynamicEnumControl: React.FC = ({ try { // Resolve template parameters (if any - they will be ignored if unresolved) const resolvedParams = dynamicConfig.params - ? resolveTemplateParams(dynamicConfig.params, currentFormData as Record) + ? resolveTemplateParams( + dynamicConfig.params, + currentFormData as Record, + ) : {}; // Add configuration for valueField, labelField, and distinct @@ -193,14 +217,18 @@ const DynamicEnumControl: React.FC = ({ }; // Call the function with correct signature: (queryName, params, formData) - const result = await func(dynamicConfig.query, paramsWithConfig, currentFormData); + const result = await func( + dynamicConfig.query, + paramsWithConfig, + currentFormData, + ); if (!Array.isArray(result)) { throw new Error(`Function returned ${typeof result}, expected array`); } setChoices(result); - + // Update local schema with dynamic enum const updatedSchema = { ...localSchema, @@ -260,14 +288,14 @@ const DynamicEnumControl: React.FC = ({ {label} {schema.required && *} - + {/* Description */} {description && ( {description} )} - + {/* Validation Errors */} {hasValidationErrors && ( @@ -291,8 +319,7 @@ const DynamicEnumControl: React.FC = ({ variant="body2" color="primary" sx={{ cursor: 'pointer', textDecoration: 'underline' }} - onClick={loadChoices} - > + onClick={loadChoices}> Retry @@ -305,15 +332,21 @@ const DynamicEnumControl: React.FC = ({ value={selectedOption} onChange={handleValueChange} options={choices} - getOptionLabel={(option) => option.title || String(option.const)} + getOptionLabel={option => option.title || String(option.const)} isOptionEqualToValue={(option, value) => option.const === value.const} disabled={!enabled} sx={{ mt: 1 }} - renderInput={(params) => ( + renderInput={params => ( )} diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index c0dea6f8b..fa7967cc9 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -1,6 +1,6 @@ /** * FormEvaluationContext.tsx - * + * * Provides extension functions to form evaluation context. * Allows renderers and other form components to access custom functions * defined in ext.json files. @@ -11,13 +11,15 @@ import React, { createContext, useContext, ReactNode } from 'react'; /** * Context value for form evaluation */ +export type ExtensionFunction = (...args: any[]) => any; + export interface FormEvaluationContextValue { /** * Map of loaded extension functions * Key: function name (e.g., "getDynamicChoiceList") * Value: the actual function */ - functions: Map; + functions: Map; } /** @@ -30,9 +32,8 @@ const defaultContextValue: FormEvaluationContextValue = { /** * Form evaluation context */ -const FormEvaluationContext = createContext( - defaultContextValue, -); +const FormEvaluationContext = + createContext(defaultContextValue); /** * Hook to access form evaluation context @@ -49,7 +50,7 @@ export interface FormEvaluationProviderProps { /** * Map of extension functions to provide */ - functions: Map; + functions: Map; /** * Child components */ diff --git a/formulus-formplayer/src/builtinExtensions.ts b/formulus-formplayer/src/builtinExtensions.ts index 41177eb2a..67f299195 100644 --- a/formulus-formplayer/src/builtinExtensions.ts +++ b/formulus-formplayer/src/builtinExtensions.ts @@ -1,10 +1,12 @@ /** * builtinExtensions.ts - * + * * Built-in extension functions that are always available in Formplayer. * These provide core functionality for dynamic choice lists and other features. */ +import { ExtensionFunction } from './FormEvaluationContext'; + // Extend window interface to include getObservationsByQuery if not already defined declare global { interface Window { @@ -26,27 +28,30 @@ declare global { */ function calculateAge(dateOfBirth: string | null | undefined): number | null { if (!dateOfBirth) return null; - + const birthDate = new Date(dateOfBirth); if (isNaN(birthDate.getTime())) return null; - + const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); - - if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + + if ( + monthDiff < 0 || + (monthDiff === 0 && today.getDate() < birthDate.getDate()) + ) { age--; } - + return age; } /** * Get dynamic choice list by querying local observations. - * + * * This function queries the native Formulus database via the WebView bridge, * then filters and formats the results based on the provided configuration. - * + * * @param queryName - Name of the query (typically a form type like 'household') * @param params - Query parameters including: * - _config.valueField: Path to extract value (e.g., 'data.hh_village_name') @@ -62,7 +67,7 @@ function calculateAge(dateOfBirth: string | null | undefined): number | null { export async function getDynamicChoiceList( queryName: string, params: Record = {}, - formData: Record = {}, + _formData: Record = {}, ): Promise> { // Check if Formulus bridge is available if (!window.formulus?.getObservationsByQuery) { @@ -76,33 +81,36 @@ export async function getDynamicChoiceList( const valueField = config.valueField || 'observationId'; const labelField = config.labelField || valueField; const distinct = config.distinct || false; - + // Build WHERE clause from params (excluding _config) // Support both 'where' and 'whereClause' for compatibility let whereClause = params.where || params.whereClause || null; - + // Get filter params (excluding _config, where, and whereClause) - const filterParams = Object.entries(params) - .filter(([key]) => key !== '_config' && key !== 'where' && key !== 'whereClause'); - + const filterParams = Object.entries(params).filter( + ([key]) => key !== '_config' && key !== 'where' && key !== 'whereClause', + ); + // Build WHERE clause from filter params if we have any if (filterParams.length > 0) { // Check if any filter values are null/undefined/empty - if so, return empty result - const hasEmptyValue = filterParams.some(([_, value]) => - value === null || value === undefined || value === '' + const hasEmptyValue = filterParams.some( + ([_, value]) => value === null || value === undefined || value === '', ); - + if (hasEmptyValue) { // Return empty choices when dependency values are not yet selected return []; } - - const conditions = filterParams.map(([fieldPath, value]) => { - // Escape single quotes in values - const escapedValue = String(value).replace(/'/g, "''"); - return `data.${fieldPath} = '${escapedValue}'`; - }).join(' AND '); - + + const conditions = filterParams + .map(([fieldPath, value]) => { + // Escape single quotes in values + const escapedValue = String(value).replace(/'/g, "''"); + return `data.${fieldPath} = '${escapedValue}'`; + }) + .join(' AND '); + // Combine with existing WHERE clause if present if (whereClause) { whereClause = `${whereClause} AND ${conditions}`; @@ -110,7 +118,7 @@ export async function getDynamicChoiceList( whereClause = conditions; } } - + // Helper to extract nested value from object path (e.g., 'data.hh_village_name') const getNestedValue = (obj: any, path: string): any => { return path.split('.').reduce((current, key) => current?.[key], obj); @@ -119,27 +127,35 @@ export async function getDynamicChoiceList( // Check if WHERE clause uses age_from_dob() syntax const usesAgeFromDob = whereClause && /age_from_dob\(/i.test(whereClause); const originalWhereClause = whereClause; - + // If using age_from_dob(), we need to filter in JavaScript after fetching // Remove age_from_dob conditions from SQL WHERE clause and filter in JS instead if (usesAgeFromDob && whereClause) { // Extract non-age conditions to keep in SQL WHERE clause // Pattern matches: age_from_dob(...) with optional NOT before it - const agePattern = /(NOT\s+)?age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/gi; + const agePattern = + /(NOT\s+)?age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/gi; const nonAgeConditions: string[] = []; - + // Split by AND/OR and keep non-age conditions // Handle parentheses and complex logic const parts = whereClause.split(/\s+(AND|OR)\s+/i); for (let i = 0; i < parts.length; i += 2) { const condition = parts[i].trim(); // Remove leading/trailing parentheses and NOT - const cleanCondition = condition.replace(/^NOT\s+/i, '').replace(/^\(+|\)+$/g, '').trim(); - if (cleanCondition && !agePattern.test(condition) && !agePattern.test(cleanCondition)) { + const cleanCondition = condition + .replace(/^NOT\s+/i, '') + .replace(/^\(+|\)+$/g, '') + .trim(); + if ( + cleanCondition && + !agePattern.test(condition) && + !agePattern.test(cleanCondition) + ) { nonAgeConditions.push(cleanCondition); } } - + // Rebuild WHERE clause without age conditions if (nonAgeConditions.length > 0) { whereClause = nonAgeConditions.join(' AND '); @@ -160,7 +176,8 @@ export async function getDynamicChoiceList( if (usesAgeFromDob && originalWhereClause) { // Parse the WHERE clause to extract age conditions // Pattern: age_from_dob(data.dob) >= 18 or NOT age_from_dob(data.dob) >= 18 - const ageConditionPattern = /(NOT\s+)?age_from_dob\(([^)]+)\)\s*(>=|<=|>|<|=|!=)\s*(\d+)/gi; + const ageConditionPattern = + /(NOT\s+)?age_from_dob\(([^)]+)\)\s*(>=|<=|>|<|=|!=)\s*(\d+)/gi; const ageConditions: Array<{ dobField: string; operator: string; @@ -169,154 +186,190 @@ export async function getDynamicChoiceList( position: number; beforeText: string; }> = []; - + // Find all age conditions and their positions let match; while ((match = ageConditionPattern.exec(originalWhereClause)) !== null) { const hasNot = !!(match[1] && match[1].trim().toUpperCase() === 'NOT'); const beforeText = originalWhereClause.substring(0, match.index); - + ageConditions.push({ dobField: match[2].trim(), operator: match[3], threshold: parseInt(match[4], 10), negated: hasNot, position: match.index, - beforeText: beforeText + beforeText: beforeText, }); } - + if (ageConditions.length > 0) { try { observations = observations.filter((obs: any) => { - // Get dob field (usually data.dob, but could be different) - const dobField = ageConditions[0].dobField; - const dob = getNestedValue(obs, dobField); - const age = calculateAge(dob); - - if (age === null) return false; - - // Helper to evaluate a single age condition - const evaluateCondition = (condition: typeof ageConditions[0]): boolean => { - let result: boolean; - switch (condition.operator) { - case '>=': result = age >= condition.threshold; break; - case '<=': result = age <= condition.threshold; break; - case '>': result = age > condition.threshold; break; - case '<': result = age < condition.threshold; break; - case '=': result = age === condition.threshold; break; - case '!=': result = age !== condition.threshold; break; - default: result = true; - } - return condition.negated ? !result : result; - }; - - // Parse the WHERE clause structure to determine logic between conditions - if (ageConditions.length === 1) { - // Single condition - return evaluateCondition(ageConditions[0]); - } else { - // Multiple conditions - need to parse the WHERE clause structure - // Check the text between conditions to determine AND/OR logic - const results: boolean[] = []; - const logics: string[] = []; - - for (let i = 0; i < ageConditions.length; i++) { - const condition = ageConditions[i]; - results.push(evaluateCondition(condition)); - - if (i < ageConditions.length - 1) { - // Check text between this condition and the next - const nextCondition = ageConditions[i + 1]; - const betweenText = originalWhereClause.substring( - condition.position + (originalWhereClause.substring(condition.position).match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0]?.length || 0), - nextCondition.position - ); - - // Check for OR (takes precedence in parsing) - if (/\bOR\b/i.test(betweenText)) { - logics.push('OR'); - } else if (/\bAND\b/i.test(betweenText)) { - logics.push('AND'); - } else { - // Default to AND if no explicit operator - logics.push('AND'); - } + // Get dob field (usually data.dob, but could be different) + const dobField = ageConditions[0].dobField; + const dob = getNestedValue(obs, dobField); + const age = calculateAge(dob); + + if (age === null) return false; + + // Helper to evaluate a single age condition + const evaluateCondition = ( + condition: (typeof ageConditions)[0], + ): boolean => { + let result: boolean; + switch (condition.operator) { + case '>=': + result = age >= condition.threshold; + break; + case '<=': + result = age <= condition.threshold; + break; + case '>': + result = age > condition.threshold; + break; + case '<': + result = age < condition.threshold; + break; + case '=': + result = age === condition.threshold; + break; + case '!=': + result = age !== condition.threshold; + break; + default: + result = true; } - } - - // Evaluate results based on logic operators - // Handle parentheses by checking if conditions are grouped - // For complex queries like (A AND B) OR C, we need to respect grouping - - // Check if there are parentheses grouping conditions - const firstConditionStart = ageConditions[0].position; - const beforeFirst = originalWhereClause.substring(0, firstConditionStart); - const lastCondition = ageConditions[ageConditions.length - 1]; - const lastConditionEnd = lastCondition.position + - (originalWhereClause.substring(lastCondition.position).match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0]?.length || 0); - const afterLast = originalWhereClause.substring(lastConditionEnd); - - // Check for NOT wrapping the entire block - const notBefore = /NOT\s*\(/i.test(beforeFirst.trim().slice(-10)); - const closingParenAfter = /^\s*\)/.test(afterLast); - const hasOpeningParen = /\(\s*$/.test(beforeFirst.trim().slice(-5)); - - // Check if conditions are grouped in parentheses - const isGrouped = hasOpeningParen && closingParenAfter; - - // Evaluate based on logic operators - let finalResult: boolean; - - if (logics.length === 0) { + return condition.negated ? !result : result; + }; + + // Parse the WHERE clause structure to determine logic between conditions + if (ageConditions.length === 1) { // Single condition - finalResult = results[0]; - } else if (logics.every(l => l === 'AND')) { - // All AND: all must be true - finalResult = results.every(r => r); - } else if (logics.every(l => l === 'OR')) { - // All OR: at least one must be true - finalResult = results.some(r => r); + return evaluateCondition(ageConditions[0]); } else { - // Mixed AND/OR - need to respect grouping - // For (A AND B) OR C pattern: - // - If grouped and first logic is AND, evaluate grouped part first - if (isGrouped && logics[0] === 'AND' && logics.some(l => l === 'OR')) { - // Find where OR starts (after grouped AND conditions) - const orIndex = logics.findIndex(l => l === 'OR'); - if (orIndex > 0) { - // Evaluate grouped AND conditions: (A AND B) - const groupedResult = results.slice(0, orIndex + 1).every(r => r); - // Then OR with remaining conditions: OR C - const remainingResults = results.slice(orIndex + 1); - finalResult = groupedResult || remainingResults.some(r => r); - } else { - // Fallback: OR all results - finalResult = results.some(r => r); + // Multiple conditions - need to parse the WHERE clause structure + // Check the text between conditions to determine AND/OR logic + const results: boolean[] = []; + const logics: string[] = []; + + for (let i = 0; i < ageConditions.length; i++) { + const condition = ageConditions[i]; + results.push(evaluateCondition(condition)); + + if (i < ageConditions.length - 1) { + // Check text between this condition and the next + const nextCondition = ageConditions[i + 1]; + const betweenText = originalWhereClause.substring( + condition.position + + (originalWhereClause + .substring(condition.position) + .match( + /age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i, + )?.[0]?.length || 0), + nextCondition.position, + ); + + // Check for OR (takes precedence in parsing) + if (/\bOR\b/i.test(betweenText)) { + logics.push('OR'); + } else if (/\bAND\b/i.test(betweenText)) { + logics.push('AND'); + } else { + // Default to AND if no explicit operator + logics.push('AND'); + } } - } else { - // Fallback: evaluate sequentially (left to right) + } + + // Evaluate results based on logic operators + // Handle parentheses by checking if conditions are grouped + // For complex queries like (A AND B) OR C, we need to respect grouping + + // Check if there are parentheses grouping conditions + const firstConditionStart = ageConditions[0].position; + const beforeFirst = originalWhereClause.substring( + 0, + firstConditionStart, + ); + const lastCondition = ageConditions[ageConditions.length - 1]; + const lastConditionEnd = + lastCondition.position + + (originalWhereClause + .substring(lastCondition.position) + .match(/age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i)?.[0] + ?.length || 0); + const afterLast = originalWhereClause.substring(lastConditionEnd); + + // Check for NOT wrapping the entire block + const notBefore = /NOT\s*\(/i.test(beforeFirst.trim().slice(-10)); + const closingParenAfter = /^\s*\)/.test(afterLast); + const hasOpeningParen = /\(\s*$/.test( + beforeFirst.trim().slice(-5), + ); + + // Check if conditions are grouped in parentheses + const isGrouped = hasOpeningParen && closingParenAfter; + + // Evaluate based on logic operators + let finalResult: boolean; + + if (logics.length === 0) { + // Single condition finalResult = results[0]; - for (let i = 0; i < logics.length; i++) { - if (logics[i] === 'OR') { - finalResult = finalResult || results[i + 1]; + } else if (logics.every(l => l === 'AND')) { + // All AND: all must be true + finalResult = results.every(r => r); + } else if (logics.every(l => l === 'OR')) { + // All OR: at least one must be true + finalResult = results.some(r => r); + } else { + // Mixed AND/OR - need to respect grouping + // For (A AND B) OR C pattern: + // - If grouped and first logic is AND, evaluate grouped part first + if ( + isGrouped && + logics[0] === 'AND' && + logics.some(l => l === 'OR') + ) { + // Find where OR starts (after grouped AND conditions) + const orIndex = logics.findIndex(l => l === 'OR'); + if (orIndex > 0) { + // Evaluate grouped AND conditions: (A AND B) + const groupedResult = results + .slice(0, orIndex + 1) + .every(r => r); + // Then OR with remaining conditions: OR C + const remainingResults = results.slice(orIndex + 1); + finalResult = + groupedResult || remainingResults.some(r => r); } else { - finalResult = finalResult && results[i + 1]; + // Fallback: OR all results + finalResult = results.some(r => r); + } + } else { + // Fallback: evaluate sequentially (left to right) + finalResult = results[0]; + for (let i = 0; i < logics.length; i++) { + if (logics[i] === 'OR') { + finalResult = finalResult || results[i + 1]; + } else { + finalResult = finalResult && results[i + 1]; + } } } } + + // Handle NOT wrapping + if (notBefore && closingParenAfter) { + // NOT (conditions) - negate the entire result + return !finalResult; + } + + return finalResult; } - - // Handle NOT wrapping - if (notBefore && closingParenAfter) { - // NOT (conditions) - negate the entire result - return !finalResult; - } - - return finalResult; - } }); - } catch (filterError: unknown) { + } catch (_filterError: unknown) { // If filtering fails, return empty array (better than crashing) observations = []; } @@ -334,7 +387,9 @@ export async function getDynamicChoiceList( }); // Filter out null/undefined values - choices = choices.filter(choice => choice.const != null && choice.const !== ''); + choices = choices.filter( + choice => choice.const != null && choice.const !== '', + ); // Apply distinct if requested if (distinct) { @@ -358,8 +413,9 @@ export async function getDynamicChoiceList( * Get all built-in extension functions as a Map * @returns Map of function name to function */ -export function getBuiltinExtensions(): Map { - const functions = new Map(); + +export function getBuiltinExtensions(): Map { + const functions = new Map(); functions.set('getDynamicChoiceList', getDynamicChoiceList); return functions; } diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index 184cb5807..bb5c3a9eb 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -6,6 +6,15 @@ */ import { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; +import { ExtensionFunction } from '../FormEvaluationContext'; + +/** + * Configuration for a single computed field + */ +export interface ComputedFieldConfig { + function: string; // Name of the extension function to call + dependencies?: string[]; // Optional: field names this computation depends on +} /** * Extension metadata passed from Formulus @@ -14,6 +23,7 @@ export interface ExtensionMetadata { definitions?: Record; functions?: Record; renderers?: Record; + computedFields?: Record>; // formType -> fieldName -> config basePath?: string; // Base path for loading modules (e.g., file:///...) } @@ -44,9 +54,9 @@ export interface LoadedRenderer { */ export interface ExtensionLoadResult { renderers: JsonFormsRendererRegistryEntry[]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - functions: Map; + functions: Map; definitions: Record; + computedFields: Record>; // formType -> fieldName -> config errors: Array<{ type: string; message: string; details?: any }>; } @@ -60,6 +70,7 @@ export async function loadExtensions( renderers: [], functions: new Map(), definitions: metadata.definitions || {}, + computedFields: metadata.computedFields || {}, errors: [], }; @@ -187,8 +198,7 @@ async function loadRenderer( async function loadFunction( metadata: ExtensionFunctionMetadata, basePath: string, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -): Promise { +): Promise { try { // Construct module path const modulePath = basePath diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index b28d4ffd6..824f37c07 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -34,6 +34,16 @@ export interface ExtensionMetadata { renderer?: string; } >; + computedFields?: Record< + string, // formType + Record< + string, // fieldName + { + function: string; // Name of extension function to call + dependencies?: string[]; // Optional: field names this computation depends on + } + > + >; basePath?: string; // Base path for loading modules } From c897ce456bd23e624d40367b0d9902fe9311d17a Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 9 Feb 2026 16:53:37 +0300 Subject: [PATCH 2/4] feat(formulus): pass computedFields from ext.json to formplayer --- formulus/src/components/FormplayerModal.tsx | 5 +++- formulus/src/services/ExtensionService.ts | 27 ++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 2d5de46dc..0b3bdd41d 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -228,7 +228,8 @@ const FormplayerModal = forwardRef( if ( mergedExtensions.definitions || mergedExtensions.functions || - mergedExtensions.renderers + mergedExtensions.renderers || + (mergedExtensions.computedFields && Object.keys(mergedExtensions.computedFields).length > 0) ) { extensions = { definitions: mergedExtensions.definitions, @@ -260,6 +261,8 @@ const FormplayerModal = forwardRef( }, {} as Record, ), + // Pass computed fields through as-is + computedFields: mergedExtensions.computedFields, // Base path for loading modules (file:// URL for WebView) // Extensions are in the /forms directory basePath: `file://${customAppPath}/forms`, diff --git a/formulus/src/services/ExtensionService.ts b/formulus/src/services/ExtensionService.ts index ba340c5ae..1aafc2869 100644 --- a/formulus/src/services/ExtensionService.ts +++ b/formulus/src/services/ExtensionService.ts @@ -7,6 +7,7 @@ export interface ExtensionDefinition { definitions?: Record; // JSON Schema definitions for $ref functions?: Record; // Custom functions renderers?: Record; // Custom question type renderers + computedFields?: Record>; // formType -> fieldName -> config } /** @@ -50,6 +51,7 @@ export interface MergedExtensions { definitions: Record; functions: Record; renderers: Record; + computedFields: Record>; } /** @@ -91,6 +93,7 @@ export class ExtensionService { definitions: {}, functions: {}, renderers: {}, + computedFields: {}, }; console.log( @@ -160,7 +163,8 @@ export class ExtensionService { } const content = await RNFS.readFile(filePath, 'utf8'); - const rawExtension = JSON.parse(content) as Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawExtension = JSON.parse(content) as Record; console.log(`[ExtensionService] Loaded ext.json from ${filePath}:`, { hasSchemas: !!rawExtension.schemas, @@ -181,7 +185,8 @@ export class ExtensionService { // Transform functions from {key: {path, export}} to {key: {name, module, export}} functions: rawExtension.functions ? Object.entries(rawExtension.functions).reduce( - (acc, [key, func]: [string, Record]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc, [key, func]: [string, any]) => { acc[key] = { name: key, // Use key as name module: func.path || func.module || '', // Support both "path" and "module" @@ -196,7 +201,8 @@ export class ExtensionService { // Transform renderers (similar structure) renderers: rawExtension.renderers ? Object.entries(rawExtension.renderers).reduce( - (acc, [key, renderer]: [string, Record]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc, [key, renderer]: [string, any]) => { // Handle both flat structure and nested structure const rendererObj = renderer.renderer || renderer; const testerObj = renderer.tester || {}; @@ -218,6 +224,9 @@ export class ExtensionService { {} as Record, ) : undefined, + + // Pass through computedFields as-is (no normalization needed) + computedFields: rawExtension.computedFields as ExtensionDefinition['computedFields'], }; console.log(`[ExtensionService] Normalized extension:`, { @@ -242,7 +251,7 @@ export class ExtensionService { if ( error instanceof Error && 'code' in error && - (error as NodeJS.ErrnoException).code === 'ENOENT' + (error as unknown as { code: string }).code === 'ENOENT' ) { // File doesn't exist - this is OK return null; @@ -335,6 +344,16 @@ export class ExtensionService { if (extension.renderers) { Object.assign(result.renderers, extension.renderers); } + + // Merge computedFields (deep merge by formType) + if (extension.computedFields) { + for (const [formType, fields] of Object.entries(extension.computedFields)) { + if (!result.computedFields[formType]) { + result.computedFields[formType] = {}; + } + Object.assign(result.computedFields[formType], fields); + } + } } /** From 88bf0ac2af5ef64c1da9f543ead2d36faa3f5ee9 Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 9 Feb 2026 17:10:21 +0300 Subject: [PATCH 3/4] fix prettier formatting --- formulus/src/components/FormplayerModal.tsx | 3 ++- formulus/src/services/ExtensionService.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 0b3bdd41d..286d03054 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -229,7 +229,8 @@ const FormplayerModal = forwardRef( mergedExtensions.definitions || mergedExtensions.functions || mergedExtensions.renderers || - (mergedExtensions.computedFields && Object.keys(mergedExtensions.computedFields).length > 0) + (mergedExtensions.computedFields && + Object.keys(mergedExtensions.computedFields).length > 0) ) { extensions = { definitions: mergedExtensions.definitions, diff --git a/formulus/src/services/ExtensionService.ts b/formulus/src/services/ExtensionService.ts index 1aafc2869..4ea9f5382 100644 --- a/formulus/src/services/ExtensionService.ts +++ b/formulus/src/services/ExtensionService.ts @@ -7,7 +7,10 @@ export interface ExtensionDefinition { definitions?: Record; // JSON Schema definitions for $ref functions?: Record; // Custom functions renderers?: Record; // Custom question type renderers - computedFields?: Record>; // formType -> fieldName -> config + computedFields?: Record< + string, + Record + >; // formType -> fieldName -> config } /** @@ -51,7 +54,10 @@ export interface MergedExtensions { definitions: Record; functions: Record; renderers: Record; - computedFields: Record>; + computedFields: Record< + string, + Record + >; } /** @@ -226,7 +232,8 @@ export class ExtensionService { : undefined, // Pass through computedFields as-is (no normalization needed) - computedFields: rawExtension.computedFields as ExtensionDefinition['computedFields'], + computedFields: + rawExtension.computedFields as ExtensionDefinition['computedFields'], }; console.log(`[ExtensionService] Normalized extension:`, { @@ -347,7 +354,9 @@ export class ExtensionService { // Merge computedFields (deep merge by formType) if (extension.computedFields) { - for (const [formType, fields] of Object.entries(extension.computedFields)) { + for (const [formType, fields] of Object.entries( + extension.computedFields, + )) { if (!result.computedFields[formType]) { result.computedFields[formType] = {}; } From 2a2c6a7a3b0194f10bc7f69a3dfd2ca4beeee1f7 Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 9 Feb 2026 20:05:14 +0300 Subject: [PATCH 4/4] refactor: remove computed fields engine --- formulus-formplayer/src/App.tsx | 66 ++----------------- .../src/services/ExtensionsLoader.ts | 11 ---- .../src/types/FormulusInterfaceDefinition.ts | 10 --- .../main/assets/formplayer_dist/index.html | 2 +- formulus/src/components/FormplayerModal.tsx | 6 +- formulus/src/services/ExtensionService.ts | 25 ------- 6 files changed, 7 insertions(+), 113 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a706df42d..f5dbcadf2 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -265,20 +265,13 @@ function App() { const [extensionRenderers, setExtensionRenderers] = useState< JsonFormsRendererRegistryEntry[] >([]); - // Store extension functions for computed fields and evaluation context + // Store extension functions for evaluation context const [extensionFunctions, setExtensionFunctions] = useState< Map >(new Map()); const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); - // Store computed field configurations keyed by formType - const [extensionComputedFields, setExtensionComputedFields] = useState< - Record< - string, - Record - > - >({}); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -343,18 +336,11 @@ function App() { setExtensionRenderers(extensionResult.renderers); setExtensionFunctions(allFunctions); setExtensionDefinitions(extensionResult.definitions); - setExtensionComputedFields(extensionResult.computedFields); console.log( '[Formplayer] Final extension functions:', Array.from(allFunctions.keys()), ); - if (Object.keys(extensionResult.computedFields).length > 0) { - console.log( - '[Formplayer] Computed fields registered:', - extensionResult.computedFields, - ); - } // Log errors but don't fail form initialization if (extensionResult.errors.length > 0) { @@ -366,14 +352,12 @@ function App() { setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); - setExtensionComputedFields({}); } } else { // No extensions provided, just use built-ins setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); - setExtensionComputedFields({}); console.log('[Formplayer] Using only built-in extensions'); } @@ -758,57 +742,17 @@ function App() { const handleDataChange = useCallback( ({ data }: { data: FormData }) => { - let finalData = data; - - // Process computed fields if available for this form type - const formType = formInitData?.formType; - const computedFieldConfigs = formType - ? extensionComputedFields[formType] - : undefined; - - if ( - computedFieldConfigs && - Object.keys(computedFieldConfigs).length > 0 - ) { - let hasComputedChanges = false; - const updatedData: Record = { ...data }; - - for (const [fieldName, config] of Object.entries( - computedFieldConfigs, - )) { - const fn = extensionFunctions.get(config.function); - if (typeof fn === 'function') { - try { - const computedValue = fn(data); - if (updatedData[fieldName] !== computedValue) { - updatedData[fieldName] = computedValue; - hasComputedChanges = true; - } - } catch (err) { - console.warn( - `[Formplayer] Error computing field "${fieldName}":`, - err, - ); - } - } - } - - if (hasComputedChanges) { - finalData = updatedData as FormData; - } - } - - setData(finalData); + setData(data); // Expose current form data globally for extension renderers - (window as any).formulusCurrentFormData = finalData; + (window as any).formulusCurrentFormData = data; // Save draft data whenever form data changes if (formInitData) { - draftService.saveDraft(formInitData.formType, finalData, formInitData); + draftService.saveDraft(formInitData.formType, data, formInitData); } }, - [formInitData, extensionComputedFields, extensionFunctions], + [formInitData], ); // Create AJV instance with extension definitions support diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index bb5c3a9eb..23b526158 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -8,14 +8,6 @@ import { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; import { ExtensionFunction } from '../FormEvaluationContext'; -/** - * Configuration for a single computed field - */ -export interface ComputedFieldConfig { - function: string; // Name of the extension function to call - dependencies?: string[]; // Optional: field names this computation depends on -} - /** * Extension metadata passed from Formulus */ @@ -23,7 +15,6 @@ export interface ExtensionMetadata { definitions?: Record; functions?: Record; renderers?: Record; - computedFields?: Record>; // formType -> fieldName -> config basePath?: string; // Base path for loading modules (e.g., file:///...) } @@ -56,7 +47,6 @@ export interface ExtensionLoadResult { renderers: JsonFormsRendererRegistryEntry[]; functions: Map; definitions: Record; - computedFields: Record>; // formType -> fieldName -> config errors: Array<{ type: string; message: string; details?: any }>; } @@ -70,7 +60,6 @@ export async function loadExtensions( renderers: [], functions: new Map(), definitions: metadata.definitions || {}, - computedFields: metadata.computedFields || {}, errors: [], }; diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 824f37c07..b28d4ffd6 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -34,16 +34,6 @@ export interface ExtensionMetadata { renderer?: string; } >; - computedFields?: Record< - string, // formType - Record< - string, // fieldName - { - function: string; // Name of extension function to call - dependencies?: string[]; // Optional: field names this computation depends on - } - > - >; basePath?: string; // Base path for loading modules } diff --git a/formulus/android/app/src/main/assets/formplayer_dist/index.html b/formulus/android/app/src/main/assets/formplayer_dist/index.html index 0ca64a7c6..36f735a42 100644 --- a/formulus/android/app/src/main/assets/formplayer_dist/index.html +++ b/formulus/android/app/src/main/assets/formplayer_dist/index.html @@ -11,7 +11,7 @@ Formulus Form Player - + diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 286d03054..2d5de46dc 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -228,9 +228,7 @@ const FormplayerModal = forwardRef( if ( mergedExtensions.definitions || mergedExtensions.functions || - mergedExtensions.renderers || - (mergedExtensions.computedFields && - Object.keys(mergedExtensions.computedFields).length > 0) + mergedExtensions.renderers ) { extensions = { definitions: mergedExtensions.definitions, @@ -262,8 +260,6 @@ const FormplayerModal = forwardRef( }, {} as Record, ), - // Pass computed fields through as-is - computedFields: mergedExtensions.computedFields, // Base path for loading modules (file:// URL for WebView) // Extensions are in the /forms directory basePath: `file://${customAppPath}/forms`, diff --git a/formulus/src/services/ExtensionService.ts b/formulus/src/services/ExtensionService.ts index 4ea9f5382..ac2ddf726 100644 --- a/formulus/src/services/ExtensionService.ts +++ b/formulus/src/services/ExtensionService.ts @@ -7,10 +7,6 @@ export interface ExtensionDefinition { definitions?: Record; // JSON Schema definitions for $ref functions?: Record; // Custom functions renderers?: Record; // Custom question type renderers - computedFields?: Record< - string, - Record - >; // formType -> fieldName -> config } /** @@ -54,10 +50,6 @@ export interface MergedExtensions { definitions: Record; functions: Record; renderers: Record; - computedFields: Record< - string, - Record - >; } /** @@ -99,7 +91,6 @@ export class ExtensionService { definitions: {}, functions: {}, renderers: {}, - computedFields: {}, }; console.log( @@ -230,10 +221,6 @@ export class ExtensionService { {} as Record, ) : undefined, - - // Pass through computedFields as-is (no normalization needed) - computedFields: - rawExtension.computedFields as ExtensionDefinition['computedFields'], }; console.log(`[ExtensionService] Normalized extension:`, { @@ -351,18 +338,6 @@ export class ExtensionService { if (extension.renderers) { Object.assign(result.renderers, extension.renderers); } - - // Merge computedFields (deep merge by formType) - if (extension.computedFields) { - for (const [formType, fields] of Object.entries( - extension.computedFields, - )) { - if (!result.computedFields[formType]) { - result.computedFields[formType] = {}; - } - Object.assign(result.computedFields[formType], fields); - } - } } /**