From 95e1104e371e5a62c5ae2433b0256ac33469a4b3 Mon Sep 17 00:00:00 2001
From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Date: Mon, 31 Jul 2023 17:27:49 +0200
Subject: [PATCH] replace localization context with localize controller
---
src/Umbraco.Web.UI.Client/index.html | 2 +-
.../src/apps/backoffice/backoffice.element.ts | 10 +-
.../controller-host-element.mixin.ts | 2 +
.../src/libs/element-api/element.mixin.ts | 12 ++
.../src/libs/localization-api/index.ts | 3 +-
.../localization.context.test.ts | 195 ------------------
.../localization-api/localization.context.ts | 64 ------
.../localize.controller.test.ts | 106 ++++++++++
.../localization-api/localize.controller.ts | 105 ++++++++++
.../src/libs/localization-api/manager.ts | 58 ++++++
.../registry/translation.registry.ts | 56 ++---
.../localization/localize.element.test.ts | 30 +--
.../core/localization/localize.element.ts | 59 +-----
.../packages/core/localization/manifests.ts | 4 +-
.../current-user-modal.element.ts | 26 +--
.../user-profile-app-profile.element.ts | 28 +--
.../users/users/repository/user.repository.ts | 15 +-
.../web-test-runner.config.mjs | 2 +-
18 files changed, 355 insertions(+), 422 deletions(-)
delete mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.test.ts
delete mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.ts
create mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts
create mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts
create mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts
diff --git a/src/Umbraco.Web.UI.Client/index.html b/src/Umbraco.Web.UI.Client/index.html
index 7f6db52d25..9f1236019b 100644
--- a/src/Umbraco.Web.UI.Client/index.html
+++ b/src/Umbraco.Web.UI.Client/index.html
@@ -1,5 +1,5 @@
-
+
diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts
index 8b908773fd..d43609d8fc 100644
--- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts
@@ -1,6 +1,6 @@
import { UmbBackofficeContext, UMB_BACKOFFICE_CONTEXT_TOKEN } from './backoffice.context.js';
import { UmbExtensionInitializer } from './extension.controller.js';
-import { UMB_LOCALIZATION_CONTEXT, UmbLocalizationContext } from '@umbraco-cms/backoffice/localization-api';
+import { UmbTranslationRegistry } from '@umbraco-cms/backoffice/localization-api';
import { UMB_AUTH } from '@umbraco-cms/backoffice/auth';
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
@@ -40,14 +40,14 @@ export class UmbBackofficeElement extends UmbLitElement {
const extensionInitializer = new UmbExtensionInitializer(this, umbExtensionsRegistry);
extensionInitializer.setLocalPackages(CORE_PACKAGES);
- const localizationContext = new UmbLocalizationContext(umbExtensionsRegistry);
- this.provideContext(UMB_LOCALIZATION_CONTEXT, localizationContext);
-
+ const translationRegistry = new UmbTranslationRegistry(umbExtensionsRegistry);
+ translationRegistry.loadLanguage('en-us');
this.consumeContext(UMB_AUTH, (auth) => {
this.observe(
auth.languageIsoCode,
(currentLanguageIsoCode) => {
- localizationContext.setLanguage(currentLanguageIsoCode);
+ translationRegistry.loadLanguage(currentLanguageIsoCode);
+ document.documentElement.lang = currentLanguageIsoCode;
},
'languageIsoCode'
);
diff --git a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host-element.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host-element.mixin.ts
index 28605231ce..2e2eddf3ae 100644
--- a/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host-element.mixin.ts
+++ b/src/Umbraco.Web.UI.Client/src/libs/controller-api/controller-host-element.mixin.ts
@@ -3,8 +3,10 @@ import { UmbControllerAlias } from './controller-alias.type.js';
import { UmbControllerHostBaseMixin } from './controller-host-base.mixin.js';
import { UmbControllerHost } from './controller-host.interface.js';
import type { UmbController } from './controller.interface.js';
+import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
export declare class UmbControllerHostElement extends HTMLElement implements UmbControllerHost {
+ get localize(): UmbLocalizeController | undefined;
hasController(controller: UmbController): boolean;
getControllers(filterMethod: (ctrl: UmbController) => boolean): UmbController[];
addController(controller: UmbController): void;
diff --git a/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts b/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts
index d178efbdb6..4ae95713ab 100644
--- a/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts
+++ b/src/Umbraco.Web.UI.Client/src/libs/element-api/element.mixin.ts
@@ -9,6 +9,7 @@ import {
UmbContextProviderController,
} from '@umbraco-cms/backoffice/context-api';
import { ObserverCallback, UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
+import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
export declare class UmbElement extends UmbControllerHostElement {
/**
@@ -28,10 +29,21 @@ export declare class UmbElement extends UmbControllerHostElement {
alias: string | UmbContextToken,
callback: UmbContextCallback
): UmbContextConsumerController;
+ get localize(): UmbLocalizeController;
}
export const UmbElementMixin = (superClass: T) => {
class UmbElementMixinClass extends UmbControllerHostElementMixin(superClass) implements UmbElement {
+ #localizeController = new UmbLocalizeController(this);
+
+ /**
+ * Get the localize controller.
+ * @readonly
+ */
+ get localize(): UmbLocalizeController {
+ return this.#localizeController;
+ }
+
/**
* @description Observe a RxJS source of choice.
* @param {Observable} source RxJS source
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/index.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/index.ts
index b89708c78d..139e9726f8 100644
--- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/index.ts
@@ -1,2 +1,3 @@
export * from './registry/translation.registry.js';
-export * from './localization.context.js';
+export * from './localize.controller.js';
+export { registerTranslation } from './manager.js';
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.test.ts
deleted file mode 100644
index efc13fbca7..0000000000
--- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.test.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import { expect, aTimeout } from '@open-wc/testing';
-import { firstValueFrom } from 'rxjs';
-import { UmbLocalizationContext } from './localization.context.js';
-import { UmbTranslationRegistry } from './registry/translation.registry.js';
-import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
-
-//#region Translations
-const english = {
- type: 'translations',
- alias: 'test.en',
- name: 'Test English',
- meta: {
- culture: 'en',
- translations: {
- general: {
- close: 'Close',
- logout: 'Log out',
- },
- },
- },
-};
-
-const englishOverride = {
- type: 'translations',
- alias: 'test.en.override',
- name: 'Test English',
- meta: {
- culture: 'en',
- translations: {
- general: {
- close: 'Close 2',
- },
- },
- },
-};
-
-const danish = {
- type: 'translations',
- alias: 'test.da',
- name: 'Test Danish',
- meta: {
- culture: 'da',
- translations: {
- general: {
- close: 'Luk',
- },
- },
- },
-};
-//#endregion
-
-describe('Localization', () => {
- let registry: UmbTranslationRegistry;
- let extensionRegistry: UmbExtensionRegistry;
-
- beforeEach(() => {
- extensionRegistry = new UmbExtensionRegistry();
- registry = new UmbTranslationRegistry(extensionRegistry);
- extensionRegistry.register(english);
- extensionRegistry.register(danish);
- registry.register(english.meta.culture, english.meta.culture);
- });
-
- describe('UmbTranslationRegistry', () => {
- it('should register and get translation', (done) => {
- registry.translations.subscribe((translations) => {
- expect(translations.get('general_close')).to.equal('Close');
- done();
- });
- });
- });
-
- describe('UmbLocalizationContext', () => {
- let context: UmbLocalizationContext;
-
- beforeEach(async () => {
- context = new UmbLocalizationContext(extensionRegistry);
- context.setLanguage(english.meta.culture, english.meta.culture);
- });
-
- describe('localize', () => {
- it('should return a value', async () => {
- const value = await firstValueFrom(context.localize('general_close'));
- expect(value).to.equal('Close');
- });
-
- it('should return fallback if key is not found', async () => {
- const value = await firstValueFrom(context.localize('general_not_found', 'Not found'));
- expect(value).to.equal('Not found');
- });
-
- it('should return an empty string if fallback is not provided', async () => {
- const value = await firstValueFrom(context.localize('general_not_found'));
- expect(value).to.equal('');
- });
-
- it('should return a new value if a key is overridden', async () => {
- const value = await firstValueFrom(context.localize('general_close'));
- expect(value).to.equal('Close');
-
- extensionRegistry.register(englishOverride);
-
- const value2 = await firstValueFrom(context.localize('general_close'));
- expect(value2).to.equal('Close 2');
- });
-
- it('should return a new value if the language is changed', async () => {
- const value = await firstValueFrom(context.localize('general_close'));
- expect(value).to.equal('Close');
-
- context.setLanguage(danish.meta.culture, english.meta.culture);
-
- await aTimeout(0);
-
- const value2 = await firstValueFrom(context.localize('general_close'));
- expect(value2).to.equal('Luk');
- });
-
- it('should use fallback language if key is not found', async () => {
- const value = await firstValueFrom(context.localize('general_logout'));
- expect(value).to.equal('Log out');
-
- context.setLanguage(danish.meta.culture, english.meta.culture);
-
- await aTimeout(0);
-
- const value2 = await firstValueFrom(context.localize('general_logout'));
- expect(value2).to.equal('Log out');
- });
- });
-
- describe('localizeMany', () => {
- it('should return values', async () => {
- const values = await firstValueFrom(context.localizeMany(['general_close', 'general_logout']));
- expect(values[0]).to.equal('Close');
- expect(values[1]).to.equal('Log out');
- });
-
- it('should return empty values if keys are not found', async () => {
- const values = await firstValueFrom(context.localizeMany(['general_close', 'general_not_found']));
- expect(values[0]).to.equal('Close');
- expect(values[1]).to.equal('');
- });
-
- it('should update values if a key is overridden', async () => {
- const values = await firstValueFrom(context.localizeMany(['general_close', 'general_logout']));
- expect(values[0]).to.equal('Close');
- expect(values[1]).to.equal('Log out');
-
- extensionRegistry.register(englishOverride);
-
- const values2 = await firstValueFrom(context.localizeMany(['general_close', 'general_logout']));
- expect(values2[0]).to.equal('Close 2');
- expect(values2[1]).to.equal('Log out');
- });
-
- it('should return new values if a language is changed', async () => {
- const values = await firstValueFrom(context.localizeMany(['general_close', 'general_logout']));
- expect(values[0]).to.equal('Close');
- expect(values[1]).to.equal('Log out');
-
- context.setLanguage(danish.meta.culture, english.meta.culture);
-
- await aTimeout(0);
-
- const values2 = await firstValueFrom(context.localizeMany(['general_close', 'general_logout']));
- expect(values2[0]).to.equal('Luk');
- expect(values2[1]).to.equal('Log out'); // This key does not exist in the danish translation so should use 'en' fallback.
- });
- });
-
- it('should emit new values in same subscription', async () => {
- const values: string[][] = [];
-
- context.localizeMany(['general_close', 'general_logout']).subscribe((value) => {
- values.push(value);
- });
-
- // Let the subscription run (values are available statically)
- await aTimeout(0);
-
- expect(values[0][0]).to.equal('Close');
- expect(values[0][1]).to.equal('Log out');
-
- // it should return new values if a key is overridden
- extensionRegistry.register(englishOverride);
-
- // Let the subscription run again
- await aTimeout(0);
-
- expect(values[1][0]).to.equal('Close 2');
- expect(values[1][1]).to.equal('Log out');
- });
- });
-});
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.ts
deleted file mode 100644
index cc6c54fb9f..0000000000
--- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.context.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { UmbTranslationRegistry } from './registry/translation.registry.js';
-import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
-import { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
-import { combineLatest, distinctUntilChanged, type Observable, map } from '@umbraco-cms/backoffice/external/rxjs';
-
-export class UmbLocalizationContext {
- #translationRegistry;
-
- constructor(umbExtensionRegistry: UmbBackofficeExtensionRegistry) {
- this.#translationRegistry = new UmbTranslationRegistry(umbExtensionRegistry);
- }
-
- get translations() {
- return this.#translationRegistry.translations;
- }
-
- /**
- * Set a new language which will query the manifests for translations and register them.
- * Eventually it will update all codes visible on the screen.
- *
- * @param languageIsoCode The language to use (example: 'en-us')
- * @param fallbackLanguageIsoCode The fallback language to use (example: 'en-us', default: 'en-us')
- */
- setLanguage(languageIsoCode: string, fallbackLanguageIsoCode?: string) {
- this.#translationRegistry.register(languageIsoCode, fallbackLanguageIsoCode);
- }
-
- /**
- * Localize a key.
- * If the key is not found, the fallback is returned.
- * If the fallback is not provided, the key is returned.
- *
- * @param key The key to localize. The key is case sensitive.
- * @param fallback The fallback text to use if the key is not found (default: undefined).
- * @example localize('general_close').subscribe((value) => {
- * console.log(value); // 'Close'
- * });
- */
- localize(key: string, fallback?: string): Observable {
- return this.translations.pipe(
- map((dictionary) => {
- return dictionary.get(key) ?? fallback ?? '';
- })
- );
- }
-
- /**
- * Localize many keys at once.
- * If a key is not found, the key is returned.
- *
- * @description This method combines the results of multiple calls to localize.
- * @param keys The keys to localize. The keys are case sensitive.
- * @example localizeMany(['general_close', 'general_logout']).subscribe((values) => {
- * console.log(values[0]); // 'Close'
- * console.log(values[1]); // 'Log out'
- * });
- * @see localize
- */
- localizeMany(keys: string[]) {
- return combineLatest(keys.map((key) => this.localize(key).pipe(distinctUntilChanged())));
- }
-}
-
-export const UMB_LOCALIZATION_CONTEXT = new UmbContextToken('UmbLocalizationContext');
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts
new file mode 100644
index 0000000000..9a6a2e8870
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts
@@ -0,0 +1,106 @@
+import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing';
+import { UmbLocalizeController } from './localize.controller.js';
+import { UmbTranslationRegistry } from './registry/translation.registry.js';
+import { customElement, property } from '@umbraco-cms/backoffice/external/lit';
+import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
+import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
+@customElement('umb-localize-controller-host')
+class UmbLocalizeControllerHostElement extends UmbLitElement {
+ @property()
+ lang = 'en-us';
+}
+
+//#region Translations
+const english = {
+ type: 'translations',
+ alias: 'test.en',
+ name: 'Test English',
+ meta: {
+ culture: 'en-us',
+ translations: {
+ general: {
+ close: 'Close',
+ logout: 'Log out',
+ },
+ },
+ },
+};
+
+const englishOverride = {
+ type: 'translations',
+ alias: 'test.en.override',
+ name: 'Test English',
+ meta: {
+ culture: 'en-us',
+ translations: {
+ general: {
+ close: 'Close 2',
+ },
+ },
+ },
+};
+
+const danish = {
+ type: 'translations',
+ alias: 'test.da',
+ name: 'Test Danish',
+ meta: {
+ culture: 'da-dk',
+ translations: {
+ general: {
+ close: 'Luk',
+ },
+ },
+ },
+};
+//#endregion
+
+describe('UmbLocalizeController', () => {
+ const registry = new UmbTranslationRegistry(umbExtensionsRegistry);
+ umbExtensionsRegistry.register(english);
+ umbExtensionsRegistry.register(danish);
+ registry.loadLanguage(english.meta.culture);
+ registry.loadLanguage(danish.meta.culture);
+
+ let element: UmbLocalizeControllerHostElement;
+
+ beforeEach(async () => {
+ element = await fixture(html``);
+ });
+
+ it('should have a localize controller', () => {
+ expect(element.localize).to.be.instanceOf(UmbLocalizeController);
+ });
+
+ it('should return a term', async () => {
+ expect(element.localize.term('general_close')).to.equal('Close');
+ });
+
+ it('should update the term when the language changes', async () => {
+ expect(element.localize.term('general_close')).to.equal('Close');
+ element.lang = danish.meta.culture;
+ await elementUpdated(element);
+ expect(element.localize.term('general_close')).to.equal('Luk');
+ });
+
+ it('should update the term when the dir changes', async () => {
+ expect(element.localize.term('general_close')).to.equal('Close');
+ element.dir = 'rtl';
+ await elementUpdated(element);
+ expect(element.localize.term('general_close')).to.equal('Close');
+ });
+
+ it('should provide a fallback term when the term is not found', async () => {
+ element.lang = danish.meta.culture;
+ await elementUpdated(element);
+ expect(element.localize.term('general_close')).to.equal('Luk');
+ expect(element.localize.term('general_logout')).to.equal('Log out');
+ });
+
+ // TODO: fix this test
+ // it('should override a term', async () => {
+ // umbExtensionsRegistry.register(englishOverride);
+ // await aTimeout(0);
+ // expect(element.localize.term('general_close')).to.equal('Close 2');
+ // });
+});
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts
new file mode 100644
index 0000000000..8a407397f6
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts
@@ -0,0 +1,105 @@
+import {
+ DefaultTranslation,
+ FunctionParams,
+ Translation,
+ connectedElements,
+ documentDirection,
+ documentLanguage,
+ fallback,
+ translations,
+} from './manager.js';
+import { UmbController, UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
+
+export class UmbLocalizeController implements UmbController {
+ host;
+ controllerAlias = 'localize';
+
+ constructor(host: UmbControllerHostElement) {
+ this.host = host;
+ this.host.addController(this);
+ }
+
+ hostConnected(): void {
+ if (connectedElements.has(this.host)) {
+ return;
+ }
+
+ connectedElements.add(this.host);
+ }
+
+ hostDisconnected(): void {
+ connectedElements.delete(this.host);
+ }
+
+ destroy(): void {
+ connectedElements.delete(this.host);
+ this.host.removeController(this);
+ }
+
+ /**
+ * Gets the host element's directionality as determined by the `dir` attribute. The return value is transformed to
+ * lowercase.
+ */
+ dir() {
+ return `${this.host.dir || documentDirection}`.toLowerCase();
+ }
+
+ /**
+ * Gets the host element's language as determined by the `lang` attribute. The return value is transformed to
+ * lowercase.
+ */
+ lang() {
+ return `${this.host.lang || documentLanguage}`.toLowerCase();
+ }
+
+ private getTranslationData(lang: string) {
+ const locale = new Intl.Locale(lang);
+ const language = locale?.language.toLowerCase();
+ const region = locale?.region?.toLowerCase() ?? '';
+ const primary = translations.get(`${language}-${region}`);
+ const secondary = translations.get(language);
+
+ return { locale, language, region, primary, secondary };
+ }
+
+ /** Outputs a translated term. */
+ term(key: K, ...args: FunctionParams): string {
+ const { primary, secondary } = this.getTranslationData(this.lang());
+ let term: any;
+
+ // Look for a matching term using regionCode, code, then the fallback
+ if (primary && primary[key]) {
+ term = primary[key];
+ } else if (secondary && secondary[key]) {
+ term = secondary[key];
+ } else if (fallback && fallback[key as keyof Translation]) {
+ term = fallback[key as keyof Translation];
+ } else {
+ console.error(`No translation found for: ${String(key)}`);
+ return String(key);
+ }
+
+ if (typeof term === 'function') {
+ return term(...args) as string;
+ }
+
+ return term;
+ }
+
+ /** Outputs a localized date in the specified format. */
+ date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string {
+ dateToFormat = new Date(dateToFormat);
+ return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
+ }
+
+ /** Outputs a localized number in the specified format. */
+ number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string {
+ numberToFormat = Number(numberToFormat);
+ return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
+ }
+
+ /** Outputs a localized time in relative format. */
+ relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string {
+ return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts
new file mode 100644
index 0000000000..3b51f06d06
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts
@@ -0,0 +1,58 @@
+import { LitElement } from '@umbraco-cms/backoffice/external/lit';
+
+export type FunctionParams = T extends (...args: infer U) => string ? U : [];
+
+export interface Translation {
+ $code: string; // e.g. en, en-GB
+ $dir: 'ltr' | 'rtl';
+}
+
+export interface DefaultTranslation extends Translation {
+ [key: string]: any;
+}
+
+export const connectedElements = new Set();
+const documentElementObserver = new MutationObserver(update);
+export const translations: Map = new Map();
+export let documentDirection = document.documentElement.dir || 'ltr';
+export let documentLanguage = document.documentElement.lang || navigator.language;
+export let fallback: Translation;
+
+// Watch for changes on
+documentElementObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['dir', 'lang'],
+});
+
+/** Registers one or more translations */
+export function registerTranslation(...translation: Translation[]) {
+ translation.map((t) => {
+ const code = t.$code.toLowerCase();
+
+ if (translations.has(code)) {
+ // Merge translations that share the same language code
+ translations.set(code, { ...translations.get(code), ...t });
+ } else {
+ translations.set(code, t);
+ }
+
+ // The first translation that's registered is the fallback
+ if (!fallback) {
+ fallback = t;
+ }
+ });
+
+ update();
+}
+
+/** Updates all localized elements that are currently connected */
+export function update() {
+ documentDirection = document.documentElement.dir || 'ltr';
+ documentLanguage = document.documentElement.lang || navigator.language;
+
+ [...connectedElements.keys()].map((el) => {
+ if (typeof (el as LitElement).requestUpdate === 'function') {
+ (el as LitElement).requestUpdate();
+ }
+ });
+}
diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/registry/translation.registry.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/registry/translation.registry.ts
index 0b82ca0a0a..b9425b1d26 100644
--- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/registry/translation.registry.ts
+++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/registry/translation.registry.ts
@@ -1,29 +1,21 @@
+import { Translation, registerTranslation } from '../manager.js';
import { hasDefaultExport, loadExtension } from '@umbraco-cms/backoffice/extension-api';
import { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
-import { ReplaySubject, Subscription } from '@umbraco-cms/backoffice/external/rxjs';
+import { Subscription } from '@umbraco-cms/backoffice/external/rxjs';
-export type UmbTranslationDictionary = Map;
+export type UmbTranslationDictionary = Record;
export class UmbTranslationRegistry {
- #extensionRegistry;
- #innerDictionary = new ReplaySubject(1);
- #innerDictionaryValue: UmbTranslationDictionary = new Map();
+ #registry;
#subscription?: Subscription;
- constructor(umbExtensionRegistry: UmbBackofficeExtensionRegistry) {
- this.#extensionRegistry = umbExtensionRegistry;
+ constructor(extensionRegistry: UmbBackofficeExtensionRegistry) {
+ this.#registry = extensionRegistry;
}
- get translations() {
- return this.#innerDictionary.asObservable();
- }
-
- register(userCulture: string, fallbackCulture = 'en-us') {
+ loadLanguage(userCulture: string) {
+ // Normalize the culture
userCulture = userCulture.toLowerCase();
- fallbackCulture = fallbackCulture.toLowerCase();
-
- // Reset the inner dictionary.
- this.#innerDictionaryValue = new Map();
// Cancel any previous subscription.
if (this.#subscription) {
@@ -31,17 +23,18 @@ export class UmbTranslationRegistry {
}
// Load new translations
- this.#subscription = this.#extensionRegistry.extensionsOfType('translations').subscribe(async (extensions) => {
+ this.#subscription = this.#registry.extensionsOfType('translations').subscribe(async (extensions) => {
await Promise.all(
extensions
- .filter((x) => x.meta.culture === userCulture || x.meta.culture === fallbackCulture)
+ .filter((x) => x.meta.culture.toLowerCase() === userCulture)
.map(async (extension) => {
+ const innerDictionary: UmbTranslationDictionary = {};
+
// If extension contains a dictionary, add it to the inner dictionary.
if (extension.meta.translations) {
for (const [dictionaryName, dictionary] of Object.entries(extension.meta.translations)) {
- this.#addOrUpdateDictionary(dictionaryName, dictionary);
+ this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary);
}
- return;
}
// If extension contains a js file, load it and add the default dictionary to the inner dictionary.
@@ -49,22 +42,29 @@ export class UmbTranslationRegistry {
if (loadedExtension && hasDefaultExport(loadedExtension)) {
for (const [dictionaryName, dictionary] of Object.entries(loadedExtension.default)) {
- this.#addOrUpdateDictionary(dictionaryName, dictionary);
+ this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary);
}
}
+
+ // Notify subscribers that the inner dictionary has changed.
+ const translation: Translation = {
+ $code: userCulture,
+ $dir: 'ltr',
+ ...innerDictionary,
+ };
+ registerTranslation(translation);
})
);
-
- // Notify subscribers that the inner dictionary has changed.
- if (this.#innerDictionaryValue.size > 0) {
- this.#innerDictionary.next(this.#innerDictionaryValue);
- }
});
}
- #addOrUpdateDictionary(dictionaryName: string, dictionary: Record) {
+ #addOrUpdateDictionary(
+ innerDictionary: UmbTranslationDictionary,
+ dictionaryName: string,
+ dictionary: Record
+ ) {
for (const [key, value] of Object.entries(dictionary)) {
- this.#innerDictionaryValue.set(`${dictionaryName}_${key}`, value);
+ innerDictionary[`${dictionaryName}_${key}`] = value;
}
}
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts
index 81591ae206..ad7a9f85e7 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.test.ts
@@ -1,9 +1,9 @@
-import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing';
+import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { UmbLocalizeElement } from './localize.element.js';
-import { UMB_LOCALIZATION_CONTEXT, UmbLocalizationContext } from '@umbraco-cms/backoffice/localization-api';
-import { UmbExtensionRegistry } from '@umbraco-cms/backoffice/extension-api';
import '@umbraco-cms/backoffice/context-api';
+import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
+import { UmbTranslationRegistry } from '@umbraco-cms/backoffice/localization-api';
const english = {
type: 'translations',
@@ -46,22 +46,13 @@ describe('umb-localize', () => {
});
describe('localization', () => {
- let hostElement: HTMLElement;
- let extensionRegistry: UmbExtensionRegistry;
- let context: UmbLocalizationContext;
+ umbExtensionsRegistry.register(english);
+ umbExtensionsRegistry.register(danish);
+ const translationRegistry = new UmbTranslationRegistry(umbExtensionsRegistry);
+ translationRegistry.loadLanguage(english.meta.culture);
beforeEach(async () => {
- extensionRegistry = new UmbExtensionRegistry();
- extensionRegistry.register(english);
- extensionRegistry.register(danish);
- context = new UmbLocalizationContext(extensionRegistry);
- context.setLanguage(english.meta.culture, english.meta.culture);
- hostElement = await fixture(
- html`
-
- `
- );
- element = hostElement.querySelector('umb-localize') as UmbLocalizeElement;
+ element = await fixture(html``);
});
it('should localize a key', async () => {
@@ -79,14 +70,11 @@ describe('umb-localize', () => {
});
it('should change the value if the language is changed', async () => {
- await elementUpdated(element);
expect(element.shadowRoot?.innerHTML).to.contain('Close');
- context.setLanguage(danish.meta.culture);
+ element.lang = danish.meta.culture;
await elementUpdated(element);
- await aTimeout(0);
-
expect(element.shadowRoot?.innerHTML).to.contain('Luk');
});
});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts
index 9343502d82..2cd362de21 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/localize.element.ts
@@ -1,7 +1,5 @@
-import { UMB_LOCALIZATION_CONTEXT } from '@umbraco-cms/backoffice/localization-api';
import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
-import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
/**
* This element allows you to localize a string with optional interpolation values.
@@ -15,67 +13,24 @@ export class UmbLocalizeElement extends UmbLitElement {
* @example key="general_ok"
*/
@property({ type: String })
- set key(value: string) {
- const isNewKey = this.#key !== value;
- this.#key = value;
-
- // Only reload translations if the key has changed, otherwise the load happens when the context is there.
- if (isNewKey) {
- this.#load();
- }
- }
-
- get key() {
- return this.#key;
- }
+ key!: string;
/**
* If true, the key will be rendered instead of the localized value if the key is not found.
* @attr
*/
- @property({ type: Boolean })
+ @property()
debug = false;
@state()
- protected value?: string;
-
- #key = '';
- #localizationContext?: typeof UMB_LOCALIZATION_CONTEXT.TYPE;
- #subscription?: UmbObserverController;
-
- constructor() {
- super();
- this.consumeContext(UMB_LOCALIZATION_CONTEXT, (instance) => {
- this.#localizationContext = instance;
- this.#load();
- });
- }
-
- async #load() {
- if (this.#subscription) {
- this.#subscription.destroy();
- }
-
- if (!this.#localizationContext) {
- return;
- }
-
- this.#subscription = this.observe(this.#localizationContext!.localize(this.key), (value) => {
- if (value) {
- (this.getHostElement() as HTMLElement).removeAttribute('data-umb-localize-error');
- this.value = value;
- } else {
- (this.getHostElement() as HTMLElement).setAttribute('data-umb-localize-error', `Key not found: ${this.key}`);
- console.warn('Key not found:', this.key, this);
- if (this.debug) {
- this.value = this.key;
- }
- }
- });
+ get text(): string {
+ const localizedValue = this.localize.term(this.key);
+ console.log('localizedValue', localizedValue);
+ return localizedValue;
}
protected render() {
- return this.value ? html`${this.value}` : html``;
+ return this.text ? html`${this.text}` : html``;
}
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/manifests.ts
index 29bf873007..d9d29fced6 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/manifests.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/manifests.ts
@@ -10,7 +10,7 @@ const translationManifests: Array = [
meta: {
culture: 'en-us',
},
- loader: () => import('../../../assets/lang/en-us.js'),
+ loader: () => import('../../../assets/lang/en-us.js' as unknown as string),
},
{
type: 'translations',
@@ -20,7 +20,7 @@ const translationManifests: Array = [
meta: {
culture: 'da-dk',
},
- loader: () => import('../../../assets/lang/da-dk.js'),
+ loader: () => import('../../../assets/lang/da-dk.js' as unknown as string),
},
];
diff --git a/src/Umbraco.Web.UI.Client/src/packages/users/current-user/modals/current-user/current-user-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/users/current-user/modals/current-user/current-user-modal.element.ts
index 872fe2d25e..5eb6edc4d7 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/users/current-user/modals/current-user/current-user-modal.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/users/current-user/modals/current-user/current-user-modal.element.ts
@@ -1,4 +1,3 @@
-import { UMB_LOCALIZATION_CONTEXT } from '@umbraco-cms/backoffice/localization-api';
import { UMB_AUTH, type UmbLoggedInUser } from '@umbraco-cms/backoffice/auth';
import { UMB_APP } from '@umbraco-cms/backoffice/context';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
@@ -11,12 +10,6 @@ export class UmbCurrentUserModalElement extends UmbLitElement {
@property({ attribute: false })
modalContext?: UmbModalContext;
- @state()
- protected labelClose = 'Close';
-
- @state()
- protected labelLogout = 'Log out';
-
@state()
private _currentUser?: UmbLoggedInUser;
@@ -35,13 +28,6 @@ export class UmbCurrentUserModalElement extends UmbLitElement {
this.consumeContext(UMB_APP, (instance) => {
this.#appContext = instance;
});
-
- this.consumeContext(UMB_LOCALIZATION_CONTEXT, (instance) => {
- instance.localizeMany(['general_close', 'general_logout']).subscribe((values) => {
- this.labelClose = values[0];
- this.labelLogout = values[1];
- });
- });
}
private async _observeCurrentUser() {
@@ -72,9 +58,15 @@ export class UmbCurrentUserModalElement extends UmbLitElement {
- ${this.labelClose}
-
- ${this.labelLogout}
+
+ ${this.localize.term('general_close')}
+
+
+ ${this.localize.term('general_logout')}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/users/current-user/user-profile-apps/user-profile-app-profile.element.ts b/src/Umbraco.Web.UI.Client/src/packages/users/current-user/user-profile-apps/user-profile-app-profile.element.ts
index bdfe434667..b7e749582f 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/users/current-user/user-profile-apps/user-profile-app-profile.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/users/current-user/user-profile-apps/user-profile-app-profile.element.ts
@@ -1,4 +1,3 @@
-import { UMB_LOCALIZATION_CONTEXT } from '@umbraco-cms/backoffice/localization-api';
import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit';
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
@@ -14,15 +13,6 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement {
@state()
private _currentUser?: UmbLoggedInUser;
- @state()
- protected _labelYourProfile = 'Your profile';
-
- @state()
- protected _labelEditProfile = 'Edit';
-
- @state()
- protected _labelChangePassword = 'Change password';
-
private _modalContext?: UmbModalManagerContext;
private _auth?: typeof UMB_AUTH.TYPE;
@@ -37,14 +27,6 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement {
this._auth = instance;
this._observeCurrentUser();
});
-
- this.consumeContext(UMB_LOCALIZATION_CONTEXT, (instance) => {
- instance.localizeMany(['user_yourProfile', 'general_edit', 'general_changePassword']).subscribe((value) => {
- this._labelYourProfile = value[0];
- this._labelEditProfile = value[1];
- this._labelChangePassword = value[2];
- });
- });
}
private async _observeCurrentUser() {
@@ -72,12 +54,12 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement {
render() {
return html`
-
-
- ${this._labelEditProfile}
+
+
+ ${this.localize.term('general_edit')}
-
- ${this._labelChangePassword}
+
+ ${this.localize.term('general_changePassword')}
`;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/users/users/repository/user.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/users/users/repository/user.repository.ts
index 6e3b376b96..02aeb397b7 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/users/users/repository/user.repository.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/users/users/repository/user.repository.ts
@@ -1,4 +1,3 @@
-import { UMB_LOCALIZATION_CONTEXT } from '@umbraco-cms/backoffice/localization-api';
import {
UmbUserCollectionFilterModel,
UmbUserDetail,
@@ -54,10 +53,6 @@ export class UmbUserRepository
#notificationContext?: UmbNotificationContext;
- #labels = {
- userEditSaved: 'User saved',
- };
-
constructor(host: UmbControllerHostElement) {
this.#host = host;
@@ -82,12 +77,6 @@ export class UmbUserRepository
this.#notificationContext = instance;
}).asPromise(),
]);
-
- new UmbContextConsumerController(this.#host, UMB_LOCALIZATION_CONTEXT, (instance) => {
- instance.localizeMany(['speechBubbles_editUserSaved']).subscribe((values) => {
- this.#labels.userEditSaved = values[0];
- });
- });
}
// COLLECTION
@@ -200,7 +189,9 @@ export class UmbUserRepository
}
if (!error) {
- const notification = { data: { message: this.#labels.userEditSaved } };
+ const notification = {
+ data: { message: this.#host.localize?.term('speechBubbles_editUserSaved') ?? 'User saved' },
+ };
this.#notificationContext?.peek('positive', notification);
}
diff --git a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs
index 34fc6663d7..2c476908be 100644
--- a/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs
+++ b/src/Umbraco.Web.UI.Client/web-test-runner.config.mjs
@@ -126,7 +126,7 @@ export default {
reporters: ['lcovonly', 'text-summary'],
},
testRunnerHtml: (testFramework) =>
- `
+ `