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();
});
});