mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(visits): reach every lab result, condition, document and visit from the link pickers
The visit form's lab picker offered the newest 200 results, the condition picker the newest 100 conditions, the document pickers the newest thousand documents and the document sheet's visit picker the newest 100 visits. On a long record everything older could not be linked. Each picker now opens on the records around the date of the thing being linked, pages older and newer from there, and searches through the list route itself. Records already linked always show, from the labels the record's own read carries and from what was picked in the sheet. The lab list takes a q filter over analyte and panel, the condition list from/to/q/offset/sortDir, and the visit list an offset.
This commit is contained in:
1 parent
dad9a7778e
commit
73c4dda224
35 files changed
+1714
-251
No files matched your search
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* The link picker over a list that pages on the server.
|
||||
*
|
||||
* A visit's link pickers used to offer one capped page and nothing past it.
|
||||
* Paged, three things have to hold: what the record already links shows
|
||||
* whether or not a loaded page holds it, the sheet offers the paging the
|
||||
* server reports, and an empty first window is not read as "nothing to link".
|
||||
*
|
||||
* ResponsiveSheet portals its body, which static markup does not render, so it
|
||||
* is mocked down to a wrapper that always shows its children.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="mock-sheet">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
EntityLinkPicker,
|
||||
resolvableOptions,
|
||||
type EntityLinkOption,
|
||||
type EntityLinkServerPaging,
|
||||
} from "../entity-link-picker";
|
||||
import { mergeWindowPages, uniqueById } from "../use-link-picker-pages";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const server = (over: Partial<EntityLinkServerPaging> = {}) => ({
|
||||
term: "",
|
||||
onTermChange: () => undefined,
|
||||
searchPending: false,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
loadingOlder: false,
|
||||
loadingNewer: false,
|
||||
onOlder: () => undefined,
|
||||
onNewer: () => undefined,
|
||||
...over,
|
||||
});
|
||||
|
||||
const base = {
|
||||
icon: FlaskConical,
|
||||
title: "Lab results",
|
||||
slot: "labs",
|
||||
searchPlaceholder: "Search",
|
||||
emptyLabel: "Nothing to link",
|
||||
pending: false,
|
||||
onChange: () => undefined,
|
||||
};
|
||||
|
||||
const loaded: EntityLinkOption[] = [
|
||||
{ id: "recent", label: "Glucose", dateLabel: "12.05.2026" },
|
||||
];
|
||||
const linked2019: EntityLinkOption = {
|
||||
id: "ferritin-2019",
|
||||
label: "Ferritin",
|
||||
dateLabel: "03.04.2019",
|
||||
};
|
||||
|
||||
describe("paged link picker", () => {
|
||||
it("a linked record outside every loaded page still shows as a chip and in the sheet", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={["ferritin-2019"]}
|
||||
knownOptions={[linked2019]}
|
||||
server={server({ hasOlder: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="labs-chip"');
|
||||
expect(html).toContain("Ferritin");
|
||||
expect(html).toContain("03.04.2019");
|
||||
// In the sheet, on top under its own heading.
|
||||
expect(html).toContain("Linked");
|
||||
});
|
||||
|
||||
it("offers older and newer pages while the server reports them", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({ hasOlder: true, hasNewer: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="labs-load-older"');
|
||||
expect(html).toContain('data-slot="labs-load-newer"');
|
||||
expect(html).toContain("Load older");
|
||||
expect(html).toContain("Load newer");
|
||||
});
|
||||
|
||||
it("offers no paging once the server has no more", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server()}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="labs-load-older"');
|
||||
expect(html).not.toContain('data-slot="labs-load-newer"');
|
||||
});
|
||||
|
||||
it("an empty first window with more to page is not 'nothing to link'", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={[]}
|
||||
selected={[]}
|
||||
server={server({ hasNewer: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("Nothing to link");
|
||||
expect(html).toContain('data-slot="labs-add"');
|
||||
});
|
||||
|
||||
it("does not filter a server answer again by the term", () => {
|
||||
// The route matched on the panel name; the option does not render it.
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({ term: "metabolic" })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="labs-option"');
|
||||
});
|
||||
|
||||
it("a route without search filters what is loaded", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={loaded}
|
||||
selected={[]}
|
||||
server={server({ term: "nomatch", filterLocally: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="labs-option"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvableOptions", () => {
|
||||
it("prefers the loaded option, then the known one, then a remembered pick", () => {
|
||||
const picked = new Map([
|
||||
["a", { id: "a", label: "picked" }],
|
||||
["b", { id: "b", label: "picked" }],
|
||||
["c", { id: "c", label: "picked" }],
|
||||
]);
|
||||
const map = resolvableOptions(
|
||||
[{ id: "a", label: "loaded" }],
|
||||
[
|
||||
{ id: "a", label: "known" },
|
||||
{ id: "b", label: "known" },
|
||||
],
|
||||
picked,
|
||||
);
|
||||
expect(map.get("a")?.label).toBe("loaded");
|
||||
expect(map.get("b")?.label).toBe("known");
|
||||
expect(map.get("c")?.label).toBe("picked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWindowPages", () => {
|
||||
it("puts the newer window, newest first, above the older one", () => {
|
||||
const merged = mergeWindowPages(
|
||||
[
|
||||
{ items: [{ id: "n1" }, { id: "n2" }], next: "2" },
|
||||
{ items: [{ id: "n3" }], next: null },
|
||||
],
|
||||
[{ items: [{ id: "o1" }, { id: "o2" }], next: null }],
|
||||
);
|
||||
expect(merged.map((item) => item.id)).toEqual([
|
||||
"n3",
|
||||
"n2",
|
||||
"n1",
|
||||
"o1",
|
||||
"o2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a record the two windows both returned", () => {
|
||||
expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([
|
||||
{ id: "x" },
|
||||
{ id: "y" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Every request a link picker can send must be one its list route accepts.
|
||||
*
|
||||
* A refused parameter comes back as a 422, and a picker that reads 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 once showed
|
||||
* nothing at all. Each builder is checked in every direction, with and without
|
||||
* a date, against the very schema its route parses with.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { documentListQuerySchema } from "@/lib/validations/inbound-documents";
|
||||
import { encounterListQuerySchema } from "@/lib/validations/encounters";
|
||||
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
|
||||
import { listLabResultsSchema } from "@/lib/validations/labs";
|
||||
|
||||
import {
|
||||
documentPickerUrl,
|
||||
encounterPickerUrl,
|
||||
episodePickerUrl,
|
||||
labPickerUrl,
|
||||
} from "../picker-requests";
|
||||
import {
|
||||
PICKER_PAGE_SIZE,
|
||||
nextOffset,
|
||||
pickerPivot,
|
||||
type PickerPageRequest,
|
||||
} from "../use-link-picker-pages";
|
||||
|
||||
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
|
||||
|
||||
const REQUESTS: PickerPageRequest[] = [
|
||||
{ direction: "older", pivot, term: "", cursor: null },
|
||||
{ direction: "older", pivot, term: "", cursor: "200" },
|
||||
{ direction: "older", pivot: null, term: "", cursor: null },
|
||||
{ direction: "newer", pivot, term: "", cursor: null },
|
||||
{ direction: "newer", pivot, term: "", cursor: "100" },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: null },
|
||||
{ direction: "search", pivot: null, term: "ferritin", cursor: "100" },
|
||||
];
|
||||
|
||||
function params(url: string): Record<string, string> {
|
||||
return Object.fromEntries(new URL(url, "http://local").searchParams);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["labs", labPickerUrl, listLabResultsSchema],
|
||||
["episodes", episodePickerUrl, illnessEpisodeListQuerySchema],
|
||||
["encounters", encounterPickerUrl, encounterListQuerySchema],
|
||||
])("%s picker", (_name, build, schema) => {
|
||||
it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => {
|
||||
const result = schema.safeParse(params(build(request)));
|
||||
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents picker", () => {
|
||||
it.each(REQUESTS)("$direction parses", (request) => {
|
||||
// The document cursor is a row id, not an offset.
|
||||
const url = documentPickerUrl({
|
||||
...request,
|
||||
cursor: request.cursor ? "doc-id" : null,
|
||||
});
|
||||
const result = documentListQuerySchema.safeParse(params(url));
|
||||
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
|
||||
});
|
||||
|
||||
it("splits the day-granular window at the pivot's day", () => {
|
||||
const older = params(
|
||||
documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
);
|
||||
const newer = params(
|
||||
documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
|
||||
);
|
||||
expect(older.to).toBe("2019-05-17");
|
||||
expect(newer.from).toBe("2019-05-18");
|
||||
expect(older.order).toBe("desc");
|
||||
expect(newer.order).toBe("asc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the windows around a record's date", () => {
|
||||
it("splits a week after the date, at the end of that day", () => {
|
||||
expect(pivot).toBe("2019-05-17T23:59:59.999Z");
|
||||
expect(pickerPivot(null)).toBeNull();
|
||||
expect(pickerPivot("not a date")).toBeNull();
|
||||
});
|
||||
|
||||
it("older reads up to the split newest first, newer after it oldest first", () => {
|
||||
const older = params(
|
||||
labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
);
|
||||
const newer = params(
|
||||
labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
|
||||
);
|
||||
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
|
||||
expect(newer).toMatchObject({
|
||||
from: "2019-05-18T00:00:00.000Z",
|
||||
sortDir: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
it("a search ignores the window and sends the term", () => {
|
||||
const search = params(
|
||||
labPickerUrl({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term: "ferritin",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
expect(search.q).toBe("ferritin");
|
||||
expect(search.from).toBeUndefined();
|
||||
expect(search.to).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an offset page that came back full has a next page, a short one has none", () => {
|
||||
expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE));
|
||||
expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200");
|
||||
expect(nextOffset("100", 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWholeVault", () => {
|
||||
it("asks only for pages the list route accepts, and walks every cursor", async () => {
|
||||
describe("fetchVaultPage", () => {
|
||||
it("asks for one page the list route accepts and hands back its cursor", async () => {
|
||||
const { documentListQuerySchema } =
|
||||
await import("@/lib/validations/inbound-documents");
|
||||
const apiFetch = await import("@/lib/api/api-fetch");
|
||||
const urls: string[] = [];
|
||||
const pages = [
|
||||
{ documents: [doc("a", "2026-05-01")], nextCursor: "a" },
|
||||
{ documents: [doc("b", "1991-03-01")], nextCursor: null },
|
||||
];
|
||||
const spy = vi
|
||||
.spyOn(apiFetch, "apiGet")
|
||||
.mockImplementation(async (url: string) => {
|
||||
urls.push(url);
|
||||
return pages.shift() as never;
|
||||
return {
|
||||
documents: [doc("a", "2026-05-01")],
|
||||
nextCursor: "a",
|
||||
} as never;
|
||||
});
|
||||
try {
|
||||
const { fetchWholeVault } = await import("../vault-document-options");
|
||||
const documents = await fetchWholeVault();
|
||||
expect(documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
// The route parses its query with this schema and answers a 422 to
|
||||
// anything it refuses; the picker used to read that 422 as an empty
|
||||
// vault.
|
||||
const params = Object.fromEntries(
|
||||
new URL(url, "http://local").searchParams,
|
||||
);
|
||||
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
|
||||
}
|
||||
expect(urls[1]).toContain("cursor=a");
|
||||
const { fetchVaultPage } = await import("../vault-document-options");
|
||||
const page = await fetchVaultPage({
|
||||
direction: "older",
|
||||
pivot: "2026-05-17T23:59:59.999Z",
|
||||
term: "",
|
||||
cursor: "z",
|
||||
});
|
||||
expect(page.items.map((d) => d.id)).toEqual(["a"]);
|
||||
expect(page.next).toBe("a");
|
||||
const params = Object.fromEntries(
|
||||
new URL(urls[0]!, "http://local").searchParams,
|
||||
);
|
||||
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
|
||||
expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -30,10 +30,18 @@
|
||||
* sheet where its button cannot be pressed while the sheet is open. The chip
|
||||
* carries the option's date, so two doses of one vaccine read as two
|
||||
* different records.
|
||||
*
|
||||
* **A long list pages on the server.** A caller over a list that can outgrow
|
||||
* one page passes `server`: the search box then asks the list route instead
|
||||
* of filtering what is loaded, and "Load newer" / "Load older" page through
|
||||
* the windows around the record's date (see `useLinkPickerPages`). A record
|
||||
* that is linked always shows as a chip, whether or not the page that holds
|
||||
* it is loaded: the caller passes what it knows about the existing links as
|
||||
* `knownOptions`, and every option picked in this session is remembered.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Plus, Search, X } from "lucide-react";
|
||||
import { Check, Loader2, Plus, Search, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -82,6 +90,74 @@ export function filterOptions(
|
||||
return options.filter((option) => haystack(option).includes(needle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side search and paging for a list that can outgrow one page. The
|
||||
* caller owns the term (it is part of the list route's query) and the pages.
|
||||
*/
|
||||
export interface EntityLinkServerPaging {
|
||||
term: string;
|
||||
onTermChange: (term: string) => void;
|
||||
/** The typed term has not been answered yet. */
|
||||
searchPending: boolean;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
loadingOlder: boolean;
|
||||
loadingNewer: boolean;
|
||||
onOlder: () => void;
|
||||
onNewer: () => void;
|
||||
/**
|
||||
* The list route has no text search: the term filters the loaded options
|
||||
* here instead of being sent.
|
||||
*/
|
||||
filterLocally?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paging props from a `useLinkPickerPages` result, so every caller
|
||||
* wires the same fields the same way.
|
||||
*/
|
||||
export function serverPagingFrom(pages: {
|
||||
term: string;
|
||||
setTerm: (term: string) => void;
|
||||
searchPending: boolean;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
loadingOlder: boolean;
|
||||
loadingNewer: boolean;
|
||||
loadOlder: () => void;
|
||||
loadNewer: () => void;
|
||||
filterLocally: boolean;
|
||||
}): EntityLinkServerPaging {
|
||||
return {
|
||||
term: pages.term,
|
||||
onTermChange: pages.setTerm,
|
||||
searchPending: pages.searchPending,
|
||||
hasOlder: pages.hasOlder,
|
||||
hasNewer: pages.hasNewer,
|
||||
loadingOlder: pages.loadingOlder,
|
||||
loadingNewer: pages.loadingNewer,
|
||||
onOlder: pages.loadOlder,
|
||||
onNewer: pages.loadNewer,
|
||||
filterLocally: pages.filterLocally,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The options a chip can be resolved from: what is loaded, what the caller
|
||||
* knows about the existing links, and what was picked in this session. The
|
||||
* loaded option wins, then the known one, then the picked one.
|
||||
*/
|
||||
export function resolvableOptions(
|
||||
options: readonly EntityLinkOption[],
|
||||
knownOptions: readonly EntityLinkOption[],
|
||||
picked: ReadonlyMap<string, EntityLinkOption>,
|
||||
): Map<string, EntityLinkOption> {
|
||||
const map = new Map<string, EntityLinkOption>(picked);
|
||||
for (const option of knownOptions) map.set(option.id, option);
|
||||
for (const option of options) map.set(option.id, option);
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface EntityLinkGroup {
|
||||
/** Group key, or null for the flat (ungrouped) bucket. */
|
||||
key: string | null;
|
||||
@@ -165,6 +241,8 @@ export function EntityLinkPicker({
|
||||
errorLabel,
|
||||
onRetry,
|
||||
extraAction,
|
||||
server,
|
||||
knownOptions,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -191,27 +269,68 @@ export function EntityLinkPicker({
|
||||
* the way forward. The element renders nothing when it has nothing to offer.
|
||||
*/
|
||||
extraAction?: ReactNode;
|
||||
/** Server-side search and paging; omit for a list that fits one read. */
|
||||
server?: EntityLinkServerPaging;
|
||||
/**
|
||||
* What the caller knows about records already linked (label and date from
|
||||
* the record's own read), so their chips show even when no loaded page
|
||||
* holds them.
|
||||
*/
|
||||
knownOptions?: EntityLinkOption[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [localTerm, setLocalTerm] = useState("");
|
||||
const term = server ? server.term : localTerm;
|
||||
const setTerm = server ? server.onTermChange : setLocalTerm;
|
||||
|
||||
const optionById = useMemo(() => {
|
||||
const map = new Map<string, EntityLinkOption>();
|
||||
for (const option of options) map.set(option.id, option);
|
||||
return map;
|
||||
}, [options]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupOptions(filterOptions(options, term)),
|
||||
[options, term],
|
||||
// Every option picked in this session, so its chip survives a search or a
|
||||
// page change that takes it out of the loaded list. Written from the event
|
||||
// handlers, never during render.
|
||||
const [picked, setPicked] = useState<ReadonlyMap<string, EntityLinkOption>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const remember = (more: readonly EntityLinkOption[]) => {
|
||||
if (more.length === 0) return;
|
||||
setPicked((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const option of more) next.set(option.id, option);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const optionById = useMemo(
|
||||
() => resolvableOptions(options, knownOptions ?? [], picked),
|
||||
[options, knownOptions, picked],
|
||||
);
|
||||
|
||||
// A server-paged list is already the answer to the term; filtering it
|
||||
// again here would drop a match on a field the option does not render.
|
||||
const groups = useMemo(() => {
|
||||
const visible =
|
||||
server && !server.filterLocally ? options : filterOptions(options, term);
|
||||
// Linked records the loaded pages do not hold stay reachable in the
|
||||
// sheet, on top, so they can be unticked where they are listed.
|
||||
const loaded = new Set(options.map((option) => option.id));
|
||||
const linkedElsewhere = term.trim()
|
||||
? []
|
||||
: selected
|
||||
.filter((id) => !loaded.has(id))
|
||||
.map((id) => optionById.get(id))
|
||||
.filter((option): option is EntityLinkOption => option != null)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
group: { key: "__linked__", label: t("links.picker.linked") },
|
||||
}));
|
||||
return groupOptions([...linkedElsewhere, ...visible]);
|
||||
}, [options, term, server, selected, optionById, t]);
|
||||
|
||||
// The link the last X removed, offered back until the next change.
|
||||
const [lastRemoved, setLastRemoved] = useState<EntityLinkOption | null>(null);
|
||||
|
||||
const change = (ids: string[]) => {
|
||||
const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => {
|
||||
setLastRemoved(null);
|
||||
remember(touched);
|
||||
onChange(ids);
|
||||
};
|
||||
|
||||
@@ -245,7 +364,9 @@ export function EntityLinkPicker({
|
||||
onRetry={onRetry}
|
||||
slot={`${slot}-error`}
|
||||
/>
|
||||
) : options.length === 0 ? (
|
||||
) : options.length === 0 &&
|
||||
selected.length === 0 &&
|
||||
!(server && (server.hasOlder || server.hasNewer)) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
{extraAction}
|
||||
@@ -315,7 +436,9 @@ export function EntityLinkPicker({
|
||||
<button
|
||||
type="button"
|
||||
data-slot={`${slot}-undo-button`}
|
||||
onClick={() => change(restoreLink(selected, lastRemoved.id))}
|
||||
onClick={() =>
|
||||
change(restoreLink(selected, lastRemoved.id), [lastRemoved])
|
||||
}
|
||||
className="text-foreground focus-visible:ring-ring/50 min-h-8 rounded-sm font-medium underline underline-offset-2 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("common.undo")}
|
||||
@@ -363,12 +486,31 @@ export function EntityLinkPicker({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
{server?.searchPending && groups.length === 0 ? (
|
||||
<div
|
||||
className="flex justify-center py-6"
|
||||
data-slot={`${slot}-searching`}
|
||||
>
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
) : groups.length === 0 &&
|
||||
!(server && (server.hasOlder || server.hasNewer)) ? (
|
||||
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
|
||||
{t("links.picker.searchNoMatch")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
|
||||
{server?.hasNewer ? (
|
||||
<PageButton
|
||||
slot={`${slot}-load-newer`}
|
||||
loading={server.loadingNewer}
|
||||
onClick={server.onNewer}
|
||||
label={t("links.picker.loadNewer")}
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const groupIds = group.options.map((option) => option.id);
|
||||
return (
|
||||
@@ -384,7 +526,9 @@ export function EntityLinkPicker({
|
||||
<button
|
||||
type="button"
|
||||
data-slot={`${slot}-group-select`}
|
||||
onClick={() => change(toggleAll(selected, groupIds))}
|
||||
onClick={() =>
|
||||
change(toggleAll(selected, groupIds), group.options)
|
||||
}
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("links.picker.selectAll")}
|
||||
@@ -401,7 +545,7 @@ export function EntityLinkPicker({
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() =>
|
||||
change(toggleOne(selected, option.id))
|
||||
change(toggleOne(selected, option.id), [option])
|
||||
}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
@@ -438,6 +582,14 @@ export function EntityLinkPicker({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{server?.hasOlder ? (
|
||||
<PageButton
|
||||
slot={`${slot}-load-older`}
|
||||
loading={server.loadingOlder}
|
||||
onClick={server.onOlder}
|
||||
label={t("links.picker.loadOlder")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -451,3 +603,37 @@ export function EntityLinkPicker({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Load newer" / "Load older": a full-width row inside the scrolling list. */
|
||||
function PageButton({
|
||||
slot,
|
||||
loading,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
slot: string;
|
||||
loading: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 w-full"
|
||||
data-slot={slot}
|
||||
disabled={loading}
|
||||
aria-busy={loading}
|
||||
onClick={onClick}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The list-route requests behind each link picker, one builder per family.
|
||||
*
|
||||
* Pure, so a test can parse every URL a picker can send against the route's
|
||||
* own query schema: a page size or a parameter the route refuses answers 422,
|
||||
* and a picker that reads a 422 as an empty list hides the record it exists to
|
||||
* offer. That is exactly how a vault once read as empty.
|
||||
*
|
||||
* Every builder speaks one {@link PickerPageRequest}: `older` is the window up
|
||||
* to the split newest first, `newer` the window after it oldest first,
|
||||
* `search` the term across the whole history. The cursor is the family's own:
|
||||
* an offset for the routes that page by offset, the last row's id for the
|
||||
* document route's keyset.
|
||||
*/
|
||||
import {
|
||||
PICKER_PAGE_SIZE,
|
||||
afterPivot,
|
||||
type PickerPageRequest,
|
||||
} from "./use-link-picker-pages";
|
||||
|
||||
function withParams(path: string, params: URLSearchParams): string {
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
|
||||
export function labPickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
offset: request.cursor ?? "0",
|
||||
sortDir: request.direction === "newer" ? "asc" : "desc",
|
||||
});
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
}
|
||||
return withParams("/api/labs", params);
|
||||
}
|
||||
|
||||
/** `GET /api/illness/episodes` — offset paging, the window on the onset. */
|
||||
export function episodePickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
includeResolved: "true",
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
offset: request.cursor ?? "0",
|
||||
sortDir: request.direction === "newer" ? "asc" : "desc",
|
||||
});
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
}
|
||||
return withParams("/api/illness/episodes", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/documents/inbound` — keyset paging on the filing date. The route's
|
||||
* window is day-granular and inclusive, so the newer window starts the day
|
||||
* after the split's day.
|
||||
*/
|
||||
export function documentPickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
sort: "documentDate",
|
||||
order: request.direction === "newer" ? "asc" : "desc",
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
});
|
||||
if (request.cursor) params.set("cursor", request.cursor);
|
||||
if (request.direction === "search") params.set("q", request.term);
|
||||
else if (request.pivot !== null) {
|
||||
if (request.direction === "older") {
|
||||
params.set("to", request.pivot.slice(0, 10));
|
||||
} else {
|
||||
params.set("from", afterPivot(request.pivot).slice(0, 10));
|
||||
}
|
||||
}
|
||||
return withParams("/api/documents/inbound", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/encounters` — offset paging inside a window on the visit instant.
|
||||
* The route answers in two arms (upcoming soonest first, past newest first),
|
||||
* each paged by the same offset; the picker merges and orders them itself.
|
||||
* The visit route has no text search, so `search` reads the whole history
|
||||
* and the picker filters what it has loaded.
|
||||
*/
|
||||
export function encounterPickerUrl(request: PickerPageRequest): string {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PICKER_PAGE_SIZE),
|
||||
offset: request.cursor ?? "0",
|
||||
});
|
||||
if (request.direction !== "search" && request.pivot !== null) {
|
||||
if (request.direction === "older") params.set("to", request.pivot);
|
||||
else params.set("from", afterPivot(request.pivot));
|
||||
}
|
||||
return withParams("/api/encounters", params);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The data behind a link picker over a list that can be long: the records
|
||||
* around one date, paged older and newer from there, or a search across the
|
||||
* whole history.
|
||||
*
|
||||
* The pickers used to fetch one capped page, newest first (200 lab results,
|
||||
* 100 conditions, ten pages of documents, the last 100 visits), and offered
|
||||
* only that. On a long record everything older than the cap could not be
|
||||
* linked at all: a visit from 2019 could not reach its own 2019 lab panel.
|
||||
*
|
||||
* Now a picker opens on the records around the date of the thing being linked
|
||||
* (the visit's, the dose's, the document's). The list is split a few days
|
||||
* after that date (the same window the "belongs to a visit?" suggestion uses,
|
||||
* so a result drawn two days after the visit is on the first page) into two
|
||||
* server-side windows:
|
||||
*
|
||||
* - `older`: up to the split, newest first, so the record's own days lead
|
||||
* the list, paged further back on request;
|
||||
* - `newer`: after the split, oldest first, fetched only when asked for and
|
||||
* paged further forward from there;
|
||||
*
|
||||
* and a typed term switches to `search`, which asks the list route itself
|
||||
* (`q`) so a match is found wherever it sits in the history. With no date
|
||||
* yet the picker opens on the newest records and pages back.
|
||||
*
|
||||
* Every page comes from the family's own list route through `fetchPage`, so
|
||||
* paging, the filter and the ownership check stay server-side.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export type PickerDirection = "older" | "newer" | "search";
|
||||
|
||||
/** One page of a picker list, and the cursor for the next one (or null). */
|
||||
export interface PickerPage<T> {
|
||||
items: T[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
export interface PickerPageRequest {
|
||||
direction: PickerDirection;
|
||||
/** The split instant (ISO), or null when the record has no date yet. */
|
||||
pivot: string | null;
|
||||
/** The trimmed search term; empty outside `search`. */
|
||||
term: string;
|
||||
/** The family's own cursor (an offset or a row id), null for page one. */
|
||||
cursor: string | null;
|
||||
}
|
||||
|
||||
/** Records per page, the same for every family. */
|
||||
export const PICKER_PAGE_SIZE = 100;
|
||||
|
||||
/** How long typing settles before the search asks the server. */
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
/** Drop repeats by id, keeping the first occurrence's position. */
|
||||
export function uniqueById<T extends { id: string }>(items: readonly T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const item of items) {
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible list, newest first: the newer pages (fetched oldest first, so
|
||||
* reversed) above the older ones.
|
||||
*/
|
||||
export function mergeWindowPages<T extends { id: string }>(
|
||||
newerPages: ReadonlyArray<PickerPage<T>>,
|
||||
olderPages: ReadonlyArray<PickerPage<T>>,
|
||||
): T[] {
|
||||
const newer = newerPages.flatMap((page) => page.items).reverse();
|
||||
const older = olderPages.flatMap((page) => page.items);
|
||||
return uniqueById([...newer, ...older]);
|
||||
}
|
||||
|
||||
export function useLinkPickerPages<T extends { id: string }>({
|
||||
enabled,
|
||||
pivot,
|
||||
queryKey,
|
||||
fetchPage,
|
||||
serverSearch = true,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
pivot: string | null;
|
||||
/** The family's factory key for one direction, pivot and term. */
|
||||
queryKey: (
|
||||
direction: PickerDirection,
|
||||
pivot: string | null,
|
||||
term: string,
|
||||
) => readonly unknown[];
|
||||
fetchPage: (request: PickerPageRequest) => Promise<PickerPage<T>>;
|
||||
/**
|
||||
* Whether the list route can search. A route without a text filter (the
|
||||
* visit list: its free text is ciphertext at rest) leaves the term to the
|
||||
* picker, which filters what is loaded.
|
||||
*/
|
||||
serverSearch?: boolean;
|
||||
}) {
|
||||
const [rawTerm, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
|
||||
const term = serverSearch ? debounced : "";
|
||||
const searching = term.length > 0;
|
||||
|
||||
const older = useInfiniteQuery({
|
||||
queryKey: queryKey("older", pivot, ""),
|
||||
enabled: enabled && !searching,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
// The newer window is read only once asked for, and only for the split it
|
||||
// was asked for: a changed date starts over on the older window.
|
||||
const [newerFor, setNewerFor] = useState<string | null>(null);
|
||||
const newerRequested = pivot !== null && newerFor === pivot;
|
||||
|
||||
const newer = useInfiniteQuery({
|
||||
queryKey: queryKey("newer", pivot, ""),
|
||||
// No date, no "after": the older window already starts at the newest.
|
||||
enabled: enabled && !searching && newerRequested,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
const search = useInfiniteQuery({
|
||||
queryKey: queryKey("search", null, term),
|
||||
enabled: enabled && searching,
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
|
||||
getNextPageParam: (last) => last.next ?? undefined,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (searching) {
|
||||
return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items));
|
||||
}
|
||||
return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []);
|
||||
}, [searching, search.data, newer.data, older.data]);
|
||||
|
||||
const activeError = searching
|
||||
? search.isError
|
||||
: older.isError || (newerRequested && newer.isError);
|
||||
|
||||
return {
|
||||
items,
|
||||
/** The first window is still loading; the inline block shows a skeleton. */
|
||||
pending: older.isPending && !searching,
|
||||
error: activeError,
|
||||
retry: () => {
|
||||
if (searching) void search.refetch();
|
||||
else {
|
||||
void older.refetch();
|
||||
if (newerRequested) void newer.refetch();
|
||||
}
|
||||
},
|
||||
term: rawTerm,
|
||||
setTerm,
|
||||
/** A search is in flight (typed, or its first page not back yet). */
|
||||
searchPending:
|
||||
serverSearch &&
|
||||
(rawTerm.trim() !== term || (searching && search.isPending)),
|
||||
/** The picker filters the loaded options by the term itself. */
|
||||
filterLocally: !serverSearch,
|
||||
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
|
||||
// Unknown until asked: offered once for every dated record, then only
|
||||
// while the server reports another page.
|
||||
hasNewer:
|
||||
!searching &&
|
||||
pivot !== null &&
|
||||
(!newerRequested || newer.isFetching || newer.hasNextPage),
|
||||
loadingOlder: searching
|
||||
? search.isFetchingNextPage
|
||||
: older.isFetchingNextPage,
|
||||
loadingNewer: !searching && newerRequested && newer.isFetching,
|
||||
loadOlder: () => {
|
||||
if (searching) void search.fetchNextPage();
|
||||
else void older.fetchNextPage();
|
||||
},
|
||||
loadNewer: () => {
|
||||
if (searching || pivot === null) return;
|
||||
if (!newerRequested) setNewerFor(pivot);
|
||||
else void newer.fetchNextPage();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type LinkPickerPages<T extends { id: string }> = ReturnType<
|
||||
typeof useLinkPickerPages<T>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Where a record's date splits the list: the end of the UTC day
|
||||
* {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the
|
||||
* suggestion window would offer is on the first page. Day-granular so typing
|
||||
* the time of a visit does not refetch. Null when there is no date (or it does
|
||||
* not parse).
|
||||
*/
|
||||
export function pickerPivot(anchor: string | null): string | null {
|
||||
if (!anchor) return null;
|
||||
const parsed = new Date(anchor);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
const split = new Date(
|
||||
parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
split.setUTCHours(23, 59, 59, 999);
|
||||
return split.toISOString();
|
||||
}
|
||||
|
||||
/** The instant just after the split, where the newer window starts. */
|
||||
export function afterPivot(pivot: string): string {
|
||||
return new Date(new Date(pivot).getTime() + 1).toISOString();
|
||||
}
|
||||
|
||||
/** Offset cursors, for the list routes that page by `offset`. */
|
||||
export function nextOffset(
|
||||
cursor: string | null,
|
||||
received: number,
|
||||
): string | null {
|
||||
if (received < PICKER_PAGE_SIZE) return null;
|
||||
return String(Number(cursor ?? "0") + received);
|
||||
}
|
||||
@@ -7,38 +7,41 @@
|
||||
* The visit and vaccination forms used to fetch their document options with a
|
||||
* page size the list route refuses (`limit=200` against a ceiling of 100). The
|
||||
* 422 read as an empty list, so the picker said "No documents yet." to a
|
||||
* person with a full vault, and the only way a scan ever got attached was the
|
||||
* ±7-day suggestion offered at upload. A childhood record covering eight doses
|
||||
* from one sheet of paper could therefore be filed against exactly one of
|
||||
* them.
|
||||
* person with a full vault. The next version walked the whole vault, ten
|
||||
* pages deep, and anything past the thousandth document was out of reach.
|
||||
*
|
||||
* Now the whole vault is offered, paged through at the route's own ceiling,
|
||||
* and a read failure says so instead of pretending the vault is empty. The
|
||||
* documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day
|
||||
* window the upload suggestion uses stay on top under their own heading, so
|
||||
* the common case is still one tap; everything else follows, grouped by month
|
||||
* and searchable.
|
||||
* Now the picker opens on the documents filed around the record's date and
|
||||
* pages older and newer from there, and the search box asks the list route
|
||||
* itself, so a document is reachable wherever it sits in a vault of any size
|
||||
* (`useLinkPickerPages`). A read failure says so instead of pretending the
|
||||
* vault is empty. The documents dated within the same
|
||||
* ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day window the upload suggestion uses
|
||||
* stay on top under their own heading, so the common case is still one tap;
|
||||
* everything else follows, grouped by month.
|
||||
*
|
||||
* A document whose filing date was cleared falls outside every date window;
|
||||
* the search still finds it by title, file name or content.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
|
||||
import {
|
||||
serverPagingFrom,
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
import { documentPickerUrl } from "@/components/links/picker-requests";
|
||||
import {
|
||||
pickerPivot,
|
||||
useLinkPickerPages,
|
||||
type PickerPage,
|
||||
type PickerPageRequest,
|
||||
} from "@/components/links/use-link-picker-pages";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DOCUMENT_LIST_MAX_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { daysBetweenDateKeys } from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* How many pages the picker walks. At the route's ceiling of 100 that is a
|
||||
* thousand documents, past which the vault's own search is the better tool.
|
||||
*/
|
||||
const VAULT_PICKER_MAX_PAGES = 10;
|
||||
|
||||
/** The fields the option list reads off a document. */
|
||||
export type VaultDocument = Pick<
|
||||
InboundDocumentDto,
|
||||
@@ -50,25 +53,12 @@ interface ListPage {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Walk the vault newest filing date first, at the route's own page ceiling. */
|
||||
export async function fetchWholeVault(): Promise<VaultDocument[]> {
|
||||
const documents: VaultDocument[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) {
|
||||
const params = new URLSearchParams({
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
limit: String(DOCUMENT_LIST_MAX_LIMIT),
|
||||
});
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const result: ListPage = await apiGet<ListPage>(
|
||||
`/api/documents/inbound?${params.toString()}`,
|
||||
);
|
||||
documents.push(...result.documents);
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
return documents;
|
||||
/** One picker page of the vault. */
|
||||
export async function fetchVaultPage(
|
||||
request: PickerPageRequest,
|
||||
): Promise<PickerPage<VaultDocument>> {
|
||||
const result = await apiGet<ListPage>(documentPickerUrl(request));
|
||||
return { items: result.documents, next: result.nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions(
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's data: the whole vault as options, the suggestion group on top
|
||||
* when the record has a date, and the query state an error row needs.
|
||||
* The picker's data: the vault around the record's date as options, the
|
||||
* suggestion group on top when the record has a date, the paging the sheet
|
||||
* offers, and the query state an error row needs.
|
||||
*/
|
||||
export function useVaultDocumentOptions({
|
||||
enabled,
|
||||
@@ -148,26 +139,42 @@ export function useVaultDocumentOptions({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentVaultPicker(),
|
||||
const pages = useLinkPickerPages<VaultDocument>({
|
||||
enabled,
|
||||
queryFn: fetchWholeVault,
|
||||
pivot: pickerPivot(anchor),
|
||||
queryKey: queryKeys.inboundDocumentVaultPicker,
|
||||
fetchPage: fetchVaultPage,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
documentOptionsWithSuggestions(query.data ?? [], anchor, {
|
||||
documentOptionsWithSuggestions(pages.items, anchor, {
|
||||
suggested: t("links.picker.suggested"),
|
||||
month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`,
|
||||
date: (iso) => format.date(iso),
|
||||
}),
|
||||
[query.data, anchor, t, format],
|
||||
[pages.items, anchor, t, format],
|
||||
);
|
||||
|
||||
return {
|
||||
options,
|
||||
pending: query.isPending,
|
||||
error: query.isError,
|
||||
retry: () => void query.refetch(),
|
||||
pending: pages.pending,
|
||||
error: pages.error,
|
||||
retry: pages.retry,
|
||||
server: serverPagingFrom(pages),
|
||||
};
|
||||
}
|
||||
|
||||
/** A linked document as the record's own read names it, for its chip. */
|
||||
export function linkedDocumentOption(
|
||||
link: { id: string; label: string | null; date: string | null },
|
||||
date: (iso: string) => string,
|
||||
): EntityLinkOption | null {
|
||||
if (link.label === null) return null;
|
||||
return {
|
||||
id: link.id,
|
||||
label: link.label,
|
||||
dateLabel: link.date ? date(link.date) : null,
|
||||
href: `/documents?doc=${encodeURIComponent(link.id)}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user