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
14 changes: 11 additions & 3 deletions packages/ballot-verifier/src/screens/hooks/useElectionClassName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@ export const useElectionClassName = (confirmationBallot: IConfirmationBallot | n
const {i18n} = useTranslation()

const extractElectionName = (election: {presentation: IElectionPresentation; id: string}) => {
let language = i18n.resolvedLanguage || i18n.language
const language = i18n.resolvedLanguage || i18n.language
const defaultLanguageCode =
election.presentation.language_conf?.default_language_code ??
confirmationBallot?.election_config.election_event_presentation?.language_conf
?.default_language_code
return (
translateFromPresentation(election, "alias", language) ||
translateFromPresentation(election, "name", language) ||
translateFromPresentation(election.presentation, "alias", language) ||
translateFromPresentation(election.presentation, "name", language) ||
(defaultLanguageCode
? translateFromPresentation(election.presentation, "alias", defaultLanguageCode) ||
translateFromPresentation(election.presentation, "name", defaultLanguageCode)
: undefined) ||
election.id
)
}
Expand Down
6 changes: 5 additions & 1 deletion packages/ui-core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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/cssClassNameFormatter"
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
}
43 changes: 31 additions & 12 deletions packages/voting-portal/src/hooks/useElectionClassName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,48 @@ import {useCallback, useEffect, useMemo} from "react"
import {useParams} from "react-router"
import {useAppSelector} from "../store/hooks"
import {IElectionExtended, selectElectionById} from "../store/elections/electionsSlice"
import {selectElectionEventById} from "../store/electionEvents/electionEventsSlice"
import {useTranslation} from "react-i18next"
import {ROOT_CLASS_PREFIX, toValidClassName, translateFromPresentation} from "@sequentech/ui-core"

const resolveElectionName = (
election: IElectionExtended,
language: string,
eventDefaultLanguageCode?: string
) => {
const presentation = election.presentation
const defaultLanguageCode =
presentation?.language_conf?.default_language_code ?? eventDefaultLanguageCode

return (
translateFromPresentation(presentation, "alias", language) ||
translateFromPresentation(presentation, "name", language) ||
(defaultLanguageCode
? translateFromPresentation(presentation, "alias", defaultLanguageCode) ||
translateFromPresentation(presentation, "name", defaultLanguageCode)
: undefined) ||
election.alias ||
election.name ||
election.id
)
}

/**
* Manages election class on <html> and provides an election class formatter.
*/
export const useElectionClassName = () => {
const {i18n} = useTranslation()
const {electionId} = useParams<{electionId?: string}>()
const {electionId, eventId} = useParams<{electionId?: string; eventId?: string}>()
const election = useAppSelector(selectElectionById(String(electionId ?? "")))

const extractElectionName = (election: IElectionExtended) => {
let language = i18n.resolvedLanguage || i18n.language
return (
translateFromPresentation(election, "alias", language) ||
translateFromPresentation(election, "name", language) ||
election.id
)
}
const electionEvent = useAppSelector(selectElectionEventById(eventId))
const language = i18n.resolvedLanguage || i18n.language
const eventDefaultLanguageCode =
electionEvent?.presentation?.language_conf?.default_language_code

const getElectionClassName = useCallback(
(e: IElectionExtended) => toValidClassName(extractElectionName(e)),
[extractElectionName]
(e: IElectionExtended) =>
toValidClassName(resolveElectionName(e, language, eventDefaultLanguageCode)),
[eventDefaultLanguageCode, language]
)

const activeElectionClassName = useMemo(() => {
Expand Down
Loading
Loading