diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a7d387561..f5dbcadf2 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,11 +265,9 @@ 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 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 @@ -295,7 +296,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 +310,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 +327,20 @@ 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); - console.log('[Formplayer] Final extension functions:', Array.from(allFunctions.keys())); + console.log( + '[Formplayer] Final extension functions:', + Array.from(allFunctions.keys()), + ); // Log errors but don't fail form initialization if (extensionResult.errors.length > 0) { @@ -734,6 +744,9 @@ function App() { ({ data }: { data: FormData }) => { setData(data); + // Expose current form data globally for extension renderers + (window as any).formulusCurrentFormData = data; + // Save draft data whenever form data changes if (formInitData) { draftService.saveDraft(formInitData.formType, data, formInitData); @@ -747,6 +760,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 +850,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..23b526158 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -6,6 +6,7 @@ */ import { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; +import { ExtensionFunction } from '../FormEvaluationContext'; /** * Extension metadata passed from Formulus @@ -44,8 +45,7 @@ export interface LoadedRenderer { */ export interface ExtensionLoadResult { renderers: JsonFormsRendererRegistryEntry[]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - functions: Map; + functions: Map; definitions: Record; errors: Array<{ type: string; message: string; details?: any }>; } @@ -187,8 +187,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/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/services/ExtensionService.ts b/formulus/src/services/ExtensionService.ts index ba340c5ae..ac2ddf726 100644 --- a/formulus/src/services/ExtensionService.ts +++ b/formulus/src/services/ExtensionService.ts @@ -160,7 +160,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 +182,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 +198,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 || {}; @@ -242,7 +245,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;