Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
957 lines
29 KiB
TypeScript
957 lines
29 KiB
TypeScript
/**
|
|
* 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 type { PluginBlockDef } from "../../src/components/PortableTextEditor";
|
|
import {
|
|
_buildPluginBlockFormValues,
|
|
_hasPluginBlockFormData,
|
|
PortableTextEditor,
|
|
} from "../../src/components/PortableTextEditor";
|
|
import { render } from "../utils/render";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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. Plugin block helpers
|
|
// =============================================================================
|
|
|
|
describe("plugin block helpers", () => {
|
|
it("builds form state from field initial_value defaults", () => {
|
|
const block: PluginBlockDef = {
|
|
type: "readingTime",
|
|
pluginId: "reading-time",
|
|
label: "Reading Time",
|
|
fields: [
|
|
{
|
|
type: "select",
|
|
action_id: "variant",
|
|
label: "Style",
|
|
options: [
|
|
{ label: "Inline", value: "inline" },
|
|
{ label: "Compact", value: "compact" },
|
|
],
|
|
initial_value: "inline",
|
|
},
|
|
{
|
|
type: "toggle",
|
|
action_id: "includeHeadings",
|
|
label: "Include headings",
|
|
initial_value: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(_buildPluginBlockFormValues(block)).toEqual({
|
|
variant: "inline",
|
|
includeHeadings: true,
|
|
});
|
|
expect(_hasPluginBlockFormData(_buildPluginBlockFormValues(block))).toBe(true);
|
|
});
|
|
|
|
it("merges existing block data over defaults when editing", () => {
|
|
const block: PluginBlockDef = {
|
|
type: "readingTime",
|
|
pluginId: "reading-time",
|
|
label: "Reading Time",
|
|
fields: [
|
|
{
|
|
type: "select",
|
|
action_id: "variant",
|
|
label: "Style",
|
|
options: [
|
|
{ label: "Inline", value: "inline" },
|
|
{ label: "Compact", value: "compact" },
|
|
],
|
|
initial_value: "inline",
|
|
},
|
|
{
|
|
type: "toggle",
|
|
action_id: "includeHeadings",
|
|
label: "Include headings",
|
|
initial_value: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(
|
|
_buildPluginBlockFormValues(block, {
|
|
variant: "compact",
|
|
customLabel: "Custom label",
|
|
includeHeadings: false,
|
|
}),
|
|
).toEqual({
|
|
variant: "compact",
|
|
customLabel: "Custom label",
|
|
includeHeadings: false,
|
|
});
|
|
});
|
|
|
|
it("keeps explicit existing values over defaults", () => {
|
|
const block: PluginBlockDef = {
|
|
type: "readingTime",
|
|
pluginId: "reading-time",
|
|
label: "Reading Time",
|
|
fields: [
|
|
{
|
|
type: "number_input",
|
|
action_id: "minutes",
|
|
label: "Minutes",
|
|
initial_value: 5,
|
|
},
|
|
{
|
|
type: "text_input",
|
|
action_id: "label",
|
|
label: "Label",
|
|
initial_value: "Default label",
|
|
},
|
|
{
|
|
type: "toggle",
|
|
action_id: "includeHeadings",
|
|
label: "Include headings",
|
|
initial_value: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(
|
|
_buildPluginBlockFormValues(block, {
|
|
minutes: 0,
|
|
label: "",
|
|
includeHeadings: false,
|
|
}),
|
|
).toEqual({
|
|
minutes: 0,
|
|
label: "",
|
|
includeHeadings: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// 2. 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("calls onEditorReady with null on unmount so consumers can clear stale references", async () => {
|
|
// Without this cleanup, ContentEditor's `portableTextEditor` slot keeps
|
|
// pointing at a destroyed TipTap instance during the brief remount window
|
|
// when switching translations (FieldRenderer is re-keyed by item.id),
|
|
// causing DocumentOutline to render against a destroyed editor.
|
|
const onEditorReady = vi.fn();
|
|
const screen = await render(
|
|
<PortableTextEditor onEditorReady={onEditorReady} value={[textBlock("Mount/unmount")]} />,
|
|
);
|
|
await waitForEditor();
|
|
|
|
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(1), { timeout: 2000 });
|
|
expect(onEditorReady.mock.calls[0]![0]).toBeTruthy();
|
|
|
|
// Unmount and verify the cleanup fires onEditorReady(null).
|
|
await screen.unmount();
|
|
|
|
await vi.waitFor(() => expect(onEditorReady).toHaveBeenCalledTimes(2), { timeout: 2000 });
|
|
expect(onEditorReady.mock.calls[1]![0]).toBeNull();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|