Compare commits

...

8 Commits

Author SHA1 Message Date
Johannes 1a17b839a4 increase test coverage 2026-05-13 16:55:18 +02:00
Johannes 2348812a05 merge: bring main into browser-language branch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 16:53:54 +02:00
Cursor Agent 7c1945f926 test: cover browser language callers 2026-05-13 12:20:50 +00:00
Cursor Agent 4af26d6be2 fix: satisfy js-core browser language lint 2026-05-13 12:08:54 +00:00
Cursor Agent 5d6c0273f4 test: cover survey language resolution 2026-05-13 12:04:39 +00:00
Cursor Agent 5e7bd310f9 chore: add browser language translations 2026-05-13 11:57:34 +00:00
Cursor Agent bbada99199 refactor: centralize survey language resolution 2026-05-13 11:54:08 +00:00
Cursor Agent dd58cadddb feat: add browser language auto-selection 2026-05-13 11:37:57 +00:00
51 changed files with 898 additions and 72 deletions
@@ -86,6 +86,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
variables: true,
type: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
languages: {
select: {
default: true,
+1
View File
@@ -4924,6 +4924,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
languages: [],
triggers: [],
showLanguageSwitch: false,
autoSelectLanguage: false,
followUps: [],
isBackButtonHidden: false,
isAutoProgressingEnabled: true,
@@ -226,6 +226,7 @@ const baseSurveyProperties = {
},
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
autoSelectLanguage: null,
attributeFilters: [],
...commonMockProperties,
};
+67
View File
@@ -18,6 +18,7 @@ import {
mockActionClass,
mockId,
mockOrganizationOutput,
mockSurveyLanguages,
mockSurveyOutput,
mockSurveyWithLogic,
mockTransformedSurveyOutput,
@@ -615,6 +616,33 @@ describe("Tests for createSurvey", () => {
languages: [],
} as TSurveyCreateInput;
const getMultiLanguageCreateSurveyInput = (): TSurveyCreateInput =>
({
...mockCreateSurveyInput,
welcomeCard: {
...mockCreateSurveyInput.welcomeCard,
headline: { default: "Welcome", de: "Willkommen" },
},
questions: mockCreateSurveyInput.questions.map((question) => {
if ("choices" in question && Array.isArray(question.choices)) {
return {
...question,
headline: { ...question.headline, de: question.headline.default },
choices: question.choices.map((choice) => ({
...choice,
label: { ...choice.label, de: choice.label.default },
})),
};
}
return {
...question,
headline: { ...question.headline, de: question.headline.default },
};
}),
languages: mockSurveyLanguages,
}) as TSurveyCreateInput;
const mockActionClasses = [
{
id: "action-123",
@@ -647,6 +675,45 @@ describe("Tests for createSurvey", () => {
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
});
test("enables browser language auto-selection by default for new multi-language surveys", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockEnvironmentId, {
...getMultiLanguageCreateSurveyInput(),
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
autoSelectLanguage: true,
}),
})
);
});
test("preserves explicit browser language auto-selection setting on create", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
...mockSurveyOutput,
});
await createSurvey(mockEnvironmentId, {
...getMultiLanguageCreateSurveyInput(),
autoSelectLanguage: false,
});
expect(prisma.survey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
autoSelectLanguage: false,
}),
})
);
});
test("creates a private segment for app surveys", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
+4
View File
@@ -58,6 +58,7 @@ export const selectSurvey = {
singleUse: true,
pin: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
metadata: true,
customHeadScripts: true,
@@ -591,9 +592,12 @@ export const createSurvey = async (
try {
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const actionClasses = await getActionClasses(parsedEnvironmentId);
const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1;
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
autoSelectLanguage:
restSurveyBody.autoSelectLanguage ?? (hasMultipleEnabledLanguages ? true : undefined),
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
+21 -1
View File
@@ -2,7 +2,7 @@ import * as nextHeaders from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
import { appLanguages } from "@/lib/i18n/utils";
import { findMatchingLocale } from "./locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "./locale";
// Mock the Next.js headers function
vi.mock("next/headers", () => ({
@@ -36,6 +36,26 @@ describe("locale", () => {
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("ignores Accept-Language quality values when matching locales", async () => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("de-DE;q=0.9,en-US;q=0.8"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe("de-DE");
});
test("returns browser language codes without quality values", async () => {
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("es-MX,es;q=0.9,en-US;q=0.8"),
} as any);
const result = await findMatchingBrowserLanguageCodes();
expect(result).toEqual(["es-MX", "es", "en-US"]);
});
test("returns normalized match when available", async () => {
// Assuming we have 'en-US' in AVAILABLE_LOCALES but not 'en-GB'
const availableLocale = AVAILABLE_LOCALES.find((locale) => locale.startsWith("en-"));
+16 -2
View File
@@ -2,11 +2,25 @@ import { headers } from "next/headers";
import { TUserLocale } from "@formbricks/types/user";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
const getAcceptedLanguageCodesFromHeader = (acceptLanguage: string | null): string[] => {
return (
acceptLanguage
?.split(",")
.map((language) => language.trim().split(";")[0].trim())
.filter(Boolean) ?? []
);
};
export const findMatchingBrowserLanguageCodes = async (): Promise<string[]> => {
const headersList = await headers();
return getAcceptedLanguageCodesFromHeader(headersList.get("accept-language"));
};
export const findMatchingLocale = async (): Promise<TUserLocale> => {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
const userLocales = acceptLanguage?.split(",");
if (!userLocales) {
const userLocales = getAcceptedLanguageCodesFromHeader(acceptLanguage);
if (!userLocales.length) {
return DEFAULT_LOCALE;
}
// First, try to find an exact match without normalization
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
"auto_select_browser_language": "Browsersprache standardmäßig verwenden",
"auto_select_browser_language_description": "Öffnet die Umfrage automatisch in der Browsersprache der befragten Person, wenn diese Sprache aktiv ist. Fällt auf die Standardsprache zurück.",
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Auslöser schließen, wenn keine Antwort erfolgt.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
"auto_select_browser_language": "Use browser language by default",
"auto_select_browser_language_description": "Automatically open the survey in the respondent's browser language when that language is active. Falls back to the default language.",
"automatically_close_survey_after_n_seconds_if_no_response": "Automatically close survey after <autoCloseInput /> seconds after trigger if no response.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
"auto_select_browser_language": "Usar el idioma del navegador por defecto",
"auto_select_browser_language_description": "Abre automáticamente la encuesta en el idioma del navegador de la persona encuestada cuando ese idioma está activo. Si no coincide, usa el idioma predeterminado.",
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras activarse si no hay respuesta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
"auto_select_browser_language": "Utiliser la langue du navigateur par défaut",
"auto_select_browser_language_description": "Ouvre automatiquement l'enquête dans la langue du navigateur de la personne interrogée lorsque cette langue est active. Sinon, la langue par défaut est utilisée.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes si aucune réponse n'est donnée après le déclenchement.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
"auto_select_browser_language": "A böngésző nyelvének használata alapértelmezésként",
"auto_select_browser_language_description": "Automatikusan a válaszadó böngészőnyelvén nyitja meg a kérdőívet, ha ez a nyelv aktív. Ellenkező esetben az alapértelmezett nyelvre vált.",
"automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása <autoCloseInput /> másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
"auto_select_browser_language": "ブラウザーの言語をデフォルトで使用",
"auto_select_browser_language_description": "その言語が有効な場合、回答者のブラウザー言語でアンケートを自動的に開きます。一致しない場合はデフォルト言語に戻ります。",
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、反応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
"auto_select_browser_language": "Browsertaal standaard gebruiken",
"auto_select_browser_language_description": "Opent de enquête automatisch in de browsertaal van de respondent wanneer die taal actief is. Valt terug op de standaardtaal.",
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activatie als er geen reactie komt.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
"auto_select_browser_language": "Usar o idioma do navegador por padrão",
"auto_select_browser_language_description": "Abre automaticamente a pesquisa no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma padrão.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente a pesquisa após <autoCloseInput /> segundos do acionamento se não houver resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
"auto_select_browser_language": "Usar o idioma do navegador por predefinição",
"auto_select_browser_language_description": "Abre automaticamente o inquérito no idioma do navegador do respondente quando esse idioma está ativo. Caso contrário, usa o idioma predefinido.",
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos depois do acionamento se não houver resposta.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
"auto_select_browser_language": "Folosește implicit limba browserului",
"auto_select_browser_language_description": "Deschide automat sondajul în limba browserului respondentului atunci când această limbă este activă. Revine la limba implicită.",
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat sondajul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
"auto_select_browser_language": "Использовать язык браузера по умолчанию",
"auto_select_browser_language_description": "Автоматически открывает опрос на языке браузера респондента, если этот язык активен. В противном случае используется язык по умолчанию.",
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрывать опрос через <autoCloseInput /> секунд после срабатывания триггера, если нет ответа.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
"auto_select_browser_language": "Använd webbläsarens språk som standard",
"auto_select_browser_language_description": "Öppnar automatiskt enkäten på respondentens webbläsarspråk när det språket är aktivt. Faller tillbaka till standardspråket.",
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng undersökningen automatiskt efter <autoCloseInput /> sekunder efter utlösning om inget svar ges.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "Otomatik kayıt devre dışı",
"auto_save_disabled_tooltip": "Survey'iniz yalnızca taslak durumundayken otomatik kaydedilir. Bu, yayınlanmış survey'lerin yanlışlıkla güncellenmesini önler.",
"auto_save_on": "Otomatik kayıt açık",
"auto_select_browser_language": "Varsayılan olarak tarayıcı dilini kullan",
"auto_select_browser_language_description": "Bu dil aktif olduğunda anketi otomatik olarak yanıtlayıcının tarayıcı dilinde açar. Eşleşme yoksa varsayılan dile döner.",
"automatically_close_survey_after_n_seconds_if_no_response": "Yanıt verilmezse anketi tetiklendikten sonra <autoCloseInput /> saniye sonra otomatik olarak kapat.",
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt sonrasında survey'i otomatik olarak kapatın.",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli bir süre yanıt vermezse survey'i otomatik olarak kapatın.",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
"auto_select_browser_language": "默认使用浏览器语言",
"auto_select_browser_language_description": "当受访者的浏览器语言处于启用状态时,自动以该语言打开调查。否则回退到默认语言。",
"automatically_close_survey_after_n_seconds_if_no_response": "如果触发后无响应,则在 <autoCloseInput /> 秒后自动关闭调查。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
+2
View File
@@ -1370,6 +1370,8 @@
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
"auto_select_browser_language": "預設使用瀏覽器語言",
"auto_select_browser_language_description": "當受訪者的瀏覽器語言已啟用時,自動以該語言開啟問卷。否則會回到預設語言。",
"automatically_close_survey_after_n_seconds_if_no_response": "如果沒有回應,將在觸發後 <autoCloseInput /> 秒自動關閉問卷。",
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
@@ -50,6 +50,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
styling: true,
projectOverwrites: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
})
.partial({
redirectUrl: true,
@@ -63,6 +64,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
styling: true,
projectOverwrites: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
inlineTriggers: true,
displayPercentage: true,
})
+1
View File
@@ -39,6 +39,7 @@ export const selectSurvey = {
singleUse: true,
pin: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
isBackButtonHidden: true,
isAutoProgressingEnabled: true,
@@ -19,6 +19,7 @@ import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
import { TEnvironmentContextForLinkSurvey } from "@/modules/survey/link/lib/environment";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getSurveyLanguageCode } from "@/modules/survey/link/lib/utils";
interface SurveyRendererProps {
survey: TSurvey;
@@ -36,6 +37,7 @@ interface SurveyRendererProps {
// New props - pre-fetched in parent
environmentContext: TEnvironmentContextForLinkSurvey;
locale: TUserLocale;
browserLanguageCodes?: string[];
responseCount?: number;
}
@@ -59,6 +61,7 @@ export const renderSurvey = async ({
isPreview,
environmentContext,
locale,
browserLanguageCodes = [],
responseCount,
}: SurveyRendererProps) => {
const langParam = searchParams.lang;
@@ -108,7 +111,7 @@ export const renderSurvey = async ({
<VerifyEmail
survey={survey}
isErrorComponent={true}
languageCode={getLanguageCode(langParam, survey)}
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
styling={project.styling}
locale={locale}
/>
@@ -118,7 +121,7 @@ export const renderSurvey = async ({
<VerifyEmail
singleUseId={searchParams.suId ?? ""}
survey={survey}
languageCode={getLanguageCode(langParam, survey)}
languageCode={getSurveyLanguageCode(langParam, survey, browserLanguageCodes)}
styling={project.styling}
locale={locale}
/>
@@ -127,7 +130,7 @@ export const renderSurvey = async ({
// Compute final styling based on project and survey settings
const styling = computeStyling(project.styling, survey.styling);
const languageCode = getLanguageCode(langParam, survey);
const languageCode = getSurveyLanguageCode(langParam, survey, browserLanguageCodes);
const publicDomain = getPublicDomain();
// Handle PIN-protected surveys
@@ -194,24 +197,3 @@ function computeStyling(
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
}
/**
* Determines the language code to use for the survey.
* Checks URL parameter against available survey languages and returns
* "default" if language is not found or disabled.
*/
function getLanguageCode(langParam: string | undefined, survey: TSurvey): string {
if (!langParam) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findMatchingLocale } from "@/lib/utils/locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
@@ -137,9 +137,10 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
}
// Parallel fetch of environment context and locale
const [environmentContext, locale, singleUseResponse] = await Promise.all([
const [environmentContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
findMatchingBrowserLanguageCodes(),
// Fetch existing response for this contact
getExistingContactResponse(survey.id, contactId)(),
]);
@@ -158,6 +159,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
singleUseResponse,
environmentContext,
locale,
browserLanguageCodes,
responseCount,
});
};
+1
View File
@@ -58,6 +58,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
styling: true,
surveyClosedMessage: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
recaptcha: true,
metadata: true,
+82 -1
View File
@@ -3,7 +3,13 @@ import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
import {
getElementsFromSurveyBlocks,
getSurveyLanguageCode,
getWebAppLocale,
isRTL,
isRTLLanguage,
} from "./utils";
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
({
@@ -45,6 +51,7 @@ const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
autoSelectLanguage: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
@@ -98,6 +105,80 @@ describe("getWebAppLocale", () => {
});
});
describe("getSurveyLanguageCode", () => {
const language = (code: string, overrides: Partial<TSurvey["languages"][number]> = {}) => ({
language: {
id: `lang-${code}`,
code,
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: false,
enabled: true,
...overrides,
});
test("uses the URL language parameter before browser language auto-selection", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("de")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode("de", survey, ["en-US"])).toBe("de");
});
test("matches browser language exactly when auto-selection is enabled", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("de-DE")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "en-US"])).toBe("de-DE");
});
test("matches browser language by base language when exact variant is unavailable", () => {
const survey = {
...createMockSurvey([language("en", { default: true }), language("es-ES")]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["es-MX", "en-US"])).toBe("es-ES");
});
test("uses aliases and ignores disabled languages", () => {
const survey = {
...createMockSurvey([
language("en", { default: true }),
language("de", { enabled: false }),
language("fr-FR", {
language: {
id: "lang-fr-FR",
code: "fr-FR",
alias: "fr",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
}),
]),
autoSelectLanguage: true,
};
expect(getSurveyLanguageCode(undefined, survey, ["de-DE", "fr-CA"])).toBe("fr-FR");
});
test("falls back to default language when auto-selection is disabled or unmatched", () => {
const survey = createMockSurvey([language("en", { default: true }), language("de")]);
expect(getSurveyLanguageCode(undefined, survey, ["de-DE"])).toBe("default");
expect(getSurveyLanguageCode(undefined, { ...survey, autoSelectLanguage: true }, ["fr-FR"])).toBe(
"default"
);
});
});
describe("isRTL", () => {
test("detects RTL characters", () => {
expect(isRTL("مرحبا")).toBe(true);
+17
View File
@@ -1,6 +1,7 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
import { TSurvey } from "@formbricks/types/surveys/types";
export function isRTL(text: string): boolean {
@@ -55,6 +56,22 @@ export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: s
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
export const getSurveyLanguageCode = (
langParam: string | undefined,
survey: TSurvey,
browserLanguageCodes: string[] = []
): string => {
return (
resolveSurveyLanguage({
languages: survey.languages,
explicitLanguageCode: langParam,
browserLanguageCodes,
autoSelectLanguage: survey.autoSelectLanguage,
unmatchedExplicitLanguageBehavior: "fallback",
}) ?? "default"
);
};
/**
* Maps survey language codes to web app locale codes.
* Falls back to "en-US" if the language is not available in web app locales.
+4 -2
View File
@@ -3,7 +3,7 @@ import { notFound } from "next/navigation";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys/types";
import { findMatchingLocale } from "@/lib/utils/locale";
import { findMatchingBrowserLanguageCodes, findMatchingLocale } from "@/lib/utils/locale";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
@@ -101,9 +101,10 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
// Stage 2: Parallel fetch of all remaining data
const [environmentContext, locale, singleUseResponse] = await Promise.all([
const [environmentContext, locale, browserLanguageCodes, singleUseResponse] = await Promise.all([
getEnvironmentContextForLinkSurvey(survey.environmentId),
findMatchingLocale(),
findMatchingBrowserLanguageCodes(),
// Only fetch single-use response if we have a validated ID
isSingleUseSurvey && singleUseId
? getResponseBySingleUseId(survey.id, singleUseId)()
@@ -124,6 +125,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
isPreview,
environmentContext,
locale,
browserLanguageCodes,
responseCount,
});
};
@@ -122,7 +122,7 @@ export const LanguageView = ({
buttonText: t("environments.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
},
@@ -139,6 +139,7 @@ export const LanguageView = ({
const language = projectLanguages.find((lang) => lang.code === languageCode);
if (!language) return;
const isNewMultiLanguageSurvey = localSurvey.languages.length === 0;
let languageExists = false;
const newLanguages =
localSurvey.languages.map((lang) => {
@@ -154,7 +155,11 @@ export const LanguageView = ({
}
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
setLocalSurvey({ ...localSurvey, languages: newLanguages });
setLocalSurvey({
...localSurvey,
languages: newLanguages,
autoSelectLanguage: isNewMultiLanguageSurvey ? true : localSurvey.autoSelectLanguage,
});
};
const handleToggleLanguage = (code: string) => {
@@ -221,7 +226,7 @@ export const LanguageView = ({
buttonText: t("environments.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
},
@@ -232,6 +237,10 @@ export const LanguageView = ({
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
};
const handleAutoSelectLanguageToggle = () => {
setLocalSurvey({ ...localSurvey, autoSelectLanguage: !localSurvey.autoSelectLanguage });
};
const openTranslationModal = (code: string) => {
setActiveLanguageCode(code);
setTranslationModalOpen(true);
@@ -456,6 +465,16 @@ export const LanguageView = ({
)}
childBorder={true}
/>
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="autoSelectLanguage"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.autoSelectLanguage}
onToggle={handleAutoSelectLanguageToggle}
title={t("environments.surveys.edit.auto_select_browser_language")}
description={t("environments.surveys.edit.auto_select_browser_language_description")}
childBorder={true}
/>
</div>
)}
@@ -36,6 +36,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
segment: null,
languages: [],
showLanguageSwitch: false,
autoSelectLanguage: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
variables: [],
+10
View File
@@ -754,6 +754,10 @@
"example": null,
"type": "boolean"
},
"autoSelectLanguage": {
"example": null,
"type": "boolean"
},
"delay": {
"example": 0,
"type": "integer"
@@ -6156,6 +6160,7 @@
{
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -6340,6 +6345,7 @@
"value": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdBy": null,
"delay": 0,
"displayLimit": null,
@@ -6495,6 +6501,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -6761,6 +6768,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -7014,6 +7022,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -7217,6 +7226,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -37,12 +37,14 @@ How to deliver a specific language depends on the survey type (app or link surve
![Add Multiple Languages to your Project](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-languages.webp)
You can come back to this page anytime to add more languages or remove existing ones.
</Step>
<Step title="Create or Edit Your Survey">
Return to the dashboard to create a new survey or edit an existing one:
![Survey Overview](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/surveys-home.webp)
</Step>
<Step title="Enable Multi-language Support">
@@ -52,19 +54,26 @@ How to deliver a specific language depends on the survey type (app or link surve
Choose a **Default Language** for your survey.
New multi-language surveys use the respondent's browser language by default when a matching active language is
available. You can change this anytime with the **Use browser language by default** toggle. This setting is
separate from **Show language switch**; you can automatically open the right language without showing respondents a
manual language menu.
<Note>Changing the default language will reset all the translations you have made for the survey.</Note>
</Step>
<Step title="Add Supported Languages">
Add the languages from the dropdown that you want to support in your survey:
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-language-in-survey.webp)
</Step>
<Step title="Preview and Translate Content">
You can now see the survey in the selected language by clicking on the language dropdown in any of the questions.
Now you can translate all survey content, including questions, options, and button placeholders, into the selected language.
</Step>
@@ -74,6 +83,17 @@ How to deliver a specific language depends on the survey type (app or link surve
</Step>
</Steps>
## Language Selection Order
Formbricks selects the survey language in this order:
1. An explicit `lang` URL parameter for link surveys, or an explicit SDK/user language for app surveys.
2. The respondent's browser language when **Use browser language by default** is enabled.
3. The survey's default language.
Language matching first checks the exact identifier or alias. If there is no exact match, Formbricks checks language
variants with the same base language, for example `es-MX` can match an active `es-ES` translation.
## App Surveys Configuration
<Steps>
@@ -91,8 +111,11 @@ How to deliver a specific language depends on the survey type (app or link surve
<Note>
If a user has a language assigned, a survey has multi-language activated and it is missing a translation in
the language of the user, the survey will not be displayed.
the language of the user, the survey will not be displayed. When no user language is assigned and **Use browser
language by default** is enabled, Formbricks uses the browser language and falls back to the survey default if
there is no match.
</Note>
</Step>
<Step title="Start Collecting Responses">
@@ -104,7 +127,9 @@ How to deliver a specific language depends on the survey type (app or link surve
## Link Surveys Configuration
For link surveys, the translation delivery is dependent on the `lang` URL parameter.
For link surveys, the `lang` URL parameter always takes priority. If no `lang` parameter is present and **Use browser
language by default** is enabled, Formbricks uses the respondent's browser language when a matching active language is
available.
<Steps>
<Step title="Publish Your Survey">
@@ -122,7 +147,10 @@ For link surveys, the translation delivery is dependent on the `lang` URL parame
- German: [https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de](https://app.Formbricks.com/s/clptfos2i1pj516pvhxqyu3bn?lang=de)
Without the `lang` parameter, Formbricks will show the survey in the default language you have set.
Without the `lang` parameter, Formbricks will use the browser language when **Use browser language by default** is
enabled and a matching active language exists. Otherwise, it will show the survey in the default language you have
set.
</Step>
<Step title="Start Collecting Responses">
@@ -150,14 +178,13 @@ Formbricks fully supports Right-to-Left (RTL) languages such as Arabic, Hebrew,
Add an RTL language (like Arabic or Hebrew) in the **Survey Languages** settings
</Step>
<Step title="Create Translations">
Create translations for your survey content in the RTL language
</Step>
<Step title="Create Translations">Create translations for your survey content in the RTL language</Step>
<Step title="Automatic RTL Display">
The survey will automatically display in RTL format when that language is selected
![RTL Language Support](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/rtl-support.webp)
</Step>
</Steps>
@@ -0,0 +1 @@
ALTER TABLE "Survey" ADD COLUMN "autoSelectLanguage" BOOLEAN;
+1
View File
@@ -401,6 +401,7 @@ model Survey {
displayPercentage Decimal?
languages SurveyLanguage[]
showLanguageSwitch Boolean?
autoSelectLanguage Boolean?
followUps SurveyFollowUp[]
/// [SurveyRecaptcha]
recaptcha Json? @default("{\"enabled\": false, \"threshold\":0.1}")
+4
View File
@@ -55,6 +55,10 @@ const ZSurveyBase = z.object({
status: z.enum(SurveyStatus).describe("The status of the survey"),
thankYouMessage: z.string().nullable().describe("The thank you message of the survey"),
showLanguageSwitch: z.boolean().nullable().describe("Whether to show the language switch"),
autoSelectLanguage: z
.boolean()
.nullable()
.describe("Whether to automatically select the survey language from the respondent's browser"),
showThankYouMessage: z.boolean().nullable().describe("Whether to show the thank you message"),
welcomeCard: z
.object({
+3
View File
@@ -41,6 +41,9 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@formbricks/types": "workspace:*"
},
"author": "Formbricks <hola@formbricks.com>",
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -22,6 +22,7 @@ export const mockConfig: TConfig = {
variables: [],
type: "app", // "link" or "app"
showLanguageSwitch: true,
autoSelectLanguage: false,
endings: [],
autoClose: 5,
status: "inProgress", // whatever statuses you use
@@ -482,6 +482,42 @@ describe("utils.ts", () => {
expect(getLanguageCode(survey, "fr")).toBe("fr");
expect(getLanguageCode(survey, "fr-FR")).toBe("fr");
});
test("returns a loose variant match for the selected language", () => {
const survey = {
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "es-ES" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
expect(getLanguageCode(survey, "es-MX")).toBe("es-ES");
});
test("uses fallback languages only when auto-select is enabled", () => {
const survey = {
autoSelectLanguage: true,
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "de" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
expect(getLanguageCode(survey, undefined, ["de-DE", "en-US"])).toBe("de");
expect(getLanguageCode({ ...survey, autoSelectLanguage: false }, undefined, ["de-DE"])).toBe("default");
});
test("does not fall back to browser language when an explicit user language is unavailable", () => {
const survey = {
autoSelectLanguage: true,
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "de" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
expect(getLanguageCode(survey, "fr", ["de-DE"])).toBeUndefined();
});
});
// ---------------------------------------------------------------------------------
+12 -20
View File
@@ -1,3 +1,4 @@
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
import { Logger } from "@/lib/common/logger";
import type {
TEnvironmentState,
@@ -176,27 +177,18 @@ export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: string): string | undefined => {
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code.toLowerCase() === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
export const getLanguageCode = (
survey: TEnvironmentStateSurvey,
language?: string,
fallbackLanguages: string[] = []
): string | undefined => {
return resolveSurveyLanguage({
languages: survey.languages,
explicitLanguageCode: language,
browserLanguageCodes: fallbackLanguages,
autoSelectLanguage: survey.autoSelectLanguage,
unmatchedExplicitLanguageBehavior: "undefined",
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
};
export const getSecureRandom = (): number => {
@@ -24,6 +24,7 @@ export const mockSurvey: TEnvironmentStateSurvey = {
},
type: "app", // "link" or "app"
showLanguageSwitch: true,
autoSelectLanguage: false,
endings: [],
autoClose: 5,
status: "inProgress", // whatever statuses you use
@@ -1,6 +1,7 @@
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { Config } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { executeRecaptcha, loadRecaptchaScript } from "@/lib/common/recaptcha";
import type * as CommonUtils from "@/lib/common/utils";
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
@@ -27,11 +28,18 @@ vi.mock("@/lib/common/logger", () => ({
},
}));
vi.mock("@/lib/common/recaptcha", () => ({
executeRecaptcha: vi.fn().mockResolvedValue("recaptcha-token"),
loadRecaptchaScript: vi.fn().mockResolvedValue(undefined),
}));
const mockTimeoutStack = {
add: vi.fn(),
};
vi.mock("@/lib/common/timeout-stack", () => ({
TimeoutStack: {
getInstance: vi.fn(() => ({
add: vi.fn(),
})),
getInstance: vi.fn(() => mockTimeoutStack),
},
}));
@@ -86,6 +94,42 @@ describe("widget-file", () => {
widget.setIsSurveyRunning(true);
});
test("getBrowserLanguageCodes prefers navigator.languages and falls back to navigator.language", () => {
const originalLanguages = navigator.languages;
const originalLanguage = navigator.language;
Object.defineProperty(navigator, "languages", {
configurable: true,
value: ["de-DE", "en-US"],
});
expect(widget.getBrowserLanguageCodes()).toEqual(["de-DE", "en-US"]);
Object.defineProperty(navigator, "languages", {
configurable: true,
value: [],
});
Object.defineProperty(navigator, "language", {
configurable: true,
value: "fr-FR",
});
expect(widget.getBrowserLanguageCodes()).toEqual(["fr-FR"]);
Object.defineProperty(navigator, "language", {
configurable: true,
value: "",
});
expect(widget.getBrowserLanguageCodes()).toEqual([]);
Object.defineProperty(navigator, "languages", {
configurable: true,
value: originalLanguages,
});
Object.defineProperty(navigator, "language", {
configurable: true,
value: originalLanguage,
});
});
test("triggerSurvey skips if shouldDisplayBasedOnPercentage returns false", async () => {
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
(shouldDisplayBasedOnPercentage as Mock).mockReturnValueOnce(false);
@@ -165,6 +209,64 @@ describe("widget-file", () => {
vi.useRealTimers();
});
test("renderWidget enables reCAPTCHA and provides getRecaptchaToken when spam protection is active", async () => {
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
recaptchaSiteKey: "site-key",
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
recaptcha: { enabled: true, threshold: 0.5 },
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
expect(loadRecaptchaScript).toHaveBeenCalledWith("site-key");
const renderPayload = vi.mocked(window.formbricksSurveys.renderSurvey).mock.calls[0][0];
expect(renderPayload.isSpamProtectionEnabled).toBe(true);
await expect(renderPayload.getRecaptchaToken()).resolves.toBe("recaptcha-token");
expect(executeRecaptcha).toHaveBeenCalledWith("site-key");
vi.useRealTimers();
});
test("renderWidget short-circuits if isSurveyRunning is already true", async () => {
widget.setIsSurveyRunning(true);
await widget.renderWidget(mockSurvey);
@@ -217,6 +319,72 @@ describe("widget-file", () => {
);
});
test("renderWidget passes browser languages when auto-select is enabled and no user language is set", async () => {
const originalLanguages = navigator.languages;
Object.defineProperty(navigator, "languages", {
configurable: true,
value: ["de-DE", "en-US"],
});
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
displays: [],
responses: [],
lastDisplayAt: null,
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
(getLanguageCode as Mock).mockReturnValueOnce("de");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
const autoSelectSurvey = {
...mockSurvey,
autoSelectLanguage: true,
delay: 0,
languages: [
{ language: { code: "en" }, default: true, enabled: true },
{ language: { code: "de" }, default: false, enabled: true },
],
} as unknown as TEnvironmentStateSurvey;
await widget.renderWidget(autoSelectSurvey);
vi.advanceTimersByTime(0);
expect(getLanguageCode).toHaveBeenCalledWith(autoSelectSurvey, undefined, ["de-DE", "en-US"]);
vi.useRealTimers();
Object.defineProperty(navigator, "languages", {
configurable: true,
value: originalLanguages,
});
});
test("closeSurvey removes widget container, resets filtered surveys, sets isSurveyRunning=false", () => {
const mockConfigValue = {
get: vi.fn().mockReturnValue({
@@ -446,6 +614,121 @@ describe("widget-file", () => {
vi.useRealTimers();
});
test("renderWidget callback handlers update displays and responses in config", async () => {
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
recaptchaSiteKey: undefined,
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
(filterSurveys as Mock).mockReturnValue(["filtered"]);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
const renderPayload = vi.mocked(window.formbricksSurveys.renderSurvey).mock.calls[0][0];
renderPayload.onDisplayCreated();
renderPayload.onResponseCreated();
expect(renderPayload.getSetIsResponseSendingFinished(vi.fn())).toBeUndefined();
expect(mockConfigValue.update).toHaveBeenCalledTimes(2);
const displayUpdate = mockConfigValue.update.mock.calls[0][0];
expect(displayUpdate.user.data.displays).toHaveLength(1);
expect(displayUpdate.user.data.displays[0].surveyId).toBe(mockSurvey.id);
expect(displayUpdate.user.data.lastDisplayAt).toBeInstanceOf(Date);
expect(displayUpdate.filteredSurveys).toEqual(["filtered"]);
const responseUpdate = mockConfigValue.update.mock.calls[1][0];
expect(responseUpdate.user.data.responses).toEqual([mockSurvey.id]);
expect(responseUpdate.filteredSurveys).toEqual(["filtered"]);
vi.useRealTimers();
});
test("renderWidget adds timeout to stack when action is provided", async () => {
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget(
{
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey,
"manual-trigger"
);
expect(mockTimeoutStack.add).toHaveBeenCalledWith("manual-trigger", expect.anything());
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
@@ -634,9 +917,6 @@ describe("widget-file", () => {
const appendChildSpy = vi.spyOn(document.head, "appendChild");
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
vi.useFakeTimers();
await widget.renderWidget({
@@ -653,8 +933,6 @@ describe("widget-file", () => {
});
expect(scriptAppendCalls.length).toBe(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});
+12 -1
View File
@@ -18,6 +18,13 @@ import { type TTrackProperties } from "@/types/survey";
let isSurveyRunning = false;
export const getBrowserLanguageCodes = (): string[] => {
if (typeof navigator === "undefined") return [];
if (navigator.languages.length > 0) return [...navigator.languages];
if (navigator.language) return [navigator.language];
return [];
};
export const setIsSurveyRunning = (value: boolean): void => {
isSurveyRunning = value;
};
@@ -91,7 +98,11 @@ export const renderWidget = async (
let languageCode = "default";
if (isMultiLanguageSurvey) {
const displayLanguage = getLanguageCode(survey, language);
const displayLanguage = getLanguageCode(
survey,
language,
survey.autoSelectLanguage ? getBrowserLanguageCodes() : []
);
//if survey is not available in selected language, survey wont be shown
if (!displayLanguage) {
logger.debug(`Survey "${survey.id}" is not available in specified language.`);
+1
View File
@@ -10,6 +10,7 @@ export type TEnvironmentStateSurvey = Pick<
| "variables"
| "type"
| "showLanguageSwitch"
| "autoSelectLanguage"
| "endings"
| "autoClose"
| "status"
+1
View File
@@ -15,6 +15,7 @@ export const ZJsEnvironmentStateSurvey = ZSurveyBase.pick({
variables: true,
type: true,
showLanguageSwitch: true,
autoSelectLanguage: true,
languages: true,
endings: true,
autoClose: true,
+5 -1
View File
@@ -11,6 +11,8 @@
"sideEffects": false,
"scripts": {
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"clean": "rimraf node_modules .turbo"
},
"dependencies": {
@@ -20,6 +22,8 @@
"node-html-parser": "7.1.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*"
"@formbricks/config-typescript": "workspace:*",
"@vitest/coverage-v8": "4.1.6",
"vitest": "4.1.6"
}
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, test } from "vitest";
import { matchSurveyLanguage, normalizeLanguageCode, resolveSurveyLanguage } from "./language";
const languages = [
{
default: true,
enabled: true,
language: {
code: "en",
alias: "English",
},
},
{
default: false,
enabled: true,
language: {
code: "es-ES",
alias: "es",
},
},
{
default: false,
enabled: false,
language: {
code: "de",
alias: null,
},
},
];
describe("survey language helpers", () => {
test("normalizes quality values, whitespace, underscores, and case", () => {
expect(normalizeLanguageCode(" PT_BR ;q=0.9 ")).toBe("pt-br");
});
test("matches exact codes, aliases, and loose variants while ignoring disabled languages", () => {
expect(matchSurveyLanguage(languages, "es")).toBe("es-ES");
expect(matchSurveyLanguage(languages, "es-MX")).toBe("es-ES");
expect(matchSurveyLanguage(languages, "de-DE")).toBeUndefined();
});
test("resolves explicit language before browser language", () => {
expect(
resolveSurveyLanguage({
languages,
explicitLanguageCode: "es-MX",
browserLanguageCodes: ["en-US"],
autoSelectLanguage: true,
})
).toBe("es-ES");
});
test("uses browser language only when enabled and falls back to default", () => {
expect(
resolveSurveyLanguage({
languages,
browserLanguageCodes: ["es-MX"],
autoSelectLanguage: true,
})
).toBe("es-ES");
expect(
resolveSurveyLanguage({
languages,
browserLanguageCodes: ["es-MX"],
autoSelectLanguage: false,
})
).toBe("default");
expect(
resolveSurveyLanguage({
languages,
browserLanguageCodes: ["fr-CA"],
autoSelectLanguage: true,
})
).toBe("default");
});
test("supports strict unmatched explicit language behavior", () => {
expect(
resolveSurveyLanguage({
languages,
explicitLanguageCode: "fr",
browserLanguageCodes: ["es-MX"],
autoSelectLanguage: true,
unmatchedExplicitLanguageBehavior: "undefined",
})
).toBeUndefined();
});
});
+108
View File
@@ -0,0 +1,108 @@
interface TSurveyLanguageLike {
default?: boolean | null;
enabled?: boolean | null;
language: {
code: string;
alias?: string | null;
};
}
interface ResolveSurveyLanguageInput<T extends TSurveyLanguageLike> {
languages: T[];
explicitLanguageCode?: string;
browserLanguageCodes?: string[];
autoSelectLanguage?: boolean | null;
unmatchedExplicitLanguageBehavior?: "fallback" | "undefined";
}
export const normalizeLanguageCode = (languageCode: string): string =>
languageCode.trim().split(";")[0].trim().replace("_", "-").toLowerCase();
const getBaseLanguageCode = (languageCode: string): string =>
normalizeLanguageCode(languageCode).split("-")[0];
const getSelectableLanguageCode = (surveyLanguage: TSurveyLanguageLike): string | undefined => {
if (surveyLanguage.default) return "default";
if (!surveyLanguage.enabled) return undefined;
return surveyLanguage.language.code;
};
const findExactLanguageMatch = <T extends TSurveyLanguageLike>(
languages: T[],
languageCode: string
): string | undefined => {
const normalizedLanguageCode = normalizeLanguageCode(languageCode);
const selectedLanguage = languages.find((surveyLanguage) => {
return (
normalizeLanguageCode(surveyLanguage.language.code) === normalizedLanguageCode ||
(surveyLanguage.language.alias
? normalizeLanguageCode(surveyLanguage.language.alias) === normalizedLanguageCode
: false)
);
});
return selectedLanguage ? getSelectableLanguageCode(selectedLanguage) : undefined;
};
const findLooseLanguageMatch = <T extends TSurveyLanguageLike>(
languages: T[],
languageCode: string
): string | undefined => {
const baseLanguageCode = getBaseLanguageCode(languageCode);
for (const surveyLanguage of languages) {
const selectableLanguageCode = getSelectableLanguageCode(surveyLanguage);
if (!selectableLanguageCode) continue;
const languageBaseCode = getBaseLanguageCode(surveyLanguage.language.code);
const aliasBaseCode = surveyLanguage.language.alias
? getBaseLanguageCode(surveyLanguage.language.alias)
: undefined;
if (languageBaseCode === baseLanguageCode || aliasBaseCode === baseLanguageCode) {
return selectableLanguageCode;
}
}
return undefined;
};
export const matchSurveyLanguage = <T extends TSurveyLanguageLike>(
languages: T[],
languageCode: string
): string | undefined => {
return findExactLanguageMatch(languages, languageCode) ?? findLooseLanguageMatch(languages, languageCode);
};
/**
* Resolves survey language precedence without coupling callers to a delivery channel:
* explicit language (URL or SDK/user setting) -\> browser languages when enabled -\> survey default.
*/
export const resolveSurveyLanguage = <T extends TSurveyLanguageLike>({
languages,
explicitLanguageCode,
browserLanguageCodes = [],
autoSelectLanguage,
unmatchedExplicitLanguageBehavior = "fallback",
}: ResolveSurveyLanguageInput<T>): string | undefined => {
if (explicitLanguageCode) {
const explicitMatch = matchSurveyLanguage(languages, explicitLanguageCode);
if (explicitMatch) return explicitMatch;
return unmatchedExplicitLanguageBehavior === "undefined" ? undefined : "default";
}
if (!autoSelectLanguage) return "default";
for (const browserLanguageCode of browserLanguageCodes) {
const exactMatch = findExactLanguageMatch(languages, browserLanguageCode);
if (exactMatch) return exactMatch;
}
for (const browserLanguageCode of browserLanguageCodes) {
const looseMatch = findLooseLanguageMatch(languages, browserLanguageCode);
if (looseMatch) return looseMatch;
}
return "default";
};
+1
View File
@@ -907,6 +907,7 @@ export const ZSurveyBase = z.object({
projectOverwrites: ZSurveyProjectOverwrites.nullable(),
styling: ZSurveyStyling.nullable(),
showLanguageSwitch: z.boolean().nullable(),
autoSelectLanguage: z.boolean().nullish(),
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
segment: ZSegment.nullable(),
singleUse: ZSurveySingleUse.nullable(),
+10
View File
@@ -762,6 +762,10 @@ importers:
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@7.3.3(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))
packages/js-core:
dependencies:
'@formbricks/types':
specifier: workspace:*
version: link:../types
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
@@ -1038,6 +1042,12 @@ importers:
'@formbricks/config-typescript':
specifier: workspace:*
version: link:../config-typescript
'@vitest/coverage-v8':
specifier: 4.1.6
version: 4.1.6(vitest@4.1.6)
vitest:
specifier: 4.1.6
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@8.0.12(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.47.1)(tsx@4.21.0)(yaml@2.9.0))
packages/vite-plugins:
devDependencies: