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();
+ });
+ });
+});