import { useCallback, useRef, useState } from 'react'; import { olderThan } from '../utils/versionOrder'; type Versioned = { meta?: { versionId?: string } }; const versionOf = (record: Versioned | undefined): number | undefined => { const version = Number(record?.meta?.versionId); return record?.meta?.versionId && Number.isFinite(version) ? version : undefined; }; /** Whether `record` is newer than every one of `known` (an unversioned record is never newer). */ const newerThan = (record: Versioned, ...known: (Versioned | undefined)[]): boolean => { const incoming = versionOf(record); if (incoming === undefined) return known.every((other) => !other); return known.every((other) => { const version = versionOf(other); return version === undefined || incoming > version; }); }; const stable = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(stable); if (value && typeof value === 'object') { return Object.keys(value as Record).sort().reduce>((out, key) => { const entry = (value as Record)[key]; if (entry !== undefined) out[key] = stable(entry); return out; }, {}); } return value; }; const same = (a: unknown, b: unknown) => JSON.stringify(stable(a)) === JSON.stringify(stable(b)); /** * Why an opening can save nothing more: someone else changed the record (`changed`: a 412, or a newer version * arriving while the form held changes), the page named no version (`outOfDate`: a 428), or a save's result could * not be confirmed (`unconfirmed`). */ export type StaleReason = 'changed' | 'outOfDate' | 'unconfirmed'; interface EditBaselineOptions { /** The form's values for a record (a `transformFromFhir…`). */ formOf: (record: T) => D; /** The form's values now. */ read: () => D; /** Puts values in the form (a react-hook-form `reset`). */ write: (values: D) => void; /** Anything else the page shows from the record it fills from. */ onFill?: (record: T) => void; } /** * The version a form was opened on: the only version it saves against. * * DIRTY MEANS DIFFERENT FROM THE OPENING RECORD, not from the form library's defaults. A clean form follows a newer * record. A dirty one is never refilled and its basis never moves: a newer version arriving marks the opening * `stale`, and nothing more is saved from it. A refused save, an out-of-date page or an unconfirmed result does the * same. The way on is Reload record - `open` from an authoritative read, which discards the draft (the page says so * first) - or leaving. Nothing is merged, compared or retried. * * Arrivals only move forward: an older read or broadcast never displaces a newer one. While a save is under way they * wait for its answer, so this form's own write coming back is not taken for someone else's. Each save holds a * token: opening, closing or a later save ends it, and a late completion ends nothing that came after it. */ export const useEditBaseline = ({ formOf, read, write, onFill }: EditBaselineOptions) => { const [opening, setOpening] = useState(); const [stale, setStaleState] = useState(); const openingRef = useRef(); const staleRef = useRef(); // The newest version of this record known here, shown or not. const newestRef = useRef(); const saves = useRef(0); const saving = useRef(); const waiting = useRef(); const setStale = useCallback((reason: StaleReason | undefined) => { staleRef.current = reason; setStaleState(reason); }, []); const isDirty = useCallback(() => { const known = openingRef.current; return known ? !same(read(), formOf(known)) : false; }, [read, formOf]); /** A new opening: the form is filled from `record`, and any save still under way ends nothing here. */ const open = useCallback((record: T) => { openingRef.current = record; if (!newestRef.current || newerThan(record, newestRef.current)) newestRef.current = record; saving.current = undefined; waiting.current = undefined; setOpening(record); setStale(undefined); write(formOf(record)); onFill?.(record); }, [formOf, write, onFill, setStale]); /** Nothing is open: a new record is being loaded, or the page moved to another one. */ const close = useCallback(() => { openingRef.current = undefined; newestRef.current = undefined; saving.current = undefined; waiting.current = undefined; setOpening(undefined); setStale(undefined); }, [setStale]); const arrive = useCallback((record: T) => { if (saving.current !== undefined) { if (newerThan(record, waiting.current)) waiting.current = record; return; } const known = openingRef.current; if (!known) { open(record); return; } // Only forward: not the version open, nor older than the newest already known. if (!newerThan(record, known, newestRef.current)) return; if (staleRef.current || isDirty()) { newestRef.current = record; if (!staleRef.current) setStale('changed'); return; } open(record); }, [isDirty, open, setStale]); /** This opening saves nothing more; only Reload record (or leaving) moves on. */ const refuse = useCallback((reason: StaleReason) => setStale(reason), [setStale]); /** A save starts: arrivals wait for its answer. The token ends only this save. */ const beginSave = useCallback((): number => { saves.current += 1; saving.current = saves.current; waiting.current = undefined; return saves.current; }, []); /** `saved`: what the save wrote, now the opening. Without it the save did not land. Nothing, once superseded. */ const endSave = useCallback((token: number, saved?: T) => { if (saving.current !== token) return; saving.current = undefined; const arrived = waiting.current; waiting.current = undefined; if (saved && !olderThan(saved, newestRef.current)) open(saved); if (arrived) arrive(arrived); }, [arrive, open]); return { opening, basedOn: opening?.meta?.versionId, stale, isDirty, open, close, arrive, refuse, beginSave, endSave, /** The newest version known here (an opening never goes back past it). */ newest: useCallback(() => newestRef.current, []), }; };