import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaDetailPanel } from "../../src/components/MediaDetailPanel"; import type { MediaItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { ...actual, updateMedia: vi.fn().mockResolvedValue({}), deleteMedia: vi.fn().mockResolvedValue({}), deleteFromProvider: vi.fn().mockResolvedValue({}), }; }); // Import the mocked functions for assertions import { updateMedia, deleteMedia, deleteFromProvider } from "../../src/lib/api"; function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return {children}; } function makeImageItem(overrides: Partial = {}): MediaItem { return { id: "media-1", filename: "photo.jpg", mimeType: "image/jpeg", url: "https://example.com/photo.jpg", size: 204800, width: 1920, height: 1080, alt: "A nice photo", caption: "Photo caption", createdAt: "2025-01-15T10:30:00Z", ...overrides, }; } function makePdfItem(overrides: Partial = {}): MediaItem { return { id: "media-2", filename: "document.pdf", mimeType: "application/pdf", url: "https://example.com/document.pdf", size: 1048576, createdAt: "2025-01-15T10:30:00Z", ...overrides, }; } function renderPanel(props: Partial> = {}) { const defaultProps: React.ComponentProps = { open: true, item: makeImageItem(), onClose: vi.fn(), onDeleted: vi.fn(), ...props, }; return render( , ); } describe("MediaDetailPanel", () => { beforeEach(() => { vi.clearAllMocks(); }); it("does not render dialog contents when closed", async () => { const screen = await renderPanel({ open: false }); await expect .element(screen.getByText("Media Details"), { timeout: 100 }) .not.toBeInTheDocument(); }); it("displays filename and file size", async () => { const item = makeImageItem({ size: 204800 }); const screen = await renderPanel({ item }); // Filename is in a disabled input const filenameInput = screen.getByLabelText("Filename"); await expect.element(filenameInput).toHaveValue("photo.jpg"); // 204800 bytes = 200 KB await expect.element(screen.getByText("200 KB")).toBeInTheDocument(); }); it("displays dimensions for images", async () => { const item = makeImageItem({ width: 1920, height: 1080 }); const screen = await renderPanel({ item }); await expect.element(screen.getByText("1920 × 1080")).toBeInTheDocument(); }); it("renders the responsive two-column viewport-bounded dialog layout", async () => { const screen = await renderPanel(); const dialog = screen.getByRole("dialog", { name: "Media Details" }).element(); const header = screen.getByTestId("media-detail-dialog-header").element(); const body = screen.getByTestId("media-detail-dialog-body").element(); const previewColumn = screen.getByTestId("media-detail-dialog-preview-column").element(); const detailsColumn = screen.getByTestId("media-detail-dialog-details-column").element(); const fileFacts = screen.getByTestId("media-detail-dialog-file-facts").element(); const footer = screen.getByTestId("media-detail-dialog-footer").element(); expect(body.className).toContain("grid-cols-1"); expect(body.className).toContain("md:grid-cols-2"); expect(dialog.style.height).toBe(""); expect(dialog.style.maxHeight).toBe("min(88dvh, 48rem)"); expect(dialog.className).toContain("data-starting-style:scale-90"); expect(dialog.className).toContain("data-starting-style:opacity-0"); expect(dialog.style.transitionProperty).toBe("scale, opacity"); expect(header.style.padding).toBe("1.25rem 2rem"); expect(previewColumn.contains(fileFacts)).toBe(true); expect(detailsColumn.contains(fileFacts)).toBe(false); expect(previewColumn.className).toContain("md:p-8"); expect(detailsColumn.className).toContain("md:p-8"); // Columns only constrain/scroll at md+; on mobile the body scrolls as one // column so collapsed columns can't compress and overlap their content. expect(body.className).toContain("overflow-y-auto"); expect(body.className).toContain("md:overflow-hidden"); expect(previewColumn.className).toContain("md:min-h-0"); expect(previewColumn.className).toContain("md:overflow-y-auto"); expect(previewColumn.className).not.toContain(" min-h-0"); expect(detailsColumn.className).toContain("md:min-h-0"); expect(detailsColumn.className).toContain("md:overflow-y-auto"); expect(detailsColumn.className).not.toContain(" min-h-0"); expect(fileFacts.className).toContain("space-y-3"); expect(footer.style.padding).toBe("1.25rem 2rem"); }); it("shows image preview for image mimeTypes", async () => { const item = makeImageItem(); const screen = await renderPanel({ item }); const img = screen.getByAltText("A nice photo"); await expect.element(img).toBeInTheDocument(); await expect.element(img).toHaveAttribute("src", item.url); }); it("does not show image preview for non-image mimeTypes", async () => { const item = makePdfItem(); const screen = await renderPanel({ item }); // Should show the mime type text instead of img await expect.element(screen.getByText("application/pdf")).toBeInTheDocument(); }); it("alt text input is editable", async () => { const item = makeImageItem({ alt: "Initial alt" }); const screen = await renderPanel({ item }); const altInput = screen.getByLabelText("Alt Text"); await expect.element(altInput).toBeInTheDocument(); expect(altInput.element().className).toContain("w-full"); await expect .element(screen.getByRole("button", { name: "Why is this important?" })) .toBeInTheDocument(); await altInput.fill("New alt text"); await expect.element(altInput).toHaveValue("New alt text"); }); it("shows caption textarea only for images", async () => { const imageItem = makeImageItem(); const screen = await renderPanel({ item: imageItem }); // Caption textarea should exist for images - find by placeholder const captionArea = screen.getByPlaceholder("Optional caption for display"); await expect.element(captionArea).toBeInTheDocument(); await expect.element(captionArea).toHaveValue("Photo caption"); }); it("hides caption textarea for non-images", async () => { const pdfItem = makePdfItem(); const screen = await renderPanel({ item: pdfItem }); await expect .element(screen.getByPlaceholder("Optional caption for display"), { timeout: 100 }) .not.toBeInTheDocument(); await expect .element(screen.getByLabelText("Caption"), { timeout: 100 }) .not.toBeInTheDocument(); }); it("filename input is disabled with tooltip help", async () => { const item = makeImageItem(); const screen = await renderPanel({ item }); const filenameInput = screen.getByLabelText("Filename"); await expect.element(filenameInput).toBeDisabled(); expect(filenameInput.element().className).toContain("bg-kumo-tint"); expect(filenameInput.element().className).toContain("w-full"); await expect .element(screen.getByRole("button", { name: "Why can't this be changed?" })) .toBeInTheDocument(); }); it("save button is disabled when no changes", async () => { const item = makeImageItem(); const screen = await renderPanel({ item }); const saveBtn = screen.getByRole("button", { name: "Save" }); await expect.element(saveBtn).toBeDisabled(); }); it("save button is enabled after changing alt text", async () => { const item = makeImageItem({ alt: "Original" }); const screen = await renderPanel({ item }); const altInput = screen.getByLabelText("Alt Text"); await altInput.fill("Changed alt text"); const saveBtn = screen.getByRole("button", { name: "Save" }); await expect.element(saveBtn).toBeEnabled(); }); it("save calls updateMedia with correct payload", async () => { const onClose = vi.fn(); const item = makeImageItem({ alt: "Old alt", caption: "Old caption" }); const screen = await renderPanel({ item, onClose }); const altInput = screen.getByLabelText("Alt Text"); await altInput.fill("New alt"); const saveBtn = screen.getByRole("button", { name: "Save" }); await expect.element(saveBtn).toBeEnabled(); saveBtn.element().click(); await vi.waitFor(() => { expect(updateMedia).toHaveBeenCalledWith("media-1", { alt: "New alt", caption: "Old caption", }); expect(onClose).toHaveBeenCalled(); }); }); it("saves empty strings when clearing alt text and caption", async () => { const item = makeImageItem({ alt: "Old alt", caption: "Old caption" }); const screen = await renderPanel({ item }); await screen.getByLabelText("Alt Text").fill(""); await screen.getByLabelText("Caption").fill(""); screen.getByRole("button", { name: "Save" }).element().click(); await vi.waitFor(() => { expect(updateMedia).toHaveBeenCalledWith("media-1", { alt: "", caption: "", }); }); }); it("disables editing and closing while a save is pending", async () => { let resolveUpdate!: (item: MediaItem) => void; vi.mocked(updateMedia).mockImplementationOnce( () => new Promise((resolve) => (resolveUpdate = resolve)), ); const onClose = vi.fn(); const item = makeImageItem({ alt: "Old alt", caption: "Old caption" }); const screen = await renderPanel({ item, onClose }); const altInput = screen.getByLabelText("Alt Text"); await altInput.fill("New alt"); screen.getByRole("button", { name: "Save" }).element().click(); await expect.element(altInput).toBeDisabled(); await expect.element(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); await expect.element(screen.getByRole("button", { name: "Close" })).toBeDisabled(); await expect.element(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); screen.getByRole("button", { name: "Close" }).element().click(); expect(onClose).not.toHaveBeenCalled(); resolveUpdate({ ...item, alt: "New alt" }); await vi.waitFor(() => { expect(onClose).toHaveBeenCalled(); }); }); it("saves dirty metadata with the keyboard shortcut", async () => { const onClose = vi.fn(); const item = makeImageItem({ alt: "Old alt", caption: "Old caption" }); const screen = await renderPanel({ item, onClose }); await screen.getByLabelText("Alt Text").fill("Shortcut alt"); window.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true })); await vi.waitFor(() => { expect(updateMedia).toHaveBeenCalledWith("media-1", { alt: "Shortcut alt", caption: "Old caption", }); expect(onClose).toHaveBeenCalled(); }); }); it("does not consume the keyboard save shortcut when nothing can be saved", async () => { await renderPanel({ item: makeImageItem({ provider: "cloudflare-images" }), providerName: "Cloudflare Images", }); const event = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true }); window.dispatchEvent(event); expect(event.defaultPrevented).toBe(false); expect(updateMedia).not.toHaveBeenCalled(); }); it("delete with confirm calls deleteMedia and onClose + onDeleted", async () => { const onClose = vi.fn(); const onDeleted = vi.fn(); const item = makeImageItem(); const screen = await renderPanel({ item, onClose, onDeleted }); const deleteBtn = screen.getByRole("button", { name: "Delete" }); deleteBtn.element().click(); // ConfirmDialog should appear await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument(); // Direct DOM click to bypass Base UI inert overlay const allDeleteBtns = screen.getByRole("button", { name: "Delete" }).all(); allDeleteBtns.at(-1)!.element().click(); // Wait for mutation to complete await vi.waitFor(() => { expect(deleteMedia).toHaveBeenCalledWith("media-1"); expect(onClose).toHaveBeenCalled(); expect(onDeleted).toHaveBeenCalled(); }); }); it("deletes provider assets through the provider API when deletion is supported", async () => { const onDeleted = vi.fn(); const screen = await renderPanel({ item: makeImageItem({ id: "provider-1", provider: "cloudflare-images" }), providerName: "Cloudflare Images", canDelete: true, onDeleted, }); screen.getByRole("button", { name: "Delete" }).element().click(); await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument(); screen.getByRole("button", { name: "Delete" }).all().at(-1)!.element().click(); await vi.waitFor(() => { expect(deleteFromProvider).toHaveBeenCalledWith("cloudflare-images", "provider-1"); expect(deleteMedia).not.toHaveBeenCalled(); expect(onDeleted).toHaveBeenCalledTimes(1); }); }); it("delete cancelled does not call deleteMedia", async () => { const item = makeImageItem(); const screen = await renderPanel({ item }); const deleteBtn = screen.getByRole("button", { name: "Delete" }); deleteBtn.element().click(); // ConfirmDialog should appear await expect.element(screen.getByText("Delete Media?")).toBeInTheDocument(); // Direct DOM click to bypass Base UI inert overlay screen.getByRole("button", { name: "Cancel" }).all().at(-1)!.element().click(); expect(deleteMedia).not.toHaveBeenCalled(); }); it("close button calls onClose when clean", async () => { const onClose = vi.fn(); const item = makeImageItem(); const screen = await renderPanel({ item, onClose }); screen.getByRole("button", { name: "Close" }).element().click(); expect(onClose).toHaveBeenCalled(); }); it("close button opens discard confirmation when dirty", async () => { const onClose = vi.fn(); const item = makeImageItem({ alt: "Original" }); const screen = await renderPanel({ item, onClose }); await screen.getByLabelText("Alt Text").fill("Changed alt"); screen.getByRole("button", { name: "Close" }).element().click(); await expect.element(screen.getByText("Discard changes?")).toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); screen.getByRole("button", { name: "Discard" }).element().click(); expect(onClose).toHaveBeenCalled(); }); it("cancels the close fallback when reopened before the fallback timer fires", async () => { vi.useFakeTimers(); try { const onClose = vi.fn(); const onClosed = vi.fn(); const firstItem = makeImageItem({ id: "media-1", filename: "first.jpg" }); const secondItem = makeImageItem({ id: "media-2", filename: "second.jpg" }); const screen = await render( , ); screen.getByRole("button", { name: "Close" }).element().click(); expect(onClose).toHaveBeenCalled(); await screen.rerender( , ); await vi.advanceTimersByTimeAsync(500); expect(onClosed).not.toHaveBeenCalled(); await expect.element(screen.getByLabelText("Filename")).toHaveValue("second.jpg"); } finally { vi.useRealTimers(); } }); it("form fields reset when item prop changes", async () => { const item1 = makeImageItem({ id: "m1", alt: "Alt one", caption: "Cap one" }); const item2 = makeImageItem({ id: "m2", alt: "Alt two", caption: "Cap two" }); const screen = await renderPanel({ item: item1 }); // Verify item1 alt is shown const altInput = screen.getByLabelText("Alt Text"); await expect.element(altInput).toHaveValue("Alt one"); // Rerender with item2 await screen.rerender( , ); // The alt text should now show item2's alt await expect.element(screen.getByLabelText("Alt Text")).toHaveValue("Alt two"); }); }); describe("MediaDetailPanel file URL", () => { it("shows the absolute file URL with a Copy URL action", async () => { const screen = await renderPanel({ item: makeImageItem({ url: "/_emdash/api/media/file/01ABC.jpg" }), }); // Relative local-storage URLs are shown as absolute (origin-resolved) // so they can be pasted anywhere. The Kumo ClipboardText component // renders the value as text and a copy button (labelled "Copy URL"). const absolute = new URL("/_emdash/api/media/file/01ABC.jpg", window.location.origin).href; await expect.element(screen.getByText(absolute)).toBeVisible(); await expect.element(screen.getByRole("button", { name: /Copy URL/ })).toBeVisible(); }); it("does not expose provider preview URLs as public URLs", async () => { const screen = await renderPanel({ item: makeImageItem({ provider: "cloudflare-images", url: "https://preview.example/image" }), providerName: "Cloudflare Images", }); await expect.element(screen.getByText("Managed by Cloudflare Images")).toBeVisible(); await expect.element(screen.getByText("No public URL available")).toBeVisible(); await expect .element(screen.getByRole("button", { name: /Copy URL/ }), { timeout: 100 }) .not.toBeInTheDocument(); }); it("renders provider assets read-only", async () => { const screen = await renderPanel({ item: makeImageItem({ provider: "cloudflare-images" }), providerName: "Cloudflare Images", }); await expect .element(screen.getByRole("button", { name: "Delete" }), { timeout: 100 }) .not.toBeInTheDocument(); await expect .element(screen.getByRole("button", { name: "Save" }), { timeout: 100 }) .not.toBeInTheDocument(); await expect .element(screen.getByLabelText("Alt Text"), { timeout: 100 }) .not.toBeInTheDocument(); await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument(); }); });