Merge branch 'main' into improvement/document-model

This commit is contained in:
Mads Rasmussen
2024-01-26 12:15:31 +01:00
18 changed files with 442 additions and 327 deletions

View File

@@ -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,11 +193,10 @@ 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
// TODO: find better name for this method
this.#authContext.login();
this.#authContext.makeAuthorizationRequest();
// Return false to prevent the route from being rendered
return false;

View File

@@ -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<BaseType, ResultType>;
/**
* Use the UmbLocalizeController to localize your element.
* @see UmbLocalizeController
* @see UmbLocalizationController
*/
localize: UmbLocalizeController;
localize: UmbLocalizationController;
}
export const UmbElementMixin = <T extends HTMLElementConstructor>(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.

View File

@@ -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<T extends Pick<ManifestBase, 'alias'>>(
previousValue: Array<T>,
@@ -213,6 +206,7 @@ export class UmbExtensionRegistry<
) as unknown as Observable<Array<ExtensionType>>;
}
// TODO: get rid of the name get
getByAlias<T extends ManifestBase = ManifestBase>(alias: string) {
return this.extensions.pipe(
map((exts) => exts.find((ext) => ext.alias === alias)),
@@ -245,6 +239,7 @@ export class UmbExtensionRegistry<
) as Observable<T | undefined>;
}
// TODO: get rid of the name get
getByTypeAndAlias<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>,
@@ -275,6 +270,7 @@ export class UmbExtensionRegistry<
) as Observable<T | undefined>;
}
// TODO: get rid of the name get
getByTypeAndAliases<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>,
@@ -306,6 +302,49 @@ export class UmbExtensionRegistry<
) as Observable<Array<T>>;
}
/**
* 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<T | undefined>} - An observable of the extensions that matches the type and filter method
*/
byTypeAndFilter<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>,
>(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<T | undefined>;
}
/**
* Get an observable that provides extensions matching the given type.
* @param type {string} - The type of the extensions to get.
* @returns {Observable<T | undefined>} - An observable of the extensions that matches the type.
*/
extensionsOfType<
Key extends keyof ManifestTypeMap<ManifestTypes> | string,
T extends ManifestBase = SpecificManifestTypeOrManifestBase<ManifestTypes, Key>,
@@ -331,6 +370,11 @@ export class UmbExtensionRegistry<
) as Observable<Array<T>>;
}
/**
* Get an observable that provides extensions matching given types.
* @param type {Array<string>} - The types of the extensions to get.
* @returns {Observable<T | undefined>} - An observable of the extensions that matches the types.
*/
extensionsOfTypes<ExtensionTypes extends ManifestBase = ManifestBase>(
types: string[],
): Observable<Array<ExtensionTypes>> {

View File

@@ -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';

View File

@@ -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 { 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';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
@@ -11,7 +11,21 @@ class UmbLocalizeControllerHostElement extends UmbElementMixin(LitElement) {
@property() lang = 'en-us';
}
interface TestLocalization extends LocalizationSet {
@customElement('umb-localization-render-count')
class UmbLocalizationRenderCountElement extends UmbElementMixin(LitElement) {
amountOfUpdates = 0;
requestUpdate() {
super.requestUpdate();
this.amountOfUpdates++;
}
render() {
return html`${this.localize.term('logout')}`;
}
}
interface TestLocalization extends UmbLocalizationSetBase {
close: string;
logout: string;
withInlineToken: any;
@@ -36,20 +50,26 @@ const english: TestLocalization = {
},
};
const englishOverride: DefaultLocalizationSet = {
const englishOverride: UmbLocalizationSet = {
$code: 'en-us',
$dir: 'ltr',
close: 'Close 2',
};
const danish: DefaultLocalizationSet = {
const englishOverrideLogout: UmbLocalizationSet = {
$code: 'en-us',
$dir: 'ltr',
logout: 'Log out 2',
};
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,10 +77,10 @@ const danishRegional: DefaultLocalizationSet = {
//#endregion
describe('UmbLocalizeController', () => {
let controller: UmbLocalizeController<TestLocalization>;
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);
@@ -72,12 +92,12 @@ describe('UmbLocalizeController', () => {
getControllers: () => [],
removeControllerByAlias: () => {},
} satisfies UmbControllerHost;
controller = new UmbLocalizeController(host);
controller = new UmbLocalizationController(host);
});
afterEach(() => {
controller.destroy();
localizations.clear();
umbLocalizationManager.localizations.clear();
});
it('should have a default language', () => {
@@ -130,9 +150,9 @@ 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
registerLocalization(englishOverride);
umbLocalizationManager.registerLocalization(englishOverride);
expect(controller.term('close')).to.equal('Close 2');
});
@@ -152,6 +172,42 @@ 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`<umb-localization-render-count></umb-localization-render-count>`,
);
// 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.shadowRoot!.textContent).to.equal('Log out');
// Let the registry load the new extension
umbLocalizationManager.registerLocalization(englishOverride);
// 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');
// Let the registry load the new extension
umbLocalizationManager.registerLocalization(englishOverrideLogout);
// 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');
});
});
describe('date', () => {
@@ -226,7 +282,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 () => {

View File

@@ -12,19 +12,16 @@ 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,
UmbLocalizationSet,
FunctionParams,
LocalizationSet} from './manager.js';
import {
connectedElements,
documentDirection,
documentLanguage,
fallback,
localizations,
} from './manager.js';
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';
const LocalizeControllerAlias = Symbol();
const LocalizationControllerAlias = Symbol();
/**
* The UmbLocalizeController enables localization for your element.
*
@@ -43,12 +40,13 @@ const LocalizeControllerAlias = Symbol();
* }
* ```
*/
export class UmbLocalizeController<LocalizationType extends LocalizationSet = DefaultLocalizationSet>
export class UmbLocalizationController<LocalizationSetType extends UmbLocalizationSetBase = UmbLocalizationSet>
implements UmbController
{
#host;
#hostEl;
controllerAlias = LocalizeControllerAlias;
#hostEl?: HTMLElement & Partial<Pick<LitElement, 'requestUpdate'>>;
readonly controllerAlias = LocalizationControllerAlias;
#usedKeys = new Array<UmbLocalizationSetKey>();
constructor(host: UmbControllerHost) {
this.#host = host;
@@ -57,15 +55,11 @@ export class UmbLocalizeController<LocalizationType extends LocalizationSet = De
}
hostConnected(): void {
if (connectedElements.has(this.#hostEl)) {
return;
}
connectedElements.add(this.#hostEl);
umbLocalizationManager.appendConsumer(this);
}
hostDisconnected(): void {
connectedElements.delete(this.#hostEl);
umbLocalizationManager.removeConsumer(this);
}
destroy(): void {
@@ -73,12 +67,24 @@ export class UmbLocalizeController<LocalizationType extends LocalizationSet = De
this.#hostEl = undefined as any;
}
documentUpdate() {
this.#hostEl?.requestUpdate?.();
}
keysChanged(changedKeys: Set<UmbLocalizationSetKey>) {
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 +92,25 @@ export class UmbLocalizeController<LocalizationType extends LocalizationSet = De
* lowercase.
*/
lang() {
return `${this.#hostEl.lang || documentLanguage}`.toLowerCase();
return `${this.#hostEl?.lang || umbLocalizationManager.documentLanguage}`.toLowerCase();
}
private getLocalizationData(lang: string) {
const locale = new Intl.Locale(lang);
const language = locale?.language.toLowerCase();
const region = locale?.region?.toLowerCase() ?? '';
const primary = <LocalizationType>localizations.get(`${language}-${region}`);
const secondary = <LocalizationType>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<K extends keyof LocalizationType>(key: K, ...args: FunctionParams<LocalizationType[K]>): string {
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
if (!this.#usedKeys.includes(key)) {
this.#usedKeys.push(key);
}
const { primary, secondary } = this.getLocalizationData(this.lang());
let term: any;
@@ -109,8 +119,8 @@ export class UmbLocalizeController<LocalizationType extends LocalizationSet = De
term = primary[key];
} else if (secondary && secondary[key]) {
term = secondary[key];
} else if (fallback && fallback[key as keyof LocalizationSet]) {
term = fallback[key as keyof LocalizationSet];
} else if (umbLocalizationManager.fallback && umbLocalizationManager.fallback[key]) {
term = umbLocalizationManager.fallback[key];
} else {
return String(key);
}

View File

@@ -0,0 +1,130 @@
/*
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 { UmbLocalizationController } from './localization.controller.js';
import type { UmbLocalizationEntry } from './types/localization.js';
export type FunctionParams<T> = T extends (...args: infer U) => string ? U : [];
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: UmbLocalizationSetKey]: UmbLocalizationEntry;
}
export const UMB_DEFAULT_LOCALIZATION_CULTURE = 'en-us';
export class UmbLocalizationManager {
connectedControllers = new Set<UmbLocalizationController<UmbLocalizationSetBase>>();
#documentElementObserver: MutationObserver;
#changedKeys: Set<UmbLocalizationSetKey> = new Set();
#requestUpdateChangedKeysId?: number = undefined;
localizations: Map<string, UmbLocalizationSetBase> = new Map();
documentDirection = document.documentElement.dir || 'ltr';
documentLanguage = document.documentElement.lang || navigator.language;
get fallback(): UmbLocalizationSet | undefined {
return this.localizations.get(UMB_DEFAULT_LOCALIZATION_CULTURE) as UmbLocalizationSet;
}
constructor() {
this.#documentElementObserver = new MutationObserver(this.updateAll);
this.#documentElementObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['dir', 'lang'],
});
}
appendConsumer(consumer: UmbLocalizationController<UmbLocalizationSetBase>) {
if (this.connectedControllers.has(consumer)) return;
this.connectedControllers.add(consumer);
}
removeConsumer(consumer: UmbLocalizationController<UmbLocalizationSetBase>) {
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);
}
// 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);
registerManyLocalizations(translations: Array<UmbLocalizationSetBase>) {
translations.map(this.#registerLocalizationBind);
}
/** Updates all localized elements that are currently connected */
updateAll = () => {
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);
});
this.#changedKeys.clear();
};
/**
* 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);
}
}
export const umbLocalizationManager = new UmbLocalizationManager();

View File

@@ -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> = 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<HTMLElement>();
const documentElementObserver = new MutationObserver(update);
export const localizations: Map<string, LocalizationSet> = 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 <html lang>
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();
}
});
}

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -1,129 +1,111 @@
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 {
#currentLanguage = new UmbStringState(document.documentElement.lang ?? 'en-us');
readonly currentLanguage = this.#currentLanguage.asObservable();
#loadedExtAliases: Array<string> = [];
/**
* Get the current registered translations.
*/
get localizations() {
return localizations;
return umbLocalizationManager.localizations;
}
get isDefaultLoaded() {
return this.#isDefaultLoaded.asObservable();
}
#currentLanguage = new Subject<string>();
#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))),
);
// 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:
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<UmbLocalizationDictionary>(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<UmbLocalizationDictionary>(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());
}
}

View File

@@ -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 = {

View File

@@ -25,17 +25,8 @@ export class UmbCurrentUserContext extends UmbBaseController {
this.#observeIsAuthorized();
});
// TODO: revisit this. It can probably be simplified
this.observe(umbLocalizationRegistry.isDefaultLoaded, (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);

View File

@@ -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()) {
@@ -170,9 +174,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();
}
/**
@@ -228,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;
@@ -313,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()),
);
}
}

View File

@@ -1,46 +0,0 @@
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
export interface IUmbAuthContext {
isAuthorized: Observable<boolean>;
/**
* Initiates the login flow.
*/
login(): void;
/**
* Initialise the auth flow.
*/
setInitialState(): Promise<void>;
/**
* 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<string>;
/**
* Clears the token storage.
*/
clearTokenStorage(): Promise<void>;
/**
* Signs the user out by removing any tokens from the browser.
*/
signOut(): Promise<void>;
}

View File

@@ -1,4 +1,6 @@
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<IUmbAuthContext>('UmbAuthContext');
export const UMB_AUTH_CONTEXT = new UmbContextToken<UmbAuthContext>('UmbAuthContext');
export const UMB_STORAGE_TOKEN_RESPONSE_NAME = 'umb:userAuthTokenResponse';
export const UMB_STORAGE_REDIRECT_URL = 'umb:auth:redirect';

View File

@@ -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<boolean>(false);
readonly isAuthorized = this.#isAuthorized.asObservable();
@@ -26,13 +25,20 @@ export class UmbAuthContext extends UmbBaseController implements IUmbAuthContext
/**
* 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) {
@@ -59,6 +65,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<string> {
@@ -75,7 +88,6 @@ export class UmbAuthContext extends UmbBaseController implements IUmbAuthContext
/**
* Signs the user out by removing any tokens from the browser.
* @return {*} {Promise<void>}
* @memberof UmbAuthContext
*/
signOut(): Promise<void> {

View File

@@ -1,3 +1,2 @@
export * from './auth.context.interface.js';
export * from './auth.context.js';
export * from './auth.context.token.js';