diff --git a/src/components/clinical-dashboard/image-lightbox.tsx b/src/components/clinical-dashboard/image-lightbox.tsx index ce30ebd18f..c969d70d7f 100644 --- a/src/components/clinical-dashboard/image-lightbox.tsx +++ b/src/components/clinical-dashboard/image-lightbox.tsx @@ -14,35 +14,63 @@ const MIN_SCALE = 1; const MAX_SCALE = 6; const clampScale = (value: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, value)); +type ImageLightboxBaseProps = { + open: boolean; + onClose: () => void; + alt: string; + caption?: string; + returnFocusRef?: RefObject; +}; + +/** + * Endpoint mode: fetch a private image through `/api/.../signed-url` (rail crops). + * URL mode: parent already owns a document signed URL (whole-document images). + * Exactly one source must be provided — URL mode must never touch the signed-URL LRU. + */ +export type ImageLightboxProps = ImageLightboxBaseProps & + ({ endpoint: string; url?: never } | { url: string; endpoint?: never }); + /** - * Fullscreen, zoomable viewer for a single private image (diagram / table crop). + * Fullscreen, zoomable viewer for a single private image (diagram / table crop / + * whole-document photo). * * Built on the shared Sheet (focus trap, Escape, scroll-lock, focus return) and * the shared useViewerGestures hook, so wheel/pinch zoom and drag-to-pan match * the PDF canvas. Zoom/pan/rotate are pure CSS transforms on the . */ -export function ImageLightbox({ - open, - onClose, - endpoint, - alt, - caption, - returnFocusRef, -}: { - open: boolean; - onClose: () => void; - endpoint: string; - alt: string; - caption?: string; - returnFocusRef?: RefObject; -}) { - const { url, failed, retry, markFailed } = useSignedImageUrl(endpoint, open); +export function ImageLightbox(props: ImageLightboxProps) { + const { open, onClose, alt, caption, returnFocusRef } = props; + const endpoint = "endpoint" in props ? props.endpoint : undefined; + const directUrl = "url" in props ? props.url : undefined; + const endpointMode = typeof endpoint === "string" && endpoint.length > 0; + + const { + url: fetchedUrl, + failed: fetchFailed, + retry: retryFetch, + markFailed: markFetchFailed, + } = useSignedImageUrl(endpoint ?? "", open && endpointMode); + + const [directFailed, setDirectFailed] = useState(false); const [retryDisabled, setRetryDisabled] = useState(false); const [scale, setScale] = useState(1); const [rotation, setRotation] = useState(0); const [translate, setTranslate] = useState({ x: 0, y: 0 }); const stageRef = useRef(null); const scaleRef = useRef(1); + // Fresh direct URL after parent re-issue clears a prior load failure during render + // (same identity-adjust pattern as useSignedImageUrl — no set-state-in-effect). + const [seenDirectUrl, setSeenDirectUrl] = useState(directUrl ?? null); + if (!endpointMode && (directUrl ?? null) !== seenDirectUrl) { + setSeenDirectUrl(directUrl ?? null); + setDirectFailed(false); + } + + // URL mode: blank when the parent clears the signed URL (auth identity change). + // Never seed from the module LRU — that would revive a prior identity's image. + // Hide the once a load failure is recorded so role=alert can surface. + const url = endpointMode ? fetchedUrl : open && directUrl && !directFailed ? directUrl : null; + const failed = endpointMode ? fetchFailed : directFailed; useEffect(() => { scaleRef.current = scale; @@ -51,15 +79,28 @@ export function ImageLightbox({ const handleRetry = useCallback(() => { if (retryDisabled) return; setRetryDisabled(true); - retry(); + if (endpointMode) { + retryFetch(); + } else { + setDirectFailed(false); + } setTimeout(() => setRetryDisabled(false), 2000); - }, [retryDisabled, retry]); + }, [endpointMode, retryDisabled, retryFetch]); + + const markFailed = useCallback(() => { + if (endpointMode) { + markFetchFailed(); + return; + } + setDirectFailed(true); + }, [endpointMode, markFetchFailed]); // Reset the view on close so the next open never inherits a prior image's zoom. const handleClose = useCallback(() => { setScale(1); setRotation(0); setTranslate({ x: 0, y: 0 }); + setDirectFailed(false); onClose(); }, [onClose]); @@ -104,6 +145,8 @@ export function ImageLightbox({
. + * by the parent (not a re-fetchable endpoint). Primary viewing opens the shared + * ImageLightbox in URL mode; Open/Download remain secondary recovery affordances. */ function InlineImagePreview({ signedUrl, @@ -106,8 +106,10 @@ function InlineImagePreview({ title: string; }) { const [failed, setFailed] = useState(false); + const [lightboxOpen, setLightboxOpen] = useState(false); const announcementSourceId = useId(); const failureTransition = useRef(0); + const triggerRef = useRef(null); useEffect(() => { if (!failed) return; @@ -153,21 +155,60 @@ function InlineImagePreview({ } return ( -
-
+
+
{title} setFailed(true)} - className="absolute inset-0 h-full w-full object-contain" + className="mx-auto max-h-[min(70vh,36rem)] w-full object-contain" /> +
- - +
+ + + + {downloadSignedUrl ? ( + + + ) : null} +
+ setLightboxOpen(false)} + url={signedUrl} + alt={title} + returnFocusRef={triggerRef} + />
); } diff --git a/tests/document-viewer-non-pdf-preview.dom.test.tsx b/tests/document-viewer-non-pdf-preview.dom.test.tsx index dc0fb97d4d..768dbd49f0 100644 --- a/tests/document-viewer-non-pdf-preview.dom.test.tsx +++ b/tests/document-viewer-non-pdf-preview.dom.test.tsx @@ -1,9 +1,20 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NonPdfSourcePreview } from "@/components/document-viewer/non-pdf-source-preview"; import { LiveAnnouncer, resetAnnouncerForTests } from "@/components/ui/live-announcer"; +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + session: { user: { id: "user-a" } }, + authorizationHeader: { Authorization: "Bearer user-a" }, + markSessionExpired: vi.fn(), + registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })), + isAuthEpochCurrent: vi.fn(() => true), + }), +})); + describe("DocumentViewer non-PDF image preview", () => { beforeEach(() => resetAnnouncerForTests()); @@ -12,6 +23,29 @@ describe("DocumentViewer non-PDF image preview", () => { vi.useRealTimers(); }); + it("opens the shared immersive lightbox from the primary expand control", async () => { + render( + , + ); + + expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Expand image: Clinical chart" })); + expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument(); + expect(screen.getByTestId("image-lightbox-stage")).toHaveAttribute("data-source-mode", "url"); + expect(screen.getAllByRole("img", { name: "Clinical chart" }).length).toBeGreaterThanOrEqual(1); + + expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "https://example.test/chart.png"); + expect(screen.getByRole("link", { name: "Download" })).toHaveAttribute( + "href", + "https://example.test/chart-download.png", + ); + }); + it("observably announces sub-second fail-retry-fail transitions while same-state rerenders stay silent", () => { vi.useFakeTimers(); const props = { @@ -53,4 +87,26 @@ describe("DocumentViewer non-PDF image preview", () => { act(() => vi.advanceTimersByTime(50)); expect(assertiveRegion).toHaveTextContent("Image preview could not load"); }); + + it("unmounts the lightbox when the parent blanks the signed URL", async () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "View immersive" })); + expect(await screen.findByTestId("image-lightbox")).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument(); + }); + expect(screen.getByText(/signed URL is generated/i)).toBeInTheDocument(); + }); }); diff --git a/tests/image-lightbox-url-mode.dom.test.tsx b/tests/image-lightbox-url-mode.dom.test.tsx new file mode 100644 index 0000000000..2a471a6574 --- /dev/null +++ b/tests/image-lightbox-url-mode.dom.test.tsx @@ -0,0 +1,73 @@ +/** @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ImageLightbox } from "@/components/clinical-dashboard/image-lightbox"; +import { clearSignedUrlCache, setCachedSignedUrl } from "@/lib/signed-url-cache"; + +const ENDPOINT = "/api/images/lightbox-url-mode/signed-url"; +const PRIOR_USER_URL = "https://example.supabase.co/storage/v1/object/sign/prior-user.png?token=stale"; +const DIRECT_URL = "https://example.test/whole-document.png"; + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => ({ + status: "authenticated", + session: { user: { id: "user-b" } }, + authorizationHeader: { Authorization: "Bearer user-b" }, + markSessionExpired: vi.fn(), + registerAuthRequest: vi.fn(() => ({ epoch: 1, release: vi.fn() })), + isAuthEpochCurrent: vi.fn(() => true), + }), +})); + +describe("ImageLightbox URL mode", () => { + beforeEach(() => { + clearSignedUrlCache(); + }); + + afterEach(() => { + clearSignedUrlCache(); + }); + + it("renders a parent-owned URL without reading the signed-URL LRU", async () => { + setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() }); + + render(); + + const stage = await screen.findByTestId("image-lightbox-stage"); + expect(stage).toHaveAttribute("data-source-mode", "url"); + const image = screen.getByRole("img", { name: "Whole document chart" }); + expect(image).toHaveAttribute("src", DIRECT_URL); + expect(image).not.toHaveAttribute("src", PRIOR_USER_URL); + }); + + it("blanks the stage when the parent clears the direct URL while open", async () => { + const { rerender } = render(); + expect(screen.getByRole("img", { name: "Whole document chart" })).toBeInTheDocument(); + + // Parent auth-clear: signed URL gone. URL mode must not revive PRIOR_USER_URL from LRU. + setCachedSignedUrl(ENDPOINT, { url: PRIOR_USER_URL, expiresAt: new Date(Date.now() + 60_000).toISOString() }); + rerender(); + + await waitFor(() => { + expect(screen.queryByRole("img", { name: "Whole document chart" })).not.toBeInTheDocument(); + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading image"); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("announces load failure with role=alert and retries without endpoint fetch", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + render(); + + fireEvent.error(screen.getByRole("img", { name: "Broken chart" })); + expect(screen.getByRole("alert")).toHaveTextContent("Image could not load."); + expect(fetchSpy).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByRole("img", { name: "Broken chart" })).toHaveAttribute("src", DIRECT_URL); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +});