Files
kunthawat 2d1be52177 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
2026-05-03 10:44:54 +07:00

619 lines
18 KiB
TypeScript

/**
* Slash command menu tests.
*
* Tests the "/" trigger, command filtering, keyboard navigation,
* command execution, and menu dismissal via Escape.
*
* The slash menu is internal to PortableTextEditor and driven by
* TipTap's Suggestion plugin. We test it through the full editor
* since there's no standalone export.
*/
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";
// ---------------------------------------------------------------------------
// 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": "" }];
},
});
const embedMeta: Record<string, { label: string }> = {
youtube: { label: "YouTube Video" },
vimeo: { label: "Vimeo" },
tweet: { label: "Tweet" },
};
return {
PluginBlockExtension,
getEmbedMeta: (type: string) => ({
label: embedMeta[type]?.label ?? "Embed",
Icon: () => null,
}),
registerPluginBlocks: () => {},
resolveIcon: () => () => null,
};
});
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WHITESPACE_SPLIT_REGEX = /\s+/;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Render the editor, wait for TipTap, return editor instance + ProseMirror element */
async function renderEditor(props: Partial<PortableTextEditorProps> = {}) {
let editorInstance: Editor | null = null;
const screen = await render(
<PortableTextEditor
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 */
async function focusEditor(pm: HTMLElement) {
pm.focus();
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
}
/** Get the slash menu portal element from document.body */
function getSlashMenu(): HTMLElement | null {
const portals = document.querySelectorAll("body > div");
for (const el of portals) {
if (el.querySelector("[data-index]") || el.textContent?.includes("No results")) {
return el as HTMLElement;
}
}
return null;
}
/** Wait for the slash menu to appear */
async function waitForSlashMenu(): Promise<HTMLElement> {
let menu: HTMLElement | null = null;
await vi.waitFor(
() => {
menu = getSlashMenu();
expect(menu).toBeTruthy();
},
{ timeout: 3000 },
);
return menu!;
}
/** Wait for the slash menu to disappear */
async function waitForSlashMenuClosed() {
await vi.waitFor(
() => {
expect(getSlashMenu()).toBeNull();
},
{ timeout: 3000 },
);
}
/** Get visible items in the slash menu */
function getSlashMenuItems(menu: HTMLElement): HTMLButtonElement[] {
return [...menu.querySelectorAll("button[data-index]")];
}
/**
* Check if an item is the selected/highlighted item.
* Selected items use "bg-kumo-tint text-kumo-default" (space-separated).
* Non-selected items use "hover:bg-kumo-tint/50".
*/
function isItemSelected(el: HTMLElement): boolean {
// Split className by spaces and check for exact "bg-kumo-tint" token
return el.className.split(WHITESPACE_SPLIT_REGEX).includes("bg-kumo-tint");
}
// =============================================================================
// Slash Command Menu
// =============================================================================
describe("Slash Command Menu", () => {
it("opens when typing / at the start of an empty line", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
// Default commands: heading1-3, bullet/numbered list, quote, code block, divider, image, section
expect(items.length).toBeGreaterThanOrEqual(8);
});
it("shows default block type commands", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Heading 1");
expect(titles).toContain("Heading 2");
expect(titles).toContain("Heading 3");
expect(titles).toContain("Bullet List");
expect(titles).toContain("Numbered List");
expect(titles).toContain("Quote");
expect(titles).toContain("Code Block");
expect(titles).toContain("Divider");
});
it("shows descriptions for each command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
for (const item of items) {
const description = item.querySelector(".text-xs");
expect(description).toBeTruthy();
expect(description!.textContent!.length).toBeGreaterThan(0);
}
});
it("filters commands by query text", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// Type filter text — Suggestion plugin watches text after "/"
await userEvent.keyboard("head");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles.length).toBeGreaterThanOrEqual(1);
expect(titles.every((t) => t.toLowerCase().includes("heading"))).toBe(true);
},
{ timeout: 3000 },
);
});
it("shows No results when no commands match", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("xyznonexistent");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
expect(menu!.textContent).toContain("No results");
},
{ timeout: 3000 },
);
});
it("highlights the first item by default", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
expect(isItemSelected(items[0]!)).toBe(true);
});
it("moves selection down with ArrowDown", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{ArrowDown}");
await vi.waitFor(() => {
const menu = getSlashMenu()!;
const items = getSlashMenuItems(menu);
expect(isItemSelected(items[1]!)).toBe(true);
expect(isItemSelected(items[0]!)).toBe(false);
});
});
it("moves selection up with ArrowUp from second item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// Move down, then back up
await userEvent.keyboard("{ArrowDown}");
await vi.waitFor(() => {
const items = getSlashMenuItems(getSlashMenu()!);
expect(isItemSelected(items[1]!)).toBe(true);
});
await userEvent.keyboard("{ArrowUp}");
await vi.waitFor(() => {
const items = getSlashMenuItems(getSlashMenu()!);
expect(isItemSelected(items[0]!)).toBe(true);
});
});
it("wraps selection around when pressing ArrowUp from first item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{ArrowUp}");
await vi.waitFor(() => {
const menu = getSlashMenu()!;
const items = getSlashMenuItems(menu);
const lastItem = items.at(-1)!;
expect(isItemSelected(lastItem)).toBe(true);
});
});
it("executes selected command on Enter and converts to heading", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// First item is "Heading 1"
await userEvent.keyboard("{Enter}");
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("h1")).toBeTruthy();
});
});
it("closes menu on Escape without executing", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("{Escape}");
await waitForSlashMenuClosed();
// Should still be a paragraph
expect(pm.querySelector("h1")).toBeNull();
});
it("executes command when clicking an item", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const quoteBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Quote",
);
expect(quoteBtn).toBeTruthy();
quoteBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("blockquote")).toBeTruthy();
});
});
it("inserts a code block via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const codeBlockBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Code Block",
);
expect(codeBlockBtn).toBeTruthy();
codeBlockBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("pre")).toBeTruthy();
});
});
it("inserts a horizontal rule via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const dividerBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Divider",
);
expect(dividerBtn).toBeTruthy();
dividerBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("hr")).toBeTruthy();
});
});
it("inserts bullet list via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const bulletBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Bullet List",
);
expect(bulletBtn).toBeTruthy();
bulletBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("ul")).toBeTruthy();
});
});
it("inserts numbered list via slash command", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const numberedBtn = items.find(
(btn) => btn.querySelector(".font-medium")?.textContent === "Numbered List",
);
expect(numberedBtn).toBeTruthy();
numberedBtn!.click();
await waitForSlashMenuClosed();
await vi.waitFor(() => {
expect(pm.querySelector("ol")).toBeTruthy();
});
});
it("highlights item on mouse hover", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
// React listens for pointerenter/mouseenter on the element.
// Use userEvent.hover which properly dispatches pointer + mouse events.
await userEvent.hover(items[2]!);
await vi.waitFor(() => {
const freshItems = getSlashMenuItems(menu);
expect(isItemSelected(freshItems[2]!)).toBe(true);
});
});
it("filters by alias (typing /h1 shows Heading 1)", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
await userEvent.keyboard("h1");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
expect(items.length).toBeGreaterThanOrEqual(1);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Heading 1");
},
{ timeout: 3000 },
);
});
it("includes Image and Section commands", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Image");
expect(titles).toContain("Section");
});
it("prioritises title matches over description matches when filtering", async () => {
const { editor, pm } = await renderEditor();
await focusEditor(pm);
editor.commands.insertContent("/");
await waitForSlashMenu();
// "sec" matches "Section" by title and headings by description ("section heading")
await userEvent.keyboard("sec");
await vi.waitFor(
() => {
const menu = getSlashMenu();
expect(menu).toBeTruthy();
const items = getSlashMenuItems(menu!);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles.length).toBeGreaterThan(1);
expect(titles[0]).toBe("Section");
},
{ timeout: 3000 },
);
});
it("includes plugin block commands when provided", async () => {
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "test-plugin",
type: "youtube",
label: "YouTube Video",
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("YouTube Video");
});
it("renders plugin block commands with a custom category override", async () => {
// A plugin block that opts into the "Sections" category instead of the
// default "Embeds". The category itself isn't currently surfaced in the
// rendered DOM (the slash menu doesn't group by category), but providing
// it must not break rendering and the block must still be selectable.
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "marketing-blocks",
type: "marketing.hero",
label: "Hero",
category: "Sections",
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Hero");
});
it("renders plugin block commands without a category (default Embeds)", async () => {
// Existing plugins that omit `category` must continue to render under
// the default category. This guards against regressions in the type
// widening / fallback behaviour.
const { editor, pm } = await renderEditor({
pluginBlocks: [
{
pluginId: "test-plugin",
type: "vimeo",
label: "Vimeo",
// no category provided
},
],
});
await focusEditor(pm);
editor.commands.insertContent("/");
const menu = await waitForSlashMenu();
const items = getSlashMenuItems(menu);
const titles = items.map((btn) => btn.querySelector(".font-medium")?.textContent ?? "");
expect(titles).toContain("Vimeo");
});
});