diff --git a/apps/web/src/components/DetailBoxes.tsx b/apps/web/src/components/DetailBoxes.tsx index 304c111..af46f7e 100644 --- a/apps/web/src/components/DetailBoxes.tsx +++ b/apps/web/src/components/DetailBoxes.tsx @@ -6,6 +6,10 @@ import { orderEngagements, setCustomValue } from "../edits.ts"; import { actorConnectionId, describeRating, + draftFailedSave, + draftIncoming, + draftSaved, + draftStartSave, engagementNouns, formatDateRange, formatPartialDate, @@ -16,6 +20,7 @@ import { rollupLabel, statusLabel, timeAgo, + type Draft, type PartialDate, } from "../format.ts"; import { useEntryPatch, useSources } from "../hooks.ts"; @@ -357,6 +362,7 @@ export function SourcesBox({ entry, engagements }: { entry: EntryView; engagemen export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) { const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 }); const patch = useEntryPatch(entry.id); + const scale = useRatingScale(); if (!defs.data?.length) return null; const save = (def: CustomFieldDef, value: unknown) => patch.edit((cur) => { @@ -370,7 +376,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin save(d, v)} /> ))} - {patch.error &&

{friendlyError(patch.error)}

} + {patch.error &&

{friendlyError(patch.error, { ratingScale: scale })}

} ); } @@ -494,21 +500,33 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) export function ReviewBox({ entry }: { entry: EntryView }) { const patch = useEntryPatch(entry.id); const [saved, setSaved] = useState(false); - const [text, setText] = useState(entry.review ?? ""); - // The server value this text was last in sync with. When the review changes elsewhere (Restore, - // another tab) and the user has not typed since, show the new value, so a later blur can't undo it. - const synced = useRef(entry.review ?? ""); - useEffect(() => { - const incoming = entry.review ?? ""; - if (incoming === synced.current) return; - setText((cur) => (cur === synced.current ? incoming : cur)); - synced.current = incoming; - }, [entry.review]); + // Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows + // up here unless the user has typed since; a failed save keeps what they typed. + const [draft, setDraft] = useState(() => ({ text: entry.review ?? "", synced: entry.review ?? "", saving: false })); + const inFlight = useRef(0); + useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]); const save = () => { - const next = text.trim() === "" ? null : text; - if ((next ?? "") === (entry.review ?? "")) return; - synced.current = next ?? ""; - patch.mutate({ review: next }, { onSuccess: () => setSaved(true) }); + const next = draft.text.trim() === "" ? null : draft.text; + const stored = next ?? ""; + if (stored === draft.synced && stored === (entry.review ?? "")) return; + const was = draft.synced; + inFlight.current += 1; + setDraft((d) => ({ ...draftStartSave(d), synced: stored })); + const settle = (d: Draft) => ({ ...d, saving: inFlight.current > 0 }); + patch.mutate( + { review: next }, + { + onSuccess: () => { + inFlight.current -= 1; + setDraft((d) => settle(draftSaved(d, stored))); + setSaved(true); + }, + onError: () => { + inFlight.current -= 1; + setDraft((d) => settle(draftFailedSave(d, was))); + }, + }, + ); }; return ( Your review (Markdown)