mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-22 23:10:30 -06:00
chore: refactors question form input (#4567)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -258,6 +258,7 @@ export const SurveyMenuBar = ({
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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://",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user