Skip to content

(feat): Enable external form Submission #550

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 28 additions & 24 deletions src/components/sidebar/sidebar.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface SidebarProps {
onCancel: () => void;
handleClose: () => void;
hideFormCollapseToggle: () => void;
isSubmissionTriggeredExternally?: boolean;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would name this something like hideControls or hideInternalButtons which defaults to false.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed to

}

const Sidebar: React.FC<SidebarProps> = ({
Expand All @@ -25,6 +26,7 @@ const Sidebar: React.FC<SidebarProps> = ({
onCancel,
handleClose,
hideFormCollapseToggle,
isSubmissionTriggeredExternally,
}) => {
const { t } = useTranslation();
const { pages, pagesWithErrors, activePages, evaluatedPagesVisibility } = usePageObserver();
Expand Down Expand Up @@ -53,32 +55,34 @@ const Sidebar: React.FC<SidebarProps> = ({
requestPage={requestPage}
/>
))}
{sessionMode !== 'view' && <hr className={styles.divider} />}
{sessionMode !== 'view' && !isSubmissionTriggeredExternally && <hr className={styles.divider} />}

<div className={styles.sideNavActions}>
{sessionMode !== 'view' && (
<Button className={styles.saveButton} disabled={isFormSubmitting} type="submit" size={responsiveSize}>
{isFormSubmitting ? (
<InlineLoading description={t('submitting', 'Submitting') + '...'} />
) : (
<span>{`${t('save', 'Save')}`}</span>
)}
{!isSubmissionTriggeredExternally && (
<div className={styles.sideNavActions}>
{sessionMode !== 'view' && (
<Button className={styles.saveButton} disabled={isFormSubmitting} type="submit" size={responsiveSize}>
{isFormSubmitting ? (
<InlineLoading description={t('submitting', 'Submitting') + '...'} />
) : (
<span>{`${t('save', 'Save')}`}</span>
)}
</Button>
)}
<Button
className={classNames(styles.closeButton, {
[styles.topMargin]: sessionMode === 'view',
})}
kind="tertiary"
onClick={() => {
onCancel?.();
handleClose?.();
hideFormCollapseToggle();
}}
size={responsiveSize}>
{sessionMode === 'view' ? t('close', 'Close') : t('cancel', 'Cancel')}
</Button>
)}
<Button
className={classNames(styles.closeButton, {
[styles.topMargin]: sessionMode === 'view',
})}
kind="tertiary"
onClick={() => {
onCancel?.();
handleClose?.();
hideFormCollapseToggle();
}}
size={responsiveSize}>
{sessionMode === 'view' ? t('close', 'Close') : t('cancel', 'Cancel')}
</Button>
</div>
</div>
)}
</div>
);
};
Expand Down
12 changes: 9 additions & 3 deletions src/form-engine.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import MarkdownWrapper from './components/inputs/markdown/markdown-wrapper.compo
import PatientBanner from './components/patient-banner/patient-banner.component';
import Sidebar from './components/sidebar/sidebar.component';
import styles from './form-engine.scss';
import { useExternalSubmitListener } from './hooks/useExternalSubmitListener';

