mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(links): offer the whole vault when linking a document to a record
The visit and vaccination forms asked the document list for a page of 200 while the route accepts at most 100. The refusal read as an empty list, so the picker said "No documents yet." to anyone with a full vault, and the only way a scan reached a dose was the seven-day suggestion offered at upload. The visit form's condition picker had the same problem against the episode list. The document pickers now walk the vault page by page at the route's own limit, so any document can be linked from a vaccination or a visit, and one document can sit on as many records as it covers. Documents dated within seven days of the record stay on top under their own heading, the rest follow by month and are searchable. A failed read now shows an error with a retry instead of passing for an empty list. Refs #1024, #1005
This commit is contained in:
1 parent
8f889cf942
commit
f46ed99def
17 files changed
+443
-85
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
|
||||
import { listLabResultsSchema } from "@/lib/validations/labs";
|
||||
import {
|
||||
EPISODE_FETCH_LIMIT,
|
||||
PICKER_FETCH_LIMIT,
|
||||
} from "../encounter-link-pickers";
|
||||
|
||||
/**
|
||||
* Each picker's page size must be one its list route accepts. A refused size
|
||||
* comes back as a 422, and a picker that treats a failed read as an empty
|
||||
* list tells the person there is nothing to link. That is how the visit
|
||||
* form's condition picker and both document pickers showed nothing at all.
|
||||
*/
|
||||
describe("visit link picker page sizes", () => {
|
||||
it("the condition picker asks for a page the episode route accepts", () => {
|
||||
expect(
|
||||
illnessEpisodeListQuerySchema.safeParse({
|
||||
limit: String(EPISODE_FETCH_LIMIT),
|
||||
includeResolved: "true",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("the lab picker asks for a page the lab route accepts", () => {
|
||||
expect(
|
||||
listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -273,6 +273,7 @@ export function EncounterForm({
|
||||
|
||||
<EncounterLinkPickers
|
||||
modules={user?.modules}
|
||||
anchor={draft.occurredAt || null}
|
||||
documentIds={draft.documentIds}
|
||||
labResultIds={draft.labResultIds}
|
||||
episodeIds={draft.episodeIds}
|
||||
|
||||
@@ -27,14 +27,20 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
|
||||
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
|
||||
const PICKER_FETCH_LIMIT = 200;
|
||||
export const PICKER_FETCH_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The episode list route caps `limit` at 100 and answers anything above it
|
||||
* with a 422, which the picker used to read as "nothing here to link".
|
||||
*/
|
||||
export const EPISODE_FETCH_LIMIT = 100;
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
@@ -45,18 +51,17 @@ interface LabListPage {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
export function EncounterLinkPickers({
|
||||
modules,
|
||||
anchor,
|
||||
documentIds,
|
||||
labResultIds,
|
||||
episodeIds,
|
||||
onChange,
|
||||
}: {
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined;
|
||||
/** The visit's own date, for the document suggestions on top. */
|
||||
anchor: string | null;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
@@ -73,13 +78,12 @@ export function EncounterLinkPickers({
|
||||
const labsOn = modules?.labs === true;
|
||||
const illnessOn = modules?.illness === true;
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
|
||||
// The whole vault, suggestions near the visit's date on top — see
|
||||
// `useVaultDocumentOptions` for why this no longer asks for one oversized
|
||||
// page.
|
||||
const documents = useVaultDocumentOptions({
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${PICKER_FETCH_LIMIT}`,
|
||||
),
|
||||
anchor,
|
||||
});
|
||||
|
||||
const labs = useQuery({
|
||||
@@ -103,30 +107,12 @@ export function EncounterLinkPickers({
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${PICKER_FETCH_LIMIT}`,
|
||||
`/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
// Documents group by month; the caller localizes the heading.
|
||||
const documentOptions: EntityLinkOption[] = (
|
||||
documents.data?.documents ?? []
|
||||
).map((doc) => {
|
||||
const date = doc.documentDate ?? doc.reportDate ?? doc.createdAt;
|
||||
return {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? format.date(date) : null,
|
||||
group: date
|
||||
? {
|
||||
key: date.slice(0, 7),
|
||||
label: `${format.monthShort(date)} ${date.slice(0, 4)}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
|
||||
// underneath. A visit links the whole panel from a day in one "select all".
|
||||
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
|
||||
@@ -163,10 +149,13 @@ export function EncounterLinkPickers({
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
pending={documents.pending}
|
||||
error={documents.error}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={documents.retry}
|
||||
selected={documentIds}
|
||||
onChange={(ids) => onChange({ documentIds: ids })}
|
||||
options={documentOptions}
|
||||
options={documents.options}
|
||||
searchPlaceholder={t("links.picker.searchPlaceholder")}
|
||||
emptyLabel={t("encounters.form.linkNothingToOffer")}
|
||||
/>
|
||||
@@ -178,6 +167,9 @@ export function EncounterLinkPickers({
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
error={labs.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void labs.refetch()}
|
||||
selected={labResultIds}
|
||||
onChange={(ids) => onChange({ labResultIds: ids })}
|
||||
options={labOptions}
|
||||
@@ -192,6 +184,9 @@ export function EncounterLinkPickers({
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
error={episodes.isError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void episodes.refetch()}
|
||||
selected={episodeIds}
|
||||
onChange={(ids) => onChange({ episodeIds: ids })}
|
||||
options={episodeOptions}
|
||||
|
||||
Reference in new issue
Block a user