feat(labs): show and file a reading's related report and visit in its sheet

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:34:08 +02:00
1 parent 7167ba8cdc
commit da1d31e370
5 files changed
+187 -5

No files matched your search

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { RelatedLink } from "@/lib/links/related";
import { queryKeys } from "@/lib/query-keys";
const captured = vi.hoisted(() => ({
pickers: null as null | {
linksPath: string;
kinds: readonly string[];
onSaved: () => void;
},
}));
vi.mock("@/components/links/related-records", async () => {
const actual = await vi.importActual<
typeof import("@/components/links/related-records")
>("@/components/links/related-records");
return {
...actual,
RelationLinkPickers: (props: {
linksPath: string;
kinds: readonly string[];
onSaved: () => void;
}) => {
captured.pickers = props;
return null;
},
};
});
import { LabResultRelatedLinks } from "../lab-result-related-links";
const REPORT: RelatedLink = {
kind: "document",
id: "doc-1",
label: "Lab report March",
date: "2026-03-01T00:00:00.000Z",
href: "/documents?doc=doc-1",
via: "direct",
};
function render(qc: QueryClient) {
return renderToStaticMarkup(
<QueryClientProvider client={qc}>
<I18nProvider initialLocale="en">
<LabResultRelatedLinks labResultId="lab-1" takenAt={null} />
</I18nProvider>
</QueryClientProvider>,
);
}
/**
* A lab reading shows what it relates to where it is shown in detail (its
* edit sheet), and files the report and the visit through its own links
* route; a save refreshes every end that lists the reading back.
*/
describe("LabResultRelatedLinks", () => {
it("renders nothing before the detail read lands", () => {
expect(render(new QueryClient())).toBe("");
});
it("lists the related records and offers the report and visit pickers", () => {
const qc = new QueryClient();
qc.setQueryData(queryKeys.labResult("lab-1"), { related: [REPORT] });
const html = render(qc);
expect(html).toContain('data-slot="lab-result-related"');
expect(html).toContain("Lab report March");
expect(captured.pickers?.linksPath).toBe("/api/labs/lab-1/links");
expect(captured.pickers?.kinds).toEqual(["document", "encounter"]);
});
it("refreshes the reading and every end that lists it after a save", () => {
const qc = new QueryClient();
qc.setQueryData(queryKeys.labResult("lab-1"), { related: [] });
const spy = vi.spyOn(qc, "invalidateQueries");
render(qc);
captured.pickers?.onSaved();
const keys = spy.mock.calls.map((call) => call[0]?.queryKey);
expect(keys).toEqual(
expect.arrayContaining([
queryKeys.labResult("lab-1"),
queryKeys.documents(),
queryKeys.encounters(),
queryKeys.illness(),
]),
);
});
});
+20 -5
View File
@@ -28,7 +28,7 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
@@ -40,7 +40,11 @@ import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
import type { LabResultDetailDto, LabResultDto } from "./types";
import {
fetchLabResultDetail,
LabResultRelatedLinks,
} from "./lab-result-related-links";
import type { LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
const NOTE_MAX_LENGTH = 2000;
@@ -242,9 +246,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
const detail = await apiGet<LabResultDetailDto>(
`/api/labs/${reading.id}`,
);
// Through the cache: the related section below reads the same detail.
const detail = await queryClient.fetchQuery({
queryKey: queryKeys.labResult(reading.id),
queryFn: () => fetchLabResultDetail(reading.id),
});
setEditNote(detail.note ?? "");
setEditSeed({ ...seed, note: detail.note ?? "" });
} catch {
@@ -314,6 +320,9 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}
const editingTakenAt =
readings.find((reading) => reading.id === editingId)?.takenAt ?? null;
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...readings].sort((a, b) => {
@@ -593,6 +602,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</Button>
</div>
</form>
{editingId ? (
<LabResultRelatedLinks
labResultId={editingId}
takenAt={editingTakenAt}
/>
) : null}
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
@@ -0,0 +1,67 @@
"use client";
/**
* A lab reading's v1.43 relation links in its edit sheet: the records the
* reading relates to (`related[]` on `GET /api/labs/{id}`, resolved on the
* server) and the pickers that file the report it was read from and the
* visit it was drawn at. The pickers write straight through
* `/api/labs/{id}/links` when picked, independent of the form's Save, the
* same way the visit sheet's medication and dose pickers do.
*/
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
RelatedRecordsBlock,
RelationLinkPickers,
} from "@/components/links/related-records";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDetailDto } from "./types";
export function fetchLabResultDetail(id: string) {
return apiGet<LabResultDetailDto>(`/api/labs/${encodeURIComponent(id)}`);
}
export function LabResultRelatedLinks({
labResultId,
takenAt,
}: {
labResultId: string;
takenAt: string | null;
}) {
const qc = useQueryClient();
const detail = useQuery({
queryKey: queryKeys.labResult(labResultId),
queryFn: () => fetchLabResultDetail(labResultId),
});
// The pickers seed from this read; before it lands they would offer an
// empty selection that the first tap could not compute a change from.
if (!detail.data) return null;
const related = detail.data.related;
return (
<div className="space-y-4 border-t pt-4" data-slot="lab-result-related">
<RelatedRecordsBlock related={related} />
<RelationLinkPickers
key={labResultId}
linksPath={`/api/labs/${encodeURIComponent(labResultId)}/links`}
kinds={["document", "encounter"]}
related={related}
anchor={takenAt}
onSaved={() => {
// The other ends list the reading too: the document's and the
// visit's sheets seed their pickers from their cached reads, and a
// condition reaches the reading through the visit.
for (const queryKey of [
queryKeys.labResult(labResultId),
queryKeys.documents(),
queryKeys.encounters(),
queryKeys.illness(),
]) {
void qc.invalidateQueries({ queryKey });
}
}}
/>
</div>
);
}
+4
View File
@@ -1,3 +1,5 @@
import type { RelatedLink } from "@/lib/links/related";
import type {
ReferenceRangeOrigin,
ReferenceRangeStatus,
@@ -58,6 +60,8 @@ export interface LabResultDto {
/** The single-resource detail DTO — carries the decrypted note. */
export interface LabResultDetailDto extends Omit<LabResultDto, "hasNote"> {
note: string | null;
/** v1.43 — the records this reading relates to, resolved on the server. */
related?: RelatedLink[];
}
export interface LabResultListResponse {
+5
View File
@@ -34,6 +34,11 @@ export const labKeys = {
// mutation's `labResults()` invalidation evict it.
labResultsOverview: () => ["lab-results", "overview"] as const,
// One reading's detail (`GET /api/labs/{id}`): the decrypted note and the
// records it relates to. Under the `lab-results` prefix so every result
// mutation evicts it.
labResult: (id: string) => ["lab-results", "detail", id] as const,
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`