"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, getRecallQuestions, headlineToRecall, recallToHeadline, replaceRecallInfoWithUnderline, } from "@formbricks/lib/utils/recall"; import { TI18nString, TSurvey, TSurveyChoice, TSurveyQuestion } 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 RecallQuestionSelect from "./components/RecallQuestionSelect"; import { isValueIncomplete } from "./lib/utils"; import { determineImageUploaderVisibility, getCardText, getChoiceLabel, getIndex, getLabelById, getMatrixLabel, getPlaceHolderById, } from "./utils"; interface QuestionFormInputProps { id: string; value: TI18nString | undefined; localSurvey: TSurvey; questionIdx: number; updateQuestion?: (questionIdx: number, data: Partial) => void; updateSurvey?: (data: Partial) => void; updateChoice?: (choiceIdx: number, data: Partial) => void; updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial) => void; isInvalid: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; label?: string; maxLength?: number; placeholder?: string; ref?: RefObject; onBlur?: React.FocusEventHandler; className?: string; } export const QuestionFormInput = ({ id, value, localSurvey, questionIdx, updateQuestion, updateSurvey, updateChoice, updateMatrixLabel, isInvalid, label, selectedLanguageCode, setSelectedLanguageCode, maxLength, placeholder, onBlur, className, }: 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(); const [showImageUploader, setShowImageUploader] = useState( determineImageUploaderVisibility(questionIdx, localSurvey) ); const [showQuestionSelect, setShowQuestionSelect] = useState(false); const [showFallbackInput, setShowFallbackInput] = useState(false); const [recallQuestions, setRecallQuestions] = useState( getLocalizedValue(text, selectedLanguageCode).includes("#recall:") ? getRecallQuestions(getLocalizedValue(text, selectedLanguageCode), localSurvey, selectedLanguageCode) : [] ); const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>( getLocalizedValue(text, selectedLanguageCode).includes("/fallback:") ? getFallbackValues(getLocalizedValue(text, selectedLanguageCode)) : {} ); const highlightContainerRef = useRef(null); const fallbackInputRef = useRef(null); const inputRef = useRef(null); const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => { return recallQuestions.find((q) => q.id === id); }); // Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef. useSyncScroll(highlightContainerRef, inputRef); useEffect(() => { if (!isWelcomeCard && (id === "headline" || id === "subheader")) { checkForRecallSymbol(); } // Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' . const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => { if (!getLocalizedValue(recallQuestion.headline, selectedLanguageCode).includes("#recall:")) { return [(recallQuestion.headline as TI18nString)[selectedLanguageCode]]; } const recallQuestionText = (recallQuestion[id as keyof typeof recallQuestion] as string) || ""; const recallInfo = extractRecallInfo(recallQuestionText); if (recallInfo) { const recallQuestionId = extractId(recallInfo); const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId); if (recallQuestion) { return [recallQuestionText.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)[ selectedLanguageCode ]; filterRecallQuestions(remainingText); recallQuestionHeadlines.forEach((headline) => { const index = remainingText.indexOf("@" + headline); if (index !== -1) { if (index > 0) { parts.push( {remainingText.substring(0, index)} ); } parts.push( {"@" + headline} ); remainingText = remainingText.substring(index + headline.length + 1); } }); if (remainingText?.length) { parts.push( {remainingText} ); } 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))) { setShowQuestionSelect(true); } else { setShowQuestionSelect(false); } }; // Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details. const addRecallQuestion = (recallQuestion: TSurveyQuestion) => { if ((recallQuestion.headline as TI18nString)[selectedLanguageCode].trim() === "") { toast.error("Cannot add question with empty headline as recall"); return; } let recallQuestionTemp = structuredClone(recallQuestion); recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp, selectedLanguageCode); setRecallQuestions((prevQuestions) => { const updatedQuestions = [...prevQuestions, recallQuestionTemp]; return updatedQuestions; }); if (!Object.keys(fallbacks).includes(recallQuestion.id)) { setFallbacks((prevFallbacks) => ({ ...prevFallbacks, [recallQuestion.id]: "", })); } setShowQuestionSelect(false); let modifiedHeadlineWithId = { ...getElementTextBasedOnType() }; modifiedHeadlineWithId[selectedLanguageCode] = getLocalizedValue( modifiedHeadlineWithId, selectedLanguageCode ).replace("@", `#recall:${recallQuestion.id}/fallback:# `); handleUpdate(getLocalizedValue(modifiedHeadlineWithId, selectedLanguageCode)); const modifiedHeadlineWithName = recallToHeadline( modifiedHeadlineWithId, localSurvey, false, selectedLanguageCode ); 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 filterRecallQuestions = (remainingText: string) => { let includedQuestions: TSurveyQuestion[] = []; recallQuestions.forEach((recallQuestion) => { if (remainingText.includes(`@${getLocalizedValue(recallQuestion.headline, selectedLanguageCode)}`)) { includedQuestions.push(recallQuestion); } else { const questionToRemove = getLocalizedValue(recallQuestion.headline, selectedLanguageCode).slice( 0, -1 ); const newText = { ...text }; newText[selectedLanguageCode] = text[selectedLanguageCode].replace(`@${questionToRemove}`, ""); setText(newText); handleUpdate(text[selectedLanguageCode].replace(`@${questionToRemove}`, "")); let updatedFallback = { ...fallbacks }; delete updatedFallback[recallQuestion.id]; setFallbacks(updatedFallback); } }); setRecallQuestions(includedQuestions); }; const addFallback = () => { let headlineWithFallback = getElementTextBasedOnType(); filteredRecallQuestions.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 (
{showImageUploader && id === "headline" && ( { 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} /> )}
{renderedText}
{getLocalizedValue(getElementTextBasedOnType(), selectedLanguageCode).includes("recall:") && ( )} 1 ? "pr-24" : ""} ${className}`} placeholder={placeholder ? placeholder : getPlaceHolderById(id)} id={id} name={id} aria-label={label ? label : getLabelById(id)} autoComplete={showQuestionSelect ? "off" : "on"} value={recallToHeadline(text, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]} ref={inputRef} onBlur={onBlur} onChange={(e) => { let translatedText = { ...getElementTextBasedOnType(), [selectedLanguageCode]: e.target.value, }; setText(recallToHeadline(translatedText, localSurvey, false, selectedLanguageCode)); handleUpdate( headlineToRecall(e.target.value, recallQuestions, fallbacks, selectedLanguageCode) ); }} maxLength={maxLength ?? undefined} isInvalid={ isInvalid && text[selectedLanguageCode]?.trim() === "" && localSurvey.languages?.length > 1 && isTranslationIncomplete } /> {enabledLanguages.length > 1 && ( )} {!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && ( )}
{id === "headline" && !isWelcomeCard && ( setShowImageUploader((prev) => !prev)} /> )}
{showQuestionSelect && ( )}
{selectedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
Translate: {recallToHeadline(value, localSurvey, false, "default")["default"]}
)} {selectedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
Contains Incomplete translations
)}
); }; QuestionFormInput.displayName = "QuestionFormInput";