From 63c7e4b55ac201748936506636ef752ced69b2a4 Mon Sep 17 00:00:00 2001 From: Imamuzzaki Abu Salam Date: Wed, 31 Jul 2024 19:25:00 +0700 Subject: [PATCH] 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> --- packages/ui/components/editor/Editor.test.tsx | 79 ++++++ packages/ui/components/editor/Editor.tsx | 1 + .../editor/nodes/VariableNode.test.ts | 87 ++++++ .../plugins/AddVariablesDropdown.test.tsx | 73 +++++ .../editor/plugins/AutoLinkPlugin.test.tsx | 95 +++++++ .../editor/plugins/ToolbarPlugin.test.tsx | 267 ++++++++++++++++++ 6 files changed, 602 insertions(+) create mode 100644 packages/ui/components/editor/Editor.test.tsx create mode 100644 packages/ui/components/editor/nodes/VariableNode.test.ts create mode 100644 packages/ui/components/editor/plugins/AddVariablesDropdown.test.tsx create mode 100644 packages/ui/components/editor/plugins/AutoLinkPlugin.test.tsx create mode 100644 packages/ui/components/editor/plugins/ToolbarPlugin.test.tsx diff --git a/packages/ui/components/editor/Editor.test.tsx b/packages/ui/components/editor/Editor.test.tsx new file mode 100644 index 0000000000..34d5da2a4e --- /dev/null +++ b/packages/ui/components/editor/Editor.test.tsx @@ -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(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + expect(screen.getByText("Start typing...")).toBeInTheDocument(); + }); + + it("renders toolbar", () => { + render(); + expect(screen.getByText("Normal")).toBeInTheDocument(); + expect(screen.getByText("add_variable")).toBeInTheDocument(); + }); + + it("respects editable prop", () => { + render(); + const editor = screen.getByTestId("editor-input"); + // eslint-disable-next-line playwright/missing-playwright-await + expect(editor).toHaveAttribute("contenteditable", "false"); + }); + + it("excludes toolbar items", () => { + render(); + expect(screen.queryByTitle("Bold")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Italic")).not.toBeInTheDocument(); + }); + + it("renders variables plugin when variables are provided", () => { + render(); + expect(screen.getByText("add_variable")).toBeInTheDocument(); + }); + + it("does not render variables plugin when variables are not provided", () => { + render(); + expect(screen.queryByText("add_variable")).not.toBeInTheDocument(); + }); + + it("disables lists when disableLists is true", () => { + render(); + 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(); + const editor = screen.getByRole("textbox"); + await user.type(editor, "Hello, world!"); + expect(defaultProps.getText).toHaveBeenCalled(); + }); + + it("applies custom height", () => { + render(); + const editorInner = screen.getByRole("textbox").parentElement; + expect(editorInner).toHaveStyle({ height: "300px" }); + }); + + it("handles first render and update template", () => { + const setFirstRender = vi.fn(); + render( + + ); + expect(setFirstRender).toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/components/editor/Editor.tsx b/packages/ui/components/editor/Editor.tsx index 3cd7799e3f..c4a62ca09a 100644 --- a/packages/ui/components/editor/Editor.tsx +++ b/packages/ui/components/editor/Editor.tsx @@ -88,6 +88,7 @@ export const Editor = (props: TextEditorProps) => { { + 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); + }); + }); +}); diff --git a/packages/ui/components/editor/plugins/AddVariablesDropdown.test.tsx b/packages/ui/components/editor/plugins/AddVariablesDropdown.test.tsx new file mode 100644 index 0000000000..4905ef4bf9 --- /dev/null +++ b/packages/ui/components/editor/plugins/AddVariablesDropdown.test.tsx @@ -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 }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +describe("AddVariablesDropdown", () => { + const mockAddVariable = vi.fn(); + const variables = ["var1", "var2", "var3"]; + + it("renders correctly", () => { + render(); + expect(screen.getByText("add_variable")).toBeInTheDocument(); + }); + + it("renders text editor version correctly", () => { + render(); + expect(screen.getByText("add_variable")).toBeInTheDocument(); + expect(screen.getByText("+")).toBeInTheDocument(); + }); + + it("opens dropdown on click", async () => { + render(); + fireEvent.click(screen.getByText("add_variable")); + await waitFor(() => { + expect(screen.getByText("add_dynamic_variables".toLocaleUpperCase())).toBeInTheDocument(); + }); + }); + + it("renders all variables", async () => { + render(); + 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(); + 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(); + fireEvent.click(screen.getByText("add_variable")); + await waitFor(() => { + for (const variable of variables) { + expect(screen.getByText(`${variable}_info`)).toBeInTheDocument(); + } + }); + }); +}); diff --git a/packages/ui/components/editor/plugins/AutoLinkPlugin.test.tsx b/packages/ui/components/editor/plugins/AutoLinkPlugin.test.tsx new file mode 100644 index 0000000000..a3566056b3 --- /dev/null +++ b/packages/ui/components/editor/plugins/AutoLinkPlugin.test.tsx @@ -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( + + } + placeholder={
Enter some text...
} + ErrorBoundary={LexicalErrorBoundary} + /> + +
+ ); +} + +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(); + }); +}); diff --git a/packages/ui/components/editor/plugins/ToolbarPlugin.test.tsx b/packages/ui/components/editor/plugins/ToolbarPlugin.test.tsx new file mode 100644 index 0000000000..2cf97dc0e3 --- /dev/null +++ b/packages/ui/components/editor/plugins/ToolbarPlugin.test.tsx @@ -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 }) => {name}, +})); + +vi.mock("../../button", () => ({ + Button: ({ children, onClick, StartIcon }: any) => ( + + ), +})); + +vi.mock("../../form/dropdown", () => ({ + Dropdown: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children }: any) =>
{children}
, + DropdownMenuContent: ({ children }: any) =>
{children}
, + DropdownMenuItem: ({ children }: any) =>
{children}
, +})); + +vi.mock("./AddVariablesDropdown", () => ({ + AddVariablesDropdown: () =>
AddVariablesDropdown
, +})); + +const initialConfig = { + namespace: "MyEditor", + theme: {}, + onError: (error: Error) => console.error(error), +}; + +const TestWrapper = ({ children }: { children: string | JSX.Element }) => ( + + } + placeholder={
Enter some text...
} + ErrorBoundary={LexicalErrorBoundary} + /> + + {children} +
+); + +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( + + + + ); + + 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( + + + + ); + + expect(screen.queryByTestId("dropdown")).toBeNull(); + }); + + it("renders variables dropdown when variables prop is provided", () => { + render( + + + + ); + + expect(screen.getByTestId("add-variables-dropdown")).toBeDefined(); + }); + + it("excludes toolbar items when specified", () => { + render( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + const boldButton = screen.getByTestId("button-bold"); + fireEvent.click(boldButton); + }); + + it("toggles italic formatting when italic button is clicked", () => { + render( + + + + ); + + const italicButton = screen.getByTestId("button-italic"); + fireEvent.click(italicButton); + }); + + it("toggles link when link button is clicked", async () => { + render( + + + + ); + + const linkButton = screen.getByTestId("button-link"); + fireEvent.click(linkButton); + }); + + // Additional tests + it("calls setText when editor content changes", async () => { + render( + + + + ); + + 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(() => "

Initial content

"); + const setText = vi.fn(); + const setFirstRender = vi.fn(); + + const { rerender } = render( + + + + ); + + // Wait for initial render + await waitFor(() => { + expect(setFirstRender).toHaveBeenCalledWith(false); + }); + + // Update getText mock and trigger update + getText.mockReturnValue("

Updated content

"); + + await act(async () => { + rerender( + + + + ); + }); + + // 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(() => "

Initial content

"); + const setText = vi.fn(); + const setFirstRender = vi.fn(); + + render( + + + + ); + + 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( + + "

Test content

"} /> +
+ ); + }); + + await waitFor(() => { + expect(setText).toHaveBeenCalled(); + }); + }); +});