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 8bc82be01..1ebcf17b4 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/App.tsx b/formulus/App.tsx index 259c3acee..680b12fdb 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,60 @@ 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; + returnOnly?: boolean; // For child forms opened from linkedtable + }; - 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, + entry.returnOnly, // ← Pass returnOnly flag + ); + }, 200); + }; + + tryInitialize(); + }, + [], + ); + + const closeFormplayerEntry = React.useCallback((entryId: string) => { + formplayerModalRefs.current.delete(entryId); + setFormplayerStack(current => + current.filter(entry => entry.id !== entryId), + ); + }, []); useEffect(() => { FormService.getInstance(); @@ -92,19 +140,14 @@ 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; + const { + formType, + observationId, + params, + savedData, + operationId, + returnOnly, + } = config; try { const formService = await FormService.getInstance(); @@ -127,21 +170,21 @@ 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, + returnOnly: returnOnly || false, // Include returnOnly flag + }; - // 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 +193,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 +229,7 @@ function AppInner(): React.JSX.Element { ); appEvents.removeListener('closeFormplayer', handleCloseFormplayer); }; - }, []); + }, [closeFormplayerEntry, initializeStackEntry]); return ( <> @@ -192,14 +239,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); + }} + /> + ))} { + const parts = path.split('.'); + let cur = obj; + for (const p of parts) { + if (!cur || typeof cur !== 'object') return undefined; + cur = cur[p]; + } + return cur; + }; + + return observations.filter(obs => + conditions.every(({ path, value }) => { + const actual = getNested(obs.data || {}, path); + return actual !== undefined && String(actual) === String(value); + }), + ); + } catch (e) { + console.warn( + 'filterObservationsByWhereClause: Failed to apply whereClause filter, returning unfiltered observations.', + whereClause, + e, + ); + return observations; + } + } + // Initialize the formulus interface globalThis.formulus = { // getVersion: => Promise @@ -230,8 +284,8 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record => Promise - openFormplayer: function (formType, params, savedData) { + // openFormplayer: formType: string, params: Record, savedData: Record => Promise + openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -288,6 +342,7 @@ formType: formType, params: params, savedData: savedData, + options: options, }), ); }); @@ -356,68 +411,36 @@ }); }, - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + // getObservationsByQuery: options: { formType: string; whereClause?: string; isDraft?: boolean; includeDeleted?: boolean } => Promise + // NOTE: This is implemented entirely in the WebView layer by calling getObservations + // and then applying a lightweight filter based on the generated whereClause string. + // This avoids additional native bridge work while still supporting dynamic choice lists. getObservationsByQuery: function (options) { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getObservationsByQuery_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error( - "'getObservationsByQuery' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'getObservationsByQuery', - messageId, - options: options, - }), + try { + const formType = options?.formType; + const whereClause = options?.whereClause || '1=1'; + const isDraft = + typeof options?.isDraft === 'boolean' ? options.isDraft : false; + const includeDeleted = + typeof options?.includeDeleted === 'boolean' + ? options.includeDeleted + : false; + + return globalThis.formulus + .getObservations(formType, isDraft, includeDeleted) + .then(observations => + filterObservationsByWhereClause(observations, whereClause), + ); + } catch (e) { + console.error( + 'getObservationsByQuery: Failed to execute query, returning empty list.', + e, ); - }); + return Promise.resolve([]); + } }, - // submitObservation: formType: string, finalData: Record => Promise + // submitObservation: formType: string, finalData: Record => Promise submitObservation: function (formType, finalData) { return new Promise((resolve, reject) => { const messageId = @@ -479,7 +502,7 @@ }); }, - // updateObservation: observationId: string, formType: string, finalData: Record => Promise + // updateObservation: observationId: string, formType: string, finalData: Record => Promise updateObservation: function (observationId, formType, finalData) { return new Promise((resolve, reject) => { const messageId = @@ -725,7 +748,7 @@ }); }, - // launchIntent: fieldId: string, intentSpec: Record => Promise + // launchIntent: fieldId: string, intentSpec: Record => Promise launchIntent: function (fieldId, intentSpec) { return new Promise((resolve, reject) => { const messageId = @@ -787,7 +810,7 @@ }); }, - // callSubform: fieldId: string, formType: string, options: Record => Promise + // callSubform: fieldId: string, formType: string, options: Record => Promise callSubform: function (fieldId, formType, options) { return new Promise((resolve, reject) => { const messageId = @@ -911,8 +934,8 @@ }); }, - // requestQrcode: fieldId: string => Promise - requestQrcode: function (fieldId) { + // requestSignature: fieldId: string => Promise + requestSignature: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -927,18 +950,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestSignature callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + 'requestSignature callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'requestQrcode_response' && + data.type === 'requestSignature_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -950,7 +973,7 @@ } } catch (e) { console.error( - "'requestQrcode' callback: Error processing response:", + "'requestSignature' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -964,7 +987,7 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'requestQrcode', + type: 'requestSignature', messageId, fieldId: fieldId, }), @@ -972,8 +995,8 @@ }); }, - // requestBiometric: fieldId: string => Promise - requestBiometric: function (fieldId) { + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -988,18 +1011,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'requestBiometric_response' && + data.type === 'requestQrcode_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1011,7 +1034,7 @@ } } catch (e) { console.error( - "'requestBiometric' callback: Error processing response:", + "'requestQrcode' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1025,7 +1048,7 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'requestBiometric', + type: 'requestQrcode', messageId, fieldId: fieldId, }), @@ -1033,128 +1056,8 @@ }); }, - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestConnectivityStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error( - "'requestConnectivityStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'requestConnectivityStatus', - messageId, - }), - ); - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'requestSyncStatus_response' && - data.messageId === messageId - ) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error( - "'requestSyncStatus' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'requestSyncStatus', - messageId, - }), - ); - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function (fieldId, modelId, input) { + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -1169,18 +1072,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'runLocalModel_response' && + data.type === 'requestBiometric_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1192,7 +1095,7 @@ } } catch (e) { console.error( - "'runLocalModel' callback: Error processing response:", + "'requestBiometric' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1206,18 +1109,16 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'runLocalModel', + type: 'requestBiometric', messageId, fieldId: fieldId, - modelId: modelId, - input: input, }), ); }); }, - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function () { + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -1232,18 +1133,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'getCurrentUser_response' && + data.type === 'requestConnectivityStatus_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1255,7 +1156,7 @@ } } catch (e) { console.error( - "'getCurrentUser' callback: Error processing response:", + "'requestConnectivityStatus' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1269,15 +1170,15 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'getCurrentUser', + type: 'requestConnectivityStatus', messageId, }), ); }); }, - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function () { + // requestSyncStatus: => Promise + requestSyncStatus: function () { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -1292,18 +1193,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'getThemeMode_response' && + data.type === 'requestSyncStatus_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1315,7 +1216,7 @@ } } catch (e) { console.error( - "'getThemeMode' callback: Error processing response:", + "'requestSyncStatus' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1329,15 +1230,15 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'getThemeMode', + type: 'requestSyncStatus', messageId, }), ); }); }, - // getAttachmentUri: fileName: string => Promise - getAttachmentUri: function (fileName) { + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -1352,18 +1253,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'getAttachmentUri_response' && + data.type === 'runLocalModel_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1375,7 +1276,7 @@ } } catch (e) { console.error( - "'getAttachmentUri' callback: Error processing response:", + "'runLocalModel' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1389,16 +1290,18 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'getAttachmentUri', + type: 'runLocalModel', messageId, - fileName: fileName, + fieldId: fieldId, + modelId: modelId, + input: input, }), ); }); }, - // getAttachmentsUri: => Promise - getAttachmentsUri: function () { + // getCurrentUser: => Promise<{ username: string; displayName?: string; }> + getCurrentUser: function () { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); @@ -1413,18 +1316,18 @@ } else if (typeof event.data === 'object' && event.data !== null) { data = event.data; // Already an object } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'getAttachmentsUri_response' && + data.type === 'getCurrentUser_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1436,7 +1339,7 @@ } } catch (e) { console.error( - "'getAttachmentsUri' callback: Error processing response:", + "'getCurrentUser' callback: Error processing response:", e, 'Raw event.data:', event.data, @@ -1450,101 +1353,38 @@ // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'getAttachmentsUri', + type: 'getCurrentUser', messageId, }), ); }); }, - // getCustomAppUri: => Promise - getCustomAppUri: function () { + // getThemeMode: () => Promise<'light' | 'dark' | 'system'> + getThemeMode: function () { return new Promise((resolve, reject) => { const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - // Add response handler for methods that return values - const callback = event => { try { let data; if (typeof event.data === 'string') { data = JSON.parse(event.data); } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + data = event.data; } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject( - new Error( - 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + - String(event.data), - ), - ); - return; - } - if ( - data.type === 'getCustomAppUri_response' && - data.messageId === messageId - ) { window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error( - "'getCustomAppUri' callback: Error processing response:", - e, - 'Raw event.data:', - event.data, - ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage( - JSON.stringify({ - type: 'getCustomAppUri', - messageId, - }), - ); - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function () { - return new Promise((resolve, reject) => { - const messageId = - 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = event => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object - } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener reject( new Error( - 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data), ), ); return; } if ( - data.type === 'getFormSpecsUri_response' && + data.type === 'getThemeMode_response' && data.messageId === messageId ) { window.removeEventListener('message', callback); @@ -1556,21 +1396,20 @@ } } catch (e) { console.error( - "'getFormSpecsUri' callback: Error processing response:", + "'getThemeMode' callback: Error processing response:", e, 'Raw event.data:', event.data, ); - window.removeEventListener('message', callback); // Ensure listener is removed on error too + window.removeEventListener('message', callback); reject(e); } }; window.addEventListener('message', callback); - // Send the message to React Native globalThis.ReactNativeWebView.postMessage( JSON.stringify({ - type: 'getFormSpecsUri', + type: 'getThemeMode', messageId, }), ); 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 df5e796f7..9ed7cc3d0 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/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/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/CustomAppWebView.tsx b/formulus/src/components/CustomAppWebView.tsx index e1a885ab7..4980b4a1a 100644 --- a/formulus/src/components/CustomAppWebView.tsx +++ b/formulus/src/components/CustomAppWebView.tsx @@ -26,6 +26,7 @@ export interface CustomAppWebViewHandle { injectJavaScript: (script: string) => 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..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, @@ -48,6 +49,7 @@ import { geolocationService } from '../services/GeolocationService'; interface FormplayerModalProps { visible: boolean; + isActive?: boolean; onClose: () => void; } @@ -58,6 +60,7 @@ export interface FormplayerModalHandle { observationId: string | null, existingObservationData: Record | null, operationId: string | null, + returnOnly?: boolean, ) => void; handleSubmission: (data: { formType: string; @@ -67,7 +70,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(); @@ -94,6 +97,10 @@ const FormplayerModal = forwardRef( // Track if form has been successfully submitted to avoid double resolution const [formSubmitted, setFormSubmitted] = 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< string | null @@ -191,6 +198,7 @@ const FormplayerModal = forwardRef( // Track WebView ready state const [webViewReady, setWebViewReady] = useState(false); + const previousIsActiveRef = useRef(isActive); // Handle WebView load complete const handleWebViewLoad = () => { @@ -206,6 +214,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) { @@ -214,6 +223,8 @@ const FormplayerModal = forwardRef( ); } + returnOnlyRef.current = returnOnlyMode; + // GPS session: fresh fix + light watch while the user fills the form geolocationService.beginObservationSession(); @@ -446,6 +457,7 @@ const FormplayerModal = forwardRef( uiSchema: formType.uiSchema ?? {}, extensions, customQuestionTypes, + returnOnly: returnOnlyMode, } as FormInitData; if (!webViewRef.current) { @@ -487,32 +499,43 @@ 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 (!returnOnlyRef.current) { + // 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 = ''; + console.log( + '[FormplayerModal] Form returned without DB save (returnOnly mode):', + resultObservationId, + ); } // Mark form as successfully submitted @@ -583,17 +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) { - // Register this modal as the active one for handling submissions + if (visible && isActive) { setActiveFormplayerModal({ handleSubmission }); - } else { - // Unregister when modal is closed - setActiveFormplayerModal(null); + return () => { + clearActiveFormplayerModalIfMatches(handleSubmission); + }; + } - // Reset form state when modal is closed - setTimeout(() => { + if (!visible) { + const timeoutId = setTimeout(() => { setCurrentFormType(null); setCurrentFormDisplayName(null); setCurrentObservationId(null); @@ -601,9 +626,26 @@ 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 + return () => clearTimeout(timeoutId); + } + + return undefined; + }, [visible, isActive, handleSubmission]); + + useEffect(() => { + if ( + visible && + isActive && + webViewReady && + currentFormType && + previousIsActiveRef.current === false + ) { + webViewRef.current?.notifyReceiveFocus(); } - }, [visible, handleSubmission]); + previousIsActiveRef.current = isActive; + }, [visible, isActive, webViewReady, currentFormType]); useImperativeHandle(ref, () => ({ initializeForm, handleSubmission })); diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 8bc82be01..1ebcf17b4 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; @@ -284,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 2f460abc7..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; @@ -117,6 +115,7 @@ const startFormplayerOperation = ( params: Record = {}, savedData: Record = {}, observationId: string | null = null, + returnOnly: boolean = false, ): Promise => { const operationId = `${formType}_${Date.now()}_${Math.random() .toString(36) @@ -136,6 +135,7 @@ const startFormplayerOperation = ( savedData, observationId, operationId, + returnOnly, }); setTimeout( @@ -159,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, @@ -224,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) => { @@ -295,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; @@ -320,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 => { @@ -1166,12 +1138,15 @@ 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: { 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 {