fix(documents): keep the link pickers from deleting a link made elsewhere

A dose or a visit can link documents from its own form, and a
document's sheet can link doses and visits; both ends write the same
table, and the sheet writes the whole set on every tap. Dose and visit
writes left the document reads alone, and the sheet seeded its pickers
from a detail cached for five minutes, so the first tap there after
linking from the dose's form wrote a set without the new link and
removed it.

Dose and visit writes now evict the document root, the sheet reads the
document again on every open, and its pickers wait for that read before
they offer a change.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:15:36 +02:00
1 parent 28d491174e
commit 15cce33a28
5 files changed
+105 -7

No files matched your search

@@ -103,7 +103,12 @@ describe("recordOptions", () => {
describe("<DocumentRecordLinks>", () => {
it("lets a manager link the page to doses and visits", () => {
const html = render(
<DocumentRecordLinks doc={doc()} canManage onChange={() => undefined} />,
<DocumentRecordLinks
doc={doc()}
canManage
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('data-slot="document-vaccination-links"');
expect(html).toContain('data-slot="document-vaccination-links-add"');
@@ -124,6 +129,7 @@ describe("<DocumentRecordLinks>", () => {
],
})}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
@@ -137,9 +143,26 @@ describe("<DocumentRecordLinks>", () => {
<DocumentRecordLinks
doc={doc({ vaccinationLinks: null })}
canManage
seedFresh
onChange={() => undefined}
/>,
);
expect(html).not.toContain("document-vaccination-links");
});
it("offers no picker until the document has been read since the sheet opened", () => {
// Each change is a replace-set write seeded from the document's links.
// Seeded from a cached copy that predates a link made on the dose's or
// the visit's own form, the first tap would silently delete that link.
const html = render(
<DocumentRecordLinks
doc={doc()}
canManage
seedFresh={false}
onChange={() => undefined}
/>,
);
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
expect(html).not.toContain('data-slot="document-visit-links-add"');
});
});
@@ -353,11 +353,30 @@ export function DocumentDetailSheet({
const detail = useQuery({
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
enabled: open && documentId !== null,
// Always read again on open: the link pickers below write replace-sets
// seeded from this copy (see `seedFresh`).
staleTime: 0,
queryFn: () =>
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
});
const doc = detail.data;
// The detail's timestamp when the sheet opened on this document. A read
// that lands after it is fresh enough to seed the link pickers from; a
// cached copy from before the sheet opened is not.
const [openedOn, setOpenedOn] = useState<{
id: string;
seenAt: number;
} | null>(null);
const openKey = open && documentId !== null ? documentId : null;
if (openKey !== (openedOn?.id ?? null)) {
setOpenedOn(
openKey === null ? null : { id: openKey, seenAt: detail.dataUpdatedAt },
);
}
const linkSeedFresh =
openedOn !== null && detail.dataUpdatedAt > openedOn.seenAt;
// 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction
// had no UI link at all: an auto-staged (PENDING) or OCR-confirmed
// (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already
@@ -1111,6 +1130,9 @@ export function DocumentDetailSheet({
key={`${doc.id}:${linkResets}`}
doc={doc}
canManage={canManageDocuments}
seedFresh={linkSeedFresh}
seedError={!linkSeedFresh && detail.isRefetchError}
onRetrySeed={() => void detail.refetch()}
onChange={(part) => patch.mutate(part)}
/>
@@ -91,10 +91,24 @@ function doseName(
export function DocumentRecordLinks({
doc,
canManage,
seedFresh,
seedError = false,
onRetrySeed,
onChange,
}: {
doc: InboundDocumentDetailDto;
canManage: boolean;
/**
* Whether `doc` was read from the server since the sheet opened. Every
* change is a replace-set write seeded from `doc`'s links, so a seed from a
* cached copy that predates a link made on the dose's or the visit's own
* form would delete that link on the first tap. The pickers wait for a
* fresh read instead.
*/
seedFresh: boolean;
/** That fresh read failed; the pickers show the retry row instead. */
seedError?: boolean;
onRetrySeed?: () => void;
onChange: (part: {
encounterIds?: string[];
vaccinationIds?: string[];
@@ -155,10 +169,13 @@ export function DocumentRecordLinks({
icon={CalendarDays}
title={t("documents.detail.visitsLabel")}
slot="document-visit-links"
pending={encounters.isPending}
error={encounters.isError}
pending={encounters.isPending || !seedFresh}
error={encounters.isError || seedError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void encounters.refetch()}
onRetry={() => {
if (encounters.isError) void encounters.refetch();
if (seedError) onRetrySeed?.();
}}
selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)}
onChange={(ids) => {
setVisitIds(ids);
@@ -199,10 +216,13 @@ export function DocumentRecordLinks({
icon={Syringe}
title={t("documents.detail.vaccinationsLabel")}
slot="document-vaccination-links"
pending={vaccinations.isPending}
error={vaccinations.isError}
pending={vaccinations.isPending || !seedFresh}
error={vaccinations.isError || seedError}
errorLabel={t("links.picker.loadError")}
onRetry={() => void vaccinations.refetch()}
onRetry={() => {
if (vaccinations.isError) void vaccinations.refetch();
if (seedError) onRetrySeed?.();
}}
selected={
doseIds ??
(doc.vaccinationLinks ?? []).map((link) => link.vaccinationId)
@@ -0,0 +1,25 @@
/**
* A visit or a dose written from its own form can link or unlink documents,
* and the document's sheet seeds its replace-set pickers from its cached
* links. A record write that left the document reads alone kept that seed
* stale, and the next tap on the sheet deleted the new link. Both record
* bundles therefore evict the document root, and the document's own link
* write evicts the record roots (pinned in `document-detail-sheet`).
*/
import { describe, expect, it } from "vitest";
import {
encounterDependentKeys,
queryKeys,
vaccinationDependentKeys,
} from "@/lib/query-keys";
describe("record writes reach the document reads", () => {
it("a dose write evicts the documents root", () => {
expect(vaccinationDependentKeys).toContainEqual(queryKeys.documents());
});
it("a visit write evicts the documents root", () => {
expect(encounterDependentKeys).toContainEqual(queryKeys.documents());
});
});
+8
View File
@@ -281,6 +281,10 @@ export const encounterDependentKeys = [
// `invalidateReminderReads` helper, which carries it) — the digest is
// typically unmounted while the user is on the visits surface.
queryKeys.dailyDigest(),
// A visit's form links and unlinks documents, and the document's sheet
// seeds its replace-set visit picker from its own cached links: left
// stale, the next tap there would delete the link just made.
queryKeys.documents(),
];
/**
@@ -300,6 +304,10 @@ export const encounterDependentKeys = [
export const vaccinationDependentKeys = [
queryKeys.vaccinations(),
queryKeys.measurementReminders(),
// A dose's form links and unlinks documents, and the document's sheet
// seeds its replace-set dose picker from its own cached links: left stale,
// the next tap there would delete the link just made.
queryKeys.documents(),
];
/**