Skip to content

Commit c612fcc

Browse files
iOvergaardCopilot
andauthored
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 <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent c885922 commit c612fcc

File tree

6 files changed

+185
-53
lines changed

6 files changed

+185
-53
lines changed

src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,74 @@ describe('UmbLocalizationController', () => {
318318
});
319319
});
320320

321+
describe('termOrDefault', () => {
322+
it('should return the translation when the key exists', () => {
323+
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
324+
expect(controller.termOrDefault('logout', 'Sign out')).to.equal('Log out');
325+
});
326+
327+
it('should return the default value when the key does not exist', () => {
328+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
329+
expect((controller.termOrDefault as any)('nonExistentKey', 'Default Value')).to.equal('Default Value');
330+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
331+
expect((controller.termOrDefault as any)('anotherMissingKey', 'Fallback')).to.equal('Fallback');
332+
});
333+
334+
it('should work with function-based translations and arguments', () => {
335+
expect(controller.termOrDefault('numUsersSelected', 'No selection', 0)).to.equal('No users selected');
336+
expect(controller.termOrDefault('numUsersSelected', 'No selection', 1)).to.equal('One user selected');
337+
expect(controller.termOrDefault('numUsersSelected', 'No selection', 5)).to.equal('5 users selected');
338+
});
339+
340+
it('should work with string-based translations and placeholder arguments', () => {
341+
expect(controller.termOrDefault('withInlineToken', 'N/A', 'Hello', 'World')).to.equal('Hello World');
342+
expect(controller.termOrDefault('withInlineTokenLegacy', 'N/A', 'Foo', 'Bar')).to.equal('Foo Bar');
343+
});
344+
345+
it('should use default value for missing key even with arguments', () => {
346+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
347+
expect((controller.termOrDefault as any)('missingKey', 'Default', 'arg1', 'arg2')).to.equal('Default');
348+
});
349+
350+
it('should handle the three-tier fallback before using defaultValue', async () => {
351+
// Switch to Danish regional
352+
document.documentElement.lang = danishRegional.$code;
353+
await aTimeout(0);
354+
355+
// Primary (da-dk) has 'close'
356+
expect(controller.termOrDefault('close', 'X')).to.equal('Luk');
357+
358+
// Secondary (da) has 'notOnRegional', not on da-dk
359+
expect(controller.termOrDefault('notOnRegional', 'Not found')).to.equal('Not on regional');
360+
361+
// Fallback (en) has 'logout', not on da-dk or da
362+
expect(controller.termOrDefault('logout', 'Sign out')).to.equal('Log out');
363+
364+
// Non-existent key should use default
365+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
366+
expect((controller.termOrDefault as any)('completelyMissing', 'Fallback Value')).to.equal('Fallback Value');
367+
});
368+
369+
it('should update when language changes', async () => {
370+
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
371+
372+
// Switch to Danish
373+
document.documentElement.lang = danishRegional.$code;
374+
await aTimeout(0);
375+
376+
expect(controller.termOrDefault('close', 'X')).to.equal('Luk');
377+
});
378+
379+
it('should override a term if new localization is registered', () => {
380+
expect(controller.termOrDefault('close', 'X')).to.equal('Close');
381+
382+
// Register override
383+
umbLocalizationManager.registerLocalization(englishOverride);
384+
385+
expect(controller.termOrDefault('close', 'X')).to.equal('Close 2');
386+
});
387+
});
388+
321389
describe('string', () => {
322390
it('should replace words prefixed with a # with translated value', async () => {
323391
const str = '#close';

src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts

Lines changed: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -110,56 +110,116 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
110110
}
111111

112112
/**
113-
* Outputs a translated term.
114-
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
115-
* @param {...any} args - the arguments to parse for this localization entry.
116-
* @returns {string} - the translated term as a string.
117-
* @example
118-
* Retrieving a term without any arguments:
119-
* ```ts
120-
* this.localize.term('area_term');
121-
* ```
122-
* Retrieving a term with arguments:
123-
* ```ts
124-
* this.localize.term('general_greeting', ['John']);
125-
* ```
113+
* Looks up a localization entry for the given key.
114+
* Searches in order: primary (regional) → secondary (language) → fallback (en).
115+
* Also tracks the key usage for reactive updates.
116+
* @param {string} key - the localization key to look up.
117+
* @returns {any} - the localization entry (string or function), or null if not found.
126118
*/
127-
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
119+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
120+
#lookupTerm<K extends keyof LocalizationSetType>(key: K): any {
128121
if (!this.#usedKeys.includes(key)) {
129122
this.#usedKeys.push(key);
130123
}
131124

132125
const { primary, secondary } = this.#getLocalizationData(this.lang());
133126

134-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
135-
let term: any;
136-
137127
// Look for a matching term using regionCode, code, then the fallback
138128
if (primary?.[key]) {
139-
term = primary[key];
129+
return primary[key];
140130
} else if (secondary?.[key]) {
141-
term = secondary[key];
131+
return secondary[key];
142132
} else if (umbLocalizationManager.fallback?.[key]) {
143-
term = umbLocalizationManager.fallback[key];
144-
} else {
145-
return String(key);
133+
return umbLocalizationManager.fallback[key];
146134
}
147135

136+
return null;
137+
}
138+
139+
/**
140+
* Processes a localization entry (string or function) with the provided arguments.
141+
* @param {any} term - the localization entry to process.
142+
* @param {unknown[]} args - the arguments to apply to the term.
143+
* @returns {string} - the processed term as a string.
144+
*/
145+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
146+
#processTerm(term: any, args: unknown[]): string {
148147
if (typeof term === 'function') {
149148
return term(...args) as string;
150149
}
151150

152151
if (typeof term === 'string') {
153152
if (args.length) {
154153
// Replace placeholders of format "%index%" and "{index}" with provided values
155-
term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
154+
return term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
156155
const index = p2 || p3;
157156
return typeof args[index] !== 'undefined' ? String(args[index]) : match;
158157
});
159158
}
160159
}
161160