interface FormEngineProps {
patientUUID: string;
Expand All @@ -33,6 +34,7 @@ interface FormEngineProps {
handleClose?: () => void;
handleConfirmQuestionDeletion?: (question: Readonly<FormField>) => Promise<void>;
markFormAsDirty?: (isDirty: boolean) => void;
isSubmissionTriggeredExternally?: boolean;
}

const FormEngine = ({
Expand All @@ -48,6 +50,7 @@ const FormEngine = ({
handleClose,
handleConfirmQuestionDeletion,
markFormAsDirty,
isSubmissionTriggeredExternally = false,
}: FormEngineProps) => {
const { t } = useTranslation();
const session = useSession();
Expand Down Expand Up @@ -107,11 +110,13 @@ const FormEngine = ({
markFormAsDirty?.(isFormDirty);
}, [isFormDirty]);

const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const handleSubmit = useCallback((e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
setIsSubmitting(true);
}, []);

useExternalSubmitListener(handleSubmit);

return (
<form ref={ref} noValidate className={classNames('cds--form', styles.form)} onSubmit={handleSubmit}>
{isLoadingPatient || isLoadingFormJson ? (
Expand Down Expand Up @@ -152,6 +157,7 @@ const FormEngine = ({
onCancel={onCancel}
handleClose={handleClose}
hideFormCollapseToggle={hideFormCollapseToggle}
isSubmissionTriggeredExternally={isSubmissionTriggeredExternally}
/>
)}
<div className={styles.formContentInner}>
Expand All @@ -167,7 +173,7 @@ const FormEngine = ({
setIsLoadingFormDependencies={setIsLoadingDependencies}
/>
</div>
{showBottomButtonSet && (
{showBottomButtonSet && !isSubmissionTriggeredExternally && (
<ButtonSet className={styles.minifiedButtons}>
<Button
kind="secondary"
Expand Down
34 changes: 34 additions & 0 deletions src/hooks/useExternalSubmitListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';

/**
* useExternalSubmitListener
*
* A custom React hook that enables triggering form submission externally via a global custom event.
*
* This is particularly useful in environments where the FormEngine
* is embedded inside another application or UI shell, and you need to trigger submission from outside
* the form (e.g., from a toolbar button, modal footer, or iframe parent).
*
* Registers a global `window` event listener that listens for a specific custom event.
* When the custom event is dispatched, the provided `submitFn` is invoked.
* Ensures proper cleanup on unmount to avoid memory leaks or duplicate submissions.
*
* @param submitFn - A function that triggers the form submission. This will be called when the event is received.
* @param eventName - (Optional) The name of the custom event to listen for. Defaults to `'triger-form-engine-submit'`.
*
* @example
* // Inside your form component
* useExternalSubmitListener(() => handleSubmit());
*
* // Somewhere else (e.g., external button or shell app)
* window.dispatchEvent(new Event('triger-form-engine-submit'));
*/
export function useExternalSubmitListener(submitFn: () => void, eventName = 'triger-form-engine-submit') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are to make this hook more specific, I would change its signature slightly:

Suggested change
export function useExternalSubmitListener(submitFn: () => void, eventName = 'triger-form-engine-submit') {
export function useExternalSubmitListener(internalSubmitHandler: () => void) {

If the hook is defining a specific listener, I suggest defining the eventName locally and renaming it to something like "rfe-form-submit-action" or "rfe-trigger-form-submit".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

useEffect(() => {
const handleSubmitWrapper = () => submitFn();
window.addEventListener(eventName, handleSubmitWrapper);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should expect the event to provide more details about the target form session ie. provide the target form and patient UUID. This would help us map the dispatched event to the correct form instance:

const { formUuid, patientUuid } = useFormContext();
const handleSubmit = (event) => {
    const { formUuid: targetForm, patientUuid: targetPatient, encounterUuid /**while in edit mode **/} = event.details;
    if (targetForm === formUuid && targetPatient === patientUuid) {
         // invoke submission
         internalSubmitHandler();
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

return () => {
window.removeEventListener(eventName, handleSubmitWrapper);
};
}, [submitFn, eventName]);
}
74 changes: 74 additions & 0 deletions src/hooks/usePostSubmissionCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';

type Action = () => void;

/**
* usePostSubmissionCallback
*
* A custom React hook that enables deferring an action until after a form is submitted.
*
* This hook listens for a custom `form-submission-complete` event (dispatched on the `window`)
* and allows registering a one-time callback (action) to be executed once the form has been submitted.
*
* If the form has **already been submitted** when the event is received, the action is executed immediately.
* If the form has **not yet been submitted**, the action is stored and executed only once `setIsFormSubmitted(true)` is called.
*
* This is useful when you want to register a follow-up action (like redirecting or showing a notification),
* but you need to ensure the form has finished submitting before it runs.
*
* @returns {{
* setIsFormSubmitted: (submitted: boolean) => void;
* }} - Returns a setter to mark the form as submitted.
*
* @example
* const { setIsFormSubmitted } = usePostSubmissionCallback();
*
* // Later, after form submission:
* setIsFormSubmitted(true);
*
* // Somewhere else in the app, dispatch a custom event:
* window.dispatchEvent(
* new CustomEvent('form-submission-complete', {
* detail: { action: () => console.log('Post-submission logic executed') }
* })
* );
*/
export function usePostSubmissionCallback() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we achieve the same utility from onSubmit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @samuelmale ,
After further review, I also agree that we no longer need the usePostSubmissionCallback hook. The existing onSubmit handler sufficiently covers all post-submission scenarios at this point. Additionally, the useExternalSubmitListener hook (which we plan to rename) works seamlessly alongside onSubmit, and together they handle the flow effectively.

const [isFormSubmitted, setIsFormSubmitted] = useState(false);

const pendingActionRef = useRef<Action | null>(null);
const hasRunRef = useRef(false);

// Listen for the 'form-submission-complete' event and capture or invoke the action
useEffect(() => {
const handler = (event: CustomEvent) => {
const action = event.detail?.action;

if (typeof action === 'function') {
if (isFormSubmitted && !hasRunRef.current) {
hasRunRef.current = true;
action();
} else {
pendingActionRef.current = action;
}
}
};

window.addEventListener('form-submission-complete', handler as EventListener);

return () => {
window.removeEventListener('form-submission-complete', handler as EventListener);
};
}, [isFormSubmitted]);

// Execute any pending action once form is marked as submitted
useEffect(() => {
if (isFormSubmitted && pendingActionRef.current && !hasRunRef.current) {
hasRunRef.current = true;
pendingActionRef.current();
pendingActionRef.current = null;
}
}, [isFormSubmitted]);

return { setIsFormSubmitted };
}
4 changes: 4 additions & 0 deletions src/provider/form-factory-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { type FormContextProps } from './form-provider';
import { processPostSubmissionActions, validateForm } from './form-factory-helper';
import { useTranslation } from 'react-i18next';
import { usePostSubmissionActions } from '../hooks/usePostSubmissionActions';
import { usePostSubmissionCallback } from '../hooks/usePostSubmissionCallback';

interface FormFactoryProviderContextProps {
patient: fhir.Patient;
Expand Down Expand Up @@ -95,6 +96,8 @@ export const FormFactoryProvider: React.FC<FormFactoryProviderProps> = ({
EncounterFormProcessor: EncounterFormProcessor,
});

const { setIsFormSubmitted } = usePostSubmissionCallback();

useEffect(() => {
if (isSubmitting) {
// TODO: find a dynamic way of managing the form processing order
Expand Down Expand Up @@ -123,6 +126,7 @@ export const FormFactoryProvider: React.FC<FormFactoryProviderProps> = ({
if (postSubmissionHandlers) {
await processPostSubmissionActions(postSubmissionHandlers, results, patient, sessionMode, t);
}
setIsFormSubmitted(true);
hideFormCollapseToggle();
if (onSubmit) {
onSubmit(results);
Expand Down
Loading