Skip to content

Commit

Permalink
feat: adding CachingTranslateLoader (#96)
Browse files Browse the repository at this point in the history
* feat: adding CachingTranslateLoader

* fix: linter issues

* fix: linter issues

* fix: createTranslateLoader fix

* fix: add defaultIfEmpty, fix unit tests

---------

Co-authored-by: kim.tran <[email protected]>
  • Loading branch information
SchettlerKoehler and kim.tran authored Feb 2, 2024
1 parent b4ca427 commit 52fc2c6
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 30 deletions.
1 change: 1 addition & 0 deletions libs/portal-integration-angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions libs/portal-integration-angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 },
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,8 +10,9 @@ export class AsyncTranslateLoader implements TranslateLoader {
getTranslation(lang: string): Observable<any> {
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))
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
})
Original file line number Diff line number Diff line change
@@ -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<any> {
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))
)
})
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<MfeInfo>>
let globalLoading$: Observable<boolean>
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,
<AppStateService>(<unknown>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, <AppStateService>(<unknown>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, <AppStateService>(<unknown>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,
<AppStateService>(<unknown>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,
<AppStateService>(<unknown>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,
<AppStateService>(<unknown>appStateServiceMock),
translationCacheService
)

expect(httpClientMock.get).toHaveBeenCalledTimes(0)
translateLoader.getTranslation('en').subscribe(() => {
expect(httpClientMock.get).toHaveBeenCalledTimes(0)
done()
})
})
})
})
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>> {
constructor() {
super('translationCache', 1)
}
}

@Injectable({ providedIn: 'root' })
export class TranslationCacheService implements OnDestroy {
translationCache$ = new TranslationCacheTopic()

ngOnDestroy(): void {
this.translationCache$.destroy()
}

getTranslationFile(url: string): Observable<any> {
return this.getCache().pipe(map((t) => t[url]))
}

updateTranslationFile(url: string, translations: any): Observable<any> {
return this.getCache().pipe(
first(),
mergeMap((t) => {
return from(this.translationCache$.publish({ ...t, [url]: translations })).pipe(map(() => translations))
})
)
}

private getCache(): Observable<Record<string, any>> {
if (this.translationCache$.getValue()) {
return this.translationCache$.asObservable()
}
return concat(of({}), this.translationCache$.asObservable())
}
}
Loading

0 comments on commit 52fc2c6

Please sign in to comment.