test: add tests for components/editor (#15938)

* test: add VariableNode test suite

* test: add ToolbarPlugin test suite

* test: add AutoLinkPlugin test suite

* test: add AddVariablesDropdown test suite

* test: add Editor test suite

* chore: Remove unnecessary comments in ToolbarPlugin.test.tsx

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
This commit is contained in:
Imamuzzaki Abu Salam
2024-07-31 14:25:00 +02:00
committed by GitHub
co-authored by Amit Sharma
parent 24e5226e3e
commit 63c7e4b55a
6 changed files with 602 additions and 0 deletions
@@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import type { TextEditorProps } from "./Editor";
import { Editor } from "./Editor";
describe("Editor", () => {
const defaultProps: TextEditorProps = {
getText: vi.fn(),
setText: vi.fn(),
variables: ["name", "email"],
height: "200px",
placeholder: "Start typing...",
};
it("renders editor with default props", () => {
render(<Editor {...defaultProps} />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByText("Start typing...")).toBeInTheDocument();
});
it("renders toolbar", () => {
render(<Editor {...defaultProps} />);
expect(screen.getByText("Normal")).toBeInTheDocument();
expect(screen.getByText("add_variable")).toBeInTheDocument();
});
it("respects editable prop", () => {
render(<Editor {...defaultProps} editable={false} />);
const editor = screen.getByTestId("editor-input");
// eslint-disable-next-line playwright/missing-playwright-await
expect(editor).toHaveAttribute("contenteditable", "false");
});
it("excludes toolbar items", () => {
render(<Editor {...defaultProps} excludedToolbarItems={["bold", "italic"]} />);
expect(screen.queryByTitle("Bold")).not.toBeInTheDocument();
expect(screen.queryByTitle("Italic")).not.toBeInTheDocument();
});
it("renders variables plugin when variables are provided", () => {
render(<Editor {...defaultProps} />);
expect(screen.getByText("add_variable")).toBeInTheDocument();
});
it("does not render variables plugin when variables are not provided", () => {
render(<Editor {...defaultProps} variables={undefined} />);
expect(screen.queryByText("add_variable")).not.toBeInTheDocument();
});
it("disables lists when disableLists is true", () => {
render(<Editor {...defaultProps} disableLists={true} />);
expect(screen.queryByTitle("Bullet List")).not.toBeInTheDocument();
expect(screen.queryByTitle("Numbered List")).not.toBeInTheDocument();
});
it("calls getText when text is entered", async () => {
const user = userEvent.setup();
render(<Editor {...defaultProps} />);
const editor = screen.getByRole("textbox");
await user.type(editor, "Hello, world!");
expect(defaultProps.getText).toHaveBeenCalled();
});
it("applies custom height", () => {
render(<Editor {...defaultProps} height="300px" />);
const editorInner = screen.getByRole("textbox").parentElement;
expect(editorInner).toHaveStyle({ height: "300px" });
});
it("handles first render and update template", () => {
const setFirstRender = vi.fn();
render(
<Editor {...defaultProps} updateTemplate={true} firstRender={true} setFirstRender={setFirstRender} />
);
expect(setFirstRender).toHaveBeenCalled();
});
});
+1
View File
@@ -88,6 +88,7 @@ export const Editor = (props: TextEditorProps) => {
<RichTextPlugin
contentEditable={
<ContentEditable
data-testid="editor-input"
readOnly={!editable}
style={{ height: props.height }}
className="editor-input"
@@ -0,0 +1,87 @@
import { createEditor, TextNode, type LexicalEditor } from "lexical";
import { describe, it, expect, beforeEach } from "vitest";
import { $createVariableNode, $isVariableNode, VariableNode } from "./VariableNode";
let editor: LexicalEditor;
beforeEach(() => {
editor = createEditor({
nodes: [VariableNode],
});
});
describe("VariableNode", () => {
it("should create a VariableNode with given text", () => {
editor.update(() => {
const node = $createVariableNode("test");
expect(node.getTextContent()).toBe("test");
});
});
it("should create a VariableNode with default text", () => {
editor.update(() => {
const node = $createVariableNode();
expect(node.getTextContent()).toBe("");
});
});
it("should clone a VariableNode", () => {
editor.update(() => {
const original = $createVariableNode("test");
const clone = VariableNode.clone(original);
expect(clone.getTextContent()).toBe("test");
expect(clone.__key).not.toBe(original.__key);
});
});
it("should create a DOM element with correct attributes", () => {
editor.update(() => {
const node = $createVariableNode("test");
const dom = node.createDOM(editor._config);
expect(dom.className).toBe("bg-info");
expect(dom.getAttribute("data-lexical-variable")).toBe("true");
});
});
it("should export JSON correctly", () => {
editor.update(() => {
const node = $createVariableNode("test");
const json = node.exportJSON();
expect(json).toEqual({
...node.exportJSON(),
type: "variable",
version: 1,
});
});
});
it("should return true for isTextEntity", () => {
editor.update(() => {
const node = $createVariableNode("test");
expect(node.isTextEntity()).toBe(true);
});
});
it("should return false for canInsertTextBefore and canInsertTextAfter", () => {
editor.update(() => {
const node = $createVariableNode("test");
expect(node.canInsertTextBefore()).toBe(false);
expect(node.canInsertTextAfter()).toBe(false);
});
});
it("should identify a VariableNode correctly", () => {
editor.update(() => {
const node = $createVariableNode("test");
expect($isVariableNode(node)).toBe(true);
});
});
it("should not identify a non-VariableNode", () => {
editor.update(() => {
const node = new TextNode("test");
expect($isVariableNode(node)).toBe(false);
});
});
});
@@ -0,0 +1,73 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import { AddVariablesDropdown } from "./AddVariablesDropdown";
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string) => key,
}),
}));
// Mock the Dropdown component
vi.mock("../../form/dropdown", () => ({
Dropdown: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe("AddVariablesDropdown", () => {
const mockAddVariable = vi.fn();
const variables = ["var1", "var2", "var3"];
it("renders correctly", () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} />);
expect(screen.getByText("add_variable")).toBeInTheDocument();
});
it("renders text editor version correctly", () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} isTextEditor />);
expect(screen.getByText("add_variable")).toBeInTheDocument();
expect(screen.getByText("+")).toBeInTheDocument();
});
it("opens dropdown on click", async () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} />);
fireEvent.click(screen.getByText("add_variable"));
await waitFor(() => {
expect(screen.getByText("add_dynamic_variables".toLocaleUpperCase())).toBeInTheDocument();
});
});
it("renders all variables", async () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} />);
fireEvent.click(screen.getByText("add_variable"));
await waitFor(() => {
for (const variable of variables) {
expect(
screen.getByText((content) => content.includes(`{${variable}_variable}`.toUpperCase()))
).toBeInTheDocument();
}
});
});
it("calls addVariable when a variable is clicked", async () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} />);
fireEvent.click(screen.getByText("add_variable"));
await waitFor(() => {
fireEvent.click(screen.getByText((content) => content.includes("{VAR1_VARIABLE}")));
});
expect(mockAddVariable).toHaveBeenCalledWith("var1_variable");
});
it("renders variable info for each variable", async () => {
render(<AddVariablesDropdown addVariable={mockAddVariable} variables={variables} />);
fireEvent.click(screen.getByText("add_variable"));
await waitFor(() => {
for (const variable of variables) {
expect(screen.getByText(`${variable}_info`)).toBeInTheDocument();
}
});
});
});
@@ -0,0 +1,95 @@
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import PlaygroundAutoLinkPlugin from "./AutoLinkPlugin";
const mockAutoLinkPlugin = vi.fn(() => null);
vi.mock("@lexical/react/LexicalAutoLinkPlugin", () => ({
// @ts-expect-error - it needs props, somehow it not detect it.
AutoLinkPlugin: (props: any) => mockAutoLinkPlugin(props),
}));
function setup() {
const initialConfig = {
namespace: "test-editor",
onError: (error: Error) => console.error(error),
};
return render(
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div>Enter some text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<PlaygroundAutoLinkPlugin />
</LexicalComposer>
);
}
describe("PlaygroundAutoLinkPlugin", () => {
beforeEach(() => {
mockAutoLinkPlugin.mockClear();
});
it("renders without crashing", () => {
setup();
expect(screen.getByText("Enter some text...")).toBeTruthy();
});
it("passes correct matchers to AutoLinkPlugin", () => {
setup();
expect(mockAutoLinkPlugin).toHaveBeenCalledWith(
expect.objectContaining({
matchers: expect.arrayContaining([expect.any(Function), expect.any(Function)]),
})
);
});
it("correctly matches URLs", () => {
setup();
// @ts-expect-error - it's definitely not undefined
const matchers = mockAutoLinkPlugin.mock.calls[0][0].matchers;
const urlMatcher = matchers[0];
const validUrls = ["https://www.example.com", "http://example.com", "www.example.com"];
validUrls.forEach((url) => {
const result = urlMatcher(url);
expect(result).toEqual({
index: 0,
length: url.length,
text: url,
url: url,
});
});
expect(urlMatcher("not a url")).toBeNull();
});
it("correctly matches email addresses", () => {
setup();
// @ts-expect-error - it's definitely not undefined
const matchers = mockAutoLinkPlugin.mock.calls[0][0].matchers;
const emailMatcher = matchers[1];
const validEmails = ["test@example.com", "test.name@example.co.uk", "test+alias@example.com"];
validEmails.forEach((email) => {
const result = emailMatcher(email);
expect(result).toEqual({
index: 0,
length: email.length,
text: email,
url: `mailto:${email}`,
});
});
expect(emailMatcher("not an email")).toBeNull();
});
});
@@ -0,0 +1,267 @@
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import ToolbarPlugin from "./ToolbarPlugin";
// Mocks
vi.mock("../../..", () => ({
Icon: ({ name }: { name: string }) => <span data-testid={`icon-${name}`}>{name}</span>,
}));
vi.mock("../../button", () => ({
Button: ({ children, onClick, StartIcon }: any) => (
<button onClick={onClick} data-testid={`button-${StartIcon || "default"}`}>
{StartIcon && <span>{StartIcon}</span>}
{children}
</button>
),
}));
vi.mock("../../form/dropdown", () => ({
Dropdown: ({ children }: any) => <div data-testid="dropdown">{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div data-testid="dropdown-trigger">{children}</div>,
DropdownMenuContent: ({ children }: any) => <div data-testid="dropdown-content">{children}</div>,
DropdownMenuItem: ({ children }: any) => <div data-testid="dropdown-item">{children}</div>,
}));
vi.mock("./AddVariablesDropdown", () => ({
AddVariablesDropdown: () => <div data-testid="add-variables-dropdown">AddVariablesDropdown</div>,
}));
const initialConfig = {
namespace: "MyEditor",
theme: {},
onError: (error: Error) => console.error(error),
};
const TestWrapper = ({ children }: { children: string | JSX.Element }) => (
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div>Enter some text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
{children}
</LexicalComposer>
);
describe("ToolbarPlugin", () => {
const defaultProps = {
editable: true,
getText: vi.fn(() => ""),
setText: vi.fn(),
firstRender: true,
setFirstRender: vi.fn(),
updateTemplate: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
it("renders correctly with default props", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
expect(screen.getByTestId("dropdown")).toBeDefined();
expect(screen.getByTestId("button-bold")).toBeDefined();
expect(screen.getByTestId("button-italic")).toBeDefined();
expect(screen.getByTestId("button-link")).toBeDefined();
});
it("does not render when editable is false", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} editable={false} />
</TestWrapper>
);
expect(screen.queryByTestId("dropdown")).toBeNull();
});
it("renders variables dropdown when variables prop is provided", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} variables={["var1", "var2"]} />
</TestWrapper>
);
expect(screen.getByTestId("add-variables-dropdown")).toBeDefined();
});
it("excludes toolbar items when specified", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} excludedToolbarItems={["bold", "italic"]} />
</TestWrapper>
);
expect(screen.queryByTestId("button-bold")).toBeNull();
expect(screen.queryByTestId("button-italic")).toBeNull();
expect(screen.getByTestId("button-link")).toBeDefined();
});
it("changes block type when dropdown item is clicked", async () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
fireEvent.click(screen.getByTestId("dropdown-trigger"));
await waitFor(() => {
expect(screen.getByTestId("dropdown-content")).toBeDefined();
});
const h1Button = screen.getByText("Large Heading");
fireEvent.click(h1Button);
});
it("toggles bold formatting when bold button is clicked", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
const boldButton = screen.getByTestId("button-bold");
fireEvent.click(boldButton);
});
it("toggles italic formatting when italic button is clicked", () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
const italicButton = screen.getByTestId("button-italic");
fireEvent.click(italicButton);
});
it("toggles link when link button is clicked", async () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
const linkButton = screen.getByTestId("button-link");
fireEvent.click(linkButton);
});
// Additional tests
it("calls setText when editor content changes", async () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} />
</TestWrapper>
);
const editableContent = screen.getByRole("textbox");
await userEvent.type(editableContent, "Hello, World!");
expect(defaultProps.setText).toHaveBeenCalled();
});
it("updates editor content when updateTemplate prop changes", async () => {
const getText = vi.fn(() => "<p>Initial content</p>");
const setText = vi.fn();
const setFirstRender = vi.fn();
const { rerender } = render(
<TestWrapper>
<ToolbarPlugin
{...defaultProps}
getText={getText}
setText={setText}
setFirstRender={setFirstRender}
firstRender={true}
updateTemplate={false}
/>
</TestWrapper>
);
// Wait for initial render
await waitFor(() => {
expect(setFirstRender).toHaveBeenCalledWith(false);
});
// Update getText mock and trigger update
getText.mockReturnValue("<p>Updated content</p>");
await act(async () => {
rerender(
<TestWrapper>
<ToolbarPlugin
{...defaultProps}
getText={getText}
setText={setText}
setFirstRender={setFirstRender}
firstRender={false}
updateTemplate={true}
/>
</TestWrapper>
);
});
// Wait for content update
await waitFor(
() => {
expect(setText).toHaveBeenCalledWith(expect.stringContaining("Updated content"));
},
{ timeout: 5000 }
);
});
it("initializes editor with provided text", async () => {
const getText = vi.fn(() => "<p>Initial content</p>");
const setText = vi.fn();
const setFirstRender = vi.fn();
render(
<TestWrapper>
<ToolbarPlugin
{...defaultProps}
getText={getText}
setText={setText}
setFirstRender={setFirstRender}
firstRender={true}
/>
</TestWrapper>
);
await waitFor(
() => {
expect(setFirstRender).toHaveBeenCalledWith(false);
expect(setText).toHaveBeenCalledWith(expect.stringContaining("Initial content"));
},
{ timeout: 3000 }
);
});
it("calls setText after content update", async () => {
const setText = vi.fn();
await act(async () => {
render(
<TestWrapper>
<ToolbarPlugin {...defaultProps} setText={setText} getText={() => "<p>Test content</p>"} />
</TestWrapper>
);
});
await waitFor(() => {
expect(setText).toHaveBeenCalled();
});
});
});