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:
566
packages/admin/tests/editor/bubble-menu.test.tsx
Normal file
566
packages/admin/tests/editor/bubble-menu.test.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* Bubble menu tests.
|
||||
*
|
||||
* Tests the inline formatting bubble menu that appears when text is selected.
|
||||
* Covers formatting buttons (bold, italic, underline, strikethrough, code),
|
||||
* link insertion/editing, and menu visibility.
|
||||
*
|
||||
* The bubble menu uses TipTap's BubbleMenu component and only appears
|
||||
* when there's a text selection in the editor.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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: "Hello world" }],
|
||||
},
|
||||
];
|
||||
|
||||
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
|
||||
let editorInstance: Editor | null = null;
|
||||
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
value={defaultValue}
|
||||
onEditorReady={(editor) => {
|
||||
editorInstance = editor;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
expect(editorInstance).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
return { screen, editor: editorInstance!, pm };
|
||||
}
|
||||
|
||||
/** Focus the editor and select all text using TipTap commands */
|
||||
async function focusAndSelectAll(editor: Editor, pm: HTMLElement) {
|
||||
pm.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
|
||||
editor.commands.focus();
|
||||
editor.commands.selectAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the bubble menu element.
|
||||
* TipTap's BubbleMenu renders as a div with role=presentation (tippy.js).
|
||||
* Our menu has the class "bg-kumo-base" and contains aria-label buttons.
|
||||
*/
|
||||
function getBubbleMenu(): HTMLElement | null {
|
||||
// The BubbleMenu from @tiptap/react/menus renders inline.
|
||||
// Look for the container with our known class pattern.
|
||||
const candidates = document.querySelectorAll('[class*="bg-kumo-base"]');
|
||||
for (const el of candidates) {
|
||||
// Bubble menu has formatting buttons with specific aria-labels
|
||||
if (el.querySelector('[aria-label="Bold"]') && el.querySelector('[aria-label="Italic"]')) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
// Also check for link input mode (has Apply link button)
|
||||
for (const el of candidates) {
|
||||
if (el.querySelector('[aria-label="Apply link"]')) {
|
||||
return el as HTMLElement;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Wait for bubble menu to appear */
|
||||
async function waitForBubbleMenu(): Promise<HTMLElement> {
|
||||
let menu: HTMLElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
menu = getBubbleMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
return menu!;
|
||||
}
|
||||
|
||||
/** Get a bubble menu button by aria-label */
|
||||
function getBubbleButton(menu: HTMLElement, label: string): HTMLButtonElement | null {
|
||||
return menu.querySelector(`[aria-label="${label}"]`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bubble Menu
|
||||
// =============================================================================
|
||||
|
||||
describe("Bubble Menu", () => {
|
||||
it("appears when text is selected", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
expect(menu).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows formatting buttons: Bold, Italic, Underline, Strikethrough, Code", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Italic")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Underline")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Strikethrough")).toBeTruthy();
|
||||
expect(getBubbleButton(menu, "Code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Add link button", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
expect(getBubbleButton(menu, "Add link")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles bold when Bold button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const boldBtn = getBubbleButton(menu, "Bold")!;
|
||||
|
||||
boldBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
});
|
||||
|
||||
// Verify the text is wrapped in <strong>
|
||||
expect(pm.querySelector("strong")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles italic when Italic button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const italicBtn = getBubbleButton(menu, "Italic")!;
|
||||
|
||||
italicBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles underline when Underline button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const underlineBtn = getBubbleButton(menu, "Underline")!;
|
||||
|
||||
underlineBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("underline")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("u")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles strikethrough when Strikethrough button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const strikeBtn = getBubbleButton(menu, "Strikethrough")!;
|
||||
|
||||
strikeBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("strike")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("s")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles inline code when Code button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const codeBtn = getBubbleButton(menu, "Code")!;
|
||||
|
||||
codeBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("code")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows link input when Add link button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const linkBtn = getBubbleButton(menu, "Add link")!;
|
||||
|
||||
linkBtn.click();
|
||||
|
||||
// The bubble menu should now show the link input
|
||||
await vi.waitFor(() => {
|
||||
const applyBtn = getBubbleButton(menu, "Apply link");
|
||||
expect(applyBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should have a URL input with placeholder
|
||||
const input = menu.querySelector('input[type="url"]');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies link URL when Apply button is clicked", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const linkBtn = getBubbleButton(menu, "Add link")!;
|
||||
linkBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Type a URL into the input
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
// Use native value setter + input event for React controlled input
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "https://example.com");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click Apply
|
||||
const applyBtn = getBubbleButton(menu, "Apply link")!;
|
||||
applyBtn.click();
|
||||
|
||||
// The editor should now have a link
|
||||
await vi.waitFor(() => {
|
||||
const link = pm.querySelector("a");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies link on Enter key in URL input", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Add link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "https://test.org");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Press Enter
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const link = pm.querySelector("a");
|
||||
expect(link).toBeTruthy();
|
||||
expect(link!.getAttribute("href")).toBe("https://test.org");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Edit link and Remove link buttons when cursor is on a link", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Should show "Edit link" instead of "Add link"
|
||||
expect(getBubbleButton(menu, "Edit link")).toBeTruthy();
|
||||
|
||||
// Click Edit link to show input
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("removes link when Remove link button is clicked", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Click Edit link to open link input mode
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Remove link")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click Remove link
|
||||
getBubbleButton(menu, "Remove link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes link input on Escape and returns focus to editor", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Add link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
// Press Escape
|
||||
await userEvent.keyboard("{Escape}");
|
||||
|
||||
// Should return to the formatting buttons view
|
||||
await vi.waitFor(() => {
|
||||
expect(getBubbleButton(menu, "Bold")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("unsets link when Apply is clicked with empty URL", async () => {
|
||||
const linkValue = [
|
||||
{
|
||||
_type: "block" as const,
|
||||
_key: "1",
|
||||
style: "normal" as const,
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: "s1",
|
||||
text: "Click here",
|
||||
marks: ["link1"],
|
||||
},
|
||||
],
|
||||
markDefs: [{ _type: "link", _key: "link1", href: "https://example.com" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { editor, pm } = await renderEditor({ value: linkValue });
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
getBubbleButton(menu, "Edit link")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(menu.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear the input
|
||||
const input = menu.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
input.focus();
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
nativeInputValueSetter.call(input, "");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
// Click Apply
|
||||
getBubbleButton(menu, "Apply link")!.click();
|
||||
|
||||
// Link should be removed
|
||||
await vi.waitFor(() => {
|
||||
expect(pm.querySelector("a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("can apply multiple formatting marks", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
|
||||
// Apply bold + italic
|
||||
getBubbleButton(menu, "Bold")!.click();
|
||||
getBubbleButton(menu, "Italic")!.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
expect(pm.querySelector("strong")).toBeTruthy();
|
||||
expect(pm.querySelector("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("toggles off formatting when clicked twice", async () => {
|
||||
const { editor, pm } = await renderEditor();
|
||||
await focusAndSelectAll(editor, pm);
|
||||
|
||||
const menu = await waitForBubbleMenu();
|
||||
const boldBtn = getBubbleButton(menu, "Bold")!;
|
||||
|
||||
// Apply bold
|
||||
boldBtn.click();
|
||||
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(true));
|
||||
|
||||
// Re-select (bold toggle may deselect)
|
||||
editor.commands.selectAll();
|
||||
|
||||
// Remove bold
|
||||
boldBtn.click();
|
||||
await vi.waitFor(() => expect(editor.isActive("bold")).toBe(false));
|
||||
|
||||
expect(pm.querySelector("strong")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user