diff --git a/src/components/body-sites/__tests__/body-site-fields.test.tsx b/src/components/body-sites/__tests__/body-site-fields.test.tsx index ca4c1537b..61b5a6c5d 100644 --- a/src/components/body-sites/__tests__/body-site-fields.test.tsx +++ b/src/components/body-sites/__tests__/body-site-fields.test.tsx @@ -8,10 +8,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; * * - the suggestions are the server's list, in its order; * - the site already typed is not suggested back to itself; - * - no list (a refused or failed read) is a plain field, not an empty list. + * - no list (a refused or failed read) is a plain field, not an empty list; + * - a delegate whose grant does not include the visits' section is not + * sent to a read that would refuse them. * - * Mutation check (run, seen red): drop the `list` attribute from the input → - * "offers the sites the record holds" goes red. + * Mutation checks (each run, each seen red): drop the `list` attribute from + * the input → "offers the sites the record holds" goes red; enable the read + * unconditionally in `canReadBodySites` → "does not ask for the list" goes + * red. */ vi.mock("@/lib/api/api-fetch", async () => { @@ -24,6 +28,7 @@ vi.mock("@/lib/api/api-fetch", async () => { import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BodySiteListDTO } from "@/lib/body-sites/dto"; +import { canReadBodySites } from "@/hooks/use-body-sites"; import { BodySiteFields } from "../body-site-fields"; function render(data: BodySiteListDTO | null, typed = ""): string { @@ -77,4 +82,12 @@ describe("", () => { expect(html).not.toContain(" { + expect(canReadBodySites(null)).toBe(true); + expect(canReadBodySites(undefined)).toBe(true); + expect(canReadBodySites(["profile", "illness"])).toBe(true); + expect(canReadBodySites(["illness"])).toBe(false); + expect(canReadBodySites([])).toBe(false); + }); }); diff --git a/src/components/body-sites/body-site-fields.tsx b/src/components/body-sites/body-site-fields.tsx index 666691c21..16c9d3fa9 100644 --- a/src/components/body-sites/body-site-fields.tsx +++ b/src/components/body-sites/body-site-fields.tsx @@ -10,14 +10,18 @@ * types. The list comes from the server (`useBodySiteSuggestions`), which * decrypts and folds the sites; the browser's own `datalist` does the * narrowing, so a suggestion is a nudge and never a restriction. A caller whose - * grant cannot read the list gets a plain text field. + * grant cannot read the list gets a plain text field and no request. */ import { useId } from "react"; import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; -import { useBodySiteSuggestions } from "@/hooks/use-body-sites"; +import { + canReadBodySites, + useBodySiteSuggestions, +} from "@/hooks/use-body-sites"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; import { LATERALITIES, @@ -46,7 +50,11 @@ export function BodySiteFields({ const listId = `${useId()}-body-sites`; const typed = bodySite.trim().toLowerCase(); // The exact site already typed is not suggested back to itself. - const suggestions = useBodySiteSuggestions().filter( + // The list is read in the visits' section. A delegate without it (say, one + // who may only keep the illness journal) is not sent to a read that would + // refuse them on every open; the field simply offers nothing. + const { sections } = useRecordCapabilities(); + const suggestions = useBodySiteSuggestions(canReadBodySites(sections)).filter( (site) => site.toLowerCase() !== typed, ); diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 24ddccef9..e05d1dee9 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { bodySiteText } from "@/components/encounters/encounter-labels"; +import { canReadBodySites } from "@/hooks/use-body-sites"; import { IllnessCorrelationCard } from "./illness-correlation-card"; import { IllnessDayTimeline } from "./illness-day-timeline"; @@ -46,7 +47,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { const { canManageDomain, sections } = useRecordCapabilities(); // The body-site view is read in the visits' section; a grant without it // gets the site as text rather than a link that would be refused. - const canOpenBodySites = sections === null || sections.includes("profile"); + const canOpenBodySites = canReadBodySites(sections); const canManageIllness = canManageDomain("illness"); const fmt = useFormatters(); const { diff --git a/src/hooks/use-body-sites.ts b/src/hooks/use-body-sites.ts index ef2a6a7fc..fff12203a 100644 --- a/src/hooks/use-body-sites.ts +++ b/src/hooks/use-body-sites.ts @@ -40,6 +40,18 @@ export function useBodySites( }); } +/** + * Whether the caller may ask for the list at all. It is read in the visits' + * section (`profile`), so a grant without it would be refused on every open; + * `null` or absent sections mean the whole record (one's own, or an + * entire-record grant). + */ +export function canReadBodySites( + sections: readonly string[] | null | undefined, +): boolean { + return !sections || sections.includes("profile"); +} + /** * The site names a body-site field suggests: every site the record holds, * most used first. A failed or refused read (a grant without the visits