Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 92 additions & 45 deletions formulus/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,13 +59,58 @@ function AppInner(): React.JSX.Element {
onResult: (result: unknown) => void;
} | null>(null);

const [formplayerVisible, setFormplayerVisible] = useState(false);
const formplayerModalRef = React.useRef<FormplayerModalHandle>(null);
const formplayerVisibleRef = React.useRef(false);
type FormplayerStackEntry = {
id: string;
formSpec: FormSpec;
params: Record<string, unknown> | null;
observationId: string | null;
savedData: Record<string, unknown> | null;
operationId: string | null;
};

useEffect(() => {
formplayerVisibleRef.current = formplayerVisible;
}, [formplayerVisible]);
const [formplayerStack, setFormplayerStack] = useState<
FormplayerStackEntry[]
>([]);
const formplayerModalRefs = React.useRef(
new Map<string, FormplayerModalHandle | null>(),
);

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();
Expand Down Expand Up @@ -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<void>(resolve => setTimeout(() => resolve(), 300));
}

const { formType, observationId, params, savedData, operationId } =
config;

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -182,7 +220,7 @@ function AppInner(): React.JSX.Element {
);
appEvents.removeListener('closeFormplayer', handleCloseFormplayer);
};
}, []);
}, [closeFormplayerEntry, initializeStackEntry]);

return (
<>
Expand All @@ -192,14 +230,23 @@ function AppInner(): React.JSX.Element {
/>
<NavigationContainer theme={navigationTheme}>
<MainAppNavigator />
<FormplayerModal
ref={formplayerModalRef}
visible={formplayerVisible}
onClose={() => {
formplayerVisibleRef.current = false;
setFormplayerVisible(false);
}}
/>
{formplayerStack.map((entry, index) => (
<FormplayerModal
key={entry.id}
ref={instance => {
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);
}}
/>
))}
</NavigationContainer>

<QRScannerModal
Expand Down
2 changes: 2 additions & 0 deletions formulus/src/components/CustomAppWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
injectJavaScript: (script: string) => void;
sendFormInit: (formData: FormInitData) => Promise<void>;
sendAttachmentData: (attachmentData: File) => Promise<void>;
notifyReceiveFocus: () => void;
}

interface CustomAppWebViewProps {
Expand Down Expand Up @@ -340,6 +341,7 @@
messageManager.sendFormInit(formData),
sendAttachmentData: (attachmentData: File) =>
messageManager.sendAttachmentData(attachmentData),
notifyReceiveFocus: () => messageManager.notifyReceiveFocus(),
}),
[messageManager],
);
Expand Down Expand Up @@ -511,10 +513,10 @@
webView: {
flex: 1,
},
webViewTransparent: {

Check warning on line 516 in formulus/src/components/CustomAppWebView.tsx

View workflow job for this annotation

GitHub Actions / Formulus (React Native)

Color literal: { backgroundColor: 'transparent' }
backgroundColor: 'transparent',
},
webViewContainerTransparent: {

Check warning on line 519 in formulus/src/components/CustomAppWebView.tsx

View workflow job for this annotation

GitHub Actions / Formulus (React Native)

Color literal: { backgroundColor: 'transparent' }
backgroundColor: 'transparent',
flex: 1,
},
Expand Down
27 changes: 22 additions & 5 deletions formulus/src/components/FormplayerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { geolocationService } from '../services/GeolocationService';

interface FormplayerModalProps {
visible: boolean;
isActive?: boolean;
onClose: () => void;
}

Expand All @@ -67,7 +68,7 @@ export interface FormplayerModalHandle {
}

const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(
({ visible, onClose }, ref) => {
({ visible, isActive = true, onClose }, ref) => {
const webViewRef = useRef<CustomAppWebViewHandle>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { showConfirm } = useConfirmModal();
Expand Down Expand Up @@ -191,6 +192,7 @@ const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(

// Track WebView ready state
const [webViewReady, setWebViewReady] = useState(false);
const previousIsActiveRef = useRef(isActive);

// Handle WebView load complete
const handleWebViewLoad = () => {
Expand Down Expand Up @@ -585,14 +587,16 @@ const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(

// 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);
Expand All @@ -603,7 +607,20 @@ const FormplayerModal = forwardRef<FormplayerModalHandle, FormplayerModalProps>(
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 }));

Expand Down
23 changes: 23 additions & 0 deletions formulus/src/webview/FormulusWebViewHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading