mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-28 01:20:24 -05:00
feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import { ToolbarPlugin } from "./toolbar-plugin";
|
||||
- bold
|
||||
- italic
|
||||
- link
|
||||
- underline
|
||||
*/
|
||||
export type TextEditorProps = {
|
||||
getText: () => string;
|
||||
|
||||
@@ -147,6 +147,7 @@ vi.mock("lucide-react", () => ({
|
||||
Bold: () => <span data-testid="bold-icon">Bold</span>,
|
||||
Italic: () => <span data-testid="italic-icon">Italic</span>,
|
||||
Link: () => <span data-testid="link-icon">Link</span>,
|
||||
Underline: () => <span data-testid="underline-icon">Underline</span>,
|
||||
ChevronDownIcon: () => <span data-testid="chevron-icon">ChevronDown</span>,
|
||||
}));
|
||||
|
||||
@@ -186,6 +187,7 @@ describe("ToolbarPlugin", () => {
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -218,20 +220,57 @@ describe("ToolbarPlugin", () => {
|
||||
});
|
||||
|
||||
test("excludes toolbar items when specified", () => {
|
||||
render(
|
||||
const { rerender } = render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["bold", "italic"]}
|
||||
excludedToolbarItems={["bold", "italic", "underline"]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not render bold and italic buttons but should render link
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
|
||||
// Rerender with different excluded items
|
||||
rerender(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("excludes all toolbar items when specified", () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "bold", "italic", "underline", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles firstRender and updateTemplate props", () => {
|
||||
@@ -253,4 +292,122 @@ describe("ToolbarPlugin", () => {
|
||||
// the component renders without errors when these props are provided
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
test("dispatches bold format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const boldIcon = screen.getByTestId("bold-icon");
|
||||
const boldButton = boldIcon.parentElement;
|
||||
expect(boldButton).toBeInTheDocument();
|
||||
expect(boldButton).not.toBeNull();
|
||||
await userEvent.click(boldButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "bold");
|
||||
});
|
||||
|
||||
test("dispatches italic format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const italicIcon = screen.getByTestId("italic-icon");
|
||||
const italicButton = italicIcon.parentElement;
|
||||
expect(italicButton).toBeInTheDocument();
|
||||
expect(italicButton).not.toBeNull();
|
||||
await userEvent.click(italicButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "italic");
|
||||
});
|
||||
|
||||
test("dispatches underline format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const underlineIcon = screen.getByTestId("underline-icon");
|
||||
const underlineButton = underlineIcon.parentElement;
|
||||
expect(underlineButton).toBeInTheDocument();
|
||||
expect(underlineButton).not.toBeNull();
|
||||
await userEvent.click(underlineButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "underline");
|
||||
});
|
||||
|
||||
test("dispatches link command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const linkIcon = screen.getByTestId("link-icon");
|
||||
const linkButton = linkIcon.parentElement;
|
||||
expect(linkButton).toBeInTheDocument();
|
||||
expect(linkButton).not.toBeNull();
|
||||
await userEvent.click(linkButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("toggleLink", {
|
||||
url: "https://",
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatches numbered list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const numberedListButton = screen.getAllByTestId("button")[1]; // ol
|
||||
await userEvent.click(numberedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertOrderedList", undefined);
|
||||
});
|
||||
|
||||
test("dispatches bulleted list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const bulletedListButton = screen.getAllByTestId("button")[2]; // ul
|
||||
await userEvent.click(bulletedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertUnorderedList", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,12 @@ import {
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical";
|
||||
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
|
||||
import { Bold, ChevronDownIcon, Italic, Link } from "lucide-react";
|
||||
import { Bold, ChevronDownIcon, Italic, Link, Underline } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AddVariablesDropdown } from "./add-variables-dropdown";
|
||||
@@ -235,6 +236,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
|
||||
// save ref to setText to use it in event listeners safely
|
||||
const setText = useRef<any>(props.setText);
|
||||
@@ -334,7 +336,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
}
|
||||
setIsBold(selection.hasFormat("bold"));
|
||||
setIsItalic(selection.hasFormat("italic"));
|
||||
|
||||
setIsUnderline(selection.hasFormat("underline"));
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
@@ -459,95 +461,94 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "bold",
|
||||
icon: Bold,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
|
||||
active: isBold,
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
icon: Italic,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
|
||||
active: isItalic,
|
||||
},
|
||||
{
|
||||
key: "underline",
|
||||
icon: Underline,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
|
||||
active: isUnderline,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
icon: Link,
|
||||
onClick: insertLink,
|
||||
active: isLink,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="toolbar flex" ref={toolbarRef}>
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<span className={"icon" + blockType} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={"icon block-type " + key} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("bold") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||
}}
|
||||
className={isBold ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Bold />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("italic") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||
}}
|
||||
className={isItalic ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Italic />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("link") && (
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={insertLink}
|
||||
className={isLink ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Link />
|
||||
</Button>
|
||||
{isLink ? (
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span className={cn("icon", blockType)} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={cn("icon block-type", key)} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{items.map(({ key, icon: Icon, onClick, active }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={active ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Icon />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
{isLink &&
|
||||
!props.excludedToolbarItems?.includes("link") &&
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}
|
||||
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,5 +20,6 @@ export const exampleTheme = {
|
||||
text: {
|
||||
bold: "fb-editor-text-bold",
|
||||
italic: "fb-editor-text-italic",
|
||||
underline: "fb-editor-text-underline",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
font-style: italic !important;
|
||||
}
|
||||
|
||||
.fb-editor-text-underline {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.fb-editor-link {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user