diff --git a/docs/en/UI/Angular/Localization.md b/docs/en/UI/Angular/Localization.md index 6c7e6084a6..b0156ff372 100644 --- a/docs/en/UI/Angular/Localization.md +++ b/docs/en/UI/Angular/Localization.md @@ -37,7 +37,9 @@ The pipe will replace the key with the localized text. You can also specify a default value as shown below: ```html -

{%{{{ { key: 'Resource::Key', defaultValue: 'Default Value' } | abpLocalization }}}%}

+

+ {%{{{ { key: 'Resource::Key', defaultValue: 'Default Value' } | abpLocalization }}}%} +

``` To use interpolation, you must give the values for interpolation as pipe parameters, for example: @@ -79,12 +81,18 @@ After that, you are able to use localization service. > You can add interpolation parameters as arguments to `instant()` and `get()` methods. ```js -this.localizationService.instant('AbpIdentity::UserDeletionConfirmation', 'John'); +this.localizationService.instant( + 'AbpIdentity::UserDeletionConfirmation', + 'John' +); // with fallback value this.localizationService.instant( - { key: 'AbpIdentity::UserDeletionConfirmation', defaultValue: 'Default Value' }, - 'John', + { + key: 'AbpIdentity::UserDeletionConfirmation', + defaultValue: 'Default Value', + }, + 'John' ); // Output @@ -97,9 +105,106 @@ To get a localized text as [_Observable_](https://rxjs.dev/guide/observable) use this.localizationService.get('Resource::Key'); // with fallback value -this.localizationService.get({ key: 'Resource::Key', defaultValue: 'Default Value' }); +this.localizationService.get({ + key: 'Resource::Key', + defaultValue: 'Default Value', +}); ``` +## UI Localizations + +Localizations can be determined on backend side. Angular UI gets the localizations from the `application-configuration` API's response. You can also determine localizations on the UI side. + +See an example: + +```ts +// app.module.ts + +@NgModule({ + imports: [ + //...other imports + CoreModule.forRoot({ + localizations: [ + { + culture: 'en', + resources: [ + { + resourceName: 'MyProjectName', + texts: { + Administration: 'Administration', + HomePage: 'Home', + }, + }, + ], + }, + { + culture: 'de-DE', + resources: [ + { + resourceName: 'MyProjectName', + texts: { + Administration: 'Verwaltung', + HomePage: 'Startseite', + }, + }, + ], + }, + ], + }), + ] +}) +``` + +...or, you can determine the localizations in a feature module: + +```ts +// your feature module + +@NgModule({ + imports: [ + //...other imports + CoreModule.forChild({ + localizations: [ + { + culture: 'en', + resources: [ + { + resourceName: 'MyProjectName', + texts: { + Administration: 'Administration', + HomePage: 'Home', + }, + }, + ], + }, + { + culture: 'de-DE', + resources: [ + { + resourceName: 'MyProjectName', + texts: { + Administration: 'Verwaltung', + HomePage: 'Startseite', + }, + }, + ], + }, + ], + }), + ] +}) +``` + +The localizations above can be used like this: + +```html +
{%{{{ 'MyProjectName::Administration' | abpLocalization }}}%}
+ +
{%{{{ 'MyProjectName::HomePage' | abpLocalization }}}%}
+``` + +> **Note:** If you have specified the same localizations in the UI and backend, the backend localizations override the UI localizations. + ## RTL Support As of v2.9 ABP has RTL support. If you are generating a new project with v2.9 and above, everything is set, you do not need to do any changes. If you are migrating your project from an earlier version, please follow the 2 steps below: @@ -137,7 +242,7 @@ Find [styles configuration in angular.json](https://angular.io/guide/workspace-c "bundleName": "bootstrap-ltr.min" }, "apps/dev-app/src/styles.scss" - ], + ] } } } @@ -198,7 +303,6 @@ import { registerLocale } from '@abp/ng.core/locale'; ] ``` - ### Mapping of Culture Name to Angular Locale File Name Some of the culture names defined in .NET do not match Angular locales. In such cases, the Angular app throws an error like below at runtime: @@ -222,7 +326,7 @@ import { registerLocale } from '@abp/ng.core/locale'; // ...other options, registerLocaleFn: registerLocale( { - cultureNameLocaleFileMap: { + cultureNameLocaleFileMap: { "DotnetCultureName": "AngularLocaleFileName", "pt-BR": "pt" // example }, @@ -243,10 +347,10 @@ Add the below code to the `app.module.ts` by replacing `your-locale` placeholder import { storeLocaleData } from '@abp/ng.core/locale'; import( -/* webpackChunkName: "_locale-your-locale-js"*/ -/* webpackMode: "eager" */ -'@angular/common/locales/your-locale.js' -).then(m => storeLocaleData(m.default, 'your-locale')); + /* webpackChunkName: "_locale-your-locale-js"*/ + /* webpackMode: "eager" */ + '@angular/common/locales/your-locale.js' +).then((m) => storeLocaleData(m.default, 'your-locale')); ``` ...or a custom `registerLocale` function can be passed to the `CoreModule`: @@ -285,7 +389,6 @@ After this custom `registerLocale` function, since the en and fr added to the `w Which locale files you add to `webpackInclude` magic comment, they will be included in the bundle - ## See Also -* [Localization in ASP.NET Core](../../Localization.md) +- [Localization in ASP.NET Core](../../Localization.md) diff --git a/npm/ng-packs/packages/core/src/lib/core.module.ts b/npm/ng-packs/packages/core/src/lib/core.module.ts index d951f965ee..c34b4a2fab 100644 --- a/npm/ng-packs/packages/core/src/lib/core.module.ts +++ b/npm/ng-packs/packages/core/src/lib/core.module.ts @@ -27,6 +27,7 @@ import { CookieLanguageProvider } from './providers/cookie-language.provider'; import { LocaleProvider } from './providers/locale.provider'; import { LocalizationService } from './services/localization.service'; import { oAuthStorage } from './strategies/auth-flow.strategy'; +import { localizationContributor, LOCALIZATIONS } from './tokens/localization.token'; import { coreOptionsFactory, CORE_OPTIONS } from './tokens/options.token'; import { TENANT_KEY } from './tokens/tenant-key.token'; import { noop } from './utils/common-utils'; @@ -176,6 +177,26 @@ export class CoreModule { }, { provide: OAuthStorage, useFactory: storageFactory }, { provide: TENANT_KEY, useValue: options.tenantKey || '__tenant' }, + { + provide: LOCALIZATIONS, + multi: true, + useValue: localizationContributor(options.localizations), + deps: [LocalizationService], + }, + ], + }; + } + + static forChild(options = {} as ABP.Child): ModuleWithProviders { + return { + ngModule: RootCoreModule, + providers: [ + { + provide: LOCALIZATIONS, + multi: true, + useValue: localizationContributor(options.localizations), + deps: [LocalizationService], + }, ], }; } diff --git a/npm/ng-packs/packages/core/src/lib/models/common.ts b/npm/ng-packs/packages/core/src/lib/models/common.ts index 0196ce385a..12318357dd 100644 --- a/npm/ng-packs/packages/core/src/lib/models/common.ts +++ b/npm/ng-packs/packages/core/src/lib/models/common.ts @@ -11,6 +11,21 @@ export namespace ABP { skipGetAppConfiguration?: boolean; sendNullsAsQueryParam?: boolean; tenantKey?: string; + localizations?: Localization[]; + } + + export interface Child { + localizations?: Localization[]; + } + + export interface Localization { + culture: string; + resources: LocalizationResource[]; + } + + export interface LocalizationResource { + resourceName: string; + texts: Record; } export interface HasPolicy { diff --git a/npm/ng-packs/packages/core/src/lib/services/localization.service.ts b/npm/ng-packs/packages/core/src/lib/services/localization.service.ts index f7c0315568..afdda6849b 100644 --- a/npm/ng-packs/packages/core/src/lib/services/localization.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/localization.service.ts @@ -1,10 +1,11 @@ import { registerLocaleData } from '@angular/common'; import { Injectable, Injector, isDevMode, Optional, SkipSelf } from '@angular/core'; -import { from, Observable, Subject } from 'rxjs'; +import { BehaviorSubject, combineLatest, from, Observable, Subject } from 'rxjs'; import { filter, map, mapTo, switchMap } from 'rxjs/operators'; import { ABP } from '../models/common'; import { LocalizationWithDefault } from '../models/localization'; import { ApplicationConfigurationDto } from '../proxy/volo/abp/asp-net-core/mvc/application-configurations/models'; +import { localizations$ } from '../tokens/localization.token'; import { CORE_OPTIONS } from '../tokens/options.token'; import { createLocalizer, createLocalizerWithFallback } from '../utils/localization-utils'; import { interpolate } from '../utils/string-utils'; @@ -16,6 +17,12 @@ export class LocalizationService { private latestLang = this.sessionState.getLanguage(); private _languageChange$ = new Subject(); + private uiLocalizations$ = new BehaviorSubject( + new Map>>(), + ); + + private localizations$ = new BehaviorSubject(new Map>()); + /** * Returns currently selected language */ @@ -38,6 +45,66 @@ export class LocalizationService { if (otherInstance) throw new Error('LocalizationService should have only one instance.'); this.listenToSetLanguage(); + this.initLocalizationValues(); + } + + private initLocalizationValues() { + localizations$.subscribe(val => this.addLocalization(val)); + + const remoteLocalizations$ = this.configState.getDeep$('localization.values') as Observable< + Record> + >; + const currentLanguage$ = this.sessionState.getLanguage$(); + + const uiLocalizations$ = combineLatest([currentLanguage$, this.uiLocalizations$]).pipe( + map(([currentLang, localizations]) => localizations.get(currentLang)), + ); + + combineLatest([remoteLocalizations$, uiLocalizations$]) + .pipe( + map(([remote, local]) => { + if (remote) { + if (!local) { + local = new Map(); + } + + Object.entries(remote).forEach(entry => { + const resourceName = entry[0]; + const remoteTexts = entry[1]; + let resource = local.get(resourceName) || {}; + resource = { ...resource, ...remoteTexts }; + + local.set(resourceName, resource); + }); + } + + return local; + }), + ) + .subscribe(val => this.localizations$.next(val)); + } + + addLocalization(localizations?: ABP.Localization[]) { + if (!localizations) return; + + const localizationMap = this.uiLocalizations$.value; + + localizations.forEach(loc => { + const cultureMap = + localizationMap.get(loc.culture) || new Map>(); + + loc.resources.forEach(res => { + let resource: Record = cultureMap.get(res.resourceName) || {}; + + resource = { ...resource, ...res.texts }; + + cultureMap.set(res.resourceName, resource); + }); + + localizationMap.set(loc.culture, cultureMap); + }); + + this.uiLocalizations$.next(localizationMap); } private listenToSetLanguage() { @@ -70,15 +137,15 @@ export class LocalizationService { get(key: string | LocalizationWithDefault, ...interpolateParams: string[]): Observable { return this.configState .getAll$() - .pipe(map(state => getLocalization(state, key, ...interpolateParams))); + .pipe(map(state => this.getLocalization(state, key, ...interpolateParams))); } getResource(resourceName: string) { - return this.configState.getDeep(`localization.values.${resourceName}`); + return this.localizations$.value.get(resourceName); } getResource$(resourceName: string) { - return this.configState.getDeep$(`localization.values.${resourceName}`); + return this.localizations$.pipe(map(res => res.get(resourceName))); } /** @@ -87,7 +154,7 @@ export class LocalizationService { * @param interpolateParams Values to intepolate. */ instant(key: string | LocalizationWithDefault, ...interpolateParams: string[]): string { - return getLocalization(this.configState.getAll(), key, ...interpolateParams); + return this.getLocalization(this.configState.getAll(), key, ...interpolateParams); } localize(resourceName: string, key: string, defaultValue: string): Observable { @@ -117,60 +184,62 @@ export class LocalizationService { const localization = this.configState.getOne('localization'); return createLocalizerWithFallback(localization)(resourceNames, keys, defaultValue); } -} -function getLocalization( - state: ApplicationConfigurationDto, - key: string | LocalizationWithDefault, - ...interpolateParams: string[] -) { - if (!key) key = ''; - let defaultValue: string; + private getLocalization( + state: ApplicationConfigurationDto, + key: string | LocalizationWithDefault, + ...interpolateParams: string[] + ) { + if (!key) key = ''; + let defaultValue: string; - if (typeof key !== 'string') { - defaultValue = key.defaultValue; - key = key.key; - } + if (typeof key !== 'string') { + defaultValue = key.defaultValue; + key = key.key; + } - const keys = key.split('::') as string[]; - const warn = (message: string) => { - if (isDevMode) console.warn(message); - }; + const keys = key.split('::') as string[]; + const warn = (message: string) => { + if (isDevMode) console.warn(message); + }; - if (keys.length < 2) { - warn('The localization source separator (::) not found.'); - return defaultValue || (key as string); - } - if (!state.localization) return defaultValue || keys[1]; + if (keys.length < 2) { + warn('The localization source separator (::) not found.'); + return defaultValue || (key as string); + } + if (!state.localization) return defaultValue || keys[1]; - const sourceName = keys[0] || state.localization.defaultResourceName; - const sourceKey = keys[1]; + const sourceName = keys[0] || state.localization.defaultResourceName; + const sourceKey = keys[1]; - if (sourceName === '_') { - return defaultValue || sourceKey; - } + if (sourceName === '_') { + return defaultValue || sourceKey; + } - if (!sourceName) { - warn('Localization source name is not specified and the defaultResourceName was not defined!'); + if (!sourceName) { + warn( + 'Localization source name is not specified and the defaultResourceName was not defined!', + ); - return defaultValue || sourceKey; - } + return defaultValue || sourceKey; + } - const source = state.localization.values[sourceName]; - if (!source) { - warn('Could not find localization source: ' + sourceName); - return defaultValue || sourceKey; - } + const source = this.localizations$.value.get(sourceName); + if (!source) { + warn('Could not find localization source: ' + sourceName); + return defaultValue || sourceKey; + } - let localization = source[sourceKey]; - if (typeof localization === 'undefined') { - return defaultValue || sourceKey; - } + let localization = source[sourceKey]; + if (typeof localization === 'undefined') { + return defaultValue || sourceKey; + } - interpolateParams = interpolateParams.filter(params => params != null); - if (localization) localization = interpolate(localization, interpolateParams); + interpolateParams = interpolateParams.filter(params => params != null); + if (localization) localization = interpolate(localization, interpolateParams); - if (typeof localization !== 'string') localization = ''; + if (typeof localization !== 'string') localization = ''; - return localization || defaultValue || (key as string); + return localization || defaultValue || (key as string); + } } diff --git a/npm/ng-packs/packages/core/src/lib/tests/localization.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/localization.service.spec.ts index b531b1bf23..550d689b46 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/localization.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/localization.service.spec.ts @@ -76,7 +76,7 @@ describe('LocalizationService', () => { sessionState, spectator.inject(Injector), null, - null, + configState, ); } catch (error) { expect((error as Error).message).toBe('LocalizationService should have only one instance.'); diff --git a/npm/ng-packs/packages/core/src/lib/tokens/index.ts b/npm/ng-packs/packages/core/src/lib/tokens/index.ts index 2b7028ce53..da1ea9bdad 100644 --- a/npm/ng-packs/packages/core/src/lib/tokens/index.ts +++ b/npm/ng-packs/packages/core/src/lib/tokens/index.ts @@ -1,6 +1,7 @@ export * from './app-config.token'; export * from './cookie-language-key.token'; export * from './list.token'; +export * from './localization.token'; export * from './lodaer-delay.token'; export * from './manage-profile.token'; export * from './options.token'; diff --git a/npm/ng-packs/packages/core/src/lib/tokens/localization.token.ts b/npm/ng-packs/packages/core/src/lib/tokens/localization.token.ts new file mode 100644 index 0000000000..e4fd2eeb3f --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tokens/localization.token.ts @@ -0,0 +1,13 @@ +import { InjectionToken } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { ABP } from '../models/common'; + +export const LOCALIZATIONS = new InjectionToken('LOCALIZATIONS'); + +export function localizationContributor(localizations: ABP.Localization[]) { + if (localizations) { + localizations$.next([...localizations$.value, ...localizations]); + } +} + +export const localizations$ = new BehaviorSubject([]);