From 173d4f3ed458266dcfbcdde1c01bc4dcdbb9cfb9 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:48:58 +0100 Subject: [PATCH 1/9] remove IUmbAuthContext interface since it was merely put in as a safe-guard in case we had to move the services around --- .../src/shared/auth/auth.context.interface.ts | 46 ------------------- .../src/shared/auth/auth.context.token.ts | 4 +- .../src/shared/auth/auth.context.ts | 11 +++-- .../src/shared/auth/index.ts | 1 - 4 files changed, 10 insertions(+), 52 deletions(-) delete mode 100644 src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.interface.ts diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.interface.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.interface.ts deleted file mode 100644 index b307a4b7b9..0000000000 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.interface.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; - -export interface IUmbAuthContext { - isAuthorized: Observable; - - /** - * Initiates the login flow. - */ - login(): void; - - /** - * Initialise the auth flow. - */ - setInitialState(): Promise; - - /** - * Checks if there is a token and it is still valid. - */ - getIsAuthorized(): boolean; - - /** - * Gets the latest token from the Management API. - * If the token is expired, it will be refreshed. - * - * NB! The user may experience being redirected to the login screen if the token is expired. - * - * @example - * ```js - * const token = await authContext.getLatestToken(); - * const result = await fetch('https://my-api.com', { headers: { Authorization: `Bearer ${token}` } }); - * ``` - * - * @returns The latest token from the Management API - */ - getLatestToken(): Promise; - - /** - * Clears the token storage. - */ - clearTokenStorage(): Promise; - - /** - * Signs the user out by removing any tokens from the browser. - */ - signOut(): Promise; -} diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts index b7c70219f0..4725be3acb 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts @@ -1,4 +1,4 @@ -import type { IUmbAuthContext } from './auth.context.interface.js'; +import type { UmbAuthContext } from './auth.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; -export const UMB_AUTH_CONTEXT = new UmbContextToken('UmbAuthContext'); +export const UMB_AUTH_CONTEXT = new UmbContextToken('UmbAuthContext'); diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts index 8a0a298e06..49bf424100 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts @@ -1,11 +1,10 @@ -import type { IUmbAuthContext } from './auth.context.interface.js'; import { UmbAuthFlow } from './auth-flow.js'; import { UMB_AUTH_CONTEXT } from './auth.context.token.js'; import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; import { UmbBaseController } from '@umbraco-cms/backoffice/class-api'; import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; -export class UmbAuthContext extends UmbBaseController implements IUmbAuthContext { +export class UmbAuthContext extends UmbBaseController { #isAuthorized = new UmbBooleanState(false); readonly isAuthorized = this.#isAuthorized.asObservable(); @@ -59,6 +58,13 @@ export class UmbAuthContext extends UmbBaseController implements IUmbAuthContext * * NB! The user may experience being redirected to the login screen if the token is expired. * + * @example + * ```js + * const token = await authContext.getLatestToken(); + * const result = await fetch('https://my-api.com', { headers: { Authorization: `Bearer ${token}` } }); + * ``` + * + * @memberof UmbAuthContext * @returns The latest token from the Management API */ getLatestToken(): Promise { @@ -75,7 +81,6 @@ export class UmbAuthContext extends UmbBaseController implements IUmbAuthContext /** * Signs the user out by removing any tokens from the browser. - * @return {*} {Promise} * @memberof UmbAuthContext */ signOut(): Promise { diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/index.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/index.ts index f2e7f83bb1..e2633039d7 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/index.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/index.ts @@ -1,3 +1,2 @@ -export * from './auth.context.interface.js'; export * from './auth.context.js'; export * from './auth.context.token.js'; From 9baa16878530fc918a37993993e84b8871bf9469 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:52:16 +0100 Subject: [PATCH 2/9] rename 'login' to 'makeAuthorizationRequest' and add a new public method to complete a pending authorization --- src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts | 3 +-- .../src/shared/auth/auth-flow.ts | 3 --- .../src/shared/auth/auth.context.ts | 11 +++++++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 4786301fa7..b9bf8a3b10 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -190,8 +190,7 @@ export class UmbAppElement extends UmbLitElement { window.sessionStorage.setItem('umb:auth:redirect', location.href); // Make a request to the auth server to start the auth flow - // TODO: find better name for this method - this.#authContext.login(); + this.#authContext.makeAuthorizationRequest(); // Return false to prevent the route from being rendered return false; diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts index 0d77f7143e..7dbb62350c 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts @@ -170,9 +170,6 @@ export class UmbAuthFlow { this.#refreshToken = this.#accessTokenResponse.refreshToken; } } - - // If no token was found, or if it was invalid, check if there is a new authorization to be made - await this.completeAuthorizationIfPossible(); } /** diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts index 49bf424100..7fa2415579 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.ts @@ -25,13 +25,20 @@ export class UmbAuthContext extends UmbBaseController { /** * Initiates the login flow. */ - login(): void { + makeAuthorizationRequest() { return this.#authFlow.makeAuthorizationRequest(); } + /** + * Completes the login flow. + */ + completeAuthorizationRequest() { + return this.#authFlow.completeAuthorizationIfPossible(); + } + /** * Checks if the user is authorized. If Authorization is bypassed, the user is always authorized. - * @returns {boolean} True if the user is authorized, otherwise false. + * @returns True if the user is authorized, otherwise false. */ getIsAuthorized() { if (this.#isBypassed) { From c2738c645a2d7d5a5586362e75f610c47043ba68 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:53:30 +0100 Subject: [PATCH 3/9] make the app element wait with any redirects if there is a ?code in the url, and make sure the authorization notifier makes the final redirect after the authorization request has been completed bonus: move magic strings into constants --- .../src/apps/app/app.element.ts | 34 +++++++++++-------- .../src/shared/auth/auth-flow.ts | 29 ++++++++++------ .../src/shared/auth/auth.context.token.ts | 2 ++ 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index b9bf8a3b10..1663dd3d26 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -1,8 +1,8 @@ import type { UmbAppErrorElement } from './app-error.element.js'; import { UmbAppContext } from './app.context.js'; import { UmbServerConnection } from './server-connection.js'; -import type { UMB_AUTH_CONTEXT} from '@umbraco-cms/backoffice/auth'; -import { UmbAuthContext } from '@umbraco-cms/backoffice/auth'; +import type { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; +import { UMB_STORAGE_REDIRECT_URL, UmbAuthContext } from '@umbraco-cms/backoffice/auth'; import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UUIIconRegistryEssential } from '@umbraco-cms/backoffice/external/uui'; import { UmbIconRegistry } from '@umbraco-cms/backoffice/icon'; @@ -29,13 +29,12 @@ export class UmbAppElement extends UmbLitElement { * @attr */ @property({ type: String }) - // TODO: get from server config + // TODO: get from base element or maybe move to UmbAuthContext.#getRedirectUrl since it is only used there backofficePath = '/umbraco'; /** * Bypass authentication. */ - // TODO: this might not be the right solution @property({ type: Boolean }) bypassAuth = false; @@ -140,6 +139,15 @@ export class UmbAppElement extends UmbLitElement { } #redirect() { + // If there is a ?code parameter in the url, then we are in the middle of the oauth flow + // and we need to complete the login (the authorization notifier will redirect after this is done + // essentially hitting this method again) + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.has('code')) { + this.#authContext?.completeAuthorizationRequest(); + return; + } + switch (this.#serverConnection?.getStatus()) { case RuntimeLevelModel.INSTALL: history.replaceState(null, '', 'install'); @@ -156,17 +164,15 @@ export class UmbAppElement extends UmbLitElement { case RuntimeLevelModel.RUN: { const pathname = pathWithoutBasePath({ start: true, end: false }); - // If we are on the installer or upgrade page, redirect to the root - // but if not, keep the current path but replace state anyway to initialize the router - let currentRoute = location.href; - const savedRoute = sessionStorage.getItem('umb:auth:redirect'); - if (savedRoute) { - sessionStorage.removeItem('umb:auth:redirect'); - currentRoute = savedRoute; + // If we are on installer or upgrade page, redirect to the root since we are in the RUN state + if (pathname === '/install' || pathname === '/upgrade') { + history.replaceState(null, '', '/'); + break; } - const finalPath = pathname === '/install' || pathname === '/upgrade' ? '/' : currentRoute; - history.replaceState(null, '', finalPath); + // Keep the current path but replace state anyway to initialize the router + // because the router will not initialize a wildcard route by itself + history.replaceState(null, '', location.href); break; } @@ -187,7 +193,7 @@ export class UmbAppElement extends UmbLitElement { } // Save location.href so we can redirect to it after login - window.sessionStorage.setItem('umb:auth:redirect', location.href); + window.sessionStorage.setItem(UMB_STORAGE_REDIRECT_URL, location.href); // Make a request to the auth server to start the auth flow this.#authContext.makeAuthorizationRequest(); diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts index 7dbb62350c..4ced20b66d 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth-flow.ts @@ -13,9 +13,8 @@ * License for the specific language governing permissions and limitations under * the License. */ -import type { - LocationLike, - StringMap} from '@umbraco-cms/backoffice/external/openid'; +import { UMB_STORAGE_REDIRECT_URL, UMB_STORAGE_TOKEN_RESPONSE_NAME } from './auth.context.token.js'; +import type { LocationLike, StringMap } from '@umbraco-cms/backoffice/external/openid'; import { BaseTokenRequestHandler, BasicQueryStringUtils, @@ -29,13 +28,11 @@ import { GRANT_TYPE_REFRESH_TOKEN, RevokeTokenRequest, TokenRequest, - TokenResponse + TokenResponse, } from '@umbraco-cms/backoffice/external/openid'; const requestor = new FetchRequestor(); -const TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse'; - /** * This class is needed to prevent the hash from being parsed as part of the query string. */ @@ -144,6 +141,15 @@ export class UmbAuthFlow { await this.#makeRefreshTokenRequest(response.code, codeVerifier); await this.performWithFreshTokens(); await this.#saveTokenState(); + + // Redirect to the saved state or root + let currentRoute = '/'; + const savedRoute = sessionStorage.getItem(UMB_STORAGE_REDIRECT_URL); + if (savedRoute) { + sessionStorage.removeItem(UMB_STORAGE_REDIRECT_URL); + currentRoute = savedRoute; + } + history.replaceState(null, '', currentRoute); } }); } @@ -152,17 +158,15 @@ export class UmbAuthFlow { * This method will initialize all the state needed for the auth flow. * * It will: - * - Fetch the service configuration from the server * - Check if there is a token response in local storage * - If there is a token response, check if it is valid * - If it is not valid, check if there is a new authorization to be made * - If there is a new authorization to be made, complete it * - If there is no token response, check if there is a new authorization to be made * - If there is a new authorization to be made, complete it - * - If there is no new authorization to be made, do nothing */ async setInitialState() { - const tokenResponseJson = await this.#storageBackend.getItem(TOKEN_RESPONSE_NAME); + const tokenResponseJson = await this.#storageBackend.getItem(UMB_STORAGE_TOKEN_RESPONSE_NAME); if (tokenResponseJson) { const response = new TokenResponse(JSON.parse(tokenResponseJson)); if (response.isValid()) { @@ -225,7 +229,7 @@ export class UmbAuthFlow { * Forget all cached token state */ async clearTokenStorage() { - await this.#storageBackend.removeItem(TOKEN_RESPONSE_NAME); + await this.#storageBackend.removeItem(UMB_STORAGE_TOKEN_RESPONSE_NAME); // clear the internal state this.#accessTokenResponse = undefined; @@ -310,7 +314,10 @@ export class UmbAuthFlow { */ async #saveTokenState() { if (this.#accessTokenResponse) { - await this.#storageBackend.setItem(TOKEN_RESPONSE_NAME, JSON.stringify(this.#accessTokenResponse.toJson())); + await this.#storageBackend.setItem( + UMB_STORAGE_TOKEN_RESPONSE_NAME, + JSON.stringify(this.#accessTokenResponse.toJson()), + ); } } diff --git a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts index 4725be3acb..38907f35e4 100644 --- a/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts +++ b/src/Umbraco.Web.UI.Client/src/shared/auth/auth.context.token.ts @@ -2,3 +2,5 @@ import type { UmbAuthContext } from './auth.context.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; export const UMB_AUTH_CONTEXT = new UmbContextToken('UmbAuthContext'); +export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse'; +export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect'; From d40be8cdd81d72c2d77571f0562133eb3c7160de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 25 Jan 2024 13:29:02 +0100 Subject: [PATCH 4/9] refactor --- .../src/libs/element-api/element.mixin.ts | 8 +- .../registry/extension.registry.ts | 62 +++++- .../src/libs/localization-api/index.ts | 4 +- ...est.ts => localization.controller.test.ts} | 20 +- ...ntroller.ts => localization.controller.ts} | 64 +++--- .../localization-api/localization.manager.ts | 106 ++++++++++ .../src/libs/localization-api/manager.ts | 73 ------- .../localization/localize.element.test.ts | 4 +- .../registry/localization.registry.test.ts | 2 +- .../registry/localization.registry.ts | 183 +++++++++--------- .../section-sidebar.element.ts | 2 + .../tree-menu-item-default.element.ts | 5 +- .../user/current-user/current-user.context.ts | 3 +- 13 files changed, 305 insertions(+), 231 deletions(-) rename src/Umbraco.Web.UI.Client/src/libs/localization-api/{localize.controller.test.ts => localization.controller.test.ts} (91%) rename src/Umbraco.Web.UI.Client/src/libs/localization-api/{localize.controller.ts => localization.controller.ts} (72%) create mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts 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 8f3c361bbf..604601de81 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 @@ -1,4 +1,4 @@ -import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { HTMLElementConstructor } from '@umbraco-cms/backoffice/extension-api'; import { UmbControllerHostElementMixin, type UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api'; @@ -34,14 +34,14 @@ export declare class UmbElement extends UmbControllerHostElement { ): UmbContextConsumerController; /** * Use the UmbLocalizeController to localize your element. - * @see UmbLocalizeController + * @see UmbLocalizationController */ - localize: UmbLocalizeController; + localize: UmbLocalizationController; } export const UmbElementMixin = (superClass: T) => { class UmbElementMixinClass extends UmbControllerHostElementMixin(superClass) implements UmbElement { - localize: UmbLocalizeController = new UmbLocalizeController(this); + localize: UmbLocalizationController = new UmbLocalizationController(this); /** * @description Observe a RxJS source of choice. diff --git a/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts b/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts index 2a86b6b220..26622af8fd 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/extension-api/registry/extension.registry.ts @@ -1,15 +1,8 @@ import type { ManifestBase, ManifestKind } from '../types/index.js'; import type { ManifestTypeMap, SpecificManifestTypeOrManifestBase } from '../types/map.types.js'; import { UmbBasicState } from '@umbraco-cms/backoffice/observable-api'; -import type { - Observable} from '@umbraco-cms/backoffice/external/rxjs'; -import { - map, - distinctUntilChanged, - combineLatest, - of, - switchMap, -} from '@umbraco-cms/backoffice/external/rxjs'; +import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { map, distinctUntilChanged, combineLatest, of, switchMap } from '@umbraco-cms/backoffice/external/rxjs'; function extensionArrayMemoization>( previousValue: Array, @@ -213,6 +206,7 @@ export class UmbExtensionRegistry< ) as unknown as Observable>; } + // TODO: get rid of the name get getByAlias(alias: string) { return this.extensions.pipe( map((exts) => exts.find((ext) => ext.alias === alias)), @@ -245,6 +239,7 @@ export class UmbExtensionRegistry< ) as Observable; } + // TODO: get rid of the name get getByTypeAndAlias< Key extends keyof ManifestTypeMap | string, T extends ManifestBase = SpecificManifestTypeOrManifestBase, @@ -275,6 +270,7 @@ export class UmbExtensionRegistry< ) as Observable; } + // TODO: get rid of the name get getByTypeAndAliases< Key extends keyof ManifestTypeMap | string, T extends ManifestBase = SpecificManifestTypeOrManifestBase, @@ -306,6 +302,49 @@ export class UmbExtensionRegistry< ) as Observable>; } + /** + * Get an observable of an extension by type and a given filter method. + * This will return the all extensions that matches the type and which filter method returns true. + * The filter method will be called for each extension manifest of the given type, and the first argument to it is the extension manifest. + * @param type {string} - The type of the extension to get + * @param filter {(ext: T): void} - The filter method to use to filter the extensions + * @returns {Observable} - An observable of the extensions that matches the type and filter method + */ + byTypeAndFilter< + Key extends keyof ManifestTypeMap | string, + T extends ManifestBase = SpecificManifestTypeOrManifestBase, + >(type: Key, filter: (ext: T) => boolean) { + return combineLatest([ + this.extensions.pipe( + map((exts) => exts.find((ext) => ext.type === type && filter(ext as unknown as T))), + distinctUntilChanged(extensionSingleMemoization), + ), + this._kindsOfType(type), + ]).pipe( + map(([ext, kinds]) => { + // TODO: share one merge function between the different methods of this class: + // Specific Extension Meta merge (does not merge conditions) + if (ext) { + const baseManifest = kinds.find((kind) => kind.matchKind === ext.kind)?.manifest; + if (baseManifest) { + const merged = { __isMatchedWithKind: true, ...baseManifest, ...ext } as any; + if ((baseManifest as any).meta) { + merged.meta = { ...(baseManifest as any).meta, ...(ext as any).meta }; + } + return merged; + } + } + return ext; + }), + distinctUntilChanged(extensionAndKindMatchSingleMemoization), + ) as Observable; + } + + /** + * Get an observable that provides extensions matching the given type. + * @param type {string} - The type of the extensions to get. + * @returns {Observable} - An observable of the extensions that matches the type. + */ extensionsOfType< Key extends keyof ManifestTypeMap | string, T extends ManifestBase = SpecificManifestTypeOrManifestBase, @@ -331,6 +370,11 @@ export class UmbExtensionRegistry< ) as Observable>; } + /** + * Get an observable that provides extensions matching given types. + * @param type {Array} - The types of the extensions to get. + * @returns {Observable} - An observable of the extensions that matches the types. + */ extensionsOfTypes( types: string[], ): Observable> { 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 0f691deadf..5527ef1f2d 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,3 +1,3 @@ -export * from './localize.controller.js'; +export * from './localization.controller.js'; export * from './types/localization.js'; -export * from './manager.js'; +export * from './localization.manager.js'; 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/localization.controller.test.ts similarity index 91% rename from src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts rename to src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index 4a64d36c92..3f21914a19 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -1,7 +1,7 @@ import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing'; -import type { DefaultLocalizationSet, LocalizationSet} from './manager.js'; -import { registerLocalization, localizations } from './manager.js'; -import { UmbLocalizeController } from './localize.controller.js'; +import type { UmbLocalizationSet, UmbLocalizationSetBase } from './localization.manager.js'; +import { registerLocalization, localizations } from './localization.manager.js'; +import { UmbLocalizationController } from './localization.controller.js'; import { LitElement, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -11,7 +11,7 @@ class UmbLocalizeControllerHostElement extends UmbElementMixin(LitElement) { @property() lang = 'en-us'; } -interface TestLocalization extends LocalizationSet { +interface TestLocalization extends UmbLocalizationSetBase { close: string; logout: string; withInlineToken: any; @@ -36,20 +36,20 @@ const english: TestLocalization = { }, }; -const englishOverride: DefaultLocalizationSet = { +const englishOverride: UmbLocalizationSet = { $code: 'en-us', $dir: 'ltr', close: 'Close 2', }; -const danish: DefaultLocalizationSet = { +const danish: UmbLocalizationSet = { $code: 'da', $dir: 'ltr', close: 'Luk', notOnRegional: 'Not on regional', }; -const danishRegional: DefaultLocalizationSet = { +const danishRegional: UmbLocalizationSet = { $code: 'da-dk', $dir: 'ltr', close: 'Luk', @@ -57,7 +57,7 @@ const danishRegional: DefaultLocalizationSet = { //#endregion describe('UmbLocalizeController', () => { - let controller: UmbLocalizeController; + let controller: UmbLocalizationController; beforeEach(async () => { registerLocalization(english, danish, danishRegional); @@ -72,7 +72,7 @@ describe('UmbLocalizeController', () => { getControllers: () => [], removeControllerByAlias: () => {}, } satisfies UmbControllerHost; - controller = new UmbLocalizeController(host); + controller = new UmbLocalizationController(host); }); afterEach(() => { @@ -226,7 +226,7 @@ describe('UmbLocalizeController', () => { }); it('should have a localize controller', () => { - expect(element.localize).to.be.instanceOf(UmbLocalizeController); + expect(element.localize).to.be.instanceOf(UmbLocalizationController); }); it('should update the term when the language changes', async () => { 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/localization.controller.ts similarity index 72% rename from src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts rename to src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts index 09d8054575..1d428ebbe6 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localize.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts @@ -11,20 +11,12 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { - DefaultLocalizationSet, - FunctionParams, - LocalizationSet} from './manager.js'; -import { - connectedElements, - documentDirection, - documentLanguage, - fallback, - localizations, -} from './manager.js'; +import type { UmbLocalizationSet, FunctionParams, UmbLocalizationSetBase } from './localization.manager.js'; +import { umbLocalizationManager } from './localization.manager.js'; +import type { LitElement } from '@umbraco-cms/backoffice/external/lit'; import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -const LocalizeControllerAlias = Symbol(); +const LocalizationControllerAlias = Symbol(); /** * The UmbLocalizeController enables localization for your element. * @@ -43,12 +35,11 @@ const LocalizeControllerAlias = Symbol(); * } * ``` */ -export class UmbLocalizeController - implements UmbController -{ +export class UmbLocalizationController implements UmbController { #host; - #hostEl; - controllerAlias = LocalizeControllerAlias; + #hostEl?: HTMLElement & Partial>; + readonly controllerAlias = LocalizationControllerAlias; + #usedKeys = new Array(); constructor(host: UmbControllerHost) { this.#host = host; @@ -57,15 +48,11 @@ export class UmbLocalizeController) { + const hasOneOfTheseKeys = this.#usedKeys.find((key) => changedKeys.has(key)); + + if (hasOneOfTheseKeys) { + this.#hostEl?.requestUpdate?.(); + } + } + /** * Gets the host element's directionality as determined by the `dir` attribute. The return value is transformed to * lowercase. */ dir() { - return `${this.#hostEl.dir || documentDirection}`.toLowerCase(); + return `${this.#hostEl?.dir || umbLocalizationManager.documentDirection}`.toLowerCase(); } /** @@ -86,21 +85,23 @@ export class UmbLocalizeControllerlocalizations.get(`${language}-${region}`); - const secondary = localizations.get(language); + const primary = umbLocalizationManager.localizations.get(`${language}-${region}`); + const secondary = umbLocalizationManager.localizations.get(language); return { locale, language, region, primary, secondary }; } /** Outputs a translated term. */ - term(key: K, ...args: FunctionParams): string { + term(key: K, ...args: FunctionParams): string { + this.#usedKeys.push(key); + const { primary, secondary } = this.getLocalizationData(this.lang()); let term: any; @@ -109,8 +110,11 @@ export class UmbLocalizeController = T extends (...args: infer U) => string ? U : []; + +export interface UmbLocalizationSetBase { + $code: string; // e.g. en, en-GB + $dir: 'ltr' | 'rtl'; +} +export interface UmbLocalizationSet extends UmbLocalizationSetBase { + [key: string]: UmbLocalizationEntry; +} + +export class UmbLocalizationManager { + connectedControllers = new Set(); + #documentElementObserver: MutationObserver; + + #changedKeys: Set = new Set(); + #requestUpdateChangedKeysId?: number = undefined; + + localizations: Map = new Map(); + documentDirection = document.documentElement.dir || 'ltr'; + documentLanguage = document.documentElement.lang || navigator.language; + fallback?: UmbLocalizationSetBase; + + constructor() { + this.#documentElementObserver = new MutationObserver(this.updateAll); + this.#documentElementObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['dir', 'lang'], + }); + } + + appendConsumer(consumer: UmbLocalizationController) { + if (this.connectedControllers.has(consumer)) return; + this.connectedControllers.add(consumer); + } + removeConsumer(consumer: UmbLocalizationController) { + this.connectedControllers.delete(consumer); + } + + /** Registers one or more translations */ + registerLocalization(t: UmbLocalizationSetBase) { + const code = t.$code.toLowerCase(); + + if (this.localizations.has(code)) { + // Merge translations that share the same language code + this.localizations.set(code, { ...this.localizations.get(code), ...t }); + } else { + this.localizations.set(code, t); + } + + // The first translation we registerer will become the fallback + if (!this.fallback) { + this.fallback = t; + } + + this.requestChangedKeysUpdate(); + } + #registerLocalizationBind = this.registerLocalization.bind(this); + + registerManyLocalizations(translations: Array) { + translations.map(this.#registerLocalizationBind); + } + + /** Updates all localized elements that are currently connected */ + updateAll = () => { + this.documentDirection = document.documentElement.dir || 'ltr'; + this.documentLanguage = document.documentElement.lang || navigator.language; + + // Check if there was any changed. + this.connectedControllers.forEach((ctrl) => { + ctrl.documentUpdate(); + }); + + this.#changedKeys.clear(); + }; + + updateChangedKeys = () => { + this.connectedControllers.forEach((ctrl) => { + ctrl.keysChanged(this.#changedKeys); + }); + + this.#changedKeys.clear(); + }; + + requestChangedKeysUpdate() { + if (this.#requestUpdateChangedKeysId) return; + this.#requestUpdateChangedKeysId = requestAnimationFrame(this.updateChangedKeys); + } +} + +export const umbLocalizationManager = new UmbLocalizationManager(); 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 deleted file mode 100644 index 6148e0f52c..0000000000 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/manager.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* -This module is a modified copy of the original Shoelace localize package: https://github.com/shoelace-style/localize - -The original license is included below. - -Copyright (c) 2020 A Beautiful Site, LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ -import type { UmbLocalizationEntry } from './types/localization.js'; -import type { LitElement } from '@umbraco-cms/backoffice/external/lit'; - -export type FunctionParams = T extends (...args: infer U) => string ? U : []; - -export interface LocalizationSet { - $code: string; // e.g. en, en-GB - $dir: 'ltr' | 'rtl'; -} - -export interface DefaultLocalizationSet extends LocalizationSet { - [key: string]: UmbLocalizationEntry; -} - -export const connectedElements = new Set(); -const documentElementObserver = new MutationObserver(update); -export const localizations: Map = new Map(); -export let documentDirection = document.documentElement.dir || 'ltr'; -export let documentLanguage = document.documentElement.lang || navigator.language; -export let fallback: LocalizationSet; - -// Watch for changes on -documentElementObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ['dir', 'lang'], -}); - -/** Registers one or more translations */ -export function registerLocalization(...translation: LocalizationSet[]) { - translation.map((t) => { - const code = t.$code.toLowerCase(); - - if (localizations.has(code)) { - // Merge translations that share the same language code - localizations.set(code, { ...localizations.get(code), ...t }); - } else { - localizations.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') { - // TODO: We might want to implement a specific Umbraco method for informing about this. and then make the default UmbLitElement call requestUpdate..? Cause then others can implement their own solution? - (el as LitElement).requestUpdate(); - } - }); -} 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 3db7df5f70..2280d2be64 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 @@ -2,7 +2,7 @@ import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testin import { UmbLocalizeElement } from './localize.element.js'; import { umbLocalizationRegistry } from '@umbraco-cms/backoffice/localization'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; const english = { type: 'localization', @@ -62,7 +62,7 @@ describe('umb-localize', () => { }); it('should have a localize controller', () => { - expect(element.localize).to.be.instanceOf(UmbLocalizeController); + expect(element.localize).to.be.instanceOf(UmbLocalizationController); }); it('should localize a key', async () => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts index 10ffa5d6e1..82ba527409 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.test.ts @@ -1,6 +1,6 @@ import { aTimeout, expect } from '@open-wc/testing'; import { UmbLocalizationRegistry } from './localization.registry.js'; -import type { ManifestLocalization} from '@umbraco-cms/backoffice/extension-registry'; +import type { ManifestLocalization } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; //#region Localizations diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts index c123b55f54..3af9c6bc7d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts @@ -1,129 +1,122 @@ import type { + UmbLocalizationSetBase, UmbLocalizationDictionary, UmbLocalizationFlatDictionary, - LocalizationSet} from '@umbraco-cms/backoffice/localization-api'; -import { - registerLocalization, - localizations, } from '@umbraco-cms/backoffice/localization-api'; -import { hasDefaultExport, loadManifestPlainJs } from '@umbraco-cms/backoffice/extension-api'; -import type { UmbBackofficeExtensionRegistry} from '@umbraco-cms/backoffice/extension-registry'; +import { umbLocalizationManager } from '@umbraco-cms/backoffice/localization-api'; +import type { ManifestLocalization, UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import { - BehaviorSubject, - Subject, - combineLatest, - map, - distinctUntilChanged, - filter, - startWith, -} from '@umbraco-cms/backoffice/external/rxjs'; +import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { combineLatest } from '@umbraco-cms/backoffice/external/rxjs'; +import { hasDefaultExport, loadManifestPlainJs } from '@umbraco-cms/backoffice/extension-api'; + +function addOrUpdateDictionary( + innerDictionary: UmbLocalizationFlatDictionary, + dictionaryName: string, + dictionary: UmbLocalizationDictionary['value'], +) { + for (const [key, value] of Object.entries(dictionary)) { + innerDictionary[`${dictionaryName}_${key}`] = value; + } +} export class UmbLocalizationRegistry { + // + #isDefaultLoadedPromise?: Promise; + #isDefaultLoadedResolve?: (value: boolean) => void; + + #currentLanguage = new UmbStringState(document.documentElement.lang ?? 'en-us'); + readonly currentLanguage = this.#currentLanguage.asObservable(); + + #loadedExtAliases: Array = []; + /** * Get the current registered translations. */ get localizations() { - return localizations; + return umbLocalizationManager.localizations; } get isDefaultLoaded() { - return this.#isDefaultLoaded.asObservable(); + this.#isDefaultLoadedPromise ??= new Promise((resolve) => { + this.#isDefaultLoadedResolve = resolve; + }); + return this.#isDefaultLoadedPromise; } - #currentLanguage = new Subject(); - #isDefaultLoaded = new BehaviorSubject(false); - constructor(extensionRegistry: UmbBackofficeExtensionRegistry) { - const currentLanguage$ = this.#currentLanguage.pipe( - startWith(document.documentElement.lang || 'en-us'), - map((x) => x.toLowerCase()), - distinctUntilChanged(), - ); + combineLatest([this.currentLanguage, extensionRegistry.extensionsOfType('localization')]).subscribe( + async ([currentLanguage, extensions]) => { + const locale = new Intl.Locale(currentLanguage); + const filteredExt = extensions.filter( + (ext) => + ext.meta.culture.toLowerCase() === locale.baseName.toLowerCase() || + ext.meta.culture.toLowerCase() === locale.language.toLowerCase(), + ); - const currentExtensions$ = extensionRegistry.extensionsOfType('localization').pipe( - filter((x) => x.length > 0), - distinctUntilChanged((prev, curr) => prev.length === curr.length && prev.every((x) => curr.includes(x))), - ); + // Check if there is any difference to the cached aliases + const diff = filteredExt.filter((ext) => !this.#loadedExtAliases.includes(ext.alias)); + if (diff.length !== 0) { + // got new localizations to load: + const translations = await Promise.all(diff.map(this.#loadExtension)); - combineLatest([currentLanguage$, currentExtensions$]).subscribe(async ([userCulture, extensions]) => { - const locale = new Intl.Locale(userCulture); - const translations = await Promise.all( - extensions - .filter( - (x) => - x.meta.culture.toLowerCase() === locale.baseName.toLowerCase() || - x.meta.culture.toLowerCase() === locale.language.toLowerCase(), - ) - .map(async (extension) => { - const innerDictionary: UmbLocalizationFlatDictionary = {}; + if (translations.length) { + umbLocalizationManager.registerManyLocalizations(translations); - // If extension contains a dictionary, add it to the inner dictionary. - if (extension.meta.localizations) { - for (const [dictionaryName, dictionary] of Object.entries(extension.meta.localizations)) { - this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary); - } + // Set the document language + const newLang = locale.baseName.toLowerCase(); + if (document.documentElement.lang.toLowerCase() !== newLang) { + document.documentElement.lang = newLang; } - // If extension contains a js file, load it and add the default dictionary to the inner dictionary. - if (extension.js) { - const loadedExtension = await loadManifestPlainJs(extension.js); - - if (loadedExtension && hasDefaultExport(loadedExtension)) { - for (const [dictionaryName, dictionary] of Object.entries(loadedExtension.default)) { - this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary); - } - } + // Set the document direction to the direction of the primary language + const newDir = translations[0].$dir ?? 'ltr'; + if (document.documentElement.dir !== newDir) { + document.documentElement.dir = newDir; } - - // Notify subscribers that the inner dictionary has changed. - return { - $code: extension.meta.culture.toLowerCase(), - $dir: extension.meta.direction ?? 'ltr', - ...innerDictionary, - } satisfies LocalizationSet; - }), - ); - - if (translations.length) { - registerLocalization(...translations); - - // Set the document language - const newLang = locale.baseName.toLowerCase(); - if (document.documentElement.lang.toLowerCase() !== newLang) { - document.documentElement.lang = newLang; + } } - - // Set the document direction to the direction of the primary language - const newDir = translations[0].$dir ?? 'ltr'; - if (document.documentElement.dir !== newDir) { - document.documentElement.dir = newDir; - } - } - - if (!this.#isDefaultLoaded.value) { - this.#isDefaultLoaded.next(true); - this.#isDefaultLoaded.complete(); - } - }); + }, + ); } + #loadExtension = async (extension: ManifestLocalization) => { + this.#loadedExtAliases.push(extension.alias); + + const innerDictionary: UmbLocalizationFlatDictionary = {}; + + // If extension contains a dictionary, add it to the inner dictionary. + if (extension.meta.localizations) { + for (const [dictionaryName, dictionary] of Object.entries(extension.meta.localizations)) { + addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary); + } + } + + // If extension contains a js file, load it and add the default dictionary to the inner dictionary. + if (extension.js) { + const loadedExtension = await loadManifestPlainJs(extension.js); + + if (loadedExtension && hasDefaultExport(loadedExtension)) { + for (const [dictionaryName, dictionary] of Object.entries(loadedExtension.default)) { + addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary); + } + } + } + + // Notify subscribers that the inner dictionary has changed. + return { + $code: extension.meta.culture.toLowerCase(), + $dir: extension.meta.direction ?? 'ltr', + ...innerDictionary, + } satisfies UmbLocalizationSetBase; + }; + /** * Load a language from the extension registry. * @param locale The locale to load. */ loadLanguage(locale: string) { - this.#currentLanguage.next(locale); - } - - #addOrUpdateDictionary( - innerDictionary: UmbLocalizationFlatDictionary, - dictionaryName: string, - dictionary: UmbLocalizationDictionary['value'], - ) { - for (const [key, value] of Object.entries(dictionary)) { - innerDictionary[`${dictionaryName}_${key}`] = value; - } + this.#currentLanguage.setValue(locale.toLowerCase()); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts index 28fa72bb14..19eeef0b32 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts @@ -13,6 +13,8 @@ export class UmbSectionSidebarElement extends UmbLitElement { } render() { + // TODO: just for testing purpose, should be removed in PR. + console.log('Render', this); return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-menu-item-default/tree-menu-item-default.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-menu-item-default/tree-menu-item-default.element.ts index 7d59390733..f06de51854 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-menu-item-default/tree-menu-item-default.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-menu-item-default/tree-menu-item-default.element.ts @@ -3,10 +3,9 @@ import { UmbLitElement } from '@umbraco-cms/internal/lit-element'; import type { ManifestMenuItemTreeKind, UmbBackofficeManifestKind, - UmbMenuItemElement} from '@umbraco-cms/backoffice/extension-registry'; -import { - umbExtensionsRegistry, + UmbMenuItemElement, } from '@umbraco-cms/backoffice/extension-registry'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; // TODO: Move to separate file: const manifest: UmbBackofficeManifestKind = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts index a41700c52d..ddac865d44 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts @@ -25,8 +25,7 @@ export class UmbCurrentUserContext extends UmbBaseController { this.#observeIsAuthorized(); }); - // TODO: revisit this. It can probably be simplified - this.observe(umbLocalizationRegistry.isDefaultLoaded, (isDefaultLoaded) => { + umbLocalizationRegistry.isDefaultLoaded.then((isDefaultLoaded) => { if (!isDefaultLoaded) return; this.observe( From 83b466cacc7fa0f7e753fd2688915408b2674cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 25 Jan 2024 14:57:04 +0100 Subject: [PATCH 5/9] only update if document lang or dir was changed --- .../localization-api/localization.manager.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts index 31bd88ce99..b5b58c0fc9 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts @@ -78,18 +78,30 @@ export class UmbLocalizationManager { /** Updates all localized elements that are currently connected */ updateAll = () => { - this.documentDirection = document.documentElement.dir || 'ltr'; - this.documentLanguage = document.documentElement.lang || navigator.language; + const newDir = document.documentElement.dir || 'ltr'; + const newLang = document.documentElement.lang || navigator.language; + + if (this.documentDirection === newDir && this.documentLanguage === newLang) return; + + // The document direction or language did changed, so lets move on: + this.documentDirection = newDir; + this.documentLanguage = newLang; // Check if there was any changed. this.connectedControllers.forEach((ctrl) => { ctrl.documentUpdate(); }); + if (this.#requestUpdateChangedKeysId) { + cancelAnimationFrame(this.#requestUpdateChangedKeysId); + this.#requestUpdateChangedKeysId = undefined; + } this.#changedKeys.clear(); }; updateChangedKeys = () => { + this.#requestUpdateChangedKeysId = undefined; + this.connectedControllers.forEach((ctrl) => { ctrl.keysChanged(this.#changedKeys); }); From 444cae8b31a712c0b9cfe27a1c3e9f6f108aa5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 25 Jan 2024 15:15:24 +0100 Subject: [PATCH 6/9] a bit of more restructuring --- .../localization.controller.test.ts | 10 ++--- .../localization.controller.ts | 28 ++++++++------ .../localization-api/localization.manager.ts | 37 +++++++++++-------- .../registry/localization.registry.ts | 13 +------ .../section-sidebar.element.ts | 2 - .../user/current-user/current-user.context.ts | 12 +----- 6 files changed, 46 insertions(+), 56 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index 3f21914a19..695c9b756c 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -1,6 +1,6 @@ import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing'; import type { UmbLocalizationSet, UmbLocalizationSetBase } from './localization.manager.js'; -import { registerLocalization, localizations } from './localization.manager.js'; +import { umbLocalizationManager } from './localization.manager.js'; import { UmbLocalizationController } from './localization.controller.js'; import { LitElement, customElement, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; @@ -57,10 +57,10 @@ const danishRegional: UmbLocalizationSet = { //#endregion describe('UmbLocalizeController', () => { - let controller: UmbLocalizationController; + let controller: UmbLocalizationController; beforeEach(async () => { - registerLocalization(english, danish, danishRegional); + umbLocalizationManager.registerManyLocalizations([english, danish, danishRegional]); document.documentElement.lang = english.$code; document.documentElement.dir = english.$dir; await aTimeout(0); @@ -77,7 +77,7 @@ describe('UmbLocalizeController', () => { afterEach(() => { controller.destroy(); - localizations.clear(); + umbLocalizationManager.localizations.clear(); }); it('should have a default language', () => { @@ -132,7 +132,7 @@ describe('UmbLocalizeController', () => { it('should override a term if new translation is registered', () => { // Let the registry load the new extension - registerLocalization(englishOverride); + umbLocalizationManager.registerLocalization(englishOverride); expect(controller.term('close')).to.equal('Close 2'); }); diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts index 1d428ebbe6..dd20e23ebd 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts @@ -11,7 +11,12 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { UmbLocalizationSet, FunctionParams, UmbLocalizationSetBase } from './localization.manager.js'; +import type { + UmbLocalizationSet, + FunctionParams, + UmbLocalizationSetBase, + UmbLocalizationSetKey, +} from './localization.manager.js'; import { umbLocalizationManager } from './localization.manager.js'; import type { LitElement } from '@umbraco-cms/backoffice/external/lit'; import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -35,11 +40,13 @@ const LocalizationControllerAlias = Symbol(); * } * ``` */ -export class UmbLocalizationController implements UmbController { +export class UmbLocalizationController + implements UmbController +{ #host; #hostEl?: HTMLElement & Partial>; readonly controllerAlias = LocalizationControllerAlias; - #usedKeys = new Array(); + #usedKeys = new Array(); constructor(host: UmbControllerHost) { this.#host = host; @@ -64,7 +71,7 @@ export class UmbLocalizationController implements UmbController { this.#hostEl?.requestUpdate?.(); } - keysChanged(changedKeys: Set) { + keysChanged(changedKeys: Set) { const hasOneOfTheseKeys = this.#usedKeys.find((key) => changedKeys.has(key)); if (hasOneOfTheseKeys) { @@ -92,14 +99,14 @@ export class UmbLocalizationController implements UmbController { const locale = new Intl.Locale(lang); const language = locale?.language.toLowerCase(); const region = locale?.region?.toLowerCase() ?? ''; - const primary = umbLocalizationManager.localizations.get(`${language}-${region}`); - const secondary = umbLocalizationManager.localizations.get(language); + const primary = umbLocalizationManager.localizations.get(`${language}-${region}`) as LocalizationSetType; + const secondary = umbLocalizationManager.localizations.get(language) as LocalizationSetType; return { locale, language, region, primary, secondary }; } /** Outputs a translated term. */ - term(key: K, ...args: FunctionParams): string { + term(key: K, ...args: FunctionParams): string { this.#usedKeys.push(key); const { primary, secondary } = this.getLocalizationData(this.lang()); @@ -110,11 +117,8 @@ export class UmbLocalizationController implements UmbController { term = primary[key]; } else if (secondary && secondary[key]) { term = secondary[key]; - } else if ( - umbLocalizationManager.fallback && - umbLocalizationManager.fallback[key as keyof UmbLocalizationSetBase] - ) { - term = umbLocalizationManager.fallback[key as keyof UmbLocalizationSetBase]; + } else if (umbLocalizationManager.fallback && umbLocalizationManager.fallback[key]) { + term = umbLocalizationManager.fallback[key]; } else { return String(key); } diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts index b5b58c0fc9..e481072374 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts @@ -20,21 +20,29 @@ export interface UmbLocalizationSetBase { $code: string; // e.g. en, en-GB $dir: 'ltr' | 'rtl'; } + +export type UmbLocalizationSetKey = string | number | symbol; + export interface UmbLocalizationSet extends UmbLocalizationSetBase { - [key: string]: UmbLocalizationEntry; + [key: UmbLocalizationSetKey]: UmbLocalizationEntry; } +export const UMB_DEFAULT_LOCALIZATION_CULTURE = 'en-us'; + export class UmbLocalizationManager { - connectedControllers = new Set(); + connectedControllers = new Set>(); #documentElementObserver: MutationObserver; - #changedKeys: Set = new Set(); + #changedKeys: Set = new Set(); #requestUpdateChangedKeysId?: number = undefined; localizations: Map = new Map(); documentDirection = document.documentElement.dir || 'ltr'; documentLanguage = document.documentElement.lang || navigator.language; - fallback?: UmbLocalizationSetBase; + + get fallback(): UmbLocalizationSet | undefined { + return this.localizations.get(UMB_DEFAULT_LOCALIZATION_CULTURE) as UmbLocalizationSet; + } constructor() { this.#documentElementObserver = new MutationObserver(this.updateAll); @@ -44,11 +52,11 @@ export class UmbLocalizationManager { }); } - appendConsumer(consumer: UmbLocalizationController) { + appendConsumer(consumer: UmbLocalizationController) { if (this.connectedControllers.has(consumer)) return; this.connectedControllers.add(consumer); } - removeConsumer(consumer: UmbLocalizationController) { + removeConsumer(consumer: UmbLocalizationController) { this.connectedControllers.delete(consumer); } @@ -63,12 +71,7 @@ export class UmbLocalizationManager { this.localizations.set(code, t); } - // The first translation we registerer will become the fallback - if (!this.fallback) { - this.fallback = t; - } - - this.requestChangedKeysUpdate(); + this.#requestChangedKeysUpdate(); } #registerLocalizationBind = this.registerLocalization.bind(this); @@ -99,7 +102,7 @@ export class UmbLocalizationManager { this.#changedKeys.clear(); }; - updateChangedKeys = () => { + #updateChangedKeys = () => { this.#requestUpdateChangedKeysId = undefined; this.connectedControllers.forEach((ctrl) => { @@ -109,9 +112,13 @@ export class UmbLocalizationManager { this.#changedKeys.clear(); }; - requestChangedKeysUpdate() { + /** + * Request an update of all consumers of the keys defined in #changedKeys. + * This waits one frame, which ensures that multiple changes are collected into one. + */ + #requestChangedKeysUpdate() { if (this.#requestUpdateChangedKeysId) return; - this.#requestUpdateChangedKeysId = requestAnimationFrame(this.updateChangedKeys); + this.#requestUpdateChangedKeysId = requestAnimationFrame(this.#updateChangedKeys); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts index 3af9c6bc7d..708e437f84 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts @@ -21,10 +21,6 @@ function addOrUpdateDictionary( } export class UmbLocalizationRegistry { - // - #isDefaultLoadedPromise?: Promise; - #isDefaultLoadedResolve?: (value: boolean) => void; - #currentLanguage = new UmbStringState(document.documentElement.lang ?? 'en-us'); readonly currentLanguage = this.#currentLanguage.asObservable(); @@ -37,13 +33,6 @@ export class UmbLocalizationRegistry { return umbLocalizationManager.localizations; } - get isDefaultLoaded() { - this.#isDefaultLoadedPromise ??= new Promise((resolve) => { - this.#isDefaultLoadedResolve = resolve; - }); - return this.#isDefaultLoadedPromise; - } - constructor(extensionRegistry: UmbBackofficeExtensionRegistry) { combineLatest([this.currentLanguage, extensionRegistry.extensionsOfType('localization')]).subscribe( async ([currentLanguage, extensions]) => { @@ -54,7 +43,7 @@ export class UmbLocalizationRegistry { ext.meta.culture.toLowerCase() === locale.language.toLowerCase(), ); - // Check if there is any difference to the cached aliases + // Only get the extensions that are not already loading/loaded: const diff = filteredExt.filter((ext) => !this.#loadedExtAliases.includes(ext.alias)); if (diff.length !== 0) { // got new localizations to load: diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts index 19eeef0b32..28fa72bb14 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-sidebar/section-sidebar.element.ts @@ -13,8 +13,6 @@ export class UmbSectionSidebarElement extends UmbLitElement { } render() { - // TODO: just for testing purpose, should be removed in PR. - console.log('Render', this); return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts index ddac865d44..f891318a6d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/current-user.context.ts @@ -25,16 +25,8 @@ export class UmbCurrentUserContext extends UmbBaseController { this.#observeIsAuthorized(); }); - umbLocalizationRegistry.isDefaultLoaded.then((isDefaultLoaded) => { - if (!isDefaultLoaded) return; - - this.observe( - this.languageIsoCode, - (currentLanguageIsoCode) => { - umbLocalizationRegistry.loadLanguage(currentLanguageIsoCode); - }, - 'umbCurrentUserLanguageIsoCode', - ); + this.observe(this.languageIsoCode, (currentLanguageIsoCode) => { + umbLocalizationRegistry.loadLanguage(currentLanguageIsoCode); }); this.provideContext(UMB_CURRENT_USER_CONTEXT, this); From 37a4fee6a1a045952f239c239c9b7d1fb2d640dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 25 Jan 2024 15:50:40 +0100 Subject: [PATCH 7/9] tests --- .../localization.controller.test.ts | 53 ++++++++++++++++++- .../localization.controller.ts | 4 +- .../localization-api/localization.manager.ts | 5 ++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index 695c9b756c..ac1ad635d2 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -11,6 +11,22 @@ class UmbLocalizeControllerHostElement extends UmbElementMixin(LitElement) { @property() lang = 'en-us'; } +@customElement('umb-localization-render-count') +class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) { + amountOfUpdates = 0; + amountOfRenders = 0; + + requestUpdate() { + super.requestUpdate(); + this.amountOfUpdates++; + } + + render() { + this.amountOfRenders++; + return html`${this.localize.term('logout')}`; + } +} + interface TestLocalization extends UmbLocalizationSetBase { close: string; logout: string; @@ -42,6 +58,12 @@ const englishOverride: UmbLocalizationSet = { close: 'Close 2', }; +const englishOverrideLogout: UmbLocalizationSet = { + $code: 'en-us', + $dir: 'ltr', + logout: 'Log out 2', +}; + const danish: UmbLocalizationSet = { $code: 'da', $dir: 'ltr', @@ -130,7 +152,7 @@ describe('UmbLocalizeController', () => { expect(controller.term('logout')).to.equal('Log out'); // Fallback }); - it('should override a term if new translation is registered', () => { + it('should override a term if new localization is registered', () => { // Let the registry load the new extension umbLocalizationManager.registerLocalization(englishOverride); @@ -152,6 +174,35 @@ describe('UmbLocalizeController', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((controller.term as any)('logout', 'Hello', 'World')).to.equal('Log out'); }); + + it('only reacts to changes of its own localization-keys', async () => { + const element: UmbLocalizationRenderCountElement = await fixture( + html``, + ); + expect(element.amountOfUpdates).to.equal(0); + expect(element.amountOfRenders).to.equal(1); + + expect(element.shadowRoot!.textContent).to.equal('Log out'); + expect(element.amountOfUpdates).to.equal(0); + + // Let the registry load the new extension + umbLocalizationManager.registerLocalization(englishOverride); + + await aTimeout(12); + + // This should still be the same (cause it should not be affected as the change did not change our localization key) + expect(element.amountOfUpdates).to.equal(0); + expect(element.shadowRoot!.textContent).to.equal('Log out'); + + // Let the registry load the new extension + umbLocalizationManager.registerLocalization(englishOverrideLogout); + + await aTimeout(12); + + // Now we should have gotten one update and the text should be different + expect(element.amountOfUpdates).to.equal(1); + expect(element.shadowRoot!.textContent).to.equal('Log out 2'); + }); }); describe('date', () => { diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts index dd20e23ebd..767370df80 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.ts @@ -107,7 +107,9 @@ export class UmbLocalizationController(key: K, ...args: FunctionParams): string { - this.#usedKeys.push(key); + if (!this.#usedKeys.includes(key)) { + this.#usedKeys.push(key); + } const { primary, secondary } = this.getLocalizationData(this.lang()); let term: any; diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts index e481072374..226eb5f277 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.manager.ts @@ -71,6 +71,11 @@ export class UmbLocalizationManager { this.localizations.set(code, t); } + // Declare what keys have been changed: + const keys = Object.keys(t); + for (const key of keys) { + this.#changedKeys.add(key); + } this.#requestChangedKeysUpdate(); } #registerLocalizationBind = this.registerLocalization.bind(this); From 7a5d376491b4529487c6b66d623138eba8194826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 26 Jan 2024 08:50:24 +0100 Subject: [PATCH 8/9] unit test --- .../localization.controller.test.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index ac1ad635d2..44f4e7235b 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -16,6 +16,9 @@ class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) { amountOfUpdates = 0; amountOfRenders = 0; + @property({ type: Number }) + test: number = 0; + requestUpdate() { super.requestUpdate(); this.amountOfUpdates++; @@ -23,7 +26,7 @@ class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) { render() { this.amountOfRenders++; - return html`${this.localize.term('logout')}`; + return html`${this.localize.term('logout') + '#' + this.test}`; } } @@ -177,31 +180,44 @@ describe('UmbLocalizeController', () => { it('only reacts to changes of its own localization-keys', async () => { const element: UmbLocalizationRenderCountElement = await fixture( - html``, + html``, ); + + // Something triggers multiple updates initially, and it varies how many it is. So we wait for a timeout to ensure that we have a clean slate and then reset the counter: + await aTimeout(20); + element.amountOfUpdates = 0; + expect(element.amountOfUpdates).to.equal(0); expect(element.amountOfRenders).to.equal(1); - - expect(element.shadowRoot!.textContent).to.equal('Log out'); - expect(element.amountOfUpdates).to.equal(0); + expect(element.shadowRoot!.textContent).to.equal('Log out#10'); // Let the registry load the new extension umbLocalizationManager.registerLocalization(englishOverride); - await aTimeout(12); + // Wait three frames is safe: + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); // This should still be the same (cause it should not be affected as the change did not change our localization key) expect(element.amountOfUpdates).to.equal(0); - expect(element.shadowRoot!.textContent).to.equal('Log out'); + expect(element.amountOfRenders).to.equal(1); + expect(element.shadowRoot!.textContent).to.equal('Log out#10'); // Let the registry load the new extension umbLocalizationManager.registerLocalization(englishOverrideLogout); - await aTimeout(12); + // Wait three frames is safe: + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); // Now we should have gotten one update and the text should be different expect(element.amountOfUpdates).to.equal(1); - expect(element.shadowRoot!.textContent).to.equal('Log out 2'); + expect(element.amountOfRenders).to.equal(2); + expect(element.shadowRoot!.textContent).to.equal('Log out 2#10'); + + console.log('done'); }); }); From eb49484e320473456ec2081af8ed1900a777e3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 26 Jan 2024 08:51:57 +0100 Subject: [PATCH 9/9] clean up test --- .../localization.controller.test.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index 44f4e7235b..9df2003229 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -14,10 +14,6 @@ class UmbLocalizeControllerHostElement extends UmbElementMixin(LitElement) { @customElement('umb-localization-render-count') class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) { amountOfUpdates = 0; - amountOfRenders = 0; - - @property({ type: Number }) - test: number = 0; requestUpdate() { super.requestUpdate(); @@ -25,8 +21,7 @@ class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) { } render() { - this.amountOfRenders++; - return html`${this.localize.term('logout') + '#' + this.test}`; + return html`${this.localize.term('logout')}`; } } @@ -180,16 +175,14 @@ describe('UmbLocalizeController', () => { it('only reacts to changes of its own localization-keys', async () => { const element: UmbLocalizationRenderCountElement = await fixture( - html``, + html``, ); // Something triggers multiple updates initially, and it varies how many it is. So we wait for a timeout to ensure that we have a clean slate and then reset the counter: await aTimeout(20); element.amountOfUpdates = 0; - expect(element.amountOfUpdates).to.equal(0); - expect(element.amountOfRenders).to.equal(1); - expect(element.shadowRoot!.textContent).to.equal('Log out#10'); + expect(element.shadowRoot!.textContent).to.equal('Log out'); // Let the registry load the new extension umbLocalizationManager.registerLocalization(englishOverride); @@ -201,8 +194,7 @@ describe('UmbLocalizeController', () => { // This should still be the same (cause it should not be affected as the change did not change our localization key) expect(element.amountOfUpdates).to.equal(0); - expect(element.amountOfRenders).to.equal(1); - expect(element.shadowRoot!.textContent).to.equal('Log out#10'); + expect(element.shadowRoot!.textContent).to.equal('Log out'); // Let the registry load the new extension umbLocalizationManager.registerLocalization(englishOverrideLogout); @@ -214,10 +206,7 @@ describe('UmbLocalizeController', () => { // Now we should have gotten one update and the text should be different expect(element.amountOfUpdates).to.equal(1); - expect(element.amountOfRenders).to.equal(2); - expect(element.shadowRoot!.textContent).to.equal('Log out 2#10'); - - console.log('done'); + expect(element.shadowRoot!.textContent).to.equal('Log out 2'); }); });