chore: refactors question form input (#4567)

This commit is contained in:
Anshuman Pandey
2025-01-10 21:03:17 +05:30
committed by GitHub
parent 3ca1a72c6a
commit 2e2f0fdbb5
11 changed files with 721 additions and 456 deletions

View File

@@ -281,7 +281,12 @@ export const EditEndingCard = ({
/>
)}
{endingCard.type === "redirectToUrl" && (
<RedirectUrlForm endingCard={endingCard} updateSurvey={updateSurvey} />
<RedirectUrlForm
localSurvey={localSurvey}
endingCard={endingCard}
updateSurvey={updateSurvey}
contactAttributeKeys={contactAttributeKeys}
/>
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -1,26 +1,84 @@
import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { useTranslations } from "next-intl";
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { useRef } from "react";
import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSurvey, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
interface RedirectUrlFormProps {
localSurvey: TSurvey;
endingCard: TSurveyRedirectUrlCard;
updateSurvey: (input: Partial<TSurveyRedirectUrlCard>) => void;
contactAttributeKeys: TContactAttributeKey[];
}
export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormProps) => {
export const RedirectUrlForm = ({
localSurvey,
contactAttributeKeys,
endingCard,
updateSurvey,
}: RedirectUrlFormProps) => {
const selectedLanguageCode = "default";
const t = useTranslations();
const inputRef = useRef<HTMLInputElement>(null);
return (
<form className="mt-3 space-y-3">
<div className="space-y-2">
<Label>{t("common.url")}</Label>
<Input
id="redirectUrl"
name="redirectUrl"
className="bg-white"
placeholder="https://formbricks.com"
value={endingCard.url}
onChange={(e) => updateSurvey({ url: e.target.value })}
<RecallWrapper
value={endingCard.url ?? ""}
questionId={endingCard.id}
onChange={(val, recallItems, fallbacks) => {
const updatedValue = {
...endingCard,
url: recallItems && fallbacks ? headlineToRecall(val, recallItems, fallbacks) : val,
};
updateSurvey(updatedValue);
}}
onAddFallback={() => {
inputRef.current?.focus();
}}
contactAttributeKeys={contactAttributeKeys}
isRecallAllowed
localSurvey={localSurvey}
usedLanguageCode={"default"}
render={({ value, onChange, highlightedJSX, children }) => {
return (
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
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`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
</div>
<Input
ref={inputRef}
id="redirectUrl"
name="redirectUrl"
className="relative text-black caret-black"
placeholder="https://formbricks.com"
value={
recallToHeadline(
{
[selectedLanguageCode]: value,
},
localSurvey,
false,
"default",
contactAttributeKeys
)[selectedLanguageCode]
}
onChange={(e) => onChange(e.target.value)}
/>
{children}
</div>
);
}}
/>
</div>
<div className="space-y-2">

View File

@@ -258,6 +258,7 @@ export const SurveyMenuBar = ({
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
return false;
}
return true;

View File

@@ -44,7 +44,7 @@ export function LanguageIndicator({
});
return (
<div className="absolute right-2 top-2">
<div className="absolute right-2 top-2 z-10">
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
@@ -61,7 +61,8 @@ export function LanguageIndicator({
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== languageToBeDisplayed?.language.code && (
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}

View File

@@ -0,0 +1,101 @@
import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator";
import { useTranslations } from "next-intl";
import React, { ReactNode, useMemo } from "react";
import { getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface MultiLangWrapperRenderProps {
value: TI18nString;
onChange: (value: string, recallItems?: TSurveyRecallItem[], fallbacks?: { [key: string]: string }) => void;
children?: ReactNode;
}
interface MultiLangWrapperProps {
isTranslationIncomplete: boolean;
value: TI18nString;
onChange: (value: TI18nString) => void;
localSurvey: TSurvey;
selectedLanguageCode: string;
setSelectedLanguageCode: (code: string) => void;
locale: TUserLocale;
render: (props: MultiLangWrapperRenderProps) => ReactNode;
contactAttributeKeys?: TContactAttributeKey[];
}
export const MultiLangWrapper = ({
isTranslationIncomplete,
value,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
render,
onChange,
contactAttributeKeys,
}: MultiLangWrapperProps) => {
const t = useTranslations();
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const enabledLanguages = useMemo(
() => getEnabledLanguages(localSurvey.languages ?? []),
[localSurvey.languages]
);
const handleChange = (
newValue: string,
recallItems?: TSurveyRecallItem[],
fallbacks?: { [key: string]: string }
) => {
const updatedValue = {
...value,
[usedLanguageCode]:
recallItems && fallbacks ? headlineToRecall(newValue, recallItems, fallbacks) : newValue,
};
onChange(updatedValue);
};
return (
<div className="w-full">
<div>
{render({
value,
onChange: handleChange,
children:
enabledLanguages.length > 1 ? (
<LanguageIndicator
selectedLanguageCode={usedLanguageCode}
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
) : null,
})}
</div>
{enabledLanguages.length > 1 && (
<>
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
<div className="mt-1 text-xs text-slate-500">
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
{contactAttributeKeys
? recallToHeadline(value, localSurvey, false, "default", contactAttributeKeys)["default"]
: value.default}
</div>
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
<div className="mt-1 text-xs text-red-400">
{t("environments.project.languages.incomplete_translations")}
</div>
)}
</>
)}
</div>
);
};

View File

@@ -7,6 +7,7 @@ import { Input } from "@/modules/ui/components/input";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import {
CalendarDaysIcon,
ContactIcon,
EyeOffIcon,
FileDigitIcon,
FileTextIcon,
@@ -40,6 +41,7 @@ const questionIconMapping = {
date: CalendarDaysIcon,
cal: PhoneIcon,
address: HomeIcon,
contactInfo: ContactIcon,
ranking: ListOrderedIcon,
};
@@ -92,20 +94,20 @@ export const RecallItemSelect = ({
}));
}
return [];
}, [localSurvey.hiddenFields]);
}, [localSurvey.hiddenFields, recallItemIds]);
const attributeClassRecallItems = useMemo(() => {
const contactAttributekeysRecallItems = useMemo(() => {
if (localSurvey.type !== "app") return [];
return contactAttributeKeys
.filter((attributeKey) => !recallItemIds.includes(attributeKey.key.replaceAll(" ", "nbsp")))
.map((attributeKey) => {
return {
id: attributeKey.key.replaceAll(" ", "nbsp"),
label: attributeKey.name ?? attributeKey.key,
label: attributeKey.key,
type: "attributeClass" as const,
};
});
}, [contactAttributeKeys]);
}, [contactAttributeKeys, localSurvey.type, recallItemIds]);
const variableRecallItems = useMemo(() => {
if (localSurvey.variables.length) {
@@ -146,7 +148,7 @@ export const RecallItemSelect = ({
return [
...surveyQuestionRecallItems,
...hiddenFieldRecallItems,
...attributeClassRecallItems,
...contactAttributekeysRecallItems,
...variableRecallItems,
].filter((recallItems) => {
if (searchValue.trim() === "") return true;
@@ -157,7 +159,7 @@ export const RecallItemSelect = ({
}, [
surveyQuestionRecallItems,
hiddenFieldRecallItems,
attributeClassRecallItems,
contactAttributekeysRecallItems,
variableRecallItems,
searchValue,
]);

View File

@@ -0,0 +1,309 @@
import { FallbackInput } from "@/modules/surveys/components/QuestionFormInput/components/FallbackInput";
import { RecallItemSelect } from "@/modules/surveys/components/QuestionFormInput/components/RecallItemSelect";
import { Button } from "@/modules/ui/components/button";
import { PencilIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import {
extractId,
extractRecallInfo,
findRecallInfoById,
getFallbackValues,
getRecallItems,
headlineToRecall,
recallToHeadline,
replaceRecallInfoWithUnderline,
} from "@formbricks/lib/utils/recall";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
interface RecallWrapperRenderProps {
value: string;
onChange: (val: string) => void;
highlightedJSX: JSX.Element[];
children: ReactNode;
isRecallSelectVisible: boolean;
}
interface RecallWrapperProps {
value: string;
onChange: (val: string, recallItems: TSurveyRecallItem[], fallbacks: { [id: string]: string }) => void;
localSurvey: TSurvey;
questionId: string;
contactAttributeKeys: TContactAttributeKey[];
render: (props: RecallWrapperRenderProps) => React.ReactNode;
usedLanguageCode: string;
isRecallAllowed: boolean;
onAddFallback: (fallback: string) => void;
}
export const RecallWrapper = ({
value,
onChange,
localSurvey,
questionId,
contactAttributeKeys,
render,
usedLanguageCode,
isRecallAllowed,
onAddFallback,
}: RecallWrapperProps) => {
const t = useTranslations();
const [showRecallItemSelect, setShowRecallItemSelect] = useState(false);
const [showFallbackInput, setShowFallbackInput] = useState(false);
const [recallItems, setRecallItems] = useState<TSurveyRecallItem[]>(
value.includes("#recall:")
? getRecallItems(value, localSurvey, usedLanguageCode, contactAttributeKeys)
: []
);
const [fallbacks, setFallbacks] = useState<{ [id: string]: string }>(
value.includes("/fallback:") ? getFallbackValues(value) : {}
);
const [internalValue, setInternalValue] = useState<string>(headlineToRecall(value, recallItems, fallbacks));
const [renderedText, setRenderedText] = useState<JSX.Element[]>([]);
const fallbackInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setInternalValue(headlineToRecall(value, recallItems, fallbacks));
}, [value, recallItems, fallbacks]);
const checkForRecallSymbol = useCallback((str: string) => {
// Get cursor position by finding last character
// Only trigger when @ is the last character typed
const lastChar = str[str.length - 1];
const shouldShow = lastChar === "@";
setShowRecallItemSelect(shouldShow);
}, []);
const handleInputChange = useCallback(
(newVal: string) => {
const updatedText = {
[usedLanguageCode]: newVal,
};
const val = recallToHeadline(updatedText, localSurvey, false, usedLanguageCode, contactAttributeKeys)[
usedLanguageCode
];
setInternalValue(newVal);
if (isRecallAllowed) {
checkForRecallSymbol(val);
}
onChange(newVal, recallItems, fallbacks);
},
[
checkForRecallSymbol,
contactAttributeKeys,
isRecallAllowed,
localSurvey,
onChange,
recallItems,
fallbacks,
usedLanguageCode,
]
);
const addRecallItem = useCallback(
(recallItem: TSurveyRecallItem) => {
if (recallItem.label.trim() === "") {
toast.error("Recall item label cannot be empty");
return;
}
let recallItemTemp = structuredClone(recallItem);
recallItemTemp.label = replaceRecallInfoWithUnderline(recallItem.label);
const updatedRecallItems = [...recallItems, recallItemTemp];
setRecallItems(updatedRecallItems);
if (!Object.keys(fallbacks).includes(recallItem.id)) {
setFallbacks((prevFallbacks) => ({
...prevFallbacks,
[recallItem.id]: "",
}));
}
setShowRecallItemSelect(false);
let modifiedHeadlineWithId = { [usedLanguageCode]: internalValue };
modifiedHeadlineWithId[usedLanguageCode] = modifiedHeadlineWithId[usedLanguageCode].replace(
/@(\b|$)/g,
`#recall:${recallItem.id}/fallback:# `
);
onChange(modifiedHeadlineWithId[usedLanguageCode], updatedRecallItems, fallbacks);
setInternalValue(modifiedHeadlineWithId[usedLanguageCode]);
setShowFallbackInput(true);
},
[fallbacks, usedLanguageCode, internalValue, onChange, recallItems]
);
const addFallback = useCallback(() => {
let newVal = internalValue;
recallItems.forEach((item) => {
const recallInfo = findRecallInfoById(newVal, item.id);
if (recallInfo) {
const fallbackValue = (fallbacks[item.id]?.trim() || "").replace(/ /g, "nbsp");
let updatedFallbacks = { ...fallbacks };
updatedFallbacks[item.id] = fallbackValue;
setFallbacks(updatedFallbacks);
newVal = newVal.replace(recallInfo, `#recall:${item.id}/fallback:${fallbackValue}#`);
onChange(newVal, recallItems, updatedFallbacks);
}
});
setShowFallbackInput(false);
setShowRecallItemSelect(false);
onAddFallback(newVal);
}, [fallbacks, recallItems, internalValue, onChange, onAddFallback]);
const filterRecallItems = useCallback(
(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 newInternalValue = internalValue.replace(`@${recallItemToRemove}`, "");
setInternalValue(newInternalValue);
onChange(newInternalValue, recallItems, fallbacks);
let updatedFallback = { ...fallbacks };
delete updatedFallback[recallItem.id];
setFallbacks(updatedFallback);
setRecallItems(includedRecallItems);
}
});
},
[fallbacks, internalValue, onChange, recallItems, setInternalValue]
);
useEffect(() => {
if (showFallbackInput && fallbackInputRef.current) {
fallbackInputRef.current.focus();
}
}, [showFallbackInput]);
useEffect(() => {
const recallItemLabels = recallItems.flatMap((recallItem) => {
if (!recallItem.label.includes("#recall:")) {
return [recallItem.label];
}
const info = extractRecallInfo(recallItem.label);
if (info) {
const recallItemId = extractId(info);
const recallQuestion = localSurvey.questions.find((q) => q.id === recallItemId);
if (recallQuestion) {
// replace nested recall with "___"
return [recallItem.label.replace(info, "___")];
}
}
return [];
});
const processInput = (): JSX.Element[] => {
const parts: JSX.Element[] = [];
let remainingText = recallToHeadline(
{ [usedLanguageCode]: internalValue },
localSurvey,
false,
usedLanguageCode,
contactAttributeKeys
)[usedLanguageCode];
filterRecallItems(remainingText);
recallItemLabels.forEach((label) => {
const index = remainingText.indexOf("@" + label);
if (index !== -1) {
if (index > 0) {
parts.push(
<span key={`text-${parts.length}`} className="whitespace-pre">
{remainingText.substring(0, index)}
</span>
);
}
parts.push(
<span
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
key={`recall-${parts.length}`}>
{"@" + label}
</span>
);
remainingText = remainingText.substring(index + label.length + 1);
}
});
if (remainingText?.length) {
parts.push(
<span className="whitespace-pre" key={`remaining-${parts.length}`}>
{remainingText}
</span>
);
}
return parts;
};
setRenderedText(processInput());
}, [internalValue, recallItems]);
return (
<div className="relative">
{render({
value: internalValue,
onChange: handleInputChange,
highlightedJSX: renderedText,
isRecallSelectVisible: showRecallItemSelect,
children: (
<div>
{internalValue.includes("recall:") && (
<Button
variant="ghost"
type="button"
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
onClick={(e) => {
e.preventDefault();
setShowFallbackInput(true);
}}>
{t("environments.surveys.edit.edit_recall")}
<PencilIcon className="h-3 w-3" />
</Button>
)}
{showRecallItemSelect && (
<RecallItemSelect
localSurvey={localSurvey}
questionId={questionId}
addRecallItem={addRecallItem}
setShowRecallItemSelect={setShowRecallItemSelect}
recallItems={recallItems}
selectedLanguageCode={usedLanguageCode}
hiddenFields={localSurvey.hiddenFields}
contactAttributeKeys={contactAttributeKeys}
/>
)}
{showFallbackInput && recallItems.length > 0 && (
<FallbackInput
filteredRecallItems={recallItems}
fallbacks={fallbacks}
setFallbacks={setFallbacks}
fallbackInputRef={fallbackInputRef}
addFallback={addFallback}
/>
)}
</div>
),
})}
</div>
);
};

View File

@@ -1,6 +1,7 @@
"use client";
import { LanguageIndicator } from "@/modules/ee/multi-language-surveys/components/language-indicator";
import { MultiLangWrapper } from "@/modules/surveys/components/QuestionFormInput/components/MultiLangWrapper";
import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
@@ -8,28 +9,12 @@ import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { debounce } from "lodash";
import { ImagePlusIcon, PencilIcon, TrashIcon } from "lucide-react";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { type JSX, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import {
createI18nString,
extractLanguageCodes,
getEnabledLanguages,
getLocalizedValue,
} from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { useSyncScroll } from "@formbricks/lib/utils/hooks/useSyncScroll";
import {
extractId,
extractRecallInfo,
findRecallInfoById,
getFallbackValues,
getRecallItems,
headlineToRecall,
recallToHeadline,
replaceRecallInfoWithUnderline,
} from "@formbricks/lib/utils/recall";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import {
TI18nString,
@@ -37,12 +22,9 @@ import {
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRecallItem,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FallbackInput } from "./components/FallbackInput";
import { RecallItemSelect } from "./components/RecallItemSelect";
import {
determineImageUploaderVisibility,
getChoiceLabel,
@@ -116,11 +98,6 @@ export const QuestionFormInput = ({
: question.id;
}, [isWelcomeCard, isEndingCard, question?.id]);
const enabledLanguages = useMemo(
() => getEnabledLanguages(localSurvey.languages ?? []),
[localSurvey.languages]
);
const surveyLanguageCodes = useMemo(
() => extractLanguageCodes(localSurvey.languages),
[localSurvey.languages]
@@ -171,140 +148,16 @@ export const QuestionFormInput = ({
]);
const [text, setText] = useState(elementText);
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, usedLanguageCode).includes("#recall:")
? getRecallItems(
getLocalizedValue(text, usedLanguageCode),
localSurvey,
usedLanguageCode,
contactAttributeKeys
)
: []
);
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(() => {
const localizedValue = getLocalizedValue(text, usedLanguageCode);
return localizedValue.includes("/fallback:") ? getFallbackValues(localizedValue) : {};
});
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(() => {
setRecallItems(
getLocalizedValue(text, usedLanguageCode).includes("#recall:")
? getRecallItems(
getLocalizedValue(text, usedLanguageCode),
localSurvey,
usedLanguageCode,
contactAttributeKeys
)
: []
);
}, [usedLanguageCode]);
useEffect(() => {
// 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, usedLanguageCode, contactAttributeKeys)[
usedLanguageCode
];
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 h-fit cursor-pointer 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, recallItems]);
useEffect(() => {
if (fallbackInputRef.current) {
fallbackInputRef.current.focus();
}
}, [showFallbackInput]);
// useEffect(() => {
// setText(getElementTextBasedOnType());
// }, [localSurvey]);
const checkForRecallSymbol = useCallback(
(value: TI18nString) => {
const pattern = /(^|\s)@(\s|$)/;
if (pattern.test(getLocalizedValue(value, usedLanguageCode))) {
setShowRecallItemSelect(true);
} else {
setShowRecallItemSelect(false);
}
},
[usedLanguageCode]
);
// updation of questions, WelcomeCard, ThankYouCard and choices is done in a different manner,
// questions -> updateQuestion
// thankYouCard, welcomeCard-> updateSurvey
// choice -> updateChoice
// matrixLabel -> updateMatrixLabel
const createUpdatedText = useCallback(
(updatedText: string): TI18nString => {
return {
@@ -391,103 +244,6 @@ export const QuestionFormInput = ({
]
);
// Adds a new recall question to the recallItems array, updates fallbacks, modifies the text with recall details.
const addRecallItem = useCallback(
(recallItem: TSurveyRecallItem) => {
if (recallItem.label.trim() === "") {
toast.error(t("environments.surveys.edit.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 = { ...elementText };
modifiedHeadlineWithId[usedLanguageCode] = getLocalizedValue(
modifiedHeadlineWithId,
usedLanguageCode
).replace(/(?<=^|\s)@(?=\s|$)/g, `#recall:${recallItem.id}/fallback:# `);
handleUpdate(getLocalizedValue(modifiedHeadlineWithId, usedLanguageCode));
const modifiedHeadlineWithName = recallToHeadline(
modifiedHeadlineWithId,
localSurvey,
false,
usedLanguageCode,
contactAttributeKeys
);
setText(modifiedHeadlineWithName);
setShowFallbackInput(true);
},
[contactAttributeKeys, elementText, fallbacks, handleUpdate, localSurvey, usedLanguageCode]
);
// 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 = useCallback(
(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[usedLanguageCode] = text[usedLanguageCode].replace(`@${recallItemToRemove}`, "");
setText(newText);
handleUpdate(text[usedLanguageCode].replace(`@${recallItemToRemove}`, ""));
let updatedFallback = { ...fallbacks };
delete updatedFallback[recallItem.id];
setFallbacks(updatedFallback);
setRecallItems(includedRecallItems);
}
});
},
[fallbacks, handleUpdate, recallItems, text, usedLanguageCode]
);
const addFallback = () => {
let headlineWithFallback = elementText;
filteredRecallItems.forEach((recallQuestion) => {
if (recallQuestion) {
const recallInfo = findRecallInfoById(
getLocalizedValue(headlineWithFallback, usedLanguageCode),
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[usedLanguageCode] = getLocalizedValue(
headlineWithFallback,
usedLanguageCode
).replace(recallInfo, `#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`);
handleUpdate(getLocalizedValue(headlineWithFallback, usedLanguageCode));
}
}
});
setShowFallbackInput(false);
inputRef.current?.focus();
};
const getFileUrl = (): string | undefined => {
if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
if (isEndingCard) {
@@ -504,197 +260,173 @@ export const QuestionFormInput = ({
} else return question.videoUrl;
};
const debouncedHandleUpdate = useMemo(
() => debounce((value) => handleUpdate(headlineToRecall(value, recallItems, fallbacks)), 100),
[handleUpdate, recallItems, fallbacks]
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const updatedText = {
...elementText,
[usedLanguageCode]: value,
};
const valueTI18nString = recallToHeadline(
updatedText,
localSurvey,
false,
usedLanguageCode,
contactAttributeKeys
);
setText(valueTI18nString);
if (id === "headline" || id === "subheader") {
checkForRecallSymbol(valueTI18nString);
}
debouncedHandleUpdate(value);
};
const debouncedHandleUpdate = useMemo(() => debounce((value) => handleUpdate(value), 100), [handleUpdate]);
const [animationParent] = useAutoAnimate();
return (
<div className="w-full">
<div className="w-full">
{label && (
<div className="mb-2 mt-3">
<Label htmlFor={id}>{label}</Label>
</div>
)}
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
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 (isEndingCard && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
updateQuestion(questionIdx, update);
}
}
{label && (
<div className="mb-2 mt-3">
<Label htmlFor={id}>{label}</Label>
</div>
)}
<MultiLangWrapper
isTranslationIncomplete={isTranslationIncomplete}
value={text}
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
key={selectedLanguageCode}
onChange={(updatedText) => {
setText(updatedText);
debouncedHandleUpdate(updatedText[usedLanguageCode]);
}}
contactAttributeKeys={contactAttributeKeys}
render={({ value, onChange, children: languageIndicator }) => {
return (
<RecallWrapper
contactAttributeKeys={contactAttributeKeys}
localSurvey={localSurvey}
questionId={questionId}
value={value[usedLanguageCode]}
onChange={(value, recallItems, fallbacks) => {
// Pass all values to MultiLangWrapper's onChange
onChange(value, recallItems, fallbacks);
}}
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={!isWelcomeCard && (id === "headline" || id === "subheader")}
usedLanguageCode={usedLanguageCode}
render={({
value,
onChange,
highlightedJSX,
children: recallComponents,
isRecallSelectVisible,
}) => {
return (
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
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 (isEndingCard && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
updateQuestion(questionIdx, update);
}
}
}}
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
/>
)}
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
{languageIndicator}
{/* The highlight container is absolutely positioned behind the input */}
<div className="h-10 w-full"></div>
<div
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 ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
</div>
<Input
key={`${questionId}-${id}-${usedLanguageCode}`}
value={
recallToHeadline(
{
[usedLanguageCode]: value,
},
localSurvey,
false,
usedLanguageCode,
contactAttributeKeys
)[usedLanguageCode]
}
dir="auto"
onChange={(e) => onChange(e.target.value)}
id={id}
name={id}
placeholder={placeholder ?? getPlaceHolderById(id, t)}
aria-label={label}
maxLength={maxLength}
ref={inputRef}
onBlur={onBlur}
className={`absolute top-0 text-black caret-black ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
} ${className}`}
isInvalid={
isInvalid &&
text[usedLanguageCode]?.trim() === "" &&
localSurvey.languages?.length > 1 &&
isTranslationIncomplete
}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={id === "headline"}
/>
{recallComponents}
</div>
<div>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
className="ml-2"
onClick={(e) => {
e.preventDefault();
setShowImageUploader((prev) => !prev);
}}>
<ImagePlusIcon />
</Button>
</TooltipRenderer>
)}
{id === "subheader" && question && question.subheader !== undefined && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
size="icon"
aria-label="Remove description"
className="ml-2"
onClick={(e) => {
e.preventDefault();
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
)}
</div>
</div>
</div>
);
}}
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 ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto">
{renderedText}
</div>
{getLocalizedValue(elementText, usedLanguageCode).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);
}}>
{t("environments.surveys.edit.edit_recall")}
<PencilIcon className="ml-2 h-3 w-3" />
</button>
)}
<Input
key={`${questionId}-${id}-${usedLanguageCode}`}
dir="auto"
className={`absolute top-0 text-black caret-black ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
} ${className}`}
placeholder={placeholder ? placeholder : getPlaceHolderById(id, t)}
id={id}
name={id}
aria-label={label}
autoComplete={showRecallItemSelect ? "off" : "on"}
value={
recallToHeadline(text, localSurvey, false, usedLanguageCode, contactAttributeKeys)[
usedLanguageCode
]
}
onChange={handleInputChange}
ref={inputRef}
onBlur={onBlur}
maxLength={maxLength ?? undefined}
autoFocus={id === "headline"}
isInvalid={
isInvalid &&
text[usedLanguageCode]?.trim() === "" &&
localSurvey.languages?.length > 1 &&
isTranslationIncomplete
}
/>
{enabledLanguages.length > 1 && (
<LanguageIndicator
selectedLanguageCode={usedLanguageCode}
surveyLanguages={enabledLanguages}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
)}
{!showRecallItemSelect && showFallbackInput && recallItems.length > 0 && (
<FallbackInput
filteredRecallItems={filteredRecallItems}
fallbacks={fallbacks}
setFallbacks={setFallbacks}
fallbackInputRef={fallbackInputRef}
addFallback={addFallback}
/>
)}
</div>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
className="ml-2"
onClick={(e) => {
e.preventDefault();
setShowImageUploader((prev) => !prev);
}}>
<ImagePlusIcon />
</Button>
</TooltipRenderer>
)}
{id === "subheader" && question && question.subheader !== undefined && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
size="icon"
aria-label="Remove description"
className="ml-2"
onClick={(e) => {
e.preventDefault();
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
)}
</div>
</div>
{showRecallItemSelect && (
<RecallItemSelect
localSurvey={localSurvey}
questionId={questionId}
addRecallItem={addRecallItem}
setShowRecallItemSelect={setShowRecallItemSelect}
recallItems={recallItems}
selectedLanguageCode={usedLanguageCode}
hiddenFields={localSurvey.hiddenFields}
contactAttributeKeys={contactAttributeKeys}
/>
)}
</div>
{usedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
<div className="mt-1 text-xs text-slate-500">
<strong>{t("environments.project.languages.translate")}:</strong>{" "}
{recallToHeadline(value, localSurvey, false, "default", contactAttributeKeys)["default"]}
</div>
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
<div className="mt-1 text-xs text-red-400">
{t("environments.project.languages.incomplete_translations")}
</div>
)}
);
}}
/>
</div>
);
};

