Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/ui-core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
139 changes: 139 additions & 0 deletions packages/ui-core/src/services/translate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
//
// 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<typeof translateFromPresentation>[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")
})
})
114 changes: 104 additions & 10 deletions packages/ui-core/src/services/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,111 @@ export const translate = <T, K extends keyof T>(
return input[key] as string
}

export const translateFromPresentation = (
object: any,
key: string,
lang: string
type TranslationValue = string | null | undefined

interface TranslatablePresentation {
i18n?: Record<string, Record<string, TranslationValue>>
}

type TranslatableEntity<K extends string = string> = Partial<
Record<K, string | null | undefined>
> & {
presentation?: TranslatablePresentation | null
}

type TranslationInput<K extends string = string> =
| TranslatablePresentation
| TranslatableEntity<K>
| null
| undefined

interface TranslateFromPresentationOptions {
defaultLanguageCode?: string
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
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 = <K extends string>(
object: TranslationInput<K>
): 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 = <K extends string>(
object: TranslationInput<K>,
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
}
26 changes: 22 additions & 4 deletions packages/voting-portal/src/routes/ElectionSelectionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ const ElectionWrapper: React.FC<ElectionWrapperProps> = ({
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 =
Expand Down Expand Up @@ -221,7 +224,11 @@ const ElectionWrapper: React.FC<ElectionWrapperProps> = ({
<SelectElection
isActive={canVote()}
isOpen={isVotingOpen()}
title={translateFromPresentation(election, "name", i18n.language) || "-"}
title={
translateFromPresentation(election, "name", i18n.language, {
defaultLanguageCode,
}) || "-"
}
hasVoted={castVotes.length > 0}
onClickToVote={canVote() ? onClickToVote : undefined}
onClickBallotLocator={handleClickBallotLocator}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
})
Expand All @@ -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
})
Expand All @@ -445,7 +463,7 @@ const ElectionSelectionScreen: React.FC = () => {

setTestElectionId(foundTestElection?.id || null)
}
}, [dataElections, dispatch, i18n.language])
}, [dataElections, dispatch, eventDefaultLanguageCode, i18n.language])

useEffect(() => {
if (!testElectionId) {
Expand Down
14 changes: 12 additions & 2 deletions packages/voting-portal/src/routes/StartScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -195,12 +199,18 @@ const StartScreen: React.FC = () => {
<Stepper selected={1} />
</Box>
<StyledTitle variant="h3" justifyContent="center" fontWeight="bold">
<span>{translateFromPresentation(titleObject, "name", i18n.language) ?? "-"}</span>
<span>
{translateFromPresentation(titleObject, "name", i18n.language, {
defaultLanguageCode,
}) ?? "-"}
</span>
</StyledTitle>
{titleObject.description ? (
<Typography variant="body2" sx={{color: theme.palette.customGrey.main}}>
{stringToHtml(
translateFromPresentation(titleObject, "description", i18n.language) ?? "-"
translateFromPresentation(titleObject, "description", i18n.language, {
defaultLanguageCode,
}) ?? "-"
)}
</Typography>
) : null}
Expand Down
Loading
Loading