From 5db75fa9cbfe7e626a52314cf4fa8b910a673419 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 14:37:43 +0300 Subject: [PATCH 1/9] feat(formulus): support AnthroCollect ranking renderer --- EXTENSION_IMPLEMENTATION.md | 70 +++ formulus-formplayer/src/App.tsx | 483 ++++++++++-------- .../src/FormEvaluationContext.tsx | 9 +- .../src/services/ExtensionsLoader.ts | 58 ++- formulus/src/components/FormplayerModal.tsx | 8 +- formulus/src/services/ExtensionService.ts | 21 +- .../webview/FormulusInterfaceDefinition.ts | 2 + 7 files changed, 419 insertions(+), 232 deletions(-) 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/src/App.tsx b/formulus-formplayer/src/App.tsx index a7d387561..c0c46360e 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -7,14 +7,14 @@ import React, { createContext, useContext, useMemo, -} from 'react'; -import './App.css'; -import { JsonForms } from '@jsonforms/react'; +} from "react"; +import "./App.css"; +import { JsonForms } from "@jsonforms/react"; import { materialRenderers, materialCells, -} from '@jsonforms/material-renderers'; -import { JsonSchema7, JsonFormsRendererRegistryEntry } from '@jsonforms/core'; +} from "@jsonforms/material-renderers"; +import { JsonSchema7, JsonFormsRendererRegistryEntry } from "@jsonforms/core"; import { Alert, Snackbar, @@ -22,62 +22,65 @@ import { Box, Typography, ThemeProvider, -} from '@mui/material'; -import { createTheme, getThemeOptions } from './theme/theme'; -import Ajv from 'ajv'; -import addErrors from 'ajv-errors'; -import addFormats from 'ajv-formats'; +} from "@mui/material"; +import { createTheme, getThemeOptions } from "./theme/theme"; +import Ajv from "ajv"; +import addErrors from "ajv-errors"; +import addFormats from "ajv-formats"; // Import the FormulusInterface client -import FormulusClient from './services/FormulusInterface'; -import { FormInitData } from './types/FormulusInterfaceDefinition'; +import FormulusClient from "./services/FormulusInterface"; +import { + FormInitData, + FormulusInterface, +} from "./types/FormulusInterfaceDefinition"; import SwipeLayoutRenderer, { swipeLayoutTester, groupAsSwipeLayoutTester, -} from './renderers/SwipeLayoutRenderer'; -import { finalizeRenderer, finalizeTester } from './renderers/FinalizeRenderer'; +} from "./renderers/SwipeLayoutRenderer"; +import { finalizeRenderer, finalizeTester } from "./renderers/FinalizeRenderer"; import PhotoQuestionRenderer, { photoQuestionTester, -} from './renderers/PhotoQuestionRenderer'; +} from "./renderers/PhotoQuestionRenderer"; import SignatureQuestionRenderer, { signatureQuestionTester, -} from './renderers/SignatureQuestionRenderer'; +} from "./renderers/SignatureQuestionRenderer"; import FileQuestionRenderer, { fileQuestionTester, -} from './renderers/FileQuestionRenderer'; +} from "./renderers/FileQuestionRenderer"; import AudioQuestionRenderer, { audioQuestionTester, -} from './renderers/AudioQuestionRenderer'; +} from "./renderers/AudioQuestionRenderer"; import GPSQuestionRenderer, { gpsQuestionTester, -} from './renderers/GPSQuestionRenderer'; +} from "./renderers/GPSQuestionRenderer"; import VideoQuestionRenderer, { videoQuestionTester, -} from './renderers/VideoQuestionRenderer'; +} from "./renderers/VideoQuestionRenderer"; import HtmlLabelRenderer, { htmlLabelTester, -} from './renderers/HtmlLabelRenderer'; +} from "./renderers/HtmlLabelRenderer"; import AdateQuestionRenderer, { adateQuestionTester, -} from './renderers/AdateQuestionRenderer'; -import { shellMaterialRenderers } from './theme/material-wrappers'; -import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; +} from "./renderers/AdateQuestionRenderer"; +import { shellMaterialRenderers } from "./theme/material-wrappers"; +import DynamicEnumControl, { dynamicEnumTester } from "./DynamicEnumControl"; -import ErrorBoundary from './components/ErrorBoundary'; -import { draftService } from './services/DraftService'; -import DraftSelector from './components/DraftSelector'; -import { loadExtensions } from './services/ExtensionsLoader'; -import { getBuiltinExtensions } from './builtinExtensions'; -import { FormEvaluationProvider } from './FormEvaluationContext'; +import ErrorBoundary from "./components/ErrorBoundary"; +import { draftService } from "./services/DraftService"; +import DraftSelector from "./components/DraftSelector"; +import { loadExtensions } from "./services/ExtensionsLoader"; +import { getBuiltinExtensions } from "./builtinExtensions"; +import { FormEvaluationProvider } from "./FormEvaluationContext"; // Import development dependencies (Vite will tree-shake these in production) -import { webViewMock } from './mocks/webview-mock'; -import DevTestbed from './mocks/DevTestbed'; +import { webViewMock } from "./mocks/webview-mock"; +import DevTestbed from "./mocks/DevTestbed"; // Initialize the mock in development mode (synchronously) if (import.meta.env.DEV) { - console.log('[App] Initializing WebView mock for development'); + console.log("[App] Initializing WebView mock for development"); webViewMock.init(); } @@ -102,59 +105,59 @@ const ensureSwipeLayoutRoot = (uiSchema: FormUISchema | null): FormUISchema => { if (!uiSchema) { // If no UI schema, create a basic SwipeLayout with empty elements return { - type: 'SwipeLayout', + type: "SwipeLayout", elements: [], }; } // If root is already SwipeLayout, return as is - if (uiSchema.type === 'SwipeLayout') { + if (uiSchema.type === "SwipeLayout") { return { ...uiSchema }; } // If root is not SwipeLayout, wrap the entire schema in a SwipeLayout if ( - uiSchema.type === 'Group' || - uiSchema.type === 'VerticalLayout' || - uiSchema.type === 'HorizontalLayout' || + uiSchema.type === "Group" || + uiSchema.type === "VerticalLayout" || + uiSchema.type === "HorizontalLayout" || uiSchema.elements ) { console.log( - `Root UI schema type is "${uiSchema.type}", wrapping in SwipeLayout`, + `Root UI schema type is "${uiSchema.type}", wrapping in SwipeLayout` ); return { - type: 'SwipeLayout', + type: "SwipeLayout", elements: [uiSchema], }; } // If there are multiple root elements (array), wrap them in SwipeLayout if (Array.isArray(uiSchema)) { - console.log('Multiple root elements detected, wrapping in SwipeLayout'); + console.log("Multiple root elements detected, wrapping in SwipeLayout"); return { - type: 'SwipeLayout', + type: "SwipeLayout", elements: uiSchema, }; } // Fallback: create SwipeLayout with the original schema as a single element return { - type: 'SwipeLayout', + type: "SwipeLayout", elements: [uiSchema], }; }; // Function to process UI schema and ensure Finalize element is present const processUISchemaWithFinalize = ( - uiSchema: FormUISchema | null, + uiSchema: FormUISchema | null ): FormUISchema => { if (!uiSchema || !uiSchema.elements) { // If no UI schema or no elements, create a basic one with just Finalize return { - type: 'VerticalLayout', + type: "VerticalLayout", elements: [ { - type: 'Finalize', + type: "Finalize", }, ], }; @@ -167,24 +170,24 @@ const processUISchemaWithFinalize = ( // Check for existing Finalize elements and remove them const existingFinalizeIndices: number[] = []; elements.forEach((element, index) => { - if (element && element.type === 'Finalize') { + if (element && element.type === "Finalize") { existingFinalizeIndices.push(index); } }); if (existingFinalizeIndices.length > 0) { console.warn( - `Found ${existingFinalizeIndices.length} existing Finalize element(s) in UI schema. Removing them as they will be automatically added.`, + `Found ${existingFinalizeIndices.length} existing Finalize element(s) in UI schema. Removing them as they will be automatically added.` ); // Remove existing Finalize elements (in reverse order to maintain indices) - existingFinalizeIndices.reverse().forEach(index => { + existingFinalizeIndices.reverse().forEach((index) => { elements.splice(index, 1); }); } // Always add our Finalize element as the last element elements.push({ - type: 'Finalize', + type: "Finalize", }); processedUISchema.elements = elements; @@ -194,17 +197,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 }, @@ -224,17 +240,17 @@ export const customRenderers = [ function App() { // Initialize WebView mock ONLY in development mode and ONLY if ReactNativeWebView doesn't exist if ( - process.env.NODE_ENV === 'development' && + process.env.NODE_ENV === "development" && webViewMock && !window.ReactNativeWebView ) { console.log( - 'Development mode detected and no ReactNativeWebView found, initializing WebView mock...', + "Development mode detected and no ReactNativeWebView found, initializing WebView mock..." ); webViewMock.init(); console.log( - 'WebView mock initialized, isActive:', - webViewMock.isActiveMock(), + "WebView mock initialized, isActive:", + webViewMock.isActiveMock() ); } /* else if (process.env.NODE_ENV !== 'development') { console.log('Production mode detected, NOT initializing WebView mock'); @@ -256,7 +272,7 @@ function App() { const [formInitData, setFormInitData] = useState(null); const [showDraftSelector, setShowDraftSelector] = useState(false); const [pendingFormInit, setPendingFormInit] = useState( - null, + null ); const [darkMode, setDarkMode] = useState(false); const [extensionRenderers, setExtensionRenderers] = useState< @@ -271,6 +287,9 @@ function App() { const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); + const [formulusApi, setFormulusApi] = useState( + null + ); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -295,10 +314,12 @@ 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', { + console.log("[Formplayer] Form init received", { formType: receivedFormType, hasSchema: !!formSchema, hasUISchema: !!uiSchema, @@ -306,7 +327,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,24 +344,27 @@ function App() { if (extensions) { try { const extensionResult = await loadExtensions(extensions); - + // Merge loaded functions with built-ins (loaded functions take precedence) extensionResult.functions.forEach((func, name) => { allFunctions.set(name, func); }); - + setExtensionRenderers(extensionResult.renderers); setExtensionFunctions(allFunctions); setExtensionDefinitions(extensionResult.definitions); - 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) { - console.warn('Extension loading errors:', extensionResult.errors); + console.warn("Extension loading errors:", extensionResult.errors); } } catch (error) { - console.error('Failed to load extensions:', error); + console.error("Failed to load extensions:", error); // Still use built-in functions even if loading fails setExtensionRenderers([]); setExtensionFunctions(allFunctions); @@ -348,15 +375,15 @@ function App() { setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); - console.log('[Formplayer] Using only built-in extensions'); + console.log("[Formplayer] Using only built-in extensions"); } if (!formSchema) { console.warn( - 'formSchema was not provided. Form rendering might fail or be incomplete.', + "formSchema was not provided. Form rendering might fail or be incomplete." ); setLoadError( - 'Form schema is missing. Form rendering might fail or be incomplete.', + "Form schema is missing. Form rendering might fail or be incomplete." ); setSchema({} as FormSchema); // Set to empty schema or handle as per requirements // First ensure SwipeLayout root, then process to ensure Finalize element is present @@ -368,7 +395,7 @@ function App() { setSchema(formSchema as FormSchema); // First ensure SwipeLayout root, then process to ensure Finalize element is present const swipeLayoutUISchema = ensureSwipeLayoutRoot( - uiSchema as FormUISchema, + uiSchema as FormUISchema ); const processedUISchema = processUISchemaWithFinalize(swipeLayoutUISchema); @@ -376,18 +403,18 @@ function App() { } if (savedData && Object.keys(savedData).length > 0) { - console.log('Preloading saved data:', savedData); + console.log("Preloading saved data:", savedData); setData(savedData as FormData); } else { const defaultData = - params && typeof params === 'object' - ? (params.defaultData ?? params) + params && typeof params === "object" + ? params.defaultData ?? params : {}; - console.log('Preloading initialization form values:', defaultData); + console.log("Preloading initialization form values:", defaultData); setData(defaultData as FormData); } - console.log('Form params (if any, beyond schemas/data):', params); + console.log("Form params (if any, beyond schemas/data):", params); setLoadError(null); // Clear any previous load errors if ( @@ -396,20 +423,20 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'formplayerInitialized', + type: "formplayerInitialized", formType: receivedFormType, - status: 'success', - }), + status: "success", + }) ); } setIsLoading(false); isLoadingRef.current = false; } catch (error) { - console.error('Error initializing form:', error); + console.error("Error initializing form:", error); const errorMessage = error instanceof Error ? error.message - : 'Unknown error during form initialization'; + : "Unknown error during form initialization"; setLoadError(`Error initializing form: ${errorMessage}`); setIsLoading(false); isLoadingRef.current = false; @@ -422,32 +449,32 @@ function App() { setData, setLoadError, setIsLoading, - ], + ] ); // isLoadingRef is a ref, not needed in deps // Handler for data received via window.onFormInit const handleFormInitByNative = useCallback( (initData: FormInitData) => { - console.log('Received onFormInit event with data:', initData); + console.log("Received onFormInit event with data:", initData); try { const { formType: receivedFormType, savedData, formSchema } = initData; if (!receivedFormType) { console.error( - 'formType is crucial and was not provided in onFormInit. Cannot proceed.', + "formType is crucial and was not provided in onFormInit. Cannot proceed." ); - setLoadError('Form ID is missing. Cannot initialize form.'); + setLoadError("Form ID is missing. Cannot initialize form."); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'formplayerError', + type: "formplayerError", formType: receivedFormType, - message: 'formType missing in onFormInit', - }), + message: "formType missing in onFormInit", + }) ); } return; // Exit early @@ -459,29 +486,29 @@ function App() { if (!hasExistingSavedData) { const availableDrafts = draftService.getDraftsForForm( receivedFormType, - (formSchema as any)?.version, + (formSchema as any)?.version ); if (availableDrafts.length > 0) { console.log( - `Found ${availableDrafts.length} draft(s) for form ${receivedFormType}, showing draft selector`, + `Found ${availableDrafts.length} draft(s) for form ${receivedFormType}, showing draft selector` ); setPendingFormInit(initData); setShowDraftSelector(true); setIsLoading(false); isLoadingRef.current = false; - return { status: 'draft_selector_shown' }; // Don't proceed with normal initialization + return { status: "draft_selector_shown" }; // Don't proceed with normal initialization } } // Proceed with normal form initialization initializeForm(initData); - return { status: 'ok' }; + return { status: "ok" }; } catch (error) { - console.error('Error processing onFormInit data:', error); + console.error("Error processing onFormInit data:", error); const errorMessage = error instanceof Error ? error.message - : 'Unknown error during form initialization'; + : "Unknown error during form initialization"; setLoadError(`Error processing form data: ${errorMessage}`); if ( window.ReactNativeWebView && @@ -489,28 +516,46 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'formplayerError', + type: "formplayerError", formType: initData?.formType, - status: 'error', + status: "error", message: errorMessage, - }), + }) ); } setIsLoading(false); isLoadingRef.current = false; - return { status: 'error' }; + return { status: "error" }; } }, - [initializeForm], + [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 const globalAny = window as any; if (globalAny.__formplayerOnInitRegistered) { console.log( - 'window.onFormInit already registered for this WebView lifecycle, skipping re-registration.', + "window.onFormInit already registered for this WebView lifecycle, skipping re-registration." ); return; } @@ -521,48 +566,48 @@ function App() { setIsLoading(true); isLoadingRef.current = true; - console.log('Registering window.onFormInit handler.'); + console.log("Registering window.onFormInit handler."); globalAny.onFormInit = handleFormInitByNative; // Signal to native that the WebView is ready to receive onFormInit console.log( - 'Signaling readiness to native host (formplayerReadyToReceiveInit).', + "Signaling readiness to native host (formplayerReadyToReceiveInit)." ); if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'formplayerReadyToReceiveInit', - }), + type: "formplayerReadyToReceiveInit", + }) ); } else { console.warn( - 'ReactNativeWebView.postMessage not available. Cannot signal readiness.', + "ReactNativeWebView.postMessage not available. Cannot signal readiness." ); - console.log('Debug - NODE_ENV:', process.env.NODE_ENV); + console.log("Debug - NODE_ENV:", process.env.NODE_ENV); console.log( - 'Debug - webViewMock.isActiveMock():', - webViewMock.isActiveMock(), + "Debug - webViewMock.isActiveMock():", + webViewMock.isActiveMock() ); - console.log('Debug - isLoadingRef.current:', isLoadingRef.current); + console.log("Debug - isLoadingRef.current:", isLoadingRef.current); // Potentially set an error or handle standalone mode if WebView context isn't available // For example, if running in a standard browser for development if (isLoadingRef.current) { // Avoid setting error if already handled by timeout or success if ( - process.env.NODE_ENV === 'development' && + process.env.NODE_ENV === "development" && webViewMock.isActiveMock() ) { console.log( - 'Development mode: WebView mock is active, continuing without error', + "Development mode: WebView mock is active, continuing without error" ); // Don't set error in development mode when mock is active } else { console.log( - 'Setting error message because mock is not active or not in development', + "Setting error message because mock is not active or not in development" ); setLoadError( - 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', + "Cannot communicate with native host. Formplayer might be running in a standalone browser." ); setIsLoading(false); isLoadingRef.current = false; @@ -574,9 +619,9 @@ function App() { const initTimeout = setTimeout(() => { if (isLoadingRef.current) { // Check ref to see if still loading - console.warn('onFormInit was not called within timeout period (10s).'); + console.warn("onFormInit was not called within timeout period (10s)."); setLoadError( - 'Failed to initialize form: No data received from native host. Please try again.', + "Failed to initialize form: No data received from native host. Please try again." ); setIsLoading(false); isLoadingRef.current = false; @@ -586,10 +631,10 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'error', + type: "error", message: - 'Initialization timeout in WebView: onFormInit not called.', - }), + "Initialization timeout in WebView: onFormInit not called.", + }) ); } } @@ -602,7 +647,7 @@ function App() { // re-register handlers or resend readiness within the same WebView lifecycle. if (globalAny.onFormInit === handleFormInitByNative) { globalAny.onFormInit = undefined; - console.log('Unregistered window.onFormInit handler.'); + console.log("Unregistered window.onFormInit handler."); } }; }, [handleFormInitByNative]); // Dependency: re-run if handleFormInitByNative changes @@ -616,19 +661,19 @@ function App() { if (!uischema) return; const path = event.detail.path; - const field = path.split('/').pop(); + const field = path.split("/").pop(); const screens = uischema.elements; for (let i = 0; i < screens.length; i++) { const screen = screens[i]; // Skip the Finalize screen - if (screen.type === 'Finalize') continue; + if (screen.type === "Finalize") continue; // Type guard to ensure elements exists - if ('elements' in screen && screen.elements) { + if ("elements" in screen && screen.elements) { if (screen.elements.some((el: any) => el.scope?.includes(field))) { // Dispatch a custom event that SwipeLayoutWrapper will listen for - const navigateEvent = new CustomEvent('navigateToPage', { + const navigateEvent = new CustomEvent("navigateToPage", { detail: { page: i }, }); window.dispatchEvent(navigateEvent); @@ -649,49 +694,49 @@ function App() { if (!payloadFormInit) { console.error( - '[App.tsx] Cannot finalize form: formInitData is missing', + "[App.tsx] Cannot finalize form: formInitData is missing" ); setSubmitError( - 'Cannot submit form because initialization data is missing.', + "Cannot submit form because initialization data is missing." ); return; } - console.log('[App.tsx] Submitting form data:', payloadData); + console.log("[App.tsx] Submitting form data:", payloadData); formulusClient.current .submitObservationWithContext(payloadFormInit, payloadData) .then(() => { // Only clean up drafts after a successful save draftService.deleteDraftsForFormInstance( payloadFormInit.formType, - payloadFormInit.observationId, + payloadFormInit.observationId ); setSubmitError(null); setShowFinalizeMessage(true); }) - .catch(error => { - console.error('[App.tsx] Error submitting form:', error); - setSubmitError('Failed to submit form. Please try again.'); + .catch((error) => { + console.error("[App.tsx] Error submitting form:", error); + setSubmitError("Failed to submit form. Please try again."); }); }; window.addEventListener( - 'navigateToError', - handleNavigateToError as EventListener, + "navigateToError", + handleNavigateToError as EventListener ); window.addEventListener( - 'finalizeForm', - handleFinalizeForm as EventListener, + "finalizeForm", + handleFinalizeForm as EventListener ); return () => { window.removeEventListener( - 'navigateToError', - handleNavigateToError as EventListener, + "navigateToError", + handleNavigateToError as EventListener ); window.removeEventListener( - 'finalizeForm', - handleFinalizeForm as EventListener, + "finalizeForm", + handleFinalizeForm as EventListener ); }; }, [data, formInitData, uischema]); // Include all dependencies @@ -701,7 +746,7 @@ function App() { (draftId: string) => { const draft = draftService.getDraft(draftId); if (draft && pendingFormInit) { - console.log('Resuming draft:', draftId, draft); + console.log("Resuming draft:", draftId, draft); // Create new FormInitData with draft data as savedData const initDataWithDraft: FormInitData = { @@ -717,13 +762,13 @@ function App() { setPendingFormInit(null); } }, - [pendingFormInit, initializeForm], + [pendingFormInit, initializeForm] ); // Handler for starting a new form (ignoring drafts) const handleStartNewForm = useCallback(() => { if (pendingFormInit) { - console.log('Starting new form, ignoring drafts'); + console.log("Starting new form, ignoring drafts"); initializeForm(pendingFormInit); setShowDraftSelector(false); setPendingFormInit(null); @@ -739,7 +784,7 @@ function App() { draftService.saveDraft(formInitData.formType, data, formInitData); } }, - [formInitData], + [formInitData] ); // Create AJV instance with extension definitions support @@ -752,21 +797,21 @@ function App() { addFormats(instance); // Add custom format validators - instance.addFormat('photo', () => true); // Accept any value for photo format - instance.addFormat('qrcode', () => true); // Accept any value for qrcode format - instance.addFormat('signature', () => true); // Accept any value for signature format - instance.addFormat('select_file', () => true); // Accept any value for file selection format - instance.addFormat('audio', () => true); // Accept any value for audio format - instance.addFormat('gps', () => true); // Accept any value for GPS format - instance.addFormat('video', () => true); // Accept any value for video format - instance.addFormat('adate', (data: any) => { + instance.addFormat("photo", () => true); // Accept any value for photo format + instance.addFormat("qrcode", () => true); // Accept any value for qrcode format + instance.addFormat("signature", () => true); // Accept any value for signature format + instance.addFormat("select_file", () => true); // Accept any value for file selection format + instance.addFormat("audio", () => true); // Accept any value for audio format + instance.addFormat("gps", () => true); // Accept any value for GPS format + instance.addFormat("video", () => true); // Accept any value for video format + instance.addFormat("adate", (data: any) => { // Allow null, undefined, or empty string (for optional fields) - if (data === null || data === undefined || data === '') { + if (data === null || data === undefined || data === "") { return true; } // Validate YYYY-MM-DD format (may contain ?? for unknown parts) const dateRegex = /^(\d{4}|\?\?\?\?)-(\d{2}|\?\?)-(\d{2}|\?\?)$/; - return typeof data === 'string' && dateRegex.test(data); + return typeof data === "string" && dateRegex.test(data); }); // Add extension definitions to AJV for $ref support @@ -783,7 +828,7 @@ function App() { // Create dynamic theme based on dark mode preference // Must be called before any early returns to follow React Hooks rules const currentTheme = useMemo(() => { - return createTheme(getThemeOptions(darkMode ? 'dark' : 'light')); + return createTheme(getThemeOptions(darkMode ? "dark" : "light")); }, [darkMode]); // Show draft selector if we have pending form init and available drafts @@ -804,17 +849,18 @@ function App() { return ( + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100dvh", + }} + > Loading form... - + Waiting for data from Formulus... @@ -823,44 +869,53 @@ function App() { if (loadError || !schema || !uischema) { if (loadError) { - console.error('[Formplayer] Load error:', loadError); + console.error("[Formplayer] Load error:", loadError); // Show the actual error so user knows what went wrong (not blank white screen) return ( - + backgroundColor: "background.paper", + }} + > + Error Loading Form - + {loadError} ); } if (!schema) { - console.warn('[Formplayer] Schema not loaded yet'); + console.warn("[Formplayer] Schema not loaded yet"); } if (!uischema) { - console.warn('[Formplayer] UI schema not loaded yet'); + console.warn("[Formplayer] UI schema not loaded yet"); } // Still waiting for schema/uischema - show loading return ( + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100dvh", + }} + > Loading form... @@ -870,9 +925,9 @@ function App() { } // Log render with current state - console.log('Rendering form with:', { - schemaType: schema?.type || 'MISSING', - uiSchemaType: uischema?.type || 'MISSING', + console.log("Rendering form with:", { + schemaType: schema?.type || "MISSING", + uiSchemaType: uischema?.type || "MISSING", dataKeys: Object.keys(data), formType: formInitData?.formType, darkMode: darkMode, @@ -880,37 +935,46 @@ function App() { return ( - +
+ }} + > {/* Main app content - 60% width in development mode */}
+ width: process.env.NODE_ENV === "development" ? "60%" : "100%", + overflow: "hidden", // Prevent outer scrolling - FormLayout handles scrolling internally + padding: "4px", + boxSizing: "border-box", + height: "100%", // Ensure it takes full height + backgroundColor: "transparent", // Use theme background + }} + > {loadError ? ( + padding: "20px", + backgroundColor: "error.light", + border: "1px solid", + borderColor: "error.main", + borderRadius: "4px", + color: "error.dark", + }} + > Error Loading Form @@ -941,10 +1005,12 @@ function App() { setShowFinalizeMessage(false)}> + onClose={() => setShowFinalizeMessage(false)} + > setShowFinalizeMessage(false)} - severity="info"> + severity="info" + > Form submitted successfully! @@ -952,10 +1018,12 @@ function App() { setSubmitError(null)}> + onClose={() => setSubmitError(null)} + > setSubmitError(null)} - severity="error"> + severity="error" + > {submitError} @@ -965,13 +1033,14 @@ function App() {
{/* Development testbed - 40% width in development mode */} - {process.env.NODE_ENV === 'development' && DevTestbed && ( + {process.env.NODE_ENV === "development" && DevTestbed && (
+ width: "40%", + borderLeft: "2px solid #e0e0e0", + backgroundColor: "#fafafa", + }} + > diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index c0dea6f8b..55ff08fb7 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -1,12 +1,12 @@ /** * FormEvaluationContext.tsx - * + * * Provides extension functions to form evaluation context. * Allows renderers and other form components to access custom functions * defined in ext.json files. */ -import React, { createContext, useContext, ReactNode } from 'react'; +import React, { createContext, useContext, ReactNode } from "react"; /** * Context value for form evaluation @@ -30,9 +30,8 @@ const defaultContextValue: FormEvaluationContextValue = { /** * Form evaluation context */ -const FormEvaluationContext = createContext( - defaultContextValue, -); +const FormEvaluationContext = + createContext(defaultContextValue); /** * Hook to access form evaluation context diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index 184cb5807..083e1f5e5 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -5,7 +5,7 @@ * from custom app extensions. */ -import { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; +import { JsonFormsRendererRegistryEntry, RankedTester } from "@jsonforms/core"; /** * Extension metadata passed from Formulus @@ -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; } /** @@ -54,7 +56,7 @@ export interface ExtensionLoadResult { * Load extensions dynamically */ export async function loadExtensions( - metadata: ExtensionMetadata, + metadata: ExtensionMetadata ): Promise { const result: ExtensionLoadResult = { renderers: [], @@ -63,7 +65,7 @@ export async function loadExtensions( errors: [], }; - const basePath = metadata.basePath || ''; + const basePath = metadata.basePath || ""; // Load renderers if (metadata.renderers) { @@ -78,7 +80,7 @@ export async function loadExtensions( } } catch (error) { result.errors.push({ - type: 'renderer_load_error', + type: "renderer_load_error", message: `Failed to load renderer ${key}: ${ error instanceof Error ? error.message : String(error) }`, @@ -97,12 +99,12 @@ export async function loadExtensions( if (loadedFunction) { result.functions.set(funcMeta.name, loadedFunction); console.log( - `[ExtensionsLoader] Registered extension function "${funcMeta.name}" from module "${funcMeta.module}" (metadata key: ${key})`, + `[ExtensionsLoader] Registered extension function "${funcMeta.name}" from module "${funcMeta.module}" (metadata key: ${key})` ); } } catch (error) { result.errors.push({ - type: 'function_load_error', + type: "function_load_error", message: `Failed to load function ${key}: ${ error instanceof Error ? error.message : String(error) }`, @@ -121,7 +123,7 @@ export async function loadExtensions( */ async function loadRenderer( metadata: ExtensionRendererMetadata, - basePath: string, + basePath: string ): Promise { try { // Construct module path @@ -142,7 +144,7 @@ async function loadRenderer( ); } catch { // Fallback: try with .js extension - const modulePathWithExt = modulePath.endsWith('.js') + const modulePathWithExt = modulePath.endsWith(".js") ? modulePath : `${modulePath}.js`; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -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) { + let 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}` : "") ); } @@ -167,7 +193,7 @@ async function loadRenderer( module[rendererName] || module.default?.renderer || module.default; if (!renderer) { throw new Error( - `Renderer component "${rendererName}" not found in module ${metadata.module}`, + `Renderer component "${rendererName}" not found in module ${metadata.module}` ); } @@ -186,7 +212,7 @@ async function loadRenderer( */ async function loadFunction( metadata: ExtensionFunctionMetadata, - basePath: string, + basePath: string // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type ): Promise { try { @@ -206,7 +232,7 @@ async function loadFunction( /* @vite-ignore */ /* webpackIgnore: true */ modulePath ); } catch { - const modulePathWithExt = modulePath.endsWith('.js') + const modulePathWithExt = modulePath.endsWith(".js") ? modulePath : `${modulePath}.js`; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -219,9 +245,9 @@ async function loadFunction( // Get function const exportName = metadata.export || metadata.name; const func = module[exportName] || module.default; - if (!func || typeof func !== 'function') { + if (!func || typeof func !== "function") { throw new Error( - `Function "${exportName}" not found or not a function in module ${metadata.module}`, + `Function "${exportName}" not found or not a function in module ${metadata.module}` ); } 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 From babe061e34b3327c5c0ca694b30e45db70d84b72 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 14:48:18 +0300 Subject: [PATCH 2/9] fix linter errors --- formulus-formplayer/src/App.tsx | 9 +- .../src/DynamicEnumControl.tsx | 156 ++++--- .../src/FormEvaluationContext.tsx | 11 +- formulus-formplayer/src/builtinExtensions.ts | 388 ++++++++++-------- .../src/services/ExtensionsLoader.ts | 2 +- 5 files changed, 334 insertions(+), 232 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index c0c46360e..ca65c9e05 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -72,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"; @@ -279,10 +282,8 @@ 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 diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 84e42d495..7ef06eb21 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -1,17 +1,24 @@ /** * 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. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -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 React, { useState, useEffect, useCallback, useMemo } from "react"; +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"; /** * Interface for x-dynamicEnum configuration @@ -29,21 +36,24 @@ 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 !== '#'); - + const parts = scope.split("/").filter((p) => p && p !== "#"); + let resolved = rootSchema; for (const part of parts) { - if (resolved && typeof resolved === 'object') { + if (resolved && typeof resolved === "object") { resolved = resolved[part]; } else { return rootSchema; // Fallback to root if path invalid } } - + return resolved || rootSchema; } @@ -52,11 +62,11 @@ 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']; - }, + return !!(fieldSchema as any)?.["x-dynamicEnum"]; + } ); /** @@ -68,20 +78,24 @@ function resolveTemplateParams( 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; - + const dataPath = path.startsWith("data.") ? path.slice(5) : path; + // Get nested value - const pathParts = dataPath.split('.'); + const pathParts = dataPath.split("."); let resolvedValue: any = formData; for (const part of pathParts) { - if (resolvedValue && typeof resolvedValue === 'object') { + if (resolvedValue && typeof resolvedValue === "object") { resolvedValue = resolvedValue[part]; } else { resolvedValue = undefined; @@ -94,7 +108,7 @@ function resolveTemplateParams( resolved[key] = value; } } - + return resolved; } @@ -113,61 +127,73 @@ 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; + 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, newValue ? newValue.const : ""); }, [handleChange, path] ); // Find selected option based on current data value - must be before early returns const selectedOption = useMemo(() => { - return choices.find(opt => opt.const === data) || null; + return choices.find((opt) => opt.const === data) || null; }, [choices, data]); // 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; // Load choices when component mounts or params change const loadChoices = useCallback(async () => { if (!dynamicConfig) { - setError('x-dynamicEnum configuration is missing'); + setError("x-dynamicEnum configuration is missing"); return; } // Validate configuration if (!dynamicConfig.query) { - setError('x-dynamicEnum: query is required'); + setError("x-dynamicEnum: query is required"); return; } - const functionName = dynamicConfig.function || 'getDynamicChoiceList'; + const functionName = dynamicConfig.function || "getDynamicChoiceList"; const func = functions.get(functionName); if (!func) { - const availableFunctions = Array.from(functions.keys()).join(', '); + const availableFunctions = Array.from(functions.keys()).join(", "); setError( - `Function "${functionName}" not found. Available: ${availableFunctions || 'none'}.` + `Function "${functionName}" not found. Available: ${ + availableFunctions || "none" + }.` ); return; } @@ -178,37 +204,44 @@ 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 const paramsWithConfig = { ...resolvedParams, _config: { - valueField: dynamicConfig.valueField || 'observationId', - labelField: dynamicConfig.labelField || 'data.name', + valueField: dynamicConfig.valueField || "observationId", + labelField: dynamicConfig.labelField || "data.name", distinct: dynamicConfig.distinct || false, - distinctField: dynamicConfig.labelField || 'data.name', + distinctField: dynamicConfig.labelField || "data.name", }, }; // 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, - enum: result.map(item => item.const), + enum: result.map((item) => item.const), }; setLocalSchema(updatedSchema); } catch (err: any) { - const errorMessage = err?.message || 'Failed to load dynamic choices'; + const errorMessage = err?.message || "Failed to load dynamic choices"; setError(`${errorMessage}`); console.error(`Error loading dynamic choices for ${path}:`, err); } finally { @@ -219,18 +252,15 @@ 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), - visible, - enabled, - JSON.stringify(currentFormData), - ]); + }, [dynamicConfigQuery, dynamicConfigParamsStr, visible, enabled, currentFormDataStr]); // Early returns after all hooks if (!visible) { @@ -258,20 +288,20 @@ const DynamicEnumControl: React.FC = ({ {/* Field Label */} {label} - {schema.required && *} + {schema.required && *} - + {/* Description */} {description && ( {description} )} - + {/* Validation Errors */} {hasValidationErrors && ( - {Array.isArray(errors) ? errors.join(', ') : String(errors)} + {Array.isArray(errors) ? errors.join(", ") : String(errors)} )} {/* Control */} @@ -290,7 +320,7 @@ const DynamicEnumControl: React.FC = ({ Retry @@ -313,7 +343,13 @@ const DynamicEnumControl: React.FC = ({ )} diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index 55ff08fb7..a0f3b4a80 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -8,6 +8,11 @@ import React, { createContext, useContext, ReactNode } from "react"; +/** Extension function type (query name, params, form data) => result */ +export type ExtensionFunction = ( + ...args: unknown[] +) => unknown; + /** * Context value for form evaluation */ @@ -17,14 +22,14 @@ 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(), }; /** @@ -48,7 +53,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..477368f7c 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,47 +65,50 @@ 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) { - console.error('getDynamicChoiceList: getObservationsByQuery not available'); + console.error("getDynamicChoiceList: getObservationsByQuery not available"); return []; } try { // Extract configuration const config = params._config || {}; - const valueField = config.valueField || 'observationId'; + 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,39 +116,47 @@ 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); + return path.split(".").reduce((current, key) => current?.[key], obj); }; // 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 '); + whereClause = nonAgeConditions.join(" AND "); } else { whereClause = null; // No non-age conditions, fetch all and filter in JS } @@ -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 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,12 +385,14 @@ 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) { const seen = new Set(); - choices = choices.filter(choice => { + choices = choices.filter((choice) => { const key = String(choice.const); if (seen.has(key)) return false; seen.add(key); @@ -349,17 +402,24 @@ export async function getDynamicChoiceList( return choices; } catch (error: unknown) { - console.error('getDynamicChoiceList error:', error); + console.error("getDynamicChoiceList error:", error); return []; } } +/** 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(); - functions.set('getDynamicChoiceList', getDynamicChoiceList); +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 083e1f5e5..c7ff387e8 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -158,7 +158,7 @@ async function loadRenderer( const testerName = metadata.tester || `${metadata.name}Tester`; let tester = module[testerName] || module.default?.tester; if (!tester && metadata.testerModule) { - let testerModulePath = basePath + const testerModulePath = basePath ? `${basePath}/${metadata.testerModule}` : metadata.testerModule; let testerModule: any; From 797c5f5f1cf2246306b482e2e2633c5d8e427e3d Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 14:53:12 +0300 Subject: [PATCH 3/9] Fix code formatting --- formulus-formplayer/src/DynamicEnumControl.tsx | 8 +++++++- formulus-formplayer/src/FormEvaluationContext.tsx | 4 +--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index 7ef06eb21..deb699ebd 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -260,7 +260,13 @@ const DynamicEnumControl: React.FC = ({ loadChoices(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dynamicConfigQuery, dynamicConfigParamsStr, visible, enabled, currentFormDataStr]); + }, [ + dynamicConfigQuery, + dynamicConfigParamsStr, + visible, + enabled, + currentFormDataStr, + ]); // Early returns after all hooks if (!visible) { diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index a0f3b4a80..fe7fdfbbe 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -9,9 +9,7 @@ import React, { createContext, useContext, ReactNode } from "react"; /** Extension function type (query name, params, form data) => result */ -export type ExtensionFunction = ( - ...args: unknown[] -) => unknown; +export type ExtensionFunction = (...args: unknown[]) => unknown; /** * Context value for form evaluation From 48f706f4af3d09950f501de3f7ea295593c831e4 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 15:43:38 +0300 Subject: [PATCH 4/9] Fix code formatting --- formulus-formplayer/src/App.tsx | 433 +++++++++--------- .../src/DynamicEnumControl.tsx | 95 ++-- .../src/FormEvaluationContext.tsx | 2 +- formulus-formplayer/src/builtinExtensions.ts | 82 ++-- .../src/services/ExtensionsLoader.ts | 30 +- 5 files changed, 314 insertions(+), 328 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index ca65c9e05..a5170f6d3 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -7,14 +7,14 @@ import React, { createContext, useContext, useMemo, -} from "react"; -import "./App.css"; -import { JsonForms } from "@jsonforms/react"; +} from 'react'; +import './App.css'; +import { JsonForms } from '@jsonforms/react'; import { materialRenderers, materialCells, -} from "@jsonforms/material-renderers"; -import { JsonSchema7, JsonFormsRendererRegistryEntry } from "@jsonforms/core"; +} from '@jsonforms/material-renderers'; +import { JsonSchema7, JsonFormsRendererRegistryEntry } from '@jsonforms/core'; import { Alert, Snackbar, @@ -22,68 +22,68 @@ import { Box, Typography, ThemeProvider, -} from "@mui/material"; -import { createTheme, getThemeOptions } from "./theme/theme"; -import Ajv from "ajv"; -import addErrors from "ajv-errors"; -import addFormats from "ajv-formats"; +} from '@mui/material'; +import { createTheme, getThemeOptions } from './theme/theme'; +import Ajv from 'ajv'; +import addErrors from 'ajv-errors'; +import addFormats from 'ajv-formats'; // Import the FormulusInterface client -import FormulusClient from "./services/FormulusInterface"; +import FormulusClient from './services/FormulusInterface'; import { FormInitData, FormulusInterface, -} from "./types/FormulusInterfaceDefinition"; +} from './types/FormulusInterfaceDefinition'; import SwipeLayoutRenderer, { swipeLayoutTester, groupAsSwipeLayoutTester, -} from "./renderers/SwipeLayoutRenderer"; -import { finalizeRenderer, finalizeTester } from "./renderers/FinalizeRenderer"; +} from './renderers/SwipeLayoutRenderer'; +import { finalizeRenderer, finalizeTester } from './renderers/FinalizeRenderer'; import PhotoQuestionRenderer, { photoQuestionTester, -} from "./renderers/PhotoQuestionRenderer"; +} from './renderers/PhotoQuestionRenderer'; import SignatureQuestionRenderer, { signatureQuestionTester, -} from "./renderers/SignatureQuestionRenderer"; +} from './renderers/SignatureQuestionRenderer'; import FileQuestionRenderer, { fileQuestionTester, -} from "./renderers/FileQuestionRenderer"; +} from './renderers/FileQuestionRenderer'; import AudioQuestionRenderer, { audioQuestionTester, -} from "./renderers/AudioQuestionRenderer"; +} from './renderers/AudioQuestionRenderer'; import GPSQuestionRenderer, { gpsQuestionTester, -} from "./renderers/GPSQuestionRenderer"; +} from './renderers/GPSQuestionRenderer'; import VideoQuestionRenderer, { videoQuestionTester, -} from "./renderers/VideoQuestionRenderer"; +} from './renderers/VideoQuestionRenderer'; import HtmlLabelRenderer, { htmlLabelTester, -} from "./renderers/HtmlLabelRenderer"; +} from './renderers/HtmlLabelRenderer'; import AdateQuestionRenderer, { adateQuestionTester, -} from "./renderers/AdateQuestionRenderer"; -import { shellMaterialRenderers } from "./theme/material-wrappers"; -import DynamicEnumControl, { dynamicEnumTester } from "./DynamicEnumControl"; - -import ErrorBoundary from "./components/ErrorBoundary"; -import { draftService } from "./services/DraftService"; -import DraftSelector from "./components/DraftSelector"; -import { loadExtensions } from "./services/ExtensionsLoader"; -import { getBuiltinExtensions } from "./builtinExtensions"; +} from './renderers/AdateQuestionRenderer'; +import { shellMaterialRenderers } from './theme/material-wrappers'; +import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; + +import ErrorBoundary from './components/ErrorBoundary'; +import { draftService } from './services/DraftService'; +import DraftSelector from './components/DraftSelector'; +import { loadExtensions } from './services/ExtensionsLoader'; +import { getBuiltinExtensions } from './builtinExtensions'; import { FormEvaluationProvider, type ExtensionFunction, -} from "./FormEvaluationContext"; +} from './FormEvaluationContext'; // Import development dependencies (Vite will tree-shake these in production) -import { webViewMock } from "./mocks/webview-mock"; -import DevTestbed from "./mocks/DevTestbed"; +import { webViewMock } from './mocks/webview-mock'; +import DevTestbed from './mocks/DevTestbed'; // Initialize the mock in development mode (synchronously) if (import.meta.env.DEV) { - console.log("[App] Initializing WebView mock for development"); + console.log('[App] Initializing WebView mock for development'); webViewMock.init(); } @@ -108,59 +108,59 @@ const ensureSwipeLayoutRoot = (uiSchema: FormUISchema | null): FormUISchema => { if (!uiSchema) { // If no UI schema, create a basic SwipeLayout with empty elements return { - type: "SwipeLayout", + type: 'SwipeLayout', elements: [], }; } // If root is already SwipeLayout, return as is - if (uiSchema.type === "SwipeLayout") { + if (uiSchema.type === 'SwipeLayout') { return { ...uiSchema }; } // If root is not SwipeLayout, wrap the entire schema in a SwipeLayout if ( - uiSchema.type === "Group" || - uiSchema.type === "VerticalLayout" || - uiSchema.type === "HorizontalLayout" || + uiSchema.type === 'Group' || + uiSchema.type === 'VerticalLayout' || + uiSchema.type === 'HorizontalLayout' || uiSchema.elements ) { console.log( - `Root UI schema type is "${uiSchema.type}", wrapping in SwipeLayout` + `Root UI schema type is "${uiSchema.type}", wrapping in SwipeLayout`, ); return { - type: "SwipeLayout", + type: 'SwipeLayout', elements: [uiSchema], }; } // If there are multiple root elements (array), wrap them in SwipeLayout if (Array.isArray(uiSchema)) { - console.log("Multiple root elements detected, wrapping in SwipeLayout"); + console.log('Multiple root elements detected, wrapping in SwipeLayout'); return { - type: "SwipeLayout", + type: 'SwipeLayout', elements: uiSchema, }; } // Fallback: create SwipeLayout with the original schema as a single element return { - type: "SwipeLayout", + type: 'SwipeLayout', elements: [uiSchema], }; }; // Function to process UI schema and ensure Finalize element is present const processUISchemaWithFinalize = ( - uiSchema: FormUISchema | null + uiSchema: FormUISchema | null, ): FormUISchema => { if (!uiSchema || !uiSchema.elements) { // If no UI schema or no elements, create a basic one with just Finalize return { - type: "VerticalLayout", + type: 'VerticalLayout', elements: [ { - type: "Finalize", + type: 'Finalize', }, ], }; @@ -173,24 +173,24 @@ const processUISchemaWithFinalize = ( // Check for existing Finalize elements and remove them const existingFinalizeIndices: number[] = []; elements.forEach((element, index) => { - if (element && element.type === "Finalize") { + if (element && element.type === 'Finalize') { existingFinalizeIndices.push(index); } }); if (existingFinalizeIndices.length > 0) { console.warn( - `Found ${existingFinalizeIndices.length} existing Finalize element(s) in UI schema. Removing them as they will be automatically added.` + `Found ${existingFinalizeIndices.length} existing Finalize element(s) in UI schema. Removing them as they will be automatically added.`, ); // Remove existing Finalize elements (in reverse order to maintain indices) - existingFinalizeIndices.reverse().forEach((index) => { + existingFinalizeIndices.reverse().forEach(index => { elements.splice(index, 1); }); } // Always add our Finalize element as the last element elements.push({ - type: "Finalize", + type: 'Finalize', }); processedUISchema.elements = elements; @@ -218,7 +218,7 @@ export const FormContext = createContext({ export const useFormContext = () => useContext(FormContext); // Expose FormContext globally so extension renderers (loaded from app bundle) can access it -if (typeof window !== "undefined") { +if (typeof window !== 'undefined') { ( window as Window & { __formplayerFormContext?: typeof FormContext } ).__formplayerFormContext = FormContext; @@ -243,17 +243,17 @@ export const customRenderers = [ function App() { // Initialize WebView mock ONLY in development mode and ONLY if ReactNativeWebView doesn't exist if ( - process.env.NODE_ENV === "development" && + process.env.NODE_ENV === 'development' && webViewMock && !window.ReactNativeWebView ) { console.log( - "Development mode detected and no ReactNativeWebView found, initializing WebView mock..." + 'Development mode detected and no ReactNativeWebView found, initializing WebView mock...', ); webViewMock.init(); console.log( - "WebView mock initialized, isActive:", - webViewMock.isActiveMock() + 'WebView mock initialized, isActive:', + webViewMock.isActiveMock(), ); } /* else if (process.env.NODE_ENV !== 'development') { console.log('Production mode detected, NOT initializing WebView mock'); @@ -275,7 +275,7 @@ function App() { const [formInitData, setFormInitData] = useState(null); const [showDraftSelector, setShowDraftSelector] = useState(false); const [pendingFormInit, setPendingFormInit] = useState( - null + null, ); const [darkMode, setDarkMode] = useState(false); const [extensionRenderers, setExtensionRenderers] = useState< @@ -289,7 +289,7 @@ function App() { Record >({}); const [formulusApi, setFormulusApi] = useState( - null + null, ); // Reference to the FormulusClient instance and loading state @@ -316,11 +316,12 @@ function App() { const properties = (formSchema as any)?.properties || {}; const dynamicEnumFields = Object.entries(properties) .filter( - ([, propSchema]: [string, any]) => !!propSchema?.["x-dynamicEnum"] + ([, propSchema]: [string, any]) => + !!propSchema?.['x-dynamicEnum'], ) .map(([key]) => key); - console.log("[Formplayer] Form init received", { + console.log('[Formplayer] Form init received', { formType: receivedFormType, hasSchema: !!formSchema, hasUISchema: !!uiSchema, @@ -329,8 +330,8 @@ function App() { }); } catch (schemaLogError) { console.warn( - "[Formplayer] Failed to log schema details", - schemaLogError + '[Formplayer] Failed to log schema details', + schemaLogError, ); } @@ -356,16 +357,16 @@ function App() { setExtensionDefinitions(extensionResult.definitions); console.log( - "[Formplayer] Final extension functions:", - Array.from(allFunctions.keys()) + '[Formplayer] Final extension functions:', + Array.from(allFunctions.keys()), ); // Log errors but don't fail form initialization if (extensionResult.errors.length > 0) { - console.warn("Extension loading errors:", extensionResult.errors); + console.warn('Extension loading errors:', extensionResult.errors); } } catch (error) { - console.error("Failed to load extensions:", error); + console.error('Failed to load extensions:', error); // Still use built-in functions even if loading fails setExtensionRenderers([]); setExtensionFunctions(allFunctions); @@ -376,15 +377,15 @@ function App() { setExtensionRenderers([]); setExtensionFunctions(allFunctions); setExtensionDefinitions({}); - console.log("[Formplayer] Using only built-in extensions"); + console.log('[Formplayer] Using only built-in extensions'); } if (!formSchema) { console.warn( - "formSchema was not provided. Form rendering might fail or be incomplete." + 'formSchema was not provided. Form rendering might fail or be incomplete.', ); setLoadError( - "Form schema is missing. Form rendering might fail or be incomplete." + 'Form schema is missing. Form rendering might fail or be incomplete.', ); setSchema({} as FormSchema); // Set to empty schema or handle as per requirements // First ensure SwipeLayout root, then process to ensure Finalize element is present @@ -396,7 +397,7 @@ function App() { setSchema(formSchema as FormSchema); // First ensure SwipeLayout root, then process to ensure Finalize element is present const swipeLayoutUISchema = ensureSwipeLayoutRoot( - uiSchema as FormUISchema + uiSchema as FormUISchema, ); const processedUISchema = processUISchemaWithFinalize(swipeLayoutUISchema); @@ -404,18 +405,18 @@ function App() { } if (savedData && Object.keys(savedData).length > 0) { - console.log("Preloading saved data:", savedData); + console.log('Preloading saved data:', savedData); setData(savedData as FormData); } else { const defaultData = - params && typeof params === "object" + params && typeof params === 'object' ? params.defaultData ?? params : {}; - console.log("Preloading initialization form values:", defaultData); + console.log('Preloading initialization form values:', defaultData); setData(defaultData as FormData); } - console.log("Form params (if any, beyond schemas/data):", params); + console.log('Form params (if any, beyond schemas/data):', params); setLoadError(null); // Clear any previous load errors if ( @@ -424,20 +425,20 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: "formplayerInitialized", + type: 'formplayerInitialized', formType: receivedFormType, - status: "success", - }) + status: 'success', + }), ); } setIsLoading(false); isLoadingRef.current = false; } catch (error) { - console.error("Error initializing form:", error); + console.error('Error initializing form:', error); const errorMessage = error instanceof Error ? error.message - : "Unknown error during form initialization"; + : 'Unknown error during form initialization'; setLoadError(`Error initializing form: ${errorMessage}`); setIsLoading(false); isLoadingRef.current = false; @@ -450,32 +451,32 @@ function App() { setData, setLoadError, setIsLoading, - ] + ], ); // isLoadingRef is a ref, not needed in deps // Handler for data received via window.onFormInit const handleFormInitByNative = useCallback( (initData: FormInitData) => { - console.log("Received onFormInit event with data:", initData); + console.log('Received onFormInit event with data:', initData); try { const { formType: receivedFormType, savedData, formSchema } = initData; if (!receivedFormType) { console.error( - "formType is crucial and was not provided in onFormInit. Cannot proceed." + 'formType is crucial and was not provided in onFormInit. Cannot proceed.', ); - setLoadError("Form ID is missing. Cannot initialize form."); + setLoadError('Form ID is missing. Cannot initialize form.'); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: "formplayerError", + type: 'formplayerError', formType: receivedFormType, - message: "formType missing in onFormInit", - }) + message: 'formType missing in onFormInit', + }), ); } return; // Exit early @@ -487,29 +488,29 @@ function App() { if (!hasExistingSavedData) { const availableDrafts = draftService.getDraftsForForm( receivedFormType, - (formSchema as any)?.version + (formSchema as any)?.version, ); if (availableDrafts.length > 0) { console.log( - `Found ${availableDrafts.length} draft(s) for form ${receivedFormType}, showing draft selector` + `Found ${availableDrafts.length} draft(s) for form ${receivedFormType}, showing draft selector`, ); setPendingFormInit(initData); setShowDraftSelector(true); setIsLoading(false); isLoadingRef.current = false; - return { status: "draft_selector_shown" }; // Don't proceed with normal initialization + return { status: 'draft_selector_shown' }; // Don't proceed with normal initialization } } // Proceed with normal form initialization initializeForm(initData); - return { status: "ok" }; + return { status: 'ok' }; } catch (error) { - console.error("Error processing onFormInit data:", error); + console.error('Error processing onFormInit data:', error); const errorMessage = error instanceof Error ? error.message - : "Unknown error during form initialization"; + : 'Unknown error during form initialization'; setLoadError(`Error processing form data: ${errorMessage}`); if ( window.ReactNativeWebView && @@ -517,19 +518,19 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: "formplayerError", + type: 'formplayerError', formType: initData?.formType, - status: "error", + status: 'error', message: errorMessage, - }) + }), ); } setIsLoading(false); isLoadingRef.current = false; - return { status: "error" }; + return { status: 'error' }; } }, - [initializeForm] + [initializeForm], ); // Effect to fetch formulusApi for custom renderers (RankingRenderer, etc.) @@ -538,12 +539,12 @@ function App() { const win = window as Window & { getFormulus?: () => Promise; }; - if (typeof win.getFormulus === "function") { + if (typeof win.getFormulus === 'function') { try { const api = await win.getFormulus(); setFormulusApi(api); } catch (err) { - console.warn("[Formplayer] Failed to load formulusApi:", err); + console.warn('[Formplayer] Failed to load formulusApi:', err); } } }; @@ -556,7 +557,7 @@ function App() { const globalAny = window as any; if (globalAny.__formplayerOnInitRegistered) { console.log( - "window.onFormInit already registered for this WebView lifecycle, skipping re-registration." + 'window.onFormInit already registered for this WebView lifecycle, skipping re-registration.', ); return; } @@ -567,48 +568,48 @@ function App() { setIsLoading(true); isLoadingRef.current = true; - console.log("Registering window.onFormInit handler."); + console.log('Registering window.onFormInit handler.'); globalAny.onFormInit = handleFormInitByNative; // Signal to native that the WebView is ready to receive onFormInit console.log( - "Signaling readiness to native host (formplayerReadyToReceiveInit)." + 'Signaling readiness to native host (formplayerReadyToReceiveInit).', ); if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: "formplayerReadyToReceiveInit", - }) + type: 'formplayerReadyToReceiveInit', + }), ); } else { console.warn( - "ReactNativeWebView.postMessage not available. Cannot signal readiness." + 'ReactNativeWebView.postMessage not available. Cannot signal readiness.', ); - console.log("Debug - NODE_ENV:", process.env.NODE_ENV); + console.log('Debug - NODE_ENV:', process.env.NODE_ENV); console.log( - "Debug - webViewMock.isActiveMock():", - webViewMock.isActiveMock() + 'Debug - webViewMock.isActiveMock():', + webViewMock.isActiveMock(), ); - console.log("Debug - isLoadingRef.current:", isLoadingRef.current); + console.log('Debug - isLoadingRef.current:', isLoadingRef.current); // Potentially set an error or handle standalone mode if WebView context isn't available // For example, if running in a standard browser for development if (isLoadingRef.current) { // Avoid setting error if already handled by timeout or success if ( - process.env.NODE_ENV === "development" && + process.env.NODE_ENV === 'development' && webViewMock.isActiveMock() ) { console.log( - "Development mode: WebView mock is active, continuing without error" + 'Development mode: WebView mock is active, continuing without error', ); // Don't set error in development mode when mock is active } else { console.log( - "Setting error message because mock is not active or not in development" + 'Setting error message because mock is not active or not in development', ); setLoadError( - "Cannot communicate with native host. Formplayer might be running in a standalone browser." + 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', ); setIsLoading(false); isLoadingRef.current = false; @@ -620,9 +621,9 @@ function App() { const initTimeout = setTimeout(() => { if (isLoadingRef.current) { // Check ref to see if still loading - console.warn("onFormInit was not called within timeout period (10s)."); + console.warn('onFormInit was not called within timeout period (10s).'); setLoadError( - "Failed to initialize form: No data received from native host. Please try again." + 'Failed to initialize form: No data received from native host. Please try again.', ); setIsLoading(false); isLoadingRef.current = false; @@ -632,10 +633,10 @@ function App() { ) { window.ReactNativeWebView.postMessage( JSON.stringify({ - type: "error", + type: 'error', message: - "Initialization timeout in WebView: onFormInit not called.", - }) + 'Initialization timeout in WebView: onFormInit not called.', + }), ); } } @@ -648,7 +649,7 @@ function App() { // re-register handlers or resend readiness within the same WebView lifecycle. if (globalAny.onFormInit === handleFormInitByNative) { globalAny.onFormInit = undefined; - console.log("Unregistered window.onFormInit handler."); + console.log('Unregistered window.onFormInit handler.'); } }; }, [handleFormInitByNative]); // Dependency: re-run if handleFormInitByNative changes @@ -662,19 +663,19 @@ function App() { if (!uischema) return; const path = event.detail.path; - const field = path.split("/").pop(); + const field = path.split('/').pop(); const screens = uischema.elements; for (let i = 0; i < screens.length; i++) { const screen = screens[i]; // Skip the Finalize screen - if (screen.type === "Finalize") continue; + if (screen.type === 'Finalize') continue; // Type guard to ensure elements exists - if ("elements" in screen && screen.elements) { + if ('elements' in screen && screen.elements) { if (screen.elements.some((el: any) => el.scope?.includes(field))) { // Dispatch a custom event that SwipeLayoutWrapper will listen for - const navigateEvent = new CustomEvent("navigateToPage", { + const navigateEvent = new CustomEvent('navigateToPage', { detail: { page: i }, }); window.dispatchEvent(navigateEvent); @@ -695,49 +696,49 @@ function App() { if (!payloadFormInit) { console.error( - "[App.tsx] Cannot finalize form: formInitData is missing" + '[App.tsx] Cannot finalize form: formInitData is missing', ); setSubmitError( - "Cannot submit form because initialization data is missing." + 'Cannot submit form because initialization data is missing.', ); return; } - console.log("[App.tsx] Submitting form data:", payloadData); + console.log('[App.tsx] Submitting form data:', payloadData); formulusClient.current .submitObservationWithContext(payloadFormInit, payloadData) .then(() => { // Only clean up drafts after a successful save draftService.deleteDraftsForFormInstance( payloadFormInit.formType, - payloadFormInit.observationId + payloadFormInit.observationId, ); setSubmitError(null); setShowFinalizeMessage(true); }) - .catch((error) => { - console.error("[App.tsx] Error submitting form:", error); - setSubmitError("Failed to submit form. Please try again."); + .catch(error => { + console.error('[App.tsx] Error submitting form:', error); + setSubmitError('Failed to submit form. Please try again.'); }); }; window.addEventListener( - "navigateToError", - handleNavigateToError as EventListener + 'navigateToError', + handleNavigateToError as EventListener, ); window.addEventListener( - "finalizeForm", - handleFinalizeForm as EventListener + 'finalizeForm', + handleFinalizeForm as EventListener, ); return () => { window.removeEventListener( - "navigateToError", - handleNavigateToError as EventListener + 'navigateToError', + handleNavigateToError as EventListener, ); window.removeEventListener( - "finalizeForm", - handleFinalizeForm as EventListener + 'finalizeForm', + handleFinalizeForm as EventListener, ); }; }, [data, formInitData, uischema]); // Include all dependencies @@ -747,7 +748,7 @@ function App() { (draftId: string) => { const draft = draftService.getDraft(draftId); if (draft && pendingFormInit) { - console.log("Resuming draft:", draftId, draft); + console.log('Resuming draft:', draftId, draft); // Create new FormInitData with draft data as savedData const initDataWithDraft: FormInitData = { @@ -763,13 +764,13 @@ function App() { setPendingFormInit(null); } }, - [pendingFormInit, initializeForm] + [pendingFormInit, initializeForm], ); // Handler for starting a new form (ignoring drafts) const handleStartNewForm = useCallback(() => { if (pendingFormInit) { - console.log("Starting new form, ignoring drafts"); + console.log('Starting new form, ignoring drafts'); initializeForm(pendingFormInit); setShowDraftSelector(false); setPendingFormInit(null); @@ -785,7 +786,7 @@ function App() { draftService.saveDraft(formInitData.formType, data, formInitData); } }, - [formInitData] + [formInitData], ); // Create AJV instance with extension definitions support @@ -798,21 +799,21 @@ function App() { addFormats(instance); // Add custom format validators - instance.addFormat("photo", () => true); // Accept any value for photo format - instance.addFormat("qrcode", () => true); // Accept any value for qrcode format - instance.addFormat("signature", () => true); // Accept any value for signature format - instance.addFormat("select_file", () => true); // Accept any value for file selection format - instance.addFormat("audio", () => true); // Accept any value for audio format - instance.addFormat("gps", () => true); // Accept any value for GPS format - instance.addFormat("video", () => true); // Accept any value for video format - instance.addFormat("adate", (data: any) => { + instance.addFormat('photo', () => true); // Accept any value for photo format + instance.addFormat('qrcode', () => true); // Accept any value for qrcode format + instance.addFormat('signature', () => true); // Accept any value for signature format + instance.addFormat('select_file', () => true); // Accept any value for file selection format + instance.addFormat('audio', () => true); // Accept any value for audio format + instance.addFormat('gps', () => true); // Accept any value for GPS format + instance.addFormat('video', () => true); // Accept any value for video format + instance.addFormat('adate', (data: any) => { // Allow null, undefined, or empty string (for optional fields) - if (data === null || data === undefined || data === "") { + if (data === null || data === undefined || data === '') { return true; } // Validate YYYY-MM-DD format (may contain ?? for unknown parts) const dateRegex = /^(\d{4}|\?\?\?\?)-(\d{2}|\?\?)-(\d{2}|\?\?)$/; - return typeof data === "string" && dateRegex.test(data); + return typeof data === 'string' && dateRegex.test(data); }); // Add extension definitions to AJV for $ref support @@ -829,7 +830,7 @@ function App() { // Create dynamic theme based on dark mode preference // Must be called before any early returns to follow React Hooks rules const currentTheme = useMemo(() => { - return createTheme(getThemeOptions(darkMode ? "dark" : "light")); + return createTheme(getThemeOptions(darkMode ? 'dark' : 'light')); }, [darkMode]); // Show draft selector if we have pending form init and available drafts @@ -850,18 +851,17 @@ function App() { return ( + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100dvh', + }}> Loading form... - + Waiting for data from Formulus... @@ -870,53 +870,49 @@ function App() { if (loadError || !schema || !uischema) { if (loadError) { - console.error("[Formplayer] Load error:", loadError); + console.error('[Formplayer] Load error:', loadError); // Show the actual error so user knows what went wrong (not blank white screen) return ( + backgroundColor: 'background.paper', + }}> + sx={{ mb: 2, textAlign: 'center' }}> Error Loading Form + sx={{ textAlign: 'center', color: 'text.secondary' }}> {loadError} ); } if (!schema) { - console.warn("[Formplayer] Schema not loaded yet"); + console.warn('[Formplayer] Schema not loaded yet'); } if (!uischema) { - console.warn("[Formplayer] UI schema not loaded yet"); + console.warn('[Formplayer] UI schema not loaded yet'); } // Still waiting for schema/uischema - show loading return ( + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100dvh', + }}> Loading form... @@ -926,9 +922,9 @@ function App() { } // Log render with current state - console.log("Rendering form with:", { - schemaType: schema?.type || "MISSING", - uiSchemaType: uischema?.type || "MISSING", + console.log('Rendering form with:', { + schemaType: schema?.type || 'MISSING', + uiSchemaType: uischema?.type || 'MISSING', dataKeys: Object.keys(data), formType: formInitData?.formType, darkMode: darkMode, @@ -941,41 +937,37 @@ function App() { formInitData, formulusApi, formParams: formInitData?.params ?? {}, - }} - > + }}>
+ }}> {/* Main app content - 60% width in development mode */}
+ width: process.env.NODE_ENV === 'development' ? '60%' : '100%', + overflow: 'hidden', // Prevent outer scrolling - FormLayout handles scrolling internally + padding: '4px', + boxSizing: 'border-box', + height: '100%', // Ensure it takes full height + backgroundColor: 'transparent', // Use theme background + }}> {loadError ? ( + padding: '20px', + backgroundColor: 'error.light', + border: '1px solid', + borderColor: 'error.main', + borderRadius: '4px', + color: 'error.dark', + }}> Error Loading Form @@ -1006,12 +998,10 @@ function App() { setShowFinalizeMessage(false)} - > + onClose={() => setShowFinalizeMessage(false)}> setShowFinalizeMessage(false)} - severity="info" - > + severity="info"> Form submitted successfully! @@ -1019,12 +1009,10 @@ function App() { setSubmitError(null)} - > + onClose={() => setSubmitError(null)}> setSubmitError(null)} - severity="error" - > + severity="error"> {submitError} @@ -1034,14 +1022,13 @@ function App() {
{/* Development testbed - 40% width in development mode */} - {process.env.NODE_ENV === "development" && DevTestbed && ( + {process.env.NODE_ENV === 'development' && DevTestbed && (
+ width: '40%', + borderLeft: '2px solid #e0e0e0', + backgroundColor: '#fafafa', + }}> diff --git a/formulus-formplayer/src/DynamicEnumControl.tsx b/formulus-formplayer/src/DynamicEnumControl.tsx index deb699ebd..84c2e5273 100644 --- a/formulus-formplayer/src/DynamicEnumControl.tsx +++ b/formulus-formplayer/src/DynamicEnumControl.tsx @@ -6,11 +6,11 @@ * from database queries at runtime. */ -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { withJsonFormsControlProps } from "@jsonforms/react"; -import { ControlProps, rankWith } from "@jsonforms/core"; -import { useFormEvaluation } from "./FormEvaluationContext"; -import { useJsonForms } from "@jsonforms/react"; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { ControlProps, rankWith } from '@jsonforms/core'; +import { useFormEvaluation } from './FormEvaluationContext'; +import { useJsonForms } from '@jsonforms/react'; import { Autocomplete, TextField, @@ -18,7 +18,7 @@ import { Typography, Alert, CircularProgress, -} from "@mui/material"; +} from '@mui/material'; /** * Interface for x-dynamicEnum configuration @@ -38,16 +38,16 @@ interface DynamicEnumConfig { */ function resolveSchemaFromScope( scope: string | undefined, - rootSchema: any + 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 !== "#"); + const parts = scope.split('/').filter(p => p && p !== '#'); let resolved = rootSchema; for (const part of parts) { - if (resolved && typeof resolved === "object") { + if (resolved && typeof resolved === 'object') { resolved = resolved[part]; } else { return rootSchema; // Fallback to root if path invalid @@ -65,8 +65,8 @@ export const dynamicEnumTester = rankWith( (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"]; - } + return !!(fieldSchema as any)?.['x-dynamicEnum']; + }, ); /** @@ -75,27 +75,27 @@ 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("}}") + 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; + const dataPath = path.startsWith('data.') ? path.slice(5) : path; // Get nested value - const pathParts = dataPath.split("."); + const pathParts = dataPath.split('.'); let resolvedValue: any = formData; for (const part of pathParts) { - if (resolvedValue && typeof resolvedValue === "object") { + if (resolvedValue && typeof resolvedValue === 'object') { resolvedValue = resolvedValue[part]; } else { resolvedValue = undefined; @@ -129,7 +129,7 @@ const DynamicEnumControl: React.FC = ({ const ctx = useJsonForms(); const [choices, setChoices] = useState>( - [] + [], ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -137,26 +137,26 @@ const DynamicEnumControl: React.FC = ({ // Get x-dynamicEnum configuration first const dynamicConfig = useMemo(() => { - return (schema as any)?.["x-dynamicEnum"] as DynamicEnumConfig | undefined; + return (schema as any)?.['x-dynamicEnum'] as DynamicEnumConfig | undefined; }, [schema]); // Get current form data for template parameter resolution const currentFormData = useMemo( () => ctx?.core?.data || {}, - [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, newValue ? newValue.const : ''); }, - [handleChange, path] + [handleChange, path], ); // Find selected option based on current data value - must be before early returns const selectedOption = useMemo(() => { - return choices.find((opt) => opt.const === data) || null; + return choices.find(opt => opt.const === data) || null; }, [choices, data]); // Get display label from schema or uischema - computed before early returns @@ -164,8 +164,8 @@ const DynamicEnumControl: React.FC = ({ return ( (uischema as any)?.label || schema.title || - path.split(".").pop() || - "Field" + path.split('.').pop() || + 'Field' ); }, [uischema, schema, path]); @@ -175,25 +175,25 @@ const DynamicEnumControl: React.FC = ({ // Load choices when component mounts or params change const loadChoices = useCallback(async () => { if (!dynamicConfig) { - setError("x-dynamicEnum configuration is missing"); + setError('x-dynamicEnum configuration is missing'); return; } // Validate configuration if (!dynamicConfig.query) { - setError("x-dynamicEnum: query is required"); + setError('x-dynamicEnum: query is required'); return; } - const functionName = dynamicConfig.function || "getDynamicChoiceList"; + const functionName = dynamicConfig.function || 'getDynamicChoiceList'; const func = functions.get(functionName); if (!func) { - const availableFunctions = Array.from(functions.keys()).join(", "); + const availableFunctions = Array.from(functions.keys()).join(', '); setError( `Function "${functionName}" not found. Available: ${ - availableFunctions || "none" - }.` + availableFunctions || 'none' + }.`, ); return; } @@ -206,7 +206,7 @@ const DynamicEnumControl: React.FC = ({ const resolvedParams = dynamicConfig.params ? resolveTemplateParams( dynamicConfig.params, - currentFormData as Record + currentFormData as Record, ) : {}; @@ -214,10 +214,10 @@ const DynamicEnumControl: React.FC = ({ const paramsWithConfig = { ...resolvedParams, _config: { - valueField: dynamicConfig.valueField || "observationId", - labelField: dynamicConfig.labelField || "data.name", + valueField: dynamicConfig.valueField || 'observationId', + labelField: dynamicConfig.labelField || 'data.name', distinct: dynamicConfig.distinct || false, - distinctField: dynamicConfig.labelField || "data.name", + distinctField: dynamicConfig.labelField || 'data.name', }, }; @@ -225,7 +225,7 @@ const DynamicEnumControl: React.FC = ({ const result = await func( dynamicConfig.query, paramsWithConfig, - currentFormData + currentFormData, ); if (!Array.isArray(result)) { @@ -237,11 +237,11 @@ const DynamicEnumControl: React.FC = ({ // Update local schema with dynamic enum const updatedSchema = { ...localSchema, - enum: result.map((item) => item.const), + enum: result.map(item => item.const), }; setLocalSchema(updatedSchema); } catch (err: any) { - const errorMessage = err?.message || "Failed to load dynamic choices"; + const errorMessage = err?.message || 'Failed to load dynamic choices'; setError(`${errorMessage}`); console.error(`Error loading dynamic choices for ${path}:`, err); } finally { @@ -294,7 +294,7 @@ const DynamicEnumControl: React.FC = ({ {/* Field Label */} {label} - {schema.required && *} + {schema.required && *} {/* Description */} @@ -307,7 +307,7 @@ const DynamicEnumControl: React.FC = ({ {/* Validation Errors */} {hasValidationErrors && ( - {Array.isArray(errors) ? errors.join(", ") : String(errors)} + {Array.isArray(errors) ? errors.join(', ') : String(errors)} )} {/* Control */} @@ -326,9 +326,8 @@ const DynamicEnumControl: React.FC = ({ + sx={{ cursor: 'pointer', textDecoration: 'underline' }} + onClick={loadChoices}> Retry @@ -341,20 +340,20 @@ 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 fe7fdfbbe..65735747e 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -6,7 +6,7 @@ * defined in ext.json files. */ -import React, { createContext, useContext, ReactNode } from "react"; +import React, { createContext, useContext, ReactNode } from 'react'; /** Extension function type (query name, params, form data) => result */ export type ExtensionFunction = (...args: unknown[]) => unknown; diff --git a/formulus-formplayer/src/builtinExtensions.ts b/formulus-formplayer/src/builtinExtensions.ts index 477368f7c..e74030404 100644 --- a/formulus-formplayer/src/builtinExtensions.ts +++ b/formulus-formplayer/src/builtinExtensions.ts @@ -65,18 +65,18 @@ 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) { - console.error("getDynamicChoiceList: getObservationsByQuery not available"); + console.error('getDynamicChoiceList: getObservationsByQuery not available'); return []; } try { // Extract configuration const config = params._config || {}; - const valueField = config.valueField || "observationId"; + const valueField = config.valueField || 'observationId'; const labelField = config.labelField || valueField; const distinct = config.distinct || false; @@ -86,14 +86,14 @@ export async function getDynamicChoiceList( // Get filter params (excluding _config, where, and whereClause) const filterParams = Object.entries(params).filter( - ([key]) => key !== "_config" && key !== "where" && key !== "whereClause" + ([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 === "" + ([_, value]) => value === null || value === undefined || value === '', ); if (hasEmptyValue) { @@ -107,7 +107,7 @@ export async function getDynamicChoiceList( const escapedValue = String(value).replace(/'/g, "''"); return `data.${fieldPath} = '${escapedValue}'`; }) - .join(" AND "); + .join(' AND '); // Combine with existing WHERE clause if present if (whereClause) { @@ -119,7 +119,7 @@ export async function getDynamicChoiceList( // 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); + return path.split('.').reduce((current, key) => current?.[key], obj); }; // Check if WHERE clause uses age_from_dob() syntax @@ -142,8 +142,8 @@ export async function getDynamicChoiceList( const condition = parts[i].trim(); // Remove leading/trailing parentheses and NOT const cleanCondition = condition - .replace(/^NOT\s+/i, "") - .replace(/^\(+|\)+$/g, "") + .replace(/^NOT\s+/i, '') + .replace(/^\(+|\)+$/g, '') .trim(); if ( cleanCondition && @@ -156,7 +156,7 @@ export async function getDynamicChoiceList( // Rebuild WHERE clause without age conditions if (nonAgeConditions.length > 0) { - whereClause = nonAgeConditions.join(" AND "); + whereClause = nonAgeConditions.join(' AND '); } else { whereClause = null; // No non-age conditions, fetch all and filter in JS } @@ -188,7 +188,7 @@ export async function getDynamicChoiceList( // 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 hasNot = !!(match[1] && match[1].trim().toUpperCase() === 'NOT'); const beforeText = originalWhereClause.substring(0, match.index); ageConditions.push({ @@ -213,26 +213,26 @@ export async function getDynamicChoiceList( // Helper to evaluate a single age condition const evaluateCondition = ( - condition: (typeof ageConditions)[0] + condition: (typeof ageConditions)[0], ): boolean => { let result: boolean; switch (condition.operator) { - case ">=": + case '>=': result = age >= condition.threshold; break; - case "<=": + case '<=': result = age <= condition.threshold; break; - case ">": + case '>': result = age > condition.threshold; break; - case "<": + case '<': result = age < condition.threshold; break; - case "=": + case '=': result = age === condition.threshold; break; - case "!=": + case '!=': result = age !== condition.threshold; break; default: @@ -263,19 +263,19 @@ export async function getDynamicChoiceList( (originalWhereClause .substring(condition.position) .match( - /age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i + /age_from_dob\([^)]+\)\s*(>=|<=|>|<|=|!=)\s*\d+/i, )?.[0]?.length || 0), - nextCondition.position + nextCondition.position, ); // Check for OR (takes precedence in parsing) if (/\bOR\b/i.test(betweenText)) { - logics.push("OR"); + logics.push('OR'); } else if (/\bAND\b/i.test(betweenText)) { - logics.push("AND"); + logics.push('AND'); } else { // Default to AND if no explicit operator - logics.push("AND"); + logics.push('AND'); } } } @@ -288,7 +288,7 @@ export async function getDynamicChoiceList( const firstConditionStart = ageConditions[0].position; const beforeFirst = originalWhereClause.substring( 0, - firstConditionStart + firstConditionStart, ); const lastCondition = ageConditions[ageConditions.length - 1]; const lastConditionEnd = @@ -303,7 +303,7 @@ export async function getDynamicChoiceList( const notBefore = /NOT\s*\(/i.test(beforeFirst.trim().slice(-10)); const closingParenAfter = /^\s*\)/.test(afterLast); const hasOpeningParen = /\(\s*$/.test( - beforeFirst.trim().slice(-5) + beforeFirst.trim().slice(-5), ); // Check if conditions are grouped in parentheses @@ -315,41 +315,41 @@ export async function getDynamicChoiceList( if (logics.length === 0) { // Single condition finalResult = results[0]; - } else if (logics.every((l) => l === "AND")) { + } 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")) { + 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); + 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") + logics[0] === 'AND' && + logics.some(l => l === 'OR') ) { // Find where OR starts (after grouped AND conditions) - const orIndex = logics.findIndex((l) => l === "OR"); + 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); + .every(r => r); // Then OR with remaining conditions: OR C const remainingResults = results.slice(orIndex + 1); finalResult = - groupedResult || remainingResults.some((r) => r); + groupedResult || remainingResults.some(r => r); } else { // Fallback: OR all results - finalResult = results.some((r) => r); + 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") { + if (logics[i] === 'OR') { finalResult = finalResult || results[i + 1]; } else { finalResult = finalResult && results[i + 1]; @@ -386,13 +386,13 @@ export async function getDynamicChoiceList( // Filter out null/undefined values choices = choices.filter( - (choice) => choice.const != null && choice.const !== "" + choice => choice.const != null && choice.const !== '', ); // Apply distinct if requested if (distinct) { const seen = new Set(); - choices = choices.filter((choice) => { + choices = choices.filter(choice => { const key = String(choice.const); if (seen.has(key)) return false; seen.add(key); @@ -402,7 +402,7 @@ export async function getDynamicChoiceList( return choices; } catch (error: unknown) { - console.error("getDynamicChoiceList error:", error); + console.error('getDynamicChoiceList error:', error); return []; } } @@ -411,7 +411,7 @@ export async function getDynamicChoiceList( type BuiltinExtensionFn = ( queryName: string, params: Record, - formData?: Record + formData?: Record, ) => Promise>; /** @@ -420,6 +420,6 @@ type BuiltinExtensionFn = ( */ export function getBuiltinExtensions(): Map { const functions = new Map(); - functions.set("getDynamicChoiceList", getDynamicChoiceList); + functions.set('getDynamicChoiceList', getDynamicChoiceList); return functions; } diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index c7ff387e8..66c561d3a 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -5,7 +5,7 @@ * from custom app extensions. */ -import { JsonFormsRendererRegistryEntry, RankedTester } from "@jsonforms/core"; +import { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; /** * Extension metadata passed from Formulus @@ -56,7 +56,7 @@ export interface ExtensionLoadResult { * Load extensions dynamically */ export async function loadExtensions( - metadata: ExtensionMetadata + metadata: ExtensionMetadata, ): Promise { const result: ExtensionLoadResult = { renderers: [], @@ -65,7 +65,7 @@ export async function loadExtensions( errors: [], }; - const basePath = metadata.basePath || ""; + const basePath = metadata.basePath || ''; // Load renderers if (metadata.renderers) { @@ -80,7 +80,7 @@ export async function loadExtensions( } } catch (error) { result.errors.push({ - type: "renderer_load_error", + type: 'renderer_load_error', message: `Failed to load renderer ${key}: ${ error instanceof Error ? error.message : String(error) }`, @@ -99,12 +99,12 @@ export async function loadExtensions( if (loadedFunction) { result.functions.set(funcMeta.name, loadedFunction); console.log( - `[ExtensionsLoader] Registered extension function "${funcMeta.name}" from module "${funcMeta.module}" (metadata key: ${key})` + `[ExtensionsLoader] Registered extension function "${funcMeta.name}" from module "${funcMeta.module}" (metadata key: ${key})`, ); } } catch (error) { result.errors.push({ - type: "function_load_error", + type: 'function_load_error', message: `Failed to load function ${key}: ${ error instanceof Error ? error.message : String(error) }`, @@ -123,7 +123,7 @@ export async function loadExtensions( */ async function loadRenderer( metadata: ExtensionRendererMetadata, - basePath: string + basePath: string, ): Promise { try { // Construct module path @@ -144,7 +144,7 @@ async function loadRenderer( ); } catch { // Fallback: try with .js extension - const modulePathWithExt = modulePath.endsWith(".js") + const modulePathWithExt = modulePath.endsWith('.js') ? modulePath : `${modulePath}.js`; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -169,7 +169,7 @@ async function loadRenderer( /* @vite-ignore */ /* webpackIgnore: true */ testerModulePath ); } catch { - const pathWithExt = testerModulePath.endsWith(".js") + const pathWithExt = testerModulePath.endsWith('.js') ? testerModulePath : `${testerModulePath}.js`; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -183,7 +183,7 @@ async function loadRenderer( if (!tester) { throw new Error( `Tester function "${testerName}" not found in module ${metadata.module}` + - (metadata.testerModule ? ` or ${metadata.testerModule}` : "") + (metadata.testerModule ? ` or ${metadata.testerModule}` : ''), ); } @@ -193,7 +193,7 @@ async function loadRenderer( module[rendererName] || module.default?.renderer || module.default; if (!renderer) { throw new Error( - `Renderer component "${rendererName}" not found in module ${metadata.module}` + `Renderer component "${rendererName}" not found in module ${metadata.module}`, ); } @@ -212,7 +212,7 @@ async function loadRenderer( */ async function loadFunction( metadata: ExtensionFunctionMetadata, - basePath: string + basePath: string, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type ): Promise { try { @@ -232,7 +232,7 @@ async function loadFunction( /* @vite-ignore */ /* webpackIgnore: true */ modulePath ); } catch { - const modulePathWithExt = modulePath.endsWith(".js") + const modulePathWithExt = modulePath.endsWith('.js') ? modulePath : `${modulePath}.js`; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -245,9 +245,9 @@ async function loadFunction( // Get function const exportName = metadata.export || metadata.name; const func = module[exportName] || module.default; - if (!func || typeof func !== "function") { + if (!func || typeof func !== 'function') { throw new Error( - `Function "${exportName}" not found or not a function in module ${metadata.module}` + `Function "${exportName}" not found or not a function in module ${metadata.module}`, ); } From 984e465e35d803617f3552fa37501627100b3df3 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 15:45:16 +0300 Subject: [PATCH 5/9] Fix code formatting --- formulus-formplayer/prettier.config.cjs | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 formulus-formplayer/prettier.config.cjs diff --git a/formulus-formplayer/prettier.config.cjs b/formulus-formplayer/prettier.config.cjs new file mode 100644 index 000000000..c0a2753b3 --- /dev/null +++ b/formulus-formplayer/prettier.config.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/configuration + * Uses .cjs so Prettier receives plain config (avoids ESM default-export wrapper warnings) + */ +module.exports = { + arrowParens: 'avoid', + bracketSameLine: true, + bracketSpacing: true, + singleQuote: true, + trailingComma: 'all', +}; From 3e8b74a8299aa5386e97cedd0bac7daa5a7eea1f Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 16:02:12 +0300 Subject: [PATCH 6/9] Ignore the Prettier config in ESLint --- formulus-formplayer/eslint.config.js | 1 + 1 file changed, 1 insertion(+) 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, From 619878fee054e8c4c825ded5cef4db07ade73cf3 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 17:10:46 +0300 Subject: [PATCH 7/9] Fix code formatting --- formulus-formplayer/prettier.config.js | 14 -------------- formulus-formplayer/src/FormEvaluationContext.tsx | 4 ++-- 2 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 formulus-formplayer/prettier.config.js diff --git a/formulus-formplayer/prettier.config.js b/formulus-formplayer/prettier.config.js deleted file mode 100644 index ff4847fd8..000000000 --- a/formulus-formplayer/prettier.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @see https://prettier.io/docs/configuration - * @type {import("prettier").Config} - */ - -const config = { - arrowParens: 'avoid', - bracketSameLine: true, - bracketSpacing: true, - singleQuote: true, - trailingComma: 'all', -}; - -export default config; diff --git a/formulus-formplayer/src/FormEvaluationContext.tsx b/formulus-formplayer/src/FormEvaluationContext.tsx index 65735747e..1aaffe31f 100644 --- a/formulus-formplayer/src/FormEvaluationContext.tsx +++ b/formulus-formplayer/src/FormEvaluationContext.tsx @@ -8,8 +8,8 @@ import React, { createContext, useContext, ReactNode } from 'react'; -/** Extension function type (query name, params, form data) => result */ -export type ExtensionFunction = (...args: unknown[]) => unknown; +/** Extension function type - accepts any callable (builtins, loaded extensions) */ +export type ExtensionFunction = (...args: any[]) => any; /** * Context value for form evaluation From 370295e6c16dff394ee7cc4c21363f84464bb306 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 17:14:01 +0300 Subject: [PATCH 8/9] Fix code formatting --- formulus-formplayer/package-lock.json | 43 ------------------- formulus-formplayer/src/App.tsx | 2 +- .../src/components/FormLayout.tsx | 24 ++++++++--- .../src/renderers/FinalizeRenderer.tsx | 4 +- .../src/renderers/SwipeLayoutRenderer.tsx | 4 +- formulus-formplayer/src/theme/theme.ts | 12 ++++-- .../src/types/FormulusInterfaceDefinition.ts | 2 + 7 files changed, 36 insertions(+), 55 deletions(-) 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/src/App.tsx b/formulus-formplayer/src/App.tsx index a5170f6d3..9dc1f108a 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -410,7 +410,7 @@ function App() { } else { const defaultData = params && typeof params === 'object' - ? params.defaultData ?? params + ? (params.defaultData ?? params) : {}; console.log('Preloading initialization form values:', defaultData); setData(defaultData as FormData); 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/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 From 54a50a182e2b333dfcba5c97acafab9ef4752af0 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Sun, 8 Feb 2026 17:20:26 +0300 Subject: [PATCH 9/9] Fix type mismatch --- formulus-formplayer/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 9dc1f108a..5e2dcdf1c 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -349,7 +349,7 @@ function App() { // 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);