refactor: centralize survey language resolution

This commit is contained in:
Cursor Agent
2026-05-13 11:54:08 +00:00
parent 11a2dcf223
commit 74d2eb445f
12 changed files with 224 additions and 146 deletions
+3
View File
@@ -631,6 +631,7 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const hasMultipleEnabledLanguages = (languages ?? []).filter((language) => language.enabled).length > 1;
const actionClasses = await getActionClasses(parsedWorkspaceId);
@@ -641,6 +642,8 @@ export const createSurvey = async (workspaceId: string, surveyBody: TSurveyCreat
publishOn: normalizedPublishOn,
status: restSurveyBody.status ?? "draft",
}),
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
+2 -2
View File
@@ -2833,8 +2833,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": "Auto-select browser language",
"auto_select_browser_language_description": "Use the respondent's browser language when the survey has a matching active language. Falls back to the default language.",
"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": "Automatically close survey after",
"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.",
@@ -147,6 +147,28 @@ describe("getSurveyLanguageCode", () => {
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")]);
+10 -66
View File
@@ -1,6 +1,7 @@
import { TJsWorkspaceStateSurvey } 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,77 +56,20 @@ export function isRTLLanguage(survey: TJsWorkspaceStateSurvey, languageCode: str
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
const normalizeLanguageCode = (languageCode: string): string =>
languageCode.trim().split(";")[0].replace("_", "-").toLowerCase();
const getBaseLanguageCode = (languageCode: string): string =>
normalizeLanguageCode(languageCode).split("-")[0];
const getSelectableLanguageCode = (surveyLanguage: TSurvey["languages"][number]): string | undefined => {
if (surveyLanguage.default) return "default";
if (!surveyLanguage.enabled) return undefined;
return surveyLanguage.language.code;
};
const findExactLanguageMatch = (survey: TSurvey, languageCode: string): string | undefined => {
const normalizedLanguageCode = normalizeLanguageCode(languageCode);
const selectedLanguage = survey.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 = (survey: TSurvey, languageCode: string): string | undefined => {
const baseLanguageCode = getBaseLanguageCode(languageCode);
for (const surveyLanguage of survey.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 getSurveyLanguageCode = (
langParam: string | undefined,
survey: TSurvey,
browserLanguageCodes: string[] = []
): string => {
if (langParam) {
return findExactLanguageMatch(survey, langParam) ?? "default";
}
if (!survey.autoSelectLanguage) {
return "default";
}
for (const browserLanguageCode of browserLanguageCodes) {
const exactMatch = findExactLanguageMatch(survey, browserLanguageCode);
if (exactMatch) return exactMatch;
}
for (const browserLanguageCode of browserLanguageCodes) {
const looseMatch = findLooseLanguageMatch(survey, browserLanguageCode);
if (looseMatch) return looseMatch;
}
return "default";
return (
resolveSurveyLanguage({
languages: survey.languages,
explicitLanguageCode: langParam,
browserLanguageCodes,
autoSelectLanguage: survey.autoSelectLanguage,
unmatchedExplicitLanguageBehavior: "fallback",
}) ?? "default"
);
};
/**
@@ -149,7 +149,7 @@ export const LanguageView = ({
buttonVariant: "destructive",
onConfirm: () => {
// Strip all non-default language keys from the survey data
let cleanedSurvey = localSurvey;
let cleanedSurvey = { ...localSurvey, autoSelectLanguage: false };
for (const lang of localSurvey.languages) {
if (!lang.default) {
cleanedSurvey = removeLanguageKeysFromSurvey(cleanedSurvey, lang.language.code);
@@ -172,6 +172,7 @@ export const LanguageView = ({
const language = workspaceLanguages.find((lang) => lang.code === languageCode);
if (!language) return;
const isNewMultiLanguageSurvey = localSurvey.languages.length === 0;
let languageExists = false;
const newLanguages =
localSurvey.languages.map((lang) => {
@@ -187,7 +188,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) => {
@@ -254,7 +259,7 @@ export const LanguageView = ({
buttonText: t("workspace.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
updateSurveyTranslations({ ...localSurvey, autoSelectLanguage: false }, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo((prev) => ({ ...prev, open: false }));
},
+10
View File
@@ -328,6 +328,10 @@
"example": null,
"type": "boolean"
},
"autoSelectLanguage": {
"example": null,
"type": "boolean"
},
"delay": {
"example": 0,
"type": "integer"
@@ -4367,6 +4371,7 @@
{
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -4551,6 +4556,7 @@
"value": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdBy": null,
"delay": 0,
"displayLimit": null,
@@ -4771,6 +4777,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -5037,6 +5044,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -5290,6 +5298,7 @@
"data": {
"autoClose": null,
"autoComplete": null,
"autoSelectLanguage": null,
"createdAt": "2024-08-05T11:08:27.042Z",
"createdBy": "clfv1zvij0000ru0gunwpy43a",
"delay": 0,
@@ -5493,6 +5502,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 Workspace](/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>
+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:*",
@@ -456,6 +456,18 @@ describe("utils.ts", () => {
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();
});
});
// ---------------------------------------------------------------------------------
+8 -68
View File
@@ -1,3 +1,4 @@
import { resolveSurveyLanguage } from "@formbricks/types/surveys/language";
import { Logger } from "@/lib/common/logger";
import type {
TSurveyStyling,
@@ -161,79 +162,18 @@ export const getDefaultLanguageCode = (survey: TWorkspaceStateSurvey): string |
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
const normalizeLanguageCode = (languageCode: string): string =>
languageCode.trim().split(";")[0].replace("_", "-").toLowerCase();
const getBaseLanguageCode = (languageCode: string): string =>
normalizeLanguageCode(languageCode).split("-")[0];
const getSelectableLanguageCode = (surveyLanguage: TWorkspaceStateSurvey["languages"][number]) => {
if (surveyLanguage.default) {
return "default";
}
if (!surveyLanguage.enabled) {
return undefined;
}
return surveyLanguage.language.code;
};
const findExactLanguageMatch = (survey: TWorkspaceStateSurvey, language: string): string | undefined => {
const normalizedLanguageCode = normalizeLanguageCode(language);
const selectedLanguage = survey.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 = (survey: TWorkspaceStateSurvey, language: string): string | undefined => {
const baseLanguageCode = getBaseLanguageCode(language);
for (const surveyLanguage of survey.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 getLanguageCode = (
survey: TWorkspaceStateSurvey,
language?: string,
fallbackLanguages: string[] = []
): string | undefined => {
if (language) {
return findExactLanguageMatch(survey, language) ?? findLooseLanguageMatch(survey, language);
}
if (!survey.autoSelectLanguage) return "default";
for (const fallbackLanguage of fallbackLanguages) {
const exactMatch = findExactLanguageMatch(survey, fallbackLanguage);
if (exactMatch) return exactMatch;
}
for (const fallbackLanguage of fallbackLanguages) {
const looseMatch = findLooseLanguageMatch(survey, fallbackLanguage);
if (looseMatch) return looseMatch;
}
return "default";
return resolveSurveyLanguage({
languages: survey.languages,
explicitLanguageCode: language,
browserLanguageCodes: fallbackLanguages,
autoSelectLanguage: survey.autoSelectLanguage,
unmatchedExplicitLanguageBehavior: "undefined",
});
};
export const getSecureRandom = (): number => {
+108
View File
@@ -0,0 +1,108 @@
type 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";
};
+4
View File
@@ -840,6 +840,10 @@ importers:
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.4.0)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/js-core:
dependencies:
'@formbricks/types':
specifier: workspace:*
version: link:../types
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*