From aef46f2ad66bcef64d0bdc9eb0fef54de3d4eb43 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Wed, 8 Apr 2026 17:24:27 +0300 Subject: [PATCH 1/7] Fix: Linked table nested form lifecycle - preserve parent form state across child form sessions --- formulus/App.tsx | 137 ++++++++++++------ formulus/src/components/CustomAppWebView.tsx | 2 + formulus/src/components/FormplayerModal.tsx | 27 +++- .../src/webview/FormulusWebViewHandler.ts | 23 +++ 4 files changed, 139 insertions(+), 50 deletions(-) diff --git a/formulus/App.tsx b/formulus/App.tsx index 259c3acee..fa4fe746e 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -19,6 +19,7 @@ import QRScannerModal from './src/components/QRScannerModal'; import SignatureCaptureModal from './src/components/SignatureCaptureModal'; import MainAppNavigator from './src/navigation/MainAppNavigator'; import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; +import { FormSpec } from './src/services'; /** * Inner component that consumes the AppTheme context to build a dynamic @@ -58,13 +59,58 @@ function AppInner(): React.JSX.Element { onResult: (result: unknown) => void; } | null>(null); - const [formplayerVisible, setFormplayerVisible] = useState(false); - const formplayerModalRef = React.useRef(null); - const formplayerVisibleRef = React.useRef(false); + type FormplayerStackEntry = { + id: string; + formSpec: FormSpec; + params: Record | null; + observationId: string | null; + savedData: Record | null; + operationId: string | null; + }; - useEffect(() => { - formplayerVisibleRef.current = formplayerVisible; - }, [formplayerVisible]); + const [formplayerStack, setFormplayerStack] = useState< + FormplayerStackEntry[] + >([]); + const formplayerModalRefs = React.useRef( + new Map(), + ); + + const initializeStackEntry = React.useCallback( + (entry: FormplayerStackEntry) => { + let attempt = 0; + + const tryInitialize = () => { + const modalHandle = formplayerModalRefs.current.get(entry.id); + if (!modalHandle) { + if (attempt < 20) { + attempt += 1; + setTimeout(tryInitialize, 100); + } + return; + } + + setTimeout(() => { + modalHandle.initializeForm( + entry.formSpec, + entry.params, + entry.observationId, + entry.savedData, + entry.operationId, + ); + }, 200); + }; + + tryInitialize(); + }, + [], + ); + + const closeFormplayerEntry = React.useCallback((entryId: string) => { + formplayerModalRefs.current.delete(entryId); + setFormplayerStack(current => + current.filter(entry => entry.id !== entryId), + ); + }, []); useEffect(() => { FormService.getInstance(); @@ -92,17 +138,6 @@ function AppInner(): React.JSX.Element { ); const handleOpenFormplayer = async (config: FormInitData) => { - // If formplayer is already visible, close it first to allow opening a new form - if (formplayerVisibleRef.current) { - console.log( - '[App] Formplayer already visible, closing first before opening new form', - ); - formplayerVisibleRef.current = false; - setFormplayerVisible(false); - // Wait for modal to close before proceeding - await new Promise(resolve => setTimeout(() => resolve(), 300)); - } - const { formType, observationId, params, savedData, operationId } = config; @@ -127,21 +162,20 @@ function AppInner(): React.JSX.Element { return; } - // Set visible state first to mount the modal - formplayerVisibleRef.current = true; - setFormplayerVisible(true); + const entryId = + operationId || + `${formType}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const entry: FormplayerStackEntry = { + id: entryId, + formSpec, + params: params || null, + observationId: observationId || null, + savedData: savedData || null, + operationId: operationId || null, + }; - // Wait for modal to mount and WebView to start loading before initializing form - // This ensures the WebView ref is available and the modal is visible - setTimeout(() => { - formplayerModalRef.current?.initializeForm( - formSpec, - params || null, - observationId || null, - savedData || null, - operationId || null, - ); - }, 200); + setFormplayerStack(current => [...current, entry]); + initializeStackEntry(entry); } catch (error) { console.error('[App] Error opening formplayer:', error); Alert.alert( @@ -150,15 +184,19 @@ function AppInner(): React.JSX.Element { error instanceof Error ? error.message : 'Unknown error' }`, ); - // Reset state on error - formplayerVisibleRef.current = false; - setFormplayerVisible(false); } }; const handleCloseFormplayer = () => { - formplayerVisibleRef.current = false; - setFormplayerVisible(false); + setFormplayerStack(current => { + if (current.length === 0) { + return current; + } + const next = current.slice(0, -1); + const removed = current[current.length - 1]; + formplayerModalRefs.current.delete(removed.id); + return next; + }); }; appEvents.addListener( @@ -182,7 +220,7 @@ function AppInner(): React.JSX.Element { ); appEvents.removeListener('closeFormplayer', handleCloseFormplayer); }; - }, []); + }, [closeFormplayerEntry, initializeStackEntry]); return ( <> @@ -192,14 +230,23 @@ function AppInner(): React.JSX.Element { /> - { - formplayerVisibleRef.current = false; - setFormplayerVisible(false); - }} - /> + {formplayerStack.map((entry, index) => ( + { + if (instance) { + formplayerModalRefs.current.set(entry.id, instance); + } else { + formplayerModalRefs.current.delete(entry.id); + } + }} + visible={true} + isActive={index === formplayerStack.length - 1} + onClose={() => { + closeFormplayerEntry(entry.id); + }} + /> + ))} void; sendFormInit: (formData: FormInitData) => Promise; sendAttachmentData: (attachmentData: File) => Promise; + notifyReceiveFocus: () => void; } interface CustomAppWebViewProps { @@ -340,6 +341,7 @@ const CustomAppWebView = forwardRef< messageManager.sendFormInit(formData), sendAttachmentData: (attachmentData: File) => messageManager.sendAttachmentData(attachmentData), + notifyReceiveFocus: () => messageManager.notifyReceiveFocus(), }), [messageManager], ); diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 11e349991..9813c5ac4 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -48,6 +48,7 @@ import { geolocationService } from '../services/GeolocationService'; interface FormplayerModalProps { visible: boolean; + isActive?: boolean; onClose: () => void; } @@ -67,7 +68,7 @@ export interface FormplayerModalHandle { } const FormplayerModal = forwardRef( - ({ visible, onClose }, ref) => { + ({ visible, isActive = true, onClose }, ref) => { const webViewRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const { showConfirm } = useConfirmModal(); @@ -191,6 +192,7 @@ const FormplayerModal = forwardRef( // Track WebView ready state const [webViewReady, setWebViewReady] = useState(false); + const previousIsActiveRef = useRef(isActive); // Handle WebView load complete const handleWebViewLoad = () => { @@ -585,14 +587,16 @@ const FormplayerModal = forwardRef( // Register/unregister modal with message handlers and reset form state useEffect(() => { - if (visible) { + if (visible && isActive) { // Register this modal as the active one for handling submissions setActiveFormplayerModal({ handleSubmission }); } else { - // Unregister when modal is closed + // Inactive/hidden modals must not handle submissions. setActiveFormplayerModal(null); + } - // Reset form state when modal is closed + if (!visible) { + // Reset form state only when the modal actually closes. setTimeout(() => { setCurrentFormType(null); setCurrentFormDisplayName(null); @@ -603,7 +607,20 @@ const FormplayerModal = forwardRef( setWebViewReady(false); // Reset WebView ready state }, 300); // Small delay to ensure modal is fully closed } - }, [visible, handleSubmission]); + }, [visible, isActive, handleSubmission]); + + useEffect(() => { + if ( + visible && + isActive && + webViewReady && + currentFormType && + previousIsActiveRef.current === false + ) { + webViewRef.current?.notifyReceiveFocus(); + } + previousIsActiveRef.current = isActive; + }, [visible, isActive, webViewReady, currentFormType]); useImperativeHandle(ref, () => ({ initializeForm, handleSubmission })); diff --git a/formulus/src/webview/FormulusWebViewHandler.ts b/formulus/src/webview/FormulusWebViewHandler.ts index b04f511c6..e1bfc8a1f 100644 --- a/formulus/src/webview/FormulusWebViewHandler.ts +++ b/formulus/src/webview/FormulusWebViewHandler.ts @@ -264,7 +264,30 @@ export class FormulusWebViewMessageManager { } } + public notifyReceiveFocus(): void { + if (!this.webViewRef.current || !this.isWebViewReady) { + return; + } + + this.webViewRef.current.injectJavaScript(` + (function() { + try { + if (typeof window.onReceiveFocus === 'function') { + Promise.resolve(window.onReceiveFocus()).catch(function(error) { + console.error('Error in window.onReceiveFocus:', error); + }); + } + } catch (error) { + console.error('Error invoking window.onReceiveFocus:', error); + } + })(); + true; + `); + } + public handleReceiveFocus(): void { + this.notifyReceiveFocus(); + // Optionally call native-side handler if it exists for onReceiveFocus if (this.nativeSideHandlers.onReceiveFocus) { try { From 4116a5432d4212b641226ca2006cf1bffed6d11a Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Fri, 10 Apr 2026 10:36:22 +0300 Subject: [PATCH 2/7] feat: Add returnOnly support for embedded child forms in Formulus --- formulus/App.tsx | 5 +- formulus/src/components/FormplayerModal.tsx | 69 ++++++++++++------- .../webview/FormulusInterfaceDefinition.ts | 1 + 3 files changed, 50 insertions(+), 25 deletions(-) diff --git a/formulus/App.tsx b/formulus/App.tsx index fa4fe746e..090c12d57 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -66,6 +66,7 @@ function AppInner(): React.JSX.Element { observationId: string | null; savedData: Record | null; operationId: string | null; + returnOnly?: boolean; // For child forms opened from linkedtable }; const [formplayerStack, setFormplayerStack] = useState< @@ -96,6 +97,7 @@ function AppInner(): React.JSX.Element { entry.observationId, entry.savedData, entry.operationId, + entry.returnOnly, // ← Pass returnOnly flag ); }, 200); }; @@ -138,7 +140,7 @@ function AppInner(): React.JSX.Element { ); const handleOpenFormplayer = async (config: FormInitData) => { - const { formType, observationId, params, savedData, operationId } = + const { formType, observationId, params, savedData, operationId, returnOnly } = config; try { @@ -172,6 +174,7 @@ function AppInner(): React.JSX.Element { observationId: observationId || null, savedData: savedData || null, operationId: operationId || null, + returnOnly: returnOnly || false, // Include returnOnly flag }; setFormplayerStack(current => [...current, entry]); diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 9813c5ac4..d93596c50 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -59,6 +59,7 @@ export interface FormplayerModalHandle { observationId: string | null, existingObservationData: Record | null, operationId: string | null, + returnOnly?: boolean, ) => void; handleSubmission: (data: { formType: string; @@ -95,6 +96,10 @@ const FormplayerModal = forwardRef( // Track if form has been successfully submitted to avoid double resolution const [formSubmitted, setFormSubmitted] = useState(false); + // Track if this form should return JSON only without saving to database + // Used for child forms embedded in linked-table scenarios + const [returnOnly, setReturnOnly] = useState(false); + // Author-configurable display name shown in the native header bar const [currentFormDisplayName, setCurrentFormDisplayName] = useState< string | null @@ -208,6 +213,7 @@ const FormplayerModal = forwardRef( observationId: string | null, existingObservationData: Record | null, operationId: string | null, + returnOnlyMode: boolean = false, ) => { // Check if WebView is ready, if not log a warning (retry logic will handle it) if (!webViewReady) { @@ -216,6 +222,9 @@ const FormplayerModal = forwardRef( ); } + // Set returnOnly flag for this form session + setReturnOnly(returnOnlyMode); + // GPS session: fresh fix + light watch while the user fills the form geolocationService.beginObservationSession(); @@ -489,32 +498,44 @@ const FormplayerModal = forwardRef( setIsSubmitting(true); try { - // Get the local repository from the database service - const localRepo = databaseService.getLocalRepo(); - if (!localRepo) { - throw new Error('Database repository not available'); - } - - // Save the observation + // Save the observation (optional - skip if returnOnly flag is set) let resultObservationId: string; - if (effectiveObservationId) { - const updateSuccess = await localRepo.updateObservation({ - observationId: effectiveObservationId, - data: finalData, - }); - if (!updateSuccess) { - throw new Error('Failed to update observation'); + + if (!returnOnly) { + // Normal mode: save to database + const localRepo = databaseService.getLocalRepo(); + if (!localRepo) { + throw new Error('Database repository not available'); } - resultObservationId = effectiveObservationId; - } else { - const newId = await localRepo.saveObservation({ - formType, - data: finalData, - }); - if (!newId) { - throw new Error('Failed to save new observation'); + + if (effectiveObservationId) { + const updateSuccess = await localRepo.updateObservation({ + observationId: effectiveObservationId, + data: finalData, + }); + if (!updateSuccess) { + throw new Error('Failed to update observation'); + } + resultObservationId = effectiveObservationId; + } else { + const newId = await localRepo.saveObservation({ + formType, + data: finalData, + }); + if (!newId) { + throw new Error('Failed to save new observation'); + } + resultObservationId = newId; } - resultObservationId = newId; + } else { + // returnOnly mode: just generate ID, don't save to database + // This is used for embedded child forms in linked-table scenarios + resultObservationId = + effectiveObservationId || crypto.randomUUID(); + console.log( + '[FormplayerModal] Form returned without DB save (returnOnly mode):', + resultObservationId, + ); } // Mark form as successfully submitted @@ -582,7 +603,7 @@ const FormplayerModal = forwardRef( throw error; } }, - [currentObservationId, currentOperationId, onClose, showConfirm], + [currentObservationId, currentOperationId, onClose, showConfirm, returnOnly], ); // Register/unregister modal with message handlers and reset form state diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 644cc0e39..7efab31a5 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -56,6 +56,7 @@ export interface FormInitData { formSchema?: unknown; uiSchema?: unknown; operationId?: string; + returnOnly?: boolean; // For embedded child forms: return JSON without saving to DB extensions?: ExtensionMetadata; customQuestionTypes?: { custom_types: Record; From db15d86eaddc3ca5496c6c5f588c65f6866f6bf0 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Sat, 11 Apr 2026 15:21:58 +0300 Subject: [PATCH 3/7] fix(formplayer): remove observation metadata and uuid fallback for returnOnly child forms --- formulus/src/components/FormplayerModal.tsx | 3 +-- formulus/src/webview/FormulusInterfaceDefinition.ts | 1 + formulus/src/webview/FormulusMessageHandlers.ts | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index d93596c50..8e5d472f0 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -530,8 +530,7 @@ const FormplayerModal = forwardRef( } else { // returnOnly mode: just generate ID, don't save to database // This is used for embedded child forms in linked-table scenarios - resultObservationId = - effectiveObservationId || crypto.randomUUID(); + resultObservationId = ''; console.log( '[FormplayerModal] Form returned without DB save (returnOnly mode):', resultObservationId, diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 7efab31a5..521ebccdf 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -285,6 +285,7 @@ export interface FormulusInterface { formType: string, params: Record, savedData: Record, + options?: { returnOnly?: boolean }, ): Promise; /** diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index 0bedfec03..4064a67f7 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -116,6 +116,7 @@ const startFormplayerOperation = ( params: Record = {}, savedData: Record = {}, observationId: string | null = null, + returnOnly: boolean = false, ): Promise => { const operationId = `${formType}_${Date.now()}_${Math.random() .toString(36) @@ -135,6 +136,7 @@ const startFormplayerOperation = ( savedData, observationId, operationId, + returnOnly, }); setTimeout( @@ -1168,12 +1170,13 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { const service = await FormService.getInstance(); return await service.getObservationsByQuery(options); }, - onOpenFormplayer: async (data: FormInitData) => { + onOpenFormplayer: async (data: FormInitData & { options?: { returnOnly?: boolean } }) => { return startFormplayerOperation( data.formType, data.params, data.savedData, data.observationId ?? null, + data.options?.returnOnly || data.returnOnly || false, ); }, onFormplayerInitialized: (_data: { From c7a432c73d948ab90a7974ce16d7cf3152c213a4 Mon Sep 17 00:00:00 2001 From: Ndacyayisenga-droid Date: Sun, 12 Apr 2026 00:18:45 +0300 Subject: [PATCH 4/7] fix prettier --- formulus/App.tsx | 16 +- formulus/assets/webview/placeholder_app.html | 264 ++++++++++-------- formulus/formplayer_question_types.md | 1 - formulus/scripts/vendor-notifee-core.mjs | 4 +- formulus/src/components/FormplayerModal.tsx | 10 +- .../webview/FormulusInterfaceDefinition.ts | 2 +- .../src/webview/FormulusMessageHandlers.ts | 4 +- 7 files changed, 176 insertions(+), 125 deletions(-) diff --git a/formulus/App.tsx b/formulus/App.tsx index 090c12d57..680b12fdb 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -66,7 +66,7 @@ function AppInner(): React.JSX.Element { observationId: string | null; savedData: Record | null; operationId: string | null; - returnOnly?: boolean; // For child forms opened from linkedtable + returnOnly?: boolean; // For child forms opened from linkedtable }; const [formplayerStack, setFormplayerStack] = useState< @@ -97,7 +97,7 @@ function AppInner(): React.JSX.Element { entry.observationId, entry.savedData, entry.operationId, - entry.returnOnly, // ← Pass returnOnly flag + entry.returnOnly, // ← Pass returnOnly flag ); }, 200); }; @@ -140,8 +140,14 @@ function AppInner(): React.JSX.Element { ); const handleOpenFormplayer = async (config: FormInitData) => { - const { formType, observationId, params, savedData, operationId, returnOnly } = - config; + const { + formType, + observationId, + params, + savedData, + operationId, + returnOnly, + } = config; try { const formService = await FormService.getInstance(); @@ -174,7 +180,7 @@ function AppInner(): React.JSX.Element { observationId: observationId || null, savedData: savedData || null, operationId: operationId || null, - returnOnly: returnOnly || false, // Include returnOnly flag + returnOnly: returnOnly || false, // Include returnOnly flag }; setFormplayerStack(current => [...current, entry]); diff --git a/formulus/assets/webview/placeholder_app.html b/formulus/assets/webview/placeholder_app.html index 9fe62ead1..52454cc61 100644 --- a/formulus/assets/webview/placeholder_app.html +++ b/formulus/assets/webview/placeholder_app.html @@ -1,11 +1,11 @@ - + Custom App Placeholder - +
-

Your Custom
App

+

Your Custom
App

- Login and Update App Bundle to load your forms + Login and Update App Bundle to load + your forms

diff --git a/formulus/formplayer_question_types.md b/formulus/formplayer_question_types.md index 7a02aa5af..228beddae 100644 --- a/formulus/formplayer_question_types.md +++ b/formulus/formplayer_question_types.md @@ -104,7 +104,6 @@ Allows users to scan QR codes or enter QR code data manually. - **QR Code** - Quick Response codes - **Data Structure:** The QR code field stores a simple string value: diff --git a/formulus/scripts/vendor-notifee-core.mjs b/formulus/scripts/vendor-notifee-core.mjs index dc0ef65c5..95448f7ce 100644 --- a/formulus/scripts/vendor-notifee-core.mjs +++ b/formulus/scripts/vendor-notifee-core.mjs @@ -52,7 +52,9 @@ function patchNotifeeAndroidReleaseMinify() { `$1// [formulus] notifee_core release: keep minify off (R8 made InitProvider.onCreate final → LinkageError in NotifeeInitProvider)\n minifyEnabled false`, ); fs.writeFileSync(gradle, s); - console.log('Patched notifee android/build.gradle (release minifyEnabled false).'); + console.log( + 'Patched notifee android/build.gradle (release minifyEnabled false).', + ); } if (!fs.existsSync(path.join(dest, 'android', 'build.gradle'))) { diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 8e5d472f0..8aa2cc996 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -500,7 +500,7 @@ const FormplayerModal = forwardRef( try { // Save the observation (optional - skip if returnOnly flag is set) let resultObservationId: string; - + if (!returnOnly) { // Normal mode: save to database const localRepo = databaseService.getLocalRepo(); @@ -602,7 +602,13 @@ const FormplayerModal = forwardRef( throw error; } }, - [currentObservationId, currentOperationId, onClose, showConfirm, returnOnly], + [ + currentObservationId, + currentOperationId, + onClose, + showConfirm, + returnOnly, + ], ); // Register/unregister modal with message handlers and reset form state diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 521ebccdf..4676919ef 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -56,7 +56,7 @@ export interface FormInitData { formSchema?: unknown; uiSchema?: unknown; operationId?: string; - returnOnly?: boolean; // For embedded child forms: return JSON without saving to DB + returnOnly?: boolean; // For embedded child forms: return JSON without saving to DB extensions?: ExtensionMetadata; customQuestionTypes?: { custom_types: Record; diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index 4064a67f7..5bb0fbe73 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -1170,7 +1170,9 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { const service = await FormService.getInstance(); return await service.getObservationsByQuery(options); }, - onOpenFormplayer: async (data: FormInitData & { options?: { returnOnly?: boolean } }) => { + onOpenFormplayer: async ( + data: FormInitData & { options?: { returnOnly?: boolean } }, + ) => { return startFormplayerOperation( data.formType, data.params, From b7a4ac704a38be0c30a08a3ef4e51b86ec42fd7a Mon Sep 17 00:00:00 2001 From: Ndacyayisenga-droid Date: Sun, 12 Apr 2026 01:52:32 +0300 Subject: [PATCH 5/7] fix(formulus): forward openFormplayer options so returnOnly child forms skip DB --- formulus-formplayer/src/App.tsx | 11 +++++++---- .../src/types/FormulusInterfaceDefinition.ts | 2 ++ .../main/assets/webview/FormulusInjectionScript.js | 3 ++- .../app/src/main/assets/webview/formulus-api.js | 2 +- formulus/assets/webview/FormulusInjectionScript.js | 5 +++-- formulus/assets/webview/formulus-api.js | 2 +- formulus/src/components/FormplayerModal.tsx | 14 +++++++------- 7 files changed, 23 insertions(+), 16 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 5fdad1976..bd763983c 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -329,7 +329,10 @@ function App() { newObservationDraftSessionKey?: string | null, ) => { try { - if (initData.observationId != null) { + if (initData.returnOnly) { + // Embedded child form: data lives only in memory until returned to parent; no local drafts. + setDraftSessionKey(null); + } else if (initData.observationId != null) { setDraftSessionKey(null); } else if (newObservationDraftSessionKey !== undefined) { setDraftSessionKey(newObservationDraftSessionKey); @@ -590,7 +593,7 @@ function App() { // Check if this is a new form (no savedData) and if drafts exist const hasExistingSavedData = savedData && Object.keys(savedData).length > 0; - if (!hasExistingSavedData) { + if (!initData.returnOnly && !hasExistingSavedData) { const availableDrafts = draftService.getDraftsForForm( receivedFormType, (formSchema as any)?.version, @@ -948,8 +951,8 @@ function App() { ({ data: newData }: { data: FormData }) => { setData(newData); - // Save draft data whenever form data changes - if (formInitData) { + // Save draft data whenever form data changes (skip embedded return-only child forms) + if (formInitData && !formInitData.returnOnly) { draftService.saveDraft( formInitData.formType, newData, diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 644cc0e39..4676919ef 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -56,6 +56,7 @@ export interface FormInitData { formSchema?: unknown; uiSchema?: unknown; operationId?: string; + returnOnly?: boolean; // For embedded child forms: return JSON without saving to DB extensions?: ExtensionMetadata; customQuestionTypes?: { custom_types: Record; @@ -284,6 +285,7 @@ export interface FormulusInterface { formType: string, params: Record, savedData: Record, + options?: { returnOnly?: boolean }, ): Promise; /** diff --git a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js index 6e0905aa6..3073fbbef 100644 --- a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js +++ b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js @@ -285,7 +285,7 @@ }, // openFormplayer: formType: string, params: Record, savedData: Record => Promise - openFormplayer: function (formType, params, savedData) { + openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -342,6 +342,7 @@ formType: formType, params: params, savedData: savedData, + options: options, }), ); }); diff --git a/formulus/android/app/src/main/assets/webview/formulus-api.js b/formulus/android/app/src/main/assets/webview/formulus-api.js index a94129a9f..db9b61071 100644 --- a/formulus/android/app/src/main/assets/webview/formulus-api.js +++ b/formulus/android/app/src/main/assets/webview/formulus-api.js @@ -50,7 +50,7 @@ const FormulusAPI = { * @param {Object} savedData - Previously saved form data (for editing) * @returns {Promise} Promise that resolves when the form is completed/closed with result details */ - openFormplayer: function (formType, params, savedData) {}, + openFormplayer: function (formType, params, savedData, options) {}, /** * Get observations for a specific form diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index f008c2b73..a90df5ae7 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -230,8 +230,8 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record => Promise - openFormplayer: function (formType, params, savedData) { + // openFormplayer: formType, params, savedData, options? => Promise + openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -288,6 +288,7 @@ formType: formType, params: params, savedData: savedData, + options: options, }), ); }); diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index df5e796f7..9ed7cc3d0 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -50,7 +50,7 @@ const FormulusAPI = { * @param {Object} savedData - Previously saved form data (for editing) * @returns {Promise} Promise that resolves when the form is completed/closed with result details */ - openFormplayer: function (formType, params, savedData) {}, + openFormplayer: function (formType, params, savedData, options) {}, /** * Get observations for a specific form diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 8aa2cc996..5d2cac958 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -96,9 +96,9 @@ const FormplayerModal = forwardRef( // Track if form has been successfully submitted to avoid double resolution const [formSubmitted, setFormSubmitted] = useState(false); - // Track if this form should return JSON only without saving to database - // Used for child forms embedded in linked-table scenarios - const [returnOnly, setReturnOnly] = useState(false); + // Child / linked-table forms: return JSON only, do not persist as observations. + // Ref updates synchronously in initializeForm so submit cannot run before flag is set. + const returnOnlyRef = useRef(false); // Author-configurable display name shown in the native header bar const [currentFormDisplayName, setCurrentFormDisplayName] = useState< @@ -222,8 +222,7 @@ const FormplayerModal = forwardRef( ); } - // Set returnOnly flag for this form session - setReturnOnly(returnOnlyMode); + returnOnlyRef.current = returnOnlyMode; // GPS session: fresh fix + light watch while the user fills the form geolocationService.beginObservationSession(); @@ -457,6 +456,7 @@ const FormplayerModal = forwardRef( uiSchema: formType.uiSchema ?? {}, extensions, customQuestionTypes, + returnOnly: returnOnlyMode, } as FormInitData; if (!webViewRef.current) { @@ -501,7 +501,7 @@ const FormplayerModal = forwardRef( // Save the observation (optional - skip if returnOnly flag is set) let resultObservationId: string; - if (!returnOnly) { + if (!returnOnlyRef.current) { // Normal mode: save to database const localRepo = databaseService.getLocalRepo(); if (!localRepo) { @@ -607,7 +607,6 @@ const FormplayerModal = forwardRef( currentOperationId, onClose, showConfirm, - returnOnly, ], ); @@ -631,6 +630,7 @@ const FormplayerModal = forwardRef( setIsClosing(false); // Reset closing state when modal is fully closed setFormSubmitted(false); // Reset submission flag setWebViewReady(false); // Reset WebView ready state + returnOnlyRef.current = false; }, 300); // Small delay to ensure modal is fully closed } }, [visible, isActive, handleSubmission]); From c6daba297e1d9713b8a8aaf3ea27d19d44815f06 Mon Sep 17 00:00:00 2001 From: Ndacyayisenga-droid Date: Sun, 12 Apr 2026 01:56:09 +0300 Subject: [PATCH 6/7] fix prettier --- formulus/src/components/FormplayerModal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 5d2cac958..fd77d81e6 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -602,12 +602,7 @@ const FormplayerModal = forwardRef( throw error; } }, - [ - currentObservationId, - currentOperationId, - onClose, - showConfirm, - ], + [currentObservationId, currentOperationId, onClose, showConfirm], ); // Register/unregister modal with message handlers and reset form state From 9f22f3963ced0ea74f6562b591155efbc55e2c95 Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Tue, 14 Apr 2026 22:53:14 +0300 Subject: [PATCH 7/7] make sure child-form is not saved --- formulus/src/components/FormplayerModal.tsx | 18 ++-- .../src/webview/FormulusMessageHandlers.ts | 94 +++++++------------ 2 files changed, 43 insertions(+), 69 deletions(-) diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index fd77d81e6..63edc8f77 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -25,6 +25,7 @@ import { resolveFormOperation, resolveFormOperationByType, setActiveFormplayerModal, + clearActiveFormplayerModalIfMatches, } from '../webview/FormulusMessageHandlers'; import { FormCompletionResult, @@ -605,19 +606,19 @@ const FormplayerModal = forwardRef( [currentObservationId, currentOperationId, onClose, showConfirm], ); - // Register/unregister modal with message handlers and reset form state + // Register/unregister modal with message handlers and reset form state. + // Stacked modals (e.g. linked-table child): parent stays visible but inactive — it must NOT + // clear the global ref, or the child's submit would miss the active modal and fail or persist wrongly. useEffect(() => { if (visible && isActive) { - // Register this modal as the active one for handling submissions setActiveFormplayerModal({ handleSubmission }); - } else { - // Inactive/hidden modals must not handle submissions. - setActiveFormplayerModal(null); + return () => { + clearActiveFormplayerModalIfMatches(handleSubmission); + }; } if (!visible) { - // Reset form state only when the modal actually closes. - setTimeout(() => { + const timeoutId = setTimeout(() => { setCurrentFormType(null); setCurrentFormDisplayName(null); setCurrentObservationId(null); @@ -627,7 +628,10 @@ const FormplayerModal = forwardRef( setWebViewReady(false); // Reset WebView ready state returnOnlyRef.current = false; }, 300); // Small delay to ensure modal is fully closed + return () => clearTimeout(timeoutId); } + + return undefined; }, [visible, isActive, handleSubmission]); useEffect(() => { diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index e13c34ca7..631bdc025 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -42,14 +42,12 @@ type AudioSet = { AudioChannels: number; }; import { FormService } from '../services/FormService'; -import { Observation, ObservationData } from '../database/models/Observation'; import { getAttachmentsDirectoryFileUrl, getCustomAppDirectoryFileUrl, getFormSpecsDirectoryFileUrl, resolveAttachmentFileUrl, } from '../services/WebViewFileUrlResolver'; -import { commitDraftAttachmentsAfterSave } from '../services/attachmentStorage'; export type HandlerArgs = { data: unknown; @@ -161,26 +159,34 @@ export const openFormplayerFromNative = ( return startFormplayerOperation(formType, params, savedData, observationId); }; -let activeFormplayerModalRef: { +export type ActiveFormplayerModalHandle = { handleSubmission: (data: { formType: string; finalData: Record; observationId?: string | null; }) => Promise; -} | null = null; +}; + +let activeFormplayerModalRef: ActiveFormplayerModalHandle | null = null; export const setActiveFormplayerModal = ( - modalRef: { - handleSubmission: (data: { - formType: string; - finalData: Record; - observationId?: string | null; - }) => Promise; - } | null, + modalRef: ActiveFormplayerModalHandle | null, ) => { activeFormplayerModalRef = modalRef; }; +/** Clears the global active modal only if it still points to this submission handler (stacked modals). */ +export const clearActiveFormplayerModalIfMatches = ( + handleSubmission: ActiveFormplayerModalHandle['handleSubmission'], +) => { + if ( + activeFormplayerModalRef && + activeFormplayerModalRef.handleSubmission === handleSubmission + ) { + activeFormplayerModalRef = null; + } +}; + export const resolveFormOperation = ( operationId: string, result: FormCompletionResult, @@ -226,46 +232,6 @@ export const rejectFormOperation = (operationId: string, error: Error) => { } }; -const saveFormData = async ( - formType: string, - data: ObservationData, - observationId: string | null, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isPartial = true, -) => { - try { - const observation: Partial = { - formType, - data, - }; - - if (observationId !== null) { - observation.observationId = observationId; - observation.updatedAt = new Date(); - } else { - observation.createdAt = new Date(); - } - - const formService = await FormService.getInstance(); - const id = - observationId !== null - ? await formService.updateObservation(observationId, data) - : await formService.addNewObservation(formType, data); - - if (id != null) { - const fixedData = await commitDraftAttachmentsAfterSave( - data as Record, - ); - await formService.updateObservation(id, fixedData); - } - - return id; - } catch (error) { - console.error('Error saving form data:', error); - return null; - } -}; - export function createFormulusMessageHandlers(): FormulusMessageHandlers { return { onInitForm: (payload: unknown) => { @@ -297,13 +263,15 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { formType, finalData, }); - } else { - // Fallback to the old method if no modal is active - console.warn( - 'FormulusMessageHandlers: No active FormplayerModal, using fallback saveFormData', - ); - return await saveFormData(formType, finalData, null, false); } + + console.error( + 'FormulusMessageHandlers: No active FormplayerModal for submitObservation; refusing to persist.', + { formType }, + ); + throw new Error( + 'Form submission failed: no active form session. Close and reopen the form.', + ); }, onUpdateObservation: async (data: { observationId: string; @@ -322,11 +290,13 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { observationId: data.observationId, }); } - return await saveFormData( - data.formType, - data.finalData, - data.observationId, - false, + + console.error( + 'FormulusMessageHandlers: No active FormplayerModal for updateObservation; refusing to persist.', + { observationId: data.observationId, formType: data.formType }, + ); + throw new Error( + 'Form update failed: no active form session. Close and reopen the form.', ); }, onRequestCamera: async (fieldId: string): Promise => {