162-
return term;
161+
return String(term);
162+
}
163+
164+
/**
165+
* Outputs a translated term.
166+
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
167+
* @param {unknown[]} args - the arguments to parse for this localization entry.
168+
* @returns {string} - the translated term as a string.
169+
* @example
170+
* Retrieving a term without any arguments:
171+
* ```ts
172+
* this.localize.term('area_term');
173+
* ```
174+
* Retrieving a term with arguments:
175+
* ```ts
176+
* this.localize.term('general_greeting', ['John']);
177+
* ```
178+
*/
179+
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
180+
const term = this.#lookupTerm(key);
181+
182+
if (term === null) {
183+
return String(key);
184+
}
185+
186+
return this.#processTerm(term, args);
187+
}
188+
189+
/**
190+
* Returns the localized term for the given key, or the default value if not found.
191+
* This method follows the same resolution order as term() (primary → secondary → fallback),
192+
* but returns the provided defaultValue instead of the key when no translation is found.
193+
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
194+
* @param {string | null} defaultValue - the value to return if the key is not found in any localization set.
195+
* @param {unknown[]} args - the arguments to parse for this localization entry.
196+
* @returns {string | null} - the translated term or the default value.
197+
* @example
198+
* Retrieving a term with fallback:
199+
* ```ts
200+
* this.localize.termOrDefault('general_close', 'X');
201+
* ```
202+
* Retrieving a term with fallback and arguments:
203+
* ```ts
204+
* this.localize.termOrDefault('general_greeting', 'Hello!', userName);
205+
* ```
206+
* Retrieving a term with null as fallback:
207+
* ```ts
208+
* this.localize.termOrDefault('general_close', null);
209+
* ```
210+
*/
211+
termOrDefault<K extends keyof LocalizationSetType, D extends string | null>(
212+
key: K,
213+
defaultValue: D,
214+
...args: FunctionParams<LocalizationSetType[K]>
215+
): string | D {
216+
const term = this.#lookupTerm(key);
217+
218+
if (term === null) {
219+
return defaultValue;
220+
}
221+
222+
return this.#processTerm(term, args);
163223
}
164224

