mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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());
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user