mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-24 11:39:31 -05:00
chore: always use language maps, allow filtering multiple languages
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/app/api/v3/surveys/serializers";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
import { normalizeV3SurveyLanguageTag } from "../language";
|
||||
import { parseV3SurveyLanguageQuery } from "../language";
|
||||
|
||||
const surveyParamsSchema = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
@@ -26,20 +26,19 @@ const surveyParamsSchema = z.object({
|
||||
const surveyQuerySchema = z
|
||||
.object({
|
||||
lang: z
|
||||
.string()
|
||||
.trim()
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.transform((value, ctx) => {
|
||||
const normalizedLanguage = normalizeV3SurveyLanguageTag(value);
|
||||
const parsedLanguageQuery = parseV3SurveyLanguageQuery(value);
|
||||
|
||||
if (!normalizedLanguage) {
|
||||
if (!parsedLanguageQuery.ok) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Language must be a valid locale code",
|
||||
message: parsedLanguageQuery.message,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return normalizedLanguage;
|
||||
return parsedLanguageQuery.languages;
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeV3SurveyLanguageTag, resolveV3SurveyLanguageCode } from "./language";
|
||||
import {
|
||||
normalizeV3SurveyLanguageTag,
|
||||
parseV3SurveyLanguageQuery,
|
||||
resolveV3SurveyLanguageCode,
|
||||
} from "./language";
|
||||
|
||||
const languages = [
|
||||
{ code: "en-US", enabled: true },
|
||||
@@ -22,6 +26,43 @@ describe("normalizeV3SurveyLanguageTag", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseV3SurveyLanguageQuery", () => {
|
||||
test("parses comma-separated language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us")).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE", "pt-PT", "en-US"],
|
||||
});
|
||||
});
|
||||
|
||||
test("parses repeated language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE", "pt-PT", "en-US"],
|
||||
});
|
||||
});
|
||||
|
||||
test("deduplicates language selectors case-insensitively", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE,DE_de")).toEqual({
|
||||
ok: true,
|
||||
languages: ["de-DE"],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects empty language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({
|
||||
ok: false,
|
||||
message: "Language selector must contain valid comma-separated locale codes",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid language selectors", () => {
|
||||
expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({
|
||||
ok: false,
|
||||
message: "Language 'not a locale' is not a valid locale code",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveV3SurveyLanguageCode", () => {
|
||||
test("matches configured languages case-insensitively and normalizes underscores", () => {
|
||||
expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" });
|
||||
|
||||
@@ -3,10 +3,14 @@ type TV3SurveyLanguageInput = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type TV3SurveyLanguageQueryInput = string | string[];
|
||||
|
||||
type TResolveV3SurveyLanguageCodeResult =
|
||||
| { ok: true; code: string }
|
||||
| { ok: false; reason: "invalid" | "unknown" | "ambiguous"; message: string };
|
||||
|
||||
type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string };
|
||||
|
||||
export function normalizeV3SurveyLanguageTag(value: string): string | null {
|
||||
const normalizedSeparators = value.trim().replaceAll("_", "-");
|
||||
|
||||
@@ -17,6 +21,40 @@ export function normalizeV3SurveyLanguageTag(value: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseV3SurveyLanguageQuery(
|
||||
value: TV3SurveyLanguageQueryInput
|
||||
): TParseV3SurveyLanguageQueryResult {
|
||||
const requestedLanguages = (Array.isArray(value) ? value : [value])
|
||||
.flatMap((entry) => entry.split(","))
|
||||
.map((entry) => entry.trim());
|
||||
|
||||
if (requestedLanguages.some((entry) => entry.length === 0)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Language selector must contain valid comma-separated locale codes",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedLanguages: string[] = [];
|
||||
|
||||
for (const language of requestedLanguages) {
|
||||
const normalizedLanguage = normalizeV3SurveyLanguageTag(language);
|
||||
|
||||
if (!normalizedLanguage) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Language '${language}' is not a valid locale code`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!normalizedLanguages.some((entry) => entry.toLowerCase() === normalizedLanguage.toLowerCase())) {
|
||||
normalizedLanguages.push(normalizedLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, languages: normalizedLanguages };
|
||||
}
|
||||
|
||||
function getLanguageSubtag(languageTag: string): string {
|
||||
return languageTag.split("-")[0]?.toLowerCase() ?? languageTag.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("serializeV3SurveyResource", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("localizes the implicit default language for surveys without configured languages", () => {
|
||||
test("filters the implicit default language for surveys without configured languages", () => {
|
||||
const survey = {
|
||||
...baseSurvey,
|
||||
languages: [],
|
||||
@@ -141,10 +141,10 @@ describe("serializeV3SurveyResource", () => {
|
||||
},
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const resource = serializeV3SurveyResource(survey, { lang: "en" });
|
||||
const resource = serializeV3SurveyResource(survey, { lang: ["en"] });
|
||||
|
||||
expect(resource.language).toBe("en-US");
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: "Welcome" } });
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } });
|
||||
});
|
||||
|
||||
test("preserves stored locale variants when their keys use non-canonical casing or separators", () => {
|
||||
@@ -168,18 +168,18 @@ describe("serializeV3SurveyResource", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("localizes fields for case-insensitive underscore language selectors", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: "DE_de" });
|
||||
test("filters fields for case-insensitive underscore language selectors while preserving maps", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] });
|
||||
|
||||
expect(resource.language).toBe("de-DE");
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: { headline: "Willkommen" },
|
||||
welcomeCard: { headline: { "de-DE": "Willkommen" } },
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: "Was sollen wir verbessern?",
|
||||
subheader: "Tell us more",
|
||||
headline: { "de-DE": "Was sollen wir verbessern?" },
|
||||
subheader: { "de-DE": "Tell us more" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -188,17 +188,41 @@ describe("serializeV3SurveyResource", () => {
|
||||
});
|
||||
|
||||
test("resolves language-only selectors against configured survey languages", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: "de" });
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] });
|
||||
|
||||
expect(resource.language).toBe("de-DE");
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: "Willkommen" } });
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "de-DE": "Willkommen" } } });
|
||||
});
|
||||
|
||||
test("localizes disabled configured languages for management reads", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: "fr" });
|
||||
test("filters disabled configured languages for management reads", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr"] });
|
||||
|
||||
expect(resource.language).toBe("fr-FR");
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: "Bienvenue" } });
|
||||
expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } });
|
||||
});
|
||||
|
||||
test("filters multiple requested languages while preserving maps", () => {
|
||||
const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de"] });
|
||||
|
||||
expect(resource).not.toHaveProperty("language");
|
||||
expect(resource).toMatchObject({
|
||||
welcomeCard: {
|
||||
headline: {
|
||||
"en-US": "Welcome",
|
||||
"de-DE": "Willkommen",
|
||||
},
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
headline: {
|
||||
"en-US": "What should we improve?",
|
||||
"de-DE": "Was sollen wir verbessern?",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ambiguous language-only selectors", () => {
|
||||
@@ -230,7 +254,7 @@ describe("serializeV3SurveyResource", () => {
|
||||
],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
expect(() => serializeV3SurveyResource(survey, { lang: "pt" })).toThrow(
|
||||
expect(() => serializeV3SurveyResource(survey, { lang: ["pt"] })).toThrow(
|
||||
"Language 'pt' is ambiguous for this survey; use one of pt-BR, pt-PT"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,32 +95,41 @@ function getI18nValueForLanguage(value: Record<string, string>, languageCode: st
|
||||
function serializeCanonicalValue(
|
||||
value: unknown,
|
||||
defaultLanguage: string,
|
||||
configuredLanguageCodes: Set<string>
|
||||
languageCodes: Set<string>,
|
||||
options?: { fallbackMissingTranslations?: boolean }
|
||||
): TSerializedValue {
|
||||
if (isI18nString(value)) {
|
||||
const result: Record<string, string> = {
|
||||
[defaultLanguage]: value.default,
|
||||
};
|
||||
|
||||
for (const languageCode of configuredLanguageCodes) {
|
||||
for (const languageCode of languageCodes) {
|
||||
const translatedValue = getI18nValueForLanguage(value, languageCode);
|
||||
if (languageCode !== defaultLanguage && translatedValue !== undefined) {
|
||||
result[languageCode] = translatedValue;
|
||||
if (languageCode !== defaultLanguage) {
|
||||
if (translatedValue !== undefined) {
|
||||
result[languageCode] = translatedValue;
|
||||
} else if (options?.fallbackMissingTranslations) {
|
||||
result[languageCode] = value.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!languageCodes.has(defaultLanguage)) {
|
||||
delete result[defaultLanguage];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, configuredLanguageCodes));
|
||||
return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [
|
||||
key,
|
||||
serializeCanonicalValue(entry, defaultLanguage, configuredLanguageCodes),
|
||||
serializeCanonicalValue(entry, defaultLanguage, languageCodes, options),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -128,24 +137,6 @@ function serializeCanonicalValue(
|
||||
return value as TSerializedValue;
|
||||
}
|
||||
|
||||
function serializeLocalizedValue(value: unknown, language: string): TSerializedValue {
|
||||
if (isI18nString(value)) {
|
||||
return getI18nValueForLanguage(value, language) ?? value.default;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => serializeLocalizedValue(entry, language));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, serializeLocalizedValue(entry, language)])
|
||||
);
|
||||
}
|
||||
|
||||
return value as TSerializedValue;
|
||||
}
|
||||
|
||||
function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string {
|
||||
const result = resolveV3SurveyLanguageCode(language, languages);
|
||||
|
||||
@@ -156,7 +147,15 @@ function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: stri
|
||||
return result.code;
|
||||
}
|
||||
|
||||
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string }) {
|
||||
function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] {
|
||||
if (!requestedLanguages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language));
|
||||
}
|
||||
|
||||
export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) {
|
||||
if (Array.isArray(survey.questions) && survey.questions.length > 0) {
|
||||
throw new V3SurveyUnsupportedShapeError(
|
||||
"Legacy question-based surveys are not supported by the v3 survey management API"
|
||||
@@ -166,11 +165,12 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l
|
||||
const defaultLanguage = getDefaultLanguage(survey);
|
||||
const languages = getSurveyLanguages(survey);
|
||||
const configuredLanguageCodes = new Set(languages.map((language) => language.code));
|
||||
const language = options?.lang ? resolveRequestedLanguage(languages, options.lang) : undefined;
|
||||
|
||||
const serializeValue = language
|
||||
? (value: unknown) => serializeLocalizedValue(value, language)
|
||||
: (value: unknown) => serializeCanonicalValue(value, defaultLanguage, configuredLanguageCodes);
|
||||
const requestedLanguages = resolveRequestedLanguages(languages, options?.lang);
|
||||
const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes;
|
||||
const serializeValue = (value: unknown) =>
|
||||
serializeCanonicalValue(value, defaultLanguage, languageCodes, {
|
||||
fallbackMissingTranslations: requestedLanguages.length > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
@@ -182,7 +182,6 @@ export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { l
|
||||
status: survey.status,
|
||||
metadata: survey.metadata,
|
||||
defaultLanguage,
|
||||
...(language ? { language } : {}),
|
||||
languages,
|
||||
welcomeCard: serializeValue(survey.welcomeCard),
|
||||
blocks: serializeValue(survey.blocks),
|
||||
|
||||
@@ -203,7 +203,7 @@ paths:
|
||||
description: |
|
||||
Returns the public v3 survey management resource for one survey. By default, translatable
|
||||
fields are returned as canonical multilingual maps keyed by real locale codes. Use `lang`
|
||||
to request a localized projection where translatable fields are returned as strings.
|
||||
to filter those maps to one or more requested locale codes.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
parameters:
|
||||
@@ -217,15 +217,23 @@ paths:
|
||||
- in: query
|
||||
name: lang
|
||||
required: false
|
||||
style: form
|
||||
explode: false
|
||||
schema:
|
||||
type: string
|
||||
examples: [de-DE, de_DE, de]
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
examples:
|
||||
- [de-DE]
|
||||
- [de-DE, pt-PT]
|
||||
description: |
|
||||
Locale code for a localized read projection. The parser is case-insensitive, accepts
|
||||
`_` or `-` separators, and normalizes to canonical BCP 47 casing (`de_DE`, `DE-de` → `de-DE`).
|
||||
A language-only selector (`de`) resolves to the matching configured survey language when exactly
|
||||
one exists; otherwise it returns `400`. Disabled-but-configured languages are readable in the
|
||||
management API so unfinished translations can be completed. Aliases are not accepted.
|
||||
Comma-separated locale code filter for translatable fields, for example `?lang=de-DE,pt-PT`.
|
||||
The response shape stays stable: translatable fields are always maps keyed by locale code, never
|
||||
strings. The parser is case-insensitive, accepts `_` or `-` separators, and normalizes to canonical
|
||||
BCP 47 casing (`de_DE`, `DE-de` → `de-DE`). A language-only selector (`de`) resolves to the matching
|
||||
configured survey language when exactly one exists; otherwise it returns `400`. Disabled-but-configured
|
||||
languages are readable in the management API so unfinished translations can be completed. Aliases are
|
||||
not accepted.
|
||||
responses:
|
||||
"200":
|
||||
description: Survey retrieved successfully
|
||||
@@ -283,8 +291,8 @@ paths:
|
||||
hiddenFields:
|
||||
enabled: false
|
||||
variables: []
|
||||
localized:
|
||||
summary: Localized projection with ?lang=en-US
|
||||
filtered:
|
||||
summary: Language-filtered projection with ?lang=en-US,de-DE
|
||||
value:
|
||||
data:
|
||||
id: clseedsurveycsat000000
|
||||
@@ -296,7 +304,6 @@ paths:
|
||||
status: inProgress
|
||||
metadata: {}
|
||||
defaultLanguage: en-US
|
||||
language: en-US
|
||||
languages:
|
||||
- code: en-US
|
||||
default: true
|
||||
@@ -314,7 +321,9 @@ paths:
|
||||
type: rating
|
||||
range: 5
|
||||
scale: smiley
|
||||
headline: How satisfied are you with our product?
|
||||
headline:
|
||||
en-US: How satisfied are you with our product?
|
||||
de-DE: Wie zufrieden sind Sie mit unserem Produkt?
|
||||
required: true
|
||||
endings: []
|
||||
hiddenFields:
|
||||
@@ -460,16 +469,13 @@ components:
|
||||
enabled: { type: boolean }
|
||||
isEncrypted: { type: boolean }
|
||||
TranslatableText:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: Localized projection string returned when `?lang=` is used.
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TranslatableTextMap"
|
||||
description: |
|
||||
Survey authoring text. Default `GET /api/v3/surveys/{surveyId}` returns locale maps keyed by
|
||||
real locale codes such as `en-US` and `de-DE`. Localized reads with `?lang=` return strings.
|
||||
Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns locale maps keyed by
|
||||
real locale codes such as `en-US` and `de-DE`. Use `?lang=` to filter which locale keys are included.
|
||||
The internal storage key `default` is never exposed by v3.
|
||||
examples:
|
||||
- What should we improve?
|
||||
- en-US: What should we improve?
|
||||
de-DE: Was sollten wir verbessern?
|
||||
TranslatableTextMap:
|
||||
@@ -1130,9 +1136,6 @@ components:
|
||||
defaultLanguage:
|
||||
type: string
|
||||
description: Real locale code for the survey default language. The internal `default` translation key is never exposed.
|
||||
language:
|
||||
type: string
|
||||
description: Present only on localized `?lang=` responses.
|
||||
languages:
|
||||
type: array
|
||||
items:
|
||||
|
||||
Reference in New Issue
Block a user