Compare commits

...

3 Commits

Author SHA1 Message Date
Cursor Agent bef8dae328 fix: keep language strings scoped to survey editor 2026-05-13 12:32:52 +00:00
Cursor Agent e7e751c1c5 test: cover language translation string extraction 2026-05-13 12:13:42 +00:00
Cursor Agent d1c9b8a5a3 fix: include missed language translation strings
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-13 12:09:47 +00:00
4 changed files with 229 additions and 9 deletions
@@ -174,6 +174,13 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, {
choices: newChoices,
...(choiceId === "other" &&
!element.otherOptionPlaceholder && {
otherOptionPlaceholder: createI18nString(
t("environments.surveys.edit.please_specify"),
surveyLanguageCodes
),
}),
...(element.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
@@ -107,7 +107,7 @@ export const ManageTranslationsModal = ({
for (const s of strings) {
const val = draftTranslations[s.path] ?? "";
if (val) {
setTranslationAtPathMutable(clone, s.path, languageCode, val);
setTranslationAtPathMutable(clone, s.path, languageCode, val, s.value.default);
}
}
return clone;
@@ -121,7 +121,7 @@ export const ManageTranslationsModal = ({
const updatedSurvey = structuredClone(localSurvey);
for (const s of strings) {
const val = draftTranslations[s.path] ?? "";
setTranslationAtPathMutable(updatedSurvey, s.path, languageCode, val);
setTranslationAtPathMutable(updatedSurvey, s.path, languageCode, val, s.value.default);
}
setLocalSurvey(updatedSurvey);
setOpen(false);
@@ -0,0 +1,177 @@
import { type TFunction } from "i18next";
import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { computeTranslationProgress, extractTranslatableStrings, setTranslationAtPathMutable } from "./utils";
const t = ((key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"common.choice_n": `Choice ${options?.n}`,
"common.headline": "Headline",
"common.other_placeholder": "Other Placeholder",
"environments.surveys.edit.please_specify": "Please specify",
};
return translations[key] ?? key;
}) as unknown as TFunction;
const createSurvey = (survey: Record<string, unknown>): TSurvey =>
({
welcomeCard: { enabled: false },
blocks: [],
endings: [],
metadata: {},
...survey,
}) as unknown as TSurvey;
describe("multi-language survey utils", () => {
test("extracts missing other option placeholders for single and multi select elements", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
{
id: "multi",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Pick many" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
],
},
],
});
const strings = extractTranslatableStrings(survey, t);
expect(strings).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "blocks.0.elements.0.otherOptionPlaceholder",
fieldLabel: "Other Placeholder",
value: { default: "Please specify" },
}),
expect.objectContaining({
path: "blocks.0.elements.1.otherOptionPlaceholder",
fieldLabel: "Other Placeholder",
value: { default: "Please specify" },
}),
])
);
});
test("keeps existing other option placeholder translations when default text is empty", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
otherOptionPlaceholder: { default: "", de: "Bitte angeben" },
},
],
},
],
});
const placeholder = extractTranslatableStrings(survey, t).find(
(string) => string.path === "blocks.0.elements.0.otherOptionPlaceholder"
);
expect(placeholder?.value).toEqual({ default: "Please specify", de: "Bitte angeben" });
expect(computeTranslationProgress([placeholder!], "de")).toEqual({
translated: 1,
total: 1,
percentage: 100,
});
});
test("does not extract stale other option placeholders without an other choice", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
],
otherOptionPlaceholder: { default: "Please specify" },
},
],
},
],
});
expect(
extractTranslatableStrings(survey, t).some(
(string) => string.path === "blocks.0.elements.0.otherOptionPlaceholder"
)
).toBe(false);
});
test("creates a missing translatable field when saving a translation with a default value", () => {
const survey = createSurvey({
blocks: [
{
id: "block-1",
elements: [
{
id: "single",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "choice-1", label: { default: "One" } },
{ id: "choice-2", label: { default: "Two" } },
{ id: "other", label: { default: "Other" } },
],
},
],
},
],
});
setTranslationAtPathMutable(
survey,
"blocks.0.elements.0.otherOptionPlaceholder",
"de",
"Bitte angeben",
"Please specify"
);
expect(survey.blocks[0].elements[0]).toMatchObject({
otherOptionPlaceholder: { default: "Please specify", de: "Bitte angeben" },
});
});
});
@@ -1,11 +1,13 @@
import { type TFunction } from "i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import type { TSurveyMultipleChoiceElement, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { isI18nObject } from "@/lib/i18n/utils";
import type { TranslatableString, TranslationProgress } from "./types";
const RICH_TEXT_FIELDS = new Set(["headline", "subheader", "html"]);
const OTHER_OPTION_PLACEHOLDER_FIELD = "otherOptionPlaceholder";
const pushIfI18n = (
result: TranslatableString[],
@@ -34,6 +36,34 @@ const pushIfI18n = (
}
};
const pushOtherOptionPlaceholder = (
result: TranslatableString[],
element: TSurveyMultipleChoiceElement | TSurveyRankingElement,
path: string,
displayId: string,
fieldLabel: string,
elementId: string,
defaultPlaceholder: string
) => {
const hasOtherChoice = element.choices?.some((choice) => choice.id === "other");
if (!hasOtherChoice) return;
const existingPlaceholder = element.otherOptionPlaceholder;
const defaultText = existingPlaceholder?.default?.trim() ? existingPlaceholder.default : defaultPlaceholder;
result.push({
path: `${path}.${OTHER_OPTION_PLACEHOLDER_FIELD}`,
displayId,
fieldLabel,
value: {
...(isI18nObject(existingPlaceholder) ? existingPlaceholder : {}),
default: defaultText,
},
isRichText: false,
elementId,
});
};
export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): TranslatableString[] => {
const result: TranslatableString[] = [];
@@ -108,14 +138,14 @@ export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): Trans
});
}
});
pushIfI18n(
pushOtherOptionPlaceholder(
result,
element,
"otherOptionPlaceholder",
base,
did,
t("common.other_placeholder"),
eid
eid,
t("environments.surveys.edit.please_specify")
);
break;
}
@@ -228,14 +258,14 @@ export const extractTranslatableStrings = (survey: TSurvey, t: TFunction): Trans
});
}
});
pushIfI18n(
pushOtherOptionPlaceholder(
result,
element,
"otherOptionPlaceholder",
base,
did,
t("common.other_placeholder"),
eid
eid,
t("environments.surveys.edit.please_specify")
);
break;
}
@@ -327,7 +357,8 @@ export const setTranslationAtPathMutable = (
survey: TSurvey,
path: string,
languageCode: string,
value: string
value: string,
defaultValue?: string
): void => {
const parts = path.split(".");
if (parts.length === 0) return;
@@ -347,5 +378,10 @@ export const setTranslationAtPathMutable = (
const target = current[lastPart];
if (isTraversable(target) && !Array.isArray(target) && "default" in target) {
(target as Record<string, string>)[languageCode] = value;
return;
}
if (target === undefined && defaultValue !== undefined && value.trim() !== "") {
current[lastPart] = { default: defaultValue, [languageCode]: value };
}
};