mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(labs): show and file a reading's related report and visit in its sheet
This commit is contained in:
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(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user