Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
485 lines
13 KiB
TypeScript
485 lines
13 KiB
TypeScript
/**
|
|
* BlockMenu component tests.
|
|
*
|
|
* Tests the floating block-level context menu that appears when clicking
|
|
* a drag handle. Covers the main menu (Turn into, Duplicate, Delete),
|
|
* the "Turn into" submenu with block transforms, Escape to close,
|
|
* and click-outside dismissal.
|
|
*
|
|
* BlockMenu is a standalone component that takes an editor instance,
|
|
* an anchor element, and open/close callbacks. It renders via
|
|
* createPortal to document.body.
|
|
*/
|
|
|
|
import type { Editor } from "@tiptap/react";
|
|
import { userEvent } from "@vitest/browser/context";
|
|
import * as React from "react";
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
import { BlockMenu } from "../../src/components/editor/BlockMenu";
|
|
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
|
import { render } from "../utils/render";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks — same as other editor tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("../../src/components/MediaPickerModal", () => ({
|
|
MediaPickerModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../../src/components/SectionPickerModal", () => ({
|
|
SectionPickerModal: () => null,
|
|
}));
|
|
|
|
vi.mock("../../src/components/editor/DragHandleWrapper", () => ({
|
|
DragHandleWrapper: () => null,
|
|
}));
|
|
|
|
vi.mock("../../src/components/editor/ImageNode", async () => {
|
|
const { Node } = await import("@tiptap/core");
|
|
const ImageExtension = Node.create({
|
|
name: "image",
|
|
group: "block",
|
|
atom: true,
|
|
addAttributes() {
|
|
return {
|
|
src: { default: null },
|
|
alt: { default: "" },
|
|
title: { default: "" },
|
|
caption: { default: "" },
|
|
mediaId: { default: null },
|
|
provider: { default: "local" },
|
|
width: { default: null },
|
|
height: { default: null },
|
|
displayWidth: { default: null },
|
|
displayHeight: { default: null },
|
|
};
|
|
},
|
|
parseHTML() {
|
|
return [{ tag: "img[src]" }];
|
|
},
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ["img", HTMLAttributes];
|
|
},
|
|
});
|
|
return { ImageExtension };
|
|
});
|
|
|
|
vi.mock("../../src/components/editor/PluginBlockNode", async () => {
|
|
const { Node } = await import("@tiptap/core");
|
|
const PluginBlockExtension = Node.create({
|
|
name: "pluginBlock",
|
|
group: "block",
|
|
atom: true,
|
|
addAttributes() {
|
|
return {
|
|
blockType: { default: "embed" },
|
|
id: { default: "" },
|
|
data: { default: {} },
|
|
};
|
|
},
|
|
parseHTML() {
|
|
return [{ tag: "div[data-plugin-block]" }];
|
|
},
|
|
renderHTML({ HTMLAttributes }) {
|
|
return ["div", { ...HTMLAttributes, "data-plugin-block": "" }];
|
|
},
|
|
});
|
|
return {
|
|
PluginBlockExtension,
|
|
getEmbedMeta: () => ({ label: "Embed", Icon: () => null }),
|
|
registerPluginBlocks: () => {},
|
|
resolveIcon: () => () => null,
|
|
};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const defaultValue = [
|
|
{
|
|
_type: "block" as const,
|
|
_key: "1",
|
|
style: "normal" as const,
|
|
children: [{ _type: "span" as const, _key: "s1", text: "First paragraph" }],
|
|
},
|
|
{
|
|
_type: "block" as const,
|
|
_key: "2",
|
|
style: "normal" as const,
|
|
children: [{ _type: "span" as const, _key: "s2", text: "Second paragraph" }],
|
|
},
|
|
];
|
|
|
|
/** Render the full editor to get a real TipTap Editor instance */
|
|
async function getEditor() {
|
|
let editorInstance: Editor | null = null;
|
|
|
|
await render(
|
|
<PortableTextEditor
|
|
value={defaultValue}
|
|
onEditorReady={(editor) => {
|
|
editorInstance = editor;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await vi.waitFor(
|
|
() => {
|
|
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
|
expect(editorInstance).toBeTruthy();
|
|
},
|
|
{ timeout: 3000 },
|
|
);
|
|
|
|
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
|
return { editor: editorInstance!, pm };
|
|
}
|
|
|
|
/**
|
|
* Wrapper component that renders BlockMenu with an anchor element.
|
|
* This is needed because BlockMenu uses useFloating which needs a real DOM element.
|
|
*/
|
|
function BlockMenuTestWrapper({
|
|
editor,
|
|
isOpen,
|
|
onClose,
|
|
}: {
|
|
editor: Editor;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const anchorRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
return (
|
|
<>
|
|
<div ref={anchorRef} data-testid="anchor" style={{ width: 100, height: 20 }}>
|
|
Anchor
|
|
</div>
|
|
<BlockMenu
|
|
editor={editor}
|
|
anchorElement={anchorRef.current}
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Get the block menu portal element */
|
|
function getBlockMenu(): HTMLElement | null {
|
|
const portals = document.querySelectorAll("body > div");
|
|
for (const el of portals) {
|
|
// The block menu has "Turn into", "Duplicate", "Delete" buttons
|
|
if (el.textContent?.includes("Turn into") || el.textContent?.includes("Back")) {
|
|
return el as HTMLElement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Get all text buttons in the menu */
|
|
function getMenuButtons(menu: HTMLElement): HTMLButtonElement[] {
|
|
return [...menu.querySelectorAll("button")];
|
|
}
|
|
|
|
/** Find a button by its text content */
|
|
function findButtonByText(menu: HTMLElement, text: string): HTMLButtonElement | null {
|
|
const buttons = getMenuButtons(menu);
|
|
return buttons.find((btn) => btn.textContent?.includes(text)) ?? null;
|
|
}
|
|
|
|
// =============================================================================
|
|
// BlockMenu — Main Menu
|
|
// =============================================================================
|
|
|
|
describe("BlockMenu", () => {
|
|
it("renders nothing when isOpen is false", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={false} onClose={onClose} />);
|
|
|
|
expect(getBlockMenu()).toBeNull();
|
|
});
|
|
|
|
it("renders main menu with Turn into, Duplicate, Delete when open", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
const menu = getBlockMenu();
|
|
expect(menu).toBeTruthy();
|
|
});
|
|
|
|
const menu = getBlockMenu()!;
|
|
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
|
|
expect(findButtonByText(menu, "Duplicate")).toBeTruthy();
|
|
expect(findButtonByText(menu, "Delete")).toBeTruthy();
|
|
});
|
|
|
|
it("shows Turn into submenu when Turn into is clicked", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
const menu = getBlockMenu()!;
|
|
findButtonByText(menu, "Turn into")!.click();
|
|
|
|
// Should show transform options
|
|
await vi.waitFor(() => {
|
|
const updatedMenu = getBlockMenu()!;
|
|
expect(findButtonByText(updatedMenu, "Back")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Paragraph")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Heading 1")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Heading 2")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Heading 3")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Quote")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Code Block")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Bullet List")).toBeTruthy();
|
|
expect(findButtonByText(updatedMenu, "Numbered List")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("returns to main menu when Back is clicked in transform submenu", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
const menu = getBlockMenu()!;
|
|
findButtonByText(menu, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Back")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
const mainMenu = getBlockMenu()!;
|
|
expect(findButtonByText(mainMenu, "Turn into")).toBeTruthy();
|
|
expect(findButtonByText(mainMenu, "Duplicate")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("transforms block to heading when Heading 1 is selected", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
// Focus editor on first paragraph
|
|
editor.commands.focus("start");
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
// Open transforms
|
|
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Heading 1")).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Heading 1")!.click();
|
|
|
|
// Should close menu and transform block
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(pm.querySelector("h1")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("transforms block to blockquote", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
editor.commands.focus("start");
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Quote")).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Quote")!.click();
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(pm.querySelector("blockquote")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("transforms block to code block", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
editor.commands.focus("start");
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Code Block")).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Code Block")!.click();
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(pm.querySelector("pre")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("transforms block to bullet list", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
editor.commands.focus("start");
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Bullet List")).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Bullet List")!.click();
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(pm.querySelector("ul")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("deletes the current block when Delete is clicked", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
// Focus on first paragraph
|
|
editor.commands.focus("start");
|
|
|
|
// Count initial paragraphs
|
|
const initialParagraphs = pm.querySelectorAll("p").length;
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Delete")!.click();
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
// Should have one fewer paragraph
|
|
await vi.waitFor(() => {
|
|
const newParagraphs = pm.querySelectorAll("p").length;
|
|
expect(newParagraphs).toBeLessThan(initialParagraphs);
|
|
});
|
|
});
|
|
|
|
it("duplicates the current block when Duplicate is clicked", async () => {
|
|
const { editor, pm } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
editor.commands.focus("start");
|
|
|
|
const initialParagraphs = pm.querySelectorAll("p").length;
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
findButtonByText(getBlockMenu()!, "Duplicate")!.click();
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
const newParagraphs = pm.querySelectorAll("p").length;
|
|
expect(newParagraphs).toBe(initialParagraphs + 1);
|
|
});
|
|
});
|
|
|
|
it("closes on Escape key", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
await userEvent.keyboard("{Escape}");
|
|
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes transform submenu on Escape (returns to main, not full close)", async () => {
|
|
const { editor } = await getEditor();
|
|
const onClose = vi.fn();
|
|
|
|
await render(<BlockMenuTestWrapper editor={editor} isOpen={true} onClose={onClose} />);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(getBlockMenu()).toBeTruthy();
|
|
});
|
|
|
|
// Open transforms
|
|
findButtonByText(getBlockMenu()!, "Turn into")!.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(findButtonByText(getBlockMenu()!, "Back")).toBeTruthy();
|
|
});
|
|
|
|
// Escape should close submenu, not the whole menu
|
|
await userEvent.keyboard("{Escape}");
|
|
|
|
// onClose should NOT have been called — submenu should just close
|
|
// (The component resets showTransforms on Escape in submenu)
|
|
await vi.waitFor(() => {
|
|
const menu = getBlockMenu()!;
|
|
// Should be back to main menu
|
|
expect(findButtonByText(menu, "Turn into")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|