import { useEffect, useState } from 'react'; import { AlertCircle, CheckCircle2 } from 'lucide-react'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOrganizationServiceContext } from '../../../services/OrganizationService.tsx'; import { InputControl } from '../../../components/ui/input-control.tsx'; import { Checkbox } from '../../../components/ui/checkbox.tsx'; import { Label } from '../../../components/ui/label.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { Heading } from '../../../components/ui/heading.tsx'; import { getEffectiveFormSubmissionNotifications } from '../../../utils/formSubmissionNotifications'; import './FormSubmissionNotificationsSettings.css'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import type { Organization } from 'fhir/r4'; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveTransport } from '../../../utils/saveOperation'; import { HttpError } from '../../../services/HttpService.tsx'; import { olderThan } from '../../../utils/versionOrder'; import { useRefreshSessionOrganization } from '../../../hooks/useRefreshSessionOrganization'; import { noteFormNotificationSaveAnswer, recordUnconfirmedFormNotificationSave, sameFormNotificationSettings, settleUnconfirmedFormNotificationSave, unconfirmedFormNotificationSave, type FormNotificationSettings, } from '../../../utils/unconfirmedFormNotificationSaves'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const FormSubmissionNotificationsSettings = ({ showTitle = true }: { showTitle?: boolean }) => { const { currentOrganization, wpOrganization, setWpOrganization } = useAuthServiceContext(); const { updateFormSubmissionNotifications } = useOrganizationServiceContext(); const refreshOrganization = useRefreshSessionOrganization(); const organizationId = currentOrganization?.id; // Every save and read here belongs to this practice, the person signed in and this opening of the form. const scope = useSaveScope(organizationId, () => { setSaving(false); setChecking(false); }); const [enabled, setEnabled] = useState(true); const [recipients, setRecipients] = useState([]); const [newRecipient, setNewRecipient] = useState(''); const [addError, setAddError] = useState(); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(); // The settings a save actually stored; the success line shows only while the form still reads exactly that. const [saved, setSaved] = useState(); // A save sent with no answer: Save waits until a read of the practice says whether it landed. const [unconfirmed, setUnconfirmed] = useState(() => unconfirmedFormNotificationSave(organizationId)); const [checking, setChecking] = useState(false); // What a check found, said without claiming more than it knows. const [notice, setNotice] = useState(); // Initial UI state from the effective (default-on, seeded-from-org-email) settings. Keyed on the org // ID (not the object) on purpose: handleSave calls setWpOrganization(organization) with a NEW object // reference, so keying on the whole object would re-fire this seed right after save and clobber the // success alert + any in-flight recipient edits. ID-keying still reseeds on load + on org switch. // (Mirrors PatientPortalPageSettings.tsx's identical guard.) useEffect(() => { const effective = getEffectiveFormSubmissionNotifications(currentOrganization); setEnabled(effective.enabled); setRecipients(effective.recipients); setNewRecipient(''); setAddError(undefined); setSaveError(undefined); setSaved(undefined); setNotice(undefined); setUnconfirmed(unconfirmedFormNotificationSave(currentOrganization?.id)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentOrganization?.id]); const handleAddRecipient = () => { const email = newRecipient.trim(); if (!email) { return; } if (!EMAIL_RE.test(email)) { setAddError('Enter a valid email address.'); return; } if (recipients.some((recipient) => recipient.toLowerCase() === email.toLowerCase())) { setAddError('That email is already in the list.'); return; } setRecipients((current) => [...current, email]); setNewRecipient(''); setAddError(undefined); }; const handleRemoveRecipient = (email: string) => { setRecipients((current) => current.filter((recipient) => recipient !== email)); }; // The saved practice, unless the session already holds a newer one. const publishOrganization = (organization: Organization) => { const held = wpOrganization?.current; if (held && held.id === organization.id && olderThan(organization, held)) return; setWpOrganization(organization); }; const handleSave = async () => { const operation = scope.start(); // Exactly what is sent: edits made while it is out stay in the form, unsaved, and are never reported as saved. const sent = { enabled, recipients: [...recipients] }; setSaving(true); setSaveError(undefined); setNotice(undefined); // Recorded as it is sent, so a form opened while it is out waits for it too. Once sent it runs to its answer even // if this form goes, and that answer is noted against this attempt, though never published. let attempt: string | undefined; const sending = new AbortController(); operation.transport.signal.addEventListener('abort', () => { if (!attempt) sending.abort(); }); const transport: SaveTransport = { signal: sending.signal, admit: (phase?: 'answer') => { if (phase === 'answer' && attempt) noteFormNotificationSaveAnswer(organizationId, attempt, 'saved'); operation.admit(); }, onSend: () => { attempt = recordUnconfirmedFormNotificationSave(organizationId, sent); }, }; const request = () => updateFormSubmissionNotifications(sent, transport).catch((err) => { if (attempt && err instanceof HttpError && !outcomeUnknown(err)) noteFormNotificationSaveAnswer(organizationId, attempt, 'refused'); throw err; }); try { const { organization } = await operation.step(request, true); settleUnconfirmedFormNotificationSave(organizationId); publishOrganization(organization); setSaved(sent); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; if (outcomeUnknown(err)) { setUnconfirmed(unconfirmedFormNotificationSave(organizationId)); return; } settleUnconfirmedFormNotificationSave(organizationId); console.error('Error saving form submission notification settings:', err); setSaveError( relayServerFailure(err, 'Failed to save notification settings. Please try again.'), ); } finally { if (operation.belongs()) setSaving(false); } }; // Reads the practice, then settles a save whose answer never reached this form only on the answer to that very request. // A read says what is saved, never which request saved it, so the same settings from another writer prove nothing: // otherwise Save stays held and what is saved is described, never claimed. The form keeps whatever is typed either way. const checkUnconfirmed = async () => { if (!unconfirmedFormNotificationSave(organizationId)) { setUnconfirmed(undefined); return; } const operation = scope.start(); setChecking(true); setSaveError(undefined); setNotice(undefined); try { const fresh = await operation.step(() => refreshOrganization(operation.transport)); if (!fresh) { setSaveError('The saved settings could not be read. Try again.'); return; } const held = unconfirmedFormNotificationSave(organizationId); if (!held) { setUnconfirmed(undefined); return; } const sentIsSaved = sameFormNotificationSettings(getEffectiveFormSubmissionNotifications(fresh), held.sent); if (held.answer === 'refused') { settleUnconfirmedFormNotificationSave(organizationId); setUnconfirmed(undefined); setSaveError('That save was refused, so nothing was saved. Your changes are still here.'); return; } if (held.answer === 'saved') { settleUnconfirmedFormNotificationSave(organizationId); setUnconfirmed(undefined); setSaved(held.sent); return; } setUnconfirmed(held); setNotice(sentIsSaved ? 'The settings saved now match what you sent. Whether your last save made them is not known, and it may still land, so Save stays off.' : 'Your last save has had no answer and may still land, so Save stays off. Check again later.'); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; setSaveError(relayServerFailure(err, 'The saved settings could not be read. Try again.')); } finally { if (operation.belongs()) setChecking(false); } }; const saveSuccess = !!saved && sameFormNotificationSettings(saved, { enabled, recipients }); const fields = (
{saveError && ( {saveError} )} {unconfirmed && ( Whether your last save of these settings landed could not be confirmed. Check what is saved before saving again. )} {notice && ( {notice} )} {saveSuccess && ( Notification settings saved. )}
{ setEnabled(!!checked); }} />

Applies to all public forms for this practice. This does not configure replies to visitors.

{recipients.length > 0 ? (
    {recipients.map((recipient) => (
  • {recipient}
  • ))}
) : (

No recipients yet. Add a staff email address below.

)}
{ setNewRecipient(value ?? ""); setAddError(undefined); }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); handleAddRecipient(); } }} /> {addError && ( {addError} )}

Bot protection is managed by Akashic. No CAPTCHA keys or setup are required here.

); const saveButton = ( ); // Inside a dialog the dialog title names this already, and Save sits in the dialog footer. if (!showTitle) { return ( <>
{fields}
{saveButton}
); } // ui/heading applies typography inline: a class rule loses to the portal root's global h2 rule. return (
Akashic Public Form Settings Notifications
{fields}
{saveButton}
); }; export default FormSubmissionNotificationsSettings;