Compare commits

...

1 Commits

Author SHA1 Message Date
Johannes
f8baeb7860 strip current multi-lang editing out of survey editor 2026-04-01 16:06:53 +02:00
24 changed files with 206 additions and 1717 deletions

View File

@@ -1,100 +0,0 @@
"use client";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { LanguageIndicator } from "@/modules/survey/multi-language-surveys/components/language-indicator";
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;
}
export const MultiLangWrapper = ({
isTranslationIncomplete,
value,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
render,
onChange,
}: MultiLangWrapperProps) => {
const { t } = useTranslation();
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.workspace.languages.translate")}:</strong>{" "}
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
</div>
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
<div className="mt-1 text-xs text-red-400">
{t("environments.workspace.languages.incomplete_translations")}
</div>
)}
</>
)}
</div>
);
};

View File

@@ -17,15 +17,16 @@ import {
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { isValidHTML } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { md } from "@/lib/markdownIt";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
import { MultiLangWrapper } from "@/modules/survey/components/element-form-input/components/multi-lang-wrapper";
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { RecallWrapper } from "@/modules/survey/components/element-form-input/components/recall-wrapper";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LocalizedEditor } from "@/modules/survey/multi-language-surveys/components/localized-editor";
import { Button } from "@/modules/ui/components/button";
import { Editor } from "@/modules/ui/components/editor";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -39,7 +40,6 @@ import {
getMatrixLabel,
getPlaceHolderById,
getWelcomeCardText,
isValueIncomplete,
} from "./utils";
interface ElementFormInputProps {
@@ -54,8 +54,8 @@ interface ElementFormInputProps {
updateChoice?: (choiceIdx: number, data: Partial<TSurveyElementChoice>) => void;
updateMatrixLabel?: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
selectedLanguageCode?: string;
setSelectedLanguageCode?: (languageCode: string) => void;
label: string;
maxLength?: number;
placeholder?: string;
@@ -80,14 +80,14 @@ export const ElementFormInput = ({
updateChoice,
updateMatrixLabel,
isInvalid,
selectedLanguageCode: _selectedLanguageCode,
setSelectedLanguageCode: _setSelectedLanguageCode,
label,
selectedLanguageCode,
setSelectedLanguageCode,
maxLength,
placeholder,
onBlur,
className,
locale,
locale: _locale,
onKeyDown,
isStorageConfigured = true,
autoFocus,
@@ -96,9 +96,7 @@ export const ElementFormInput = ({
isExternalUrlsAllowed,
}: ElementFormInputProps) => {
const { t } = useTranslation();
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const usedLanguageCode = "default";
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
@@ -106,20 +104,21 @@ export const ElementFormInput = ({
const isChoice = id.includes("choice");
const isMatrixLabelRow = id.includes("row");
const isMatrixLabelColumn = id.includes("column");
const inputId = useMemo(() => {
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
const isEndingCard = elementIdx >= elements.length;
const isWelcomeCard = elementIdx === -1;
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
const elementId = useMemo(() => {
return isWelcomeCard
? "start"
: isEndingCard
? localSurvey.endings[elementIdx - elements.length].id
: currentElement.id;
if (isWelcomeCard) {
return "start";
}
if (isEndingCard) {
return localSurvey.endings[elementIdx - elements.length].id;
}
return currentElement.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
@@ -128,11 +127,6 @@ export const ElementFormInput = ({
() => extractLanguageCodes(localSurvey.languages),
[localSurvey.languages]
);
const isTranslationIncomplete = useMemo(
() => isValueIncomplete(inputId, isInvalid, surveyLanguageCodes, value),
[value, inputId, isInvalid, surveyLanguageCodes]
);
const elementText = useMemo((): TI18nString => {
if (isChoice && typeof index === "number") {
return getChoiceLabel(currentElement, index, surveyLanguageCodes);
@@ -293,14 +287,14 @@ export const ElementFormInput = ({
const getFileUrl = (): string | undefined => {
if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
if (isEndingCard) {
if (endingCard && endingCard.type === "endScreen") return endingCard.imageUrl;
if (endingCard?.type === "endScreen") return endingCard.imageUrl;
} else return currentElement.imageUrl;
};
const getVideoUrl = (): string | undefined => {
if (isWelcomeCard) return localSurvey.welcomeCard.videoUrl;
if (isEndingCard) {
if (endingCard && endingCard.type === "endScreen") return endingCard.videoUrl;
if (endingCard?.type === "endScreen") return endingCard.videoUrl;
} else return currentElement.videoUrl;
};
@@ -446,24 +440,43 @@ export const ElementFormInput = ({
<div className="flex w-full items-start gap-2">
<div className="flex-1">
<LocalizedEditor
key={`${elementId}-${id}-${selectedLanguageCode}`}
<Editor
id={id}
value={value}
localSurvey={localSurvey}
elementIdx={elementIdx}
isInvalid={isInvalid}
updateElement={(isWelcomeCard || isEndingCard ? updateSurvey : updateElement)!}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
disableLists
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
locale={locale}
elementId={elementId}
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
getText={() => {
const text = value ? (value.default ?? "") : "";
let html = md.render(text);
if (id === "headline" && text && !isValidHTML(text)) {
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
}
return html;
}}
key={`${elementId}-${id}-default`}
setFirstRender={setFirstRender}
setText={(editorValue: string) => {
if (suppressEditorUpdatesRef.current) return;
const sanitizedContent = isExternalUrlsAllowed
? editorValue
: editorValue.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
const translatedContent = {
...value,
default: sanitizedContent,
};
if (isWelcomeCard || isEndingCard) {
(updateSurvey as any)?.({ [id]: translatedContent });
return;
}
updateElement?.(elementIdx, { [id]: translatedContent });
}}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode="default"
isExternalUrlsAllowed={isExternalUrlsAllowed}
suppressUpdates={() => suppressEditorUpdatesRef.current}
/>
</div>
@@ -532,98 +545,68 @@ export const ElementFormInput = ({
<Label htmlFor={id}>{label}</Label>
</div>
)}
<MultiLangWrapper
isTranslationIncomplete={isTranslationIncomplete}
value={text}
<RecallWrapper
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
key={selectedLanguageCode}
onChange={(updatedText) => {
elementId={elementId}
value={text[usedLanguageCode]}
onChange={(updatedValue, recallItems, fallbacks) => {
const updatedText =
recallItems && fallbacks
? { ...text, [usedLanguageCode]: headlineToRecall(updatedValue, recallItems, fallbacks) }
: { ...text, [usedLanguageCode]: updatedValue };
setText(updatedText);
debouncedHandleUpdate(updatedText[usedLanguageCode]);
}}
render={({ value, onChange, children: languageIndicator }) => {
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={false}
usedLanguageCode={usedLanguageCode}
render={({ value, onChange, highlightedJSX, children: recallComponents, isRecallSelectVisible }) => {
return (
<RecallWrapper
localSurvey={localSurvey}
elementId={elementId}
value={value[usedLanguageCode]}
onChange={(value, recallItems, fallbacks) => {
// Pass all values to MultiLangWrapper's onChange
onChange(value, recallItems, fallbacks);
}}
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={false}
usedLanguageCode={usedLanguageCode}
render={({
value,
onChange,
highlightedJSX,
children: recallComponents,
isRecallSelectVisible,
}) => {
return (
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
<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={`${elementId}-${id}-${usedLanguageCode}`}
value={
recallToHeadline(
{
[usedLanguageCode]: value,
},
localSurvey,
false,
usedLanguageCode
)[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={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
</div>
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
<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"
dir="auto"
key={`${elementId}-${id}-highlight`}>
{highlightedJSX}
</div>
);
}}
/>
<Input
key={`${elementId}-${id}-${usedLanguageCode}`}
value={
recallToHeadline(
{
[usedLanguageCode]: value,
},
localSurvey,
false,
usedLanguageCode
)[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 ${className}`}
isInvalid={isInvalid && text[usedLanguageCode]?.trim() === ""}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
</div>
</div>
);
}}
/>

View File

@@ -59,8 +59,6 @@ interface BlockCardProps {
setActiveElementId: (elementId: string | null) => void;
lastElement: boolean;
lastElementIndex: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
invalidElements?: string[];
addElement: (element: any, index?: number) => void;
isFormbricksCloud: boolean;
@@ -95,8 +93,6 @@ export const BlockCard = ({
setActiveElementId,
lastElement,
lastElementIndex,
selectedLanguageCode,
setSelectedLanguageCode,
invalidElements,
addElement,
isFormbricksCloud,
@@ -136,11 +132,10 @@ export const BlockCard = ({
const [elementsParent] = useAutoAnimate();
const getElementHeadline = (
element: TSurveyElement,
languageCode: string
element: TSurveyElement
): (string | React.ReactElement)[] | string | undefined => {
const headlineData = recallToHeadline(element.headline, localSurvey, true, languageCode);
const headlineText = headlineData[languageCode];
const headlineData = recallToHeadline(element.headline, localSurvey, true, "default");
const headlineText = headlineData.default;
if (headlineText) {
return formatTextWithSlashes(getTextContent(headlineText ?? ""));
}
@@ -168,8 +163,8 @@ export const BlockCard = ({
element,
elementIdx,
updateElement,
selectedLanguageCode,
setSelectedLanguageCode,
selectedLanguageCode: "default",
setSelectedLanguageCode: () => {},
isInvalid: invalidElements ? invalidElements.includes(element.id) : false,
locale,
isStorageConfigured,
@@ -344,9 +339,7 @@ export const BlockCard = ({
})}
</p>
)}
<h3 className="text-sm font-semibold">
{getElementHeadline(element, selectedLanguageCode)}
</h3>
<h3 className="text-sm font-semibold">{getElementHeadline(element)}</h3>
{!isOpen && element.type !== TSurveyElementTypeEnum.CTA && (
<p className="mt-1 truncate text-xs text-slate-500">
{element?.required
@@ -427,7 +420,7 @@ export const BlockCard = ({
updateElement={updateElement}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
selectedLanguageCode={selectedLanguageCode}
selectedLanguageCode="default"
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
@@ -460,8 +453,7 @@ export const BlockCard = ({
localSurvey={localSurvey}
block={block}
blockIndex={blockIdx}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode="default"
updateBlockButtonLabel={updateBlockButtonLabel}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}

View File

@@ -17,7 +17,6 @@ interface BlockSettingsProps {
block: TSurveyBlock;
blockIndex: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
@@ -35,7 +34,6 @@ export const BlockSettings = ({
block,
blockIndex,
selectedLanguageCode,
setSelectedLanguageCode,
updateBlockButtonLabel,
updateBlockLogic,
updateBlockLogicFallback,
@@ -98,7 +96,6 @@ export const BlockSettings = ({
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.back")}
locale={locale}
isStorageConfigured={isStorageConfigured}
@@ -140,7 +137,6 @@ export const BlockSettings = ({
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.next")}
locale={locale}
isStorageConfigured={isStorageConfigured}

View File

@@ -25,8 +25,6 @@ interface BlocksDroppableProps {
duplicateElement: (elementIdx: number) => void;
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
invalidElements: string[] | null;
addElement: (element: any, index?: number) => void;
isFormbricksCloud: boolean;
@@ -52,9 +50,7 @@ export const BlocksDroppable = ({
setLocalSurvey,
moveElement,
project,
selectedLanguageCode,
setActiveElementId,
setSelectedLanguageCode,
updateElement,
updateBlockLogic,
updateBlockLogicFallback,
@@ -97,8 +93,6 @@ export const BlocksDroppable = ({
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateElement={duplicateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteElement={deleteElement}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}

View File

@@ -33,8 +33,6 @@ interface EditEndingCardProps {
setActiveElementId: (id: string | null) => void;
activeElementId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
addEndingCard: (index: number) => void;
isFormbricksCloud: boolean;
locale: TUserLocale;
@@ -50,8 +48,6 @@ export const EditEndingCard = ({
setActiveElementId,
activeElementId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
addEndingCard,
isFormbricksCloud,
locale,
@@ -232,14 +228,10 @@ export const EditEndingCard = ({
<p className="text-sm font-semibold">
{endingCard.type === "endScreen" &&
(endingCard.headline &&
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
recallToHeadline(endingCard.headline, localSurvey, true, "default").default
? formatTextWithSlashes(
getTextContent(
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
recallToHeadline(endingCard.headline, localSurvey, true, "default").default
)
)
: t("environments.surveys.edit.ending_card"))}
@@ -297,8 +289,6 @@ export const EditEndingCard = ({
localSurvey={localSurvey}
endingCardIndex={endingCardIndex}
isInvalid={isInvalid}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
updateSurvey={updateSurvey}
endingCard={endingCard}
locale={locale}

View File

@@ -23,8 +23,6 @@ interface EditWelcomeCardProps {
setActiveElementId: (id: string | null) => void;
activeElementId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
@@ -36,8 +34,6 @@ export const EditWelcomeCard = ({
setActiveElementId,
activeElementId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
@@ -155,8 +151,6 @@ export const EditWelcomeCard = ({
elementIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -171,8 +165,6 @@ export const EditWelcomeCard = ({
elementIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -191,8 +183,6 @@ export const EditWelcomeCard = ({
placeholder={t("common.next")}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}

View File

@@ -11,7 +11,7 @@ import {
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { Language, Project } from "@prisma/client";
import { Project } from "@prisma/client";
import React, { SetStateAction, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -52,7 +52,6 @@ import {
isUsedInRecall,
} from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { MultiLanguageCard } from "@/modules/survey/multi-language-surveys/components/multi-language-card";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
@@ -62,11 +61,8 @@ interface ElementsViewProps {
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
project: Project;
projectLanguages: Language[];
invalidElements: string[] | null;
setInvalidElements: React.Dispatch<SetStateAction<string[] | null>>;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isFormbricksCloud: boolean;
isCxMode: boolean;
locale: TUserLocale;
@@ -83,11 +79,8 @@ export const ElementsView = ({
localSurvey,
setLocalSurvey,
project,
projectLanguages,
invalidElements,
setInvalidElements,
setSelectedLanguageCode,
selectedLanguageCode,
isFormbricksCloud,
isCxMode,
locale,
@@ -173,13 +166,10 @@ export const ElementsView = ({
const updatedElements = block.elements.map((element) => {
let updatedElement = { ...element };
if (element.headline[selectedLanguageCode]?.includes(`recall:${compareId}`)) {
if (element.headline.default?.includes(`recall:${compareId}`)) {
updatedElement.headline = {
...element.headline,
[selectedLanguageCode]: element.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
),
default: element.headline.default.replaceAll(`recall:${compareId}`, `recall:${updatedId}`),
};
}
@@ -785,7 +775,7 @@ export const ElementsView = ({
);
useEffect(() => {
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, "default");
if (elementWithEmptyFallback) {
setActiveElementId(elementWithEmptyFallback.id);
if (activeElementId === elementWithEmptyFallback.id) {
@@ -793,7 +783,7 @@ export const ElementsView = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
}, [activeElementId, setActiveElementId, localSurvey]);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -850,8 +840,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
isInvalid={invalidElements ? invalidElements.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -874,8 +862,6 @@ export const ElementsView = ({
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateElement={duplicateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteElement={deleteElement}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
@@ -915,8 +901,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
isInvalid={invalidElements ? invalidElements.includes(ending.id) : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
addEndingCard={addEndingCard}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
@@ -949,16 +933,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
<MultiLanguageCard
localSurvey={localSurvey}
projectLanguages={projectLanguages}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
</>
)}
</div>

View File

@@ -19,8 +19,6 @@ interface EndScreenFormProps {
localSurvey: TSurvey;
endingCardIndex: number;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateSurvey: (
input: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }> | Partial<TSurveyRedirectUrlCard>
) => void;
@@ -34,8 +32,6 @@ export const EndScreenForm = ({
localSurvey,
endingCardIndex,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
updateSurvey,
endingCard,
locale,
@@ -44,12 +40,11 @@ export const EndScreenForm = ({
}: EndScreenFormProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const selectedLanguageCode = "default";
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const questions = getElementsFromBlocks(localSurvey.blocks);
const defaultLanguageCode = localSurvey.languages.find((lang) => lang.default)?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const usedLanguageCode = "default";
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
@@ -66,8 +61,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
@@ -85,8 +78,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
@@ -155,8 +146,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>

View File

@@ -7,7 +7,6 @@ import { TSurveyQuota } from "@formbricks/types/quota";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@/lib/useDocumentVisibility";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
@@ -55,7 +54,6 @@ interface SurveyEditorProps {
export const SurveyEditor = ({
survey,
project,
projectLanguages,
environment,
actionClasses,
contactAttributeKeys,
@@ -85,7 +83,6 @@ export const SurveyEditor = ({
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [invalidElements, setInvalidElements] = useState<string[] | null>([]);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
const [localProject, setLocalProject] = useState<Project>(project);
@@ -147,14 +144,6 @@ export const SurveyEditor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type]);
useEffect(() => {
if (!localSurvey?.languages) return;
const enabledLanguageCodes = extractLanguageCodes(getEnabledLanguages(localSurvey.languages ?? []));
if (!enabledLanguageCodes.includes(selectedLanguageCode)) {
setSelectedLanguageCode("default");
}
}, [localSurvey?.languages, selectedLanguageCode]);
if (!localSurvey) {
return <LoadingSkeleton />;
}
@@ -174,10 +163,7 @@ export const SurveyEditor = ({
setInvalidElements={setInvalidElements}
project={localProject}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
locale={locale}
setIsCautionDialogOpen={setIsCautionDialogOpen}
isStorageConfigured={isStorageConfigured}
/>
@@ -199,11 +185,8 @@ export const SurveyEditor = ({
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
project={localProject}
projectLanguages={projectLanguages}
invalidElements={invalidElements}
setInvalidElements={setInvalidElements}
selectedLanguageCode={selectedLanguageCode || "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
locale={locale}
@@ -255,7 +238,6 @@ export const SurveyEditor = ({
<FollowUpsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurveyNonNull}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
isFormbricksCloud={isFormbricksCloud}
@@ -273,7 +255,7 @@ export const SurveyEditor = ({
project={localProject}
environment={environment}
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
languageCode="default"
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>

View File

@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
@@ -38,10 +37,7 @@ interface SurveyMenuBarProps {
setInvalidElements: React.Dispatch<React.SetStateAction<string[] | null>>;
project: Project;
responseCount: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (selectedLanguage: string) => void;
isCxMode: boolean;
locale: string;
setIsCautionDialogOpen: (open: boolean) => void;
isStorageConfigured: boolean;
}
@@ -56,9 +52,7 @@ export const SurveyMenuBar = ({
setInvalidElements,
project,
responseCount,
selectedLanguageCode,
isCxMode,
locale,
setIsCautionDialogOpen,
isStorageConfigured = true,
}: SurveyMenuBarProps) => {
@@ -192,7 +186,17 @@ export const SurveyMenuBar = ({
const validateSurveyWithZod = (): boolean => {
const localSurveyValidation = ZSurvey.safeParse(localSurvey);
if (!localSurveyValidation.success) {
const issues = localSurveyValidation.error.issues;
const issues = localSurveyValidation.error.issues.filter((issue) => {
if (issue.code !== "custom") return true;
const params = issue.params as { invalidLanguageCodes?: string[] } | undefined;
if (params?.invalidLanguageCodes?.length) return false;
return !issue.message.includes("-fLang-");
});
if (issues.length === 0) {
return true;
}
const newInvalidIds: string[] = [];
for (const issue of issues) {
@@ -236,21 +240,9 @@ export const SurveyMenuBar = ({
const firstError = issues[0];
if (firstError.code === "custom") {
const params = firstError.params ?? ({} as { invalidLanguageCodes: string[] });
if (params.invalidLanguageCodes && params.invalidLanguageCodes.length) {
const invalidLanguageLabels = params.invalidLanguageCodes.map(
(invalidLanguage: string) => getLanguageLabel(invalidLanguage, locale) ?? invalidLanguage
);
const messageSplit = firstError.message.split("-fLang-")[0];
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(firstError.message, {
className: "w-fit !max-w-md",
});
}
toast.error(firstError.message, {
className: "w-fit !max-w-md",
});
return false;
}
@@ -359,7 +351,7 @@ export const SurveyMenuBar = ({
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
const isSurveyValidResult = isSurveyValid(localSurvey, t, responseCount);
if (!isSurveyValidResult) {
setIsSurveySaving(false);
return false;
@@ -439,7 +431,7 @@ export const SurveyMenuBar = ({
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
const isSurveyValidResult = isSurveyValid(localSurvey, t, responseCount);
if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);

View File

@@ -949,7 +949,7 @@ describe("validation.isSurveyValid", () => {
});
test("should return true for a completely valid survey", () => {
expect(validation.isSurveyValid(baseSurvey, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(baseSurvey, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -966,7 +966,7 @@ describe("validation.isSurveyValid", () => {
},
required: false,
});
expect(validation.isSurveyValid(baseSurvey, "de", mockT)).toBe(false);
expect(validation.isSurveyValid(baseSurvey, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.fallback_missing");
});
@@ -975,7 +975,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 0,
};
expect(validation.isSurveyValid(surveyWithZeroLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithZeroLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.response_limit_can_t_be_set_to_0");
});
@@ -984,7 +984,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 5,
};
expect(validation.isSurveyValid(surveyWithLowLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithLowLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.response_limit_needs_to_exceed_number_of_received_responses",
{
@@ -998,7 +998,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 3,
};
expect(validation.isSurveyValid(surveyWithLowLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithLowLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.response_limit_needs_to_exceed_number_of_received_responses",
{
@@ -1012,7 +1012,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 10,
};
expect(validation.isSurveyValid(surveyWithValidLimit, "en", mockT, 5)).toBe(true);
expect(validation.isSurveyValid(surveyWithValidLimit, mockT, 5)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1021,7 +1021,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: null,
};
expect(validation.isSurveyValid(surveyWithNoLimit, "en", mockT, 5)).toBe(true);
expect(validation.isSurveyValid(surveyWithNoLimit, mockT, 5)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1035,7 +1035,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
@@ -1051,7 +1051,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
@@ -1067,7 +1067,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1081,7 +1081,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithClosedMessageContent, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1101,7 +1101,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithInvalidSegment, "en", mockT)).toBe(false); // Zod parse will fail
expect(validation.isSurveyValid(surveyWithInvalidSegment, mockT)).toBe(false); // Zod parse will fail
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.invalid_targeting");
});
@@ -1128,7 +1128,7 @@ describe("validation.isSurveyValid", () => {
const mockSafeParse = vi.spyOn(ZSegmentFilters, "safeParse");
mockSafeParse.mockReturnValue({ success: true, data: surveyWithValidSegment.segment!.filters } as any);
expect(validation.isSurveyValid(surveyWithValidSegment, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithValidSegment, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
mockSafeParse.mockRestore();
});

View File

@@ -23,65 +23,56 @@ import {
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { findLanguageCodesForDuplicateLabels, getTextContent } from "@formbricks/types/surveys/validation";
import { extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
// Utility function to check if label is valid for all required languages
const getDefaultText = (label?: TI18nString): string => {
return getTextContent(label?.default ?? "");
};
export const isLabelValidForAllLanguages = (
label: TI18nString,
surveyLanguages: TSurveyLanguage[]
_surveyLanguages: TSurveyLanguage[]
): boolean => {
const filteredLanguages = surveyLanguages.filter((surveyLanguages) => {
return surveyLanguages.enabled;
});
const languageCodes = extractLanguageCodes(filteredLanguages);
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0);
return getDefaultText(label).trim().length > 0;
};
// Validation logic for multiple choice elements
const handleI18nCheckForMultipleChoice = (
element: TSurveyMultipleChoiceElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
const invalidLangCodes = findLanguageCodesForDuplicateLabels(
element.choices.map((choice) => choice.label),
languages
);
if (invalidLangCodes.length > 0) {
return false;
}
return element.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
const normalizedLabels = element.choices
.map((choice) => getDefaultText(choice.label).trim().toLowerCase())
.filter(Boolean);
const hasDuplicates = new Set(normalizedLabels).size !== normalizedLabels.length;
return !hasDuplicates && element.choices.every((choice) => getDefaultText(choice.label).trim().length > 0);
};
const handleI18nCheckForMatrixLabels = (
element: TSurveyMatrixElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
const rowsAndColumns = [...element.rows, ...element.columns];
const rowLabels = element.rows.map((row) => getDefaultText(row.label).trim().toLowerCase()).filter(Boolean);
const colLabels = element.columns
.map((column) => getDefaultText(column.label).trim().toLowerCase())
.filter(Boolean);
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
element.rows.map((row) => row.label),
languages
);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
element.columns.map((column) => column.label),
languages
);
const hasDuplicateRows = new Set(rowLabels).size !== rowLabels.length;
const hasDuplicateColumns = new Set(colLabels).size !== colLabels.length;
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
if (hasDuplicateRows || hasDuplicateColumns) {
return false;
}
return rowsAndColumns.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
return rowsAndColumns.every((choice) => getDefaultText(choice.label).trim().length > 0);
};
const handleI18nCheckForContactAndAddressFields = (
element: TSurveyContactInfoElement | TSurveyAddressElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
let fields: TInputFieldConfig[] = [];
if (element.type === "contactInfo") {
@@ -93,7 +84,7 @@ const handleI18nCheckForContactAndAddressFields = (
}
return fields.every((field) => {
if (field.show) {
return isLabelValidForAllLanguages(field.placeholder, languages);
return getDefaultText(field.placeholder).trim().length > 0;
}
return true;
});
@@ -101,11 +92,11 @@ const handleI18nCheckForContactAndAddressFields = (
// Validation rules
export const validationRules = {
openText: (element: TSurveyOpenTextElement, languages: TSurveyLanguage[]) => {
openText: (element: TSurveyOpenTextElement, _languages: TSurveyLanguage[]) => {
return element.placeholder &&
getLocalizedValue(element.placeholder, "default").trim() !== "" &&
languages.length > 1
? isLabelValidForAllLanguages(element.placeholder, languages)
getLocalizedValue(element.placeholder, "default").trim() !== ""
? getDefaultText(element.placeholder).trim().length > 0
: true;
},
multipleChoiceMulti: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
@@ -114,15 +105,15 @@ export const validationRules = {
multipleChoiceSingle: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
return handleI18nCheckForMultipleChoice(element, languages);
},
consent: (element: TSurveyConsentElement, languages: TSurveyLanguage[]) => {
return isLabelValidForAllLanguages(element.label, languages);
consent: (element: TSurveyConsentElement, _languages: TSurveyLanguage[]) => {
return getDefaultText(element.label).trim().length > 0;
},
pictureSelection: (element: TSurveyPictureSelectionElement) => {
return element.choices.length >= 2;
},
cta: (element: TSurveyCTAElement, languages: TSurveyLanguage[]) => {
cta: (element: TSurveyCTAElement, _languages: TSurveyLanguage[]) => {
return element.buttonExternal && element.ctaButtonLabel
? isLabelValidForAllLanguages(element.ctaButtonLabel, languages)
? getDefaultText(element.ctaButtonLabel).trim().length > 0
: true;
},
matrix: (element: TSurveyMatrixElement, languages: TSurveyLanguage[]) => {
@@ -135,14 +126,14 @@ export const validationRules = {
return handleI18nCheckForContactAndAddressFields(element, languages);
},
// Assuming headline is of type TI18nString
defaultValidation: (element: TSurveyElement, languages: TSurveyLanguage[]) => {
defaultValidation: (element: TSurveyElement, _languages: TSurveyLanguage[]) => {
// headline and subheader are default for every element
const isHeadlineValid = isLabelValidForAllLanguages(element.headline, languages);
const isHeadlineValid = getDefaultText(element.headline).trim().length > 0;
const isSubheaderValid =
element.subheader &&
getLocalizedValue(element.subheader, "default").trim() !== "" &&
languages.length > 1
? isLabelValidForAllLanguages(element.subheader, languages)
getLocalizedValue(element.subheader, "default").trim() !== ""
? getDefaultText(element.subheader).trim().length > 0
: true;
let isValid = isHeadlineValid && isSubheaderValid;
const defaultLanguageCode = "default";
@@ -152,7 +143,7 @@ export const validationRules = {
for (const field of fieldsToValidate) {
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
isValid = isValid && getDefaultText(fieldValue).trim().length > 0;
}
}
@@ -195,8 +186,8 @@ export const validateSurveyElementsInBatch = (
return invalidElements;
};
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
const isContentValid = (content: Record<string, string> | undefined, _surveyLanguages: TSurveyLanguage[]) => {
return !content || getDefaultText(content).trim().length > 0;
};
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
@@ -247,13 +238,8 @@ export const isEndingCardValid = (
}
};
export const isSurveyValid = (
survey: TSurvey,
selectedLanguageCode: string,
t: TFunction,
responseCount?: number
) => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
export const isSurveyValid = (survey: TSurvey, t: TFunction, responseCount?: number) => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, "default");
if (questionWithEmptyFallback) {
toast.error(t("environments.surveys.edit.fallback_missing"));
return false;

View File

@@ -19,7 +19,6 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface FollowUpItemProps {
followUp: TSurveyFollowUp;
localSurvey: TSurvey;
selectedLanguageCode: string;
mailFrom: string;
userEmail: string;
teamMemberDetails: TFollowUpEmailToUser[];
@@ -31,7 +30,6 @@ export const FollowUpItem = ({
followUp,
localSurvey,
mailFrom,
selectedLanguageCode,
userEmail,
teamMemberDetails,
setLocalSurvey,
@@ -190,7 +188,6 @@ export const FollowUpItem = ({
open={editFollowUpModalOpen}
setOpen={setEditFollowUpModalOpen}
mailFrom={mailFrom}
selectedLanguageCode={selectedLanguageCode}
defaultValues={{
surveyFollowUpId: followUp.id,
followUpName: followUp.name,

View File

@@ -68,7 +68,6 @@ interface AddFollowUpModalProps {
localSurvey: TSurvey;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedLanguageCode: string;
mailFrom: string;
defaultValues?: Partial<TCreateSurveyFollowUpForm & { surveyFollowUpId: string }>;
mode?: "create" | "edit";
@@ -88,7 +87,6 @@ export const FollowUpModal = ({
localSurvey,
open,
setOpen,
selectedLanguageCode,
mailFrom,
defaultValues,
mode = "create",
@@ -98,6 +96,7 @@ export const FollowUpModal = ({
locale,
}: AddFollowUpModalProps) => {
const { t } = useTranslation();
const selectedLanguageCode = "default";
const ELEMENTS_ICON_MAP = getElementIconMap(t);
const containerRef = useRef<HTMLDivElement>(null);
const [firstRender, setFirstRender] = useState(true);

View File

@@ -15,7 +15,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface FollowUpsViewProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
selectedLanguageCode: string;
mailFrom: string;
isSurveyFollowUpsAllowed: boolean;
isFormbricksCloud: boolean;
@@ -27,7 +26,6 @@ interface FollowUpsViewProps {
export const FollowUpsView = ({
localSurvey,
setLocalSurvey,
selectedLanguageCode,
mailFrom,
isSurveyFollowUpsAllowed,
isFormbricksCloud,
@@ -105,7 +103,6 @@ export const FollowUpsView = ({
followUp={followUp}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
teamMemberDetails={teamMemberDetails}
@@ -120,7 +117,6 @@ export const FollowUpsView = ({
setLocalSurvey={setLocalSurvey}
open={addFollowUpModalOpen}
setOpen={setAddFollowUpModalOpen}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
teamMemberDetails={teamMemberDetails}

View File

@@ -1,78 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { DefaultTag } from "@/modules/ui/components/default-tag";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import type { ConfirmationModalProps } from "./multi-language-card";
interface DefaultLanguageSelectProps {
defaultLanguage?: Language;
handleDefaultLanguageChange: (languageCode: string) => void;
projectLanguages: Language[];
setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void;
locale: string;
}
export function DefaultLanguageSelect({
defaultLanguage,
handleDefaultLanguageChange,
projectLanguages,
setConfirmationModalInfo,
locale,
}: DefaultLanguageSelectProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label>{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}</Label>
<div className="flex items-center space-x-2">
<div className="w-48">
<Select
defaultValue={`${defaultLanguage?.code}`}
disabled={Boolean(defaultLanguage)}
onValueChange={(languageCode) => {
setConfirmationModalInfo({
open: true,
title:
t("environments.surveys.edit.confirm_default_language") +
": " +
getLanguageLabel(languageCode, locale),
body: t(
"environments.surveys.edit.once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations"
),
buttonText: t("common.confirm"),
onConfirm: () => {
handleDefaultLanguageChange(languageCode);
},
buttonVariant: "default",
});
}}
value={`${defaultLanguage?.code}`}>
<SelectTrigger className="w-full max-w-full truncate px-4 text-xs text-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectLanguages.map((language) => (
<SelectItem
className="px-0.5 py-1 text-sm text-slate-800"
key={language.id}
value={language.code}>
{`${getLanguageLabel(language.code, locale)} (${language.code})`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DefaultTag />
</div>
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
interface LanguageIndicatorProps {
selectedLanguageCode: string;
surveyLanguages: TSurveyLanguage[];
setSelectedLanguageCode: (languageCode: string) => void;
setFirstRender?: (firstRender: boolean) => void;
locale: string;
}
export function LanguageIndicator({
surveyLanguages,
selectedLanguageCode,
setSelectedLanguageCode,
setFirstRender,
locale,
}: LanguageIndicatorProps) {
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => {
setShowLanguageDropdown((prev) => !prev);
};
const languageDropdownRef = useRef(null);
const changeLanguage = (language: TSurveyLanguage) => {
setSelectedLanguageCode(language.default ? "default" : language.language.code);
if (setFirstRender) {
//for lexical editor
setFirstRender(true);
}
setShowLanguageDropdown(false);
};
const languageToBeDisplayed = surveyLanguages.find((language) => {
return selectedLanguageCode === "default"
? language.default
: language.language.code === selectedLanguageCode;
});
useClickOutside(languageDropdownRef, () => {
setShowLanguageDropdown(false);
});
return (
<div className="absolute right-2 top-2">
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
<span className="max-w-full truncate">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
</span>
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
</button>
{showLanguageDropdown ? (
<div
className="absolute right-0 z-30 mt-1 max-h-64 w-48 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
}}
type="button">
<span className="min-w-0 flex-1 truncate">
{getLanguageLabel(language.language.code, locale)}
</span>
</button>
)
)}
</div>
) : null}
</div>
);
}

View File

@@ -1,47 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import type { TUserLocale } from "@formbricks/types/user";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface LanguageToggleProps {
language: Language;
isChecked: boolean;
onToggle: () => void;
onEdit: () => void;
locale: TUserLocale;
}
export function LanguageToggle({ language, isChecked, onToggle, onEdit, locale }: LanguageToggleProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col space-y-4">
<div className="flex max-w-96 items-center space-x-4">
<Switch
checked={isChecked}
id={`${language.code}-toggle`}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
/>
<Label className="truncate font-medium text-slate-800" htmlFor={`${language.code}-toggle`}>
{getLanguageLabel(language.code, locale)}
</Label>
{isChecked ? (
<button
className="truncate text-xs text-slate-600 underline hover:text-slate-800"
onClick={onEdit}
type="button">
{t("environments.surveys.edit.edit_translations", {
lang: getLanguageLabel(language.code, locale),
})}
</button>
) : null}
</div>
</div>
);
}

View File

@@ -1,191 +0,0 @@
"use client";
import { useMemo, useTransition } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
interface LocalizedEditorProps {
id: string;
value: TI18nString | undefined;
localSurvey: TSurvey;
isInvalid: boolean;
updateElement: any;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
elementIdx: number;
firstRender: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
locale: TUserLocale;
elementId: string;
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
autoFocus?: boolean;
isExternalUrlsAllowed?: boolean;
suppressUpdates?: () => boolean; // Function to check if updates should be suppressed (e.g., during deletion)
}
const checkIfValueIsIncomplete = (
id: string,
isInvalid: boolean,
surveyLanguageCodes: TSurveyLanguage[],
value?: TI18nString
) => {
const labelIds = ["subheader", "headline", "html"];
if (value === undefined) return false;
const isDefaultIncomplete = labelIds.includes(id)
? getTextContent(value.default ?? "").trim() !== ""
: false;
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
};
export function LocalizedEditor({
id,
value,
localSurvey,
isInvalid,
updateElement,
selectedLanguageCode,
setSelectedLanguageCode,
elementIdx,
firstRender,
setFirstRender,
locale,
elementId,
isCard,
autoFocus,
isExternalUrlsAllowed,
suppressUpdates,
}: Readonly<LocalizedEditorProps>) {
// Derive elements from blocks for migrated surveys
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const { t } = useTranslation();
const isInComplete = useMemo(
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
[id, isInvalid, localSurvey.languages, value]
);
const [, startTransition] = useTransition();
return (
<div className="relative w-full">
<Editor
id={id}
disableLists
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
autoFocus={autoFocus}
getText={() => {
const text = value ? (value[selectedLanguageCode] ?? "") : "";
let html = md.render(text);
// For backwards compatibility: wrap plain text headlines in <strong> tags
// This ensures old surveys maintain semibold styling when converted to HTML
if (id === "headline" && text && !isValidHTML(text)) {
// Use [\s\S]*? to match any character including newlines
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
}
return html;
}}
key={`${elementId}-${id}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
// Early exit if updates are suppressed (e.g., during deletion)
// This prevents race conditions where setText fires with stale props before React updates state
if (suppressUpdates?.()) {
return;
}
let sanitizedContent = v;
if (!isExternalUrlsAllowed) {
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
}
const currentElement = elements[elementIdx];
startTransition(() => {
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = elementIdx === -1;
const isEndingCard = elementIdx >= elements.length;
// For ending cards, check if the field exists before updating
if (isEndingCard) {
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
// If the field doesn't exist on the ending card, don't create it
if ((ending as Record<string, unknown>)?.[id] === undefined) {
return;
}
}
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
return;
}
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement({ [id]: translatedContent });
return;
}
// Check if the field exists on the element (not just if it's not undefined)
if (
currentElement &&
id in currentElement &&
(currentElement as Record<string, unknown>)[id] !== undefined
) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement(elementIdx, { [id]: translatedContent });
}
});
}}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode={selectedLanguageCode}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
{localSurvey.languages.length > 1 && (
<div>
<LanguageIndicator
selectedLanguageCode={selectedLanguageCode}
setFirstRender={setFirstRender}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={localSurvey.languages}
locale={locale}
/>
{value && selectedLanguageCode !== "default" && value.default ? (
<div className="mt-1 flex text-xs text-gray-500">
<strong>{t("environments.workspace.languages.translate")}:</strong>
<span className="ml-1">
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
</span>
</div>
) : null}
</div>
)}
{isInComplete ? (
<div className="mt-1 text-xs text-red-400">
{t("environments.workspace.languages.incomplete_translations")}
</div>
) : null}
</div>
);
}

View File

@@ -1,300 +0,0 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Language } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { ArrowUpRight, Languages } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { DefaultLanguageSelect } from "./default-language-select";
import { SecondaryLanguageSelect } from "./secondary-language-select";
interface MultiLanguageCardProps {
localSurvey: TSurvey;
projectLanguages: Language[];
setLocalSurvey: (survey: TSurvey) => void;
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
setSelectedLanguageCode: (language: string) => void;
locale: TUserLocale;
}
export interface ConfirmationModalProps {
body: string;
open: boolean;
title: string;
buttonText: string;
buttonVariant?: "default" | "destructive";
onConfirm: () => void;
}
export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
activeElementId,
localSurvey,
setActiveElementId,
setLocalSurvey,
projectLanguages,
setSelectedLanguageCode,
locale,
}) => {
const { t } = useTranslation();
const environmentId = localSurvey.environmentId;
const open = activeElementId === "multiLanguage";
const [isMultiLanguageActivated, setIsMultiLanguageActivated] = useState(localSurvey.languages.length > 0);
const [confirmationModalInfo, setConfirmationModalInfo] = useState<ConfirmationModalProps>({
title: "",
open: false,
body: "",
buttonText: "",
onConfirm: () => {},
});
const defaultLanguage = useMemo(
() => localSurvey.languages.find((language) => language.default)?.language,
[localSurvey.languages]
);
const setOpen = (open: boolean) => {
if (open) {
setActiveElementId("multiLanguage");
} else {
setActiveElementId(null);
}
};
useEffect(() => {
if (localSurvey.languages.length === 0) {
setIsMultiLanguageActivated(false);
}
}, [localSurvey.languages]);
const updateSurveyTranslations = (survey: TSurvey, updatedLanguages: TSurveyLanguage[]) => {
const translatedSurveyResult = addMultiLanguageLabels(survey, extractLanguageCodes(updatedLanguages));
const updatedSurvey = { ...translatedSurveyResult, languages: updatedLanguages };
setLocalSurvey(updatedSurvey as TSurvey);
};
const updateSurveyLanguages = (language: Language) => {
let updatedLanguages = localSurvey.languages;
const languageIndex = localSurvey.languages.findIndex(
(surveyLanguage) => surveyLanguage.language.code === language.code
);
if (languageIndex >= 0) {
// Toggle the 'enabled' property of the existing language
updatedLanguages = updatedLanguages.map((surveyLanguage, index) =>
index === languageIndex ? { ...surveyLanguage, enabled: !surveyLanguage.enabled } : surveyLanguage
);
} else {
// Add the new language
updatedLanguages = [
...updatedLanguages,
{
enabled: true,
default: false,
language,
},
];
}
updateSurveyTranslations(localSurvey, updatedLanguages);
};
const updateSurvey = (data: { languages: TSurveyLanguage[] }) => {
setLocalSurvey({ ...localSurvey, ...data });
};
const handleDefaultLanguageChange = (languageCode: string) => {
const language = projectLanguages.find((lang) => lang.code === languageCode);
if (language) {
let languageExists = false;
// Update all languages and check if the new default language already exists
const newLanguages =
localSurvey.languages.map((lang) => {
if (lang.language.code === language.code) {
languageExists = true;
return { ...lang, default: true };
}
return { ...lang, default: false };
}) ?? [];
if (!languageExists) {
// If the language doesn't exist, add it as the default
newLanguages.push({
enabled: true,
default: true,
language,
});
}
setConfirmationModalInfo({ ...confirmationModalInfo, open: false });
updateSurvey({ languages: newLanguages });
}
};
const handleActivationSwitchLogic = () => {
if (isMultiLanguageActivated) {
if (localSurvey.languages.length > 0) {
setConfirmationModalInfo({
open: true,
title: t("environments.surveys.edit.remove_translations"),
body: t("environments.surveys.edit.this_action_will_remove_all_the_translations_from_this_survey"),
buttonText: t("environments.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo({ ...confirmationModalInfo, open: false });
},
});
} else {
setIsMultiLanguageActivated(false);
}
} else {
setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
}
};
const handleLanguageSwitchToggle = () => {
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
};
const [parent] = useAutoAnimate();
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
return (
<div
className={cn(
open ? "shadow-lg" : "shadow-md",
"group z-10 flex flex-row rounded-lg bg-white text-slate-900"
)}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
</p>
</div>
<Collapsible.Root
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out"
onOpenChange={setOpen}
open={open}>
<Collapsible.CollapsibleTrigger
asChild
className="flex cursor-pointer justify-between rounded-r-lg p-4 hover:bg-slate-50">
<div>
<div className="inline-flex">
<div>
<p className="text-sm font-semibold">{t("common.multiple_languages")}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="multi-lang-toggle">
{isMultiLanguageActivated ? t("common.on") : t("common.off")}
</Label>
<Switch
checked={isMultiLanguageActivated}
disabled={projectLanguages.length === 0}
id="multi-lang-toggle"
onClick={(e) => {
e.stopPropagation();
handleActivationSwitchLogic();
}}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="space-y-6 pt-3">
{projectLanguages.length === 0 && (
<div className="mb-4 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")}
</div>
)}
{projectLanguages.length > 0 && (
<div className="space-y-6">
{isMultiLanguageActivated ? (
<div className="space-y-6">
<DefaultLanguageSelect
defaultLanguage={defaultLanguage}
handleDefaultLanguageChange={handleDefaultLanguageChange}
projectLanguages={projectLanguages}
setConfirmationModalInfo={setConfirmationModalInfo}
locale={locale}
/>
{defaultLanguage && projectLanguages.length > 1 ? (
<SecondaryLanguageSelect
defaultLanguage={defaultLanguage}
localSurvey={localSurvey}
projectLanguages={projectLanguages}
setActiveElementId={setActiveElementId}
setSelectedLanguageCode={setSelectedLanguageCode}
updateSurveyLanguages={updateSurveyLanguages}
locale={locale}
/>
) : null}
</div>
) : (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_language_on_to_get_started")}
</div>
)}
</div>
)}
<Button asChild size="sm" variant="secondary">
<Link href={`/environments/${environmentId}/workspace/languages`} target="_blank">
{t("environments.surveys.edit.manage_languages")}
<ArrowUpRight />
</Link>
</Button>
{isMultiLanguageActivated && (
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="languageSwitch"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}
title={t("environments.surveys.edit.show_language_switch")}
description={t(
"environments.surveys.edit.enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey"
)}
childBorder={true}></AdvancedOptionToggle>
)}
<ConfirmationModal
buttonText={confirmationModalInfo.buttonText}
buttonVariant={confirmationModalInfo.buttonVariant}
onConfirm={confirmationModalInfo.onConfirm}
open={confirmationModalInfo.open}
setOpen={() => {
setConfirmationModalInfo((prev) => ({ ...prev, open: !prev.open }));
}}
body={confirmationModalInfo.body}
title={confirmationModalInfo.title}
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
};

View File

@@ -1,62 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LanguageToggle } from "./language-toggle";
interface SecondaryLanguageSelectProps {
projectLanguages: Language[];
defaultLanguage: Language;
setSelectedLanguageCode: (languageCode: string) => void;
setActiveElementId: (elementId: string) => void;
localSurvey: TSurvey;
updateSurveyLanguages: (language: Language) => void;
locale: TUserLocale;
}
export function SecondaryLanguageSelect({
projectLanguages,
defaultLanguage,
setSelectedLanguageCode,
setActiveElementId,
localSurvey,
updateSurveyLanguages,
locale,
}: SecondaryLanguageSelectProps) {
const { t } = useTranslation();
const isLanguageToggled = (language: Language) => {
return localSurvey.languages.some(
(surveyLanguage) => surveyLanguage.language.code === language.code && surveyLanguage.enabled
);
};
const elements = getElementsFromBlocks(localSurvey.blocks);
return (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}
</p>{" "}
{projectLanguages
.filter((lang) => lang.id !== defaultLanguage.id)
.map((language) => (
<LanguageToggle
isChecked={isLanguageToggled(language)}
key={language.id}
language={language}
onEdit={() => {
setSelectedLanguageCode(language.code);
setActiveElementId(elements[0]?.id);
}}
onToggle={() => {
updateSurveyLanguages(language);
}}
locale={locale}
/>
))}
</div>
);
}

View File

@@ -1,8 +1,7 @@
import { expect } from "@playwright/test";
import { surveys } from "@/playwright/utils/mock";
import { test } from "./lib/fixtures";
import * as helper from "./utils/helper";
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
import { createSurvey, createSurveyWithLogic } from "./utils/helper";
test.use({
launchOptions: {
@@ -237,498 +236,6 @@ test.describe("Survey Create & Submit Response without logic", async () => {
});
});
test.describe("Multi Language Survey Create", async () => {
// 5 minutes
test.setTimeout(1000 * 60 * 5);
test("Create Survey", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
//add a new language
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Survey Languages" }).click();
await page.getByRole("button", { name: "Edit languages" }).click();
await page.getByRole("button", { name: "Add language" }).click();
await page.getByRole("button", { name: "Select" }).click();
await page.getByPlaceholder("Search items").click();
await page.getByPlaceholder("Search items").fill("Eng");
await page.getByText("English", { exact: true }).click();
await page.getByRole("button", { name: "Save changes" }).click();
await page.getByRole("button", { name: "Edit languages" }).click();
await page.getByRole("button", { name: "Add language" }).click();
await page.getByRole("button", { name: "Select" }).click();
await page.getByRole("textbox", { name: "Search items" }).click();
await page.getByRole("textbox", { name: "Search items" }).fill("German");
await page.getByText("German", { exact: true }).nth(1).click();
await page.getByRole("button", { name: "Save changes" }).click();
await page.waitForTimeout(2000);
await page.getByRole("link", { name: "Surveys" }).click();
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("German").click();
await page.locator("#welcome-toggle").click();
// Add questions in default language
await page.getByText("Add Block").click();
await page.getByRole("button", { name: "Single-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await helper.fillRichTextEditor(
page,
"Question*",
surveys.createAndSubmit.pictureSelectQuestion.question
);
// Handle file uploads
await uploadFileForFileUploadQuestion(page);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ratingQuestion.question);
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.npsQuestion.question);
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Date" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.dateQuestion.question);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.fileUploadQuestion.question);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Matrix" }).scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "Matrix" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
await page.locator("#row-1").click();
await page.locator("#row-1").fill(surveys.createAndSubmit.matrix.rows[1]);
await page.getByRole("button", { name: "Add row" }).click();
await page.locator("#row-2").click();
await page.locator("#row-2").fill(surveys.createAndSubmit.matrix.rows[2]);
await page.locator("#column-0").click();
await page.locator("#column-0").fill(surveys.createAndSubmit.matrix.columns[0]);
await page.locator("#column-1").click();
await page.locator("#column-1").fill(surveys.createAndSubmit.matrix.columns[1]);
await page.getByRole("button", { name: "Add column" }).click();
await page.locator("#column-2").click();
await page.locator("#column-2").fill(surveys.createAndSubmit.matrix.columns[2]);
await page.getByRole("button", { name: "Add column" }).click();
await page.locator("#column-3").click();
await page.locator("#column-3").fill(surveys.createAndSubmit.matrix.columns[3]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.ranking.choices[1]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.ranking.choices[2]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 4").click();
await page.getByPlaceholder("Option 4").fill(surveys.createAndSubmit.ranking.choices[3]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(surveys.createAndSubmit.ranking.choices[4]);
// Enable translation in german
await page.getByText("Welcome CardShownOn").click();
await page.getByRole("button", { name: "English" }).nth(1).click();
await page.getByRole("button", { name: "German" }).click();
// Fill welcome card in german using rich text editor helper
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.welcomeCard.headline);
await helper.fillRichTextEditor(page, "Welcome message", surveys.germanCreate.welcomeCard.description);
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
// Fill Open text question in german
await page.getByRole("main").getByText("Free text").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.openTextQuestion.question);
await page.getByLabel("Placeholder").click();
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page
.locator("div")
.filter({ hasText: /^Block 11 question$/ })
.first()
.click();
// Fill Single select question in german
await page.getByRole("main").getByText("Single-Select").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.singleSelectQuestion.options[1]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 21 question$/ })
.first()
.click();
// Fill Multi select question in german
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.multiSelectQuestion.options[2]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 31 question$/ })
.first()
.click();
// Fill Picture select question in german
await page.getByRole("main").getByText("Picture Selection").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.pictureSelectQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 41 question$/ })
.first()
.click();
// Fill Rating question in german
await page.getByRole("main").getByText("Rating").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ratingQuestion.question);
await page.getByPlaceholder("Not good").click();
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").click();
await page.getByPlaceholder("Very satisfied").fill(surveys.germanCreate.ratingQuestion.highLabel);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 51 question$/ })
.first()
.click();
// Fill NPS question in german
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.npsQuestion.question);
await page.getByLabel("Lower Label").click();
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
await page.getByLabel("Upper Label").click();
await page.getByLabel("Upper Label").fill(surveys.germanCreate.npsQuestion.highLabel);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 61 question$/ })
.first()
.click();
// Fill Date question in german
await page.getByRole("main").getByText("Date").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.dateQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 71 question$/ })
.first()
.click();
// Fill File upload question in german
await page.getByRole("main").getByText("File Upload").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.fileUploadQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 81 question$/ })
.first()
.click();
// Fill Matrix question in german
await page.getByRole("main").getByText("Matrix").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
await page.locator("#row-1").click();
await page.locator("#row-1").fill(surveys.germanCreate.matrix.rows[1]);
await page.locator("#row-2").click();
await page.locator("#row-2").fill(surveys.germanCreate.matrix.rows[2]);
await page.locator("#column-0").click();
await page.locator("#column-0").fill(surveys.germanCreate.matrix.columns[0]);
await page.locator("#column-1").click();
await page.locator("#column-1").fill(surveys.germanCreate.matrix.columns[1]);
await page.locator("#column-2").click();
await page.locator("#column-2").fill(surveys.germanCreate.matrix.columns[2]);
await page.locator("#column-3").click();
await page.locator("#column-3").fill(surveys.germanCreate.matrix.columns[3]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 91 question$/ })
.first()
.click();
// Fill Address question in german
await page.getByRole("main").getByText("Address").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.addressQuestion.question);
await page.locator('[id="addressLine1\\.placeholder"]').click();
await page
.locator('[id="addressLine1\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.addressLine1);
await page.locator('[id="addressLine2\\.placeholder"]').click();
await page
.locator('[id="addressLine2\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.addressLine2);
await page.locator('[id="city\\.placeholder"]').click();
await page
.locator('[id="city\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.city);
await page.locator('[id="state\\.placeholder"]').click();
await page
.locator('[id="state\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.state);
await page.locator('[id="zip\\.placeholder"]').click();
await page.locator('[id="zip\\.placeholder"]').fill(surveys.germanCreate.addressQuestion.placeholder.zip);
await page.locator('[id="country\\.placeholder"]').click();
await page
.locator('[id="country\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.country);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 101 question$/ })
.first()
.click();
// Fill Ranking question in german
await page.getByRole("main").getByText("Ranking").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.ranking.choices[1]);
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.ranking.choices[2]);
await page.getByPlaceholder("Option 4").click();
await page.getByPlaceholder("Option 4").fill(surveys.germanCreate.ranking.choices[3]);
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(surveys.germanCreate.ranking.choices[4]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 111 question$/ })
.first()
.click();
// Fill Thank you card in german
await page.getByText("Ending card").first().click();
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.endingCard.headline);
await helper.fillRichTextEditor(page, "Description", surveys.germanCreate.endingCard.description);
await page.locator("#showButton").check();
await page.getByPlaceholder("Create your own Survey").click();
await page.getByPlaceholder("Create your own Survey").fill(surveys.germanCreate.endingCard.buttonLabel);
// TODO: @pandeymangg - figure out if this is required
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForTimeout(2000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
await page.getByLabel("Select Language").click();
await page.getByText("German").click();
await page.getByLabel("Copy survey link to clipboard").click();
const germanSurveyUrl = await page.evaluate("navigator.clipboard.readText()");
expect(germanSurveyUrl).toContain("lang=de");
});
});
test.describe("Testing Survey with advanced logic", async () => {
// 8 minutes
test.setTimeout(1000 * 60 * 8);

View File

@@ -45,28 +45,13 @@ How to deliver a specific language depends on the survey type (app or link surve
![Survey Overview](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/surveys-home.webp)
</Step>
<Step title="Enable Multi-language Support">
In the survey editor, scroll down to the **Multiple Languages** section at the bottom and enable the toggle next to it:
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/enable-multi-lang.webp)
Choose a **Default Language** for your survey.
<Note>Changing the default language will reset all the translations you have made for the survey.</Note>
</Step>
<Step title="Add Supported Languages">
Add the languages from the dropdown that you want to support in your survey:
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-language-in-survey.webp)
</Step>
<Step title="Preview and Translate Content">
You can now see the survey in the selected language by clicking on the language dropdown in any of the questions.
Now you can translate all survey content, including questions, options, and button placeholders, into the selected language.
<Step title="Current editor behavior">
Survey language definitions still come from **Configuration → Survey Languages**.
<Note>
The old translation controls inside the Questions tab have been removed temporarily. In-editor translation
editing will return with the upcoming dedicated **Language** tab.
</Note>
</Step>
<Step title="Publish Your Survey">