View File

@@ -66,7 +66,14 @@ export function EndingCard({
useEffect(() => {
if (isCurrent) {
if (!isRedirectDisabled && endingCard.type === "redirectToUrl" && endingCard.url) {
window.top?.location.replace(endingCard.url);
try {
const url = replaceRecallInfo(endingCard.url, responseData, variablesData);
if (url && new URL(url)) {
window.top?.location.replace(url);
}
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
}
}
const handleEnter = (e: KeyboardEvent) => {

View File

@@ -53,6 +53,18 @@ export const getZSafeUrl = (message: string): z.ZodEffects<z.ZodString, string,
z
.string()
.url({ message })
.refine((url) => url.startsWith("https://"), {
message: "URL must start with https://",
.superRefine((url, ctx) => {
if (url.includes(" ")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "URL must not contain spaces",
});
}
if (!url.startsWith("https://")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "URL must start with https://",
});
}
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-new -- required for error */
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
@@ -38,9 +39,45 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({
export type TSurveyEndScreenCard = z.infer<typeof ZSurveyEndScreenCard>;
const validateUrlWithRecall = (url: string): string | null => {
try {
if (!url.startsWith("https://")) {
return "URL must start with https://";
}
if (url.includes(" ") && !url.endsWith(" ")) {
return "URL must not contain spaces";
}
new URL(url);
return null;
} catch {
const hostname = url.split("https://")[1];
if (hostname.includes("#recall:")) {
return "Recall information cannot be used in the hostname part of the URL";
}
return "Invalid Redirect URL";
}
};
export const ZSurveyRedirectUrlCard = ZSurveyEndingBase.extend({
type: z.literal("redirectToUrl"),
url: getZSafeUrl("Invalid Redirect Url").optional(),
url: z
.string()
.optional()
.superRefine((url, ctx) => {
if (!url) return;
const error = validateUrlWithRecall(url);
if (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: error,
});
}
}),
label: z.string().optional(),
});