Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
293
packages/admin/tests/components/MediaPickerModal.test.tsx
Normal file
293
packages/admin/tests/components/MediaPickerModal.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { MediaPickerModal } from "../../src/components/MediaPickerModal";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UPLOAD_BUTTON_REGEX = /Upload/;
|
||||
|
||||
vi.mock("../../src/lib/api", async () => {
|
||||
const actual = await vi.importActual("../../src/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
fetchMediaList: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "m1",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
url: "/media/photo.jpg",
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
filename: "landscape.png",
|
||||
mimeType: "image/png",
|
||||
url: "/media/landscape.png",
|
||||
size: 2048,
|
||||
width: 1200,
|
||||
height: 800,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
],
|
||||
}),
|
||||
fetchMediaProviders: vi.fn().mockResolvedValue([]),
|
||||
fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }),
|
||||
uploadMedia: vi.fn().mockResolvedValue({ id: "m3", filename: "new.jpg" }),
|
||||
uploadToProvider: vi.fn().mockResolvedValue({}),
|
||||
updateMedia: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
function QueryWrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof MediaPickerModal>> = {}) {
|
||||
const defaultProps: React.ComponentProps<typeof MediaPickerModal> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
return render(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal {...defaultProps} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MediaPickerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("displaying items", () => {
|
||||
it("shows media items when open", async () => {
|
||||
const screen = await renderModal({ open: true });
|
||||
await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("option", { name: "landscape.png" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the modal title", async () => {
|
||||
const screen = await renderModal({ title: "Pick an Image" });
|
||||
await expect.element(screen.getByText("Pick an Image")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection", () => {
|
||||
it("single click selects item (highlighted)", async () => {
|
||||
const screen = await renderModal();
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
|
||||
// Direct DOM click to bypass inert overlay
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.click();
|
||||
|
||||
// Should show selected state via aria-selected
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
// Footer should show selected filename in a <strong> tag
|
||||
await expect.element(screen.getByRole("strong")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("double click selects and calls onSelect", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
|
||||
// Use direct DOM dblclick to bypass inert overlay
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("Insert button disabled when nothing selected", async () => {
|
||||
await renderModal();
|
||||
// There are two Insert buttons — URL section and footer.
|
||||
// The footer Insert is the last one and should be disabled.
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
// The footer Insert (last one) should be disabled
|
||||
const lastInsert = insertBtns.at(-1);
|
||||
expect(lastInsert?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("Insert button enabled when item selected, calls onSelect", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
// Select an item via direct DOM click
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
const itemBtn = option.element().querySelector("button")!;
|
||||
itemBtn.click();
|
||||
|
||||
// Wait for selection to register
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Click the footer Insert button (last Insert button)
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
const lastInsert = insertBtns.at(-1)!;
|
||||
expect(lastInsert.disabled).toBe(false);
|
||||
lastInsert.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", filename: "photo.jpg" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL input", () => {
|
||||
it("invalid URL shows error", async () => {
|
||||
const screen = await renderModal();
|
||||
|
||||
// The URL input has aria-label "Image URL"
|
||||
const urlInput = screen.getByLabelText("Image URL");
|
||||
await expect.element(urlInput).toBeInTheDocument();
|
||||
|
||||
// Type an invalid URL — use direct DOM since we're inside a dialog
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
// Manually set value and trigger change
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(inputEl, "not-a-url");
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click the URL Insert button (first Insert button)
|
||||
await vi.waitFor(() => {
|
||||
const urlInsert = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Insert",
|
||||
)!;
|
||||
expect(urlInsert.disabled).toBe(false);
|
||||
urlInsert.click();
|
||||
});
|
||||
|
||||
await expect.element(screen.getByText("Please enter a valid URL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("URL input: typing a URL and submitting triggers probe", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const screen = await renderModal({ onSelect });
|
||||
|
||||
const urlInput = screen.getByLabelText("Image URL");
|
||||
const inputEl = urlInput.element() as HTMLInputElement;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(inputEl, "https://example.com/test.jpg");
|
||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click URL Insert button
|
||||
await vi.waitFor(() => {
|
||||
const urlInsert = [...document.querySelectorAll("button")].find(
|
||||
(b) => b.textContent?.trim() === "Insert",
|
||||
)!;
|
||||
urlInsert.click();
|
||||
});
|
||||
|
||||
// Image probe will fail in test env, so either onSelect called or error shown
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const called = onSelect.mock.calls.length > 0;
|
||||
const hasError =
|
||||
document.body.textContent?.includes("Could not load image from URL") ?? false;
|
||||
expect(called || hasError).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel and close", () => {
|
||||
it("Cancel closes modal", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await renderModal({ onOpenChange });
|
||||
|
||||
await expect.element(screen.getByText("Select Image")).toBeInTheDocument();
|
||||
// Direct DOM click to bypass inert overlay
|
||||
const cancelEl = screen.getByText("Cancel").element();
|
||||
const cancelBtn = cancelEl.closest("button")!;
|
||||
cancelBtn.click();
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("state reset", () => {
|
||||
it("state resets when modal reopens", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const screen = await renderModal({ open: true, onSelect, onOpenChange });
|
||||
|
||||
// Select an item
|
||||
const option = screen.getByRole("option", { name: "photo.jpg" });
|
||||
await expect.element(option).toBeInTheDocument();
|
||||
const btn = option.element().querySelector("button")!;
|
||||
btn.click();
|
||||
|
||||
// Verify selection
|
||||
await expect.element(option).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
// Close modal
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal open={false} onOpenChange={onOpenChange} onSelect={onSelect} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Reopen modal
|
||||
await screen.rerender(
|
||||
<QueryWrapper>
|
||||
<MediaPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
|
||||
</QueryWrapper>,
|
||||
);
|
||||
|
||||
// Footer Insert should be disabled (no selection after reset)
|
||||
await vi.waitFor(() => {
|
||||
const allInsertBtns = document.querySelectorAll("button");
|
||||
const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert");
|
||||
const lastInsert = insertBtns.at(-1);
|
||||
expect(lastInsert?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload", () => {
|
||||
it("upload button and file input are present", async () => {
|
||||
const screen = await renderModal();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: UPLOAD_BUTTON_REGEX }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(screen.getByLabelText("Upload file")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user