mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Fix UI freeze on missing translation params (#10735)
* Fix UI freeze on missing translation params Signed-off-by: Artem Savchenko <armisav@gmail.com> * Revert redundant changes Signed-off-by: Artem Savchenko <armisav@gmail.com> * Clean up Signed-off-by: Artem Savchenko <armisav@gmail.com> --------- Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -15,12 +15,22 @@
|
||||
//
|
||||
|
||||
import type { Plugin, IntlString } from '../platform'
|
||||
import platform, { plugin } from '../platform'
|
||||
import platform, { getEmbeddedLabel, plugin } from '../platform'
|
||||
import { Severity, Status } from '../status'
|
||||
|
||||
import { addStringsLoader, translate } from '../i18n'
|
||||
import { addStringsLoader, loadPluginStrings, translate, translateCB } from '../i18n'
|
||||
import { addEventListener, PlatformEvent, removeEventListener } from '../event'
|
||||
|
||||
function translateCBAsync (
|
||||
message: IntlString,
|
||||
params: Record<string, any>,
|
||||
language: string | undefined
|
||||
): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
translateCB(message, params, language, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
const testId = 'test-strings' as Plugin
|
||||
|
||||
const test = plugin(testId, {
|
||||
@@ -112,4 +122,93 @@ describe('i18n', () => {
|
||||
expect(translated).toBe(message)
|
||||
removeEventListener(PlatformEvent, eventListener)
|
||||
})
|
||||
|
||||
it('translateCB should match translate for loaded string', async () => {
|
||||
const fromTranslate = await translate(test.string.loadingPlugin, { plugin: 'cb' })
|
||||
const fromCB = await translateCBAsync(test.string.loadingPlugin, { plugin: 'cb' }, 'en')
|
||||
expect(fromCB).toBe(fromTranslate)
|
||||
})
|
||||
|
||||
it('translate and translateCB should return embedded label text', async () => {
|
||||
const embedded = getEmbeddedLabel('Embedded copy')
|
||||
expect(await translate(embedded, {})).toBe('Embedded copy')
|
||||
expect(await translateCBAsync(embedded, {}, 'en')).toBe('Embedded copy')
|
||||
})
|
||||
|
||||
it('loadPluginStrings(force) should clear format cache so strings still resolve', async () => {
|
||||
await translate(test.string.loadingPlugin, { plugin: 'before' })
|
||||
await loadPluginStrings('en', true)
|
||||
const after = await translate(test.string.loadingPlugin, { plugin: 'after' })
|
||||
expect(after).toContain('after')
|
||||
})
|
||||
|
||||
it('translate with skipError should not broadcast platform status (no loader)', async () => {
|
||||
const pluginId = 'plugin-skip-error-no-loader'
|
||||
const message = `${pluginId}:string:any` as IntlString
|
||||
let events = 0
|
||||
const listener = async (): Promise<void> => {
|
||||
events++
|
||||
}
|
||||
addEventListener(PlatformEvent, listener)
|
||||
await translate(message, {}, 'en', true)
|
||||
removeEventListener(PlatformEvent, listener)
|
||||
expect(events).toBe(0)
|
||||
})
|
||||
|
||||
it('translate should return message id and emit status when ICU format params are missing', async () => {
|
||||
const fmtPlugin = 'i18n-icu-format-test' as Plugin
|
||||
addStringsLoader(fmtPlugin, async () => ({
|
||||
string: {
|
||||
badPlural: '{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}}'
|
||||
}
|
||||
}))
|
||||
const message = `${fmtPlugin}:string:badPlural` as IntlString
|
||||
expect.assertions(2)
|
||||
let gotStatus = false
|
||||
const listener = async (_event: string, data: any): Promise<void> => {
|
||||
if (data instanceof Status) {
|
||||
gotStatus = true
|
||||
}
|
||||
}
|
||||
addEventListener(PlatformEvent, listener)
|
||||
const out = await translate(message, { days: 1, hours: 2 } as any, 'en')
|
||||
removeEventListener(PlatformEvent, listener)
|
||||
expect(out).toBe(message)
|
||||
expect(gotStatus).toBe(true)
|
||||
})
|
||||
|
||||
it('translateCB should resolve to message id when ICU format params are missing', async () => {
|
||||
const fmtPlugin = 'i18n-icu-format-test-cb' as Plugin
|
||||
addStringsLoader(fmtPlugin, async () => ({
|
||||
string: {
|
||||
badPlural: '{dias, plural, =0 {} other {#d}}'
|
||||
}
|
||||
}))
|
||||
const message = `${fmtPlugin}:string:badPlural` as IntlString
|
||||
expect.assertions(2)
|
||||
let gotStatus = false
|
||||
const listener = async (_event: string, data: any): Promise<void> => {
|
||||
if (data instanceof Status) {
|
||||
gotStatus = true
|
||||
}
|
||||
}
|
||||
addEventListener(PlatformEvent, listener)
|
||||
const out = await translateCBAsync(message, { days: 1 }, 'en')
|
||||
removeEventListener(PlatformEvent, listener)
|
||||
expect(out).toBe(message)
|
||||
expect(gotStatus).toBe(true)
|
||||
})
|
||||
|
||||
it('translateCB should defer to translate when translation is not yet cached', async () => {
|
||||
const deferPlugin = 'i18n-defer-translate' as Plugin
|
||||
const deferMsg = `${deferPlugin}:string:only` as IntlString
|
||||
addStringsLoader(deferPlugin, async (locale: string) => {
|
||||
if (locale === 'en') {
|
||||
return { string: { only: 'Deferred {v}' } }
|
||||
}
|
||||
return { string: { only: 'Deferred {v}' } }
|
||||
})
|
||||
const out = await translateCBAsync(deferMsg, { v: 'ok' }, 'en')
|
||||
expect(out).toBe('Deferred ok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,6 +70,28 @@ async function setStatus (status: Status, skipError?: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify platform and return a Status for load/resolve paths that do not use the per-message format cache. */
|
||||
async function pipelineErrorToStatus (err: unknown, skipError?: boolean): Promise<Status> {
|
||||
const status = unknownError(err)
|
||||
await setStatus(status, skipError)
|
||||
return status
|
||||
}
|
||||
|
||||
/**
|
||||
* On compile/resolve failure: cache failure for this intl id, notify platform, return `message` as UI fallback.
|
||||
*/
|
||||
async function handleIntlPipelineFailure (
|
||||
err: unknown,
|
||||
message: IntlString,
|
||||
localeCache: Map<IntlString, IntlMessageFormat | Status>,
|
||||
skipError?: boolean
|
||||
): Promise<IntlString> {
|
||||
const status = unknownError(err)
|
||||
localeCache.set(message, status)
|
||||
await setStatus(status, skipError)
|
||||
return message
|
||||
}
|
||||
|
||||
async function loadTranslationsForComponent (
|
||||
plugin: Plugin,
|
||||
locale: string,
|
||||
@@ -88,9 +110,7 @@ async function loadTranslationsForComponent (
|
||||
try {
|
||||
return (await loader('en')) as Record<string, IntlString> | Status
|
||||
} catch (err: any) {
|
||||
const status = unknownError(err)
|
||||
await setStatus(status, skipError)
|
||||
return status
|
||||
return await pipelineErrorToStatus(err, skipError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,9 +170,7 @@ async function getTranslation (
|
||||
return messages[id.name] as IntlString
|
||||
}
|
||||
} catch (err) {
|
||||
const status = unknownError(err)
|
||||
await setStatus(status, skipError)
|
||||
return status
|
||||
return await pipelineErrorToStatus(err, skipError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +191,7 @@ export async function translate<P extends Record<string, any>> (
|
||||
if (!cache.has(locale)) {
|
||||
cache.set(locale, localCache)
|
||||
}
|
||||
try {
|
||||
const compiled = localCache.get(message)
|
||||
|
||||
if (compiled !== undefined) {
|
||||
@@ -180,8 +199,7 @@ export async function translate<P extends Record<string, any>> (
|
||||
return message
|
||||
}
|
||||
return compiled.format(params)
|
||||
} else {
|
||||
try {
|
||||
}
|
||||
const id = _parseId(message)
|
||||
if (id.component === _EmbeddedId) {
|
||||
return id.name
|
||||
@@ -191,15 +209,11 @@ export async function translate<P extends Record<string, any>> (
|
||||
localCache.set(message, translation)
|
||||
return message
|
||||
}
|
||||
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
|
||||
localCache.set(message, compiled)
|
||||
return compiled.format(params)
|
||||
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
|
||||
localCache.set(message, compiledNew)
|
||||
return compiledNew.format(params)
|
||||
} catch (err) {
|
||||
const status = unknownError(err)
|
||||
await setStatus(status, skipError)
|
||||
localCache.set(message, status)
|
||||
return message
|
||||
}
|
||||
return await handleIntlPipelineFailure(err, message, localCache, skipError)
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -217,6 +231,7 @@ export function translateCB<P extends Record<string, any>> (
|
||||
if (!cache.has(locale)) {
|
||||
cache.set(locale, localCache)
|
||||
}
|
||||
try {
|
||||
const compiled = localCache.get(message)
|
||||
|
||||
if (compiled !== undefined) {
|
||||
@@ -234,29 +249,26 @@ export function translateCB<P extends Record<string, any>> (
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
const status = unknownError(err)
|
||||
void setStatus(status, skipError)
|
||||
localCache.set(message, status)
|
||||
resolve(message)
|
||||
void handleIntlPipelineFailure(err, message, localCache, skipError)
|
||||
return
|
||||
}
|
||||
const translation = getCachedTranslation(id, locale)
|
||||
if (translation === undefined || translation instanceof Status) {
|
||||
void translate(message, params, language)
|
||||
void translate(message, params, language, skipError)
|
||||
.then((res) => {
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
const status = unknownError(err)
|
||||
void setStatus(status, skipError)
|
||||
localCache.set(message, status)
|
||||
resolve(message)
|
||||
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
|
||||
localCache.set(message, compiled)
|
||||
resolve(compiled.format(params))
|
||||
const compiledNew = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
|
||||
localCache.set(message, compiledNew)
|
||||
resolve(compiledNew.format(params))
|
||||
}
|
||||
} catch (err) {
|
||||
void handleIntlPipelineFailure(err, message, localCache, skipError).then(resolve)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"Scheduled": "Programado",
|
||||
"Schedule": "Programa",
|
||||
"WithoutProject": "Sin proyecto",
|
||||
"TotalGroupTime": "{días, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
|
||||
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
|
||||
"Tasks": "Tareas",
|
||||
"WorkSlot": "Intervalo de trabajo",
|
||||
"WorkItem": "Elemento de trabajo",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"Scheduled": "Agendado",
|
||||
"Schedule": "Agenda",
|
||||
"WithoutProject": "Sem projeto",
|
||||
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
|
||||
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
|
||||
"Tasks": "Tarefas",
|
||||
"WorkSlot": "Intervalo de trabalho",
|
||||
"WorkItem": "Item de trabalho",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"Scheduled": "Agendado",
|
||||
"Schedule": "Agenda",
|
||||
"WithoutProject": "Sem projeto",
|
||||
"TotalGroupTime": "{dias, plural, =0 {} other {#d}} {horas, plural, =0 {} other {#h}} {minutos, plural, =0 {} other {#m}}",
|
||||
"TotalGroupTime": "{days, plural, =0 {} other {#d}} {hours, plural, =0 {} other {#h}} {minutes, plural, =0 {} other {#m}}",
|
||||
"Tasks": "Tarefas",
|
||||
"WorkSlot": "Intervalo de trabalho",
|
||||
"WorkItem": "Item de trabalho",
|
||||
|
||||
Reference in New Issue
Block a user