Localization: Adds termOrDefault() method to accept a fallback value (#20947)
* feat: adds `termOrDefault` to be able to safely fall back to a value if the translation does not exist * feat: accepts 'null' as fallback * feat: uses 'termOrDefault' to do a safe null-check and uses 'willUpdate' to contain number of re-renders * feat: uses null-check to determine if key is set * chore: accidental rename of variable * uses `when()` to evaluate * revert commits * fix: improves the fallback mechanism * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -318,6 +318,74 @@ describe('UmbLocalizationController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('termOrDefault', () => {
|
||||
it('should return the translation when the key exists', () => {
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
|
||||
expect(controller.termOrDefault('logout', 'Sign out')).to.equal('Log out');
|
||||
});
|
||||
|
||||
it('should return the default value when the key does not exist', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((controller.termOrDefault as any)('nonExistentKey', 'Default Value')).to.equal('Default Value');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((controller.termOrDefault as any)('anotherMissingKey', 'Fallback')).to.equal('Fallback');
|
||||
});
|
||||
|
||||
it('should work with function-based translations and arguments', () => {
|
||||
expect(controller.termOrDefault('numUsersSelected', 'No selection', 0)).to.equal('No users selected');
|
||||
expect(controller.termOrDefault('numUsersSelected', 'No selection', 1)).to.equal('One user selected');
|
||||
expect(controller.termOrDefault('numUsersSelected', 'No selection', 5)).to.equal('5 users selected');
|
||||
});
|
||||
|
||||
it('should work with string-based translations and placeholder arguments', () => {
|
||||
expect(controller.termOrDefault('withInlineToken', 'N/A', 'Hello', 'World')).to.equal('Hello World');
|
||||
expect(controller.termOrDefault('withInlineTokenLegacy', 'N/A', 'Foo', 'Bar')).to.equal('Foo Bar');
|
||||
});
|
||||
|
||||
it('should use default value for missing key even with arguments', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((controller.termOrDefault as any)('missingKey', 'Default', 'arg1', 'arg2')).to.equal('Default');
|
||||
});
|
||||
|
||||
it('should handle the three-tier fallback before using defaultValue', async () => {
|
||||
// Switch to Danish regional
|
||||
document.documentElement.lang = danishRegional.$code;
|
||||
await aTimeout(0);
|
||||
|
||||
// Primary (da-dk) has 'close'
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Luk');
|
||||
|
||||
// Secondary (da) has 'notOnRegional', not on da-dk
|
||||
expect(controller.termOrDefault('notOnRegional', 'Not found')).to.equal('Not on regional');
|
||||
|
||||
// Fallback (en) has 'logout', not on da-dk or da
|
||||
expect(controller.termOrDefault('logout', 'Sign out')).to.equal('Log out');
|
||||
|
||||
// Non-existent key should use default
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((controller.termOrDefault as any)('completelyMissing', 'Fallback Value')).to.equal('Fallback Value');
|
||||
});
|
||||
|
||||
it('should update when language changes', async () => {
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
|
||||
|
||||
// Switch to Danish
|
||||
document.documentElement.lang = danishRegional.$code;
|
||||
await aTimeout(0);
|
||||
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Luk');
|
||||
});
|
||||
|
||||
it('should override a term if new localization is registered', () => {
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
|
||||
|
||||
// Register override
|
||||
umbLocalizationManager.registerLocalization(englishOverride);
|
||||
|
||||
expect(controller.termOrDefault('close', 'X')).to.equal('Close 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('string', () => {
|
||||
it('should replace words prefixed with a # with translated value', async () => {
|
||||
const str = '#close';
|
||||
|
||||
@@ -109,10 +109,62 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
|
||||
return { locale, language, region, primary, secondary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a localization entry for the given key.
|
||||
* Searches in order: primary (regional) → secondary (language) → fallback (en).
|
||||
* Also tracks the key usage for reactive updates.
|
||||
* @param {string} key - the localization key to look up.
|
||||
* @returns {any} - the localization entry (string or function), or null if not found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#lookupTerm<K extends keyof LocalizationSetType>(key: K): any {
|
||||
if (!this.#usedKeys.includes(key)) {
|
||||
this.#usedKeys.push(key);
|
||||
}
|
||||
|
||||
const { primary, secondary } = this.#getLocalizationData(this.lang());
|
||||
|
||||
// Look for a matching term using regionCode, code, then the fallback
|
||||
if (primary?.[key]) {
|
||||
return primary[key];
|
||||
} else if (secondary?.[key]) {
|
||||
return secondary[key];
|
||||
} else if (umbLocalizationManager.fallback?.[key]) {
|
||||
return umbLocalizationManager.fallback[key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a localization entry (string or function) with the provided arguments.
|
||||
* @param {any} term - the localization entry to process.
|
||||
* @param {unknown[]} args - the arguments to apply to the term.
|
||||
* @returns {string} - the processed term as a string.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#processTerm(term: any, args: unknown[]): string {
|
||||
if (typeof term === 'function') {
|
||||
return term(...args) as string;
|
||||
}
|
||||
|
||||
if (typeof term === 'string') {
|
||||
if (args.length) {
|
||||
// Replace placeholders of format "%index%" and "{index}" with provided values
|
||||
return term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
|
||||
const index = p2 || p3;
|
||||
return typeof args[index] !== 'undefined' ? String(args[index]) : match;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return String(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs a translated term.
|
||||
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
|
||||
* @param {...any} args - the arguments to parse for this localization entry.
|
||||
* @param {unknown[]} args - the arguments to parse for this localization entry.
|
||||
* @returns {string} - the translated term as a string.
|
||||
* @example
|
||||
* Retrieving a term without any arguments:
|
||||
@@ -125,41 +177,49 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
|
||||
* ```
|
||||
*/
|
||||
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
|
||||
if (!this.#usedKeys.includes(key)) {
|
||||
this.#usedKeys.push(key);
|
||||
}
|
||||
const term = this.#lookupTerm(key);
|
||||
|
||||
const { primary, secondary } = this.#getLocalizationData(this.lang());
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let term: any;
|
||||
|
||||
// Look for a matching term using regionCode, code, then the fallback
|
||||
if (primary?.[key]) {
|
||||
term = primary[key];
|
||||
} else if (secondary?.[key]) {
|
||||
term = secondary[key];
|
||||
} else if (umbLocalizationManager.fallback?.[key]) {
|
||||
term = umbLocalizationManager.fallback[key];
|
||||
} else {
|
||||
if (term === null) {
|
||||
return String(key);
|
||||
}
|
||||
|
||||
if (typeof term === 'function') {
|
||||
return term(...args) as string;
|
||||
return this.#processTerm(term, args);
|
||||
}
|
||||
|
||||
if (typeof term === 'string') {
|
||||
if (args.length) {
|
||||
// Replace placeholders of format "%index%" and "{index}" with provided values
|
||||
term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
|
||||
const index = p2 || p3;
|
||||
return typeof args[index] !== 'undefined' ? String(args[index]) : match;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Returns the localized term for the given key, or the default value if not found.
|
||||
* This method follows the same resolution order as term() (primary → secondary → fallback),
|
||||
* but returns the provided defaultValue instead of the key when no translation is found.
|
||||
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
|
||||
* @param {string | null} defaultValue - the value to return if the key is not found in any localization set.
|
||||
* @param {unknown[]} args - the arguments to parse for this localization entry.
|
||||
* @returns {string | null} - the translated term or the default value.
|
||||
* @example
|
||||
* Retrieving a term with fallback:
|
||||
* ```ts
|
||||
* this.localize.termOrDefault('general_close', 'X');
|
||||
* ```
|
||||
* Retrieving a term with fallback and arguments:
|
||||
* ```ts
|
||||
* this.localize.termOrDefault('general_greeting', 'Hello!', userName);
|
||||
* ```
|
||||
* Retrieving a term with null as fallback:
|
||||
* ```ts
|
||||
* this.localize.termOrDefault('general_close', null);
|
||||
* ```
|
||||
*/
|
||||
termOrDefault<K extends keyof LocalizationSetType, D extends string | null>(
|
||||
key: K,
|
||||
defaultValue: D,
|
||||
...args: FunctionParams<LocalizationSetType[K]>
|
||||
): string | D {
|
||||
const term = this.#lookupTerm(key);
|
||||
|
||||
if (term === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return term;
|
||||
return this.#processTerm(term, args);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,10 +315,10 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
|
||||
* If the term is found in the localization set, it will be replaced with the localized term.
|
||||
* If the term is not found, the original term will be returned.
|
||||
* @param {string | undefined} text The text to translate.
|
||||
* @param {...any} args The arguments to parse for this localization entry.
|
||||
* @param {unknown[]} args The arguments to parse for this localization entry.
|
||||
* @returns {string} The translated text.
|
||||
*/
|
||||
string(text: string | undefined, ...args: any): string {
|
||||
string(text: string | undefined, ...args: unknown[]): string {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
@@ -267,16 +327,16 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
|
||||
const regex = /#\w+/g;
|
||||
|
||||
const localizedText = text.replace(regex, (match: string) => {
|
||||
const key = match.slice(1);
|
||||
if (!this.#usedKeys.includes(key)) {
|
||||
this.#usedKeys.push(key);
|
||||
const key = match.slice(1) as keyof LocalizationSetType;
|
||||
|
||||
const term = this.#lookupTerm(key);
|
||||
|
||||
// we didn't find a localized string, so we return the original string with the #
|
||||
if (term === null) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const localized = this.term(key, ...args);
|
||||
// we didn't find a localized string, so we return the original string with the #
|
||||
return localized === key ? match : localized;
|
||||
return this.#processTerm(term, args);
|
||||
});
|
||||
|
||||
return localizedText;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { css, customElement, html, property, state, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
/**
|
||||
@@ -21,16 +21,20 @@ export class UmbLocalizeNumberElement extends UmbLitElement {
|
||||
* @attr
|
||||
* @example options={ style: 'currency', currency: 'EUR' }
|
||||
*/
|
||||
@property()
|
||||
@property({ type: Object })
|
||||
options?: Intl.NumberFormatOptions;
|
||||
|
||||
@state()
|
||||
protected get text(): string {
|
||||
protected get text(): string | null {
|
||||
return this.localize.number(this.number, this.options);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.number ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
|
||||
return when(
|
||||
this.text,
|
||||
(text) => unsafeHTML(text),
|
||||
() => html`<slot></slot>`,
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { css, customElement, html, property, state, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
/**
|
||||
@@ -33,12 +33,16 @@ export class UmbLocalizeRelativeTimeElement extends UmbLitElement {
|
||||
unit: Intl.RelativeTimeFormatUnit = 'seconds';
|
||||
|
||||
@state()
|
||||
protected get text(): string {
|
||||
protected get text(): string | null {
|
||||
return this.localize.relativeTime(this.time, this.unit, this.options);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.time ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
|
||||
return when(
|
||||
this.text,
|
||||
(text) => unsafeHTML(text),
|
||||
() => html`<slot></slot>`,
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
|
||||
@@ -21,30 +21,30 @@ export class UmbLocalizeElement extends UmbLitElement {
|
||||
* The values to forward to the localization function (must be JSON compatible).
|
||||
* @attr
|
||||
* @example args="[1,2,3]"
|
||||
* @type {any[] | undefined}
|
||||
* @type {unknown[] | undefined}
|
||||
*/
|
||||
@property({ type: Array })
|
||||
args?: unknown[];
|
||||
|
||||
/**
|
||||
* If true, the key will be rendered instead of the localized value if the key is not found.
|
||||
* If true, the key will be rendered instead of the fallback value if the key is not found.
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
debug = false;
|
||||
|
||||
@state()
|
||||
protected get text(): string {
|
||||
protected get text(): string | null {
|
||||
// As translated texts can contain HTML, we will need to render with unsafeHTML.
|
||||
// But arguments can come from user input, so they should be escaped.
|
||||
const escapedArgs = (this.args ?? []).map((a) => escapeHTML(a));
|
||||
|
||||
const localizedValue = this.localize.term(this.key, ...escapedArgs);
|
||||
const localizedValue = this.localize.termOrDefault(this.key, null, ...escapedArgs);
|
||||
|
||||
// If the value is the same as the key, it means the key was not found.
|
||||
if (localizedValue === this.key) {
|
||||
// Update the data attribute based on whether the key was found
|
||||
if (localizedValue === null) {
|
||||
(this.getHostElement() as HTMLElement).setAttribute('data-localize-missing', this.key);
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
(this.getHostElement() as HTMLElement).removeAttribute('data-localize-missing');
|
||||
|
||||
@@ -82,11 +82,7 @@ export class UmbDocumentNotificationsModalElement extends UmbModalBaseElement<
|
||||
(setting) => setting.actionId,
|
||||
(setting) => {
|
||||
const localizationKey = `actions_${setting.alias}`;
|
||||
let localization = this.localize.term(localizationKey);
|
||||
if (localization === localizationKey) {
|
||||
// Fallback to alias if no localization is found
|
||||
localization = setting.alias;
|
||||
}
|
||||
const localization = this.localize.termOrDefault(localizationKey, setting.alias);
|
||||
return html`<uui-toggle
|
||||
id=${setting.actionId}
|
||||
@change=${() => this.#updateSubscription(setting.actionId)}
|
||||
|
||||
Reference in New Issue
Block a user