feat: adds multi language functionality to surveys package (#6527)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Piyush Gupta
2025-09-11 19:18:08 +05:30
committed by GitHub
parent 22d4952a40
commit 21c8b5d6e4
54 changed files with 653 additions and 192 deletions

1
.gitignore vendored
View File

@@ -74,3 +74,4 @@ infra/terraform/.terraform/
/*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules
i18n.cache

View File

@@ -34,7 +34,8 @@
"prepare": "husky install",
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json"
"tolgee-pull": "BRANCH_NAME=$(node -p \"require('./branch.json').branchName\") && tolgee pull --tags \"draft:$BRANCH_NAME\" \"production\" && prettier --write ./apps/web/locales/*.json",
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate"
},
"dependencies": {
"react": "19.1.0",

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://lingo.dev/schema/i18n.json",
"buckets": {
"json": {
"include": ["locales/[locale].json"]
}
},
"locale": {
"source": "en",
"targets": ["de"]
},
"version": 1.8
}

View File

@@ -0,0 +1,63 @@
version: 1
checksums:
55dc377b5e931bfc64e4add83e35d9ea:
common/and: ce8b9bb44f031705708a70e068bb73c8
common/apply: eb61ee65aa06a70b2bc908cf0e9f2104
common/auto_close_wrapper: fd0948e4d47150513e2b354c7685c1a7
common/back: f541015a827e37cb3b1234e56bc2aa3c
common/click_or_drag_to_upload_files: 64f59bc339568d52b8464b82546b70ea
common/close_survey: 36e6aaa19051cb253aa155ad69a9edbc
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
common/delete_file: ee2f3e3fb4d2b227aea90e44fdaca861
common/file_upload: fe254dc8892e76cf5a008d712c6ce9c8
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
common/less_than_x_minutes: 8a8528651d0b60dc93be451abf6a139b
common/move_down: 479ab9ea756d1d814f7dcfe7cd7c21ba
common/move_up: c18fce90954378eb8573f5c3050bf140
common/next: 89ddbcf710eba274963494f312bdc8a9
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
common/optional: 396fb9a0472daf401c392bdc3e248943
common/options: 59156082418d80acb211f973b1218f11
common/people_responded: b685fb877090d8658db724ad07a0dbd8
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb
common/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
common/protected_by_reCAPTCHA_and_the_Google: 32de026bff5d52e9edf5410d7d7b835f
common/question: 0576462ce60d4263d7c482463fcc9547
common/question_video: cc554b661fd62ac59db500307b3ba44e
common/ranking_items: 463f2eb500f1b42fbce6cec17612fb9a
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
common/retry: 6e44d18639560596569a1278f9c83676
common/select_a_date: 521e4a705800da06d091fde3e801ce02
common/select_for_ranking: e5f4e20752d1c2d852cd02dc3a0e9dd0
common/sending_responses: 184772f70cca69424eaf34f73520789f
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
common/upload_files_by_clicking_or_dragging_them_here: 771d058cb341fe8f5c5866d0a617d9d2
common/uploading: 31c3e8fa5eca1a3108d3fa6178962d5d
common/x_minutes: bf6ec8800c29b1447226447a991b9510
common/x_plus_minutes: 2ef597aa029e3c71d442455fbb751991
common/you_have_selected_x_date: 38eae371bac832a099c235cac97f3ef3
common/you_have_successfully_uploaded_the_file: 42fa83be555dc13e619241502cda6d9c
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
errors/file_input/file_size_exceeded_alert: d8e482a2ff05e78bbacaed9e9db9b5eb
errors/file_input/native_upload_failed: 46570e5745c6c0d2489840a3093abdb0
errors/file_input/no_valid_file_types_selected: 795acdedcffbcf06e57ea93fc16771ce
errors/file_input/only_one_file_can_be_uploaded_at_a_time: 1eda42bd46887f9702049e23fa7cb127
errors/file_input/upload_failed: 735fdfc1a37ab035121328237ddd6fd0
errors/file_input/you_can_only_upload_a_maximum_of_files: 72fe144f81075e5b06bae53b3a84d4db
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131

View File

@@ -0,0 +1,72 @@
{
"common": {
"and": "und",
"apply": "anwenden",
"auto_close_wrapper": "Automatisches Schließen",
"back": "Zurück",
"click_or_drag_to_upload_files": "Klicken oder ziehen Sie, um Dateien hochzuladen.",
"close_survey": "Umfrage schließen",
"company_logo": "Firmenlogo",
"delete_file": "Datei löschen",
"file_upload": "Datei-Upload",
"finish": "Fertig",
"language_switch": "Sprachwechsel",
"less_than_x_minutes": "{count, plural, one {weniger als 1 Minute} other {weniger als {count} Minuten}}",
"move_down": "{item} nach unten verschieben",
"move_up": "{item} nach oben verschieben",
"next": "Weiter",
"open_in_new_tab": "In neuem Tab öffnen",
"optional": "Optional",
"options": "Optionen",
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
"powered_by": "Bereitgestellt von",
"privacy_policy": "Datenschutzrichtlinie",
"protected_by_reCAPTCHA_and_the_Google": "Geschützt durch reCAPTCHA und die Google",
"question": "Frage",
"question_video": "Fragevideo",
"ranking_items": "Ranking-Elemente",
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
"retry": "Wiederholen",
"select_a_date": "Datum auswählen",
"select_for_ranking": "{item} für Ranking auswählen",
"sending_responses": "Antworten werden gesendet...",
"takes": "Dauert",
"terms_of_service": "Nutzungsbedingungen",
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
"upload_files_by_clicking_or_dragging_them_here": "Laden Sie Dateien hoch, indem Sie sie hier anklicken oder hierher ziehen",
"uploading": "Wird hochgeladen",
"x_minutes": "{count, plural, one {1 Minute} other {{count} Minuten}}",
"x_plus_minutes": "{count}+ Minuten",
"you_have_selected_x_date": "Sie haben {date} ausgewählt",
"you_have_successfully_uploaded_the_file": "Sie haben die Datei {fileName} erfolgreich hochgeladen",
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
},
"errors": {
"file_input": {
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
"native_upload_failed": "Fehler beim Hochladen der nativen Datei.",
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
},
"invalid_device_error": {
"message": "Bitte deaktivieren Sie den Spam-Schutz in den Umfrageeinstellungen, um dieses Gerät weiterhin zu verwenden.",
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
},
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
"please_upload_a_file": "Bitte laden Sie eine Datei hoch",
"recaptcha_error": {
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
"title": "Wir konnten nicht verifizieren, dass Sie ein Mensch sind."
}
}
}

View File

@@ -0,0 +1,72 @@
{
"common": {
"and": "and",
"apply": "apply",
"auto_close_wrapper": "Auto close wrapper",
"back": "Back",
"click_or_drag_to_upload_files": "Click or drag to upload files.",
"close_survey": "Close survey",
"company_logo": "Company Logo",
"delete_file": "Delete file",
"file_upload": "File upload",
"finish": "Finish",
"language_switch": "Language switch",
"less_than_x_minutes": "{count, plural, one {less than 1 minute} other {less than {count} minutes}}",
"move_down": "Move {item} down",
"move_up": "Move {item} up",
"next": "Next",
"open_in_new_tab": "Open in new tab",
"optional": "Optional",
"options": "Options",
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
"powered_by": "Powered by",
"privacy_policy": "Privacy Policy",
"protected_by_reCAPTCHA_and_the_Google": "Protected by reCAPTCHA and the Google",
"question": "Question",
"question_video": "Question Video",
"ranking_items": "Ranking Items",
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"select_a_date": "Select a date",
"select_for_ranking": "Select {item} for ranking",
"sending_responses": "Sending responses...",
"takes": "Takes",
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"upload_files_by_clicking_or_dragging_them_here": "Upload files by clicking or dragging them here",
"uploading": "Uploading",
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
"x_plus_minutes": "{count}+ minutes",
"you_have_selected_x_date": "You have selected {date}",
"you_have_successfully_uploaded_the_file": "You've successfully uploaded the file {fileName}",
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
"file_input": {
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
"native_upload_failed": "Error uploading native file.",
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
"upload_failed": "Upload failed! Please try again.",
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
},
"invalid_device_error": {
"message": "Please disable spam protection in the survey settings to continue using this device.",
"title": "This device doesnt support spam protection."
},
"please_book_an_appointment": "Please book an appointment",
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
"please_fill_out_this_field": "Please fill out this field",
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
"please_select_a_date": "Please select a date",
"please_upload_a_file": "Please upload a file",
"recaptcha_error": {
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
"title": "We couldn't verify that you're human."
}
}
}

View File

@@ -34,15 +34,19 @@
"preview": "vite preview",
"clean": "rimraf .turbo node_modules dist",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"i18n:generate": "npx lingo.dev@latest i18n"
},
"dependencies": {
"@calcom/embed-snippet": "1.3.3",
"@formkit/auto-animate": "0.8.2",
"i18next": "25.5.2",
"i18next-icu": "2.4.0",
"isomorphic-dompurify": "2.24.0",
"preact": "10.26.6",
"react-calendar": "5.1.0",
"react-date-picker": "11.0.0"
"react-date-picker": "11.0.0",
"react-i18next": "15.7.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -12,7 +12,7 @@ describe("BackButton", () => {
const { getByRole } = render(<BackButton onClick={onClick} />);
const button = getByRole("button");
expect(button).toBeDefined();
expect(button.textContent?.trim()).toEqual("Back");
expect(button.textContent?.trim()).toEqual("common.back");
expect(button.tabIndex).toEqual(2);
});

View File

@@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface BackButtonProps {
onClick: () => void;
@@ -7,6 +8,7 @@ interface BackButtonProps {
}
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
const { t } = useTranslation();
return (
<button
dir="auto"
@@ -16,7 +18,7 @@ export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButto
"fb-mb-1 hover:fb-bg-input-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2"
)}
onClick={onClick}>
{backButtonLabel || "Back"}
{backButtonLabel || t("common.back")}
</button>
);
}

View File

@@ -11,13 +11,13 @@ describe("SubmitButton", () => {
test('renders default label "Next" when no buttonLabel is provided and isLastQuestion is false', () => {
const { getByRole } = render(<SubmitButton buttonLabel={undefined} isLastQuestion={false} />);
const button = getByRole("button");
expect(button.textContent?.trim()).toBe("Next");
expect(button.textContent?.trim()).toBe("common.next");
});
test('renders "Finish" when isLastQuestion is true and no buttonLabel is provided', () => {
const { getByRole } = render(<SubmitButton buttonLabel={undefined} isLastQuestion />);
const button = getByRole("button");
expect(button.textContent?.trim()).toBe("Finish");
expect(button.textContent?.trim()).toBe("common.finish");
});
test("renders custom buttonLabel when provided", () => {

View File

@@ -1,5 +1,6 @@
import { ButtonHTMLAttributes, useRef } from "preact/compat";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
buttonLabel?: string;
@@ -34,6 +35,7 @@ export function SubmitButton({
};
}
}, [isProcessing]);
const { t } = useTranslation();
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -73,7 +75,7 @@ export function SubmitButton({
className="fb-bg-brand fb-border-submit-button-border fb-text-on-brand focus:fb-ring-focus fb-rounded-custom fb-flex fb-items-center fb-border fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-mb-1"
onClick={onClick}
disabled={disabled}>
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}
</button>
);
}

View File

@@ -78,7 +78,7 @@ describe("EndingCard", () => {
/>
);
expect(screen.getByText("Sending responses...")).toBeInTheDocument();
expect(screen.getByText("common.sending_responses")).toBeInTheDocument();
});
test("calls onOpenExternalURL when button is clicked", async () => {
@@ -121,8 +121,8 @@ describe("EndingCard", () => {
/>
);
expect(screen.getByText("Respondents will not see this card")).toBeInTheDocument();
expect(screen.getByText("They will be redirected immediately")).toBeInTheDocument();
expect(screen.getByText("common.respondents_will_not_see_this_card")).toBeInTheDocument();
expect(screen.getByText("common.they_will_be_redirected_immediately")).toBeInTheDocument();
});
test("handles Enter key press for link surveys", async () => {

View File

@@ -7,6 +7,7 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseVariables } from "@formbricks/types/responses";
import { type TSurveyEndScreenCard, type TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
@@ -38,6 +39,7 @@ export function EndingCard({
onOpenExternalURL,
isPreviewMode,
}: EndingCardProps) {
const { t } = useTranslation();
const media =
endingCard.type === "endScreen" && (endingCard.imageUrl ?? endingCard.videoUrl) ? (
<QuestionMedia imgUrl={endingCard.imageUrl} videoUrl={endingCard.videoUrl} />
@@ -158,10 +160,13 @@ export function EndingCard({
<div>
<Headline
alignTextCenter
headline={"Respondents will not see this card"}
headline={t("common.respondents_will_not_see_this_card")}
questionId="EndingCard"
/>
<Subheader
subheader={t("common.they_will_be_redirected_immediately")}
questionId="EndingCard"
/>
<Subheader subheader={"They will be redirected immediately"} questionId="EndingCard" />
</div>
) : (
<div className="fb-my-3">
@@ -176,7 +181,7 @@ export function EndingCard({
<div className="fb-my-3">
<LoadingSpinner />
</div>
<h1 className="fb-text-brand">Sending responses...</h1>
<h1 className="fb-text-brand">{t("common.sending_responses")}</h1>
</>
)}
</div>

View File

@@ -16,12 +16,10 @@ describe("ErrorComponent", () => {
expect(alertDiv).toBeInTheDocument();
const titleSpan = alertDiv.querySelector("span");
expect(titleSpan?.textContent?.trim()).toBe("We couldn't verify that you're human.");
expect(titleSpan?.textContent?.trim()).toBe("errors.recaptcha_error.title");
const messageP = alertDiv.querySelector("p");
expect(messageP?.textContent?.trim()).toBe(
"Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again."
);
expect(messageP?.textContent?.trim()).toBe("errors.recaptcha_error.message");
});
test("renders InvalidDeviceError correctly", () => {
@@ -31,12 +29,10 @@ describe("ErrorComponent", () => {
expect(alertDiv).toBeInTheDocument();
const titleSpan = alertDiv.querySelector("span");
expect(titleSpan?.textContent?.trim()).toBe("This device doesnt support spam protection.");
expect(titleSpan?.textContent?.trim()).toBe("errors.invalid_device_error.title");
const messageP = alertDiv.querySelector("p");
expect(messageP?.textContent?.trim()).toBe(
"Please disable spam protection in the survey settings to continue using this device."
);
expect(messageP?.textContent?.trim()).toBe("errors.invalid_device_error.message");
});
test("has correct accessibility attributes", () => {
@@ -59,7 +55,7 @@ describe("ErrorComponent", () => {
"fb-items-center"
);
const titleSpan = screen.getByText(/We couldn't verify that you're human/);
const titleSpan = screen.getByText("errors.recaptcha_error.title");
expect(titleSpan).toHaveClass(
"fb-mb-1.5",
"fb-text-base",
@@ -68,7 +64,7 @@ describe("ErrorComponent", () => {
"fb-text-slate-900"
);
const messageP = screen.getByText(/Your response could not be submitted/);
const messageP = screen.getByText("errors.recaptcha_error.message");
expect(messageP).toHaveClass(
"fb-max-w-lg",
"fb-text-sm",

View File

@@ -1,19 +1,20 @@
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
import { useTranslation } from "react-i18next";
interface ErrorComponentProps {
readonly errorType: TResponseErrorCodesEnum.RecaptchaError | TResponseErrorCodesEnum.InvalidDeviceError;
}
export function ErrorComponent({ errorType }: ErrorComponentProps) {
const { t } = useTranslation();
const errorData = {
[TResponseErrorCodesEnum.RecaptchaError]: {
title: "We couldn't verify that you're human.",
message:
"Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
title: t("errors.recaptcha_error.title"),
message: t("errors.recaptcha_error.message"),
},
[TResponseErrorCodesEnum.InvalidDeviceError]: {
title: "This device doesnt support spam protection.",
message: "Please disable spam protection in the survey settings to continue using this device.",
title: t("errors.invalid_device_error.title"),
message: t("errors.invalid_device_error.message"),
},
};

View File

@@ -49,7 +49,7 @@ describe("FileInput", () => {
allowMultipleFiles={true}
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const file = createFile("test.txt", 500, "text/plain");
fireEvent.change(input, { target: { files: [file] } });
@@ -73,11 +73,11 @@ describe("FileInput", () => {
allowMultipleFiles={true}
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const file = createFile("image.jpg", 1000, "image/jpeg");
fireEvent.change(input, { target: { files: [file] } });
expect(alertSpy).toHaveBeenCalledWith("No valid file types selected. Please select a valid file type.");
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.no_valid_file_types_selected");
expect(onFileUpload).not.toHaveBeenCalled();
expect(onUploadCallback).not.toHaveBeenCalled();
});
@@ -92,11 +92,11 @@ describe("FileInput", () => {
allowMultipleFiles={false}
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const files = [createFile("one.txt", 500, "text/plain"), createFile("two.txt", 500, "text/plain")];
fireEvent.change(input, { target: { files } });
expect(alertSpy).toHaveBeenCalledWith("Only one file can be uploaded at a time.");
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.only_one_file_can_be_uploaded_at_a_time");
expect(onFileUpload).not.toHaveBeenCalled();
});
@@ -114,7 +114,7 @@ describe("FileInput", () => {
);
expect(screen.getByText("fileA.txt")).toBeInTheDocument();
expect(screen.getByText("fileB.txt")).toBeInTheDocument();
const deleteBtn = screen.getByLabelText("Delete file fileA.txt");
const deleteBtn = screen.getByLabelText(/common.delete_file fileA.txt/);
const svg = deleteBtn.querySelector("svg");
if (!svg) throw new Error("Delete SVG not found");
fireEvent.click(svg);
@@ -132,12 +132,10 @@ describe("FileInput", () => {
allowMultipleFiles={true}
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const dupFile = createFile("dup.txt", 500, "text/plain");
fireEvent.change(input, { target: { files: [dupFile] } });
expect(alertSpy).toHaveBeenCalledWith(
"The following files are already uploaded: dup.txt. Duplicate files are not allowed."
);
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.duplicate_files");
});
test("handles native file upload event", async () => {
@@ -192,7 +190,7 @@ describe("FileInput", () => {
);
// Upload a small file first to verify normal behavior
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
fireEvent.change(input, { target: { files: [smallFile] } });
await waitFor(() => {
@@ -227,11 +225,11 @@ describe("FileInput", () => {
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const invalidFile = createFile("invalid.txt", 500, "text/plain");
fireEvent.change(input, { target: { files: [invalidFile] } });
expect(alertSpy).toHaveBeenCalledWith("No valid file types selected. Please select a valid file type.");
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.no_valid_file_types_selected");
expect(onFileUpload).not.toHaveBeenCalled();
});
@@ -247,13 +245,11 @@ describe("FileInput", () => {
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const dupFile = createFile("dup.txt", 500, "text/plain");
fireEvent.change(input, { target: { files: [dupFile] } });
expect(alertSpy).toHaveBeenCalledWith(
"The following files are already uploaded: dup.txt. Duplicate files are not allowed."
);
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.duplicate_files");
expect(onFileUpload).not.toHaveBeenCalled();
});
@@ -286,7 +282,7 @@ describe("FileInput", () => {
// Check that the alert for rejected files was shown
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining("exceed the maximum size of 0.5 MB"));
expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining("errors.file_input.file_size_exceeded"));
});
// Only the small file should be uploaded
@@ -344,7 +340,7 @@ describe("FileInput", () => {
/>
);
const deleteBtn = screen.getByLabelText("Delete file fileA.txt");
const deleteBtn = screen.getByLabelText(/common.delete_file fileA.txt/);
const svg = deleteBtn.querySelector("svg");
if (!svg) throw new Error("Delete SVG not found");
fireEvent.click(svg);
@@ -364,7 +360,9 @@ describe("FileInput", () => {
/>
);
const label = screen.getByLabelText("Upload files by clicking or dragging them here").closest("label");
const label = screen
.getByLabelText("common.upload_files_by_clicking_or_dragging_them_here")
.closest("label");
if (!label) throw new Error("Label not found");
// Create a mock file and DataTransfer object
@@ -408,14 +406,14 @@ describe("FileInput", () => {
/>
);
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
const file = createFile("error.txt", 500, "text/plain");
fireEvent.change(input, { target: { files: [file] } });
// Wait for the alert to be called
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith("Upload failed! Please try again.");
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.upload_failed");
});
});
@@ -435,11 +433,11 @@ describe("FileInput", () => {
const files = Array(26)
.fill(null)
.map((_, i) => createFile(`file${i}.txt`, 500, "text/plain"));
const input = screen.getByLabelText("File upload");
const input = screen.getByLabelText("common.file_upload");
fireEvent.change(input, { target: { files } });
expect(alertSpy).toHaveBeenCalledWith("You can only upload a maximum of 25 files.");
expect(alertSpy).toHaveBeenCalledWith("errors.file_input.you_can_only_upload_a_maximum_of_files");
expect(onFileUpload).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import { getMimeType, isFulfilled, isRejected } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { type JSXInternal } from "preact/src/jsx";
import { useTranslation } from "react-i18next";
import { type TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TUploadFileConfig } from "@formbricks/types/storage";
@@ -31,6 +32,7 @@ export function FileInput({
allowMultipleFiles,
htmlFor = "",
}: Readonly<FileInputProps>) {
const { t } = useTranslation();
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [parent] = useAutoAnimate();
@@ -60,14 +62,12 @@ export function FileInput({
if (duplicateFiles.length > 0) {
const duplicateNames = duplicateFiles.map((file) => file.name).join(", ");
alert(
`The following files are already uploaded: ${duplicateNames}. Duplicate files are not allowed.`
);
alert(t("errors.file_input.duplicate_files", { duplicateNames }));
}
return { filteredFiles, duplicateFiles };
},
[fileUrls, selectedFiles]
[fileUrls, selectedFiles, t]
);
// Listen for the native file-upload event dispatched via window.formbricksSurveys.onFilePick
@@ -108,9 +108,7 @@ export function FileInput({
// Display alert for rejected files
if (rejectedFiles.length > 0) {
const fileNames = rejectedFiles.join(", ");
alert(
`The following file(s) exceed the maximum size of ${maxSizeInMB} MB and were removed: ${fileNames}`
);
alert(t("errors.file_input.file_size_exceeded", { fileNames, maxSizeInMB }));
}
// If no files remain after filtering, exit early
@@ -126,7 +124,7 @@ export function FileInput({
onUploadCallback(fileUrls ? [...fileUrls, ...uploadedUrls] : uploadedUrls);
} catch (err) {
console.error(`Error uploading native file.`);
alert(`Upload failed! Please try again.`);
alert(t("errors.file_input.upload_failed"));
} finally {
setIsUploading(false);
}
@@ -144,6 +142,7 @@ export function FileInput({
onUploadCallback,
surveyId,
filterDuplicateFiles,
t,
]);
const validateFileSize = async (file: File): Promise<boolean> => {
@@ -151,7 +150,7 @@ export function FileInput({
const fileBuffer = await file.arrayBuffer();
const bufferKB = fileBuffer.byteLength / 1024;
if (bufferKB > maxSizeInMB * 1024) {
alert(`File should be less than ${maxSizeInMB.toString()} MB`);
alert(t("errors.file_input.file_size_exceeded_alert", { maxSizeInMB }));
return false;
}
}
@@ -162,12 +161,12 @@ export function FileInput({
let fileArray = Array.from(files);
if (!allowMultipleFiles && fileArray.length > 1) {
alert("Only one file can be uploaded at a time.");
alert(t("errors.file_input.only_one_file_can_be_uploaded_at_a_time"));
return;
}
if (allowMultipleFiles && selectedFiles.length + fileArray.length > FILE_LIMIT) {
alert(`You can only upload a maximum of ${FILE_LIMIT.toString()} files.`);
alert(t("errors.file_input.you_can_only_upload_a_maximum_of_files", { FILE_LIMIT }));
return;
}
@@ -193,7 +192,7 @@ export function FileInput({
});
if (!validFiles.length) {
alert("No valid file types selected. Please select a valid file type.");
alert(t("errors.file_input.no_valid_file_types_selected"));
return;
}
@@ -243,7 +242,7 @@ export function FileInput({
}
} catch (err: any) {
console.error("error in uploading file: ", err);
alert("Upload failed! Please try again.");
alert(t("errors.file_input.upload_failed"));
} finally {
setIsUploading(false);
}
@@ -298,13 +297,13 @@ export function FileInput({
return (
<div
key={index}
aria-label={`You've successfully uploaded the file ${fileName}`}
aria-label={t("common.you_have_successfully_uploaded_the_file", { fileName })}
tabIndex={0}
className="fb-bg-input-bg-selected fb-border-border fb-relative fb-m-2 fb-rounded-md fb-border">
<div className="fb-absolute fb-right-0 fb-top-0 fb-m-2">
<button
type="button"
aria-label={`Delete file ${fileName}`}
aria-label={`${t("common.delete_file")} ${fileName}`}
className="fb-bg-survey-bg fb-flex fb-h-5 fb-w-5 fb-cursor-pointer fb-items-center fb-justify-center fb-rounded-md">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -349,7 +348,7 @@ export function FileInput({
{isUploading ? (
<div className="fb-inset-0 fb-flex fb-animate-pulse fb-items-center fb-justify-center fb-rounded-lg fb-py-4">
<label htmlFor={uniqueHtmlFor} className="fb-text-subheading fb-text-sm fb-font-medium">
Uploading...
{t("common.uploading")}...
</label>
</div>
) : null}
@@ -359,7 +358,7 @@ export function FileInput({
<button
type="button"
className="focus:fb-outline-brand fb-flex fb-flex-col fb-items-center fb-justify-center fb-py-6 hover:fb-cursor-pointer w-full"
aria-label="Upload files by clicking or dragging them here"
aria-label={t("common.upload_files_by_clicking_or_dragging_them_here")}
onClick={() => document.getElementById(uniqueHtmlFor)?.click()}>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -378,7 +377,7 @@ export function FileInput({
<span
className="fb-text-placeholder fb-mt-2 fb-text-sm dark:fb-text-slate-400"
id={`${uniqueHtmlFor}-label`}>
Click or drag to upload files.
{t("common.click_or_drag_to_upload_files")}
</span>
<input
type="file"
@@ -393,7 +392,7 @@ export function FileInput({
}
}}
multiple={allowMultipleFiles}
aria-label="File upload"
aria-label={t("common.file_upload")}
aria-describedby={`${uniqueHtmlFor}-label`}
data-accept-multiple={allowMultipleFiles}
data-accept-extensions={mimeTypeForAllowedFileExtensions}

View File

@@ -7,7 +7,7 @@ describe("FormbricksBranding", () => {
test("renders branding text with correct link", () => {
render(<FormbricksBranding />);
const link = screen.getByRole("link", { name: /powered by/i });
const link = screen.getByRole("link", { name: /common.powered_by/i });
expect(link).toHaveAttribute("href", "https://formbricks.com?utm_source=survey_branding");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener");

View File

@@ -1,4 +1,7 @@
import { useTranslation } from "react-i18next";
export function FormbricksBranding() {
const { t } = useTranslation();
return (
<a
href="https://formbricks.com?utm_source=survey_branding"
@@ -7,7 +10,7 @@ export function FormbricksBranding() {
className="fb-flex fb-justify-center"
rel="noopener">
<p className="fb-text-signature fb-text-xs">
Powered by{" "}
{t("common.powered_by")}{" "}
<b>
<span className="fb-text-branding-text hover:fb-text-signature">Formbricks</span>
</b>

View File

@@ -50,7 +50,7 @@ describe("Headline", () => {
const optionalText = container.querySelector("span");
expect(optionalText).toBeInTheDocument();
expect(optionalText).toHaveTextContent("Optional");
expect(optionalText).toHaveTextContent("common.optional");
expect(optionalText).toHaveClass("fb-text-xs", "fb-opacity-60", "fb-font-normal");
expect(optionalText).toHaveAttribute("tabIndex", "-1");
});

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface HeadlineProps {
@@ -7,6 +8,7 @@ interface HeadlineProps {
alignTextCenter?: boolean;
}
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
const { t } = useTranslation();
return (
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">
{!required && (
@@ -14,7 +16,7 @@ export function Headline({ headline, questionId, required = true, alignTextCente
className="fb-text-xs fb-opacity-60 fb-font-normal fb-leading-6 fb-mb-[3px]"
tabIndex={-1}
data-testid="fb__surveys__headline-optional-text-test">
Optional
{t("common.optional")}
</span>
)}
<div

View File

@@ -84,7 +84,7 @@ describe("LanguageSwitch", () => {
/>
);
const toggleButton = screen.getByTitle("Language switch");
const toggleButton = screen.getByTitle("common.language_switch");
// Initially closed
expect(toggleButton).toHaveAttribute("aria-expanded", "false");
@@ -109,7 +109,7 @@ describe("LanguageSwitch", () => {
/>
);
const toggleButton = screen.getByTitle("Language switch");
const toggleButton = screen.getByTitle("common.language_switch");
// Open and select default language
fireEvent.click(toggleButton);
fireEvent.click(screen.getByText("en"));

View File

@@ -1,8 +1,11 @@
import { LanguageIcon } from "@/components/icons/language-icon";
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 { useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
@@ -28,6 +31,7 @@ export function LanguageSwitch({
dir = "auto",
setDir,
}: LanguageSwitchProps) {
const { t } = useTranslation();
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
const [isHovered, setIsHovered] = useState(false);
@@ -41,10 +45,19 @@ export function LanguageSwitch({
return surveyLanguage.default;
})?.language.code;
const handleI18nLanguage = (languageCode: string) => {
const calculatedLanguage = getI18nLanguage(languageCode, surveyLanguages);
if (i18n.language !== calculatedLanguage) {
i18n.changeLanguage(calculatedLanguage);
}
};
const changeLanguage = (languageCode: string) => {
const calculatedLanguageCode = languageCode === defaultLanguageCode ? "default" : languageCode;
setSelectedLanguageCode(calculatedLanguageCode);
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
setDir?.(calculateDir);
@@ -64,7 +77,7 @@ export function LanguageSwitch({
return (
<div className="fb-z-[1001] fb-flex fb-w-fit fb-items-center">
<button
title="Language switch"
title={t("common.language_switch")}
type="button"
className={cn(
"fb-text-heading fb-relative fb-h-8 fb-w-8 fb-rounded-md focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-justify-center fb-flex fb-items-center"
@@ -78,7 +91,7 @@ export function LanguageSwitch({
tabIndex={-1}
aria-haspopup="true"
aria-expanded={showLanguageDropdown}
aria-label="Language switch"
aria-label={t("common.language_switch")}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<LanguageIcon />

View File

@@ -21,7 +21,7 @@ describe("QuestionMedia", () => {
const videoUrl = "https://www.youtube.com/watch?v=test123";
render(<QuestionMedia videoUrl={videoUrl} />);
const iframe = screen.getByTitle("Question Video");
const iframe = screen.getByTitle("common.question_video");
expect(iframe).toBeTruthy();
expect(iframe.getAttribute("src")).toBe(videoUrl + "?controls=0");
});
@@ -30,7 +30,7 @@ describe("QuestionMedia", () => {
const videoUrl = "https://vimeo.com/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
const iframe = screen.getByTitle("Question Video");
const iframe = screen.getByTitle("common.question_video");
expect(iframe).toBeTruthy();
expect(iframe.getAttribute("src")).toBe(
videoUrl +
@@ -42,7 +42,7 @@ describe("QuestionMedia", () => {
const videoUrl = "https://www.loom.com/share/test123";
render(<QuestionMedia videoUrl={videoUrl} />);
const iframe = screen.getByTitle("Question Video");
const iframe = screen.getByTitle("common.question_video");
expect(iframe).toBeTruthy();
expect(iframe.getAttribute("src")).toBe(
videoUrl + "?hide_share=true&hideEmbedTopBar=true&hide_title=true"
@@ -105,7 +105,7 @@ describe("QuestionMedia", () => {
expect(loadingElement).toBeTruthy();
// Get iframe and trigger load
const iframe = screen.getByTitle("Question Video");
const iframe = screen.getByTitle("common.question_video");
await iframe.dispatchEvent(new Event("load"));
// Wait for state update
@@ -130,7 +130,7 @@ describe("QuestionMedia", () => {
const videoUrl = "https://example.com/video.mp4";
render(<QuestionMedia videoUrl={videoUrl} />);
const iframe = screen.getByTitle("Question Video");
const iframe = screen.getByTitle("common.question_video");
expect(iframe.getAttribute("src")).toBe(videoUrl);
});
});

View File

@@ -3,6 +3,7 @@ import { ImageDownIcon } from "@/components/icons/image-down-icon";
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
//Function to add extra params to videoUrls in order to reduce video controls
const getVideoUrlWithParams = (videoUrl: string): string => {
@@ -25,6 +26,7 @@ interface QuestionMediaProps {
}
export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionMediaProps) {
const { t } = useTranslation();
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = useState(true);
@@ -55,7 +57,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
<div className="fb-rounded-custom fb-bg-black">
<iframe
src={videoUrlWithParams}
title="Question Video"
title={t("common.question_video")}
frameBorder="0"
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
onLoad={() => {
@@ -74,7 +76,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
target="_blank"
rel="noreferrer"
aria-label={"Open in new tab"}
aria-label={t("common.open_in_new_tab")}
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
{imgUrl ? <ImageDownIcon size={20} /> : <ExpandIcon size={20} />}
</a>

View File

@@ -11,21 +11,21 @@ describe("RecaptchaBranding", () => {
test("renders with correct text content", () => {
const { container } = render(<RecaptchaBranding />);
const paragraph = container.querySelector("p");
expect(paragraph).toHaveTextContent("Protected by reCAPTCHA and the Google");
expect(paragraph).toHaveTextContent("Privacy Policy");
expect(paragraph).toHaveTextContent("Terms of Service");
expect(paragraph).toHaveTextContent("apply.");
expect(paragraph).toHaveTextContent("common.protected_by_reCAPTCHA_and_the_Google");
expect(paragraph).toHaveTextContent("common.privacy_policy");
expect(paragraph).toHaveTextContent("common.terms_of_service");
expect(paragraph).toHaveTextContent("common.apply");
});
test("renders links with correct attributes", () => {
render(<RecaptchaBranding />);
const privacyLink = screen.getByText("Privacy Policy").closest("a");
const privacyLink = screen.getByText("common.privacy_policy").closest("a");
expect(privacyLink).toHaveAttribute("href", "https://policies.google.com/privacy");
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveAttribute("rel", "noopener");
const termsLink = screen.getByText("Terms of Service").closest("a");
const termsLink = screen.getByText("common.terms_of_service").closest("a");
expect(termsLink).toHaveAttribute("href", "https://policies.google.com/terms");
expect(termsLink).toHaveAttribute("target", "_blank");
expect(termsLink).toHaveAttribute("rel", "noopener");
@@ -40,10 +40,10 @@ describe("RecaptchaBranding", () => {
test("links are wrapped in bold tags", () => {
render(<RecaptchaBranding />);
const privacyLink = screen.getByText("Privacy Policy");
const privacyLink = screen.getByText("common.privacy_policy");
expect(privacyLink.parentElement?.tagName).toBe("B");
const termsLink = screen.getByText("Terms of Service");
const termsLink = screen.getByText("common.terms_of_service");
expect(termsLink.parentElement?.tagName).toBe("B");
});
});

View File

@@ -1,19 +1,22 @@
import { useTranslation } from "react-i18next";
export function RecaptchaBranding() {
const { t } = useTranslation();
return (
<p className="fb-text-signature fb-text-xs fb-text-center fb-leading-6 fb-text-balance">
Protected by reCAPTCHA and the Google{" "}
{t("common.protected_by_reCAPTCHA_and_the_Google")}{" "}
<b>
<a target="_blank" rel="noopener" href="https://policies.google.com/privacy">
Privacy Policy
{t("common.privacy_policy")}
</a>
</b>{" "}
and{" "}
{t("common.and")}{" "}
<b>
<a target="_blank" rel="noopener" href="https://policies.google.com/terms">
Terms of Service
{t("common.terms_of_service")}
</a>
</b>{" "}
apply.
{t("common.apply")}.
</p>
);
}

View File

@@ -42,9 +42,9 @@ describe("ResponseErrorComponent", () => {
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
expect(screen.getByText("Your feedback is stuck :(")).toBeDefined();
expect(screen.getByText(/The servers cannot be reached at the moment/)).toBeDefined();
expect(screen.getByText("Retry")).toBeDefined();
expect(screen.getByText("common.your_feedback_is_stuck")).toBeDefined();
expect(screen.getByText(/common.the_servers_cannot_be_reached_at_the_moment/)).toBeDefined();
expect(screen.getByText("common.retry")).toBeDefined();
});
test("displays questions and responses correctly", () => {
@@ -52,10 +52,10 @@ describe("ResponseErrorComponent", () => {
<ResponseErrorComponent questions={mockQuestions} responseData={mockResponseData} onRetry={() => {}} />
);
const questions = screen.getAllByText(/Question \d/);
const questions = screen.getAllByText(/common.question \d/);
expect(questions).toHaveLength(2);
expect(questions[0].textContent).toBe("Question 1");
expect(questions[1].textContent).toBe("Question 2");
expect(questions[0].textContent).toBe("common.question 1");
expect(questions[1].textContent).toBe("common.question 2");
const answers = screen.getAllByText(/Answer \d/);
expect(answers).toHaveLength(2);
@@ -73,7 +73,7 @@ describe("ResponseErrorComponent", () => {
/>
);
const retryButton = screen.getByRole("button", { name: "Retry" });
const retryButton = screen.getByRole("button", { name: "common.retry" });
fireEvent.click(retryButton);
expect(mockOnRetry).toHaveBeenCalledTimes(1);
@@ -92,12 +92,12 @@ describe("ResponseErrorComponent", () => {
/>
);
const question = screen.getByText(/Question 1/);
expect(question.textContent).toBe("Question 1");
const question = screen.getByText("common.question 1");
expect(question.textContent).toBe("common.question 1");
const answer = screen.getByText(/Answer 1/);
const answer = screen.getByText("Answer 1");
expect(answer.textContent).toBe("Answer 1");
expect(screen.queryByText(/Answer 2/)).toBeNull();
expect(screen.queryByText("Answer 2")).toBeNull();
});
});

View File

@@ -1,5 +1,6 @@
import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@/lib/response";
import { useTranslation } from "react-i18next";
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyQuestion } from "@formbricks/types/surveys/types";
@@ -10,15 +11,16 @@ interface ResponseErrorComponentProps {
}
export function ResponseErrorComponent({ questions, responseData, onRetry }: ResponseErrorComponentProps) {
const { t } = useTranslation();
return (
<div className="fb-flex fb-flex-col fb-bg-white fb-p-4">
<span className="fb-mb-1.5 fb-text-base fb-font-bold fb-leading-6 fb-text-slate-900">
Your feedback is stuck :(
{t("common.your_feedback_is_stuck")}
</span>
<p className="fb-max-w-md fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600">
The servers cannot be reached at the moment.
{t("common.the_servers_cannot_be_reached_at_the_moment")}
<br />
Please retry now or try again later.
{t("common.please_retry_now_or_try_again_later")}
</p>
<div className="fb-mt-4 fb-rounded-lg fb-border fb-border-slate-200 fb-bg-slate-100 fb-px-4 fb-py-5">
<div className="fb-flex fb-max-h-36 fb-flex-1 fb-flex-col fb-space-y-3 fb-overflow-y-scroll">
@@ -27,7 +29,7 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
if (!response) return;
return (
<div className="fb-flex fb-flex-col" key={`response-${index.toString()}`}>
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`Question ${(index + 1).toString()}`}</span>
<span className="fb-text-sm fb-leading-6 fb-text-slate-900">{`${t("common.question")} ${(index + 1).toString()}`}</span>
<span className="fb-mt-1 fb-text-sm fb-font-semibold fb-leading-6 fb-text-slate-900">
{processResponseData(response)}
</span>
@@ -38,7 +40,7 @@ export function ResponseErrorComponent({ questions, responseData, onRetry }: Res
</div>
<div className="fb-mt-4 fb-flex fb-flex-1 fb-flex-row fb-items-center fb-justify-end fb-space-x-2">
<SubmitButton
buttonLabel="Retry"
buttonLabel={t("common.retry")}
isLastQuestion={false}
onClick={() => {
onRetry?.();

View File

@@ -18,7 +18,7 @@ describe("SurveyCloseButton", () => {
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
expect(button).toHaveAttribute("aria-label", "common.close_survey");
const backgroundColor = button?.style?.backgroundColor;
expect(backgroundColor).toBe("transparent");
@@ -33,7 +33,7 @@ describe("SurveyCloseButton", () => {
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
expect(button).toHaveAttribute("aria-label", "common.close_survey");
// hover over the button
fireEvent.mouseEnter(button as HTMLButtonElement);
@@ -51,7 +51,7 @@ describe("SurveyCloseButton", () => {
const button = wrapper.querySelector("button");
expect(button).toBeInTheDocument();
expect(button).toHaveClass("fb-text-heading", "fb-relative", "fb-h-8", "fb-w-8");
expect(button).toHaveAttribute("aria-label", "Close survey");
expect(button).toHaveAttribute("aria-label", "common.close_survey");
expect(button).toHaveStyle({
borderRadius: "12px",
});

View File

@@ -2,6 +2,7 @@ import { CloseIcon } from "@/components/icons/close-icon";
import { mixColor } from "@/lib/color";
import { cn } from "@/lib/utils";
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
interface SurveyCloseButtonProps {
onClose?: () => void;
@@ -10,6 +11,7 @@ interface SurveyCloseButtonProps {
}
export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonly<SurveyCloseButtonProps>) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8);
@@ -28,7 +30,7 @@ export function SurveyCloseButton({ onClose, hoverColor, borderRadius }: Readonl
className={cn(
"fb-text-heading fb-relative focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-p-2 fb-h-8 fb-w-8 flex items-center justify-center"
)}
aria-label="Close survey">
aria-label={t("common.close_survey")}>
<CloseIcon />
</button>
</div>

View File

@@ -50,14 +50,14 @@ describe("WelcomeCard", () => {
const timeDisplay = container.querySelector(".fb-text-subheading");
expect(timeDisplay).toBeInTheDocument();
expect(timeDisplay).toHaveTextContent(/Takes/);
expect(timeDisplay).toHaveTextContent(/common.takes/);
});
test("shows response count when showResponseCount is true and count > 3", () => {
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={10} />);
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
expect(responseText).toHaveTextContent(/10 people responded/);
expect(responseText).toHaveTextContent(/common.people_responded/);
});
test("handles submit button click", () => {
@@ -82,7 +82,7 @@ describe("WelcomeCard", () => {
const { queryByTestId } = render(<WelcomeCard {...defaultProps} responseCount={3} />);
const responseText = queryByTestId("fb__surveys__welcome-card__response-count");
expect(responseText).not.toHaveTextContent(/3 people responded/);
expect(responseText).not.toHaveTextContent(/common.people_responded/);
});
test("shows company logo when fileUrl is provided", () => {
@@ -93,7 +93,7 @@ describe("WelcomeCard", () => {
render(<WelcomeCard {...propsWithLogo} />);
const logo = screen.getByAltText("Company Logo");
const logo = screen.getByAltText("common.company_logo");
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute("src", "https://example.com/logo.png");
});
@@ -102,7 +102,7 @@ describe("WelcomeCard", () => {
// Test short survey (2 questions)
const { container } = render(<WelcomeCard {...defaultProps} />);
const timeDisplay = container.querySelector(".fb-text-subheading");
expect(timeDisplay).toHaveTextContent(/Takes less than 1 minute/);
expect(timeDisplay).toHaveTextContent(/common.takes common.less_than_x_minutes/);
// Test medium survey (12 questions)
const mediumSurvey = {
@@ -111,7 +111,7 @@ describe("WelcomeCard", () => {
};
const { container: mediumContainer } = render(<WelcomeCard {...defaultProps} survey={mediumSurvey} />);
const mediumTimeDisplay = mediumContainer.querySelector(".fb-text-subheading");
expect(mediumTimeDisplay).toHaveTextContent(/Takes 3 minutes/);
expect(mediumTimeDisplay).toHaveTextContent(/common.takes common.x_minutes/);
// Test long survey (25 questions)
const longSurvey = {
@@ -120,7 +120,7 @@ describe("WelcomeCard", () => {
};
const { container: longContainer } = render(<WelcomeCard {...defaultProps} survey={longSurvey} />);
const longTimeDisplay = longContainer.querySelector(".fb-text-subheading");
expect(longTimeDisplay).toHaveTextContent(/Takes 6\+ minutes/);
expect(longTimeDisplay).toHaveTextContent(/common.takes common.x_plus_minutes/);
});
test("shows both time and response count when both flags are true", () => {
@@ -140,7 +140,7 @@ describe("WelcomeCard", () => {
);
const textDisplay = queryByTestId("fb__surveys__welcome-card__info-text-test");
expect(textDisplay).toHaveTextContent(/Takes.*10 people responded/);
expect(textDisplay).toHaveTextContent(/common.takes.*common.people_responded/);
});
test("handles missing optional props gracefully", () => {
@@ -206,7 +206,7 @@ describe("WelcomeCard", () => {
<WelcomeCard {...defaultProps} responseCount={3} />
);
expect(queryByTestId3("fb__surveys__welcome-card__response-count")).not.toHaveTextContent(
/3 people responded/
/common.people_responded/
);
// unmount to not have conflicting test ids
@@ -215,7 +215,7 @@ describe("WelcomeCard", () => {
// Test with 4 responses (just above boundary)
const { queryByTestId: queryByTestId4 } = render(<WelcomeCard {...defaultProps} responseCount={4} />);
expect(queryByTestId4("fb__surveys__welcome-card__response-count")).toHaveTextContent(
/4 people responded/
/common.people_responded/
);
});
@@ -229,7 +229,9 @@ describe("WelcomeCard", () => {
const { container: emptyContainer } = render(
<WelcomeCard {...defaultProps} survey={emptyQuestionsSurvey} />
);
expect(emptyContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes less than 1 minute/);
expect(emptyContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
/common.takes common.less_than_x_minutes/
);
// Test with exactly 24 questions (6 minutes boundary)
const boundaryQuestionsSurvey = {
@@ -239,7 +241,9 @@ describe("WelcomeCard", () => {
const { container: boundaryContainer } = render(
<WelcomeCard {...defaultProps} survey={boundaryQuestionsSurvey} />
);
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(/Takes 6 minutes/);
expect(boundaryContainer.querySelector(".fb-text-subheading")).toHaveTextContent(
/common.takes common.x_minutes/
);
});
test("correctly processes localized content", () => {

View File

@@ -4,6 +4,7 @@ import { getLocalizedValue } from "@/lib/i18n";
import { replaceRecallInfo } from "@/lib/recall";
import { calculateElementIdx } from "@/lib/utils";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc, type TResponseVariables } from "@formbricks/types/responses";
import { type TI18nString } from "@formbricks/types/surveys/types";
@@ -76,6 +77,8 @@ export function WelcomeCard({
responseData,
variablesData,
}: WelcomeCardProps) {
const { t } = useTranslation();
const calculateTimeToComplete = () => {
let totalCards = survey.questions.length;
if (survey.endings.length > 0) totalCards += 1;
@@ -86,7 +89,7 @@ export function WelcomeCard({
const timeInSeconds = (survey.questions.length / idx) * 15; //15 seconds per question.
if (timeInSeconds > 360) {
// If it's more than 6 minutes
return "6+ minutes";
return t("common.x_plus_minutes", { count: 6 });
}
// Calculate minutes, if there are any seconds left, add a minute
const minutes = Math.floor(timeInSeconds / 60);
@@ -96,13 +99,13 @@ export function WelcomeCard({
// If there are any seconds left, we'll need to round up to the next minute
if (minutes === 0) {
// If less than 1 minute, return 'less than 1 minute'
return "less than 1 minute";
return t("common.less_than_x_minutes", { count: 1 });
}
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
return `less than ${(minutes + 1).toString()} minutes`;
return t("common.less_than_x_minutes", { count: minutes + 1 });
}
// If there are no remaining seconds, just return the number of minutes
return `${minutes.toString()} minutes`;
return t("common.x_minutes", { count: minutes });
};
const timeToFinish = survey.welcomeCard.timeToFinish;
@@ -136,7 +139,11 @@ export function WelcomeCard({
<ScrollableContainer>
<div>
{fileUrl ? (
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain"
alt={t("common.company_logo")}
/>
) : null}
<Headline
@@ -165,7 +172,9 @@ export function WelcomeCard({
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{t("common.takes")} {calculateTimeToComplete()}{" "}
</span>
</p>
</div>
) : null}
@@ -173,7 +182,9 @@ export function WelcomeCard({
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span data-testid="fb__surveys__welcome-card__response-count">{`${responseCount.toString()} people responded`}</span>
<span data-testid="fb__surveys__welcome-card__response-count">
{t("common.people_responded", { count: responseCount })}
</span>
</p>
</div>
) : null}
@@ -181,9 +192,13 @@ export function WelcomeCard({
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{t("common.takes")} {calculateTimeToComplete()}{" "}
</span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
{responseCount && responseCount > 3
? `${t("common.people_responded", { count: responseCount })}`
: ""}
</span>
</p>
</div>

View File

@@ -0,0 +1,13 @@
import { ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { I18nextProvider } from "react-i18next";
import i18n from "../../lib/i18n.config";
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
// work around for react-i18next not supporting preact
return <I18nextProvider i18n={i18n}>{children as unknown as React.ReactNode}</I18nextProvider>;
};

View File

@@ -104,7 +104,7 @@ describe("CalQuestion - New Error Handling", () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Please book an appointment")).toBeInTheDocument();
expect(screen.getByText("errors.please_book_an_appointment")).toBeInTheDocument();
});
});
@@ -116,7 +116,7 @@ describe("CalQuestion - New Error Handling", () => {
await waitFor(() => {
const calEmbed = screen.getByTestId("cal-embed");
const errorMessage = screen.getByText("Please book an appointment");
const errorMessage = screen.getByText("errors.please_book_an_appointment");
// Check that error message comes after CalEmbed in DOM order
expect(calEmbed.compareDocumentPosition(errorMessage)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
@@ -129,6 +129,6 @@ describe("CalQuestion - New Error Handling", () => {
const submitButton = screen.getByTestId("submit-button");
fireEvent.click(submitButton);
expect(screen.queryByText("Please book an appointment")).not.toBeInTheDocument();
expect(screen.queryByText("errors.please_book_an_appointment")).not.toBeInTheDocument();
});
});

View File

@@ -11,6 +11,7 @@ import {
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -44,6 +45,7 @@ export function CalQuestion({
currentQuestionId,
isBackButtonHidden,
}: Readonly<CalQuestionProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const [errorMessage, setErrorMessage] = useState("");
@@ -64,7 +66,7 @@ export function CalQuestion({
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please book an appointment");
setErrorMessage(t("errors.please_book_an_appointment"));
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {

View File

@@ -57,11 +57,11 @@ describe("DateQuestion", () => {
test("renders date question correctly", () => {
render(<DateQuestion {...defaultProps} />);
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.select_a_date")[0]).toBeInTheDocument();
expect(screen.getByText("Please choose a date")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
expect(screen.getByText("Back")).toBeInTheDocument();
expect(screen.getByText("Select a date", { selector: "span" })).toBeInTheDocument();
expect(screen.getByText("common.select_a_date", { selector: "span" })).toBeInTheDocument();
});
test("displays error message when form is submitted without a date if required", async () => {
@@ -71,7 +71,7 @@ describe("DateQuestion", () => {
await user.click(screen.getByText("Next"));
expect(screen.getByText("Please select a date.")).toBeInTheDocument();
expect(screen.getByText("errors.please_select_a_date")).toBeInTheDocument();
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
@@ -101,13 +101,13 @@ describe("DateQuestion", () => {
test("does not render back button when isFirstQuestion is true", () => {
render(<DateQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
expect(screen.queryByText("common.back")).not.toBeInTheDocument();
});
test("does not render back button when isBackButtonHidden is true", () => {
render(<DateQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByText("Back")).not.toBeInTheDocument();
expect(screen.queryByText("common.back")).not.toBeInTheDocument();
});
test("renders media content when available", () => {
@@ -119,7 +119,7 @@ describe("DateQuestion", () => {
render(<DateQuestion {...defaultProps} question={questionWithMedia} />);
// Media component should be rendered (implementation detail check)
expect(screen.getAllByText("Select a date")[0]).toBeInTheDocument();
expect(screen.getAllByText("common.select_a_date")[0]).toBeInTheDocument();
});
test("opens date picker when button is clicked", async () => {
@@ -128,7 +128,7 @@ describe("DateQuestion", () => {
render(<DateQuestion {...defaultProps} />);
// Click the select date button
const dateButton = screen.getByRole("button", { name: /select a date/i });
const dateButton = screen.getByRole("button", { name: /common.select_a_date/i });
await user.click(dateButton);
// We can check for our mocked date picker
@@ -142,7 +142,7 @@ describe("DateQuestion", () => {
render(<DateQuestion {...props} />);
// Handle timezone differences by allowing either 14th or 15th
const dateRegex = /(14th|15th) of January, 2023/;
const dateRegex = /(14th|15th) of (January|February), 2023/;
const dateElement = screen.getByText(dateRegex);
expect(dateElement).toBeInTheDocument();
});

View File

@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
import { DatePickerProps } from "react-date-picker";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import "../../styles/date-picker.css";
@@ -104,6 +105,8 @@ export function DateQuestion({
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
const { t } = useTranslation();
useEffect(() => {
if (datePickerOpen) {
if (!selectedDate) setSelectedDate(new Date());
@@ -140,7 +143,7 @@ export function DateQuestion({
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please select a date.");
setErrorMessage(t("errors.please_select_a_date"));
return;
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
@@ -175,7 +178,11 @@ export function DateQuestion({
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
}}
aria-label={selectedDate ? `You have selected ${formattedDate}` : "Select a date"}
aria-label={
selectedDate
? t("common.you_have_selected_x_date", { date: formattedDate })
: t("common.select_a_date")
}
aria-describedby={errorMessage ? "error-message" : undefined}
className="focus:fb-outline-brand fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-text-heading fb-rounded-custom fb-relative fb-flex fb-h-[12dvh] fb-w-full fb-cursor-pointer fb-appearance-none fb-items-center fb-justify-center fb-border fb-text-left fb-text-base fb-font-normal">
<div className="fb-flex fb-items-center fb-gap-2">
@@ -185,7 +192,7 @@ export function DateQuestion({
</div>
) : (
<div className="fb-flex fb-items-center fb-gap-2">
<CalendarIcon /> <span>Select a date</span>
<CalendarIcon /> <span>{t("common.select_a_date")}</span>
</div>
)}
</div>

View File

@@ -196,7 +196,7 @@ describe("FileUploadQuestion", () => {
const form = container.querySelector("form");
fireEvent.submit(form as HTMLFormElement);
expect(window.alert).toHaveBeenCalledWith("Please upload a file");
expect(window.alert).toHaveBeenCalledWith("errors.please_upload_a_file");
expect(onSubmitMock).not.toHaveBeenCalled();
});

View File

@@ -5,6 +5,7 @@ import { ScrollableContainer } from "@/components/wrappers/scrollable-container"
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
@@ -47,6 +48,7 @@ export function FileUploadQuestion({
currentQuestionId,
isBackButtonHidden,
}: Readonly<FileUploadQuestionProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
@@ -64,7 +66,7 @@ export function FileUploadQuestion({
if (value && value.length > 0) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
alert("Please upload a file");
alert(t("errors.please_upload_a_file"));
}
} else if (value) {
onSubmit({ [question.id]: value }, updatedTtcObj);

View File

@@ -141,7 +141,7 @@ describe("OpenTextQuestion", () => {
const input = screen.getByPlaceholderText("Type here...");
expect(input).toHaveAttribute("pattern", "^[0-9+][0-9+\\- ]*[0-9]$");
expect(input).toHaveAttribute("title", "Enter a valid phone number");
expect(input).toHaveAttribute("title", "errors.please_enter_a_valid_phone_number");
});
test("applies correct attributes for required fields", () => {
@@ -385,7 +385,7 @@ describe("OpenTextQuestion", () => {
);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveAttribute("title", "Please enter a valid phone number");
expect(textarea).toHaveAttribute("title", "errors.please_enter_a_valid_phone_number");
});
test("applies character limits for textarea", () => {

View File

@@ -8,6 +8,7 @@ import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { type RefObject } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -50,6 +51,7 @@ export function OpenTextQuestion({
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
@@ -71,7 +73,7 @@ export function OpenTextQuestion({
input?.setCustomValidity("");
if (question.required && (!value || value.trim() === "")) {
input?.setCustomValidity("Please fill out this field.");
input?.setCustomValidity(t("errors.please_fill_out_this_field"));
input?.reportValidity();
return;
}
@@ -116,7 +118,9 @@ export function OpenTextQuestion({
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
title={
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={
question.inputType === "text"
@@ -143,7 +147,9 @@ export function OpenTextQuestion({
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
title={
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
/>

View File

@@ -201,7 +201,7 @@ describe("PictureSelectionQuestion", () => {
test("prevents default action when clicking image expand link", async () => {
render(<PictureSelectionQuestion {...mockProps} />);
const links = screen.getAllByTitle("Open in new tab");
const links = screen.getAllByTitle("common.open_in_new_tab");
const mockStopPropagation = vi.fn();
// Simulate clicking the link but prevent the event from propagating

View File

@@ -10,6 +10,7 @@ import { getOriginalFileNameFromUrl } from "@/lib/storage";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -45,6 +46,7 @@ export function PictureSelectionQuestion({
isBackButtonHidden,
dir = "auto",
}: Readonly<PictureSelectionProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
const initialLoadingState: Record<string, boolean> = {};
@@ -121,7 +123,7 @@ export function PictureSelectionQuestion({
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<legend className="fb-sr-only">{t("common.options")}</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<div className="fb-relative" key={choice.id}>
@@ -197,7 +199,7 @@ export function PictureSelectionQuestion({
tabIndex={-1}
href={choice.imageUrl}
target="_blank"
title="Open in new tab"
title={t("common.open_in_new_tab")}
rel="noreferrer"
onClick={(e) => {
e.stopPropagation();
@@ -206,7 +208,7 @@ export function PictureSelectionQuestion({
"fb-absolute fb-bottom-4 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}>
<span className="fb-sr-only">Open in new tab</span>
<span className="fb-sr-only">{t("common.open_in_new_tab")}</span>
<ImageDownIcon />
</a>
</div>

View File

@@ -115,7 +115,7 @@ describe("RankingQuestion - New Error Handling", () => {
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Please rank all items before submitting.")).toBeInTheDocument();
expect(screen.getByText("errors.please_rank_all_items_before_submitting")).toBeInTheDocument();
});
});
@@ -126,6 +126,6 @@ describe("RankingQuestion - New Error Handling", () => {
fireEvent.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalled();
expect(screen.queryByText("Please rank all items before submitting.")).not.toBeInTheDocument();
expect(screen.queryByText("errors.please_rank_all_items_before_submitting")).not.toBeInTheDocument();
});
});

View File

@@ -12,6 +12,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type {
TSurveyQuestionChoice,
@@ -50,6 +51,7 @@ export function RankingQuestion({
currentQuestionId,
isBackButtonHidden,
}: Readonly<RankingQuestionProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
const isCurrent = question.id === currentQuestionId;
const shuffledChoicesIds = useMemo(() => {
@@ -122,7 +124,7 @@ export function RankingQuestion({
(!question.required && sortedItems.length > 0 && sortedItems.length < question.choices.length);
if (hasIncompleteRanking) {
setError("Please rank all items before submitting.");
setError(t("errors.please_rank_all_items_before_submitting"));
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {
@@ -168,7 +170,7 @@ export function RankingQuestion({
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Ranking Items</legend>
<legend className="fb-sr-only">{t("common.ranking_items")}</legend>
<div className="fb-relative" ref={parent}>
{[...sortedItems, ...unsortedItems].map((item, idx) => {
if (!item) return null;
@@ -197,7 +199,9 @@ export function RankingQuestion({
handleItemClick(item);
}}
type="button"
aria-label={`Select ${getLocalizedValue(item.label, languageCode)} for ranking`}
aria-label={t("common.select_for_ranking", {
item: getLocalizedValue(item.label, languageCode),
})}
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
<span
className={cn(
@@ -221,7 +225,9 @@ export function RankingQuestion({
e.preventDefault();
handleMove(item.id, "up");
}}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} up`}
aria-label={t("common.move_up", {
item: getLocalizedValue(item.label, languageCode),
})}
className={cn(
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
@@ -256,7 +262,9 @@ export function RankingQuestion({
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
)}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} down`}
aria-label={t("common.move_down", {
item: getLocalizedValue(item.label, languageCode),
})}
disabled={isLast}>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,7 @@
import { AutoCloseProgressBar } from "@/components/general/auto-close-progress-bar";
import React from "preact/compat";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
interface AutoCloseProps {
@@ -21,7 +22,7 @@ export function AutoCloseWrapper({
setHasInteracted,
}: AutoCloseProps) {
const [countDownActive, setCountDownActive] = useState(true);
const { t } = useTranslation();
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isAppSurvey = survey.type === "app";
@@ -69,7 +70,7 @@ export function AutoCloseWrapper({
className="fb-h-full fb-w-full"
data-testid="fb__surveys__auto-close-wrapper-test"
onKeyDown={stopCountdown}
aria-label="Auto close wrapper"
aria-label={t("common.auto_close_wrapper")}
onTouchStart={stopCountdown}>
{children}
</div>

View File

@@ -1,5 +1,7 @@
import { RenderSurvey } from "@/components/general/render-survey";
import { I18nProvider } from "@/components/i18n/provider";
import { FILE_PICK_EVENT } from "@/lib/constants";
import { getI18nLanguage } from "@/lib/i18n-utils";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
import { h, render } from "preact";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
@@ -17,11 +19,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
// render SurveyNew
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
const { mode, containerId } = props;
const { mode, containerId, languageCode } = props;
addStylesToDom();
addCustomThemeToDom({ styling: props.styling });
const language = getI18nLanguage(languageCode, props.survey.languages);
if (mode === "inline") {
if (!containerId) {
throw new Error("renderSurvey: containerId is required for inline mode");
@@ -35,9 +39,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(RenderSurvey, {
...surveyInlineProps,
}),
h(
I18nProvider,
{ language },
h(RenderSurvey, {
...surveyInlineProps,
})
),
element
);
} else {
@@ -46,9 +54,13 @@ export const renderSurvey = (props: SurveyContainerProps) => {
document.body.appendChild(modalContainer);
render(
h(RenderSurvey, {
...props,
}),
h(
I18nProvider,
{ language },
h(RenderSurvey, {
...props,
})
),
modalContainer
);
}

View File

@@ -0,0 +1,12 @@
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
export const getI18nLanguage = (languageCode: string, languages: TSurveyLanguage[]) => {
let locale = languageCode;
const isDefaultLanguage = languageCode === "default";
if (isDefaultLanguage) {
locale = languages.find((lng) => lng.default)?.language?.code || "en";
}
return locale;
};

View File

@@ -0,0 +1,22 @@
import i18n from "i18next";
import ICU from "i18next-icu";
import { initReactI18next } from "react-i18next";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
i18n
.use(ICU)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: ["en", "de"],
resources: {
en: { translation: enTranslations },
de: { translation: deTranslations },
},
interpolation: { escapeValue: false },
});
export default i18n;

View File

@@ -8,7 +8,8 @@
"jsxImportSource": "preact",
"paths": {
"@/*": ["./src/*"]
}
},
"resolveJsonModule": true
},
"extends": "@formbricks/config-typescript/js-library.json",
"include": ["src", "../types/surveys.d.ts"]

View File

@@ -1,11 +1,11 @@
import preact from "@preact/preset-vite";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { loadEnv } from "vite";
import dts from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
import { defineConfig } from "vitest/config";
import { copyCompiledAssetsPlugin } from "../vite-plugins/copy-compiled-assets";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -16,17 +16,18 @@ const config = ({ mode }) => {
return defineConfig({
test: {
environment: "node",
environmentMatchGlobs: [["**/*.test.tsx", "jsdom"], ["**/lib/**/*.test.ts", "jsdom"]],
environmentMatchGlobs: [
["**/*.test.tsx", "jsdom"],
["**/lib/**/*.test.ts", "jsdom"],
],
setupFiles: ["./vitestSetup.ts"],
exclude: ["dist/**", "node_modules/**"],
env: env,
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
reportsDirectory: "./coverage",
include: [
"src/lib/**/*.{ts,tsx}",
"src/components/**/*.{ts,tsx}"
],
include: ["src/lib/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"],
},
},
define: {
@@ -56,4 +57,4 @@ const config = ({ mode }) => {
});
};
export default config;
export default config;

