Merge pull request #817 from umbraco/feature/localization
This commit is contained in:
@@ -17,6 +17,7 @@ import { UmbDocumentTypeStore } from '../src/packages/documents/document-types/r
|
||||
import { umbExtensionsRegistry } from '../src/packages/core/extension-registry';
|
||||
import { UmbIconRegistry } from '../src/shared/icon-registry/icon.registry';
|
||||
import { UmbLitElement } from '../src/shared/lit-element';
|
||||
import { UmbTranslationRegistry } from '../src/libs/localization-api';
|
||||
import customElementManifests from '../dist-cms/custom-elements.json';
|
||||
|
||||
import '../src/libs/context-api/provide/context-provider.element';
|
||||
@@ -24,6 +25,7 @@ import '../src/libs/controller-api/controller-host-initializer.element.ts';
|
||||
import '../src/packages/core/components';
|
||||
|
||||
import { manifests as documentManifests } from '../src/packages/documents';
|
||||
import { manifests as translationManifests } from '../src/packages/core/localization/manifests';
|
||||
|
||||
// MSW
|
||||
startMockServiceWorker({ serviceWorker: { url: (import.meta.env.VITE_BASE_PATH ?? '/') + 'mockServiceWorker.js' } });
|
||||
@@ -36,6 +38,9 @@ class UmbStoryBookElement extends UmbLitElement {
|
||||
this._umbIconRegistry.attach(this);
|
||||
this._registerExtensions(documentManifests);
|
||||
this.provideContext(UMB_MODAL_CONTEXT_TOKEN, new UmbModalManagerContext(this));
|
||||
|
||||
this._registerExtensions(translationManifests);
|
||||
new UmbTranslationRegistry(umbExtensionsRegistry).loadLanguage('en-us'); // register default language
|
||||
}
|
||||
|
||||
_registerExtensions(manifests) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<base href="/" />
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./controller-api": "./dist-cms/libs/controller-api/index.js",
|
||||
"./element-api": "./dist-cms/libs/element-api/index.js",
|
||||
"./extension-api": "./dist-cms/libs/extension-api/index.js",
|
||||
"./localization-api": "./dist-cms/libs/localization-api/index.js",
|
||||
"./observable-api": "./dist-cms/libs/observable-api/index.js",
|
||||
"./auth": "./dist-cms/shared/auth/index.js",
|
||||
"./context": "./dist-cms/shared/context/index.js",
|
||||
@@ -86,11 +87,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --project ./src/tsconfig.json && rollup -c ./src/rollup.config.js",
|
||||
"build": "tsc --project ./src/tsconfig.build.json && rollup -c ./src/rollup.config.js",
|
||||
"build:vite": "tsc && vite build --mode staging",
|
||||
"build:for:static": "vite build",
|
||||
"build:for:cms": "npm run build && node ./devops/build/copy-to-cms.js",
|
||||
"build:for:npm": "npm run build && tsc-alias -f -p src/tsconfig.json && npm run generate:jsonschema:dist && npm run wc-analyze && npm run wc-analyze:vscode",
|
||||
"build:for:npm": "npm run build && tsc-alias -f -p src/tsconfig.build.json && npm run generate:jsonschema:dist && npm run wc-analyze && npm run wc-analyze:vscode",
|
||||
"preview": "vite preview --open",
|
||||
"test": "web-test-runner --coverage",
|
||||
"test:watch": "web-test-runner --watch",
|
||||
@@ -100,7 +101,7 @@
|
||||
"lint": "eslint src apps e2e",
|
||||
"lint:errors": "npm run lint -- --quiet",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "prettier 'src/**/*.ts'",
|
||||
"format": "prettier 'src/**/*.ts' -- check",
|
||||
"format:fix": "npm run format -- --write",
|
||||
"generate:api": "openapi --input https://raw.githubusercontent.com/umbraco/Umbraco-CMS/v14/dev/src/Umbraco.Cms.Api.Management/OpenApi.json --output src/external/backend-api/src --postfixServices Resource --useOptions",
|
||||
"generate:api-dev": "openapi --input http://localhost:11000/umbraco/swagger/management/swagger.json --output src/external/backend-api/src --postfixServices Resource --useOptions",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { pathWithoutBasePath } from '@umbraco-cms/backoffice/router';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
import { OpenAPI, RuntimeLevelModel, ServerResource } from '@umbraco-cms/backoffice/backend-api';
|
||||
import { contextData, umbDebugContextEventType } from '@umbraco-cms/backoffice/context-api';
|
||||
import { umbTranslationRegistry } from '@umbraco-cms/backoffice/localization-api';
|
||||
|
||||
@customElement('umb-app')
|
||||
export class UmbAppElement extends UmbLitElement {
|
||||
@@ -22,6 +23,19 @@ export class UmbAppElement extends UmbLitElement {
|
||||
@property({ type: String })
|
||||
serverUrl = window.location.origin;
|
||||
|
||||
/**
|
||||
* The default culture to use for localization.
|
||||
*
|
||||
* When the current user is resolved, the culture will be set to the user's culture.
|
||||
*
|
||||
* @attr
|
||||
* @remarks This is the default culture to use for localization, not the current culture.
|
||||
* @example "en-us"
|
||||
* @example "en"
|
||||
*/
|
||||
@property({ type: String, attribute: 'default-culture' })
|
||||
culture: string = 'en-us';
|
||||
|
||||
/**
|
||||
* The base path of the backoffice.
|
||||
*
|
||||
@@ -33,7 +47,6 @@ export class UmbAppElement extends UmbLitElement {
|
||||
|
||||
/**
|
||||
* Bypass authentication.
|
||||
* @type {boolean}
|
||||
*/
|
||||
// TODO: this might not be the right solution
|
||||
@property({ type: Boolean })
|
||||
@@ -70,9 +83,25 @@ export class UmbAppElement extends UmbLitElement {
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#setLanguage();
|
||||
this.#setup();
|
||||
}
|
||||
|
||||
#setLanguage() {
|
||||
umbTranslationRegistry.loadLanguage(this.culture);
|
||||
}
|
||||
|
||||
#listenForLanguageChange(authContext: UmbAuthContext) {
|
||||
this.observe(
|
||||
authContext.languageIsoCode,
|
||||
(currentLanguageIsoCode) => {
|
||||
umbTranslationRegistry.loadLanguage(currentLanguageIsoCode);
|
||||
},
|
||||
'languageIsoCode'
|
||||
);
|
||||
}
|
||||
|
||||
async #setup() {
|
||||
if (this.serverUrl === undefined) throw new Error('No serverUrl provided');
|
||||
|
||||
@@ -165,6 +194,8 @@ export class UmbAppElement extends UmbLitElement {
|
||||
OpenAPI.WITH_CREDENTIALS = true;
|
||||
}
|
||||
|
||||
this.#listenForLanguageChange(authContext);
|
||||
|
||||
authContext.isLoggedIn.next(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UmbExtensionInitializer } from './extension.controller.js';
|
||||
import { UmbBackofficeContext, UMB_BACKOFFICE_CONTEXT_TOKEN } from './backoffice.context.js';
|
||||
import { UmbExtensionInitializer } from './extension.controller.js';
|
||||
import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
|
||||
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
@@ -29,15 +29,14 @@ const CORE_PACKAGES = [
|
||||
|
||||
@customElement('umb-backoffice')
|
||||
export class UmbBackofficeElement extends UmbLitElement {
|
||||
#extensionInitializer = new UmbExtensionInitializer(this, umbExtensionsRegistry);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.provideContext(UMB_BACKOFFICE_CONTEXT_TOKEN, new UmbBackofficeContext());
|
||||
new UmbBundleExtensionInitializer(this, umbExtensionsRegistry);
|
||||
new UmbEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
new UmbExtensionInitializer(this, umbExtensionsRegistry);
|
||||
this.#extensionInitializer.setLocalPackages(CORE_PACKAGES);
|
||||
|
||||
const extensionInitializer = new UmbExtensionInitializer(this, umbExtensionsRegistry);
|
||||
extensionInitializer.setLocalPackages(CORE_PACKAGES);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
3246
src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts
Normal file
3246
src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts
Normal file
File diff suppressed because it is too large
Load Diff
3219
src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts
Normal file
3219
src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,16 @@
|
||||
import { HTMLElementConstructor } from '../extension-api/types.js';
|
||||
import { UmbControllerAlias } from './controller-alias.type.js';
|
||||
import type { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
|
||||
import type { HTMLElementConstructor } from '../extension-api/types.js';
|
||||
import type { UmbControllerAlias } from './controller-alias.type.js';
|
||||
import { UmbControllerHostBaseMixin } from './controller-host-base.mixin.js';
|
||||
import { UmbControllerHost } from './controller-host.interface.js';
|
||||
import type { UmbControllerHost } from './controller-host.interface.js';
|
||||
import type { UmbController } from './controller.interface.js';
|
||||
|
||||
export declare class UmbControllerHostElement extends HTMLElement implements UmbControllerHost {
|
||||
/**
|
||||
* Use the UmbLocalizeController to localize your element.
|
||||
* @see UmbLocalizeController
|
||||
*/
|
||||
localize: UmbLocalizeController;
|
||||
hasController(controller: UmbController): boolean;
|
||||
getControllers(filterMethod: (ctrl: UmbController) => boolean): UmbController[];
|
||||
addController(controller: UmbController): void;
|
||||
|
||||
@@ -8,12 +8,21 @@ import {
|
||||
UmbContextConsumerController,
|
||||
UmbContextProviderController,
|
||||
} from '@umbraco-cms/backoffice/context-api';
|
||||
import { UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { ObserverCallback, UmbObserverController } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
|
||||
import { property } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
export declare class UmbElement extends UmbControllerHostElement {
|
||||
/**
|
||||
* @description Observe a RxJS source of choice.
|
||||
* @param {Observable<T>} source RxJS source
|
||||
* @param {method} callback Callback method called when data is changed.
|
||||
* @return {UmbObserverController} Reference to a Observer Controller instance
|
||||
* @memberof UmbElementMixin
|
||||
*/
|
||||
observe<T>(
|
||||
source: Observable<T> | { asObservable: () => Observable<T> },
|
||||
callback: (_value: T) => void,
|
||||
callback: ObserverCallback<T>,
|
||||
unique?: string
|
||||
): UmbObserverController<T>;
|
||||
provideContext<R = unknown>(alias: string | UmbContextToken<R>, instance: R): UmbContextProviderController<R>;
|
||||
@@ -25,6 +34,12 @@ export declare class UmbElement extends UmbControllerHostElement {
|
||||
|
||||
export const UmbElementMixin = <T extends HTMLElementConstructor>(superClass: T) => {
|
||||
class UmbElementMixinClass extends UmbControllerHostElementMixin(superClass) implements UmbElement {
|
||||
// Make `dir` and `lang` reactive properties so they react to language changes:
|
||||
@property() dir = '';
|
||||
@property() lang = '';
|
||||
|
||||
localize: UmbLocalizeController = new UmbLocalizeController(this);
|
||||
|
||||
/**
|
||||
* @description Observe a RxJS source of choice.
|
||||
* @param {Observable<T>} source RxJS source
|
||||
@@ -34,7 +49,7 @@ export const UmbElementMixin = <T extends HTMLElementConstructor>(superClass: T)
|
||||
*/
|
||||
observe<T>(
|
||||
source: Observable<T> | { asObservable: () => Observable<T> },
|
||||
callback: (_value: T) => void,
|
||||
callback: ObserverCallback<T>,
|
||||
unique?: string
|
||||
) {
|
||||
return new UmbObserverController<T>(
|
||||
|
||||
@@ -100,6 +100,14 @@ export interface ManifestClassWithClassConstructor<T = unknown> extends Manifest
|
||||
class: ClassConstructor<T>;
|
||||
}
|
||||
|
||||
export interface ManifestWithLoaderIncludingDefaultExport<T = unknown>
|
||||
extends ManifestWithLoader<{ default: T } | Omit<object, 'default'>> {
|
||||
/**
|
||||
* The file location of the javascript file to load
|
||||
*/
|
||||
js?: string;
|
||||
}
|
||||
|
||||
export interface ManifestElement<ElementType extends HTMLElement = HTMLElement>
|
||||
extends ManifestWithLoader<{ default: ClassConstructor<ElementType> } | Omit<object, 'default'>> {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './registry/translation.registry.js';
|
||||
export * from './localize.controller.js';
|
||||
export { registerTranslation } from './manager.js';
|
||||
@@ -0,0 +1,256 @@
|
||||
import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing';
|
||||
import { UmbLocalizeController } from './localize.controller.js';
|
||||
import { umbTranslationRegistry } from './registry/translation.registry.js';
|
||||
import { customElement, property } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
import { ManifestTranslations, umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
|
||||
@customElement('umb-localize-controller-host')
|
||||
class UmbLocalizeControllerHostElement extends UmbLitElement {
|
||||
@property()
|
||||
lang = 'en-us';
|
||||
}
|
||||
|
||||
//#region Translations
|
||||
const english: ManifestTranslations = {
|
||||
type: 'translations',
|
||||
alias: 'test.en',
|
||||
name: 'Test English',
|
||||
meta: {
|
||||
culture: 'en-us',
|
||||
direction: 'ltr',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Close',
|
||||
logout: 'Log out',
|
||||
withInlineToken: '{0} {1}',
|
||||
withInlineTokenLegacy: '%0% %1%',
|
||||
numUsersSelected: (count: number) => {
|
||||
if (count === 0) return 'No users selected';
|
||||
if (count === 1) return 'One user selected';
|
||||
return `${count} users selected`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const englishOverride: ManifestTranslations = {
|
||||
type: 'translations',
|
||||
alias: 'test.en.override',
|
||||
name: 'Test English',
|
||||
meta: {
|
||||
culture: 'en-us',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Close 2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const danish: ManifestTranslations = {
|
||||
type: 'translations',
|
||||
alias: 'test.da',
|
||||
name: 'Test Danish',
|
||||
meta: {
|
||||
culture: 'da',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Luk',
|
||||
notOnRegional: 'Not on regional',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const danishRegional: ManifestTranslations = {
|
||||
type: 'translations',
|
||||
alias: 'test.da-DK',
|
||||
name: 'Test Danish (Denmark)',
|
||||
meta: {
|
||||
culture: 'da-DK',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Luk',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
//#endregion
|
||||
|
||||
describe('UmbLocalizeController', () => {
|
||||
umbExtensionsRegistry.register(english);
|
||||
umbExtensionsRegistry.register(danish);
|
||||
umbExtensionsRegistry.register(danishRegional);
|
||||
|
||||
let element: UmbLocalizeControllerHostElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
umbTranslationRegistry.loadLanguage(english.meta.culture);
|
||||
element = await fixture(html`<umb-localize-controller-host></umb-localize-controller-host>`);
|
||||
});
|
||||
|
||||
it('should have a localize controller', () => {
|
||||
expect(element.localize).to.be.instanceOf(UmbLocalizeController);
|
||||
});
|
||||
|
||||
it('should have a default language', () => {
|
||||
expect(element.localize.lang()).to.equal(english.meta.culture);
|
||||
});
|
||||
|
||||
it('should have a default dir', () => {
|
||||
expect(element.localize.dir()).to.equal(english.meta.direction);
|
||||
});
|
||||
|
||||
describe('term', () => {
|
||||
it('should return a term', async () => {
|
||||
expect(element.localize.term('general_close')).to.equal('Close');
|
||||
});
|
||||
|
||||
it('should update the term when the language changes', async () => {
|
||||
// Load Danish
|
||||
umbTranslationRegistry.loadLanguage(danishRegional.meta.culture);
|
||||
await aTimeout(0);
|
||||
expect(document.documentElement.lang).to.equal(danishRegional.meta.culture);
|
||||
|
||||
// Force an element update as well
|
||||
element.lang = danishRegional.meta.culture;
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.term('general_close')).to.equal('Luk');
|
||||
});
|
||||
|
||||
it('should update the term when the dir changes', async () => {
|
||||
expect(element.localize.term('general_close')).to.equal('Close');
|
||||
element.dir = 'rtl';
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.term('general_close')).to.equal('Close');
|
||||
});
|
||||
|
||||
it('should provide a secondary term when the term is not found', async () => {
|
||||
// Load Danish
|
||||
umbTranslationRegistry.loadLanguage(danishRegional.meta.culture);
|
||||
await aTimeout(0);
|
||||
|
||||
element.lang = danishRegional.meta.culture;
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.term('general_notOnRegional')).to.equal('Not on regional');
|
||||
});
|
||||
|
||||
it('should provide a fallback term when the term is not found', async () => {
|
||||
// Load Danish
|
||||
umbTranslationRegistry.loadLanguage(danishRegional.meta.culture);
|
||||
await aTimeout(0);
|
||||
|
||||
element.lang = danishRegional.meta.culture;
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.term('general_close')).to.equal('Luk');
|
||||
expect(element.localize.term('general_logout')).to.equal('Log out');
|
||||
});
|
||||
|
||||
it('should override a term if new extension is registered', async () => {
|
||||
umbExtensionsRegistry.register(englishOverride);
|
||||
// Let the registry load the new extension
|
||||
await aTimeout(0);
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.term('general_close')).to.equal('Close 2');
|
||||
});
|
||||
|
||||
it('should return a term with a custom format', async () => {
|
||||
expect(element.localize.term('general_numUsersSelected', 0)).to.equal('No users selected');
|
||||
expect(element.localize.term('general_numUsersSelected', 1)).to.equal('One user selected');
|
||||
expect(element.localize.term('general_numUsersSelected', 2)).to.equal('2 users selected');
|
||||
});
|
||||
|
||||
it('should return a term with a custom format with inline tokens', async () => {
|
||||
expect(element.localize.term('general_withInlineToken', 'Hello', 'World')).to.equal('Hello World');
|
||||
expect(element.localize.term('general_withInlineTokenLegacy', 'Hello', 'World')).to.equal('Hello World');
|
||||
});
|
||||
|
||||
it('should return a term with no tokens even though they are provided', async () => {
|
||||
expect(element.localize.term('general_logout', 'Hello', 'World')).to.equal('Log out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('date', () => {
|
||||
it('should return a date', async () => {
|
||||
expect(element.localize.date(new Date(2020, 0, 1))).to.equal('1/1/2020');
|
||||
});
|
||||
|
||||
it('should accept a string input', async () => {
|
||||
expect(element.localize.date('2020-01-01')).to.equal('1/1/2020');
|
||||
});
|
||||
|
||||
it('should update the date when the language changes', async () => {
|
||||
expect(element.localize.date(new Date(2020, 11, 31))).to.equal('12/31/2020');
|
||||
|
||||
// Switch browser to Danish
|
||||
element.lang = danish.meta.culture;
|
||||
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.date(new Date(2020, 11, 31))).to.equal('31.12.2020');
|
||||
});
|
||||
|
||||
it('should update the date when the dir changes', async () => {
|
||||
expect(element.localize.date(new Date(2020, 11, 31))).to.equal('12/31/2020');
|
||||
element.dir = 'rtl';
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.date(new Date(2020, 11, 31))).to.equal('12/31/2020');
|
||||
});
|
||||
|
||||
it('should return a date with a custom format', async () => {
|
||||
expect(
|
||||
element.localize.date(new Date(2020, 11, 31), { month: 'long', day: '2-digit', year: 'numeric' })
|
||||
).to.equal('December 31, 2020');
|
||||
});
|
||||
});
|
||||
|
||||
describe('number', () => {
|
||||
it('should return a number', async () => {
|
||||
expect(element.localize.number(123456.789)).to.equal('123,456.789');
|
||||
});
|
||||
|
||||
it('should accept a string input', async () => {
|
||||
expect(element.localize.number('123456.789')).to.equal('123,456.789');
|
||||
});
|
||||
|
||||
it('should update the number when the language changes', async () => {
|
||||
expect(element.localize.number(123456.789)).to.equal('123,456.789');
|
||||
|
||||
// Switch browser to Danish
|
||||
element.lang = danish.meta.culture;
|
||||
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.number(123456.789)).to.equal('123.456,789');
|
||||
});
|
||||
|
||||
it('should update the number when the dir changes', async () => {
|
||||
expect(element.localize.number(123456.789)).to.equal('123,456.789');
|
||||
element.dir = 'rtl';
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.number(123456.789)).to.equal('123,456.789');
|
||||
});
|
||||
|
||||
it('should return a number with a custom format', async () => {
|
||||
expect(element.localize.number(123456.789, { minimumFractionDigits: 2, maximumFractionDigits: 2 })).to.equal(
|
||||
'123,456.79'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relative time', () => {
|
||||
it('should return a relative time', async () => {
|
||||
expect(element.localize.relativeTime(2, 'days')).to.equal('in 2 days');
|
||||
});
|
||||
|
||||
it('should update the relative time when the language changes', async () => {
|
||||
expect(element.localize.relativeTime(2, 'days')).to.equal('in 2 days');
|
||||
|
||||
// Switch browser to Danish
|
||||
element.lang = danish.meta.culture;
|
||||
|
||||
await elementUpdated(element);
|
||||
expect(element.localize.relativeTime(2, 'days')).to.equal('om 2 dage');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
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 {
|
||||
DefaultTranslationSet,
|
||||
FunctionParams,
|
||||
TranslationSet,
|
||||
connectedElements,
|
||||
documentDirection,
|
||||
documentLanguage,
|
||||
fallback,
|
||||
translations,
|
||||
} from './manager.js';
|
||||
import { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
const LocalizeControllerAlias = Symbol();
|
||||
/**
|
||||
* The UmbLocalizeController enables localization for your element.
|
||||
*
|
||||
* @see UmbLocalizeElement
|
||||
* @example
|
||||
* ```ts
|
||||
* import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
|
||||
*
|
||||
* \@customElement('my-element')
|
||||
* export class MyElement extends LitElement {
|
||||
* private localize = new UmbLocalizeController(this);
|
||||
*
|
||||
* render() {
|
||||
* return html`<p>${this.localize.term('general_close')}</p>`;
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class UmbLocalizeController<TranslationType extends TranslationSet = DefaultTranslationSet>
|
||||
implements UmbController
|
||||
{
|
||||
#host;
|
||||
#hostEl;
|
||||
controllerAlias = LocalizeControllerAlias;
|
||||
|
||||
constructor(host: UmbControllerHost) {
|
||||
this.#host = host;
|
||||
this.#hostEl = host.getHostElement() as HTMLElement;
|
||||
this.#host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
if (connectedElements.has(this.#hostEl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedElements.add(this.#hostEl);
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
connectedElements.delete(this.#hostEl);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// We do not need to call delete here, as hostDisconnected is called when controller is removed.
|
||||
//connectedElements.delete(this.host);
|
||||
this.#host.removeController(this);
|
||||
this.#hostEl = undefined as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the host element's language as determined by the `lang` attribute. The return value is transformed to
|
||||
* lowercase.
|
||||
*/
|
||||
lang() {
|
||||
return `${this.#hostEl.lang || documentLanguage}`.toLowerCase();
|
||||
}
|
||||
|
||||
private getTranslationData(lang: string) {
|
||||
const locale = new Intl.Locale(lang);
|
||||
const language = locale?.language.toLowerCase();
|
||||
const region = locale?.region?.toLowerCase() ?? '';
|
||||
const primary = <TranslationType>translations.get(`${language}-${region}`);
|
||||
const secondary = <TranslationType>translations.get(language);
|
||||
|
||||
return { locale, language, region, primary, secondary };
|
||||
}
|
||||
|
||||
/** Outputs a translated term. */
|
||||
term<K extends keyof TranslationType>(key: K, ...args: FunctionParams<TranslationType[K]>): string {
|
||||
const { primary, secondary } = this.getTranslationData(this.lang());
|
||||
let term: any;
|
||||
|
||||
// Look for a matching term using regionCode, code, then the fallback
|
||||
if (primary && primary[key]) {
|
||||
term = primary[key];
|
||||
} else if (secondary && secondary[key]) {
|
||||
term = secondary[key];
|
||||
} else if (fallback && fallback[key as keyof TranslationSet]) {
|
||||
term = fallback[key as keyof TranslationSet];
|
||||
} else {
|
||||
return String(key);
|
||||
}
|
||||
|
||||
if (typeof term === 'function') {
|
||||
return term(...args) as string;
|
||||
}
|
||||
|
||||
if (typeof term === 'string') {
|
||||
if (args.length > 0) {
|
||||
// Replace placeholders of format "%index%" and "{index}" with provided values
|
||||
term = term.replace(/(%(\d+)%|\{(\d+)\})/g, (match, _p1, p2, p3): string => {
|
||||
const index = p2 || p3;
|
||||
return String(args[index] || match);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return term;
|
||||
}
|
||||
|
||||
/** Outputs a localized date in the specified format. */
|
||||
date(dateToFormat: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
dateToFormat = new Date(dateToFormat);
|
||||
return new Intl.DateTimeFormat(this.lang(), options).format(dateToFormat);
|
||||
}
|
||||
|
||||
/** Outputs a localized number in the specified format. */
|
||||
number(numberToFormat: number | string, options?: Intl.NumberFormatOptions): string {
|
||||
numberToFormat = Number(numberToFormat);
|
||||
return isNaN(numberToFormat) ? '' : new Intl.NumberFormat(this.lang(), options).format(numberToFormat);
|
||||
}
|
||||
|
||||
/** Outputs a localized time in relative format. */
|
||||
relativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions): string {
|
||||
return new Intl.RelativeTimeFormat(this.lang(), options).format(value, unit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 { UmbTranslationEntry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import type { LitElement } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
export type FunctionParams<T> = T extends (...args: infer U) => string ? U : [];
|
||||
|
||||
export interface TranslationSet {
|
||||
$code: string; // e.g. en, en-GB
|
||||
$dir: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export interface DefaultTranslationSet extends TranslationSet {
|
||||
[key: string]: UmbTranslationEntry;
|
||||
}
|
||||
|
||||
export const connectedElements = new Set<HTMLElement>();
|
||||
const documentElementObserver = new MutationObserver(update);
|
||||
export const translations: Map<string, TranslationSet> = new Map();
|
||||
export let documentDirection = document.documentElement.dir || 'ltr';
|
||||
export let documentLanguage = document.documentElement.lang || navigator.language;
|
||||
export let fallback: TranslationSet;
|
||||
|
||||
// Watch for changes on <html lang>
|
||||
documentElementObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['dir', 'lang'],
|
||||
});
|
||||
|
||||
/** Registers one or more translations */
|
||||
export function registerTranslation(...translation: TranslationSet[]) {
|
||||
translation.map((t) => {
|
||||
const code = t.$code.toLowerCase();
|
||||
|
||||
if (translations.has(code)) {
|
||||
// Merge translations that share the same language code
|
||||
translations.set(code, { ...translations.get(code), ...t });
|
||||
} else {
|
||||
translations.set(code, t);
|
||||
}
|
||||
|
||||
// The first translation that's registered is the fallback
|
||||
if (!fallback) {
|
||||
fallback = t;
|
||||
}
|
||||
});
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
/** Updates all localized elements that are currently connected */
|
||||
export function update() {
|
||||
documentDirection = document.documentElement.dir || 'ltr';
|
||||
documentLanguage = document.documentElement.lang || navigator.language;
|
||||
|
||||
[...connectedElements.keys()].map((el) => {
|
||||
if (typeof (el as LitElement).requestUpdate === 'function') {
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TranslationSet, registerTranslation } from '../manager.js';
|
||||
import { hasDefaultExport, loadExtension } from '@umbraco-cms/backoffice/extension-api';
|
||||
import {
|
||||
UmbBackofficeExtensionRegistry,
|
||||
UmbTranslationEntry,
|
||||
UmbTranslationsDictionary,
|
||||
umbExtensionsRegistry,
|
||||
} from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { Subject, combineLatest, map, distinctUntilChanged, Observable } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
export type UmbTranslationsFlatDictionary = Record<string, UmbTranslationEntry>;
|
||||
|
||||
export class UmbTranslationRegistry {
|
||||
#currentLanguage = new Subject<string>();
|
||||
#currentLanguageUnique: Observable<string> = this.#currentLanguage.pipe(
|
||||
map((x) => x.toLowerCase()),
|
||||
distinctUntilChanged()
|
||||
);
|
||||
|
||||
constructor(extensionRegistry: UmbBackofficeExtensionRegistry) {
|
||||
combineLatest([this.#currentLanguageUnique, extensionRegistry.extensionsOfType('translations')]).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: UmbTranslationsFlatDictionary = {};
|
||||
|
||||
// If extension contains a dictionary, add it to the inner dictionary.
|
||||
if (extension.meta.translations) {
|
||||
for (const [dictionaryName, dictionary] of Object.entries(extension.meta.translations)) {
|
||||
this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary);
|
||||
}
|
||||
}
|
||||
|
||||
// If extension contains a js file, load it and add the default dictionary to the inner dictionary.
|
||||
const loadedExtension = await loadExtension(extension);
|
||||
|
||||
if (loadedExtension && hasDefaultExport<UmbTranslationsDictionary>(loadedExtension)) {
|
||||
for (const [dictionaryName, dictionary] of Object.entries(loadedExtension.default)) {
|
||||
this.#addOrUpdateDictionary(innerDictionary, dictionaryName, dictionary);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify subscribers that the inner dictionary has changed.
|
||||
return {
|
||||
$code: userCulture,
|
||||
$dir: extension.meta.direction ?? 'ltr',
|
||||
...innerDictionary,
|
||||
} satisfies TranslationSet;
|
||||
})
|
||||
);
|
||||
|
||||
if (translations.length) {
|
||||
registerTranslation(...translations);
|
||||
|
||||
// Set the document language
|
||||
document.documentElement.lang = locale.baseName;
|
||||
|
||||
// Set the document direction to the direction of the primary language
|
||||
document.documentElement.dir = translations[0].$dir ?? 'ltr';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a language from the extension registry.
|
||||
* @param locale The locale to load.
|
||||
*/
|
||||
loadLanguage(locale: string) {
|
||||
this.#currentLanguage.next(locale);
|
||||
}
|
||||
|
||||
#addOrUpdateDictionary(
|
||||
innerDictionary: UmbTranslationsFlatDictionary,
|
||||
dictionaryName: string,
|
||||
dictionary: UmbTranslationsDictionary['value']
|
||||
) {
|
||||
for (const [key, value] of Object.entries(dictionary)) {
|
||||
innerDictionary[`${dictionaryName}_${key}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const umbTranslationRegistry = new UmbTranslationRegistry(umbExtensionsRegistry);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UmbObserver } from './observer.js';
|
||||
import { ObserverCallback, UmbObserver } from './observer.js';
|
||||
import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
import { UmbController, UmbControllerAlias, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
@@ -11,7 +11,7 @@ export class UmbObserverController<T = unknown> extends UmbObserver<T> implement
|
||||
constructor(
|
||||
host: UmbControllerHost,
|
||||
source: Observable<T>,
|
||||
callback: (_value: T) => void,
|
||||
callback: ObserverCallback<T>,
|
||||
alias?: UmbControllerAlias
|
||||
) {
|
||||
super(source, callback);
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Observable, Subscription, lastValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
export type ObserverCallbackStack<T> = {
|
||||
next: (_value: T) => void;
|
||||
error?: (_value: unknown) => void;
|
||||
complete?: () => void;
|
||||
};
|
||||
|
||||
export type ObserverCallback<T> = ((_value: T) => void) | ObserverCallbackStack<T>;
|
||||
|
||||
export class UmbObserver<T> {
|
||||
#source!: Observable<T>;
|
||||
#callback!: (_value: T) => void;
|
||||
#callback!: ObserverCallback<T>;
|
||||
#subscription!: Subscription;
|
||||
|
||||
constructor(source: Observable<T>, callback: (_value: T) => void) {
|
||||
constructor(source: Observable<T>, callback: ObserverCallback<T>) {
|
||||
this.#source = source;
|
||||
this.#subscription = source.subscribe(callback);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ManifestSectionView } from './section-view.model.js';
|
||||
import type { ManifestStore, ManifestTreeStore, ManifestItemStore } from './store.model.js';
|
||||
import type { ManifestTheme } from './theme.model.js';
|
||||
import type { ManifestTinyMcePlugin } from './tinymce-plugin.model.js';
|
||||
import type { ManifestTranslations } from './translations.model.js';
|
||||
import type { ManifestTree } from './tree.model.js';
|
||||
import type { ManifestTreeItem } from './tree-item.model.js';
|
||||
import type { ManifestUserProfileApp } from './user-profile-app.model.js';
|
||||
@@ -51,6 +52,7 @@ export * from './section.model.js';
|
||||
export * from './store.model.js';
|
||||
export * from './theme.model.js';
|
||||
export * from './tinymce-plugin.model.js';
|
||||
export * from './translations.model.js';
|
||||
export * from './tree-item.model.js';
|
||||
export * from './tree.model.js';
|
||||
export * from './user-profile-app.model.js';
|
||||
@@ -89,6 +91,7 @@ export type ManifestTypes =
|
||||
| ManifestStore
|
||||
| ManifestTheme
|
||||
| ManifestTinyMcePlugin
|
||||
| ManifestTranslations
|
||||
| ManifestTree
|
||||
| ManifestTreeItem
|
||||
| ManifestTreeStore
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ManifestWithLoaderIncludingDefaultExport } from '@umbraco-cms/backoffice/extension-api';
|
||||
|
||||
export type UmbTranslationEntry = string | ((...args: any[]) => string);
|
||||
export type UmbTranslationsDictionary = Record<string, Record<string, UmbTranslationEntry>>;
|
||||
|
||||
export interface ManifestTranslations extends ManifestWithLoaderIncludingDefaultExport<UmbTranslationsDictionary> {
|
||||
type: 'translations';
|
||||
meta: MetaTranslations;
|
||||
}
|
||||
|
||||
export interface MetaTranslations {
|
||||
/**
|
||||
* @summary The culture of the translations.
|
||||
* @description
|
||||
* The culture is a combination of a language and a country. The language is represented by an ISO 639-1 code and the country is represented by an ISO 3166-1 alpha-2 code.
|
||||
* The language and country are separated by a dash.
|
||||
* The value is used to describe the language of the translations according to the extension system
|
||||
* and it will be set as the `lang` attribute on the `<html>` element.
|
||||
* @see https://en.wikipedia.org/wiki/Language_localisation#Language_tags_and_codes
|
||||
* @examples ["en-us", "en-gb", "da-dk"]
|
||||
*/
|
||||
culture: string;
|
||||
|
||||
/**
|
||||
* @summary The direction of the translations (left-to-right or right-to-left).
|
||||
* @description
|
||||
* The value is used to describe the direction of the translations according to the extension system
|
||||
* and it will be set as the `dir` attribute on the `<html>` element. It defaults to `ltr`.
|
||||
* @see https://en.wikipedia.org/wiki/Right-to-left
|
||||
* @examples ["ltr"]
|
||||
* @default "ltr"
|
||||
*/
|
||||
direction?: 'ltr' | 'rtl';
|
||||
|
||||
/**
|
||||
* The translations.
|
||||
* @example
|
||||
* {
|
||||
* "general": {
|
||||
* "cancel": "Cancel",
|
||||
* "close": "Close"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
translations?: UmbTranslationsDictionary;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UmbBackofficeNotificationContainerElement, UmbBackofficeModalContainerElement } from './components/index.js';
|
||||
import { manifests as debugManifests } from './debug/manifests.js';
|
||||
import { manifests as localizationManifests } from './localization/manifests.js';
|
||||
import { manifests as propertyActionManifests } from './property-action/manifests.js';
|
||||
import { manifests as propertyEditorManifests } from './property-editor/manifests.js';
|
||||
import { manifests as tinyMcePluginManifests } from './property-editor/uis/tiny-mce/plugins/manifests.js';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
UmbClassExtensionsInitializer,
|
||||
} from '@umbraco-cms/backoffice/extension-registry';
|
||||
|
||||
export * from './localization/index.js';
|
||||
export * from './action/index.js';
|
||||
export * from './collection/index.js';
|
||||
export * from './components/index.js';
|
||||
@@ -42,6 +44,7 @@ export * from './workspace/index.js';
|
||||
|
||||
const manifests: Array<ManifestTypes | UmbBackofficeManifestKind> = [
|
||||
...debugManifests,
|
||||
...localizationManifests,
|
||||
...propertyActionManifests,
|
||||
...propertyEditorManifests,
|
||||
...tinyMcePluginManifests,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import './localize.element.js';
|
||||
@@ -0,0 +1,151 @@
|
||||
import { aTimeout, elementUpdated, expect, fixture, html } from '@open-wc/testing';
|
||||
import { UmbLocalizeElement } from './localize.element.js';
|
||||
|
||||
import '@umbraco-cms/backoffice/context-api';
|
||||
import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry';
|
||||
import { UmbTranslationRegistry } from '@umbraco-cms/backoffice/localization-api';
|
||||
|
||||
const english = {
|
||||
type: 'translations',
|
||||
alias: 'test.en',
|
||||
name: 'Test English',
|
||||
meta: {
|
||||
culture: 'en',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Close',
|
||||
logout: 'Log out',
|
||||
numUsersSelected: (count: number) => {
|
||||
if (count === 0) return 'No users selected';
|
||||
if (count === 1) return 'One user selected';
|
||||
return `${count} users selected`;
|
||||
},
|
||||
moreThanOneArgument: (arg1: string, arg2: string) => {
|
||||
return `${arg1} ${arg2}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const danish = {
|
||||
type: 'translations',
|
||||
alias: 'test.da',
|
||||
name: 'Test Danish',
|
||||
meta: {
|
||||
culture: 'da',
|
||||
translations: {
|
||||
general: {
|
||||
close: 'Luk',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('umb-localize', () => {
|
||||
let element: UmbLocalizeElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
element = await fixture(html`<umb-localize>Fallback value</umb-localize>`);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(element).to.be.instanceOf(UmbLocalizeElement);
|
||||
});
|
||||
|
||||
describe('localization', () => {
|
||||
umbExtensionsRegistry.register(english);
|
||||
umbExtensionsRegistry.register(danish);
|
||||
|
||||
const translationRegistry = new UmbTranslationRegistry(umbExtensionsRegistry);
|
||||
|
||||
beforeEach(async () => {
|
||||
translationRegistry.loadLanguage(english.meta.culture);
|
||||
element = await fixture(html`<umb-localize key="general_close">Fallback value</umb-localize>`);
|
||||
});
|
||||
|
||||
it('should localize a key', async () => {
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Close');
|
||||
});
|
||||
|
||||
it('should localize a key with arguments', async () => {
|
||||
element.key = 'general_numUsersSelected';
|
||||
element.args = [0];
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('No users selected');
|
||||
|
||||
element.args = [1];
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('One user selected');
|
||||
|
||||
element.args = [2];
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('2 users selected');
|
||||
});
|
||||
|
||||
it('should localize a key with multiple arguments', async () => {
|
||||
element.key = 'general_moreThanOneArgument';
|
||||
element.args = ['Hello', 'World'];
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Hello World');
|
||||
});
|
||||
|
||||
it('should localize a key with args as an attribute', async () => {
|
||||
element.key = 'general_moreThanOneArgument';
|
||||
element.setAttribute('args', '["Hello","World"]');
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Hello World');
|
||||
});
|
||||
|
||||
it('should change the value if a new key is set', async () => {
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Close');
|
||||
|
||||
element.key = 'general_logout';
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Log out');
|
||||
});
|
||||
|
||||
it('should change the value if the language is changed', async () => {
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Close');
|
||||
|
||||
translationRegistry.loadLanguage(danish.meta.culture);
|
||||
await aTimeout(0);
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('Luk');
|
||||
});
|
||||
|
||||
it('should use the slot if translation is not found', async () => {
|
||||
element.key = 'non-existing-key';
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('<slot></slot>');
|
||||
});
|
||||
|
||||
it('should toggle a data attribute', async () => {
|
||||
element.key = 'non-existing-key';
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.getAttribute('data-localize-missing')).to.equal('non-existing-key');
|
||||
|
||||
element.key = 'general_close';
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.hasAttribute('data-localize-missing')).to.equal(false);
|
||||
});
|
||||
|
||||
it('should use the key if debug is enabled and translation is not found', async () => {
|
||||
element.key = 'non-existing-key';
|
||||
element.debug = true;
|
||||
await elementUpdated(element);
|
||||
|
||||
expect(element.shadowRoot?.innerHTML).to.contain('non-existing-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/internal/lit-element';
|
||||
|
||||
/**
|
||||
* This element allows you to localize a string with optional interpolation values.
|
||||
* @element umb-localize
|
||||
* @slot - The fallback value if the key is not found.
|
||||
*/
|
||||
@customElement('umb-localize')
|
||||
export class UmbLocalizeElement extends UmbLitElement {
|
||||
/**
|
||||
* The key to localize. The key is case sensitive.
|
||||
* @attr
|
||||
* @example key="general_ok"
|
||||
*/
|
||||
@property()
|
||||
key!: string;
|
||||
|
||||
/**
|
||||
* The values to forward to the localization function (must be JSON compatible).
|
||||
* @attr
|
||||
* @example args="[1,2,3]"
|
||||
* @type {any[] | undefined}
|
||||
*/
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
args?: unknown[];
|
||||
|
||||
/**
|
||||
* If true, the key will be rendered instead of the localized value if the key is not found.
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
debug = false;
|
||||
|
||||
@state()
|
||||
protected get text(): string {
|
||||
const localizedValue = this.localize.term(this.key, ...(this.args ?? []));
|
||||
|
||||
// If the value is the same as the key, it means the key was not found.
|
||||
if (localizedValue === this.key) {
|
||||
(this.getHostElement() as HTMLElement).setAttribute('data-localize-missing', this.key);
|
||||
return '';
|
||||
}
|
||||
|
||||
(this.getHostElement() as HTMLElement).removeAttribute('data-localize-missing');
|
||||
|
||||
return localizedValue;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return this.text
|
||||
? html`${this.text}`
|
||||
: this.debug
|
||||
? html`<span style="color:red">${this.key}</span>`
|
||||
: html`<slot></slot>`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-localize': UmbLocalizeElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ManifestTypes } from '../extension-registry/index.js';
|
||||
import { ManifestTranslations } from '../extension-registry/models/translations.model.js';
|
||||
|
||||
const translationManifests: Array<ManifestTranslations> = [
|
||||
{
|
||||
type: 'translations',
|
||||
alias: 'Umb.Translations.En_US',
|
||||
weight: -100,
|
||||
name: 'English (US)',
|
||||
meta: {
|
||||
culture: 'en-us',
|
||||
},
|
||||
loader: () => import('../../../assets/lang/en-us.js'),
|
||||
},
|
||||
{
|
||||
type: 'translations',
|
||||
alias: 'Umb.Translations.Da_DK',
|
||||
weight: -100,
|
||||
name: 'Dansk (Danmark)',
|
||||
meta: {
|
||||
culture: 'da-dk',
|
||||
},
|
||||
loader: () => import('../../../assets/lang/da-dk.js'),
|
||||
},
|
||||
];
|
||||
|
||||
export const manifests: Array<ManifestTypes> = [...translationManifests];
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
import * as LocalizeStories from './localize.element.stories';
|
||||
|
||||
<Meta title="API/Localization/Intro" />
|
||||
|
||||
# Localization
|
||||
|
||||
Localization is the process of adapting an application to a specific language, culture, or region. Localization
|
||||
requires that you provide translated text for the user interface and localized data for the application to consume.
|
||||
|
||||
## Registering a language
|
||||
|
||||
To register a language, you need to add a new manifest to the Extension API.
|
||||
|
||||
The manifest can be added through the `umbraco-package.json` file like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "MyPackage",
|
||||
"extensions": [
|
||||
{
|
||||
"type": "translations",
|
||||
"alias": "MyPackage.Lang.EnUS",
|
||||
"name": "English (United States)",
|
||||
"meta": {
|
||||
"culture": "en-us"
|
||||
},
|
||||
"js": "/App_Plugins/MyPackage/lang/en-us.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If you do not have many translations, you can also choose to include them directly in the meta object like so:
|
||||
|
||||
```json
|
||||
"meta": {
|
||||
"culture": "en-us",
|
||||
"translations": {
|
||||
"section": {
|
||||
"key1": "value1",
|
||||
"key2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Layout of the language file
|
||||
|
||||
The language file is a simple JS module with a default export containing a key-value structure organised in sections.
|
||||
|
||||
```js
|
||||
export default {
|
||||
section: {
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The sections and keys will be formatted into a map in Umbraco
|
||||
with the format `section_key1` and `section_key2` which forms the unique key
|
||||
of which they are requested by.
|
||||
|
||||
The values can be either a string or a function that returns a string:
|
||||
|
||||
```js
|
||||
export default {
|
||||
section: {
|
||||
key1: 'value1',
|
||||
key2: (count) => {
|
||||
count = parseInt(count, 10);
|
||||
if (count === 0) return 'Nothing';
|
||||
if (count === 1) return 'One thing';
|
||||
return 'Many things';
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Using the translations
|
||||
|
||||
### umb-localize
|
||||
|
||||
The `umb-localize` component is used to translate text in the UI. It is used like this:
|
||||
|
||||
<Canvas of={LocalizeStories.Default} />
|
||||
|
||||
Experiment with the component here: [Localize](/story/api-localization-umblocalizeelement--default)
|
||||
|
||||
### UmbLocalizeController
|
||||
|
||||
The `UmbLocalizeController` is used to translate text in the UI. It is used like this:
|
||||
|
||||
**UmbElementMixin**
|
||||
|
||||
The controller is already initialised if you use the `UmbElementMixin` in your element:
|
||||
|
||||
```ts
|
||||
export class MyElement extends UmbElementMixin(LitElement) {
|
||||
render() {
|
||||
return html` <uui-button .label=${this.localize.term('general_close')}></uui-button> `;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reactive controller**
|
||||
|
||||
If you do not use the `UmbElementMixin` in your element, you can use the reactive controller like this:
|
||||
|
||||
```ts
|
||||
import { UmbLocalizeController } from '@umbraco-cms/backoffice/localization-api';
|
||||
|
||||
export class MyElement extends LitElement {
|
||||
// Create a new instance of the controller and attach it to the element
|
||||
private localize = new UmbLocalizeController(this);
|
||||
|
||||
render() {
|
||||
return html` <uui-button .label=${this.localize.localize('general_close')}></uui-button> `;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback
|
||||
|
||||
If a key is not found in the current language, the fallback language will be used. The fallback language is **English (United States)**.
|
||||
|
||||
## Localization in Umbraco
|
||||
|
||||
Out of the box, Umbraco ships with the following languages denoted by their ISO codes:
|
||||
|
||||
- `bs-BS` - Bosnian (Bosnia and Herzegovina)
|
||||
- `cs-CZ` - Czech (Czech Republic)
|
||||
- `cy-GB` - Welsh (United Kingdom)
|
||||
- `da-DK` - Danish (Denmark)
|
||||
- `de-DE` - German (Germany)
|
||||
- `en-GB` - English (United Kingdom)
|
||||
- `en-US` - **English (United States)** (fallback language)
|
||||
- `es-ES` - Spanish (Spain)
|
||||
- `fr-FR` - French (France)
|
||||
- `he-IL` - Hebrew (Israel)
|
||||
- `hr-HR` - Croatian (Croatia)
|
||||
- `it-IT` - Italian (Italy)
|
||||
- `ja-JP` - Japanese (Japan)
|
||||
- `ko-KR` - Korean (Korea)
|
||||
- `nb-NO` - Norwegian Bokmål (Norway)
|
||||
- `nl-NL` - Dutch (Netherlands)
|
||||
- `pl-PL` - Polish (Poland)
|
||||
- `pt-BR` - Portuguese (Brazil)
|
||||
- `ro-RO` - Romanian (Romania)
|
||||
- `ru-RU` - Russian (Russia)
|
||||
- `sv-SE` - Swedish (Sweden)
|
||||
- `tr-TR` - Turkish (Turkey)
|
||||
- `ua-UA` - Ukrainian (Ukraine)
|
||||
- `zh-CN` - Chinese (China)
|
||||
- `zh-TW` - Chinese (Taiwan)
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Meta, StoryObj } from '@storybook/web-components';
|
||||
import { html } from 'lit';
|
||||
import type { UmbLocalizeElement } from '../localize.element.js';
|
||||
import '../localize.element.js';
|
||||
|
||||
const meta: Meta<UmbLocalizeElement> = {
|
||||
title: 'API/Localization/UmbLocalizeElement',
|
||||
component: 'umb-localize',
|
||||
args: {
|
||||
key: 'general_areyousure',
|
||||
},
|
||||
argTypes: {
|
||||
args: {
|
||||
control: {
|
||||
type: 'array',
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story) => {
|
||||
return html`<div style="padding: 1rem; margin: 1rem; border: 1px solid green; max-width:50%;">
|
||||
Localized text: "${story()}"
|
||||
</div>`;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<UmbLocalizeElement>;
|
||||
|
||||
export const Default: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: `<umb-localize key="general_areyousure"></umb-localize>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithArguments: Story = {
|
||||
args: {
|
||||
key: 'blueprints_createdBlueprintMessage',
|
||||
args: ['About us'],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: `<umb-localize key="blueprints_createdBlueprintMessage" args="['About us']"></umb-localize>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const KeyNotFound: Story = {
|
||||
args: {
|
||||
key: 'general_ok_not_found',
|
||||
debug: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
code: `<umb-localize key="general_ok_not_found"></umb-localize>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export class UmbUmbracoNewsDashboardElement extends UmbLitElement {
|
||||
<p>
|
||||
There is currently very limited functionality.<br />
|
||||
Please refer to the
|
||||
<a target="_blank" href="http://docs.umbraco.com/umbraco-backoffice/">documentation</a> to learn more about
|
||||
<a target="_blank" href="https://docs.umbraco.com/umbraco-backoffice/">documentation</a> to learn more about
|
||||
what is possible.
|
||||
</p>
|
||||
</uui-box>
|
||||
|
||||
@@ -25,15 +25,9 @@ export class UmbCurrentUserModalElement extends UmbLitElement {
|
||||
this._observeCurrentUser();
|
||||
});
|
||||
|
||||
this.consumeContext(UMB_AUTH, (instance) => {
|
||||
this.#auth = instance;
|
||||
});
|
||||
|
||||
this.consumeContext(UMB_APP, (instance) => {
|
||||
this.#appContext = instance;
|
||||
});
|
||||
|
||||
this._observeCurrentUser();
|
||||
}
|
||||
|
||||
private async _observeCurrentUser() {
|
||||
@@ -64,8 +58,16 @@ export class UmbCurrentUserModalElement extends UmbLitElement {
|
||||
<umb-extension-slot id="userProfileApps" type="userProfileApp"></umb-extension-slot>
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<uui-button @click=${this._close} look="secondary">Close</uui-button>
|
||||
<uui-button @click=${this._logout} look="primary" color="danger">Logout</uui-button>
|
||||
<uui-button @click=${this._close} look="secondary" .label=${this.localize.term('general_close')}>
|
||||
${this.localize.term('general_close')}
|
||||
</uui-button>
|
||||
<uui-button
|
||||
@click=${this._logout}
|
||||
look="primary"
|
||||
color="danger"
|
||||
.label=${this.localize.term('general_logout')}>
|
||||
${this.localize.term('general_logout')}
|
||||
</uui-button>
|
||||
</div>
|
||||
</umb-body-layout>
|
||||
`;
|
||||
|
||||
@@ -27,8 +27,6 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement {
|
||||
this._auth = instance;
|
||||
this._observeCurrentUser();
|
||||
});
|
||||
|
||||
this._observeCurrentUser();
|
||||
}
|
||||
|
||||
private async _observeCurrentUser() {
|
||||
@@ -56,10 +54,13 @@ export class UmbUserProfileAppProfileElement extends UmbLitElement {
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<uui-box>
|
||||
<b slot="headline">Your profile</b>
|
||||
<uui-button look="primary" @click=${this._edit}>Edit</uui-button>
|
||||
<uui-button look="primary" @click=${this._changePassword}>Change password</uui-button>
|
||||
<uui-box .headline=${this.localize.term('user_yourProfile')}>
|
||||
<uui-button look="primary" label=${this.localize.term('general_edit')} @click=${this._edit}>
|
||||
${this.localize.term('general_edit')}
|
||||
</uui-button>
|
||||
<uui-button look="primary" label=${this.localize.term('general_changePassword')} @click=${this._changePassword}>
|
||||
${this.localize.term('general_changePassword')}
|
||||
</uui-button>
|
||||
</uui-box>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -186,8 +186,12 @@ export class UmbUserRepository
|
||||
|
||||
if (data) {
|
||||
this.#detailStore?.append(data);
|
||||
}
|
||||
|
||||
const notification = { data: { message: `User saved` } };
|
||||
if (!error) {
|
||||
const notification = {
|
||||
data: { message: this.#host.localize?.term('speechBubbles_editUserSaved') ?? 'User saved' },
|
||||
};
|
||||
this.#notificationContext?.peek('positive', notification);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UmbUserRepository } from '../repository/user.repository.js';
|
||||
import { UmbUserGroupInputElement } from '../../user-groups/components/input-user-group/user-group-input.element.js';
|
||||
import { type UmbUserDetail } from '../index.js';
|
||||
import { UmbUserWorkspaceContext } from './user-workspace.context.js';
|
||||
import { UUIInputElement, UUIInputEvent, UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
|
||||
import { UUIInputElement, UUIInputEvent, UUISelectElement, UUITextStyles } from '@umbraco-cms/backoffice/external/uui';
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ifDefined,
|
||||
repeat,
|
||||
} from '@umbraco-cms/backoffice/external/lit';
|
||||
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
import { UMB_CHANGE_PASSWORD_MODAL } from '@umbraco-cms/backoffice/modal';
|
||||
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
|
||||
@@ -34,9 +35,11 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement {
|
||||
@state()
|
||||
private _user?: UmbUserDetail;
|
||||
|
||||
@state()
|
||||
private languages: Array<{ name: string; value: string; selected: boolean }> = [];
|
||||
|
||||
#auth?: typeof UMB_AUTH.TYPE;
|
||||
#modalContext?: UmbModalManagerContext;
|
||||
#languages = []; //TODO Add languages
|
||||
#workspaceContext?: UmbUserWorkspaceContext;
|
||||
|
||||
#userRepository?: UmbUserRepository;
|
||||
@@ -78,7 +81,41 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement {
|
||||
|
||||
#observeCurrentUser() {
|
||||
if (!this.#auth) return;
|
||||
this.observe(this.#auth.currentUser, (currentUser) => (this._currentUser = currentUser));
|
||||
this.observe(this.#auth.currentUser, async (currentUser) => {
|
||||
this._currentUser = currentUser;
|
||||
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all translations and make a unique list of iso codes
|
||||
const translations = await firstValueFrom(umbExtensionsRegistry.extensionsOfType('translations'));
|
||||
|
||||
this.languages = translations
|
||||
.filter((isoCode) => isoCode !== undefined)
|
||||
.map((translation) => ({
|
||||
value: translation.meta.culture.toLowerCase(),
|
||||
name: translation.name,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
const currentUserLanguageCode = currentUser.languageIsoCode?.toLowerCase();
|
||||
|
||||
// Set the current user's language as selected
|
||||
const currentUserLanguage = this.languages.find((language) => language.value === currentUserLanguageCode);
|
||||
|
||||
if (currentUserLanguage) {
|
||||
currentUserLanguage.selected = true;
|
||||
} else {
|
||||
// If users language code did not fit any of the options. We will create an option that fits, named unknown.
|
||||
// In this way the user can keep their choice though a given language was not present at this time.
|
||||
this.languages.push({
|
||||
value: currentUserLanguageCode ?? 'en-us',
|
||||
name: currentUserLanguageCode ? `${currentUserLanguageCode} (unknown)` : 'Unknown',
|
||||
selected: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#onUserStatusChange() {
|
||||
@@ -114,6 +151,14 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement {
|
||||
}
|
||||
}
|
||||
|
||||
#onLanguageChange(event: Event) {
|
||||
const target = event.composedPath()[0] as UUISelectElement;
|
||||
|
||||
if (typeof target?.value === 'string') {
|
||||
this.#workspaceContext?.updateProperty('languageIsoCode', target.value);
|
||||
}
|
||||
}
|
||||
|
||||
#onPasswordChange() {
|
||||
// TODO: check if current user is admin
|
||||
this.#modalContext?.open(UMB_CHANGE_PASSWORD_MODAL, {
|
||||
@@ -150,12 +195,18 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement {
|
||||
if (!this._user) return nothing;
|
||||
|
||||
return html` <uui-box>
|
||||
<div slot="headline">Profile</div>
|
||||
<div slot="headline"><umb-localize key="user_profile">Profile</umb-localize></div>
|
||||
<umb-workspace-property-layout label="Email">
|
||||
<uui-input slot="editor" name="email" label="email" readonly value=${this._user.email}></uui-input>
|
||||
<uui-input slot="editor" name="email" label="email" readonly value=${ifDefined(this._user.email)}></uui-input>
|
||||
</umb-workspace-property-layout>
|
||||
<umb-workspace-property-layout label="Language" description="The language of the UI in the Backoffice">
|
||||
<uui-select slot="editor" name="language" label="language" .options=${this.#languages}> </uui-select>
|
||||
<uui-select
|
||||
slot="editor"
|
||||
name="language"
|
||||
label="language"
|
||||
.options=${this.languages}
|
||||
@change="${this.#onLanguageChange}">
|
||||
</uui-select>
|
||||
</umb-workspace-property-layout>
|
||||
</uui-box>
|
||||
<uui-box>
|
||||
@@ -172,7 +223,7 @@ export class UmbUserWorkspaceEditorElement extends UmbLitElement {
|
||||
label="Content start node"
|
||||
description="Limit the content tree to specific start nodes">
|
||||
<umb-property-editor-ui-document-picker
|
||||
.value=${this._user.contentStartNodeIds}
|
||||
.value=${this._user.contentStartNodeIds ?? []}
|
||||
@property-value-change=${(e: any) =>
|
||||
this.#workspaceContext?.updateProperty('contentStartNodeIds', e.target.value)}
|
||||
slot="editor"></umb-property-editor-ui-document-picker>
|
||||
|
||||
@@ -2,15 +2,24 @@ import { UmbUserRepository } from '../repository/user.repository.js';
|
||||
import { type UmbUserDetail } from '../index.js';
|
||||
import { UmbEntityWorkspaceContextInterface, UmbWorkspaceContext } from '@umbraco-cms/backoffice/workspace';
|
||||
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { UpdateUserRequestModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
import type { UpdateUserRequestModel } from '@umbraco-cms/backoffice/backend-api';
|
||||
import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
|
||||
import { UMB_AUTH } from '@umbraco-cms/backoffice/auth';
|
||||
import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
export class UmbUserWorkspaceContext
|
||||
extends UmbWorkspaceContext<UmbUserRepository, UmbUserDetail>
|
||||
implements UmbEntityWorkspaceContextInterface<UmbUserDetail | undefined>
|
||||
{
|
||||
#authContext?: typeof UMB_AUTH.TYPE;
|
||||
|
||||
constructor(host: UmbControllerHostElement) {
|
||||
super(host, new UmbUserRepository(host));
|
||||
|
||||
new UmbContextConsumerController(host, UMB_AUTH, (auth) => {
|
||||
this.#authContext = auth;
|
||||
});
|
||||
}
|
||||
|
||||
#data = new UmbObjectState<UmbUserDetail | undefined>(undefined);
|
||||
@@ -55,6 +64,17 @@ export class UmbUserWorkspaceContext
|
||||
}
|
||||
// If it went well, then its not new anymore?.
|
||||
this.setIsNew(false);
|
||||
|
||||
// If we are saving the current user, we need to refetch it
|
||||
await this.#reloadCurrentUser(this.#data.value.id);
|
||||
}
|
||||
|
||||
async #reloadCurrentUser(savedUserId: string): Promise<void> {
|
||||
if (!this.#authContext) return;
|
||||
const currentUser = await firstValueFrom(this.#authContext.currentUser);
|
||||
if (currentUser?.id === savedUserId) {
|
||||
await this.#authContext.fetchCurrentUser();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
|
||||
@@ -11,6 +11,7 @@ export class UmbAuthContext implements IUmbAuth {
|
||||
#currentUser = new UmbObjectState<UmbLoggedInUser | undefined>(undefined);
|
||||
readonly currentUser = this.#currentUser.asObservable();
|
||||
readonly isLoggedIn = new ReplaySubject<boolean>(1);
|
||||
readonly languageIsoCode = this.#currentUser.asObservablePart((user) => user?.languageIsoCode ?? 'en-us');
|
||||
|
||||
#host;
|
||||
#authFlow;
|
||||
@@ -33,8 +34,6 @@ export class UmbAuthContext implements IUmbAuth {
|
||||
async fetchCurrentUser(): Promise<UmbLoggedInUser | undefined> {
|
||||
const { data } = await tryExecuteAndNotify(this.#host, UserResource.getUserCurrent());
|
||||
|
||||
if (!data) return;
|
||||
|
||||
this.#currentUser.next(data);
|
||||
|
||||
return data;
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface IUmbAuth {
|
||||
*/
|
||||
get currentUser(): Observable<UmbLoggedInUser | undefined>;
|
||||
|
||||
/**
|
||||
* Get the current user's language ISO code.
|
||||
*/
|
||||
languageIsoCode: Observable<string>;
|
||||
|
||||
/**
|
||||
* Make a server request for the current user and save the state
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// TODO => this is NOT a full reimplementation of the existing media helper service, currently
|
||||
// contains only functions referenced by the TinyMCE editor
|
||||
// TODO: This should not be done in this way, we need to split this into seperate defined helper methods. This is also very specific to TinyMCE, so should be named that way.
|
||||
|
||||
import { Editor, EditorEvent } from 'tinymce';
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@umbraco-cms/backoffice/controller-api": ["src/libs/controller-api"],
|
||||
"@umbraco-cms/backoffice/element-api": ["src/libs/element-api"],
|
||||
"@umbraco-cms/backoffice/extension-api": ["src/libs/extension-api"],
|
||||
"@umbraco-cms/backoffice/localization-api": ["src/libs/localization-api"],
|
||||
"@umbraco-cms/backoffice/observable-api": ["src/libs/observable-api"],
|
||||
|
||||
// SHARED
|
||||
|
||||
@@ -48,6 +48,7 @@ export default {
|
||||
'@umbraco-cms/backoffice/controller-api': './src/libs/controller-api/index.ts',
|
||||
'@umbraco-cms/backoffice/element-api': './src/libs/element-api/index.ts',
|
||||
'@umbraco-cms/backoffice/extension-api': './src/libs/extension-api/index.ts',
|
||||
'@umbraco-cms/backoffice/localization-api': './src/libs/localization-api/index.ts',
|
||||
'@umbraco-cms/backoffice/observable-api': './src/libs/observable-api/index.ts',
|
||||
|
||||
'@umbraco-cms/backoffice/auth': './src/shared/auth/index.ts',
|
||||
@@ -125,7 +126,7 @@ export default {
|
||||
reporters: ['lcovonly', 'text-summary'],
|
||||
},
|
||||
testRunnerHtml: (testFramework) =>
|
||||
`<html>
|
||||
`<html lang="en-us">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
Reference in New Issue
Block a user