mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 19:38:37 -05:00
feat: rich text in headlines (#6685)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
||||
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||
import { ListItemNode, ListNode } from "@lexical/list";
|
||||
import { TRANSFORMERS } from "@lexical/markdown";
|
||||
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
||||
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
@@ -54,6 +55,8 @@ export type TextEditorProps = {
|
||||
selectedLanguageCode?: string;
|
||||
fallbacks?: { [id: string]: string };
|
||||
addFallback?: () => void;
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
@@ -118,10 +121,15 @@ export const Editor = (props: TextEditorProps) => {
|
||||
style={{ height: props.height }}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable style={{ height: props.height }} className="editor-input" />
|
||||
<ContentEditable
|
||||
style={{ height: props.height }}
|
||||
className="editor-input"
|
||||
aria-labelledby={props.id}
|
||||
dir="auto"
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">
|
||||
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400" dir="auto">
|
||||
{props.placeholder ?? ""}
|
||||
</div>
|
||||
}
|
||||
@@ -130,6 +138,7 @@ export const Editor = (props: TextEditorProps) => {
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<AutoLinkPlugin />
|
||||
{props.autoFocus && <AutoFocusPlugin />}
|
||||
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
|
||||
<RecallPlugin
|
||||
localSurvey={props.localSurvey}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/react";
|
||||
import { $applyNodeReplacement } from "lexical";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
import { $createRecallNode, RecallNode, RecallPayload, SerializedRecallNode } from "./recall-node";
|
||||
|
||||
vi.mock("lexical", () => ({
|
||||
@@ -23,6 +23,19 @@ vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
|
||||
return label.replace(/#recall:[^#]+#/g, "___");
|
||||
}),
|
||||
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
|
||||
// Mock: strip HTML tags, clean whitespace, truncate, replace recall patterns
|
||||
const cleanText = text.replace(/<|>/g, "").replace(/\s+/g, " ").trim();
|
||||
const withRecallReplaced = cleanText.replace(/#recall:[^#]+#/g, "___");
|
||||
|
||||
if (withRecallReplaced.length <= maxLength) {
|
||||
return withRecallReplaced;
|
||||
}
|
||||
|
||||
const start = withRecallReplaced.slice(0, 10);
|
||||
const end = withRecallReplaced.slice(-10);
|
||||
return `${start}...${end}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("RecallNode", () => {
|
||||
@@ -353,15 +366,15 @@ describe("RecallNode", () => {
|
||||
expect(span?.textContent).toContain("@");
|
||||
});
|
||||
|
||||
test("calls replaceRecallInfoWithUnderline with label", () => {
|
||||
test("calls getTextContentWithRecallTruncated with label", () => {
|
||||
const node = new RecallNode(mockPayload);
|
||||
node.decorate();
|
||||
|
||||
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith("What is your name?");
|
||||
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith("What is your name?");
|
||||
});
|
||||
|
||||
test("handles label with nested recall patterns", () => {
|
||||
vi.mocked(replaceRecallInfoWithUnderline).mockReturnValueOnce("Processed Label");
|
||||
vi.mocked(getTextContentWithRecallTruncated).mockReturnValueOnce("Processed Label");
|
||||
|
||||
const payloadWithNestedRecall: RecallPayload = {
|
||||
recallItem: {
|
||||
@@ -376,7 +389,7 @@ describe("RecallNode", () => {
|
||||
const decorated = node.decorate();
|
||||
|
||||
const { container } = render(<>{decorated}</>);
|
||||
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith(
|
||||
"What is your #recall:name/fallback:name# answer?"
|
||||
);
|
||||
expect(container.textContent).toContain("@Processed Label");
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, NodeKey, S
|
||||
import { $applyNodeReplacement, DecoratorNode } from "lexical";
|
||||
import { ReactNode } from "react";
|
||||
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
|
||||
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
|
||||
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
|
||||
|
||||
export interface RecallPayload {
|
||||
recallItem: TSurveyRecallItem;
|
||||
@@ -134,12 +134,13 @@ export class RecallNode extends DecoratorNode<ReactNode> {
|
||||
}
|
||||
|
||||
decorate(): ReactNode {
|
||||
const displayLabel = replaceRecallInfoWithUnderline(this.__recallItem.label);
|
||||
const displayLabel = getTextContentWithRecallTruncated(this.__recallItem.label);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="recall-node z-30 inline-flex h-fit justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-slate-700"
|
||||
aria-label={`Recall: ${displayLabel}`}>
|
||||
className="recall-node z-30 inline-flex h-fit justify-center whitespace-nowrap rounded-md bg-slate-100 text-sm text-slate-700"
|
||||
aria-label={`Recall: ${displayLabel}`}
|
||||
title={displayLabel}>
|
||||
@{displayLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -223,7 +223,15 @@ export const RecallPlugin = ({
|
||||
}
|
||||
});
|
||||
},
|
||||
[localSurvey, selectedLanguageCode, editor, convertTextToRecallNodes, findAllRecallNodes]
|
||||
[
|
||||
findAllRecallNodes,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setRecallItems,
|
||||
setFallbacks,
|
||||
editor,
|
||||
convertTextToRecallNodes,
|
||||
]
|
||||
);
|
||||
|
||||
// Handle @ key press for recall trigger
|
||||
@@ -260,7 +268,7 @@ export const RecallPlugin = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[editor]
|
||||
[editor, setShowRecallItemSelect]
|
||||
);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
@@ -277,7 +285,7 @@ export const RecallPlugin = ({
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
}, [showRecallItemSelect]);
|
||||
}, [setShowRecallItemSelect, showRecallItemSelect]);
|
||||
|
||||
// Clean up when dropdown closes
|
||||
useEffect(() => {
|
||||
@@ -385,11 +393,13 @@ export const RecallPlugin = ({
|
||||
},
|
||||
[
|
||||
editor,
|
||||
setShowRecallItemSelect,
|
||||
recallItems,
|
||||
setRecallItems,
|
||||
atSymbolPosition,
|
||||
replaceAtSymbolWithStoredPosition,
|
||||
replaceAtSymbolWithCurrentSelection,
|
||||
onShowFallbackInput,
|
||||
recallItems,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ import {
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical";
|
||||
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
|
||||
@@ -312,25 +310,8 @@ export const ToolbarPlugin = (
|
||||
}
|
||||
}, [editor, isLink, props]);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
(e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text/plain");
|
||||
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertRawText(text ?? "");
|
||||
}
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return true; // Prevent the default paste handler
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL
|
||||
);
|
||||
}, [editor]);
|
||||
// Removed custom PASTE_COMMAND handler to allow Lexical's default paste handler
|
||||
// to properly preserve rich text formatting (bold, italic, links, etc.)
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
@@ -423,18 +404,20 @@ export const ToolbarPlugin = (
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
icon={icon}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
icon={icon}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
tooltipText={tooltipText}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
border-color: #cbd5e1;
|
||||
border-width: 1px;
|
||||
padding: 1px;
|
||||
@@ -36,11 +35,11 @@
|
||||
position: relative;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
overflow: auto;
|
||||
overflow-y: auto;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
min-height: var(--editor-min-height, 40px);
|
||||
max-height: 150px;
|
||||
min-height: var(--editor-min-height, 48px);
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.editor-input {
|
||||
@@ -49,7 +48,7 @@
|
||||
position: relative;
|
||||
tab-size: 1;
|
||||
outline: 0;
|
||||
padding: 10px 10px;
|
||||
padding: 5px 10px 10px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -349,4 +348,4 @@ i.link {
|
||||
|
||||
.inactive-button {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user