From 52fc2c6278f07a3cd760952e0eee247434b76106 Mon Sep 17 00:00:00 2001 From: SchettlerKoehler <62466958+SchettlerKoehler@users.noreply.github.com> Date: Fri, 2 Feb 2024 08:33:22 +0100 Subject: [PATCH] feat: adding CachingTranslateLoader (#96) * feat: adding CachingTranslateLoader * fix: linter issues * fix: linter issues * fix: createTranslateLoader fix * fix: add defaultIfEmpty, fix unit tests --------- Co-authored-by: kim.tran --- libs/portal-integration-angular/package.json | 1 + libs/portal-integration-angular/src/index.ts | 2 + .../src/lib/core/portal-core.module.ts | 3 +- .../utils/async-translate-loader.utils.ts | 5 +- .../caching-translate-loader.utils.spec.ts | 80 +++++++++++++ .../utils/caching-translate-loader.utils.ts | 34 ++++++ .../create-translate-loader.utils.spec.ts | 106 ++++++++++++++---- .../utils/create-translate-loader.utils.ts | 23 +++- .../lib/services/translation-cache.service.ts | 41 +++++++ package-lock.json | 4 +- 10 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.spec.ts create mode 100644 libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.ts create mode 100644 libs/portal-integration-angular/src/lib/services/translation-cache.service.ts diff --git a/libs/portal-integration-angular/package.json b/libs/portal-integration-angular/package.json index 9f847fae..6a0cda99 100644 --- a/libs/portal-integration-angular/package.json +++ b/libs/portal-integration-angular/package.json @@ -18,6 +18,7 @@ "@ngneat/until-destroy": "^9.2.2", "chart.js": "^4.4.0", "d3-scale-chromatic": "^3.0.0", + "@onecx/accelerator": "~4", "@onecx/integration-interface": "~4", "fast-deep-equal": "^3.1.3", "msw": "^1.3.2", diff --git a/libs/portal-integration-angular/src/index.ts b/libs/portal-integration-angular/src/index.ts index 2f0ccf8c..bde66c78 100644 --- a/libs/portal-integration-angular/src/index.ts +++ b/libs/portal-integration-angular/src/index.ts @@ -71,6 +71,7 @@ export * from './lib/services/userprofile-api.service' export * from './lib/services/portal-dialog.service' export * from './lib/services/user.service' export * from './lib/services/export-data.service' +export * from './lib/services/translation-cache.service' // pipes export * from './lib/core/pipes/dynamic.pipe' @@ -124,3 +125,4 @@ export * from './lib/core/utils/create-translate-loader.utils' export * from './lib/core/utils/add-initialize-module-guard.utils' export * from './lib/core/utils/translate-service-initializer.utils' export * from './lib/core/utils/portal-api-configuration.utils' +export * from './lib/core/utils/caching-translate-loader.utils' diff --git a/libs/portal-integration-angular/src/lib/core/portal-core.module.ts b/libs/portal-integration-angular/src/lib/core/portal-core.module.ts index a14e4404..c543b72c 100644 --- a/libs/portal-integration-angular/src/lib/core/portal-core.module.ts +++ b/libs/portal-integration-angular/src/lib/core/portal-core.module.ts @@ -85,6 +85,7 @@ import { UserService } from '../services/user.service' import { UserProfileAPIService } from '../services/userprofile-api.service' import { createTranslateLoader } from './utils/create-translate-loader.utils' import { MessageService } from 'primeng/api' +import { TranslationCacheService } from '../services/translation-cache.service' export class PortalMissingTranslationHandler implements MissingTranslationHandler { handle(params: MissingTranslationHandlerParams) { @@ -105,7 +106,7 @@ export class PortalMissingTranslationHandler implements MissingTranslationHandle loader: { provide: TranslateLoader, useFactory: createTranslateLoader, - deps: [HttpClient, AppStateService, ConfigurationService], + deps: [HttpClient, AppStateService, TranslationCacheService], }, missingTranslationHandler: { provide: MissingTranslationHandler, useClass: PortalMissingTranslationHandler }, }), diff --git a/libs/portal-integration-angular/src/lib/core/utils/async-translate-loader.utils.ts b/libs/portal-integration-angular/src/lib/core/utils/async-translate-loader.utils.ts index 1e1ba362..b8282ef2 100644 --- a/libs/portal-integration-angular/src/lib/core/utils/async-translate-loader.utils.ts +++ b/libs/portal-integration-angular/src/lib/core/utils/async-translate-loader.utils.ts @@ -1,5 +1,5 @@ import { TranslateLoader } from '@ngx-translate/core' -import { first, mergeMap, Observable, tap } from 'rxjs' +import { defaultIfEmpty, first, mergeMap, Observable, of, tap } from 'rxjs' export class AsyncTranslateLoader implements TranslateLoader { static lastTimerId = 0 @@ -10,8 +10,9 @@ export class AsyncTranslateLoader implements TranslateLoader { getTranslation(lang: string): Observable { return this.translateLoader$.pipe( tap(() => console.time('AsyncTranslateLoader_' + this.timerId)), + defaultIfEmpty(undefined), first(), - mergeMap((translateLoader) => translateLoader.getTranslation(lang)), + mergeMap((translateLoader) => translateLoader?.getTranslation(lang) ?? of({})), tap(() => console.timeEnd('AsyncTranslateLoader_' + this.timerId)) ) } diff --git a/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.spec.ts b/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.spec.ts new file mode 100644 index 00000000..fff6d574 --- /dev/null +++ b/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.spec.ts @@ -0,0 +1,80 @@ +import { HttpClient } from '@angular/common/http' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { of } from 'rxjs' +import { TranslationCacheService } from '../../services/translation-cache.service' +import { CachingTranslateLoader } from './caching-translate-loader.utils' + +describe('CachingTranslateLoader', () => { + const origAddEventListener = window.addEventListener + const origPostMessage = window.postMessage + + let listeners: any[] = [] + window.addEventListener = (_type: any, listener: any) => { + listeners.push(listener) + } + + window.removeEventListener = (_type: any, listener: any) => { + listeners = listeners.filter((l) => l !== listener) + } + + window.postMessage = (m: any) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + listeners.forEach((l) => l({ data: m, stopImmediatePropagation: () => {}, stopPropagation: () => {} })) + } + + afterAll(() => { + window.addEventListener = origAddEventListener + window.postMessage = origPostMessage + }) + + let http: HttpClient + let translationCache: TranslationCacheService + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [], + imports: [HttpClientTestingModule], + }).compileComponents() + http = TestBed.inject(HttpClient) + translationCache = TestBed.inject(TranslationCacheService) + }) + + it('should get translations', (done) => { + const translateLoader = new CachingTranslateLoader(translationCache, http, `./assets/i18n/`, '.json') + const translation = { testKey: 'my translation' } + http.get = jest.fn().mockReturnValue(of(translation)) + translateLoader.getTranslation('en').subscribe((t) => { + expect(t).toEqual(translation) + done() + }) + }) + + it('should load translations only once', (done) => { + let httpCalls = 0 + const responses = [] + const translation = { testKey: 'my translation' } + http.get = jest.fn().mockImplementation(() => { + httpCalls++ + return of(translation) + }) + + const translateLoader = new CachingTranslateLoader(translationCache, http, `./assets/i18n/`, '.json') + translateLoader.getTranslation('en').subscribe((t) => { + responses.push(t) + expect(t).toEqual(translation) + expect(httpCalls).toEqual(1) + if (responses.length == 2) { + done() + } + }) + const translateLoader2 = new CachingTranslateLoader(translationCache, http, `./assets/i18n/`, '.json') + translateLoader2.getTranslation('en').subscribe((t) => { + responses.push(t) + expect(t).toEqual(translation) + expect(httpCalls).toEqual(1) + if (responses.length == 2) { + done() + } + }) + }) +}) diff --git a/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.ts b/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.ts new file mode 100644 index 00000000..9aeebe1b --- /dev/null +++ b/libs/portal-integration-angular/src/lib/core/utils/caching-translate-loader.utils.ts @@ -0,0 +1,34 @@ +import { HttpClient } from '@angular/common/http' +import { TranslateLoader } from '@ngx-translate/core' +import { TranslateHttpLoader } from '@ngx-translate/http-loader' +import { filter, first, mergeMap, Observable, of } from 'rxjs' +import { TranslationCacheService } from '../../services/translation-cache.service' + +export class CachingTranslateLoader implements TranslateLoader { + private translateLoader = new TranslateHttpLoader(this.http, this.prefix, this.suffix) + + constructor( + private translationCache: TranslationCacheService, + private http: HttpClient, + private prefix?: string, + private suffix?: string + ) {} + + getTranslation(lang: string): Observable { + const url = `${this.prefix}${lang}${this.suffix}` + + return this.translationCache.getTranslationFile(url).pipe( + filter((tf) => tf !== null), + first(), + mergeMap((tf) => { + if (tf) { + return of(tf) + } + return this.translationCache.updateTranslationFile(url, null).pipe( + mergeMap(() => this.translateLoader.getTranslation(lang)), + mergeMap((t) => this.translationCache.updateTranslationFile(url, t)) + ) + }) + ) + } +} diff --git a/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.spec.ts b/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.spec.ts index 6f97550b..f6d4236e 100644 --- a/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.spec.ts +++ b/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.spec.ts @@ -4,45 +4,111 @@ import { createTranslateLoader } from './create-translate-loader.utils' import { MockService } from 'ng-mocks' import { Observable, of } from 'rxjs' import { MfeInfo } from '@onecx/integration-interface' +import { TestBed } from '@angular/core/testing' +import { EnvironmentInjector } from '@angular/core' +import { TranslationCacheService } from '../../services/translation-cache.service' describe('CreateTranslateLoader', () => { + const origAddEventListener = window.addEventListener + const origPostMessage = window.postMessage + + let listeners: any[] = [] + window.addEventListener = (_type: any, listener: any) => { + listeners.push(listener) + } + + window.removeEventListener = (_type: any, listener: any) => { + listeners = listeners.filter((l) => l !== listener) + } + + window.postMessage = (m: any) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + listeners.forEach((l) => l({ data: m, stopImmediatePropagation: () => {}, stopPropagation: () => {} })) + } + + afterAll(() => { + window.addEventListener = origAddEventListener + window.postMessage = origPostMessage + }) + const httpClientMock = MockService(HttpClient) - httpClientMock.get = jest.fn(() => of({})) as any + httpClientMock.get = jest.fn(() => of({}, {}, {})) as any let currentMfe$: Observable> let globalLoading$: Observable + let environmentInjector: EnvironmentInjector + let translationCacheService: TranslationCacheService const appStateServiceMock = { currentMfe$: { asObservable: () => currentMfe$ }, globalLoading$: { asObservable: () => globalLoading$ }, } - beforeEach(() => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [], + }).compileComponents() + environmentInjector = TestBed.inject(EnvironmentInjector) + translationCacheService = TestBed.inject(TranslationCacheService) jest.clearAllMocks() }) - it('should call httpClient get 3 times if a remoteBaseUrl is set and if global loading is finished', () => { - currentMfe$ = of({ remoteBaseUrl: 'remoteUrl' }) - globalLoading$ = of(false) - const translateLoader = createTranslateLoader( - httpClientMock, - (appStateServiceMock), - ) + describe('without TranslationCache parameter', () => { + it('should call httpClient get 3 times if a remoteBaseUrl is set and if global loading is finished', (done) => { + currentMfe$ = of({ remoteBaseUrl: 'remoteUrl' }) + globalLoading$ = of(false) + const translateLoader = environmentInjector.runInContext(() => + createTranslateLoader(httpClientMock, (appStateServiceMock)) + ) - translateLoader.getTranslation('en').subscribe() + translateLoader.getTranslation('en').subscribe(() => { + expect(httpClientMock.get).toHaveBeenCalledTimes(3) + done() + }) + }) - expect(httpClientMock.get).toHaveBeenCalledTimes(3) + it('should not call httpClient get if global loading is not finished', (done) => { + currentMfe$ = of({}) + globalLoading$ = of(true) + const translateLoader = environmentInjector.runInContext(() => + createTranslateLoader(httpClientMock, (appStateServiceMock)) + ) + + translateLoader.getTranslation('en').subscribe(() => { + expect(httpClientMock.get).toHaveBeenCalledTimes(0) + done() + }) + }) }) - it('should not call httpClient get if global loading is not finished', () => { - currentMfe$ = of({}) - globalLoading$ = of(true) - const translateLoader = createTranslateLoader( - httpClientMock, - (appStateServiceMock), - ) + describe('with TranslationCache parameter', () => { + it('should call httpClient get 3 times if a remoteBaseUrl is set and if global loading is finished', (done) => { + currentMfe$ = of({ remoteBaseUrl: 'remoteUrl' }) + globalLoading$ = of(false) + const translateLoader = createTranslateLoader( + httpClientMock, + (appStateServiceMock), + translationCacheService + ) + + translateLoader.getTranslation('en').subscribe(() => { + expect(httpClientMock.get).toHaveBeenCalledTimes(3) + done() + }) + }) - translateLoader.getTranslation('en').subscribe() + it('should not call httpClient get if global loading is not finished', (done) => { + currentMfe$ = of({}) + globalLoading$ = of(true) + const translateLoader = createTranslateLoader( + httpClientMock, + (appStateServiceMock), + translationCacheService + ) - expect(httpClientMock.get).toHaveBeenCalledTimes(0) + translateLoader.getTranslation('en').subscribe(() => { + expect(httpClientMock.get).toHaveBeenCalledTimes(0) + done() + }) + }) }) }) diff --git a/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.ts b/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.ts index 57d4350a..6f70c31b 100644 --- a/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.ts +++ b/libs/portal-integration-angular/src/lib/core/utils/create-translate-loader.utils.ts @@ -1,15 +1,22 @@ import { Location } from '@angular/common' import { HttpClient } from '@angular/common/http' +import { inject } from '@angular/core' import { TranslateLoader } from '@ngx-translate/core' -import { TranslateHttpLoader } from '@ngx-translate/http-loader' import { combineLatest, filter, map, tap } from 'rxjs' import { AppStateService } from '../../services/app-state.service' +import { TranslationCacheService } from '../../services/translation-cache.service' import { AsyncTranslateLoader } from './async-translate-loader.utils' +import { CachingTranslateLoader } from './caching-translate-loader.utils' import { TranslateCombinedLoader } from './translate.combined.loader' let lastTranslateLoaderTimerId = 0 -export function createTranslateLoader(http: HttpClient, appStateService: AppStateService): TranslateLoader { +export function createTranslateLoader( + http: HttpClient, + appStateService: AppStateService, + translationCacheService?: TranslationCacheService +): TranslateLoader { + const ts = translationCacheService ?? inject(TranslationCacheService) const timerId = lastTranslateLoaderTimerId++ console.time('createTranslateLoader_' + timerId) return new AsyncTranslateLoader( @@ -18,15 +25,21 @@ export function createTranslateLoader(http: HttpClient, appStateService: AppStat map(([currentMfe]) => { return new TranslateCombinedLoader( // translations of shell or of app in standalone mode - new TranslateHttpLoader(http, `./assets/i18n/`, '.json'), + new CachingTranslateLoader(ts, http, `./assets/i18n/`, '.json'), // translations of portal-integration-angular of app - new TranslateHttpLoader( + new CachingTranslateLoader( + ts, http, Location.joinWithSlash(currentMfe.remoteBaseUrl, `onecx-portal-lib/assets/i18n/`), '.json' ), // translations of the app - new TranslateHttpLoader(http, Location.joinWithSlash(currentMfe.remoteBaseUrl, `assets/i18n/`), '.json') + new CachingTranslateLoader( + ts, + http, + Location.joinWithSlash(currentMfe.remoteBaseUrl, `assets/i18n/`), + '.json' + ) ) }), tap(() => console.timeEnd('createTranslateLoader_' + timerId)) diff --git a/libs/portal-integration-angular/src/lib/services/translation-cache.service.ts b/libs/portal-integration-angular/src/lib/services/translation-cache.service.ts new file mode 100644 index 00000000..420757ce --- /dev/null +++ b/libs/portal-integration-angular/src/lib/services/translation-cache.service.ts @@ -0,0 +1,41 @@ +import { Injectable, OnDestroy } from '@angular/core' +import { SyncableTopic } from '@onecx/accelerator' +import { Observable, concat, first, from, map, mergeMap, of } from 'rxjs' + +// This topic is defined here and not in integration-interface, because +// it is not used as framework independent integration but for improving +// angular specific things +class TranslationCacheTopic extends SyncableTopic> { + constructor() { + super('translationCache', 1) + } +} + +@Injectable({ providedIn: 'root' }) +export class TranslationCacheService implements OnDestroy { + translationCache$ = new TranslationCacheTopic() + + ngOnDestroy(): void { + this.translationCache$.destroy() + } + + getTranslationFile(url: string): Observable { + return this.getCache().pipe(map((t) => t[url])) + } + + updateTranslationFile(url: string, translations: any): Observable { + return this.getCache().pipe( + first(), + mergeMap((t) => { + return from(this.translationCache$.publish({ ...t, [url]: translations })).pipe(map(() => translations)) + }) + ) + } + + private getCache(): Observable> { + if (this.translationCache$.getValue()) { + return this.translationCache$.asObservable() + } + return concat(of({}), this.translationCache$.asObservable()) + } +} diff --git a/package-lock.json b/package-lock.json index c9250b8a..339fec39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@onecx/onecx-portal-ui-libs", - "version": "4.0.2", + "version": "4.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@onecx/onecx-portal-ui-libs", - "version": "4.0.2", + "version": "4.1.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": {