From cd208e33a252e4ab9af36f242eebd4761df39914 Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 16 Feb 2026 20:12:57 +0300 Subject: [PATCH 1/2] feat(formplayer): add QrcodeQuestionRenderer for in-form QR code scanning --- formulus-formplayer/src/App.tsx | 34 +- .../src/renderers/QrcodeQuestionRenderer.tsx | 347 ++++++++++++++++++ 2 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index a7d387561..7a033b639 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -55,6 +55,9 @@ import GPSQuestionRenderer, { import VideoQuestionRenderer, { videoQuestionTester, } from './renderers/VideoQuestionRenderer'; +import QrcodeQuestionRenderer, { + qrcodeQuestionTester, +} from './renderers/QrcodeQuestionRenderer'; import HtmlLabelRenderer, { htmlLabelTester, } from './renderers/HtmlLabelRenderer'; @@ -215,6 +218,7 @@ export const customRenderers = [ { tester: audioQuestionTester, renderer: AudioQuestionRenderer }, { tester: gpsQuestionTester, renderer: GPSQuestionRenderer }, { tester: videoQuestionTester, renderer: VideoQuestionRenderer }, + { tester: qrcodeQuestionTester, renderer: QrcodeQuestionRenderer }, { tester: htmlLabelTester, renderer: HtmlLabelRenderer }, { tester: adateQuestionTester, renderer: AdateQuestionRenderer }, // Dynamic choice list renderer for x-dynamicEnum fields @@ -263,7 +267,7 @@ 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 @@ -295,7 +299,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 +313,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 +330,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) { @@ -836,10 +849,15 @@ function App() { p: 3, backgroundColor: 'background.paper', }}> - + Error Loading Form - + {loadError} diff --git a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx new file mode 100644 index 000000000..84019d80e --- /dev/null +++ b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx @@ -0,0 +1,347 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { + Typography, + Box, + CircularProgress, + IconButton, + TextField, + Button, + Paper, + Chip, +} from '@mui/material'; +import { + QrCodeScanner as QrCodeIcon, + Delete as DeleteIcon, + Refresh as RefreshIcon, + Edit as EditIcon, + Check as CheckIcon, + Close as CloseIcon, +} from '@mui/icons-material'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; +import FormulusClient from '../services/FormulusInterface'; +import { QrcodeResult } from '../types/FormulusInterfaceDefinition'; +import QuestionShell from '../components/QuestionShell'; + +/** + * Tester function — matches any schema field with "format": "qrcode". + * Priority 10 ensures this takes precedence over default string renderers. + * + * Schema usage: + * { "type": "string", "format": "qrcode", "title": "Scan QR code" } + * + * The scanned value is stored as a plain string in the form data. + */ +export const qrcodeQuestionTester = rankWith( + 10, // Same priority as signature renderer + formatIs('qrcode'), +); + +const QrcodeQuestionRenderer: React.FC = ({ + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, + visible = true, +}) => { + const [isScanning, setIsScanning] = useState(false); + const [error, setError] = useState(null); + const [showManualEntry, setShowManualEntry] = useState(false); + const [manualValue, setManualValue] = useState(''); + + const formulusClient = useRef(FormulusClient.getInstance()); + + // Extract field ID from path + const fieldId = path.replace(/\//g, '_').replace(/^_/, '') || 'qrcode_field'; + + // The stored data is a plain string (the QR code value) + const currentValue: string | null = + data && typeof data === 'string' ? data : null; + + // Handle QR code scan via Formulus native bridge + const handleScan = useCallback(async () => { + if (!enabled) return; + + setIsScanning(true); + setError(null); + + try { + console.log('Requesting QR code scanner for field:', fieldId); + const result: QrcodeResult = + await formulusClient.current.requestQrcode(fieldId); + + console.log('QR code result received:', result); + + if (result.status === 'success' && result.data) { + // Store the scanned value as a plain string + handleChange(path, result.data.value); + setShowManualEntry(false); + setManualValue(''); + setError(null); + console.log('QR code scanned successfully:', result.data.value); + } else { + const errorMessage = + result.message || `QR scanner operation ${result.status}`; + throw new Error(errorMessage); + } + } catch (err: any) { + console.error('Error during QR code scan:', err); + + if (err && typeof err === 'object' && 'status' in err) { + const qrError = err as QrcodeResult; + if (qrError.status === 'cancelled') { + // User cancelled — don't show error + console.log('QR scan cancelled by user'); + } else if (qrError.status === 'error') { + setError(qrError.message || 'QR scanner error'); + } else { + setError('Unknown QR scanner error'); + } + } else { + setError(err?.message || 'Failed to scan QR code. Try manual entry.'); + } + } finally { + setIsScanning(false); + } + }, [fieldId, enabled, handleChange, path]); + + // Handle manual entry submission + const handleManualSubmit = useCallback(() => { + if (!manualValue.trim()) return; + handleChange(path, manualValue.trim()); + setShowManualEntry(false); + setManualValue(''); + setError(null); + }, [manualValue, handleChange, path]); + + // Handle delete/clear + const handleDelete = useCallback(() => { + handleChange(path, undefined); + setError(null); + setManualValue(''); + console.log('QR code value cleared for field:', fieldId); + }, [fieldId, handleChange, path]); + + // Don't render if not visible + if (!visible) { + return null; + } + + const label = (uischema as any)?.label || schema.title || 'QR Code'; + const description = schema.description; + const isRequired = Boolean( + (uischema as any)?.options?.required ?? + (schema as any)?.options?.required ?? + false, + ); + + const validationError = errors && errors.length > 0 ? String(errors) : null; + + return ( + + + Debug: fieldId="{fieldId}", path="{path}", format="qrcode", + value="{currentValue || 'empty'}" + + + ) : undefined + }> + {/* State: Value already scanned/entered */} + {currentValue ? ( + + theme.palette.mode === 'dark' + ? 'rgba(46, 125, 50, 0.08)' + : 'rgba(46, 125, 50, 0.04)', + }}> + + + } + label={currentValue} + color="success" + variant="outlined" + sx={{ + maxWidth: '100%', + height: 'auto', + '& .MuiChip-label': { + whiteSpace: 'normal', + wordBreak: 'break-all', + py: 0.5, + fontSize: '0.95rem', + fontFamily: 'monospace', + }, + }} + /> + + + + + + + + + + + + ) : ( + <> + {/* State: No value — show scan button */} + {!showManualEntry && ( + + + {isScanning ? ( + + ) : ( + + )} + + + {isScanning ? 'Opening scanner...' : 'Tap to scan QR code'} + + + {/* Manual entry link */} + + + )} + + {/* State: Manual entry mode */} + {showManualEntry && ( + + setManualValue(e.target.value)} + placeholder="Enter QR code value..." + variant="outlined" + size="small" + fullWidth + disabled={!enabled} + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') { + handleManualSubmit(); + } + }} + InputProps={{ + sx: { fontFamily: 'monospace' }, + }} + /> + + + + + + {/* Still allow scanning from manual entry mode */} + + + )} + + )} + + ); +}; + +export default withJsonFormsControlProps(QrcodeQuestionRenderer); From 146a06d8e4cdd2c77b4c345d2919175df3a7644c Mon Sep 17 00:00:00 2001 From: Najuna Date: Mon, 16 Feb 2026 22:42:04 +0300 Subject: [PATCH 2/2] fix(formplayer): resolve pre-existing lint errors for CI --- formulus-formplayer/src/App.tsx | 2 +- .../src/DynamicEnumControl.tsx | 95 +++-- .../src/FormEvaluationContext.tsx | 9 +- formulus-formplayer/src/builtinExtensions.ts | 363 ++++++++++-------- 4 files changed, 279 insertions(+), 190 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 7a033b639..e6e842761 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -267,7 +267,7 @@ function App() { JsonFormsRendererRegistryEntry[] >([]); // Store extension functions for potential future use (e.g., validation context injection) - + const [extensionFunctions, setExtensionFunctions] = useState< // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type Map 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..de2723905 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. @@ -17,6 +17,7 @@ export interface FormEvaluationContextValue { * Key: function name (e.g., "getDynamicChoiceList") * Value: the actual function */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type functions: Map; } @@ -30,9 +31,8 @@ const defaultContextValue: FormEvaluationContextValue = { /** * Form evaluation context */ -const FormEvaluationContext = createContext( - defaultContextValue, -); +const FormEvaluationContext = + createContext(defaultContextValue); /** * Hook to access form evaluation context @@ -49,6 +49,7 @@ export interface FormEvaluationProviderProps { /** * Map of extension functions to provide */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type functions: Map; /** * Child components diff --git a/formulus-formplayer/src/builtinExtensions.ts b/formulus-formplayer/src/builtinExtensions.ts index 41177eb2a..bfbb0ce20 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) { @@ -358,7 +411,9 @@ export async function getDynamicChoiceList( * Get all built-in extension functions as a Map * @returns Map of function name to function */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function getBuiltinExtensions(): Map { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const functions = new Map(); functions.set('getDynamicChoiceList', getDynamicChoiceList); return functions;