mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(encounters): stop offering the visit edit sheet to a delegate who may not save it
`VisitsSection` asked the grant about its add button and nothing else. Every row
underneath stayed tappable into the edit sheet, whose Save is `PATCH
/api/encounters/{id}` and whose Delete is `DELETE` on the same path — both
`("manage", "profile")`. So a READ delegate opened a form that could not be
saved, and a WRITE delegate, who may file a new visit, opened one that could not
be changed. Reachable wherever the checkups page is, which is every
`profile`-scoped grant.
The row now takes its open callback only when `canManageDomain("profile")`
answers, and the card renders its content as content rather than as a button
when it does not. The add button keeps asking the write question, because that
is a different question with a different answer.
Four legs in the affordances suite over the same scoped fixtures the
immunization log uses: the owner opens the sheet, a READ and a WRITE delegate
see the row and cannot open it, a WRITE delegate still gets the add button, and
a MANAGE delegate opens it only while holding the section. Two of them fail
against the previous section.
This commit is contained in:
1 parent
7c7e621fd9
commit
3e176db5b7
3 files changed
+128
-11
No files matched your search
@@ -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, <VisitsSection />, 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"]),
|
||||
<VisitsSection />,
|
||||
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"]), <VisitsSection />, seedList),
|
||||
).toContain('data-slot="visits-add"');
|
||||
expect(
|
||||
render(grant("read", ["profile"]), <VisitsSection />, 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"]), <VisitsSection />, seedList),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
rowIsTappable(
|
||||
render(grant("manage", ["labs"]), <VisitsSection />, seedList),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
<CardContent className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Body onOpen={onOpen} encounter={encounter}>
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
@@ -120,8 +123,37 @@ export function VisitCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</Body>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <div className="w-full space-y-2">{children}</div>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="visit-card-open"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user