diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 6ee555399..505c1a9c3 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; +import { PrivacyPageBackButton } from "@/components/privacy-page-back-button"; import { cn, eyebrowText, @@ -75,15 +77,20 @@ export default function PrivacyPage() {
-
-

{privacyCopy.pageEyebrow}

-

- {privacyCopy.pageTitle} -

-

- This is draft product information based on the repository's configured behaviour. It is not legal - advice, a final privacy policy, or an assertion of governance approval. -

+
+ }> + + +
+

{privacyCopy.pageEyebrow}

+

+ {privacyCopy.pageTitle} +

+

+ This is draft product information based on the repository's configured behaviour. It is not legal + advice, a final privacy policy, or an assertion of governance approval. +

+
diff --git a/src/app/reference/colour-coding/page.tsx b/src/app/reference/colour-coding/page.tsx index d5e0a946e..8a8df31ca 100644 --- a/src/app/reference/colour-coding/page.tsx +++ b/src/app/reference/colour-coding/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; +import { appModeHomeHref } from "@/lib/app-modes"; import { cn, eyebrowText, @@ -41,18 +43,21 @@ export default function ColourCodingReferencePage() { >
-
-

Reference

-

- Colour coding reference -

-

- Badges flag important content so clinical screens are faster to scan. The system uses six tones only — - meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay - distinguishable without colour. Governance lives in{" "} - docs/clinical-badge-system-guide.md; this page is generated - from src/lib/semantic-flags.ts. -

+
+ +
+

Reference

+

+ Colour coding reference +

+

+ Badges flag important content so clinical screens are faster to scan. The system uses six tones only — + meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay + distinguishable without colour. Governance lives in{" "} + docs/clinical-badge-system-guide.md; this page is generated + from src/lib/semantic-flags.ts. +

+
diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 0f2d28401..1af39b2b8 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -408,7 +408,7 @@ export function UploadPanel({ return (
- +
); @@ -428,6 +448,7 @@ export function PatientSafetyPlan() { const [mobileTab, setMobileTab] = useState<"build" | "preview">("build"); const [copied, setCopied] = useState(false); const [finalised, setFinalised] = useState(false); + const [draftDirtyByRow, setDraftDirtyByRow] = useState>({}); // Per-instance id counter — avoids a module-level mutable that would persist // across remounts; ids only need to be unique within this mounted plan. @@ -447,8 +468,26 @@ export function PatientSafetyPlan() { setFinalised(false); }, []); + const setDraftDirty = useCallback((key: string, dirty: boolean) => { + setDraftDirtyByRow((prev) => { + if (prev[key] === dirty) return prev; + return { ...prev, [key]: dirty }; + }); + }, []); + const filledSteps = useMemo(() => STEPS.filter((step) => entries[step.key].length > 0).length, [entries]); const ready = filledSteps === STEPS.length; + // Working plan content is browser-tab only and never persisted. Treat any + // entered step/reason/date as dirty so the header back control cannot discard + // an in-progress plan without an explicit confirmation. + const isDirty = useMemo( + () => + Object.values(entries).some((rows) => rows.length > 0) || + reasons.length > 0 || + planDate.trim() !== "" || + Object.values(draftDirtyByRow).some(Boolean), + [draftDirtyByRow, entries, planDate, reasons], + ); const planText = useMemo(() => { const lines: string[] = [ @@ -521,7 +560,17 @@ export function PatientSafetyPlan() { {/* Tool header */}
-
+
+ { + if (!isDirty) return true; + return window.confirm( + "Leave this safety plan? Your entries are only in this browser tab and will be lost.", + ); + }} + /> @@ -676,6 +725,7 @@ export function PatientSafetyPlan() { entries={entries[def.key]} onAdd={(primary, secondary) => addEntry(def.key, primary, secondary)} onRemove={(id) => removeEntry(def.key, id)} + onDraftDirtyChange={(dirty) => setDraftDirty(def.key, dirty)} /> ))} @@ -727,8 +777,10 @@ export function PatientSafetyPlan() { setDraftDirty("reason", dirty)} onAdd={(primary) => { setReasons((prev) => [...prev, { id: uid("reason"), primary }]); + setDraftDirty("reason", false); setFinalised(false); }} /> diff --git a/src/components/privacy-input-notice.tsx b/src/components/privacy-input-notice.tsx index e138a2831..603780035 100644 --- a/src/components/privacy-input-notice.tsx +++ b/src/components/privacy-input-notice.tsx @@ -2,11 +2,22 @@ import Link from "next/link"; import { ShieldAlert } from "lucide-react"; import { cn, textMuted } from "@/components/ui-primitives"; +import type { AppModeId } from "@/lib/app-modes"; // Compact APP-5 privacy notice shown beside clinical input controls (query // composer, document upload). Deliberately one quiet 11px line — the wording // and the /privacy link are governance copy (PIA-5) and must stay intact. -export function PrivacyInputNotice({ className, id, testId }: { className?: string; id?: string; testId?: string }) { +export function PrivacyInputNotice({ + className, + id, + testId, + returnMode, +}: { + className?: string; + id?: string; + testId?: string; + returnMode?: AppModeId; +}) { return (

Do not enter patient-identifiable information. Privacy and data processing diff --git a/src/components/privacy-page-back-button.tsx b/src/components/privacy-page-back-button.tsx new file mode 100644 index 000000000..02e56d956 --- /dev/null +++ b/src/components/privacy-page-back-button.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { NavigationBackButton } from "@/components/navigation-back-button"; +import { appModeHomeHref, isAppModeId } from "@/lib/app-modes"; + +export function PrivacyPageBackButton() { + const searchParams = useSearchParams(); + const returnMode = searchParams.get("from"); + const fallbackHref = isAppModeId(returnMode) ? appModeHomeHref(returnMode) : "/"; + + return ; +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 12f2ee969..608ca03a0 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -117,6 +117,14 @@ export function Sheet({ // Otherwise a press that begins on the panel and ends on the backdrop would // synthesize a click on the common ancestor and accidentally close the sheet. const backdropPointerDownRef = useRef(false); + // Pending focus-restore timers from the previous close. Cleared on the next + // open and on unmount so a torn-down jsdom environment cannot throw from a + // stale 50ms retry under Vitest coverage workers. + const restoreTimersRef = useRef<{ frame: number | null; timeout: number | null }>({ + frame: null, + timeout: null, + }); + const unmountingRef = useRef(false); const titleId = useId(); const descId = useId(); const sheetId = useId(); @@ -125,6 +133,22 @@ export function Sheet({ onCloseRef.current = onClose; }, [onClose]); + useEffect(() => { + unmountingRef.current = false; + const restoreTimers = restoreTimersRef.current; + return () => { + unmountingRef.current = true; + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); + restoreTimers.frame = null; + } + if (restoreTimers.timeout != null) { + window.clearTimeout(restoreTimers.timeout); + restoreTimers.timeout = null; + } + }; + }, []); + // Swipe-to-dismiss for the mobile bottom sheet: dragging the grip down past a // threshold closes the sheet; a shorter drag snaps back. Grip-initiated only, // so it never competes with scrolling the sheet body. Keyboard/backdrop/close @@ -211,23 +235,39 @@ export function Sheet({ } window.addEventListener("keydown", onKeyDown); + const restoreTimers = restoreTimersRef.current; return () => { window.cancelAnimationFrame(focusFrame); window.removeEventListener("keydown", onKeyDown); popSheet(sheetId); const restoreTarget = explicitReturnElement ?? previousActiveElement; - window.requestAnimationFrame(() => { - if (!restoreTarget?.isConnected) return; + if (restoreTimers.frame != null) { + window.cancelAnimationFrame(restoreTimers.frame); + } + if (restoreTimers.timeout != null) { + window.clearTimeout(restoreTimers.timeout); + } + if (unmountingRef.current) return; + // Focus restore is best-effort. Under Vitest coverage workers the jsdom + // `document` can be torn down before this rAF/setTimeout pair fires; bare + // `document` access then becomes an unhandled ReferenceError that fails + // the whole suite even when every test assertion passed. + restoreTimers.frame = window.requestAnimationFrame(() => { + restoreTimers.frame = null; + if (typeof document === "undefined" || !restoreTarget?.isConnected) return; restoreTarget.focus({ preventScroll: true }); - window.setTimeout(() => { + restoreTimers.timeout = window.setTimeout(() => { + restoreTimers.timeout = null; if (typeof document === "undefined") return; if ( - restoreTarget.isConnected && - document.activeElement !== restoreTarget && - document.activeElement === document.body + typeof document === "undefined" || + !restoreTarget.isConnected || + document.activeElement === restoreTarget || + document.activeElement !== document.body ) { - restoreTarget.focus({ preventScroll: true }); + return; } + restoreTarget.focus({ preventScroll: true }); }, 50); }); }; diff --git a/tests/document-viewer-shell.dom.test.tsx b/tests/document-viewer-shell.dom.test.tsx index c4751bc88..ce7d151a9 100644 --- a/tests/document-viewer-shell.dom.test.tsx +++ b/tests/document-viewer-shell.dom.test.tsx @@ -143,6 +143,12 @@ describe("DocumentViewer — shell states", () => { fireEvent.click(actionsButtons[0]); expect(await screen.findByText("clozapine-titration.pdf")).toBeVisible(); + // Close the sheet before teardown so focus-restore timers settle while jsdom + // is still alive (avoids an unhandled post-test `document` ReferenceError + // under the coverage worker pool). + fireEvent.click(screen.getByRole("button", { name: "Close document actions" })); + expect(screen.queryByText("clozapine-titration.pdf")).toBeNull(); + // A supplied payload must resolve to the ready shell — neither failure shell. expect(screen.queryByText("Source unavailable")).toBeNull(); expect(screen.queryByText("Sign in required")).toBeNull(); diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx new file mode 100644 index 000000000..59cfd1c8a --- /dev/null +++ b/tests/navigation-back-button.dom.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NavigationBackButton } from "@/components/navigation-back-button"; +import { PrivacyPageBackButton } from "@/components/privacy-page-back-button"; +import ColourCodingReferencePage from "@/app/reference/colour-coding/page"; +import { appModeHomeHref } from "@/lib/app-modes"; + +const router = vi.hoisted(() => ({ + back: vi.fn(), + push: vi.fn(), +})); +const currentSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + useSearchParams: () => currentSearchParams.value, +})); + +beforeEach(() => { + router.back.mockReset(); + router.push.mockReset(); + currentSearchParams.value = new URLSearchParams(); +}); + +describe("NavigationBackButton", () => { + it("uses the explicit in-app fallback even when browser history has prior entries", () => { + window.history.pushState({}, "", "/unrelated-route"); + window.history.pushState({}, "", "/privacy"); + expect(window.history.length).toBeGreaterThan(1); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + expect(router.back).not.toHaveBeenCalled(); + }); + + it("cancels navigation when onBeforeNavigate returns false", () => { + const onBeforeNavigate = vi.fn(() => false); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(onBeforeNavigate).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("returns the colour-coding reference to the canonical Tools home", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + }); + + it("returns privacy readers to their allowlisted source mode", () => { + currentSearchParams.value = new URLSearchParams("from=documents"); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("documents")); + }); + + it("fails closed to the default home for an invalid privacy source", () => { + currentSearchParams.value = new URLSearchParams("from=https://example.com"); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + }); +}); diff --git a/tests/patient-safety-plan-privacy.dom.test.tsx b/tests/patient-safety-plan-privacy.dom.test.tsx index bb2bd8d40..be021e784 100644 --- a/tests/patient-safety-plan-privacy.dom.test.tsx +++ b/tests/patient-safety-plan-privacy.dom.test.tsx @@ -2,12 +2,24 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PatientSafetyPlan } from "@/components/patient-safety-plan"; +import { appModeHomeHref } from "@/lib/app-modes"; + +const router = vi.hoisted(() => ({ + back: vi.fn(), + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); describe("PatientSafetyPlan privacy contract", () => { const writeText = vi.fn(async () => undefined); beforeEach(() => { vi.clearAllMocks(); + router.back.mockReset(); + router.push.mockReset(); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText }, @@ -53,4 +65,76 @@ describe("PatientSafetyPlan privacy contract", () => { expect(printSpy).toHaveBeenCalledTimes(1); }); + + it("confirms before leaving a dirty safety plan via the header back control", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getAllByRole("button", { name: "Add" })[0]); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(confirmSpy).toHaveBeenCalledWith(expect.stringMatching(/Leave this safety plan\?.*will be lost/i)); + expect(router.push).not.toHaveBeenCalled(); + + confirmSpy.mockReturnValue(true); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + }); + + it("confirms before leaving unadded safety-plan step text", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("confirms before leaving unadded contact detail text", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("Name & relationship"), { target: { value: "Priya" } }); + fireEvent.change(screen.getByLabelText("Phone or how to reach them"), { target: { value: "0400 000 000" } }); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("keeps unadded draft text dirty after loading and clearing the example", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + fireEvent.change(screen.getByLabelText("e.g. Not sleeping for a couple of nights"), { + target: { value: "Not sleeping" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Load example" })); + fireEvent.click(screen.getByRole("button", { name: "Clear all" })); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).toHaveBeenCalledOnce(); + expect(router.push).not.toHaveBeenCalled(); + }); + + it("navigates back without confirmation when the safety plan is empty", () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith(appModeHomeHref("tools")); + }); }); diff --git a/tests/privacy-ui.test.ts b/tests/privacy-ui.test.ts index 44efb1fcb..364d3c26c 100644 --- a/tests/privacy-ui.test.ts +++ b/tests/privacy-ui.test.ts @@ -1,17 +1,27 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import PrivacyPage from "@/app/privacy/page"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ + back: vi.fn(), + push: vi.fn(), + }), +})); + describe("privacy UI", () => { it("renders a persistent, keyboard-reachable privacy warning and product link", () => { const markup = renderToStaticMarkup(createElement(PrivacyInputNotice)); + const documentsMarkup = renderToStaticMarkup(createElement(PrivacyInputNotice, { returnMode: "documents" })); expect(markup).toContain("Do not enter patient-identifiable information."); expect(markup).toContain('href="/privacy"'); expect(markup).toContain("Privacy and data processing"); + expect(documentsMarkup).toContain('href="/privacy?from=documents"'); }); it("publishes an accessible governance-review draft covering configured data processing", () => { diff --git a/tests/sheet.dom.test.tsx b/tests/sheet.dom.test.tsx index d38125411..9ff02d501 100644 --- a/tests/sheet.dom.test.tsx +++ b/tests/sheet.dom.test.tsx @@ -98,4 +98,24 @@ describe("Sheet stacked-overlay coordination", () => { ); expect(document.body.style.overflow).toBe(""); }); + + it("cancels focus-restore timers on unmount so coverage teardown cannot throw", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const { unmount } = render( + +

Solo body

+ , + ); + await vi.runAllTimersAsync(); + const restoreFrameSpy = vi.spyOn(window, "requestAnimationFrame"); + + // Unmount while open: no restore callback should be scheduled after the + // mount cleanup has started tearing down the component. + unmount(); + await vi.runAllTimersAsync(); + expect(restoreFrameSpy).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); });