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) => - ` + `