import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Flex, FlexItem } from '../components/ui/flex'; import { DismissibleAlert } from '../components/ui/dismissible-alert'; import { Modal } from '../components/ui/wp-modal-shim'; import { useForm, FormProvider } from 'react-hook-form'; import { Location, Organization } from 'fhir/r4'; import { useLocationServiceContext, useAuthServiceContext, useValueSetServiceContext } from '../services'; import { FhirLocation } from '../components'; import { useFhirLocationTargetedBroadcast } from '../hooks'; import { describeWriteFailure, ownFailureSentence } from '../utils/fetchFailureMessage'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; import { defaultHoursRows } from '../utils/defaultHoursRows'; import { useEditBaseline } from '../hooks/useEditBaseline'; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, useSaveScope } from '../utils/saveOperation'; import { reloadBaseline, saveOverBaseline } from '../utils/saveOverBaseline'; import { StaleRecordNotice } from '../components/StaleRecordNotice/StaleRecordNotice'; interface LocationModalProps { isOpen: boolean; onClose: () => void; location?: Location; onSuccess?: (location: Location) => void; onError?: (error: string) => void; title?: string; } export const LocationModal: React.FC = ({ isOpen, onClose, location, onSuccess, onError, title }) => { const { wpOrganization } = useAuthServiceContext(); const { daysOfWeek } = useValueSetServiceContext(); const { transformToFhirLocation, transformFromFhirLocation, createLocation, updateLocation, fetchLocation } = useLocationServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [reloading, setReloading] = useState(false); // A create whose save could not be confirmed may exist already: not offered again, so it is never made twice. const [createUnconfirmed, setCreateUnconfirmed] = useState(false); // A create that was answered is done for this opening: nothing is created again while it closes. const [created, setCreated] = useState(false); const isNew = !location; const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Location`; const methods = useForm({ defaultValues: { status: '', name: '', alias: [''], description: '', mode: '', type: [], telecom: [], address: [], physicalType: null, position: { longitude: '', latitude: '', altitude: '' }, managingOrganization: null, partOf: null, hoursOfOperation: defaultHoursRows(daysOfWeek, 'openingTime', 'closingTime'), availabilityExceptions: '', identifier: [], // The site's clock. '' means "not set", which resolveBookingTimeZone reads as "use the // organization's" - so a new single-site practice needs no extra step. timezone: '', // Present so an edit ROUND-TRIPS extensions this form has never heard of. The transform // rebuilds the Location from named fields and a FHIR PUT replaces the whole resource, so a // field the form does not carry is a field the next save deletes. extension: [] } }); const { handleSubmit, reset } = methods; // A REFUSED CLICK SAYS SO WHERE IT HAPPENED. The button is in the modal header, above a form that // can be several screens tall, and react-hook-form reports each problem inline beside its field - // so somebody scrolled past the first card saw nothing change at all. This fills the error slot // this modal already has, at the top of the form, next to the button they pressed. const onInvalid = (errors: unknown) => setError(invalidSubmitMessage(errors)); const { getValues } = methods; const formOf = useCallback((record: Location) => transformFromFhirLocation(record) as Record, [transformFromFhirLocation]); // The form's values, as plain data the baseline can compare. const read = useCallback(() => getValues() as Record, [getValues]); const write = useCallback((values: Record) => reset(values as never), [reset]); // The version this opening of the form was filled from, and the only one it saves against. A newer one arriving // (by broadcast or a refreshed prop) while the form holds changes is never filled over them: the opening is marked // changed, and Reload record is the way on. See useEditBaseline. const baseline = useEditBaseline>({ formOf, read, write }); const { open: openBaseline, arrive, close: closeBaseline } = baseline; // Each save - an update or a create - belongs to this opening of the modal: closing it (or another person signing // in) retires it, and everything it would still say, close or hand on is dropped with its busy flag. const saveScope = useSaveScope(isOpen ? location?.id ?? 'new' : undefined, () => { closeBaseline(); setSubmitting(false); setReloading(false); setSuccess(undefined); setError(undefined); setCreateUnconfirmed(false); setCreated(false); }); useFhirLocationTargetedBroadcast({ location, isOpen, onUpdate: arrive, onDelete: onClose }); // Each opening starts from the location handed in; a newer one handed in while open arrives like a broadcast. const filledThisOpening = useRef(false); useEffect(() => { if (!isOpen) { filledThisOpening.current = false; return; } if (!location) return; if (!filledThisOpening.current) { filledThisOpening.current = true; openBaseline(location); return; } arrive(location); }, [isOpen, location, openBaseline, arrive]); const onSubmit = async (data: any) => { if (!wpOrganization.current) { const errorMsg = 'Organization information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } if (baseline.stale || createUnconfirmed || created) return; const operation = saveScope.start(); setSubmitting(true); setSuccess(undefined); setError(undefined); try { let result: Location; if (isNew) { result = await operation.step(() => createLocation( transformToFhirLocation(data, wpOrganization.current as Organization), operation.transport, ), true); } else { const outcome = await saveOverBaseline({ baseline, operation, write: (basedOn, transport) => updateLocation({ id: location?.id, ...transformToFhirLocation(data) }, basedOn, transport), }); if ('retired' in outcome) return; if ('refused' in outcome) { // A finished opening says so in its own notice; only a save that sent nothing says it here. if (!outcome.stale) setError(outcome.refused); onError?.(outcome.refused); return; } result = outcome.saved; } if (isNew) setCreated(true); const successMsg = `Location ${isNew ? 'created' : 'updated'} successfully.`; setSuccess(successMsg); onSuccess?.(result); // Closes this opening only: if the modal was closed (and perhaps opened again) meanwhile, it is left alone. setTimeout(() => { if (!operation.belongs()) return; setSuccess(undefined); onClose(); }, 1500); } catch (err: any) { if (isSaveRetired(err)) return; // An answer that never came - out of time, or the connection failed after the request may have left - // may mean it was created: not offered again until what is saved is looked at. if (isNew && outcomeUnknown(err)) { setCreateUnconfirmed(true); const unconfirmed = 'Whether it was created could not be confirmed. Close this and check the list before trying again.'; setError(unconfirmed); onError?.(unconfirmed); return; } if (isSaveTimedOut(err)) { setError(ownFailureSentence(err)); onError?.(ownFailureSentence(err)); return; } console.error(`Error ${isNew ? 'creating' : 'updating'} location:`, err); const errorMsg = describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} location`); setError(errorMsg); onError?.(errorMsg); } finally { if (operation.belongs()) setSubmitting(false); } }; // Reload record: what is saved now, read fresh, becomes the opening; the draft is discarded (the notice says so). const reload = async () => { const id = baseline.opening?.id; if (!id) return; const operation = saveScope.start(); setReloading(true); setError(undefined); try { await reloadBaseline({ baseline, operation, readNow: (transport) => fetchLocation(id, true, transport) }); } catch (reloadError) { if (isSaveRetired(reloadError)) return; setError('The record could not be read. Try Reload record again.'); } finally { if (operation.belongs()) setReloading(false); } }; if (!isOpen) return null; return ( ]} >
{success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )} void reload()} reloading={reloading} />
); };