diff --git a/EXTENSION_IMPLEMENTATION.md b/EXTENSION_IMPLEMENTATION.md index 3cc845ae3..abafa82b4 100644 --- a/EXTENSION_IMPLEMENTATION.md +++ b/EXTENSION_IMPLEMENTATION.md @@ -186,6 +186,76 @@ Corresponding UI schema: } ``` +## Ranking Renderer (AnthroCollect) + +Custom renderers that need the Formulus API and form parameters (e.g., person scope, age filters) can use the `FormContext` provided by the formplayer. + +### ext.json for ranking renderer + +```json +{ + "renderers": { + "ranking": { + "name": "RankingRenderer", + "format": "ranking", + "module": "extensions/renderers/RankingRenderer.jsx", + "tester": "rankingTester", + "renderer": "RankingRenderer", + "testerModule": "extensions/testers/rankingTester.js" + } + } +} +``` + +- **module**: Path to renderer (relative to app `forms/` directory) +- **testerModule**: Optional. Path to module containing the tester when it lives in a separate file. The tester matches when `uischema.options.renderer === 'ranking'`. + +### FormContext API for custom renderers + +Custom renderers receive JsonForms props (data, handleChange, path, uischema, schema, label, visible). For Formulus-specific data, use the FormContext exposed on `window.__formplayerFormContext`: + +```tsx +import React from 'react'; + +function RankingRenderer(props) { + const FormContext = window.__formplayerFormContext; + const { formulusApi, formParams } = FormContext + ? React.useContext(FormContext) + : { formulusApi: null, formParams: {} }; + // formulusApi: FormulusInterface for anthroData.getPersonsByScopeAndFilter(formulusApi, ...) + // formParams: { p_id, scope, age_min, age_max, ... } from openFormplayer(formType, params, savedData) +} +``` + +- **formulusApi**: The Formulus API instance (e.g., for `getObservationsByQuery`, etc.). Available after the formplayer loads. +- **formParams**: Parameters passed when opening the form via `openFormplayer(formType, params, savedData)`. Typically includes `p_id`, `scope`, `age_min`, `age_max` for person filtering. + +### UI schema for ranking control + +```json +{ + "type": "Control", + "scope": "#/properties/demo_ranking", + "options": { + "renderer": "ranking", + "sexFilter": "female" + } +} +``` + +### Form params when opening ranking forms + +When opening `p_ranking_female` or `p_ranking_male`, pass params so the ranking renderer can filter persons: + +```javascript +openFormplayer('p_ranking_female', { + p_id: 'person-uuid-or-focal', + scope: 'household', + age_min: 18, + age_max: 65 +}, savedData); +``` + ## Assumptions 1. Extension modules use ES6 module syntax (`import`/`export`) diff --git a/formulus-formplayer/eslint.config.js b/formulus-formplayer/eslint.config.js index 9ac954601..ed5192640 100644 --- a/formulus-formplayer/eslint.config.js +++ b/formulus-formplayer/eslint.config.js @@ -13,6 +13,7 @@ export default defineConfig([ '**/coverage/**', '**/__tests__/**', '**/scripts/**', + '**/prettier.config.cjs', ]), js.configs.recommended, ...tseslint.configs.recommended, diff --git a/formulus-formplayer/package-lock.json b/formulus-formplayer/package-lock.json index 22df5869a..bb3f2d922 100644 --- a/formulus-formplayer/package-lock.json +++ b/formulus-formplayer/package-lock.json @@ -2274,19 +2274,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/eslint": { - "version": "8.56.12", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", - "integrity": "sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5168,18 +5155,6 @@ "node": ">= 0.4" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6922,24 +6897,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/formulus-formplayer/prettier.config.js b/formulus-formplayer/prettier.config.cjs similarity index 61% rename from formulus-formplayer/prettier.config.js rename to formulus-formplayer/prettier.config.cjs index ff4847fd8..c0a2753b3 100644 --- a/formulus-formplayer/prettier.config.js +++ b/formulus-formplayer/prettier.config.cjs @@ -1,14 +1,11 @@ /** * @see https://prettier.io/docs/configuration - * @type {import("prettier").Config} + * Uses .cjs so Prettier receives plain config (avoids ESM default-export wrapper warnings) */ - -const config = { +module.exports = { arrowParens: 'avoid', bracketSameLine: true, bracketSpacing: true, singleQuote: true, trailingComma: 'all', }; - -export default config; diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a7d387561..5e2dcdf1c 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -30,7 +30,10 @@ import addFormats from 'ajv-formats'; // Import the FormulusInterface client import FormulusClient from './services/FormulusInterface'; -import { FormInitData } from './types/FormulusInterfaceDefinition'; +import { + FormInitData, + FormulusInterface, +} from './types/FormulusInterfaceDefinition'; import SwipeLayoutRenderer, { swipeLayoutTester, @@ -69,7 +72,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, + type ExtensionFunction, +} from './FormEvaluationContext'; // Import development dependencies (Vite will tree-shake these in production) import { webViewMock } from './mocks/webview-mock'; @@ -194,17 +200,30 @@ const processUISchemaWithFinalize = ( // Interface for the data structure passed to window.onFormInit // Removed local definition, importing from FormulusInterfaceDefinition.ts -// Create context for sharing form metadata with renderers -interface FormContextType { +// Create context for sharing form metadata and API with renderers +export interface FormContextType { formInitData: FormInitData | null; + /** Formulus API for custom renderers (e.g. RankingRenderer) - available after formplayer loads */ + formulusApi: FormulusInterface | null; + /** Form params passed when opening the form (p_id, scope, age_min, age_max, etc.) */ + formParams: Record; } export const FormContext = createContext({ formInitData: null, + formulusApi: null, + formParams: {}, }); export const useFormContext = () => useContext(FormContext); +// Expose FormContext globally so extension renderers (loaded from app bundle) can access it +if (typeof window !== 'undefined') { + ( + window as Window & { __formplayerFormContext?: typeof FormContext } + ).__formplayerFormContext = FormContext; +} + export const customRenderers = [ { tester: swipeLayoutTester, renderer: SwipeLayoutRenderer }, { tester: groupAsSwipeLayoutTester, renderer: SwipeLayoutRenderer }, @@ -263,14 +282,15 @@ function App() { JsonFormsRendererRegistryEntry[] >([]); // Store extension functions for potential future use (e.g., validation context injection) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [extensionFunctions, setExtensionFunctions] = useState< - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - Map + Map >(new Map()); const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); + const [formulusApi, setFormulusApi] = useState( + null, + ); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -295,7 +315,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 +329,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 +346,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); + allFunctions.set(name, func as ExtensionFunction); }); - + 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) { @@ -504,6 +533,24 @@ function App() { [initializeForm], ); + // Effect to fetch formulusApi for custom renderers (RankingRenderer, etc.) + useEffect(() => { + const loadFormulusApi = async () => { + const win = window as Window & { + getFormulus?: () => Promise; + }; + if (typeof win.getFormulus === 'function') { + try { + const api = await win.getFormulus(); + setFormulusApi(api); + } catch (err) { + console.warn('[Formplayer] Failed to load formulusApi:', err); + } + } + }; + loadFormulusApi(); + }, []); + // Effect for initializing form via window.onFormInit useEffect(() => { // Ensure we only register onFormInit and signal readiness once per WebView lifecycle @@ -836,10 +883,15 @@ function App() { p: 3, backgroundColor: 'background.paper', }}> - + Error Loading Form - + {loadError} @@ -880,7 +932,12 @@ function App() { return ( - +
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,26 +127,31 @@ 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 || {}; + const currentFormData = useMemo( + () => ctx?.core?.data || {}, + [ctx?.core?.data], + ); // Handle value change - must be defined before any early returns const handleValueChange = useCallback( (_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 +161,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 +191,9 @@ 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 +204,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 +222,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, @@ -219,17 +252,20 @@ const DynamicEnumControl: React.FC = ({ // Load choices on mount, when config changes, and when form data changes (for cascading filters) // currentFormData must be in deps so fields that use {{data.field}} templates reload // when the user selects a value in a dependent field (e.g. sex -> filter person list) + const dynamicConfigQuery = dynamicConfig?.query; + const dynamicConfigParamsStr = JSON.stringify(dynamicConfig?.params); + const currentFormDataStr = JSON.stringify(currentFormData); useEffect(() => { if (dynamicConfig && visible && enabled) { loadChoices(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - dynamicConfig?.query, - JSON.stringify(dynamicConfig?.params), + dynamicConfigQuery, + dynamicConfigParamsStr, visible, enabled, - JSON.stringify(currentFormData), + currentFormDataStr, ]); // Early returns after all hooks @@ -260,14 +296,14 @@ const DynamicEnumControl: React.FC = ({ {label} {schema.required && *} - + {/* Description */} {description && ( {description} )} - + {/* Validation Errors */} {hasValidationErrors && ( @@ -291,8 +327,7 @@ const DynamicEnumControl: React.FC = ({ variant="body2" color="primary" sx={{ cursor: 'pointer', textDecoration: 'underline' }} - onClick={loadChoices} - > + onClick={loadChoices}> Retry @@ -305,15 +340,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..1aaffe31f 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. @@ -8,6 +8,9 @@ import React, { createContext, useContext, ReactNode } from 'react'; +/** Extension function type - accepts any callable (builtins, loaded extensions) */ +export type ExtensionFunction = (...args: any[]) => any; + /** * Context value for form evaluation */ @@ -17,22 +20,21 @@ export interface FormEvaluationContextValue { * Key: function name (e.g., "getDynamicChoiceList") * Value: the actual function */ - functions: Map; + functions: Map; } /** * Default context value (empty functions map) */ const defaultContextValue: FormEvaluationContextValue = { - functions: new Map(), + functions: new Map(), }; /** * Form evaluation context */ -const FormEvaluationContext = createContext( - defaultContextValue, -); +const FormEvaluationContext = + createContext(defaultContextValue); /** * Hook to access form evaluation context @@ -49,7 +51,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..e74030404 100644 --- a/formulus-formplayer/src/builtinExtensions.ts +++ b/formulus-formplayer/src/builtinExtensions.ts @@ -1,6 +1,6 @@ /** * builtinExtensions.ts - * + * * Built-in extension functions that are always available in Formplayer. * These provide core functionality for dynamic choice lists and other features. */ @@ -26,27 +26,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 +65,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 +79,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 +116,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 +125,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 +174,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 +184,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 +385,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) { @@ -354,12 +407,19 @@ export async function getDynamicChoiceList( } } +/** Extension function type */ +type BuiltinExtensionFn = ( + queryName: string, + params: Record, + formData?: Record, +) => Promise>; + /** * 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/components/FormLayout.tsx b/formulus-formplayer/src/components/FormLayout.tsx index 18cdbe34e..4e550ba56 100644 --- a/formulus-formplayer/src/components/FormLayout.tsx +++ b/formulus-formplayer/src/components/FormLayout.tsx @@ -142,9 +142,15 @@ const FormLayout: React.FC = ({ (previousButton || nextButton) && !isKeyboardVisible ? { - xs: `calc(${theme.spacing(11)} + env(safe-area-inset-bottom, 0px))`, - sm: `calc(${theme.spacing(12)} + env(safe-area-inset-bottom, 0px))`, - md: `calc(${theme.spacing(13)} + env(safe-area-inset-bottom, 0px))`, + xs: `calc(${theme.spacing( + 11, + )} + env(safe-area-inset-bottom, 0px))`, + sm: `calc(${theme.spacing( + 12, + )} + env(safe-area-inset-bottom, 0px))`, + md: `calc(${theme.spacing( + 13, + )} + env(safe-area-inset-bottom, 0px))`, } : theme.spacing(15), overscrollBehavior: 'contain', @@ -171,9 +177,15 @@ const FormLayout: React.FC = ({ md: theme.spacing(1.5, 2.5), }, paddingBottom: { - xs: `calc(${theme.spacing(1)} + env(safe-area-inset-bottom, 0px))`, - sm: `calc(${theme.spacing(1.5)} + env(safe-area-inset-bottom, 0px))`, - md: `calc(${theme.spacing(1.5)} + env(safe-area-inset-bottom, 0px))`, + xs: `calc(${theme.spacing( + 1, + )} + env(safe-area-inset-bottom, 0px))`, + sm: `calc(${theme.spacing( + 1.5, + )} + env(safe-area-inset-bottom, 0px))`, + md: `calc(${theme.spacing( + 1.5, + )} + env(safe-area-inset-bottom, 0px))`, }, backgroundColor: 'background.paper', borderTop: 'none', diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index aacd992ac..72cac40a3 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -85,7 +85,9 @@ const FinalizeRenderer = ({ data }: ControlProps) => { return 'Audio recorded'; case 'gps': if (typeof value === 'object' && value.latitude && value.longitude) { - return `Location: ${value.latitude.toFixed(6)}, ${value.longitude.toFixed(6)}`; + return `Location: ${value.latitude.toFixed( + 6, + )}, ${value.longitude.toFixed(6)}`; } return 'GPS location captured'; case 'video': diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 03efd0c75..67ce757cb 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -201,7 +201,9 @@ const SwipeLayoutRenderer = ({ if (missingFields.length > 0) { const message = `Missing required ${ missingFields.length === 1 ? 'field' : 'fields' - }: ${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`; + }: ${missingFields.slice(0, 2).join(', ')}${ + missingFields.length > 2 ? '...' : '' + }`; setPendingNavigation(newPage); setSnackbarMessage(message); diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index 184cb5807..66c561d3a 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -29,6 +29,8 @@ export interface ExtensionRendererMetadata { module: string; tester?: string; renderer?: string; + /** Optional path to module containing tester (when tester is in a separate file) */ + testerModule?: string; } /** @@ -152,12 +154,36 @@ async function loadRenderer( ); } - // Get tester function + // Get tester function - from testerModule if specified, otherwise from renderer module const testerName = metadata.tester || `${metadata.name}Tester`; - const tester = module[testerName] || module.default?.tester; + let tester = module[testerName] || module.default?.tester; + if (!tester && metadata.testerModule) { + const testerModulePath = basePath + ? `${basePath}/${metadata.testerModule}` + : metadata.testerModule; + let testerModule: any; + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Dynamic import path is intentionally variable for runtime loading + testerModule = await import( + /* @vite-ignore */ /* webpackIgnore: true */ testerModulePath + ); + } catch { + const pathWithExt = testerModulePath.endsWith('.js') + ? testerModulePath + : `${testerModulePath}.js`; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + testerModule = await import( + /* @vite-ignore */ /* webpackIgnore: true */ pathWithExt + ); + } + tester = testerModule[testerName] || testerModule.default; + } if (!tester) { throw new Error( - `Tester function "${testerName}" not found in module ${metadata.module}`, + `Tester function "${testerName}" not found in module ${metadata.module}` + + (metadata.testerModule ? ` or ${metadata.testerModule}` : ''), ); } diff --git a/formulus-formplayer/src/theme/theme.ts b/formulus-formplayer/src/theme/theme.ts index 980107f7c..c99f582a3 100644 --- a/formulus-formplayer/src/theme/theme.ts +++ b/formulus-formplayer/src/theme/theme.ts @@ -172,7 +172,9 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => { styleOverrides: { root: { borderRadius: tokens.border.radius.full, // Fully rounded buttons (pill shape) - padding: `${parsePx(tokens.spacing[3])}px ${parsePx(tokens.spacing[6])}px`, // 12px 24px + padding: `${parsePx(tokens.spacing[3])}px ${parsePx( + tokens.spacing[6], + )}px`, // 12px 24px minHeight: `${tokens.touchTarget.comfortable}px`, // 48px - from tokens fontSize: parsePx(tokens.typography.fontSize.sm), fontWeight: tokens.typography.fontWeight.medium, @@ -216,12 +218,16 @@ export const getThemeOptions = (mode: 'light' | 'dark'): ThemeOptions => { }, sizeSmall: { minHeight: `${tokens.touchTarget.comfortable}px`, // Still maintain 48dp for accessibility - padding: `${parsePx(tokens.spacing[3])}px ${parsePx(tokens.spacing[4])}px`, + padding: `${parsePx(tokens.spacing[3])}px ${parsePx( + tokens.spacing[4], + )}px`, fontSize: parsePx(tokens.typography.fontSize.sm), }, sizeLarge: { minHeight: `${tokens.touchTarget.large}px`, // 56px - padding: `${parsePx(tokens.spacing[4])}px ${parsePx(tokens.spacing[8])}px`, + padding: `${parsePx(tokens.spacing[4])}px ${parsePx( + tokens.spacing[8], + )}px`, fontSize: parsePx(tokens.typography.fontSize.base), }, }, diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index b28d4ffd6..6bd58b291 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -32,6 +32,8 @@ export interface ExtensionMetadata { module: string; tester?: string; renderer?: string; + /** Optional path to module containing tester (when tester is in a separate file) */ + testerModule?: string; } >; basePath?: string; // Base path for loading modules diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 2d5de46dc..6a6d018ea 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -249,13 +249,19 @@ const FormplayerModal = forwardRef( (acc, [key, renderer]) => { // Remove leading slash from module path to avoid double-slash in URL const modulePath = (renderer.module || '').replace(/^\/+/, ''); - acc[key] = { + const entry: Record = { name: renderer.name, format: renderer.format, module: modulePath, tester: renderer.tester, renderer: renderer.renderer, }; + if (renderer.testerModule) { + entry.testerModule = ( + renderer.testerModule as string + ).replace(/^\/+/, ''); + } + acc[key] = entry; return acc; }, {} as Record, diff --git a/formulus/src/services/ExtensionService.ts b/formulus/src/services/ExtensionService.ts index ba340c5ae..ef09ad87c 100644 --- a/formulus/src/services/ExtensionService.ts +++ b/formulus/src/services/ExtensionService.ts @@ -39,6 +39,8 @@ export interface ExtensionRenderer { // Export names tester?: string; // Tester function export name renderer?: string; // Renderer component export name (defaults to name) + /** Path to module containing tester (when tester is in a separate file) */ + testerModule?: string; // Optional dependencies dependencies?: string[]; } @@ -199,8 +201,20 @@ export class ExtensionService { (acc, [key, renderer]: [string, Record]) => { // Handle both flat structure and nested structure const rendererObj = renderer.renderer || renderer; - const testerObj = renderer.tester || {}; - + const testerObj = + typeof renderer.tester === 'object' + ? renderer.tester || {} + : {}; + + const testerModulePath = + (testerObj as { path?: string; module?: string })?.path || + (testerObj as { path?: string; module?: string })?.module || + (renderer as { testerModule?: string }).testerModule; + const testerExport = + (testerObj as { export?: string })?.export ?? + (typeof renderer.tester === 'string' + ? renderer.tester + : undefined); acc[key] = { name: key, format: renderer.format || rendererObj?.format || '', @@ -209,9 +223,10 @@ export class ExtensionService { rendererObj?.module || renderer.module || '', - tester: testerObj?.export || renderer.tester?.export, + tester: testerExport || `${key}Tester`, renderer: rendererObj?.export || renderer.renderer?.export || key, + ...(testerModulePath && { testerModule: testerModulePath }), }; return acc; }, diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index b28d4ffd6..6bd58b291 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -32,6 +32,8 @@ export interface ExtensionMetadata { module: string; tester?: string; renderer?: string; + /** Optional path to module containing tester (when tester is in a separate file) */ + testerModule?: string; } >; basePath?: string; // Base path for loading modules