diff --git a/packages/ui-core/src/index.tsx b/packages/ui-core/src/index.tsx index 77a60acae5c..bf1a0aa4dce 100644 --- a/packages/ui-core/src/index.tsx +++ b/packages/ui-core/src/index.tsx @@ -25,7 +25,11 @@ export {isNumber, isString, isArray, isNull, isUndefined} from "./utils/typechec export {downloadBlob, downloadUrl} from "./services/downloadBlob" export {shuffle, splitList, keyBy} from "./utils/array" export {normalizeWriteInText} from "./services/normalizeWriteInText" -export {translate, translateFromPresentation} from "./services/translate" +export { + isTranslatablePresentation, + translate, + translateFromPresentation, +} from "./services/translate" export * from "./types/ElectionEventPresentation" export * from "./services/percentFormatter" export * from "./services/wasm" diff --git a/packages/ui-core/src/services/translate.test.ts b/packages/ui-core/src/services/translate.test.ts new file mode 100644 index 00000000000..4d45d73e946 --- /dev/null +++ b/packages/ui-core/src/services/translate.test.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {isTranslatablePresentation, translateFromPresentation} from "./translate" + +const presentation = { + i18n: { + en: {alias: "english-alias", name: "Election", description: null}, + fr: {name: "French election"}, + cat: {name: "Catalan election"}, + }, + language_conf: { + default_language_code: "en", + }, +} + +describe("translateFromPresentation", () => { + it("normalizes regional user locales to internal language codes", () => { + expect(translateFromPresentation({presentation}, "name", "en-GB")).toBe("Election") + expect(translateFromPresentation({presentation}, "name", "EN-GB")).toBe("Election") + expect(translateFromPresentation({presentation}, "name", "fr-CA")).toBe("French election") + expect(translateFromPresentation({presentation}, "name", "cat")).toBe("Catalan election") + }) + + it("falls through null, undefined, and empty translations", () => { + const presentationWithMissingValues = { + i18n: { + en: {name: "", description: null, alias: undefined}, + fr: {name: "French fallback", description: "", alias: null}, + }, + } + + expect( + translateFromPresentation(presentationWithMissingValues, "name", "en", { + defaultLanguageCode: "fr", + }) + ).toBe("French fallback") + expect( + translateFromPresentation(presentationWithMissingValues, "description", "en", { + defaultLanguageCode: "fr", + }) + ).toBeUndefined() + expect( + translateFromPresentation(presentationWithMissingValues, "alias", "en", { + defaultLanguageCode: "fr", + }) + ).toBeUndefined() + expect( + translateFromPresentation( + {name: "Legacy fallback", presentation: presentationWithMissingValues}, + "name", + "en" + ) + ).toBe("Legacy fallback") + }) + + it("uses an explicitly requested default language", () => { + expect( + translateFromPresentation({presentation}, "name", "de-DE", { + defaultLanguageCode: "en-GB", + }) + ).toBe("Election") + }) + + it("ignores malformed unrelated translations when the requested value is valid", () => { + const malformedPresentation = { + i18n: { + en: {name: "Election X", sort_hint: 2}, + }, + } as unknown as Parameters[0] + + expect(isTranslatablePresentation(malformedPresentation)).toBe(false) + expect(translateFromPresentation(malformedPresentation, "name", "en")).toBe("Election X") + }) + + it("does not assume that every presentation owns the fallback policy", () => { + expect(translateFromPresentation(presentation, "name", "de-DE")).toBeUndefined() + }) + + it("preserves user-language alias and name ordering", () => { + expect(translateFromPresentation({presentation}, "alias", "fr")).toBeUndefined() + expect(translateFromPresentation({presentation}, "name", "fr")).toBe("French election") + }) + + it("prefers a direct presentation over a nested presentation", () => { + expect( + translateFromPresentation( + { + i18n: {en: {name: "Direct election"}}, + presentation: {i18n: {en: {name: "Nested election"}}}, + }, + "name", + "en" + ) + ).toBe("Direct election") + }) + + it("supports receiving the presentation object directly", () => { + expect( + translateFromPresentation(presentation, "name", "de-DE", { + defaultLanguageCode: "en", + }) + ).toBe("Election") + }) + + it("preserves the entity value fallback used by legacy callers", () => { + expect( + translateFromPresentation({name: "Legacy election", presentation}, "name", "de-DE") + ).toBe("Legacy election") + }) + + it("prefers a default-language translation over an entity legacy value", () => { + expect( + translateFromPresentation({name: "Legacy election", presentation}, "name", "de-DE", { + defaultLanguageCode: "en", + }) + ).toBe("Election") + }) + + it("returns undefined when neither translation nor legacy value exists", () => { + expect( + translateFromPresentation( + { + presentation: { + i18n: {fr: {name: "French election"}}, + }, + }, + "name", + "de-DE", + {defaultLanguageCode: "en"} + ) + ).toBeUndefined() + }) + + it("preserves the legacy value when no presentation translations exist", () => { + expect(translateFromPresentation({name: "Election"}, "name", "en-GB")).toBe("Election") + }) +}) diff --git a/packages/ui-core/src/services/translate.ts b/packages/ui-core/src/services/translate.ts index b41244a8d2e..05077dc7a86 100644 --- a/packages/ui-core/src/services/translate.ts +++ b/packages/ui-core/src/services/translate.ts @@ -21,17 +21,111 @@ export const translate = ( return input[key] as string } -export const translateFromPresentation = ( - object: any, - key: string, - lang: string +type TranslationValue = string | null | undefined + +interface TranslatablePresentation { + i18n?: Record> +} + +type TranslatableEntity = Partial< + Record +> & { + presentation?: TranslatablePresentation | null +} + +type TranslationInput = + | TranslatablePresentation + | TranslatableEntity + | null + | undefined + +interface TranslateFromPresentationOptions { + defaultLanguageCode?: string +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const isTranslatablePresentation = (value: unknown): value is TranslatablePresentation => { + if (!isRecord(value)) { + return false + } + + if (value.i18n === undefined) { + return true + } + + if (!isRecord(value.i18n)) { + return false + } + + return Object.values(value.i18n).every( + (translations) => + isRecord(translations) && + Object.values(translations).every( + (translation) => translation == null || typeof translation === "string" + ) + ) +} + +const getPresentation = ( + object: TranslationInput +): TranslatablePresentation | undefined => { + if (!isRecord(object)) { + return undefined + } + + const value = + "i18n" in object && object.i18n + ? object + : "presentation" in object + ? object.presentation + : object + return isRecord(value) ? (value as TranslatablePresentation) : undefined +} + +const getPrimaryLanguageCode = (lang?: string): string | undefined => + lang?.split("-")[0].toLowerCase() + +const getTranslatedValue = ( + presentation: TranslatablePresentation | undefined, + language: string | undefined, + key: string ): string | undefined => { - if (object?.["i18n"]) { - return object["i18n"][lang]?.[key] || undefined + if (!language) { + return undefined } - if (object?.["presentation"]?.["i18n"]) { - return object["presentation"]["i18n"][lang]?.[key] || object[key] || undefined - } else { - return object?.[key] || undefined + + const value = presentation?.i18n?.[language]?.[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +export const translateFromPresentation = ( + object: TranslationInput, + key: K, + lang: string, + options: TranslateFromPresentationOptions = {} +): string | undefined => { + const presentation = getPresentation(object) + const userLanguage = getPrimaryLanguageCode(lang) + const defaultLanguage = getPrimaryLanguageCode(options.defaultLanguageCode) + + const translatedValue = + getTranslatedValue(presentation, userLanguage, key) || + getTranslatedValue(presentation, defaultLanguage, key) + + if (translatedValue) { + return translatedValue + } + + if (isRecord(object) && "i18n" in object && Boolean(object.i18n)) { + return undefined + } + + if (!isRecord(object)) { + return undefined } + + const legacyValue = object[key] + return typeof legacyValue === "string" && legacyValue.length ? legacyValue : undefined } diff --git a/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx b/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx index 63bdb352dae..e776392dc56 100644 --- a/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx +++ b/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx @@ -134,6 +134,9 @@ const ElectionWrapper: React.FC = ({ throw new VotingPortalError(VotingPortalErrorType.INTERNAL_ERROR) } + const defaultLanguageCode = + election.presentation?.language_conf?.default_language_code ?? + electionEvent?.presentation?.language_conf?.default_language_code const electionStatus = election?.status as IElectionStatus | null const isVotingOpen = () => { let isOnlineVotingOpen: boolean = @@ -221,7 +224,11 @@ const ElectionWrapper: React.FC = ({ 0} onClickToVote={canVote() ? onClickToVote : undefined} onClickBallotLocator={handleClickBallotLocator} @@ -271,6 +278,8 @@ const ElectionSelectionScreen: React.FC = () => { useContext(SettingsContext) const {eventId, tenantId} = useParams<{eventId?: string; tenantId?: string}>() const electionEvent = useAppSelector(selectElectionEventById(eventId)) + const eventDefaultLanguageCode = + electionEvent?.presentation?.language_conf?.default_language_code const oneBallotStyle = useAppSelector(selectFirstBallotStyle) useUpdateTranslation({electionEvent}, defaultLanguageTouched, setDefaultLanguageTouched) // Overwrite translations const ballotStyleElectionIds = useAppSelector(selectBallotStyleElectionIds) @@ -425,7 +434,12 @@ const ElectionSelectionScreen: React.FC = () => { ? translateFromPresentation( election.presentation, "alias", - i18n.language + i18n.language, + { + defaultLanguageCode: + election.presentation.language_conf + ?.default_language_code ?? eventDefaultLanguageCode, + } ) : undefined, }) @@ -434,7 +448,11 @@ const ElectionSelectionScreen: React.FC = () => { let foundTestElection = dataElections.sequent_backend_election.find((election) => { const name = election.presentation - ? translateFromPresentation(election.presentation, "name", i18n.language) + ? translateFromPresentation(election.presentation, "name", i18n.language, { + defaultLanguageCode: + election.presentation.language_conf?.default_language_code ?? + eventDefaultLanguageCode, + }) : undefined return name?.includes("TEST") ?? false }) @@ -445,7 +463,7 @@ const ElectionSelectionScreen: React.FC = () => { setTestElectionId(foundTestElection?.id || null) } - }, [dataElections, dispatch, i18n.language]) + }, [dataElections, dispatch, eventDefaultLanguageCode, i18n.language]) useEffect(() => { if (!testElectionId) { diff --git a/packages/voting-portal/src/routes/StartScreen.tsx b/packages/voting-portal/src/routes/StartScreen.tsx index 009dac10cfd..9f2e70163fc 100644 --- a/packages/voting-portal/src/routes/StartScreen.tsx +++ b/packages/voting-portal/src/routes/StartScreen.tsx @@ -166,6 +166,10 @@ const StartScreen: React.FC = () => { : election }, [election, electionEvent]) + const defaultLanguageCode = + titleObject?.presentation?.language_conf?.default_language_code ?? + electionEvent?.presentation?.language_conf?.default_language_code + useEffect(() => { if (!election || !titleObject) { navigate(backLink) @@ -195,12 +199,18 @@ const StartScreen: React.FC = () => { - {translateFromPresentation(titleObject, "name", i18n.language) ?? "-"} + + {translateFromPresentation(titleObject, "name", i18n.language, { + defaultLanguageCode, + }) ?? "-"} + {titleObject.description ? ( {stringToHtml( - translateFromPresentation(titleObject, "description", i18n.language) ?? "-" + translateFromPresentation(titleObject, "description", i18n.language, { + defaultLanguageCode, + }) ?? "-" )} ) : null} diff --git a/packages/voting-portal/src/routes/SupportMaterialsScreen.tsx b/packages/voting-portal/src/routes/SupportMaterialsScreen.tsx index 4ec15781ff6..33db34b4f5e 100644 --- a/packages/voting-portal/src/routes/SupportMaterialsScreen.tsx +++ b/packages/voting-portal/src/routes/SupportMaterialsScreen.tsx @@ -102,6 +102,7 @@ const SupportMaterialsScreen: React.FC = () => { }, [materials]) const [materialsTitles, setMaterialsTitles] = useState() + const defaultLanguageCode = materialsTitles?.presentation?.language_conf?.default_language_code useEffect(() => { if (electionEvent) { @@ -134,7 +135,8 @@ const SupportMaterialsScreen: React.FC = () => { (translateFromPresentation( materialsTitles, "materialsTitle", - i18n.language + i18n.language, + {defaultLanguageCode} ) ?? "-")} @@ -145,7 +147,8 @@ const SupportMaterialsScreen: React.FC = () => { ? (translateFromPresentation( materialsTitles, "materialsSubtitle", - i18n.language + i18n.language, + {defaultLanguageCode} ) ?? "-") : "" )} diff --git a/packages/voting-portal/src/routes/VotingScreen.tsx b/packages/voting-portal/src/routes/VotingScreen.tsx index 167c0eb474c..908448a5cd3 100644 --- a/packages/voting-portal/src/routes/VotingScreen.tsx +++ b/packages/voting-portal/src/routes/VotingScreen.tsx @@ -311,6 +311,9 @@ const VotingScreen: React.FC = () => { } = provideBallotService() const election = useAppSelector(selectElectionById(String(electionId))) const ballotStyle = useAppSelector(selectBallotStyleByElectionId(String(electionId))) + const defaultLanguageCode = + election?.presentation?.language_conf?.default_language_code ?? + ballotStyle?.ballot_eml.election_event_presentation?.language_conf?.default_language_code const selectionState = useAppSelector( selectBallotSelectionByElectionId(ballotStyle?.election_id ?? "") @@ -487,7 +490,9 @@ const VotingScreen: React.FC = () => { - {translateFromPresentation(election, "name", i18n.language) ?? "-"} + {translateFromPresentation(election, "name", i18n.language, { + defaultLanguageCode, + }) ?? "-"} { sx={{color: theme.palette.customGrey.main}} > {stringToHtml( - translateFromPresentation(election, "description", i18n.language) ?? "-" + translateFromPresentation(election, "description", i18n.language, { + defaultLanguageCode, + }) ?? "-" )} ) : null}