first commit
This commit is contained in:
161
packages/admin/tests/editor/DocumentOutline.test.tsx
Normal file
161
packages/admin/tests/editor/DocumentOutline.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
extractHeadings,
|
||||
findCurrentHeading,
|
||||
type HeadingItem,
|
||||
} from "../../src/components/editor/DocumentOutline";
|
||||
|
||||
/**
|
||||
* Create a mock editor with a document containing the specified headings
|
||||
*/
|
||||
function createMockEditor(headings: Array<{ level: number; text: string; pos: number }>) {
|
||||
const mockDoc = {
|
||||
descendants: (
|
||||
callback: (
|
||||
node: { type: { name: string }; attrs: { level: number }; textContent: string },
|
||||
pos: number,
|
||||
) => void,
|
||||
) => {
|
||||
for (const heading of headings) {
|
||||
callback(
|
||||
{
|
||||
type: { name: "heading" },
|
||||
attrs: { level: heading.level },
|
||||
textContent: heading.text,
|
||||
},
|
||||
heading.pos,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
state: {
|
||||
doc: mockDoc,
|
||||
},
|
||||
} as unknown as Parameters<typeof extractHeadings>[0];
|
||||
}
|
||||
|
||||
describe("DocumentOutline", () => {
|
||||
describe("extractHeadings", () => {
|
||||
it("returns empty array when editor is null", () => {
|
||||
const result = extractHeadings(null);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts headings from editor document", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Main Title", pos: 0 },
|
||||
{ level: 2, text: "Section One", pos: 50 },
|
||||
{ level: 3, text: "Subsection", pos: 100 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toMatchObject({
|
||||
level: 1,
|
||||
text: "Main Title",
|
||||
pos: 0,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
level: 2,
|
||||
text: "Section One",
|
||||
pos: 50,
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
level: 3,
|
||||
text: "Subsection",
|
||||
pos: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips headings with empty text", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Title", pos: 0 },
|
||||
{ level: 2, text: "", pos: 50 },
|
||||
{ level: 2, text: " ", pos: 100 },
|
||||
{ level: 2, text: "Valid", pos: 150 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.text).toBe("Title");
|
||||
expect(result[1]?.text).toBe("Valid");
|
||||
});
|
||||
|
||||
it("assigns unique keys to headings", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "Title", pos: 0 },
|
||||
{ level: 2, text: "Section", pos: 50 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
expect(result[0]?.key).toBeDefined();
|
||||
expect(result[1]?.key).toBeDefined();
|
||||
expect(result[0]?.key).not.toBe(result[1]?.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCurrentHeading", () => {
|
||||
const headings: HeadingItem[] = [
|
||||
{ level: 1, text: "Title", pos: 0, key: "h1" },
|
||||
{ level: 2, text: "Section One", pos: 100, key: "h2" },
|
||||
{ level: 2, text: "Section Two", pos: 200, key: "h3" },
|
||||
{ level: 3, text: "Subsection", pos: 300, key: "h4" },
|
||||
];
|
||||
|
||||
it("returns null for empty headings array", () => {
|
||||
const result = findCurrentHeading([], 50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when cursor is before first heading", () => {
|
||||
const headingsWithOffset: HeadingItem[] = [{ level: 1, text: "Title", pos: 100, key: "h1" }];
|
||||
const result = findCurrentHeading(headingsWithOffset, 50);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns first heading when cursor is at its position", () => {
|
||||
const result = findCurrentHeading(headings, 0);
|
||||
expect(result?.key).toBe("h1");
|
||||
});
|
||||
|
||||
it("returns heading that contains cursor position", () => {
|
||||
const result = findCurrentHeading(headings, 150);
|
||||
expect(result?.key).toBe("h2");
|
||||
});
|
||||
|
||||
it("returns last heading when cursor is past all headings", () => {
|
||||
const result = findCurrentHeading(headings, 500);
|
||||
expect(result?.key).toBe("h4");
|
||||
});
|
||||
|
||||
it("returns heading when cursor is exactly at heading position", () => {
|
||||
const result = findCurrentHeading(headings, 200);
|
||||
expect(result?.key).toBe("h3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("indentation", () => {
|
||||
it("correctly structures heading levels", () => {
|
||||
const editor = createMockEditor([
|
||||
{ level: 1, text: "H1", pos: 0 },
|
||||
{ level: 2, text: "H2", pos: 50 },
|
||||
{ level: 3, text: "H3", pos: 100 },
|
||||
]);
|
||||
|
||||
const result = extractHeadings(editor);
|
||||
|
||||
// H1 should be at root level
|
||||
expect(result[0]?.level).toBe(1);
|
||||
// H2 should be indented (level 2)
|
||||
expect(result[1]?.level).toBe(2);
|
||||
// H3 should be further indented (level 3)
|
||||
expect(result[2]?.level).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
815
packages/admin/tests/editor/PortableTextEditor.test.tsx
Normal file
815
packages/admin/tests/editor/PortableTextEditor.test.tsx
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* PortableTextEditor component tests.
|
||||
*
|
||||
* Tests the TipTap-based rich text editor in vitest browser mode,
|
||||
* covering Portable Text ↔ ProseMirror round-trip conversion,
|
||||
* toolbar behaviour, focus modes, and editor lifecycle.
|
||||
*/
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
|
||||
import type { PluginBlockDef } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — heavy components that need network / Astro context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 SPOTLIGHT_MODE_PATTERN = /Spotlight Mode/i;
|
||||
|
||||
/** Wait for the ProseMirror editor to mount inside the container */
|
||||
async function waitForEditor(): Promise<HTMLElement> {
|
||||
let pm: HTMLElement | null = null;
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
pm = document.querySelector(".ProseMirror") as HTMLElement | null;
|
||||
expect(pm).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
return pm!;
|
||||
}
|
||||
|
||||
/** Focus the ProseMirror contenteditable and wait for it to be focused */
|
||||
async function focusEditor(pm: HTMLElement) {
|
||||
pm.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(pm), { timeout: 1000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the editor, wait for it to initialize, and return the Editor instance.
|
||||
* Useful for tests that need to type or manipulate content programmatically.
|
||||
*/
|
||||
async function renderAndGetEditor(props: Partial<Parameters<typeof PortableTextEditor>[0]> = {}) {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
return { screen, editor: capturedEditor!, pm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate typing text into the editor via TipTap's API.
|
||||
* This avoids browser keyboard API issues and is more reliable in tests.
|
||||
*/
|
||||
function typeIntoEditor(editor: Editor, text: string) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
}
|
||||
|
||||
// Shorthand block builders
|
||||
function textBlock(
|
||||
text: string,
|
||||
opts: {
|
||||
style?: "normal" | "h1" | "h2" | "h3" | "blockquote";
|
||||
marks?: string[];
|
||||
listItem?: "bullet" | "number";
|
||||
level?: number;
|
||||
markDefs?: Array<{ _type: string; _key: string; [k: string]: unknown }>;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
_type: "block" as const,
|
||||
_key: Math.random().toString(36).slice(2, 9),
|
||||
style: opts.style ?? "normal",
|
||||
...(opts.listItem ? { listItem: opts.listItem, level: opts.level ?? 1 } : {}),
|
||||
children: [
|
||||
{
|
||||
_type: "span" as const,
|
||||
_key: Math.random().toString(36).slice(2, 9),
|
||||
text,
|
||||
marks: opts.marks,
|
||||
},
|
||||
],
|
||||
markDefs: opts.markDefs,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Portable Text ↔ ProseMirror Conversion (via component)
|
||||
// =============================================================================
|
||||
|
||||
describe("Portable Text ↔ ProseMirror conversion", () => {
|
||||
it("renders a paragraph from PT value", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Hello world")]} />);
|
||||
const pm = await waitForEditor();
|
||||
const p = pm.querySelector("p");
|
||||
expect(p).toBeTruthy();
|
||||
expect(p!.textContent).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("renders an h1 heading", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Title", { style: "h1" })]} />);
|
||||
const pm = await waitForEditor();
|
||||
const h1 = pm.querySelector("h1");
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent).toBe("Title");
|
||||
});
|
||||
|
||||
it("renders bold text", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Bold text", { marks: ["strong"] })]} />);
|
||||
const pm = await waitForEditor();
|
||||
const strong = pm.querySelector("strong");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(strong!.textContent).toBe("Bold text");
|
||||
});
|
||||
|
||||
it("renders a link from markDef", async () => {
|
||||
const linkKey = "lnk1";
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Click me", {
|
||||
marks: [linkKey],
|
||||
markDefs: [{ _type: "link", _key: linkKey, href: "https://example.com" }],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const anchor = pm.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor!.textContent).toBe("Click me");
|
||||
expect(anchor!.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("renders a bullet list", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Item one", { listItem: "bullet" }),
|
||||
textBlock("Item two", { listItem: "bullet" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const ul = pm.querySelector("ul");
|
||||
expect(ul).toBeTruthy();
|
||||
const items = ul!.querySelectorAll("li");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0]!.textContent).toBe("Item one");
|
||||
expect(items[1]!.textContent).toBe("Item two");
|
||||
});
|
||||
|
||||
it("renders an ordered list", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("First", { listItem: "number" }),
|
||||
textBlock("Second", { listItem: "number" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const ol = pm.querySelector("ol");
|
||||
expect(ol).toBeTruthy();
|
||||
const items = ol!.querySelectorAll("li");
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it("renders a blockquote", async () => {
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("A wise quote", { style: "blockquote" })]} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const bq = pm.querySelector("blockquote");
|
||||
expect(bq).toBeTruthy();
|
||||
expect(bq!.textContent).toBe("A wise quote");
|
||||
});
|
||||
|
||||
it("renders a code block", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[{ _type: "code", _key: "c1", code: "const x = 1", language: "js" }]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const pre = pm.querySelector("pre");
|
||||
expect(pre).toBeTruthy();
|
||||
expect(pre!.textContent).toContain("const x = 1");
|
||||
});
|
||||
|
||||
it("renders an image block", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
{
|
||||
_type: "image",
|
||||
_key: "img1",
|
||||
asset: { _ref: "img-1", url: "/test.jpg" },
|
||||
alt: "Test image",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
// The mock ImageExtension renders as <img>
|
||||
const img = pm.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img!.getAttribute("src")).toBe("/test.jpg");
|
||||
});
|
||||
|
||||
it("renders a horizontal rule", async () => {
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={[
|
||||
textBlock("Above"),
|
||||
{ _type: "break", _key: "hr1", style: "lineBreak" },
|
||||
textBlock("Below"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const hr = pm.querySelector("hr");
|
||||
expect(hr).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty editor when value is empty array", async () => {
|
||||
await render(<PortableTextEditor value={[]} placeholder="Write here..." />);
|
||||
const pm = await waitForEditor();
|
||||
// Empty editor should have a single empty paragraph
|
||||
const paragraphs = pm.querySelectorAll("p");
|
||||
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
||||
// Placeholder should appear
|
||||
expect(pm.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders empty editor when value is undefined", async () => {
|
||||
await render(<PortableTextEditor placeholder="Start..." />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
// Empty editor — no meaningful text
|
||||
const textContent = pm.textContent ?? "";
|
||||
expect(textContent.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("renders bold+italic text with multiple marks", async () => {
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("Bold italic", { marks: ["strong", "em"] })]} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
const strong = pm.querySelector("strong");
|
||||
const em = pm.querySelector("em");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(em).toBeTruthy();
|
||||
// The text is wrapped in both marks
|
||||
expect(pm.textContent).toContain("Bold italic");
|
||||
});
|
||||
|
||||
it("fires onChange with valid PT blocks when typing", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({ onChange });
|
||||
|
||||
typeIntoEditor(editor, "Hello");
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
const lastCall = onChange.mock.calls.at(-1)!;
|
||||
const blocks = lastCall[0] as Array<{ _type: string }>;
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
expect(blocks[0]!._type).toBe("block");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Editor Component Behaviour
|
||||
// =============================================================================
|
||||
|
||||
describe("Editor component behaviour", () => {
|
||||
it("shows placeholder text in empty editor", async () => {
|
||||
await render(<PortableTextEditor placeholder="Write something..." />);
|
||||
const pm = await waitForEditor();
|
||||
// TipTap sets placeholder via data-placeholder or a .is-empty class
|
||||
// Check for the placeholder content in a before pseudo-element or attribute
|
||||
const placeholderEl = pm.querySelector("[data-placeholder]");
|
||||
if (placeholderEl) {
|
||||
expect(placeholderEl.getAttribute("data-placeholder")).toBe("Write something...");
|
||||
} else {
|
||||
// Fallback: check the class-based placeholder
|
||||
const emptyNode = pm.querySelector(".is-empty, .is-editor-empty");
|
||||
expect(emptyNode).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("sets contenteditable=false when editable is false", async () => {
|
||||
await render(<PortableTextEditor editable={false} value={[textBlock("Read only")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm.getAttribute("contenteditable")).toBe("false");
|
||||
});
|
||||
|
||||
it("sets contenteditable=true by default", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Editable")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm.getAttribute("contenteditable")).toBe("true");
|
||||
});
|
||||
|
||||
it("applies spotlight-mode class when focusMode is spotlight", async () => {
|
||||
await render(<PortableTextEditor focusMode="spotlight" value={[textBlock("Focused")]} />);
|
||||
await waitForEditor();
|
||||
const wrapper = document.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not apply spotlight-mode class when focusMode is normal", async () => {
|
||||
await render(<PortableTextEditor focusMode="normal" value={[textBlock("Normal")]} />);
|
||||
await waitForEditor();
|
||||
const wrapper = document.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onFocusModeChange when spotlight button is clicked", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="normal"
|
||||
onFocusModeChange={onFocusModeChange}
|
||||
value={[textBlock("Test")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
// The spotlight button has aria-label containing "Spotlight Mode"
|
||||
const spotlightBtn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await spotlightBtn.click();
|
||||
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
|
||||
});
|
||||
|
||||
it("hides toolbar and footer in minimal mode", async () => {
|
||||
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
|
||||
await waitForEditor();
|
||||
// Toolbar has role="toolbar" — should not exist
|
||||
const toolbar = document.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
// Footer shows word count — should not exist
|
||||
const footer = document.querySelector(".border-t");
|
||||
expect(footer).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onEditorReady with Editor instance", async () => {
|
||||
const onEditorReady = vi.fn();
|
||||
await render(<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Ready")]} />);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
|
||||
|
||||
const editorArg = onEditorReady.mock.calls[0]![0] as Editor;
|
||||
expect(editorArg).toBeTruthy();
|
||||
expect(typeof editorArg.getJSON).toBe("function");
|
||||
expect(typeof editorArg.chain).toBe("function");
|
||||
});
|
||||
|
||||
it("shows word count and character count in footer", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("One two three")]} />);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("words");
|
||||
expect(text).toContain("characters");
|
||||
expect(text).toContain("min read");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. Toolbar
|
||||
// =============================================================================
|
||||
|
||||
describe("Toolbar", () => {
|
||||
async function renderWithToolbar() {
|
||||
const screen = await render(<PortableTextEditor value={[textBlock("Toolbar test")]} />);
|
||||
await waitForEditor();
|
||||
return screen;
|
||||
}
|
||||
|
||||
it("renders a toolbar with text formatting aria-label", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
|
||||
});
|
||||
|
||||
it("has inline formatting buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has heading buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has list buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has block buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has alignment buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has insert buttons", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeInTheDocument();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has history buttons (initially disabled)", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const undoBtn = screen.getByRole("button", { name: "Undo" });
|
||||
const redoBtn = screen.getByRole("button", { name: "Redo" });
|
||||
await expect.element(undoBtn).toBeInTheDocument();
|
||||
await expect.element(redoBtn).toBeInTheDocument();
|
||||
await expect.element(undoBtn).toBeDisabled();
|
||||
await expect.element(redoBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("has spotlight mode button", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles bold aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const boldBtn = screen.getByRole("button", { name: "Bold" });
|
||||
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await boldBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(boldBtn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles italic aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const italicBtn = screen.getByRole("button", { name: "Italic" });
|
||||
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await italicBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(italicBtn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Heading 1 aria-pressed when clicked", async () => {
|
||||
const screen = await renderWithToolbar();
|
||||
const pm = document.querySelector(".ProseMirror") as HTMLElement;
|
||||
await focusEditor(pm);
|
||||
|
||||
const h1Btn = screen.getByRole("button", { name: "Heading 1" });
|
||||
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await h1Btn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(h1Btn).toHaveAttribute("aria-pressed", "true");
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("enables Undo after typing and Redo after undoing", async () => {
|
||||
let editorRef: Editor | null = null;
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
value={[textBlock("Toolbar test")]}
|
||||
onEditorReady={(editor) => {
|
||||
editorRef = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
await vi.waitFor(() => expect(editorRef).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const undoBtn = screen.getByRole("button", { name: "Undo" });
|
||||
const redoBtn = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
// Initially both disabled
|
||||
await expect.element(undoBtn).toBeDisabled();
|
||||
await expect.element(redoBtn).toBeDisabled();
|
||||
|
||||
// Type something via editor API
|
||||
typeIntoEditor(editorRef!, "Some text");
|
||||
|
||||
// Undo should become enabled
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(undoBtn).toBeEnabled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Click undo
|
||||
await undoBtn.click();
|
||||
|
||||
// Redo should become enabled
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
await expect.element(redoBtn).toBeEnabled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles spotlight mode button aria-pressed", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="normal"
|
||||
onFocusModeChange={onFocusModeChange}
|
||||
value={[textBlock("Test")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("spotlight button shows pressed when focusMode is spotlight", async () => {
|
||||
const screen = await render(
|
||||
<PortableTextEditor
|
||||
focusMode="spotlight"
|
||||
onFocusModeChange={() => {}}
|
||||
value={[textBlock("Focused")]}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
const btn = screen.getByRole("button", { name: SPOTLIGHT_MODE_PATTERN });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("toolbar not present in minimal mode", async () => {
|
||||
await render(<PortableTextEditor minimal={true} value={[textBlock("Minimal")]} />);
|
||||
await waitForEditor();
|
||||
const toolbar = document.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. Slash Commands
|
||||
// =============================================================================
|
||||
|
||||
describe("Slash commands", () => {
|
||||
it("renders without errors with default commands", async () => {
|
||||
await render(<PortableTextEditor value={[textBlock("Slash test")]} />);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders without errors with pluginBlocks prop", async () => {
|
||||
const pluginBlocks: PluginBlockDef[] = [
|
||||
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
|
||||
{ type: "tweet", pluginId: "social", label: "Tweet" },
|
||||
];
|
||||
await render(
|
||||
<PortableTextEditor value={[textBlock("Plugin test")]} pluginBlocks={pluginBlocks} />,
|
||||
);
|
||||
const pm = await waitForEditor();
|
||||
expect(pm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("editor accepts pluginBlocks without crashing when typing", async () => {
|
||||
const pluginBlocks: PluginBlockDef[] = [
|
||||
{ type: "youtube", pluginId: "embeds", label: "YouTube Video" },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({
|
||||
pluginBlocks,
|
||||
onChange,
|
||||
});
|
||||
|
||||
typeIntoEditor(editor, "Hello");
|
||||
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 5. Round-trip: onChange output shape
|
||||
// =============================================================================
|
||||
|
||||
describe("onChange output shape", () => {
|
||||
it("onChange returns blocks with _type and _key", async () => {
|
||||
const onChange = vi.fn();
|
||||
const { editor } = await renderAndGetEditor({ onChange });
|
||||
|
||||
typeIntoEditor(editor, "Test");
|
||||
|
||||
await vi.waitFor(() => expect(onChange).toHaveBeenCalled(), { timeout: 2000 });
|
||||
|
||||
const blocks = onChange.mock.calls.at(-1)![0] as Array<{
|
||||
_type: string;
|
||||
_key: string;
|
||||
children?: Array<{ _type: string; text: string }>;
|
||||
}>;
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
const block = blocks[0]!;
|
||||
expect(block._type).toBe("block");
|
||||
expect(typeof block._key).toBe("string");
|
||||
expect(block.children).toBeDefined();
|
||||
expect(block.children!.length).toBeGreaterThan(0);
|
||||
expect(block.children![0]!._type).toBe("span");
|
||||
expect(block.children![0]!.text).toContain("Test");
|
||||
});
|
||||
|
||||
it("heading value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [textBlock("My Heading", { style: "h1" })];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
// Verify the editor has a heading node
|
||||
const json = capturedEditor!.getJSON();
|
||||
const headingNode = json.content?.find((n: { type: string }) => n.type === "heading");
|
||||
expect(headingNode).toBeTruthy();
|
||||
expect((headingNode as { attrs?: { level?: number } }).attrs?.level).toBe(1);
|
||||
});
|
||||
|
||||
it("code block value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [{ _type: "code" as const, _key: "c1", code: "let a = 1;", language: "js" }];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const json = capturedEditor!.getJSON();
|
||||
const codeNode = json.content?.find((n: { type: string }) => n.type === "codeBlock");
|
||||
expect(codeNode).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list value roundtrips through onEditorReady", async () => {
|
||||
let capturedEditor: Editor | null = null;
|
||||
const value = [
|
||||
textBlock("Alpha", { listItem: "bullet" }),
|
||||
textBlock("Beta", { listItem: "bullet" }),
|
||||
];
|
||||
|
||||
await render(
|
||||
<PortableTextEditor
|
||||
value={value}
|
||||
onEditorReady={(editor) => {
|
||||
capturedEditor = editor;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await waitForEditor();
|
||||
|
||||
await vi.waitFor(() => expect(capturedEditor).toBeTruthy(), { timeout: 2000 });
|
||||
|
||||
const json = capturedEditor!.getJSON();
|
||||
const listNode = json.content?.find((n: { type: string }) => n.type === "bulletList");
|
||||
expect(listNode).toBeTruthy();
|
||||
});
|
||||
});
|
||||
484
packages/admin/tests/editor/block-menu.test.tsx
Normal file
484
packages/admin/tests/editor/block-menu.test.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* 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 { render } from "vitest-browser-react";
|
||||
|
||||
import { BlockMenu } from "../../src/components/editor/BlockMenu";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
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 { render } from "vitest-browser-react";
|
||||
|
||||
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
226
packages/admin/tests/editor/input-rules.test.ts
Normal file
226
packages/admin/tests/editor/input-rules.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Input Rules Tests for TipTap Editor
|
||||
*
|
||||
* Tests that Markdown-style shortcuts work correctly in the editor.
|
||||
*
|
||||
* TipTap input rules are triggered by actual text input, not insertContent().
|
||||
* In headless tests, we simulate this by using the inputRules extension's
|
||||
* run function or by testing the resulting transformations.
|
||||
*
|
||||
* For integration testing, we verify the editor has the correct extensions
|
||||
* configured and that the expected node/mark types exist.
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
describe("Editor Input Rules", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Typography,
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Editor extension configuration", () => {
|
||||
it("has heading extension with levels 1-3", () => {
|
||||
const headingExtension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "heading",
|
||||
);
|
||||
expect(headingExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bulletList extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bulletList");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has orderedList extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "orderedList",
|
||||
);
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has blockquote extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "blockquote");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has codeBlock extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "codeBlock");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has horizontalRule extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "horizontalRule",
|
||||
);
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bold extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "bold");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has italic extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "italic");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has code extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "code");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has strike extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "strike");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it("has typography extension", () => {
|
||||
const extension = editor.extensionManager.extensions.find((ext) => ext.name === "typography");
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Commands work correctly", () => {
|
||||
it("can toggle heading 1", () => {
|
||||
editor.commands.setHeading({ level: 1 });
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle heading 2", () => {
|
||||
editor.commands.setHeading({ level: 2 });
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle heading 3", () => {
|
||||
editor.commands.setHeading({ level: 3 });
|
||||
expect(editor.isActive("heading", { level: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle bullet list", () => {
|
||||
editor.commands.toggleBulletList();
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle ordered list", () => {
|
||||
editor.commands.toggleOrderedList();
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle blockquote", () => {
|
||||
editor.commands.toggleBlockquote();
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle code block", () => {
|
||||
editor.commands.toggleCodeBlock();
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
});
|
||||
|
||||
it("can insert horizontal rule", () => {
|
||||
editor.commands.setHorizontalRule();
|
||||
const json = editor.getJSON();
|
||||
const hasHR = json.content?.some((node) => node.type === "horizontalRule");
|
||||
expect(hasHR).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle bold", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleBold();
|
||||
expect(editor.isActive("bold")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle italic", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleItalic();
|
||||
expect(editor.isActive("italic")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle code", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleCode();
|
||||
expect(editor.isActive("code")).toBe(true);
|
||||
});
|
||||
|
||||
it("can toggle strike", () => {
|
||||
editor.commands.insertContent("test");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleStrike();
|
||||
expect(editor.isActive("strike")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema has correct node types", () => {
|
||||
it("has heading node type", () => {
|
||||
expect(editor.schema.nodes.heading).toBeDefined();
|
||||
});
|
||||
|
||||
it("has bulletList node type", () => {
|
||||
expect(editor.schema.nodes.bulletList).toBeDefined();
|
||||
});
|
||||
|
||||
it("has orderedList node type", () => {
|
||||
expect(editor.schema.nodes.orderedList).toBeDefined();
|
||||
});
|
||||
|
||||
it("has blockquote node type", () => {
|
||||
expect(editor.schema.nodes.blockquote).toBeDefined();
|
||||
});
|
||||
|
||||
it("has codeBlock node type", () => {
|
||||
expect(editor.schema.nodes.codeBlock).toBeDefined();
|
||||
});
|
||||
|
||||
it("has horizontalRule node type", () => {
|
||||
expect(editor.schema.nodes.horizontalRule).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema has correct mark types", () => {
|
||||
it("has bold mark type", () => {
|
||||
expect(editor.schema.marks.bold).toBeDefined();
|
||||
});
|
||||
|
||||
it("has italic mark type", () => {
|
||||
expect(editor.schema.marks.italic).toBeDefined();
|
||||
});
|
||||
|
||||
it("has code mark type", () => {
|
||||
expect(editor.schema.marks.code).toBeDefined();
|
||||
});
|
||||
|
||||
it("has strike mark type", () => {
|
||||
expect(editor.schema.marks.strike).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input rules are registered", () => {
|
||||
it("has input rules plugin", () => {
|
||||
// StarterKit registers input rules through individual extensions
|
||||
// We verify by checking extensions have inputRules defined
|
||||
const extensions = editor.extensionManager.extensions;
|
||||
const headingExt = extensions.find((e) => e.name === "heading");
|
||||
expect(headingExt).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
303
packages/admin/tests/editor/markdown-link.test.ts
Normal file
303
packages/admin/tests/editor/markdown-link.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Markdown Link Extension Tests
|
||||
*
|
||||
* Tests the MarkdownLinkExtension which converts [text](url) syntax
|
||||
* into proper link marks:
|
||||
* - Extension registration and input/paste rule presence
|
||||
* - Input rule converts typed [text](url) to a linked text node
|
||||
* - Paste rule converts pasted markdown links inline
|
||||
* - Disallowed protocols (javascript:) are rejected
|
||||
* - Edge cases: empty text, empty href, whitespace trimming
|
||||
* - No conflict with StarterKit's Link extension commands
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { MarkdownLinkExtension } from "../../src/components/editor/MarkdownLinkExtension";
|
||||
|
||||
/**
|
||||
* Simulate typing text character-by-character into the editor.
|
||||
*
|
||||
* TipTap InputRules only fire on the `handleTextInput` view prop,
|
||||
* which requires dispatching through the EditorView — insertContent()
|
||||
* bypasses it. We call `view.someProp("handleTextInput", ...)` for
|
||||
* each character, matching how ProseMirror's input rules plugin works.
|
||||
*/
|
||||
function simulateTyping(editor: Editor, text: string) {
|
||||
for (const char of text) {
|
||||
const { from, to } = editor.state.selection;
|
||||
const deflt = () => editor.state.tr.insertText(char, from, to);
|
||||
const handled = editor.view.someProp("handleTextInput", (f) =>
|
||||
f(editor.view, from, to, char, deflt),
|
||||
);
|
||||
if (!handled) {
|
||||
// If no input rule consumed it, insert as plain text
|
||||
editor.view.dispatch(deflt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate pasting plain text into the editor.
|
||||
*
|
||||
* Uses ProseMirror's `pasteText()` which runs through the full paste
|
||||
* pipeline including TipTap's PasteRule handlers.
|
||||
*/
|
||||
function simulatePaste(editor: Editor, text: string) {
|
||||
editor.view.pasteText(text);
|
||||
}
|
||||
|
||||
/** Extract all link marks from the editor doc for assertions. */
|
||||
function extractLinks(editor: Editor): Array<{ text: string; href: string }> {
|
||||
const links: Array<{ text: string; href: string }> = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.isText) {
|
||||
const linkMark = node.marks.find((m) => m.type.name === "link");
|
||||
if (linkMark) {
|
||||
links.push({ text: node.text || "", href: linkMark.attrs.href });
|
||||
}
|
||||
}
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
describe("Markdown Link Extension", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
},
|
||||
}),
|
||||
MarkdownLinkExtension,
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Extension registration", () => {
|
||||
it("registers the markdownLink extension", () => {
|
||||
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
|
||||
expect(ext).toBeDefined();
|
||||
});
|
||||
|
||||
it("has the link mark type from StarterKit", () => {
|
||||
expect(editor.schema.marks.link).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers input rules", () => {
|
||||
const ext = editor.extensionManager.extensions.find((e) => e.name === "markdownLink");
|
||||
expect(ext).toBeDefined();
|
||||
// The extension defines addInputRules, verify it produced rules
|
||||
const plugins = editor.state.plugins;
|
||||
const inputRulesPlugin = plugins.find(
|
||||
(p) => (p.spec as Record<string, unknown>).isInputRules,
|
||||
);
|
||||
expect(inputRulesPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input rule — typed markdown links", () => {
|
||||
it("converts [text](url) to a link mark on closing paren", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("converts [text](url) with preceding text", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "Visit [Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
|
||||
// Preceding text should still be present as plain text
|
||||
const plainText = editor.getText();
|
||||
expect(plainText).toContain("Visit");
|
||||
expect(plainText).toContain("Example");
|
||||
});
|
||||
|
||||
it("trims whitespace from href", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[Docs]( https://docs.example.com )");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("https://docs.example.com");
|
||||
});
|
||||
|
||||
it("preserves link text with spaces", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[My Cool Link](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.text).toBe("My Cool Link");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paste rule — pasted markdown links", () => {
|
||||
it("converts a pasted [text](url) to a link mark", () => {
|
||||
editor.commands.focus();
|
||||
simulatePaste(editor, "[Example](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "Example", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("converts multiple markdown links in pasted text", () => {
|
||||
editor.commands.focus();
|
||||
simulatePaste(editor, "See [Foo](https://foo.com) and [Bar](https://bar.com).");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(2);
|
||||
expect(links[0]).toEqual({ text: "Foo", href: "https://foo.com" });
|
||||
expect(links[1]).toEqual({ text: "Bar", href: "https://bar.com" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Protocol allowlist — rejects disallowed URIs", () => {
|
||||
it("rejects javascript: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[click me](javascript:alert(1))");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
// The raw markdown syntax should remain as literal text
|
||||
expect(editor.getText()).toContain("[click me](javascript:alert(1))");
|
||||
});
|
||||
|
||||
it("rejects data: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[click me](data:text/html,<script>alert(1)</script>)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows https: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[safe](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows http: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[safe](http://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows relative paths", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[page](/about)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("/about");
|
||||
});
|
||||
|
||||
it("allows mailto: protocol", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[email](mailto:user@example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("does not convert when link text is empty", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not convert when href is empty", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[text]()");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles special characters in link text", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[it's a <test> & more](https://example.com)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.text).toBe("it's a <test> & more");
|
||||
});
|
||||
|
||||
it("handles query strings and fragments in URL", () => {
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[search](https://example.com/path?q=hello&lang=en#section)");
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]!.href).toBe("https://example.com/path?q=hello&lang=en#section");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coexistence with StarterKit Link extension", () => {
|
||||
it("can still set links via the Link command API", () => {
|
||||
editor.commands.insertContent("click here");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.setLink({ href: "https://example.com" });
|
||||
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toEqual({ text: "click here", href: "https://example.com" });
|
||||
});
|
||||
|
||||
it("can unset links via the Link command API", () => {
|
||||
editor.commands.insertContent("click here");
|
||||
editor.commands.selectAll();
|
||||
editor.commands.setLink({ href: "https://example.com" });
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
|
||||
editor.commands.unsetLink();
|
||||
expect(editor.isActive("link")).toBe(false);
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("markdown link input does not break subsequent link commands", () => {
|
||||
// First, create a link via markdown syntax
|
||||
editor.commands.focus();
|
||||
simulateTyping(editor, "[md link](https://md.example.com) ");
|
||||
|
||||
// Then create a link via command API
|
||||
editor.commands.insertContent("cmd link");
|
||||
// Select just the "cmd link" text
|
||||
const docSize = editor.state.doc.content.size;
|
||||
editor.commands.setTextSelection({ from: docSize - 8, to: docSize - 1 });
|
||||
editor.commands.setLink({ href: "https://cmd.example.com" });
|
||||
|
||||
const links = extractLinks(editor);
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/admin/tests/editor/metrics.test.tsx
Normal file
40
packages/admin/tests/editor/metrics.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { calculateReadingTime } from "../../src/components/PortableTextEditor";
|
||||
|
||||
describe("Editor Metrics", () => {
|
||||
describe("calculateReadingTime", () => {
|
||||
it("returns 0 minutes for empty document", () => {
|
||||
expect(calculateReadingTime(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 minute for less than 200 words", () => {
|
||||
expect(calculateReadingTime(1)).toBe(1);
|
||||
expect(calculateReadingTime(100)).toBe(1);
|
||||
expect(calculateReadingTime(199)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 minute for exactly 200 words", () => {
|
||||
expect(calculateReadingTime(200)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 2 minutes for 201-400 words", () => {
|
||||
expect(calculateReadingTime(201)).toBe(2);
|
||||
expect(calculateReadingTime(300)).toBe(2);
|
||||
expect(calculateReadingTime(400)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns correct reading time for larger documents", () => {
|
||||
expect(calculateReadingTime(1000)).toBe(5);
|
||||
expect(calculateReadingTime(1001)).toBe(6);
|
||||
expect(calculateReadingTime(2000)).toBe(10);
|
||||
});
|
||||
|
||||
it("always rounds up (ceil)", () => {
|
||||
// 201 / 200 = 1.005, ceil = 2
|
||||
expect(calculateReadingTime(201)).toBe(2);
|
||||
// 401 / 200 = 2.005, ceil = 3
|
||||
expect(calculateReadingTime(401)).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/admin/tests/editor/plugin-block-conversion.test.ts
Normal file
275
packages/admin/tests/editor/plugin-block-conversion.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Plugin Block Conversion Tests
|
||||
*
|
||||
* Tests the Portable Text ↔ ProseMirror conversion for plugin blocks (embeds).
|
||||
* Covers round-trip fidelity, data isolation, and edge cases that caused
|
||||
* bugs in the initial implementation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
_prosemirrorToPortableText as prosemirrorToPortableText,
|
||||
_portableTextToProsemirror as portableTextToProsemirror,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a ProseMirror doc with the given content nodes */
|
||||
function pmDoc(...content: unknown[]) {
|
||||
return { type: "doc", content };
|
||||
}
|
||||
|
||||
/** Build a ProseMirror pluginBlock node */
|
||||
function pmPluginBlock(blockType: string, id: string, data: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "pluginBlock",
|
||||
attrs: { blockType, id, data },
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a Portable Text plugin block (unknown _type → embed) */
|
||||
function ptPluginBlock(type: string, id: string, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_type: type,
|
||||
_key: "k1",
|
||||
id,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ProseMirror → Portable Text (convertPMNode pluginBlock case)
|
||||
// =============================================================================
|
||||
|
||||
describe("PM → PT: plugin blocks", () => {
|
||||
it("converts a basic plugin block", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "https://youtu.be/abc"));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "youtube",
|
||||
id: "https://youtu.be/abc",
|
||||
});
|
||||
expect(blocks[0]!._key).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spreads data fields into the PT block", () => {
|
||||
const doc = pmDoc(pmPluginBlock("chart", "chart-1", { color: "red", size: 42 }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "chart",
|
||||
id: "chart-1",
|
||||
color: "red",
|
||||
size: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("data fields cannot overwrite _type", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _type: "malicious" }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._type).toBe("youtube");
|
||||
});
|
||||
|
||||
it("data fields cannot overwrite _key", () => {
|
||||
const doc = pmDoc(pmPluginBlock("youtube", "vid-1", { _key: "evil" }));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._key).not.toBe("evil");
|
||||
});
|
||||
|
||||
it("handles empty data gracefully", () => {
|
||||
const doc = pmDoc(pmPluginBlock("tweet", "https://twitter.com/x/status/1"));
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]).toMatchObject({
|
||||
_type: "tweet",
|
||||
id: "https://twitter.com/x/status/1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back blockType to 'embed' when missing", () => {
|
||||
const doc = pmDoc({
|
||||
type: "pluginBlock",
|
||||
attrs: { blockType: null, id: "url", data: {} },
|
||||
});
|
||||
const blocks = prosemirrorToPortableText(doc);
|
||||
|
||||
expect(blocks[0]!._type).toBe("embed");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Portable Text → ProseMirror (convertPTBlock default case)
|
||||
// =============================================================================
|
||||
|
||||
describe("PT → PM: plugin blocks", () => {
|
||||
it("converts an unknown block type with id to pluginBlock", () => {
|
||||
const pm = portableTextToProsemirror([ptPluginBlock("youtube", "https://youtu.be/abc")]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("youtube");
|
||||
expect(node.attrs.id).toBe("https://youtu.be/abc");
|
||||
});
|
||||
|
||||
it("stores extra fields as data", () => {
|
||||
const pm = portableTextToProsemirror([
|
||||
ptPluginBlock("chart", "chart-1", { color: "red", size: 42 }),
|
||||
]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
|
||||
|
||||
expect(node.attrs.data).toEqual({ color: "red", size: 42 });
|
||||
});
|
||||
|
||||
it("filters _-prefixed keys from data", () => {
|
||||
const pm = portableTextToProsemirror([
|
||||
ptPluginBlock("youtube", "vid-1", {
|
||||
_internal: "should-be-stripped",
|
||||
_foo: "also-stripped",
|
||||
caption: "keep-this",
|
||||
}),
|
||||
]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: { data: Record<string, unknown> } };
|
||||
|
||||
expect(node.attrs.data).toEqual({ caption: "keep-this" });
|
||||
expect(node.attrs.data).not.toHaveProperty("_internal");
|
||||
expect(node.attrs.data).not.toHaveProperty("_foo");
|
||||
});
|
||||
|
||||
it("handles url field as fallback for id", () => {
|
||||
const block = { _type: "embed", _key: "k1", url: "https://example.com" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as { type: string; attrs: Record<string, unknown> };
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.id).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("treats blocks without id, url, or data as unknown (paragraph fallback)", () => {
|
||||
const block = { _type: "mystery", _key: "k1" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as { type: string };
|
||||
|
||||
expect(node.type).toBe("paragraph");
|
||||
});
|
||||
|
||||
it("converts blocks with field data but no id/url to pluginBlock", () => {
|
||||
const block = { _type: "emdash-form", _key: "k1", formId: "abc-123" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as {
|
||||
type: string;
|
||||
attrs: { blockType: string; id: string; data: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("emdash-form");
|
||||
expect(node.attrs.id).toBe("");
|
||||
expect(node.attrs.data).toEqual({ formId: "abc-123" });
|
||||
});
|
||||
|
||||
it("converts blocks with empty id and field data to pluginBlock", () => {
|
||||
const block = { _type: "emdash-form", _key: "k1", id: "", formId: "abc-123" };
|
||||
const pm = portableTextToProsemirror([block]);
|
||||
const node = pm.content?.[0] as {
|
||||
type: string;
|
||||
attrs: { blockType: string; id: string; data: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(node.type).toBe("pluginBlock");
|
||||
expect(node.attrs.blockType).toBe("emdash-form");
|
||||
expect(node.attrs.id).toBe("");
|
||||
expect(node.attrs.data).toEqual({ formId: "abc-123" });
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Round-trip: PT → PM → PT
|
||||
// =============================================================================
|
||||
|
||||
describe("Plugin block round-trip", () => {
|
||||
it("basic plugin block survives round-trip", () => {
|
||||
const original = [ptPluginBlock("youtube", "https://youtu.be/abc")];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "youtube",
|
||||
id: "https://youtu.be/abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("plugin block with data survives round-trip", () => {
|
||||
const original = [ptPluginBlock("chart", "chart-1", { color: "red", size: 42 })];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "chart",
|
||||
id: "chart-1",
|
||||
color: "red",
|
||||
size: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("_-prefixed keys do not accumulate across round-trips", () => {
|
||||
// Simulate a block that somehow has _-prefixed keys in data
|
||||
const withLeakyKeys = [
|
||||
ptPluginBlock("youtube", "vid-1", {
|
||||
_createdAt: "2024-01-01",
|
||||
caption: "test",
|
||||
}),
|
||||
];
|
||||
|
||||
// First round-trip should strip _-prefixed keys
|
||||
const pm1 = portableTextToProsemirror(withLeakyKeys);
|
||||
const rt1 = prosemirrorToPortableText(pm1);
|
||||
|
||||
expect(rt1[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
|
||||
expect(rt1[0]).not.toHaveProperty("_createdAt");
|
||||
|
||||
// Second round-trip should be stable
|
||||
const pm2 = portableTextToProsemirror(rt1);
|
||||
const rt2 = prosemirrorToPortableText(pm2);
|
||||
|
||||
expect(rt2[0]).toMatchObject({ _type: "youtube", id: "vid-1", caption: "test" });
|
||||
expect(Object.keys(rt2[0]!).filter((k) => k.startsWith("_"))).toEqual(["_type", "_key"]);
|
||||
});
|
||||
|
||||
it("field-data block (no id) survives round-trip", () => {
|
||||
const original = [{ _type: "emdash-form", _key: "k1", formId: "abc-123" }];
|
||||
const pm = portableTextToProsemirror(original);
|
||||
const roundTripped = prosemirrorToPortableText(pm);
|
||||
|
||||
expect(roundTripped).toHaveLength(1);
|
||||
expect(roundTripped[0]).toMatchObject({
|
||||
_type: "emdash-form",
|
||||
id: "",
|
||||
formId: "abc-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("data with _type/_key fields cannot overwrite block identity after round-trip", () => {
|
||||
// Start from PM where data contains _type/_key as data fields
|
||||
const doc = pmDoc(
|
||||
pmPluginBlock("youtube", "vid-1", { _type: "evil", _key: "evil", caption: "test" }),
|
||||
);
|
||||
|
||||
// PM → PT: fix 5 ensures _type/_key are set after data spread
|
||||
const pt = prosemirrorToPortableText(doc);
|
||||
expect(pt[0]!._type).toBe("youtube");
|
||||
expect(pt[0]!._key).not.toBe("evil");
|
||||
|
||||
// PT → PM → PT: fix 6 strips _-prefixed keys, so they don't persist
|
||||
const pm2 = portableTextToProsemirror(pt);
|
||||
const rt = prosemirrorToPortableText(pm2);
|
||||
expect(rt[0]!._type).toBe("youtube");
|
||||
expect(rt[0]).toMatchObject({ caption: "test" });
|
||||
});
|
||||
});
|
||||
569
packages/admin/tests/editor/slash-menu.test.tsx
Normal file
569
packages/admin/tests/editor/slash-menu.test.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* 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 { render } from "vitest-browser-react";
|
||||
|
||||
import type { PortableTextEditorProps } from "../../src/components/PortableTextEditor";
|
||||
import { PortableTextEditor } from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
810
packages/admin/tests/editor/toolbar.test.tsx
Normal file
810
packages/admin/tests/editor/toolbar.test.tsx
Normal file
@@ -0,0 +1,810 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
|
||||
import {
|
||||
PortableTextEditor,
|
||||
type PortableTextEditorProps,
|
||||
} from "../../src/components/PortableTextEditor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — heavy components that need network / Astro context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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 onEditorReady = (editor: Editor) => {
|
||||
editorInstance = editor;
|
||||
};
|
||||
|
||||
const screen = await render(
|
||||
<PortableTextEditor value={defaultValue} onEditorReady={onEditorReady} {...props} />,
|
||||
);
|
||||
|
||||
// Wait for TipTap to initialize
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(document.querySelector(".ProseMirror")).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
return { screen, editor: editorInstance! };
|
||||
}
|
||||
|
||||
/** Focus the ProseMirror editor and select all text */
|
||||
async function focusAndSelectAll(screen: Awaited<ReturnType<typeof render>>) {
|
||||
const prosemirror = screen.container.querySelector(".ProseMirror") as HTMLElement;
|
||||
prosemirror.focus();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(prosemirror), { timeout: 1000 });
|
||||
// Use Control on Linux CI, Meta on macOS
|
||||
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
|
||||
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
|
||||
await userEvent.keyboard(`${mod}{a}${modUp}`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Toolbar Presence and Structure
|
||||
// =============================================================================
|
||||
|
||||
describe("Toolbar Presence and Structure", () => {
|
||||
it("has role='toolbar' with correct aria-label", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
await expect.element(toolbar).toHaveAttribute("aria-label", "Text formatting");
|
||||
});
|
||||
|
||||
it("has all formatting buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Bold" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Italic" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Underline" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Strikethrough" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Inline Code" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all heading buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 1" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 2" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Heading 3" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all list buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Bullet List" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Numbered List" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all block buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Quote" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Code Block" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all alignment buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Left" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Center" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Align Right" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has all insert buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Link" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Insert Image" })).toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: "Insert Horizontal Rule" }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it("has history buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
await expect.element(screen.getByRole("button", { name: "Redo" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("has Spotlight Mode button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await expect.element(screen.getByRole("button", { name: "Spotlight Mode" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides toolbar when minimal={true}", async () => {
|
||||
const { screen } = await renderEditor({ minimal: true });
|
||||
const toolbar = screen.container.querySelector('[role="toolbar"]');
|
||||
expect(toolbar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Formatting Button Toggle States
|
||||
// =============================================================================
|
||||
|
||||
describe("Formatting Button Toggle States", () => {
|
||||
it("Bold: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Bold" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Italic: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Italic" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Underline: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Underline" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Strikethrough: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Strikethrough" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Inline Code: click toggles aria-pressed to true", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Inline Code" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 1: click toggles aria-pressed to true and changes to h1", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
// Focus editor and place cursor (block commands need cursor in a paragraph)
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 1" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 2: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 2" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Heading 3: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Heading 3" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Bullet List: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Bullet List" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Numbered List: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Numbered List" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Quote: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Quote" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Code Block: click toggles aria-pressed to true", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
editor.commands.focus();
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Code Block" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("Toggle off: clicking Bold twice returns aria-pressed to false", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// First click: on
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Second click: off
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. Text Alignment
|
||||
// =============================================================================
|
||||
|
||||
describe("Text Alignment", () => {
|
||||
it("Align Center becomes pressed, Align Left becomes unpressed", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
|
||||
alignCenter.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("Align Right becomes pressed, others unpressed", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
const alignRight = screen.getByRole("button", { name: "Align Right" });
|
||||
|
||||
alignRight.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("false");
|
||||
expect(alignCenter.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("Align Left becomes pressed after switching from another alignment", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const alignLeft = screen.getByRole("button", { name: "Align Left" });
|
||||
const alignRight = screen.getByRole("button", { name: "Align Right" });
|
||||
|
||||
// First switch to right
|
||||
alignRight.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Then switch back to left
|
||||
alignLeft.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(alignLeft.element().getAttribute("aria-pressed")).toBe("true");
|
||||
expect(alignRight.element().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. Undo/Redo
|
||||
// =============================================================================
|
||||
|
||||
describe("Undo/Redo", () => {
|
||||
it("initially Undo and Redo are disabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const undo = screen.getByRole("button", { name: "Undo" });
|
||||
const redo = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
await expect.element(undo).toBeDisabled();
|
||||
await expect.element(redo).toBeDisabled();
|
||||
});
|
||||
|
||||
it("after making a change, Undo becomes enabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change - toggle bold
|
||||
screen.getByRole("button", { name: "Bold" }).element().click();
|
||||
|
||||
const undo = screen.getByRole("button", { name: "Undo" });
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("after undo, Redo becomes enabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change
|
||||
screen.getByRole("button", { name: "Bold" }).element().click();
|
||||
|
||||
const undo = screen.getByRole("button", { name: "Undo" });
|
||||
const redo = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
// Wait for undo to be enabled, then click it
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
undo.element().click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(redo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("after redo, Undo is enabled and Redo is disabled", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Make a change
|
||||
screen.getByRole("button", { name: "Bold" }).element().click();
|
||||
|
||||
const undo = screen.getByRole("button", { name: "Undo" });
|
||||
const redo = screen.getByRole("button", { name: "Redo" });
|
||||
|
||||
// Undo
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
undo.element().click();
|
||||
|
||||
// Redo
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(redo.element().disabled).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
redo.element().click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(undo.element().disabled).toBe(false);
|
||||
expect(redo.element().disabled).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 5. Link Insertion (Toolbar Popover)
|
||||
// =============================================================================
|
||||
|
||||
describe("Link Insertion", () => {
|
||||
it("clicking Insert Link opens a popover with URL input", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
const linkBtn = screen.getByRole("button", { name: "Insert Link" });
|
||||
linkBtn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const input = screen.container.querySelector('input[type="url"]');
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("popover has Cancel and Apply buttons", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("typing URL and clicking Apply sets the link", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.container.querySelector('input[type="url"]') as HTMLInputElement;
|
||||
// Focus input and type URL
|
||||
input.focus();
|
||||
// Use native input value setter to trigger React's onChange
|
||||
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 }));
|
||||
|
||||
screen.getByRole("button", { name: "Apply" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Cancel closes the popover", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
screen.getByRole("button", { name: "Cancel" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('input[type="url"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("Remove button appears when link already exists", async () => {
|
||||
const { screen, editor } = await renderEditor();
|
||||
await focusAndSelectAll(screen);
|
||||
|
||||
// Set a link programmatically
|
||||
editor.chain().focus().setLink({ href: "https://example.com" }).run();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(editor.isActive("link")).toBe(true);
|
||||
});
|
||||
|
||||
// Re-select all to ensure cursor is in the link
|
||||
const mod = navigator.platform.includes("Mac") ? "{Meta>}" : "{Control>}";
|
||||
const modUp = navigator.platform.includes("Mac") ? "{/Meta}" : "{/Control}";
|
||||
await userEvent.keyboard(`${mod}{a}${modUp}`);
|
||||
|
||||
screen.getByRole("button", { name: "Insert Link" }).element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 6. Focus Mode Toggle
|
||||
// =============================================================================
|
||||
|
||||
describe("Focus Mode Toggle", () => {
|
||||
it("initially Spotlight Mode aria-pressed is false", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("clicking Spotlight Mode toggles aria-pressed to true and adds class", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
// The wrapper div should have the spotlight-mode class
|
||||
const wrapper = screen.container.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Spotlight Mode again toggles back to false and removes class", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
|
||||
// Toggle on
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// Toggle off
|
||||
btn.element().click();
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.element().getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.container.querySelector(".spotlight-mode")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("with controlled focusMode prop, reflects external state", async () => {
|
||||
const { screen } = await renderEditor({ focusMode: "spotlight" });
|
||||
|
||||
// The button title changes to "Exit Spotlight Mode" when active
|
||||
const btn = screen.getByRole("button", { name: "Exit Spotlight Mode" });
|
||||
await expect.element(btn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const wrapper = screen.container.querySelector(".spotlight-mode");
|
||||
expect(wrapper).toBeTruthy();
|
||||
});
|
||||
|
||||
it("with onFocusModeChange callback, fires with correct mode", async () => {
|
||||
const onFocusModeChange = vi.fn();
|
||||
const { screen } = await renderEditor({
|
||||
focusMode: "normal",
|
||||
onFocusModeChange,
|
||||
});
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
btn.element().click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onFocusModeChange).toHaveBeenCalledWith("spotlight");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 7. WAI-ARIA Keyboard Navigation
|
||||
// =============================================================================
|
||||
|
||||
describe("WAI-ARIA Keyboard Navigation", () => {
|
||||
it("ArrowRight from Bold moves focus to Italic", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const italic = screen.getByRole("button", { name: "Italic" });
|
||||
|
||||
// Focus the Bold button
|
||||
bold.element().focus();
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
|
||||
// Press ArrowRight
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(italic.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowLeft from Italic moves focus to Bold", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const italic = screen.getByRole("button", { name: "Italic" });
|
||||
|
||||
// Focus the Italic button
|
||||
italic.element().focus();
|
||||
expect(document.activeElement).toBe(italic.element());
|
||||
|
||||
// Press ArrowLeft
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("Home moves focus to first button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
const alignCenter = screen.getByRole("button", { name: "Align Center" });
|
||||
|
||||
// Focus a button in the middle
|
||||
alignCenter.element().focus();
|
||||
|
||||
// Press Home
|
||||
await userEvent.keyboard("{Home}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("End moves focus to last button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the first button
|
||||
bold.element().focus();
|
||||
|
||||
// Press End — last button is Spotlight Mode (or Exit Spotlight Mode)
|
||||
await userEvent.keyboard("{End}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const active = document.activeElement as HTMLElement;
|
||||
// Last button in the toolbar — its aria-label should be "Spotlight Mode"
|
||||
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowRight wraps from last to first button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const spotlightBtn = screen.getByRole("button", { name: "Spotlight Mode" });
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the last button
|
||||
spotlightBtn.element().focus();
|
||||
|
||||
// Press ArrowRight - should wrap to first
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(bold.element());
|
||||
});
|
||||
});
|
||||
|
||||
it("ArrowLeft wraps from first to last button", async () => {
|
||||
const { screen } = await renderEditor();
|
||||
|
||||
const bold = screen.getByRole("button", { name: "Bold" });
|
||||
|
||||
// Focus the first button
|
||||
bold.element().focus();
|
||||
|
||||
// Press ArrowLeft - should wrap to last
|
||||
await userEvent.keyboard("{ArrowLeft}");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const active = document.activeElement as HTMLElement;
|
||||
expect(active.getAttribute("aria-label")).toBe("Spotlight Mode");
|
||||
});
|
||||
});
|
||||
});
|
||||
324
packages/admin/tests/editor/transforms.test.ts
Normal file
324
packages/admin/tests/editor/transforms.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Block Transform Tests
|
||||
*
|
||||
* Tests that block transformations work correctly:
|
||||
* - Transform paragraph to headings (H1, H2, H3)
|
||||
* - Transform to blockquote, code block
|
||||
* - Transform to bullet and ordered lists
|
||||
* - Duplicate block preserves content
|
||||
* - Delete block removes content
|
||||
*
|
||||
* These transformations are used by the BlockMenu component.
|
||||
*/
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { blockTransforms } from "../../src/components/editor/BlockMenu";
|
||||
|
||||
describe("Block Transforms", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>Test content</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
describe("Transform to Paragraph", () => {
|
||||
it("transforms heading to paragraph", () => {
|
||||
editor.commands.setHeading({ level: 1 });
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
|
||||
const transform = blockTransforms.find((t) => t.id === "paragraph");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading")).toBe(false);
|
||||
expect(editor.isActive("paragraph")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to paragraph", () => {
|
||||
editor.commands.setHeading({ level: 2 });
|
||||
const transform = blockTransforms.find((t) => t.id === "paragraph");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Heading", () => {
|
||||
it("transforms paragraph to heading 1", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("transforms paragraph to heading 2", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading2");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it("transforms paragraph to heading 3", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading3");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("heading", { level: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to heading", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("can change heading level", () => {
|
||||
const h1Transform = blockTransforms.find((t) => t.id === "heading1");
|
||||
h1Transform?.transform(editor);
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(true);
|
||||
|
||||
const h2Transform = blockTransforms.find((t) => t.id === "heading2");
|
||||
h2Transform?.transform(editor);
|
||||
expect(editor.isActive("heading", { level: 2 })).toBe(true);
|
||||
expect(editor.isActive("heading", { level: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Blockquote", () => {
|
||||
it("transforms paragraph to blockquote", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to blockquote", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles blockquote off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "blockquote");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("blockquote")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("blockquote")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Code Block", () => {
|
||||
it("transforms paragraph to code block", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to code block", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles code block off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "codeBlock");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("codeBlock")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("codeBlock")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Bullet List", () => {
|
||||
it("transforms paragraph to bullet list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to bullet list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("toggles bullet list off when already active", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
|
||||
transform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform to Ordered List", () => {
|
||||
it("transforms paragraph to ordered list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves text content when transforming to ordered list", () => {
|
||||
const transform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
transform?.transform(editor);
|
||||
|
||||
expect(editor.getText().trim()).toBe("Test content");
|
||||
});
|
||||
|
||||
it("can switch between bullet and ordered list", () => {
|
||||
const bulletTransform = blockTransforms.find((t) => t.id === "bulletList");
|
||||
bulletTransform?.transform(editor);
|
||||
expect(editor.isActive("bulletList")).toBe(true);
|
||||
|
||||
const orderedTransform = blockTransforms.find((t) => t.id === "orderedList");
|
||||
orderedTransform?.transform(editor);
|
||||
expect(editor.isActive("orderedList")).toBe(true);
|
||||
expect(editor.isActive("bulletList")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Transform metadata", () => {
|
||||
it("has all required transform definitions", () => {
|
||||
const expectedIds = [
|
||||
"paragraph",
|
||||
"heading1",
|
||||
"heading2",
|
||||
"heading3",
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
"bulletList",
|
||||
"orderedList",
|
||||
];
|
||||
|
||||
for (const id of expectedIds) {
|
||||
const transform = blockTransforms.find((t) => t.id === id);
|
||||
expect(transform, `Transform "${id}" should exist`).toBeDefined();
|
||||
expect(transform?.label, `Transform "${id}" should have a label`).toBeTruthy();
|
||||
expect(transform?.icon, `Transform "${id}" should have an icon`).toBeDefined();
|
||||
expect(
|
||||
typeof transform?.transform,
|
||||
`Transform "${id}" should have a transform function`,
|
||||
).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Block Duplicate", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>First paragraph</p><p>Second paragraph</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("duplicates block and preserves content", () => {
|
||||
// Position cursor in first paragraph
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
const { selection } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
// Get the block node at current position
|
||||
const blockStart = $from.start($from.depth);
|
||||
const blockEnd = $to.end($to.depth);
|
||||
|
||||
// Get the content to duplicate
|
||||
const slice = editor.state.doc.slice(blockStart, blockEnd);
|
||||
|
||||
// Insert after current block
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr }) => {
|
||||
tr.insert(blockEnd + 1, slice.content);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
|
||||
const json = editor.getJSON();
|
||||
expect(json.content?.length).toBe(3); // Now 3 paragraphs
|
||||
|
||||
// Check content
|
||||
const texts =
|
||||
json.content?.map((block) => {
|
||||
if (block.type === "paragraph" && block.content?.[0]) {
|
||||
return (block.content[0] as { text?: string }).text;
|
||||
}
|
||||
return "";
|
||||
}) ?? [];
|
||||
|
||||
expect(texts[0]).toBe("First paragraph");
|
||||
expect(texts[1]).toBe("First paragraph"); // Duplicated
|
||||
expect(texts[2]).toBe("Second paragraph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Block Delete", () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
],
|
||||
content: "<p>First paragraph</p><p>Second paragraph</p>",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("deletes block at cursor position", () => {
|
||||
// Position cursor in first paragraph
|
||||
editor.commands.setTextSelection(1);
|
||||
|
||||
editor.commands.deleteNode("paragraph");
|
||||
|
||||
const json = editor.getJSON();
|
||||
expect(json.content?.length).toBe(1); // Now 1 paragraph
|
||||
|
||||
// Check remaining content
|
||||
const text =
|
||||
json.content?.[0]?.type === "paragraph" && json.content[0].content?.[0]
|
||||
? (json.content[0].content[0] as { text?: string }).text
|
||||
: "";
|
||||
|
||||
expect(text).toBe("Second paragraph");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user