165225
/**
@@ -255,10 +315,10 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
255315
* If the term is found in the localization set, it will be replaced with the localized term.
256316
* If the term is not found, the original term will be returned.
257317
* @param {string | undefined} text The text to translate.
258-
* @param {...any} args The arguments to parse for this localization entry.
318+
* @param {unknown[]} args The arguments to parse for this localization entry.
259319
* @returns {string} The translated text.
260320
*/
261-
string(text: string | undefined, ...args: any): string {
321+
string(text: string | undefined, ...args: unknown[]): string {
262322
if (typeof text !== 'string') {
263323
return '';
264324
}
@@ -267,16 +327,16 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
267327
const regex = /#\w+/g;
268328

269329
const localizedText = text.replace(regex, (match: string) => {
270-
const key = match.slice(1);
271-
if (!this.#usedKeys.includes(key)) {
272-
this.#usedKeys.push(key);
273-
}
330+
const key = match.slice(1) as keyof LocalizationSetType;
331+
332+
const term = this.#lookupTerm(key);
274333

275-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
276-
// @ts-ignore
277-
const localized = this.term(key, ...args);
278334
// we didn't find a localized string, so we return the original string with the #
279-
return localized === key ? match : localized;
335+
if (term === null) {
336+
return match;
337+
}
338+
339+
return this.#processTerm(term, args);
280340
});
281341

282342
return localizedText;

src/Umbraco.Web.UI.Client/src/packages/core/localization/localize-number.element.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
1+
import { css, customElement, html, property, state, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit';
22
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
33

44
/**
@@ -21,16 +21,20 @@ export class UmbLocalizeNumberElement extends UmbLitElement {
2121
* @attr
2222
* @example options={ style: 'currency', currency: 'EUR' }
2323
*/
24-
@property()
24+
@property({ type: Object })
2525
options?: Intl.NumberFormatOptions;
2626

2727
@state()
28-
protected get text(): string {
28+
protected get text(): string | null {
2929
return this.localize.number(this.number, this.options);
3030
}
3131

3232
override render() {
33-
return this.number ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
33+
return when(
34+
this.text,
35+
(text) => unsafeHTML(text),
36+
() => html`<slot></slot>`,
37+
);
3438
}
3539

3640
static override styles = [

src/Umbraco.Web.UI.Client/src/packages/core/localization/localize-relative-time.element.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { css, customElement, html, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
1+
import { css, customElement, html, property, state, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit';
22
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
33

44
/**
@@ -33,12 +33,16 @@ export class UmbLocalizeRelativeTimeElement extends UmbLitElement {
3333
unit: Intl.RelativeTimeFormatUnit = 'seconds';
3434

3535
@state()
36-
protected get text(): string {
36+
protected get text(): string | null {
3737
return this.localize.relativeTime(this.time, this.unit, this.options);
3838
}
3939

4040
override render() {
41-
return this.time ? html`${unsafeHTML(this.text)}` : html`<slot></slot>`;
41+
return when(
42+
this.text,
43+
(text) => unsafeHTML(text),
44+
() => html`<slot></slot>`,
45+
);
4246
}
4347

4448
static override styles = [

src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,30 +21,30 @@ export class UmbLocalizeElement extends UmbLitElement {
2121
* The values to forward to the localization function (must be JSON compatible).
2222
* @attr
2323
* @example args="[1,2,3]"
24-
* @type {any[] | undefined}
24+
* @type {unknown[] | undefined}
2525
*/
2626
@property({ type: Array })
2727
args?: unknown[];
2828

2929
/**
30-
* If true, the key will be rendered instead of the localized value if the key is not found.
30+
* If true, the key will be rendered instead of the fallback value if the key is not found.
3131
* @attr
3232
*/
3333
@property({ type: Boolean })
3434
debug = false;
3535

3636
@state()
37-
protected get text(): string {
37+
protected get text(): string | null {
3838
// As translated texts can contain HTML, we will need to render with unsafeHTML.
3939
// But arguments can come from user input, so they should be escaped.
4040
const escapedArgs = (this.args ?? []).map((a) => escapeHTML(a));
4141

42-
const localizedValue = this.localize.term(this.key, ...escapedArgs);
42+
const localizedValue = this.localize.termOrDefault(this.key, null, ...escapedArgs);
4343

44-
// If the value is the same as the key, it means the key was not found.
45-
if (localizedValue === this.key) {
44+
// Update the data attribute based on whether the key was found
45+
if (localizedValue === null) {
4646
(this.getHostElement() as HTMLElement).setAttribute('data-localize-missing', this.key);
47-
return '';
47+
return null;
4848
}
4949

5050
(this.getHostElement() as HTMLElement).removeAttribute('data-localize-missing');

src/Umbraco.Web.UI.Client/src/packages/documents/documents/entity-actions/notifications/modal/document-notifications-modal.element.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,7 @@ export class UmbDocumentNotificationsModalElement extends UmbModalBaseElement<
8282
(setting) => setting.actionId,
8383
(setting) => {
8484
const localizationKey = `actions_${setting.alias}`;
85-
let localization = this.localize.term(localizationKey);
86-
if (localization === localizationKey) {
87-
// Fallback to alias if no localization is found
88-
localization = setting.alias;
89-
}
85+
const localization = this.localize.termOrDefault(localizationKey, setting.alias);
9086
return html`<uui-toggle
9187
id=${setting.actionId}
9288
@change=${() => this.#updateSubscription(setting.actionId)}

0 commit comments

Comments
 (0)