From edd4b056f246266e817d0b89d78506b65d083fc7 Mon Sep 17 00:00:00 2001 From: Iben Van de Veire Date: Thu, 3 Oct 2024 15:09:18 +0200 Subject: [PATCH] feat(ngx-i18n): Add the ability to change the configuration when needed --- apps/i18n-test/src/app/app.component.ts | 10 ++- apps/i18n-test/src/main.ts | 1 - .../src/lib/guards/i18n/i18n.guard.spec.ts | 48 +++++++++----- libs/i18n/src/lib/guards/i18n/i18n.guard.ts | 63 +++++++++++-------- libs/i18n/src/lib/i18n.types.ts | 2 +- .../services/root-i18n/root-i18n.service.ts | 57 ++++++++++++++--- 6 files changed, 127 insertions(+), 54 deletions(-) diff --git a/apps/i18n-test/src/app/app.component.ts b/apps/i18n-test/src/app/app.component.ts index 983f7221..b959d276 100644 --- a/apps/i18n-test/src/app/app.component.ts +++ b/apps/i18n-test/src/app/app.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { RouterOutlet } from '@angular/router'; -import { NgxI18nService } from '@ngx/i18n'; +import { NgxI18nRootService, NgxI18nService } from '@ngx/i18n'; @Component({ selector: 'app-root', @@ -11,7 +11,13 @@ import { NgxI18nService } from '@ngx/i18n'; imports: [RouterOutlet, TranslateModule], }) export class AppComponent { - constructor(private readonly i18nService: NgxI18nService) { + constructor( + private readonly i18nService: NgxI18nService, + private readonly rootService: NgxI18nRootService + ) { + setTimeout(() => { + rootService.setAvailableLanguages(['nl', 'fr']); + }, 3000); i18nService.initI18n('nl').subscribe(); } } diff --git a/apps/i18n-test/src/main.ts b/apps/i18n-test/src/main.ts index 9037f622..3db955ff 100644 --- a/apps/i18n-test/src/main.ts +++ b/apps/i18n-test/src/main.ts @@ -10,7 +10,6 @@ bootstrapApplication(AppComponent, { importProvidersFrom(BrowserModule, AppRoutingModule), importNgxI18nProviders({ defaultLanguage: 'nl', - availableLanguages: ['nl', 'fr'], defaultAssetPaths: ['./assets/shared/'], }), provideHttpClient(withInterceptorsFromDi()), diff --git a/libs/i18n/src/lib/guards/i18n/i18n.guard.spec.ts b/libs/i18n/src/lib/guards/i18n/i18n.guard.spec.ts index 03fbd08b..7c688350 100644 --- a/libs/i18n/src/lib/guards/i18n/i18n.guard.spec.ts +++ b/libs/i18n/src/lib/guards/i18n/i18n.guard.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Router, convertToParamMap } from '@angular/router'; +import { subscribeSpyTo } from '@hirez_io/observer-spy'; +import { Observable, of } from 'rxjs'; import { NgxI18nRootService } from '../../services'; -import { NgxI18nConfigurationToken } from '../../tokens/i18n.token'; import { NgxI18nGuard } from './i18n.guard'; describe('NgxI18nGuard', () => { @@ -15,18 +16,13 @@ describe('NgxI18nGuard', () => { availableLanguages: ['nl', 'en'], setCurrentLanguage: jasmine.createSpy(), initializeLanguage: jasmine.createSpy(), + availableLanguages$: of(['nl', 'en']), + defaultLanguage: 'nl', }; beforeEach(() => { TestBed.configureTestingModule({ providers: [ - { - provide: NgxI18nConfigurationToken, - useValue: { - defaultLanguage: 'nl', - availableLanguages: ['nl', 'en'], - }, - }, { provide: NgxI18nRootService, useValue: i18nService, @@ -45,7 +41,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({ language: 'nl' }), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(true); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(true); route = { parent: { @@ -54,7 +54,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({}), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(true); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(true); }); }); @@ -64,7 +68,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({ language: 'en' }), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(true); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(true); expect(i18nService.setCurrentLanguage).toHaveBeenCalledWith('en'); expect(router.navigate).toHaveBeenCalledWith(['/', 'en']); @@ -75,7 +83,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({}), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(true); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(true); expect(i18nService.setCurrentLanguage).toHaveBeenCalledWith('en'); expect(router.navigate).toHaveBeenCalledWith(['/', 'en']); }); @@ -87,7 +99,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({ language: 'de' }), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(false); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(false); expect(router.navigate).toHaveBeenCalledWith(['/', 'nl']); route = { @@ -97,7 +113,11 @@ describe('NgxI18nGuard', () => { paramMap: convertToParamMap({}), } as ActivatedRouteSnapshot; - expect(NgxI18nGuard(route, undefined)).toBe(false); + expect( + subscribeSpyTo( + NgxI18nGuard(route, undefined) as Observable + ).getFirstValue() + ).toBe(false); expect(router.navigate).toHaveBeenCalledWith(['/', 'nl']); }); }); diff --git a/libs/i18n/src/lib/guards/i18n/i18n.guard.ts b/libs/i18n/src/lib/guards/i18n/i18n.guard.ts index 280b0b81..25e1c0e9 100644 --- a/libs/i18n/src/lib/guards/i18n/i18n.guard.ts +++ b/libs/i18n/src/lib/guards/i18n/i18n.guard.ts @@ -1,48 +1,55 @@ import { inject } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router'; +import { Observable, take, map } from 'rxjs'; import { NgxI18nRootService } from '../../services'; -import { NgxI18nConfiguration } from '../../i18n.types'; -import { NgxI18nConfigurationToken } from '../../tokens'; /** * Set the language in the url of the route * * @param route - The provided route */ -export const NgxI18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean => { +export const NgxI18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): Observable => { // Iben: Fetch all injectables const router: Router = inject(Router); const i18nService = inject(NgxI18nRootService); - const config: NgxI18nConfiguration = inject(NgxI18nConfigurationToken); // Iben: Initialize the current language of the application i18nService.initializeLanguage(); - // Iben: Get the two language params - const currentLanguage = i18nService.currentLanguage; - const routeLanguage = getLanguage(route, config); + return i18nService.availableLanguages$.pipe( + take(1), + map((availableLanguages) => { + // Iben: Get the two language params + const currentLanguage = i18nService.currentLanguage; + const routeLanguage = getLanguage( + route, + availableLanguages, + i18nService.languageRouteParam + ); - // Iben: If both languages are the same, we can continue - if (currentLanguage === routeLanguage) { - return true; - } + // Iben: If both languages are the same, we can continue + if (currentLanguage === routeLanguage) { + return true; + } - // Iben: If the router language differs, we check if it is available - if (config?.availableLanguages.includes(routeLanguage)) { - // Iben: Update the language - i18nService.setCurrentLanguage(routeLanguage); + // Iben: If the router language differs, we check if it is available + if (availableLanguages.includes(routeLanguage)) { + // Iben: Update the language + i18nService.setCurrentLanguage(routeLanguage); - //Iben: Re-route to the new language - router.navigate(['/', routeLanguage]); + //Iben: Re-route to the new language + router.navigate(['/', routeLanguage]); - return true; - } + return true; + } - //Iben: The current language is set to "default" when no previous language exists. - router.navigate(['/', currentLanguage]); + //Iben: The current language is set to "default" when no previous language exists. + router.navigate(['/', currentLanguage]); - return false; + return false; + }) + ); }; /** @@ -51,17 +58,21 @@ export const NgxI18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): bool * @param route - The provided route * @param config - The provided config */ -const getLanguage = (route: ActivatedRouteSnapshot, config: NgxI18nConfiguration): string => { - const language = route?.paramMap.get(config.languageRouteParam || 'language'); +const getLanguage = ( + route: ActivatedRouteSnapshot, + availableLanguages: string[], + languageRouteParam: string +): string => { + const language = route?.paramMap.get(languageRouteParam || 'language'); const parent = route?.parent; if (!parent && !language) { return undefined; } - if (config.availableLanguages.includes(language)) { + if (availableLanguages.includes(language)) { return language; } - return getLanguage(parent, config); + return getLanguage(parent, availableLanguages, languageRouteParam); }; diff --git a/libs/i18n/src/lib/i18n.types.ts b/libs/i18n/src/lib/i18n.types.ts index dcd9cb7d..3e9379d6 100644 --- a/libs/i18n/src/lib/i18n.types.ts +++ b/libs/i18n/src/lib/i18n.types.ts @@ -1,6 +1,6 @@ export interface NgxI18nConfiguration { defaultLanguage: string; - availableLanguages: string[]; + availableLanguages?: string[]; defaultAssetPaths: string[]; languageRouteParam?: string; } diff --git a/libs/i18n/src/lib/services/root-i18n/root-i18n.service.ts b/libs/i18n/src/lib/services/root-i18n/root-i18n.service.ts index 63885d76..c3e85993 100644 --- a/libs/i18n/src/lib/services/root-i18n/root-i18n.service.ts +++ b/libs/i18n/src/lib/services/root-i18n/root-i18n.service.ts @@ -1,6 +1,6 @@ import { isPlatformBrowser } from '@angular/common'; import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, filter, Observable } from 'rxjs'; import { NgxI18nConfiguration } from '../../i18n.types'; import { NgxI18nConfigurationToken } from '../../tokens'; @@ -15,11 +15,40 @@ export class NgxI18nRootService { undefined ); + /** + * A subject to hold the available languages + */ + private readonly availableLanguagesSubject: BehaviorSubject = new BehaviorSubject([]); + + /** + * The available languages + * + * Only emits once the list contains at least one language + */ + public readonly availableLanguages$: Observable = this.availableLanguagesSubject + .asObservable() + .pipe(filter((languages) => languages?.length > 0)); + + /** + * The default language of the application + */ + public defaultLanguage: string; + + /** + * The route param we use to set the language, by default this is `language` + */ + public languageRouteParam: string; + constructor( @Inject(PLATFORM_ID) private readonly platformId: string, @Inject(NgxI18nConfigurationToken) private readonly configuration: NgxI18nConfiguration - ) {} + ) { + // Iben: Set the initial values so that we can refer to the services as the source of truth + this.defaultLanguage = configuration.defaultLanguage; + this.languageRouteParam = configuration.languageRouteParam || 'language'; + this.availableLanguagesSubject.next(this.configuration.availableLanguages || []); + } /** * The current language of the application, as an Observable @@ -64,17 +93,25 @@ export class NgxI18nRootService { } // Iben: If the current language does not exist, we check if it exists in the local storage, if not, we use the default config - let language = this.configuration.defaultLanguage; + let language = this.defaultLanguage; if (isPlatformBrowser(this.platformId)) { - language = - localStorage.getItem('ngx-i18n-language') || this.configuration.defaultLanguage; + language = localStorage.getItem('ngx-i18n-language') || this.defaultLanguage; } // Iben: We set the new language this.setCurrentLanguage(language); } + /** + * Set the list of available languages + * + * @param languages - The list of available languages + */ + public setAvailableLanguages(languages: string[]): void { + this.availableLanguagesSubject.next(languages); + } + /** * Checks if the newly proposed language can be set, if not we return either the current language or the default language * @@ -85,12 +122,12 @@ export class NgxI18nRootService { let newLanguage = language; // Iben: Check if the new language is part of the available languages - if (!this.configuration.availableLanguages.includes(language)) { + if (!this.availableLanguagesSubject.getValue().includes(language)) { // Iben: If a language is set that's not part of the available languages, we return a warn console.warn( - `NgxI18n: A language, ${language}, was attempted to be set that was not part of the available languages (${this.configuration.availableLanguages.join( - ', ' - )})` + `NgxI18n: A language, ${language}, was attempted to be set that was not part of the available languages (${this.availableLanguagesSubject + .getValue() + .join(', ')})` ); // Iben: If there is already a language set, we early exit and keep the remaining language @@ -99,7 +136,7 @@ export class NgxI18nRootService { } // Iben: If no language exists, we use the default language - newLanguage = this.configuration.defaultLanguage; + newLanguage = this.defaultLanguage; } return newLanguage;