diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index c802640c5..e433653cd 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -174,6 +174,8 @@ import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsor import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card"; import { LedgerRowItem } from "@/components/medications/dose-history-ledger"; import { VaccinationsView } from "@/components/vaccinations/vaccinations-view"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import type { EncounterDTO } from "@/lib/encounters/dto"; import { queryKeys } from "@/lib/query-keys"; import type { DailyDigest } from "@/lib/daily/digest"; import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; @@ -740,3 +742,82 @@ describe("the immunization log's add, edit and delete", () => { ).not.toContain('data-slot="vaccination-add-empty"'); }); }); + +/* -------------------------------------------------------------------------- */ +/* The visits list */ +/* -------------------------------------------------------------------------- */ + +describe("a visit row's edit sheet", () => { + const VISIT: EncounterDTO = { + id: "visit-1", + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + }; + + const seedList = (client: QueryClient) => { + client.setQueryData( + queryKeys.encounterList(null, null, undefined, undefined), + { + upcoming: [], + past: [VISIT], + }, + ); + }; + + /** The row's content is a button only while it opens something. */ + const rowIsTappable = (html: string) => + html.includes('data-slot="visit-card-open"'); + + it("opens for the owner", () => { + expect(rowIsTappable(render(OWN_RECORD, , seedList))).toBe( + true, + ); + }); + + it("does not open for a READ or a WRITE delegate", () => { + // The add button was the only thing asked, and it asks the right question + // — `POST /api/encounters` is WRITE. The row underneath opens a sheet + // whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE. + for (const level of ["read", "write"] as const) { + const html = render( + grant(level, ["profile"]), + , + seedList, + ); + expect(html, `${level} still sees the row`).toContain( + 'data-slot="visit-card"', + ); + expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false); + } + }); + + it("keeps the add button for a WRITE delegate", () => { + // The two answers are different questions and must not collapse into one. + expect( + render(grant("write", ["profile"]), , seedList), + ).toContain('data-slot="visits-add"'); + expect( + render(grant("read", ["profile"]), , seedList), + ).not.toContain('data-slot="visits-add"'); + }); + + it("opens for a MANAGE delegate holding the section, and not one outside it", () => { + expect( + rowIsTappable( + render(grant("manage", ["profile"]), , seedList), + ), + ).toBe(true); + expect( + rowIsTappable( + render(grant("manage", ["labs"]), , seedList), + ), + ).toBe(false); + }); +}); diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 7f4ecdf32..0baaf512e 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -35,7 +35,14 @@ export function VisitCard({ onOpen, }: { encounter: Encounter; - onOpen: (encounter: Encounter) => void; + /** + * Opens the edit sheet. Omitted when the caller may not change the visit — + * `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`, + * so a READ or WRITE delegate gets the card as a card and not as a button. + * A row that reads as tappable and answers 403 is the affordance saying + * something about the grant that is not true. + */ + onOpen?: (encounter: Encounter) => void; }) { const { t } = useTranslations(); const format = useFormatters(); @@ -71,11 +78,7 @@ export function VisitCard({ className="gap-2 py-3 md:py-4" > - + ); } + +/** + * The row's content, tappable or not. + * + * One element either way rather than two copies of the content: the read-only + * form keeps the same spacing the button had, so a card that loses its edit + * affordance does not also change shape. + */ +function Body({ + onOpen, + encounter, + children, +}: { + onOpen?: (encounter: Encounter) => void; + encounter: Encounter; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 2af5b67d0..3b2ad8c9a 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card"; export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); - const { canWriteDomain } = useRecordCapabilities(); - // `POST /api/encounters` answers at WRITE under the profile section. + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/encounters` answers at WRITE under the profile section; the + // edit and the delete behind the row are both MANAGE. Only the add was + // asked, so every row stayed tappable into a sheet whose Save and Delete + // the server refuses. const canAddVisit = canWriteDomain("profile"); + const canManageVisits = canManageDomain("profile"); const list = useEncounters(enabled); // `null` closed, `"new"` create, a row edits it. @@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} @@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))}