mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-30 09:21:48 -05:00
Compare commits
1 Commits
typeerror-
...
feat/attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d47ab9709 |
@@ -24,6 +24,7 @@ interface LinkSurveyWrapperProps {
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
publicDomain: string;
|
||||
isBrandingEnabled: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export const LinkSurveyWrapper = ({
|
||||
@@ -41,6 +42,7 @@ export const LinkSurveyWrapper = ({
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
publicDomain,
|
||||
isBrandingEnabled,
|
||||
dir = "auto",
|
||||
}: LinkSurveyWrapperProps) => {
|
||||
//for embedded survey strip away all surrounding css
|
||||
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
|
||||
@@ -80,11 +82,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} />
|
||||
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} dir={dir} />
|
||||
)}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
<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 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 />
|
||||
Survey Preview 👀
|
||||
<ResetProgressButton onClick={handleResetSurvey} />
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -116,6 +117,11 @@ 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 (
|
||||
<>
|
||||
@@ -140,7 +146,8 @@ export const SurveyClientWrapper = ({
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
isBrandingEnabled={project.linkSurveyBranding}>
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
dir={logoDir}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
environmentId={survey.environmentId}
|
||||
|
||||
@@ -1,85 +1,188 @@
|
||||
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 { getWebAppLocale } from "./utils";
|
||||
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;
|
||||
|
||||
describe("getWebAppLocale", () => {
|
||||
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey => {
|
||||
return {
|
||||
id: "survey-1",
|
||||
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",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
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,
|
||||
blocks,
|
||||
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;
|
||||
};
|
||||
}) as unknown as TJsEnvironmentStateSurvey;
|
||||
|
||||
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([
|
||||
test("checks language codes when multi-language enabled", () => {
|
||||
const survey = createJsSurvey([
|
||||
{
|
||||
language: {
|
||||
id: "lang1",
|
||||
code: "de",
|
||||
id: "l1",
|
||||
code: "ar",
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "proj1",
|
||||
projectId: "p1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getWebAppLocale("default", survey)).toBe("de-DE");
|
||||
expect(isRTLLanguage(survey, "ar")).toBe(true);
|
||||
expect(isRTLLanguage(survey, "en")).toBe(false);
|
||||
});
|
||||
|
||||
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");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
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,6 +13,7 @@ interface ClientLogoProps {
|
||||
projectLogo: Project["logo"] | null;
|
||||
surveyLogo?: TLogo | null;
|
||||
previewSurvey?: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export const ClientLogo = ({
|
||||
@@ -20,13 +21,23 @@ 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(previewSurvey ? "" : "top-3 left-3 md:top-7 md:left-7", "group absolute z-0 rounded-lg")}
|
||||
className={cn(positionClasses, "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("mr-3 ml-3", optionLabelClassName)}
|
||||
className={cn("mx-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("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
className={cn("mx-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("mr-3 ml-3", optionLabelClassName)}
|
||||
className={cn("mx-3", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
|
||||
@@ -60,26 +60,6 @@ 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,
|
||||
@@ -94,21 +74,11 @@ 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 transition-all",
|
||||
paddingClass,
|
||||
"rounded-option flex h-12 cursor-pointer items-center border px-3 transition-all",
|
||||
"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",
|
||||
@@ -138,16 +108,14 @@ function RankingItem({
|
||||
)}>
|
||||
{displayNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
|
||||
dir={dir}>
|
||||
<span className="font-option text-option font-option-weight text-option-label shrink grow text-start">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Up/Down buttons for ranked items */}
|
||||
{isRanked ? (
|
||||
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
|
||||
<div className={cn("border-option-border -mx-3 flex h-full grow-0 flex-col")} dir={dir}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isFirst ? -1 : 0}
|
||||
@@ -157,10 +125,7 @@ function RankingItem({
|
||||
}}
|
||||
disabled={isFirst || disabled}
|
||||
aria-label={`Move ${item.label} up`}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center px-2 transition-colors",
|
||||
topButtonRadiusClass
|
||||
)}>
|
||||
className={cn("flex flex-1 items-center justify-center px-2 transition-colors")}>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
@@ -173,8 +138,7 @@ 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",
|
||||
bottomButtonRadiusClass
|
||||
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors"
|
||||
)}>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -261,7 +225,6 @@ 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,6 +244,7 @@ function SingleSelect({
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
dir={dir}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
@@ -254,7 +255,7 @@ function SingleSelect({
|
||||
aria-required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
className={cn("mx-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
@@ -265,6 +266,7 @@ function SingleSelect({
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<label
|
||||
htmlFor={`${inputId}-${otherOptionId}`}
|
||||
dir={dir}
|
||||
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<RadioGroupItem
|
||||
@@ -304,6 +306,7 @@ function SingleSelect({
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
dir={dir}
|
||||
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<RadioGroupItem
|
||||
@@ -313,7 +316,7 @@ function SingleSelect({
|
||||
aria-required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
className={cn("mx-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
@@ -16,6 +17,8 @@ interface DateElementProps {
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
surveyLanguages: TSurveyLanguage[];
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export function DateElement({
|
||||
@@ -26,6 +29,8 @@ export function DateElement({
|
||||
ttc,
|
||||
setTtc,
|
||||
currentElementId,
|
||||
surveyLanguages,
|
||||
dir = "auto",
|
||||
}: Readonly<DateElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
||||
@@ -78,7 +83,12 @@ export function DateElement({
|
||||
required={element.required}
|
||||
requiredLabel={t("common.required")}
|
||||
errorMessage={errorMessage}
|
||||
locale={languageCode}
|
||||
locale={
|
||||
languageCode === "default"
|
||||
? surveyLanguages.find((language) => language.default)?.language.code
|
||||
: languageCode
|
||||
}
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface MatrixElementProps {
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export function MatrixElement({
|
||||
@@ -25,6 +26,7 @@ export function MatrixElement({
|
||||
ttc,
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
}: Readonly<MatrixElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
||||
@@ -142,6 +144,7 @@ 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,6 +16,7 @@ interface RankingElementProps {
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export function RankingElement({
|
||||
@@ -26,6 +27,7 @@ export function RankingElement({
|
||||
ttc,
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
}: Readonly<RankingElementProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
@@ -128,6 +130,7 @@ 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,6 +9,7 @@ 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";
|
||||
@@ -36,6 +37,7 @@ interface BlockConditionalProps {
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
fullSizeCards: boolean;
|
||||
surveyLanguages: TSurveyLanguage[];
|
||||
}
|
||||
|
||||
export function BlockConditional({
|
||||
@@ -58,7 +60,8 @@ export function BlockConditional({
|
||||
onOpenExternalURL,
|
||||
dir,
|
||||
fullSizeCards,
|
||||
}: BlockConditionalProps) {
|
||||
surveyLanguages,
|
||||
}: Readonly<BlockConditionalProps>) {
|
||||
// Track the current element being filled (for TTC tracking)
|
||||
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
||||
|
||||
@@ -290,6 +293,7 @@ export function BlockConditional({
|
||||
return (
|
||||
<div key={element.id}>
|
||||
<ElementConditional
|
||||
surveyLanguages={surveyLanguages}
|
||||
element={element}
|
||||
value={value[element.id]}
|
||||
onChange={(responseData) => handleElementChange(element.id, responseData)}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -36,6 +37,7 @@ 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({
|
||||
@@ -53,7 +55,8 @@ export function ElementConditional({
|
||||
dir,
|
||||
formRef,
|
||||
onTtcCollect,
|
||||
}: ElementConditionalProps) {
|
||||
surveyLanguages,
|
||||
}: Readonly<ElementConditionalProps>) {
|
||||
// Ref to the container div, used to find and expose the form element inside
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -224,6 +227,8 @@ export function ElementConditional({
|
||||
setTtc={wrappedSetTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
surveyLanguages={surveyLanguages}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
@@ -280,6 +285,7 @@ export function ElementConditional({
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Address:
|
||||
@@ -299,6 +305,7 @@ 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 { checkIfSurveyIsRTL, cn } from "@/lib/utils";
|
||||
import { cn, isRTLLanguage } from "@/lib/utils";
|
||||
|
||||
interface LanguageSwitchProps {
|
||||
survey: TJsEnvironmentStateSurvey;
|
||||
@@ -59,7 +59,7 @@ export function LanguageSwitch({
|
||||
handleI18nLanguage(calculatedLanguageCode);
|
||||
|
||||
if (setDir) {
|
||||
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
|
||||
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
|
||||
setDir?.(calculateDir);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
import { checkIfSurveyIsRTL } from "@/lib/utils";
|
||||
import { isRTLLanguage } from "@/lib/utils";
|
||||
import { SurveyContainer } from "../wrappers/survey-container";
|
||||
import { Survey } from "./survey";
|
||||
|
||||
@@ -8,12 +8,11 @@ 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 = checkIfSurveyIsRTL(props.survey, props.languageCode);
|
||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
|
||||
|
||||
useEffect(() => {
|
||||
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
|
||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||
setDir(isRTL ? "rtl" : "auto");
|
||||
}, [props.languageCode, props.survey]);
|
||||
|
||||
|
||||
@@ -801,6 +801,7 @@ 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 className="mr-1">
|
||||
<div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
@@ -177,18 +177,16 @@ export function WelcomeCard({
|
||||
</div>
|
||||
{timeToFinish && !showResponseCount ? (
|
||||
<div
|
||||
className="text-subheading my-4 flex items-center"
|
||||
className="text-subheading my-4 flex items-center space-x-1"
|
||||
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">
|
||||
<div className="text-subheading my-4 flex items-center space-x-1">
|
||||
<UsersIcon />
|
||||
<p className="pt-1 text-xs">
|
||||
<span data-testid="fb__surveys__welcome-card__response-count">
|
||||
@@ -198,12 +196,10 @@ export function WelcomeCard({
|
||||
</div>
|
||||
) : null}
|
||||
{timeToFinish && showResponseCount ? (
|
||||
<div className="text-subheading my-4 flex items-center">
|
||||
<div className="text-subheading my-4 flex items-center space-x-1">
|
||||
<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 })}`
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getMimeType,
|
||||
getShuffledChoicesIds,
|
||||
getShuffledRowIndices,
|
||||
isRTL,
|
||||
isRTLLanguage,
|
||||
} from "./utils";
|
||||
|
||||
// Mock crypto.getRandomValues for deterministic shuffle tests
|
||||
@@ -327,3 +329,83 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,26 +222,43 @@ export function isRTL(text: string): boolean {
|
||||
return rtlCharRegex.test(text);
|
||||
}
|
||||
|
||||
export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCode: string): boolean => {
|
||||
if (survey.welcomeCard.enabled) {
|
||||
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
|
||||
if (welcomeCardHeadline) {
|
||||
return isRTL(welcomeCardHeadline);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
for (const question of questions) {
|
||||
const questionHeadline = question.headline[languageCode];
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a flat array of elements from the survey's blocks structure.
|
||||
|
||||
Reference in New Issue
Block a user