mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-04 09:29:42 -06:00
534 lines
19 KiB
TypeScript
534 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { PencilIcon } from "lucide-react";
|
|
import { ImagePlusIcon } from "lucide-react";
|
|
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "react-hot-toast";
|
|
|
|
import { extractLanguageCodes, getEnabledLanguages, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
|
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
|
import { useSyncScroll } from "@formbricks/lib/utils/hooks/useSyncScroll";
|
|
import {
|
|
extractId,
|
|
extractRecallInfo,
|
|
findRecallInfoById,
|
|
getFallbackValues,
|
|
getRecallItems,
|
|
headlineToRecall,
|
|
recallToHeadline,
|
|
replaceRecallInfoWithUnderline,
|
|
} from "@formbricks/lib/utils/recall";
|
|
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
|
import {
|
|
TI18nString,
|
|
TSurvey,
|
|
TSurveyChoice,
|
|
TSurveyQuestion,
|
|
TSurveyRecallItem,
|
|
} from "@formbricks/types/surveys";
|
|
|
|
import { LanguageIndicator } from "../../ee/multiLanguage/components/LanguageIndicator";
|
|
import { createI18nString } from "../../lib/i18n/utils";
|
|
import { FileInput } from "../FileInput";
|
|
import { Input } from "../Input";
|
|
import { Label } from "../Label";
|
|
import { FallbackInput } from "./components/FallbackInput";
|
|
import { RecallItemSelect } from "./components/RecallItemSelect";
|
|
import {
|
|
determineImageUploaderVisibility,
|
|
getCardText,
|
|
getChoiceLabel,
|
|
getIndex,
|
|
getMatrixLabel,
|
|
getPlaceHolderById,
|
|
isValueIncomplete,
|
|
} from "./utils";
|
|
|
|
interface QuestionFormInputProps {
|
|
id: string;
|
|
value: TI18nString | undefined;
|
|
localSurvey: TSurvey;
|
|
questionIdx: number;
|
|
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
|
|
updateSurvey?: (data: Partial<TSurveyQuestion>) => void;
|
|
updateChoice?: (choiceIdx: number, data: Partial<TSurveyChoice>) => void;
|
|
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyQuestion>) => void;
|
|
isInvalid: boolean;
|
|
selectedLanguageCode: string;
|
|
setSelectedLanguageCode: (languageCode: string) => void;
|
|
label: string;
|
|
maxLength?: number;
|
|
placeholder?: string;
|
|
ref?: RefObject<HTMLInputElement>;
|
|
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
|
className?: string;
|
|
attributeClasses: TAttributeClass[];
|
|
}
|
|
|
|
export const QuestionFormInput = ({
|
|
id,
|
|
value,
|
|
localSurvey,
|
|
questionIdx,
|
|
updateQuestion,
|
|
updateSurvey,
|
|
updateChoice,
|
|
updateMatrixLabel,
|
|
isInvalid,
|
|
label,
|
|
selectedLanguageCode,
|
|
setSelectedLanguageCode,
|
|
maxLength,
|
|
placeholder,
|
|
onBlur,
|
|
className,
|
|
attributeClasses,
|
|
}: QuestionFormInputProps) => {
|
|
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
|
const isChoice = id.includes("choice");
|
|
const isMatrixLabelRow = id.includes("row");
|
|
const isMatrixLabelColumn = id.includes("column");
|
|
const isThankYouCard = questionIdx === localSurvey.questions.length;
|
|
const isWelcomeCard = questionIdx === -1;
|
|
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
|
|
|
|
const questionId = useMemo(() => {
|
|
return isWelcomeCard ? "start" : isThankYouCard ? "end" : question.id;
|
|
}, [isWelcomeCard, isThankYouCard, question?.id]);
|
|
|
|
const enabledLanguages = useMemo(
|
|
() => getEnabledLanguages(localSurvey.languages ?? []),
|
|
[localSurvey.languages]
|
|
);
|
|
|
|
const surveyLanguageCodes = useMemo(
|
|
() => extractLanguageCodes(localSurvey.languages),
|
|
[localSurvey.languages]
|
|
);
|
|
const isTranslationIncomplete = useMemo(
|
|
() => isValueIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
|
[value, id, isInvalid, surveyLanguageCodes]
|
|
);
|
|
|
|
const getElementTextBasedOnType = (): TI18nString => {
|
|
if (isChoice && typeof index === "number") {
|
|
return getChoiceLabel(question, index, surveyLanguageCodes);
|
|
}
|
|
|
|
if (isThankYouCard || isWelcomeCard) {
|
|
return getCardText(localSurvey, id, isThankYouCard, surveyLanguageCodes);
|
|
}
|
|
|
|
if ((isMatrixLabelColumn || isMatrixLabelRow) && typeof index === "number") {
|
|
return getMatrixLabel(question, index, surveyLanguageCodes, isMatrixLabelRow ? "row" : "column");
|
|
}
|
|
|
|
return (
|
|
(question && (question[id as keyof TSurveyQuestion] as TI18nString)) ||
|
|
createI18nString("", surveyLanguageCodes)
|
|
);
|
|
};
|
|
|
|
const [text, setText] = useState(getElementTextBasedOnType());
|
|
const [renderedText, setRenderedText] = useState<JSX.Element[]>();
|
|
const [showImageUploader, setShowImageUploader] = useState<boolean>(
|
|
determineImageUploaderVisibility(questionIdx, localSurvey)
|
|
);
|
|
const [showRecallItemSelect, setShowRecallItemSelect] = useState(false);
|
|
const [showFallbackInput, setShowFallbackInput] = useState(false);
|
|
const [recallItems, setRecallItems] = useState<TSurveyRecallItem[]>(
|
|
getLocalizedValue(text, selectedLanguageCode).includes("#recall:")
|
|
? getRecallItems(
|
|
getLocalizedValue(text, selectedLanguageCode),
|
|
localSurvey,
|
|
selectedLanguageCode,
|
|
attributeClasses
|
|
)
|
|
: []
|
|
);
|
|
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(
|
|
getLocalizedValue(text, selectedLanguageCode).includes("/fallback:")
|
|
? getFallbackValues(getLocalizedValue(text, selectedLanguageCode))
|
|
: {}
|
|
);
|
|
|
|
const highlightContainerRef = useRef<HTMLInputElement>(null);
|
|
const fallbackInputRef = useRef<HTMLInputElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const filteredRecallItems = Array.from(new Set(recallItems.map((q) => q.id))).map((id) => {
|
|
return recallItems.find((q) => q.id === id);
|
|
});
|
|
|
|
// Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef.
|
|
useSyncScroll(highlightContainerRef, inputRef);
|
|
|
|
useEffect(() => {
|
|
if (id === "headline" || id === "subheader") {
|
|
checkForRecallSymbol();
|
|
}
|
|
// Generates an array of headlines from recallItems, replacing nested recall questions with '___' .
|
|
const recallItemLabels = recallItems.flatMap((recallItem) => {
|
|
if (!recallItem.label.includes("#recall:")) {
|
|
return [recallItem.label];
|
|
}
|
|
const recallItemLabel = recallItem.label;
|
|
const recallInfo = extractRecallInfo(recallItemLabel);
|
|
|
|
if (recallInfo) {
|
|
const recallItemId = extractId(recallInfo);
|
|
const recallQuestion = localSurvey.questions.find((question) => question.id === recallItemId);
|
|
|
|
if (recallQuestion) {
|
|
return [recallItemLabel.replace(recallInfo, `___`)];
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines.
|
|
const processInput = (): JSX.Element[] => {
|
|
const parts: JSX.Element[] = [];
|
|
let remainingText = recallToHeadline(text, localSurvey, false, selectedLanguageCode, attributeClasses)[
|
|
selectedLanguageCode
|
|
];
|
|
filterRecallItems(remainingText);
|
|
recallItemLabels.forEach((label) => {
|
|
const index = remainingText.indexOf("@" + label);
|
|
if (index !== -1) {
|
|
if (index > 0) {
|
|
parts.push(
|
|
<span key={parts.length} className="whitespace-pre">
|
|
{remainingText.substring(0, index)}
|
|
</span>
|
|
);
|
|
}
|
|
parts.push(
|
|
<span
|
|
className="z-30 flex cursor-pointer items-center justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
|
key={parts.length}>
|
|
{"@" + label}
|
|
</span>
|
|
);
|
|
remainingText = remainingText.substring(index + label.length + 1);
|
|
}
|
|
});
|
|
if (remainingText?.length) {
|
|
parts.push(
|
|
<span className="whitespace-pre" key={parts.length}>
|
|
{remainingText}
|
|
</span>
|
|
);
|
|
}
|
|
return parts;
|
|
};
|
|
setRenderedText(processInput());
|
|
}, [text]);
|
|
|
|
useEffect(() => {
|
|
if (fallbackInputRef.current) {
|
|
fallbackInputRef.current.focus();
|
|
}
|
|
}, [showFallbackInput]);
|
|
|
|
useEffect(() => {
|
|
setText(getElementTextBasedOnType());
|
|
}, [localSurvey]);
|
|
|
|
const checkForRecallSymbol = () => {
|
|
const pattern = /(^|\s)@(\s|$)/;
|
|
if (pattern.test(getLocalizedValue(text, selectedLanguageCode))) {
|
|
setShowRecallItemSelect(true);
|
|
} else {
|
|
setShowRecallItemSelect(false);
|
|
}
|
|
};
|
|
|
|
// Adds a new recall question to the recallItems array, updates fallbacks, modifies the text with recall details.
|
|
const addRecallItem = (recallItem: TSurveyRecallItem) => {
|
|
if (recallItem.label.trim() === "") {
|
|
toast.error("Cannot add question with empty headline as recall");
|
|
return;
|
|
}
|
|
let recallItemTemp = structuredClone(recallItem);
|
|
recallItemTemp.label = replaceRecallInfoWithUnderline(recallItem.label);
|
|
setRecallItems((prevQuestions) => {
|
|
const updatedQuestions = [...prevQuestions, recallItemTemp];
|
|
return updatedQuestions;
|
|
});
|
|
if (!Object.keys(fallbacks).includes(recallItem.id)) {
|
|
setFallbacks((prevFallbacks) => ({
|
|
...prevFallbacks,
|
|
[recallItem.id]: "",
|
|
}));
|
|
}
|
|
setShowRecallItemSelect(false);
|
|
let modifiedHeadlineWithId = { ...getElementTextBasedOnType() };
|
|
modifiedHeadlineWithId[selectedLanguageCode] = getLocalizedValue(
|
|
modifiedHeadlineWithId,
|
|
selectedLanguageCode
|
|
).replace(/(?<=^|\s)@(?=\s|$)/g, `#recall:${recallItem.id}/fallback:# `);
|
|
handleUpdate(getLocalizedValue(modifiedHeadlineWithId, selectedLanguageCode));
|
|
const modifiedHeadlineWithName = recallToHeadline(
|
|
modifiedHeadlineWithId,
|
|
localSurvey,
|
|
false,
|
|
selectedLanguageCode,
|
|
attributeClasses
|
|
);
|
|
setText(modifiedHeadlineWithName);
|
|
setShowFallbackInput(true);
|
|
};
|
|
|
|
// Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states.
|
|
const filterRecallItems = (remainingText: string) => {
|
|
let includedRecallItems: TSurveyRecallItem[] = [];
|
|
recallItems.forEach((recallItem) => {
|
|
if (remainingText.includes(`@${recallItem.label}`)) {
|
|
includedRecallItems.push(recallItem);
|
|
} else {
|
|
const recallItemToRemove = recallItem.label.slice(0, -1);
|
|
const newText = { ...text };
|
|
newText[selectedLanguageCode] = text[selectedLanguageCode].replace(`@${recallItemToRemove}`, "");
|
|
setText(newText);
|
|
handleUpdate(text[selectedLanguageCode].replace(`@${recallItemToRemove}`, ""));
|
|
let updatedFallback = { ...fallbacks };
|
|
delete updatedFallback[recallItem.id];
|
|
setFallbacks(updatedFallback);
|
|
}
|
|
});
|
|
setRecallItems(includedRecallItems);
|
|
};
|
|
|
|
const addFallback = () => {
|
|
let headlineWithFallback = getElementTextBasedOnType();
|
|
filteredRecallItems.forEach((recallQuestion) => {
|
|
if (recallQuestion) {
|
|
const recallInfo = findRecallInfoById(
|
|
getLocalizedValue(headlineWithFallback, selectedLanguageCode),
|
|
recallQuestion!.id
|
|
);
|
|
if (recallInfo) {
|
|
let fallBackValue = fallbacks[recallQuestion.id].trim();
|
|
fallBackValue = fallBackValue.replace(/ /g, "nbsp");
|
|
let updatedFallback = { ...fallbacks };
|
|
updatedFallback[recallQuestion.id] = fallBackValue;
|
|
setFallbacks(updatedFallback);
|
|
headlineWithFallback[selectedLanguageCode] = getLocalizedValue(
|
|
headlineWithFallback,
|
|
selectedLanguageCode
|
|
).replace(recallInfo, `#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`);
|
|
handleUpdate(getLocalizedValue(headlineWithFallback, selectedLanguageCode));
|
|
}
|
|
}
|
|
});
|
|
setShowFallbackInput(false);
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
// updation of questions, WelcomeCard, ThankYouCard and choices is done in a different manner,
|
|
// questions -> updateQuestion
|
|
// thankYouCard, welcomeCard-> updateSurvey
|
|
// choice -> updateChoice
|
|
// matrixLabel -> updateMatrixLabel
|
|
|
|
const handleUpdate = (updatedText: string) => {
|
|
const translatedText = createUpdatedText(updatedText);
|
|
|
|
if (isChoice) {
|
|
updateChoiceDetails(translatedText);
|
|
} else if (isThankYouCard || isWelcomeCard) {
|
|
updateSurveyDetails(translatedText);
|
|
} else if (isMatrixLabelRow || isMatrixLabelColumn) {
|
|
updateMatrixLabelDetails(translatedText);
|
|
} else {
|
|
updateQuestionDetails(translatedText);
|
|
}
|
|
};
|
|
|
|
const createUpdatedText = (updatedText: string): TI18nString => {
|
|
return {
|
|
...getElementTextBasedOnType(),
|
|
[selectedLanguageCode]: updatedText,
|
|
};
|
|
};
|
|
|
|
const updateChoiceDetails = (translatedText: TI18nString) => {
|
|
if (updateChoice && typeof index === "number") {
|
|
updateChoice(index, { label: translatedText });
|
|
}
|
|
};
|
|
|
|
const updateSurveyDetails = (translatedText: TI18nString) => {
|
|
if (updateSurvey) {
|
|
updateSurvey({ [id]: translatedText });
|
|
}
|
|
};
|
|
|
|
const updateMatrixLabelDetails = (translatedText: TI18nString) => {
|
|
if (updateMatrixLabel && typeof index === "number") {
|
|
updateMatrixLabel(index, isMatrixLabelRow ? "row" : "column", translatedText);
|
|
}
|
|
};
|
|
|
|
const updateQuestionDetails = (translatedText: TI18nString) => {
|
|
if (updateQuestion) {
|
|
updateQuestion(questionIdx, { [id]: translatedText });
|
|
}
|
|
};
|
|
|
|
const getFileUrl = () => {
|
|
if (isThankYouCard) return localSurvey.thankYouCard.imageUrl;
|
|
else if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
|
|
else return question.imageUrl;
|
|
};
|
|
|
|
const getVideoUrl = () => {
|
|
if (isThankYouCard) return localSurvey.thankYouCard.videoUrl;
|
|
else if (isWelcomeCard) return localSurvey.welcomeCard.videoUrl;
|
|
else return question.videoUrl;
|
|
};
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div className="w-full">
|
|
<div className="mb-2 mt-3">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 bg-white">
|
|
{showImageUploader && id === "headline" && (
|
|
<FileInput
|
|
id="question-image"
|
|
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
|
environmentId={localSurvey.environmentId}
|
|
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
|
|
if (url) {
|
|
const update =
|
|
fileType === "video"
|
|
? { videoUrl: url[0], imageUrl: "" }
|
|
: { imageUrl: url[0], videoUrl: "" };
|
|
if (isThankYouCard && updateSurvey) {
|
|
updateSurvey(update);
|
|
} else if (updateQuestion) {
|
|
updateQuestion(questionIdx, update);
|
|
}
|
|
}
|
|
}}
|
|
fileUrl={getFileUrl()}
|
|
videoUrl={getVideoUrl()}
|
|
isVideoAllowed={true}
|
|
/>
|
|
)}
|
|
<div className="flex items-center space-x-2">
|
|
<div className="group relative w-full ">
|
|
<div className="h-10 w-full "></div>
|
|
<div
|
|
id="wrapper"
|
|
ref={highlightContainerRef}
|
|
className="no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ">
|
|
{renderedText}
|
|
</div>
|
|
{getLocalizedValue(getElementTextBasedOnType(), selectedLanguageCode).includes("recall:") && (
|
|
<button
|
|
className="fixed right-14 hidden items-center rounded-b-lg bg-slate-100 px-2.5 py-1 text-xs hover:bg-slate-200 group-hover:flex"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setShowFallbackInput(true);
|
|
}}>
|
|
Edit Recall
|
|
<PencilIcon className="ml-2 h-3 w-3" />
|
|
</button>
|
|
)}
|
|
<Input
|
|
key={`${questionId}-${id}-${selectedLanguageCode}`}
|
|
className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : ""} ${className}`}
|
|
placeholder={placeholder ? placeholder : getPlaceHolderById(id)}
|
|
id={id}
|
|
name={id}
|
|
aria-label={label}
|
|
autoComplete={showRecallItemSelect ? "off" : "on"}
|
|
value={
|
|
recallToHeadline(text, localSurvey, false, selectedLanguageCode, attributeClasses)[
|
|
selectedLanguageCode
|
|
]
|
|
}
|
|
ref={inputRef}
|
|
onBlur={onBlur}
|
|
onChange={(e) => {
|
|
let translatedText = {
|
|
...getElementTextBasedOnType(),
|
|
[selectedLanguageCode]: e.target.value,
|
|
};
|
|
setText(
|
|
recallToHeadline(
|
|
translatedText,
|
|
localSurvey,
|
|
false,
|
|
selectedLanguageCode,
|
|
attributeClasses
|
|
)
|
|
);
|
|
handleUpdate(headlineToRecall(e.target.value, recallItems, fallbacks));
|
|
}}
|
|
maxLength={maxLength ?? undefined}
|
|
isInvalid={
|
|
isInvalid &&
|
|
text[selectedLanguageCode]?.trim() === "" &&
|
|
localSurvey.languages?.length > 1 &&
|
|
isTranslationIncomplete
|
|
}
|
|
/>
|
|
{enabledLanguages.length > 1 && (
|
|
<LanguageIndicator
|
|
selectedLanguageCode={selectedLanguageCode}
|
|
surveyLanguages={enabledLanguages}
|
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
|
/>
|
|
)}
|
|
{!showRecallItemSelect && showFallbackInput && recallItems.length > 0 && (
|
|
<FallbackInput
|
|
filteredRecallItems={filteredRecallItems}
|
|
fallbacks={fallbacks}
|
|
setFallbacks={setFallbacks}
|
|
fallbackInputRef={fallbackInputRef}
|
|
addFallback={addFallback}
|
|
/>
|
|
)}
|
|
</div>
|
|
{id === "headline" && !isWelcomeCard && (
|
|
<ImagePlusIcon
|
|
aria-label="Toggle image uploader"
|
|
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
|
onClick={() => setShowImageUploader((prev) => !prev)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{showRecallItemSelect && (
|
|
<RecallItemSelect
|
|
localSurvey={localSurvey}
|
|
questionId={questionId}
|
|
addRecallItem={addRecallItem}
|
|
setShowRecallItemSelect={setShowRecallItemSelect}
|
|
recallItems={recallItems}
|
|
selectedLanguageCode={selectedLanguageCode}
|
|
hiddenFields={localSurvey.hiddenFields}
|
|
attributeClasses={attributeClasses}
|
|
/>
|
|
)}
|
|
</div>
|
|
{selectedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
<strong>Translate:</strong>{" "}
|
|
{recallToHeadline(value, localSurvey, false, "default", attributeClasses)["default"]}
|
|
</div>
|
|
)}
|
|
{selectedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
|
|
<div className="mt-1 text-xs text-red-400">Contains Incomplete translations</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
QuestionFormInput.displayName = "QuestionFormInput";
|