From a5d2ba780d28a33e87fc5fca3a8978f6185b0994 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Sun, 18 Apr 2021 15:31:08 +0200 Subject: [PATCH 1/6] feat: improve detecting missing translations --- src/app/app.server.module.ts | 6 +- src/app/core/internationalization.module.ts | 58 ++++++++++++++----- .../configuration/configuration.effects.ts | 5 +- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/app/app.server.module.ts b/src/app/app.server.module.ts index c2a6a3d1ea..7fcfe08c67 100644 --- a/src/app/app.server.module.ts +++ b/src/app/app.server.module.ts @@ -15,11 +15,13 @@ import { AppComponent } from './app.component'; import { AppModule } from './app.module'; export class UniversalErrorHandler implements ErrorHandler { - handleError(error: Error): void { + handleError(error: unknown): void { if (error instanceof HttpErrorResponse) { console.error('ERROR', error.message); - } else { + } else if (error instanceof Error) { console.error('ERROR', error.name, error.message, error.stack?.split('\n')?.[1]?.trim()); + } else { + console.error('ERROR', error?.toString()); } } } diff --git a/src/app/core/internationalization.module.ts b/src/app/core/internationalization.module.ts index 09e4f63b96..435d0d4987 100644 --- a/src/app/core/internationalization.module.ts +++ b/src/app/core/internationalization.module.ts @@ -2,15 +2,22 @@ import { isPlatformBrowser, isPlatformServer, registerLocaleData } from '@angula import { HttpClient } from '@angular/common/http'; import localeDe from '@angular/common/locales/de'; import localeFr from '@angular/common/locales/fr'; -import { Inject, Injectable, LOCALE_ID, NgModule, PLATFORM_ID } from '@angular/core'; +import { ErrorHandler, Inject, Injectable, InjectionToken, LOCALE_ID, NgModule, PLATFORM_ID } from '@angular/core'; import { TransferState } from '@angular/platform-browser'; import { Store, select } from '@ngrx/store'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + MissingTranslationHandler, + MissingTranslationHandlerParams, + TranslateLoader, + TranslateModule, + TranslateParser, + TranslateService, +} from '@ngx-translate/core'; import { from, of } from 'rxjs'; import { catchError, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { SSR_LOCALE, SSR_TRANSLATIONS } from './configurations/state-keys'; -import { getCurrentLocale, getRestEndpoint } from './store/core/configuration'; +import { getRestEndpoint } from './store/core/configuration'; import { whenTruthy } from './utils/operators'; export type Translations = Record>; @@ -46,16 +53,7 @@ class ICMTranslateLoader implements TranslateLoader { if (isPlatformBrowser(this.platformId) && this.transferState.hasKey(SSR_TRANSLATIONS)) { return of(this.transferState.get(SSR_TRANSLATIONS, undefined)); } - const local$ = from(import(`../../assets/i18n/${lang}.json`)).pipe( - catchError(() => - this.store.pipe( - select(getCurrentLocale), - whenTruthy(), - take(1), - switchMap(defaultLang => from(import(`../../assets/i18n/${defaultLang}.json`))) - ) - ) - ); + const local$ = from(import(`../../assets/i18n/${lang}.json`)).pipe(catchError(() => of({}))); return this.store.pipe( select(getRestEndpoint), whenTruthy(), @@ -83,12 +81,46 @@ class ICMTranslateLoader implements TranslateLoader { } } +const FALLBACK_LANG = new InjectionToken('fallbackTranslateLanguage'); + +@Injectable() +class FallbackMissingTranslationHandler implements MissingTranslationHandler { + constructor( + private parser: TranslateParser, + private translateLoader: TranslateLoader, + private errorHandler: ErrorHandler, + @Inject(FALLBACK_LANG) private fallback: string + ) {} + + handle(params: MissingTranslationHandlerParams) { + if (params.interpolateParams || /^\w+(\.[\w-]+)+$/.test(params.key)) { + this.errorHandler.handleError(`missing translation in ${params.translateService.currentLang}: ${params.key}`); + if (params.translateService.currentLang !== this.fallback) { + return this.translateLoader.getTranslation(this.fallback).pipe( + map(translations => { + if (translations[params.key]) { + return this.parser.interpolate(translations[params.key], params.interpolateParams); + } + this.errorHandler.handleError(`missing translation in ${this.fallback}: ${params.key}`); + return params.key; + }), + map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) + ); + } + } + return params.key; + } +} + @NgModule({ imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: ICMTranslateLoader }, + missingTranslationHandler: { provide: MissingTranslationHandler, useClass: FallbackMissingTranslationHandler }, + useDefaultLang: false, }), ], + providers: [{ provide: FALLBACK_LANG, useValue: 'en_US' }], }) export class InternationalizationModule { constructor( diff --git a/src/app/core/store/core/configuration/configuration.effects.ts b/src/app/core/store/core/configuration/configuration.effects.ts index 0c06d71f5a..3a888298f2 100644 --- a/src/app/core/store/core/configuration/configuration.effects.ts +++ b/src/app/core/store/core/configuration/configuration.effects.ts @@ -6,7 +6,6 @@ import { Store, select } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { defer, fromEvent, iif, merge } from 'rxjs'; import { - debounceTime, distinctUntilChanged, map, mapTo, @@ -53,12 +52,10 @@ export class ConfigurationEffects { select(getCurrentLocale), mapToProperty('lang'), distinctUntilChanged(), - // https://github.com/ngx-translate/core/issues/1030 - debounceTime(0), whenTruthy(), switchMap(lang => languageChanged$.pipe(mapTo(lang), take(1))) ) - .subscribe((lang: string) => { + .subscribe(lang => { this.transferState.set(SSR_LOCALE, lang); translateService.use(lang); }); From 576adfe0d9708cf98b89bc6a4db3c1bde5860fdf Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Mon, 7 Jun 2021 20:18:19 +0200 Subject: [PATCH 2/6] refactor: move missing translation handler to own file --- src/app/core/internationalization.module.ts | 46 +++---------------- .../fallback-missing-translation-handler.ts | 39 ++++++++++++++++ 2 files changed, 45 insertions(+), 40 deletions(-) create mode 100644 src/app/core/utils/translate/fallback-missing-translation-handler.ts diff --git a/src/app/core/internationalization.module.ts b/src/app/core/internationalization.module.ts index 435d0d4987..197b60a2cd 100644 --- a/src/app/core/internationalization.module.ts +++ b/src/app/core/internationalization.module.ts @@ -2,23 +2,20 @@ import { isPlatformBrowser, isPlatformServer, registerLocaleData } from '@angula import { HttpClient } from '@angular/common/http'; import localeDe from '@angular/common/locales/de'; import localeFr from '@angular/common/locales/fr'; -import { ErrorHandler, Inject, Injectable, InjectionToken, LOCALE_ID, NgModule, PLATFORM_ID } from '@angular/core'; +import { Inject, Injectable, LOCALE_ID, NgModule, PLATFORM_ID } from '@angular/core'; import { TransferState } from '@angular/platform-browser'; import { Store, select } from '@ngrx/store'; -import { - MissingTranslationHandler, - MissingTranslationHandlerParams, - TranslateLoader, - TranslateModule, - TranslateParser, - TranslateService, -} from '@ngx-translate/core'; +import { MissingTranslationHandler, TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { from, of } from 'rxjs'; import { catchError, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { SSR_LOCALE, SSR_TRANSLATIONS } from './configurations/state-keys'; import { getRestEndpoint } from './store/core/configuration'; import { whenTruthy } from './utils/operators'; +import { + FALLBACK_LANG, + FallbackMissingTranslationHandler, +} from './utils/translate/fallback-missing-translation-handler'; export type Translations = Record>; @@ -81,37 +78,6 @@ class ICMTranslateLoader implements TranslateLoader { } } -const FALLBACK_LANG = new InjectionToken('fallbackTranslateLanguage'); - -@Injectable() -class FallbackMissingTranslationHandler implements MissingTranslationHandler { - constructor( - private parser: TranslateParser, - private translateLoader: TranslateLoader, - private errorHandler: ErrorHandler, - @Inject(FALLBACK_LANG) private fallback: string - ) {} - - handle(params: MissingTranslationHandlerParams) { - if (params.interpolateParams || /^\w+(\.[\w-]+)+$/.test(params.key)) { - this.errorHandler.handleError(`missing translation in ${params.translateService.currentLang}: ${params.key}`); - if (params.translateService.currentLang !== this.fallback) { - return this.translateLoader.getTranslation(this.fallback).pipe( - map(translations => { - if (translations[params.key]) { - return this.parser.interpolate(translations[params.key], params.interpolateParams); - } - this.errorHandler.handleError(`missing translation in ${this.fallback}: ${params.key}`); - return params.key; - }), - map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) - ); - } - } - return params.key; - } -} - @NgModule({ imports: [ TranslateModule.forRoot({ diff --git a/src/app/core/utils/translate/fallback-missing-translation-handler.ts b/src/app/core/utils/translate/fallback-missing-translation-handler.ts new file mode 100644 index 0000000000..fe3f8eced4 --- /dev/null +++ b/src/app/core/utils/translate/fallback-missing-translation-handler.ts @@ -0,0 +1,39 @@ +import { ErrorHandler, Inject, Injectable, InjectionToken } from '@angular/core'; +import { + MissingTranslationHandler, + MissingTranslationHandlerParams, + TranslateLoader, + TranslateParser, +} from '@ngx-translate/core'; +import { map } from 'rxjs/operators'; + +export const FALLBACK_LANG = new InjectionToken('fallbackTranslateLanguage'); + +@Injectable() +export class FallbackMissingTranslationHandler implements MissingTranslationHandler { + constructor( + private parser: TranslateParser, + private translateLoader: TranslateLoader, + private errorHandler: ErrorHandler, + @Inject(FALLBACK_LANG) private fallback: string + ) {} + + handle(params: MissingTranslationHandlerParams) { + if (params.interpolateParams || /^\w+(\.[\w-]+)+$/.test(params.key)) { + this.errorHandler.handleError(`missing translation in ${params.translateService.currentLang}: ${params.key}`); + if (params.translateService.currentLang !== this.fallback) { + return this.translateLoader.getTranslation(this.fallback).pipe( + map(translations => { + if (translations[params.key]) { + return this.parser.interpolate(translations[params.key], params.interpolateParams); + } + this.errorHandler.handleError(`missing translation in ${this.fallback}: ${params.key}`); + return params.key; + }), + map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) + ); + } + } + return params.key; + } +} From 77a3373dce788c4126c4f68a49b90eadfd80aaf9 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 9 Jun 2021 12:24:08 +0200 Subject: [PATCH 3/6] refactor: use store and effects for retrieving server translations --- .../configurations/ngrx-state-transfer.ts | 2 +- src/app/core/configurations/state-keys.ts | 4 - src/app/core/internationalization.module.ts | 90 +++++++------------ .../localizations.service.spec.ts | 21 +++++ .../localizations/localizations.service.ts | 45 ++++++++++ .../configuration/configuration.actions.ts | 18 +++- .../configuration.effects.spec.ts | 7 +- .../configuration/configuration.effects.ts | 35 +++++++- .../configuration.integration.spec.ts | 8 +- .../configuration/configuration.reducer.ts | 18 +++- .../configuration/configuration.selectors.ts | 3 + .../store/shopping/shopping-store.spec.ts | 4 +- 12 files changed, 183 insertions(+), 72 deletions(-) create mode 100644 src/app/core/services/localizations/localizations.service.spec.ts create mode 100644 src/app/core/services/localizations/localizations.service.ts diff --git a/src/app/core/configurations/ngrx-state-transfer.ts b/src/app/core/configurations/ngrx-state-transfer.ts index 3afbe2110b..6e3c9defd1 100644 --- a/src/app/core/configurations/ngrx-state-transfer.ts +++ b/src/app/core/configurations/ngrx-state-transfer.ts @@ -9,7 +9,7 @@ import { mergeDeep } from 'ish-core/utils/functions'; export const NGRX_STATE_SK = makeStateKey('ngrxState'); -const STATE_ACTION_TYPE = '[Internal] Import NgRx State'; +export const STATE_ACTION_TYPE = '[Internal] Import NgRx State'; let transferredState: object; diff --git a/src/app/core/configurations/state-keys.ts b/src/app/core/configurations/state-keys.ts index 810c6723bd..a630d4441b 100644 --- a/src/app/core/configurations/state-keys.ts +++ b/src/app/core/configurations/state-keys.ts @@ -1,11 +1,7 @@ import { makeStateKey } from '@angular/platform-browser'; -import { Translations } from 'ish-core/internationalization.module'; - export const DISPLAY_VERSION = makeStateKey('displayVersion'); export const COOKIE_CONSENT_VERSION = makeStateKey('cookieConsentVersion'); export const SSR_LOCALE = makeStateKey('ssrLocale'); - -export const SSR_TRANSLATIONS = makeStateKey('ssrTranslation'); diff --git a/src/app/core/internationalization.module.ts b/src/app/core/internationalization.module.ts index 197b60a2cd..19e700c67b 100644 --- a/src/app/core/internationalization.module.ts +++ b/src/app/core/internationalization.module.ts @@ -1,16 +1,17 @@ -import { isPlatformBrowser, isPlatformServer, registerLocaleData } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; +import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeFr from '@angular/common/locales/fr'; -import { Inject, Injectable, LOCALE_ID, NgModule, PLATFORM_ID } from '@angular/core'; +import { Inject, Injectable, LOCALE_ID, NgModule } from '@angular/core'; import { TransferState } from '@angular/platform-browser'; +import { Actions, ROOT_EFFECTS_INIT, ofType } from '@ngrx/effects'; import { Store, select } from '@ngrx/store'; import { MissingTranslationHandler, TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { from, of } from 'rxjs'; -import { catchError, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { ReplaySubject, combineLatest, defer, from, of } from 'rxjs'; +import { catchError, first, map, switchMap, take, tap } from 'rxjs/operators'; -import { SSR_LOCALE, SSR_TRANSLATIONS } from './configurations/state-keys'; -import { getRestEndpoint } from './store/core/configuration'; +import { NGRX_STATE_SK, STATE_ACTION_TYPE } from './configurations/ngrx-state-transfer'; +import { SSR_LOCALE } from './configurations/state-keys'; +import { getServerTranslations, loadServerTranslations } from './store/core/configuration'; import { whenTruthy } from './utils/operators'; import { FALLBACK_LANG, @@ -19,61 +20,38 @@ import { export type Translations = Record>; -function maybeJSON(val: string) { - if (val.startsWith('{')) { - try { - return JSON.parse(val); - } catch { - // default - } - } - return val; -} - -function filterAndTransformKeys(translations: Record): Translations { - return Object.entries(translations) - .filter(([key]) => key.startsWith('pwa-')) - .map(([key, value]) => [key.substring(4), maybeJSON(value)]) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); -} - @Injectable() class ICMTranslateLoader implements TranslateLoader { - constructor( - private httpClient: HttpClient, - private transferState: TransferState, - @Inject(PLATFORM_ID) private platformId: string, - private store: Store - ) {} + private initialized$ = new ReplaySubject(1); + + constructor(private store: Store, actions: Actions, transferState: TransferState) { + const actionType = transferState.hasKey(NGRX_STATE_SK) ? STATE_ACTION_TYPE : ROOT_EFFECTS_INIT; + actions.pipe(ofType(actionType), first()).subscribe(() => { + this.initialized$.next(true); + }); + } getTranslation(lang: string) { - if (isPlatformBrowser(this.platformId) && this.transferState.hasKey(SSR_TRANSLATIONS)) { - return of(this.transferState.get(SSR_TRANSLATIONS, undefined)); - } - const local$ = from(import(`../../assets/i18n/${lang}.json`)).pipe(catchError(() => of({}))); - return this.store.pipe( - select(getRestEndpoint), - whenTruthy(), - take(1), - switchMap(url => - this.httpClient.get(`${url};loc=${lang}/localizations`, { - params: { - searchKeys: 'pwa-', - }, - }) - ), - map(filterAndTransformKeys), - withLatestFrom(local$), - map(([translations, localTranslations]) => ({ - ...localTranslations, - ...translations, - })), - tap((translations: Translations) => { - if (isPlatformServer(this.platformId)) { - this.transferState.set(SSR_TRANSLATIONS, translations); + const local$ = defer(() => from(import(`../../assets/i18n/${lang}.json`)).pipe(catchError(() => of({})))); + const server$ = this.store.pipe( + select(getServerTranslations(lang)), + tap(translations => { + if (!translations) { + this.store.dispatch(loadServerTranslations({ lang })); } }), - catchError(() => local$) + whenTruthy(), + take(1) + ); + return this.initialized$.pipe( + switchMap(() => + combineLatest([local$, server$]).pipe( + map(([localTranslations, serverTranslations]) => ({ + ...localTranslations, + ...serverTranslations, + })) + ) + ) ); } } diff --git a/src/app/core/services/localizations/localizations.service.spec.ts b/src/app/core/services/localizations/localizations.service.spec.ts new file mode 100644 index 0000000000..a4cb1cc684 --- /dev/null +++ b/src/app/core/services/localizations/localizations.service.spec.ts @@ -0,0 +1,21 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; + +import { LocalizationsService } from './localizations.service'; + +describe('Localizations Service', () => { + let localizationsService: LocalizationsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [provideMockStore()], + }); + localizationsService = TestBed.inject(LocalizationsService); + }); + + it('should be created', () => { + expect(localizationsService).toBeTruthy(); + }); +}); diff --git a/src/app/core/services/localizations/localizations.service.ts b/src/app/core/services/localizations/localizations.service.ts new file mode 100644 index 0000000000..a43dd28917 --- /dev/null +++ b/src/app/core/services/localizations/localizations.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { map, switchMap } from 'rxjs/operators'; + +import { Translations } from 'ish-core/internationalization.module'; +import { getRestEndpoint } from 'ish-core/store/core/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; + +function maybeJSON(val: string) { + if (val.startsWith('{')) { + try { + return JSON.parse(val); + } catch { + // default + } + } + return val; +} + +function filterAndTransformKeys(translations: Record): Translations { + return Object.entries(translations) + .filter(([key]) => key.startsWith('pwa-')) + .map(([key, value]) => [key.substring(4), maybeJSON(value)]) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); +} + +@Injectable({ providedIn: 'root' }) +export class LocalizationsService { + constructor(private httpClient: HttpClient, private store: Store) {} + + getServerTranslations(lang: string) { + return this.store.pipe(select(getRestEndpoint), whenTruthy()).pipe( + switchMap(url => + this.httpClient + .get(`${url};loc=${lang}/localizations`, { + params: { + searchKeys: 'pwa-', + }, + }) + .pipe(map(filterAndTransformKeys)) + ) + ); + } +} diff --git a/src/app/core/store/core/configuration/configuration.actions.ts b/src/app/core/store/core/configuration/configuration.actions.ts index cce577b344..997053455b 100644 --- a/src/app/core/store/core/configuration/configuration.actions.ts +++ b/src/app/core/store/core/configuration/configuration.actions.ts @@ -1,9 +1,25 @@ import { createAction } from '@ngrx/store'; -import { payload } from 'ish-core/utils/ngrx-creators'; +import { Translations } from 'ish-core/internationalization.module'; +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; import { ConfigurationState } from './configuration.reducer'; type ConfigurationType = Partial; export const applyConfiguration = createAction('[Configuration] Apply Configuration', payload()); + +export const loadServerTranslations = createAction( + '[Configuration] Load Server Translations', + payload<{ lang: string }>() +); + +export const loadServerTranslationsSuccess = createAction( + '[Configuration] Load Server Translations Success', + payload<{ lang: string; translations: Translations }>() +); + +export const loadServerTranslationsFail = createAction( + '[Configuration] Load Server Translations Fail', + httpError<{ lang: string }>() +); diff --git a/src/app/core/store/core/configuration/configuration.effects.spec.ts b/src/app/core/store/core/configuration/configuration.effects.spec.ts index 0f5f95f897..03864c88f7 100644 --- a/src/app/core/store/core/configuration/configuration.effects.spec.ts +++ b/src/app/core/store/core/configuration/configuration.effects.spec.ts @@ -6,7 +6,9 @@ import { Action } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Observable, Subject, of } from 'rxjs'; import { take } from 'rxjs/operators'; +import { instance, mock } from 'ts-mockito'; +import { LocalizationsService } from 'ish-core/services/localizations/localizations.service'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { applyConfiguration } from './configuration.actions'; @@ -24,7 +26,10 @@ describe('Configuration Effects', () => { CoreStoreModule.forTesting(['configuration', 'serverConfig'], [ConfigurationEffects]), TranslateModule.forRoot(), ], - providers: [provideMockActions(() => actions$)], + providers: [ + provideMockActions(() => actions$), + { provide: LocalizationsService, useFactory: () => instance(mock(LocalizationsService)) }, + ], }); effects = TestBed.inject(ConfigurationEffects); diff --git a/src/app/core/store/core/configuration/configuration.effects.ts b/src/app/core/store/core/configuration/configuration.effects.ts index 3a888298f2..c8f0864b34 100644 --- a/src/app/core/store/core/configuration/configuration.effects.ts +++ b/src/app/core/store/core/configuration/configuration.effects.ts @@ -6,9 +6,11 @@ import { Store, select } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { defer, fromEvent, iif, merge } from 'rxjs'; import { + distinct, distinctUntilChanged, map, mapTo, + mergeMap, shareReplay, switchMap, take, @@ -20,10 +22,22 @@ import { LARGE_BREAKPOINT_WIDTH, MEDIUM_BREAKPOINT_WIDTH } from 'ish-core/config import { NGRX_STATE_SK } from 'ish-core/configurations/ngrx-state-transfer'; import { SSR_LOCALE } from 'ish-core/configurations/state-keys'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; -import { distinctCompareWith, mapToProperty, whenTruthy } from 'ish-core/utils/operators'; +import { LocalizationsService } from 'ish-core/services/localizations/localizations.service'; +import { + distinctCompareWith, + mapErrorToAction, + mapToPayloadProperty, + mapToProperty, + whenTruthy, +} from 'ish-core/utils/operators'; import { StatePropertiesService } from 'ish-core/utils/state-transfer/state-properties.service'; -import { applyConfiguration } from './configuration.actions'; +import { + applyConfiguration, + loadServerTranslations, + loadServerTranslationsFail, + loadServerTranslationsSuccess, +} from './configuration.actions'; import { getCurrentLocale, getDeviceType } from './configuration.selectors'; @Injectable() @@ -37,7 +51,8 @@ export class ConfigurationEffects { @Inject(MEDIUM_BREAKPOINT_WIDTH) private mediumBreakpointWidth: number, @Inject(LARGE_BREAKPOINT_WIDTH) private largeBreakpointWidth: number, translateService: TranslateService, - appRef: ApplicationRef + appRef: ApplicationRef, + private localizationsService: LocalizationsService ) { appRef.isStable .pipe(takeWhile(() => isPlatformBrowser(platformId))) @@ -133,4 +148,18 @@ export class ConfigurationEffects { ) ) ); + + loadServerTranslations$ = createEffect(() => + this.actions$.pipe( + ofType(loadServerTranslations), + mapToPayloadProperty('lang'), + distinct(), + mergeMap(lang => + this.localizationsService.getServerTranslations(lang).pipe( + map(translations => loadServerTranslationsSuccess({ lang, translations })), + mapErrorToAction(loadServerTranslationsFail, { lang }) + ) + ) + ) + ); } diff --git a/src/app/core/store/core/configuration/configuration.integration.spec.ts b/src/app/core/store/core/configuration/configuration.integration.spec.ts index 29159a55a4..b0eeb5b6ca 100644 --- a/src/app/core/store/core/configuration/configuration.integration.spec.ts +++ b/src/app/core/store/core/configuration/configuration.integration.spec.ts @@ -6,12 +6,13 @@ import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { EMPTY } from 'rxjs'; -import { mock, when } from 'ts-mockito'; +import { instance, mock, when } from 'ts-mockito'; import { configurationMeta } from 'ish-core/configurations/configuration.meta'; import { ConfigurationGuard } from 'ish-core/guards/configuration.guard'; import { Locale } from 'ish-core/models/locale/locale.model'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; +import { LocalizationsService } from 'ish-core/services/localizations/localizations.service'; import { applyConfiguration, getFeatures, getRestEndpoint } from 'ish-core/store/core/configuration'; import { ConfigurationEffects } from 'ish-core/store/core/configuration/configuration.effects'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; @@ -45,7 +46,10 @@ describe('Configuration Integration', () => { ]), TranslateModule.forRoot(), ], - providers: [provideStoreSnapshots()], + providers: [ + provideStoreSnapshots(), + { provide: LocalizationsService, useFactory: () => instance(mock(LocalizationsService)) }, + ], }); router = TestBed.inject(Router); diff --git a/src/app/core/store/core/configuration/configuration.reducer.ts b/src/app/core/store/core/configuration/configuration.reducer.ts index 9492b081fe..f010b38293 100644 --- a/src/app/core/store/core/configuration/configuration.reducer.ts +++ b/src/app/core/store/core/configuration/configuration.reducer.ts @@ -1,11 +1,12 @@ import { createReducer, on } from '@ngrx/store'; +import { Translations } from 'ish-core/internationalization.module'; import { Locale } from 'ish-core/models/locale/locale.model'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; import { environment } from '../../../../../environments/environment'; -import { applyConfiguration } from './configuration.actions'; +import { applyConfiguration, loadServerTranslationsFail, loadServerTranslationsSuccess } from './configuration.actions'; export interface ConfigurationState { baseURL?: string; @@ -20,6 +21,7 @@ export interface ConfigurationState { defaultLocale?: string; locales?: Locale[]; lang?: string; + serverTranslations: { [lang: string]: Translations }; // not synced via state transfer _deviceType?: DeviceType; } @@ -35,10 +37,22 @@ const initialState: ConfigurationState = { defaultLocale: environment.defaultLocale, locales: environment.locales, lang: undefined, + serverTranslations: {}, _deviceType: environment.defaultDeviceType, }; +function setTranslations(state: ConfigurationState, lang: string, translations: Translations): ConfigurationState { + return { + ...state, + serverTranslations: { ...state.serverTranslations, [lang]: translations }, + }; +} + export const configurationReducer = createReducer( initialState, - on(applyConfiguration, (state, action) => ({ ...state, ...action.payload })) + on(applyConfiguration, (state, action) => ({ ...state, ...action.payload })), + on(loadServerTranslationsSuccess, (state, action) => + setTranslations(state, action.payload.lang, action.payload.translations) + ), + on(loadServerTranslationsFail, (state, action) => setTranslations(state, action.payload.lang, {})) ); diff --git a/src/app/core/store/core/configuration/configuration.selectors.ts b/src/app/core/store/core/configuration/configuration.selectors.ts index 5fd0920771..1820a38a06 100644 --- a/src/app/core/store/core/configuration/configuration.selectors.ts +++ b/src/app/core/store/core/configuration/configuration.selectors.ts @@ -88,3 +88,6 @@ export const getIdentityProvider = createSelectorFactory< state.identityProvider && (state.identityProvider === 'ICM' ? { type: 'ICM' } : state.identityProviders?.[state.identityProvider]) ); + +export const getServerTranslations = (lang: string) => + createSelector(getConfigurationState, state => state.serverTranslations?.[lang]); diff --git a/src/app/core/store/shopping/shopping-store.spec.ts b/src/app/core/store/shopping/shopping-store.spec.ts index d21d1d6bcf..addf8a6ea8 100644 --- a/src/app/core/store/shopping/shopping-store.spec.ts +++ b/src/app/core/store/shopping/shopping-store.spec.ts @@ -977,7 +977,7 @@ describe('Shopping Store', () => { categories: tree(A.123,A.123.456) @ngrx/router-store/cancel: routerState: {"url":"","params":{},"queryParams":{},"data":{}} - storeState: {"configuration":{"defaultLocale":"en_US","locales":[3],"_de... + storeState: {"configuration":{"defaultLocale":"en_US","locales":[3],"ser... event: {"id":1,"url":"/category/A.123.456/product/P3"} @ngrx/router-store/request: routerState: {"url":"","params":{},"queryParams":{},"data":{}} @@ -1026,7 +1026,7 @@ describe('Shopping Store', () => { error: {"name":"HttpErrorResponse","message":"error loading categor... @ngrx/router-store/cancel: routerState: {"url":"","params":{},"queryParams":{},"data":{}} - storeState: {"configuration":{"defaultLocale":"en_US","locales":[3],"_de... + storeState: {"configuration":{"defaultLocale":"en_US","locales":[3],"ser... event: {"id":1,"url":"/category/A.123.XXX"} @ngrx/router-store/request: routerState: {"url":"","params":{},"queryParams":{},"data":{}} From 34145d6d5356595fdf52b4653d8a09ec79558fa9 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 9 Jun 2021 12:39:50 +0200 Subject: [PATCH 4/6] refactor: move icm translate loader and translations type to own files --- src/app/core/internationalization.module.ts | 48 +------------------ .../localizations/localizations.service.ts | 2 +- .../configuration/configuration.actions.ts | 2 +- .../configuration/configuration.reducer.ts | 2 +- .../utils/translate/icm-translate-loader.ts | 47 ++++++++++++++++++ .../core/utils/translate/translations.type.ts | 1 + 6 files changed, 53 insertions(+), 49 deletions(-) create mode 100644 src/app/core/utils/translate/icm-translate-loader.ts create mode 100644 src/app/core/utils/translate/translations.type.ts diff --git a/src/app/core/internationalization.module.ts b/src/app/core/internationalization.module.ts index 19e700c67b..6d73c51d5b 100644 --- a/src/app/core/internationalization.module.ts +++ b/src/app/core/internationalization.module.ts @@ -1,60 +1,16 @@ import { registerLocaleData } from '@angular/common'; import localeDe from '@angular/common/locales/de'; import localeFr from '@angular/common/locales/fr'; -import { Inject, Injectable, LOCALE_ID, NgModule } from '@angular/core'; +import { Inject, LOCALE_ID, NgModule } from '@angular/core'; import { TransferState } from '@angular/platform-browser'; -import { Actions, ROOT_EFFECTS_INIT, ofType } from '@ngrx/effects'; -import { Store, select } from '@ngrx/store'; import { MissingTranslationHandler, TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { ReplaySubject, combineLatest, defer, from, of } from 'rxjs'; -import { catchError, first, map, switchMap, take, tap } from 'rxjs/operators'; -import { NGRX_STATE_SK, STATE_ACTION_TYPE } from './configurations/ngrx-state-transfer'; import { SSR_LOCALE } from './configurations/state-keys'; -import { getServerTranslations, loadServerTranslations } from './store/core/configuration'; -import { whenTruthy } from './utils/operators'; import { FALLBACK_LANG, FallbackMissingTranslationHandler, } from './utils/translate/fallback-missing-translation-handler'; - -export type Translations = Record>; - -@Injectable() -class ICMTranslateLoader implements TranslateLoader { - private initialized$ = new ReplaySubject(1); - - constructor(private store: Store, actions: Actions, transferState: TransferState) { - const actionType = transferState.hasKey(NGRX_STATE_SK) ? STATE_ACTION_TYPE : ROOT_EFFECTS_INIT; - actions.pipe(ofType(actionType), first()).subscribe(() => { - this.initialized$.next(true); - }); - } - - getTranslation(lang: string) { - const local$ = defer(() => from(import(`../../assets/i18n/${lang}.json`)).pipe(catchError(() => of({})))); - const server$ = this.store.pipe( - select(getServerTranslations(lang)), - tap(translations => { - if (!translations) { - this.store.dispatch(loadServerTranslations({ lang })); - } - }), - whenTruthy(), - take(1) - ); - return this.initialized$.pipe( - switchMap(() => - combineLatest([local$, server$]).pipe( - map(([localTranslations, serverTranslations]) => ({ - ...localTranslations, - ...serverTranslations, - })) - ) - ) - ); - } -} +import { ICMTranslateLoader } from './utils/translate/icm-translate-loader'; @NgModule({ imports: [ diff --git a/src/app/core/services/localizations/localizations.service.ts b/src/app/core/services/localizations/localizations.service.ts index a43dd28917..df1f881560 100644 --- a/src/app/core/services/localizations/localizations.service.ts +++ b/src/app/core/services/localizations/localizations.service.ts @@ -3,9 +3,9 @@ import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { map, switchMap } from 'rxjs/operators'; -import { Translations } from 'ish-core/internationalization.module'; import { getRestEndpoint } from 'ish-core/store/core/configuration'; import { whenTruthy } from 'ish-core/utils/operators'; +import { Translations } from 'ish-core/utils/translate/translations.type'; function maybeJSON(val: string) { if (val.startsWith('{')) { diff --git a/src/app/core/store/core/configuration/configuration.actions.ts b/src/app/core/store/core/configuration/configuration.actions.ts index 997053455b..126272d25c 100644 --- a/src/app/core/store/core/configuration/configuration.actions.ts +++ b/src/app/core/store/core/configuration/configuration.actions.ts @@ -1,7 +1,7 @@ import { createAction } from '@ngrx/store'; -import { Translations } from 'ish-core/internationalization.module'; import { httpError, payload } from 'ish-core/utils/ngrx-creators'; +import { Translations } from 'ish-core/utils/translate/translations.type'; import { ConfigurationState } from './configuration.reducer'; diff --git a/src/app/core/store/core/configuration/configuration.reducer.ts b/src/app/core/store/core/configuration/configuration.reducer.ts index f010b38293..0e5dd7f08c 100644 --- a/src/app/core/store/core/configuration/configuration.reducer.ts +++ b/src/app/core/store/core/configuration/configuration.reducer.ts @@ -1,8 +1,8 @@ import { createReducer, on } from '@ngrx/store'; -import { Translations } from 'ish-core/internationalization.module'; import { Locale } from 'ish-core/models/locale/locale.model'; import { DeviceType } from 'ish-core/models/viewtype/viewtype.types'; +import { Translations } from 'ish-core/utils/translate/translations.type'; import { environment } from '../../../../../environments/environment'; diff --git a/src/app/core/utils/translate/icm-translate-loader.ts b/src/app/core/utils/translate/icm-translate-loader.ts new file mode 100644 index 0000000000..13525ed2af --- /dev/null +++ b/src/app/core/utils/translate/icm-translate-loader.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { TransferState } from '@angular/platform-browser'; +import { Actions, ROOT_EFFECTS_INIT, ofType } from '@ngrx/effects'; +import { Store, select } from '@ngrx/store'; +import { TranslateLoader } from '@ngx-translate/core'; +import { ReplaySubject, combineLatest, defer, from, of } from 'rxjs'; +import { catchError, first, map, switchMap, take, tap } from 'rxjs/operators'; + +import { NGRX_STATE_SK, STATE_ACTION_TYPE } from 'ish-core/configurations/ngrx-state-transfer'; +import { getServerTranslations, loadServerTranslations } from 'ish-core/store/core/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; + +@Injectable() +export class ICMTranslateLoader implements TranslateLoader { + private initialized$ = new ReplaySubject(1); + + constructor(private store: Store, actions: Actions, transferState: TransferState) { + const actionType = transferState.hasKey(NGRX_STATE_SK) ? STATE_ACTION_TYPE : ROOT_EFFECTS_INIT; + actions.pipe(ofType(actionType), first()).subscribe(() => { + this.initialized$.next(true); + }); + } + + getTranslation(lang: string) { + const local$ = defer(() => from(import(`../../../../assets/i18n/${lang}.json`)).pipe(catchError(() => of({})))); + const server$ = this.store.pipe( + select(getServerTranslations(lang)), + tap(translations => { + if (!translations) { + this.store.dispatch(loadServerTranslations({ lang })); + } + }), + whenTruthy(), + take(1) + ); + return this.initialized$.pipe( + switchMap(() => + combineLatest([local$, server$]).pipe( + map(([localTranslations, serverTranslations]) => ({ + ...localTranslations, + ...serverTranslations, + })) + ) + ) + ); + } +} diff --git a/src/app/core/utils/translate/translations.type.ts b/src/app/core/utils/translate/translations.type.ts new file mode 100644 index 0000000000..ba69d0d147 --- /dev/null +++ b/src/app/core/utils/translate/translations.type.ts @@ -0,0 +1 @@ +export type Translations = Record>; From 4ea6a4923766a03f0920c1c6075a03c5acd930ad Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 9 Jun 2021 12:51:37 +0200 Subject: [PATCH 5/6] feat: report missing translation only once --- .../fallback-missing-translation-handler.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/core/utils/translate/fallback-missing-translation-handler.ts b/src/app/core/utils/translate/fallback-missing-translation-handler.ts index fe3f8eced4..57e7da9c8e 100644 --- a/src/app/core/utils/translate/fallback-missing-translation-handler.ts +++ b/src/app/core/utils/translate/fallback-missing-translation-handler.ts @@ -5,6 +5,7 @@ import { TranslateLoader, TranslateParser, } from '@ngx-translate/core'; +import { memoize } from 'lodash-es'; import { map } from 'rxjs/operators'; export const FALLBACK_LANG = new InjectionToken('fallbackTranslateLanguage'); @@ -18,16 +19,23 @@ export class FallbackMissingTranslationHandler implements MissingTranslationHand @Inject(FALLBACK_LANG) private fallback: string ) {} + private reportMissingTranslation = memoize<(lang: string, key: string) => void>( + (lang, key) => { + this.errorHandler.handleError(`missing translation in ${lang}: ${key}`); + }, + (lang, key) => lang + key + ); + handle(params: MissingTranslationHandlerParams) { if (params.interpolateParams || /^\w+(\.[\w-]+)+$/.test(params.key)) { - this.errorHandler.handleError(`missing translation in ${params.translateService.currentLang}: ${params.key}`); + this.reportMissingTranslation(params.translateService.currentLang, params.key); if (params.translateService.currentLang !== this.fallback) { return this.translateLoader.getTranslation(this.fallback).pipe( map(translations => { if (translations[params.key]) { return this.parser.interpolate(translations[params.key], params.interpolateParams); } - this.errorHandler.handleError(`missing translation in ${this.fallback}: ${params.key}`); + this.reportMissingTranslation(this.fallback, params.key); return params.key; }), map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) From 8811ec7e73bb14c67b001eb5d73ff2cabf111a4f Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 9 Jun 2021 13:47:58 +0200 Subject: [PATCH 6/6] feat: check ICM api for specific translations as fallback --- .../localizations/localizations.service.ts | 20 ++++-- .../configuration/configuration.actions.ts | 10 +++ .../configuration/configuration.effects.ts | 15 ++++ .../configuration/configuration.reducer.ts | 27 ++++++- .../configuration/configuration.selectors.ts | 3 + .../fallback-missing-translation-handler.ts | 71 +++++++++++++++---- 6 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/app/core/services/localizations/localizations.service.ts b/src/app/core/services/localizations/localizations.service.ts index df1f881560..c5dc9664ad 100644 --- a/src/app/core/services/localizations/localizations.service.ts +++ b/src/app/core/services/localizations/localizations.service.ts @@ -1,7 +1,8 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; -import { map, switchMap } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { catchError, concatMap, map, take } from 'rxjs/operators'; import { getRestEndpoint } from 'ish-core/store/core/configuration'; import { whenTruthy } from 'ish-core/utils/operators'; @@ -27,11 +28,15 @@ function filterAndTransformKeys(translations: Record): Translati @Injectable({ providedIn: 'root' }) export class LocalizationsService { - constructor(private httpClient: HttpClient, private store: Store) {} + private icmEndpoint$: Observable; + + constructor(private httpClient: HttpClient, store: Store) { + this.icmEndpoint$ = store.pipe(select(getRestEndpoint), whenTruthy(), take(1)); + } getServerTranslations(lang: string) { - return this.store.pipe(select(getRestEndpoint), whenTruthy()).pipe( - switchMap(url => + return this.icmEndpoint$.pipe( + concatMap(url => this.httpClient .get(`${url};loc=${lang}/localizations`, { params: { @@ -42,4 +47,11 @@ export class LocalizationsService { ) ); } + + getSpecificTranslation(lang: string, key: string) { + return this.icmEndpoint$.pipe( + concatMap(url => this.httpClient.get(`${url};loc=${lang}/localizations/${key}`, { responseType: 'text' })), + catchError(() => of('')) + ); + } } diff --git a/src/app/core/store/core/configuration/configuration.actions.ts b/src/app/core/store/core/configuration/configuration.actions.ts index 126272d25c..db5513bf03 100644 --- a/src/app/core/store/core/configuration/configuration.actions.ts +++ b/src/app/core/store/core/configuration/configuration.actions.ts @@ -23,3 +23,13 @@ export const loadServerTranslationsFail = createAction( '[Configuration] Load Server Translations Fail', httpError<{ lang: string }>() ); + +export const loadSingleServerTranslation = createAction( + '[Configuration] Load Single Server Translation', + payload<{ lang: string; key: string }>() +); + +export const loadSingleServerTranslationSuccess = createAction( + '[Configuration] Load Single Server Translation Success', + payload<{ lang: string; key: string; translation: string }>() +); diff --git a/src/app/core/store/core/configuration/configuration.effects.ts b/src/app/core/store/core/configuration/configuration.effects.ts index c8f0864b34..085fba519b 100644 --- a/src/app/core/store/core/configuration/configuration.effects.ts +++ b/src/app/core/store/core/configuration/configuration.effects.ts @@ -26,6 +26,7 @@ import { LocalizationsService } from 'ish-core/services/localizations/localizati import { distinctCompareWith, mapErrorToAction, + mapToPayload, mapToPayloadProperty, mapToProperty, whenTruthy, @@ -37,6 +38,8 @@ import { loadServerTranslations, loadServerTranslationsFail, loadServerTranslationsSuccess, + loadSingleServerTranslation, + loadSingleServerTranslationSuccess, } from './configuration.actions'; import { getCurrentLocale, getDeviceType } from './configuration.selectors'; @@ -162,4 +165,16 @@ export class ConfigurationEffects { ) ) ); + + loadSingleServerTranslation$ = createEffect(() => + this.actions$.pipe( + ofType(loadSingleServerTranslation), + mapToPayload(), + mergeMap(({ lang, key }) => + this.localizationsService + .getSpecificTranslation(lang, key) + .pipe(map(translation => loadSingleServerTranslationSuccess({ lang, key, translation }))) + ) + ) + ); } diff --git a/src/app/core/store/core/configuration/configuration.reducer.ts b/src/app/core/store/core/configuration/configuration.reducer.ts index 0e5dd7f08c..67e4469038 100644 --- a/src/app/core/store/core/configuration/configuration.reducer.ts +++ b/src/app/core/store/core/configuration/configuration.reducer.ts @@ -6,7 +6,12 @@ import { Translations } from 'ish-core/utils/translate/translations.type'; import { environment } from '../../../../../environments/environment'; -import { applyConfiguration, loadServerTranslationsFail, loadServerTranslationsSuccess } from './configuration.actions'; +import { + applyConfiguration, + loadServerTranslationsFail, + loadServerTranslationsSuccess, + loadSingleServerTranslationSuccess, +} from './configuration.actions'; export interface ConfigurationState { baseURL?: string; @@ -48,11 +53,29 @@ function setTranslations(state: ConfigurationState, lang: string, translations: }; } +function addSingleTranslation( + state: ConfigurationState, + lang: string, + key: string, + translation: string +): ConfigurationState { + return { + ...state, + serverTranslations: { + ...state.serverTranslations, + [lang]: { ...state.serverTranslations?.[lang], [key]: translation }, + }, + }; +} + export const configurationReducer = createReducer( initialState, on(applyConfiguration, (state, action) => ({ ...state, ...action.payload })), on(loadServerTranslationsSuccess, (state, action) => setTranslations(state, action.payload.lang, action.payload.translations) ), - on(loadServerTranslationsFail, (state, action) => setTranslations(state, action.payload.lang, {})) + on(loadServerTranslationsFail, (state, action) => setTranslations(state, action.payload.lang, {})), + on(loadSingleServerTranslationSuccess, (state, action) => + addSingleTranslation(state, action.payload.lang, action.payload.key, action.payload.translation) + ) ); diff --git a/src/app/core/store/core/configuration/configuration.selectors.ts b/src/app/core/store/core/configuration/configuration.selectors.ts index 1820a38a06..23932ecf7f 100644 --- a/src/app/core/store/core/configuration/configuration.selectors.ts +++ b/src/app/core/store/core/configuration/configuration.selectors.ts @@ -91,3 +91,6 @@ export const getIdentityProvider = createSelectorFactory< export const getServerTranslations = (lang: string) => createSelector(getConfigurationState, state => state.serverTranslations?.[lang]); + +export const getSpecificServerTranslation = (lang: string, key: string) => + createSelector(getServerTranslations(lang), translations => translations?.[key]); diff --git a/src/app/core/utils/translate/fallback-missing-translation-handler.ts b/src/app/core/utils/translate/fallback-missing-translation-handler.ts index 57e7da9c8e..ff60b4c833 100644 --- a/src/app/core/utils/translate/fallback-missing-translation-handler.ts +++ b/src/app/core/utils/translate/fallback-missing-translation-handler.ts @@ -1,4 +1,6 @@ -import { ErrorHandler, Inject, Injectable, InjectionToken } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { ErrorHandler, Inject, Injectable, InjectionToken, PLATFORM_ID } from '@angular/core'; +import { Store, select } from '@ngrx/store'; import { MissingTranslationHandler, MissingTranslationHandlerParams, @@ -6,7 +8,11 @@ import { TranslateParser, } from '@ngx-translate/core'; import { memoize } from 'lodash-es'; -import { map } from 'rxjs/operators'; +import { concat, defer, iif, of } from 'rxjs'; +import { filter, first, map, tap } from 'rxjs/operators'; + +import { getSpecificServerTranslation, loadSingleServerTranslation } from 'ish-core/store/core/configuration'; +import { whenTruthy } from 'ish-core/utils/operators'; export const FALLBACK_LANG = new InjectionToken('fallbackTranslateLanguage'); @@ -16,7 +22,9 @@ export class FallbackMissingTranslationHandler implements MissingTranslationHand private parser: TranslateParser, private translateLoader: TranslateLoader, private errorHandler: ErrorHandler, - @Inject(FALLBACK_LANG) private fallback: string + @Inject(FALLBACK_LANG) private fallback: string, + @Inject(PLATFORM_ID) private platformId: string, + private store: Store ) {} private reportMissingTranslation = memoize<(lang: string, key: string) => void>( @@ -26,21 +34,54 @@ export class FallbackMissingTranslationHandler implements MissingTranslationHand (lang, key) => lang + key ); + private retrieveSpecificTranslation(lang: string, key: string) { + return defer(() => + this.store.pipe( + select(getSpecificServerTranslation(lang, key)), + tap(translation => { + if (translation === undefined) { + this.store.dispatch(loadSingleServerTranslation({ lang, key })); + } + }), + filter(translation => translation !== undefined), + first() + ) + ); + } + handle(params: MissingTranslationHandlerParams) { if (params.interpolateParams || /^\w+(\.[\w-]+)+$/.test(params.key)) { this.reportMissingTranslation(params.translateService.currentLang, params.key); - if (params.translateService.currentLang !== this.fallback) { - return this.translateLoader.getTranslation(this.fallback).pipe( - map(translations => { - if (translations[params.key]) { - return this.parser.interpolate(translations[params.key], params.interpolateParams); - } - this.reportMissingTranslation(this.fallback, params.key); - return params.key; - }), - map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) - ); - } + + const doSingleCheck = isPlatformBrowser(this.platformId) && /\berror\b/.test(params.key); + const isFallbackAvailable = params.translateService.currentLang !== this.fallback; + return concat( + // try API call with specific key + iif(() => doSingleCheck, this.retrieveSpecificTranslation(params.translateService.currentLang, params.key)), + // try fallback translations + iif( + () => isFallbackAvailable, + this.translateLoader.getTranslation(this.fallback).pipe( + map(translations => translations?.[params.key]), + tap(translation => { + if (!translation) { + this.reportMissingTranslation(this.fallback, params.key); + } + }), + first() + ) + ), + // try API call with fallback translation + iif(() => isFallbackAvailable && doSingleCheck, this.retrieveSpecificTranslation(this.fallback, params.key)), + // give up + of(params.key) + ).pipe( + // stop after first emission + whenTruthy(), + first(), + map(translation => this.parser.interpolate(translation, params.interpolateParams)), + map(translation => (PRODUCTION_MODE ? translation : 'TRANSLATE_ME ' + translation)) + ); } return params.key; }