View File

@@ -0,0 +1,16 @@
import { vi } from "vitest";
// mock react-i18next useTranslation on components
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});

81
pnpm-lock.yaml generated
View File

@@ -766,6 +766,12 @@ importers:
'@formkit/auto-animate':
specifier: 0.8.2
version: 0.8.2
i18next:
specifier: 25.5.2
version: 25.5.2(typescript@5.8.3)
i18next-icu:
specifier: 2.4.0
version: 2.4.0(intl-messageformat@10.7.16)
isomorphic-dompurify:
specifier: 2.24.0
version: 2.24.0
@@ -778,6 +784,9 @@ importers:
react-date-picker:
specifier: 11.0.0
version: 11.0.0(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-i18next:
specifier: 15.7.3
version: 15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -6590,6 +6599,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -6631,6 +6643,19 @@ packages:
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
i18next-icu@2.4.0:
resolution: {integrity: sha512-LnoZOVS/lp2l4jOMVq2vN/LPf1oGi7sMovdcnKiVjcs7Ysek61rkXMOVEbPZ/3N990uToZpKe6kNE1t2wxcsfA==}
peerDependencies:
intl-messageformat: ^10.3.3
i18next@25.5.2:
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -6689,6 +6714,9 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -8334,6 +8362,22 @@ packages:
react: '>=16'
react-dom: '>=16'
react-i18next@15.7.3:
resolution: {integrity: sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==}
peerDependencies:
i18next: '>= 25.4.1'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -9580,6 +9624,10 @@ packages:
jsdom:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
vscode-oniguruma@2.0.1:
resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==}
@@ -17046,6 +17094,10 @@ snapshots:
html-escaper@2.0.2: {}
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -17110,6 +17162,16 @@ snapshots:
hyphenate-style-name@1.1.0: {}
i18next-icu@2.4.0(intl-messageformat@10.7.16):
dependencies:
intl-messageformat: 10.7.16
i18next@25.5.2(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.28.2
optionalDependencies:
typescript: 5.8.3
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -17162,6 +17224,13 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
intl-messageformat@10.7.16:
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.2
tslib: 2.8.1
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
@@ -18801,6 +18870,16 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-i18next@15.7.3(i18next@25.5.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.28.2
html-parse-stringify: 3.0.1
i18next: 25.5.2(typescript@5.8.3)
react: 19.1.0
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
typescript: 5.8.3
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -20340,6 +20419,8 @@ snapshots:
- tsx
- yaml
void-elements@3.1.0: {}
vscode-oniguruma@2.0.1: {}
vscode-textmate@9.2.0: {}