Compare commits

..

2 Commits

Author SHA1 Message Date
Cursor Agent b22c839d3c chore: update pnpm-lock.yaml after dependency installation 2026-01-21 02:55:47 +00:00
Cursor Agent d44cc1e27e fix: add safe requestSubmit wrapper for Mobile Safari 15.5 compatibility
- Add safeRequestSubmit helper function with null checks and browser compatibility fallback
- Replace all form.requestSubmit() calls with safeRequestSubmit(form)
- Dispatch submit event as fallback for browsers without requestSubmit support
- Fixes TypeError: t.requestSubmit is not a function on /s/:surveyId route
2026-01-21 02:53:10 +00:00
20 changed files with 185 additions and 417 deletions
@@ -24,7 +24,6 @@ interface LinkSurveyWrapperProps {
IS_FORMBRICKS_CLOUD: boolean;
publicDomain: string;
isBrandingEnabled: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const LinkSurveyWrapper = ({
@@ -42,7 +41,6 @@ export const LinkSurveyWrapper = ({
IS_FORMBRICKS_CLOUD,
publicDomain,
isBrandingEnabled,
dir = "auto",
}: LinkSurveyWrapperProps) => {
//for embedded survey strip away all surrounding css
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
@@ -82,11 +80,11 @@ export const LinkSurveyWrapper = ({
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} dir={dir} />
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />
@@ -10,7 +10,6 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -117,11 +116,6 @@ export const SurveyClientWrapper = ({
}
setResponseData({});
};
// Determine text direction based on language code for logo positioning only
// which checks both language code and survey content. This is only for logo UI positioning.
const logoDir = useMemo(() => {
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
}, [languageCode, survey]);
return (
<>
@@ -146,8 +140,7 @@ export const SurveyClientWrapper = ({
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}
dir={logoDir}>
isBrandingEnabled={project.linkSurveyBranding}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}
+55 -158
View File
@@ -1,188 +1,85 @@
import { describe, expect, test } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
({
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
questions: [],
blocks: [],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
styling: null,
segment: null,
languages,
displayPercentage: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
singleUse: null,
pin: null,
projectOverwrites: null,
surveyClosedMessage: null,
followUps: [],
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
slug: null,
metadata: {},
}) as TSurvey;
import { getWebAppLocale } from "./utils";
describe("getWebAppLocale", () => {
test("maps language codes and handles defaults", () => {
expect(getWebAppLocale("en", createMockSurvey())).toBe("en-US");
expect(getWebAppLocale("de", createMockSurvey())).toBe("de-DE");
const surveyWithLang = createMockSurvey([
{
language: {
id: "l1",
code: "de",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyWithLang)).toBe("de-DE");
expect(getWebAppLocale("xx", createMockSurvey())).toBe("en-US");
});
test("returns en-US when default requested but no default language", () => {
const surveyNoDefault = createMockSurvey([
{
language: { id: "l1", code: "de", alias: null, createdAt: new Date(), updatedAt: new Date(), projectId: "p1" },
default: false,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyNoDefault)).toBe("en-US");
});
test("matches base language code for variants", () => {
expect(getWebAppLocale("pt-PT", createMockSurvey())).toBe("pt-PT");
expect(getWebAppLocale("es-MX", createMockSurvey())).toBe("es-ES");
});
});
describe("isRTL", () => {
test("detects RTL characters", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
expect(isRTL("Hello")).toBe(false);
});
});
describe("isRTLLanguage", () => {
const createJsSurvey = (
languages: TJsEnvironmentStateSurvey["languages"] = [],
blocks: TSurveyBlock[] = []
): TJsEnvironmentStateSurvey =>
({
id: "s1",
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey => {
return {
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test",
name: "Test Survey",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
blocks,
questions: [],
blocks: [],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
styling: null,
segment: null,
languages,
}) as unknown as TJsEnvironmentStateSurvey;
displayPercentage: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
singleUse: null,
pin: null,
projectOverwrites: null,
surveyClosedMessage: null,
followUps: [],
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
slug: null,
metadata: {},
} as TSurvey;
};
test("checks language codes when multi-language enabled", () => {
const survey = createJsSurvey([
test("maps language codes to web app locales", () => {
const survey = createMockSurvey();
expect(getWebAppLocale("en", survey)).toBe("en-US");
expect(getWebAppLocale("de", survey)).toBe("de-DE");
expect(getWebAppLocale("pt-BR", survey)).toBe("pt-BR");
});
test("handles 'default' languageCode by finding default language in survey", () => {
const survey = createMockSurvey([
{
language: {
id: "l1",
code: "ar",
id: "lang1",
code: "de",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
projectId: "proj1",
},
default: true,
enabled: true,
},
]);
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "en")).toBe(false);
expect(getWebAppLocale("default", survey)).toBe("de-DE");
});
test("checks content when no languages configured", () => {
const element = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(true);
});
test("checks welcomeCard headline when enabled and no languages", () => {
const survey = {
...createJsSurvey([], []),
welcomeCard: { enabled: true, headline: { default: "مرحبا" } },
} as unknown as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
test("returns false when no languages and no headlines found", () => {
const element = { id: "q1", type: TSurveyElementTypeEnum.OpenText, headline: {}, required: false };
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(false);
});
});
describe("getElementsFromSurveyBlocks", () => {
test("extracts elements from blocks", () => {
const el1 = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyElement;
const el2 = {
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [el1, el2] } as TSurveyBlock;
const result = getElementsFromSurveyBlocks([block]);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("q1");
test("falls back to en-US when language is not supported", () => {
const survey = createMockSurvey();
expect(getWebAppLocale("default", survey)).toBe("en-US");
expect(getWebAppLocale("xx", survey)).toBe("en-US");
});
});
-55
View File
@@ -1,60 +1,5 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param survey The survey to test
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
}
/**
* Derives a flat array of elements from the survey's blocks structure.
* @param blocks The blocks array
* @returns An array of TSurveyElement (pure elements without block-level properties)
*/
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
/**
* Maps survey language codes to web app locale codes.
* Falls back to "en-US" if the language is not available in web app locales.
@@ -13,7 +13,6 @@ interface ClientLogoProps {
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const ClientLogo = ({
@@ -21,23 +20,13 @@ export const ClientLogo = ({
projectLogo,
surveyLogo,
previewSurvey = false,
dir = "auto",
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
let positionClasses = "";
if (!previewSurvey) {
if (dir === "rtl") {
positionClasses = "top-3 right-3 md:top-7 md:right-7";
} else {
positionClasses = "top-3 left-3 md:top-7 md:left-7";
}
}
return (
<div
className={cn(positionClasses, "group absolute z-0 rounded-lg")}
className={cn(previewSurvey ? "" : "top-3 left-3 md:top-7 md:left-7", "group absolute z-0 rounded-lg")}
style={{ backgroundColor: logoToUse?.bgColor }}>
{previewSurvey && environmentId && (
<Link
@@ -304,7 +304,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mx-3", optionLabelClassName)}
className={cn("mr-3 ml-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -336,7 +336,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mx-3 grow", optionLabelClassName)}
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>
@@ -385,7 +385,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mx-3", optionLabelClassName)}
className={cn("mr-3 ml-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -60,6 +60,26 @@ interface RankingItemProps {
dir?: TextDirection;
}
function getTopButtonRadiusClass(isFirst: boolean, dir?: TextDirection): string {
if (isFirst) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-tl-md";
}
return "rounded-tr-md";
}
function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): string {
if (isLast) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-bl-md";
}
return "rounded-br-md";
}
function RankingItem({
item,
rankedIds,
@@ -74,11 +94,21 @@ function RankingItem({
const isLast = isRanked && rankIndex === rankedIds.length - 1;
const displayNumber = isRanked ? rankIndex + 1 : undefined;
// RTL-aware padding class
const paddingClass = dir === "rtl" ? "pr-3" : "pl-3";
// RTL-aware border class for control buttons
const borderClass = dir === "rtl" ? "border-r" : "border-l";
// RTL-aware border radius classes for control buttons
const topButtonRadiusClass = getTopButtonRadiusClass(isFirst, dir);
const bottomButtonRadiusClass = getBottomButtonRadiusClass(isLast, dir);
return (
<div
dir={dir}
className={cn(
"rounded-option flex h-12 cursor-pointer items-center border px-3 transition-all",
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
paddingClass,
"bg-option-bg border-option-border",
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
isRanked && "bg-option-selected-bg border-brand",
@@ -108,14 +138,16 @@ function RankingItem({
)}>
{displayNumber}
</span>
<span className="font-option text-option font-option-weight text-option-label shrink grow text-start">
<span
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
dir={dir}>
{item.label}
</span>
</button>
{/* Up/Down buttons for ranked items */}
{isRanked ? (
<div className={cn("border-option-border -mx-3 flex h-full grow-0 flex-col")} dir={dir}>
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
<button
type="button"
tabIndex={isFirst ? -1 : 0}
@@ -125,7 +157,10 @@ function RankingItem({
}}
disabled={isFirst || disabled}
aria-label={`Move ${item.label} up`}
className={cn("flex flex-1 items-center justify-center px-2 transition-colors")}>
className={cn(
"flex flex-1 items-center justify-center px-2 transition-colors",
topButtonRadiusClass
)}>
<ChevronUp className="h-5 w-5" />
</button>
<button
@@ -138,7 +173,8 @@ function RankingItem({
disabled={isLast || disabled}
aria-label={`Move ${item.label} down`}
className={cn(
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors"
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors",
bottomButtonRadiusClass
)}>
<ChevronDown className="h-5 w-5" />
</button>
@@ -225,6 +261,7 @@ function Ranking({
{/* Ranking Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
@@ -244,7 +244,6 @@ function SingleSelect({
return (
<label
key={option.id}
dir={dir}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
@@ -255,7 +254,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mx-3 grow", optionLabelClassName)}
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -266,7 +265,6 @@ function SingleSelect({
{hasOtherOption && otherOptionId ? (
<label
htmlFor={`${inputId}-${otherOptionId}`}
dir={dir}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -306,7 +304,6 @@ function SingleSelect({
<label
key={option.id}
htmlFor={optionId}
dir={dir}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -316,7 +313,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mx-3 grow", optionLabelClassName)}
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
@@ -17,8 +16,6 @@ interface DateElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
surveyLanguages: TSurveyLanguage[];
dir?: "ltr" | "rtl" | "auto";
}
export function DateElement({
@@ -29,8 +26,6 @@ export function DateElement({
ttc,
setTtc,
currentElementId,
surveyLanguages,
dir = "auto",
}: Readonly<DateElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -83,12 +78,7 @@ export function DateElement({
required={element.required}
requiredLabel={t("common.required")}
errorMessage={errorMessage}
locale={
languageCode === "default"
? surveyLanguages.find((language) => language.default)?.language.code
: languageCode
}
dir={dir}
locale={languageCode}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>
@@ -15,7 +15,6 @@ interface MatrixElementProps {
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function MatrixElement({
@@ -26,7 +25,6 @@ export function MatrixElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<MatrixElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -144,7 +142,6 @@ export function MatrixElement({
return (
<form key={element.id} onSubmit={handleSubmit} className="w-full">
<Matrix
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}
@@ -16,7 +16,6 @@ interface RankingElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function RankingElement({
@@ -27,7 +26,6 @@ export function RankingElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<RankingElementProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
@@ -130,7 +128,6 @@ export function RankingElement({
return (
<form onSubmit={handleSubmit} className="w-full">
<Ranking
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}
@@ -9,7 +9,6 @@ import {
type TSurveyMatrixElement,
type TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
@@ -17,6 +16,41 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/**
* Safely calls requestSubmit on a form element with fallback for browsers
* that don't support it (e.g., Mobile Safari 15.5)
*/
const safeRequestSubmit = (form: HTMLFormElement | null | undefined): void => {
if (!form) {
return;
}
// Check if requestSubmit is available
if (typeof form.requestSubmit === "function") {
try {
form.requestSubmit();
} catch (error) {
// Fallback if requestSubmit throws an error
console.warn("[Formbricks] form.requestSubmit() failed, using fallback:", error);
dispatchSubmitEvent(form);
}
} else {
// Fallback for browsers that don't support requestSubmit
dispatchSubmitEvent(form);
}
};
/**
* Fallback method to trigger form validation by dispatching a submit event
*/
const dispatchSubmitEvent = (form: HTMLFormElement): void => {
const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
});
form.dispatchEvent(submitEvent);
};
interface BlockConditionalProps {
block: TSurveyBlock;
value: TResponseData;
@@ -37,7 +71,6 @@ interface BlockConditionalProps {
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
fullSizeCards: boolean;
surveyLanguages: TSurveyLanguage[];
}
export function BlockConditional({
@@ -60,8 +93,7 @@ export function BlockConditional({
onOpenExternalURL,
dir,
fullSizeCards,
surveyLanguages,
}: Readonly<BlockConditionalProps>) {
}: BlockConditionalProps) {
// Track the current element being filled (for TTC tracking)
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
@@ -144,7 +176,7 @@ export function BlockConditional({
response.length < rankingElement.choices.length);
if (hasIncompleteRanking) {
form.requestSubmit();
safeRequestSubmit(form);
return false;
}
return true;
@@ -177,7 +209,7 @@ export function BlockConditional({
element.type === TSurveyElementTypeEnum.ContactInfo
) {
if (!form.checkValidity()) {
form.requestSubmit();
safeRequestSubmit(form);
return false;
}
return true;
@@ -194,14 +226,14 @@ export function BlockConditional({
response &&
hasUnansweredRows(response, element)
) {
form.requestSubmit();
safeRequestSubmit(form);
return false;
}
// For other element types, check if required fields are empty
// CTA elements should not block navigation even if marked required (as they are informational)
if (element.type !== TSurveyElementTypeEnum.CTA && element.required && isEmptyResponse(response)) {
form.requestSubmit();
safeRequestSubmit(form);
return false;
}
@@ -232,9 +264,7 @@ export function BlockConditional({
// Call each form's submit method to trigger TTC calculation
block.elements.forEach((element) => {
const form = elementFormRefs.current.get(element.id);
if (form) {
form.requestSubmit();
}
safeRequestSubmit(form);
});
// Collect TTC from the ref (populated synchronously by form submissions)
@@ -293,7 +323,6 @@ export function BlockConditional({
return (
<div key={element.id}>
<ElementConditional
surveyLanguages={surveyLanguages}
element={element}
value={value[element.id]}
onChange={(responseData) => handleElementChange(element.id, responseData)}
@@ -4,7 +4,6 @@ import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "
import { type TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { AddressElement } from "@/components/elements/address-element";
import { CalElement } from "@/components/elements/cal-element";
import { ConsentElement } from "@/components/elements/consent-element";
@@ -37,7 +36,6 @@ interface ElementConditionalProps {
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
surveyLanguages: TSurveyLanguage[];
}
export function ElementConditional({
@@ -55,8 +53,7 @@ export function ElementConditional({
dir,
formRef,
onTtcCollect,
surveyLanguages,
}: Readonly<ElementConditionalProps>) {
}: ElementConditionalProps) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -227,8 +224,6 @@ export function ElementConditional({
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
surveyLanguages={surveyLanguages}
dir={dir}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
@@ -285,7 +280,6 @@ export function ElementConditional({
ttc={ttc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Address:
@@ -305,7 +299,6 @@ export function ElementConditional({
case TSurveyElementTypeEnum.Ranking:
return (
<RankingElement
dir={dir}
element={element}
value={Array.isArray(value) ? getResponseValueForRankingElement(value, element.choices) : []}
onChange={onChange}
@@ -8,7 +8,7 @@ import { mixColor } from "@/lib/color";
import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { cn, isRTLLanguage } from "@/lib/utils";
import { checkIfSurveyIsRTL, cn } from "@/lib/utils";
interface LanguageSwitchProps {
survey: TJsEnvironmentStateSurvey;
@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
setDir?.(calculateDir);
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { isRTLLanguage } from "@/lib/utils";
import { checkIfSurveyIsRTL } from "@/lib/utils";
import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey";
@@ -8,11 +8,12 @@ export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
useEffect(() => {
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto");
}, [props.languageCode, props.survey]);
@@ -801,7 +801,6 @@ export function Survey({
return (
Boolean(block) && (
<BlockConditional
surveyLanguages={localSurvey.languages}
key={block.id}
surveyId={localSurvey.id}
block={{
@@ -30,7 +30,7 @@ interface WelcomeCardProps {
function TimerIcon() {
return (
<div>
<div className="mr-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -177,16 +177,18 @@ export function WelcomeCard({
</div>
{timeToFinish && !showResponseCount ? (
<div
className="text-subheading my-4 flex items-center space-x-1"
className="text-subheading my-4 flex items-center"
data-testid="fb__surveys__welcome-card__time-display">
<TimerIcon />
<p className="pt-1 text-xs">
<span>{calculateTimeToComplete()} </span>
<span>
{calculateTimeToComplete()}{" "}
</span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="text-subheading my-4 flex items-center space-x-1">
<div className="text-subheading my-4 flex items-center">
<UsersIcon />
<p className="pt-1 text-xs">
<span data-testid="fb__surveys__welcome-card__response-count">
@@ -196,10 +198,12 @@ export function WelcomeCard({
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="text-subheading my-4 flex items-center space-x-1">
<div className="text-subheading my-4 flex items-center">
<TimerIcon />
<p className="pt-1 text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span>{calculateTimeToComplete()} </span>
<span>
{calculateTimeToComplete()}{" "}
</span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3
? `${t("common.people_responded", { count: responseCount })}`
-82
View File
@@ -10,8 +10,6 @@ import {
getMimeType,
getShuffledChoicesIds,
getShuffledRowIndices,
isRTL,
isRTLLanguage,
} from "./utils";
// Mock crypto.getRandomValues for deterministic shuffle tests
@@ -329,83 +327,3 @@ describe("findBlockByElementId", () => {
expect(block).toBeUndefined();
});
});
describe("isRTL", () => {
test("returns true for RTL text", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
});
test("returns false for LTR text", () => {
expect(isRTL("Hello")).toBe(false);
expect(isRTL("")).toBe(false);
});
});
describe("isRTLLanguage", () => {
test("returns true for RTL language codes when multi-language enabled", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "ar",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "he")).toBe(true);
});
test("returns false for LTR language codes", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "en",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "en")).toBe(false);
});
test("checks survey content when no languages configured", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
});
+18 -35
View File
@@ -222,43 +222,26 @@ export function isRTL(text: string): boolean {
return rtlCharRegex.test(text);
}
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCode: string): boolean => {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
};
/**
* Derives a flat array of elements from the survey's blocks structure.
+1
View File
@@ -10249,6 +10249,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
tarn@3.0.2:
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}