Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/i18n-test/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { I18nGuard } from '@ngx/i18n';
import { NgxI18nEmptyComponent, NgxI18nGuard, NgxI18nSetLanguageGuard } from '@ngx/i18n';

const routes: Routes = [
{
path: '',
canActivate: [NgxI18nSetLanguageGuard],
component: NgxI18nEmptyComponent,
},
{
path: ':language',
canActivate: [I18nGuard],
canActivate: [NgxI18nGuard],
loadChildren: () => import('../feature/feature.routes').then((m) => m.FeatureRoutes),
},
];
Expand Down
4 changes: 2 additions & 2 deletions apps/i18n-test/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterOutlet } from '@angular/router';
import { I18nService } from '@ngx/i18n';
import { NgxI18nService } from '@ngx/i18n';

@Component({
selector: 'app-root',
Expand All @@ -11,7 +11,7 @@ import { I18nService } from '@ngx/i18n';
imports: [RouterOutlet, TranslateModule],
})
export class AppComponent {
constructor(private readonly i18nService: I18nService) {
constructor(private readonly i18nService: NgxI18nService) {
i18nService.initI18n('nl').subscribe();
}
}
4 changes: 2 additions & 2 deletions apps/i18n-test/src/feature/feature.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { Routes } from '@angular/router';

import { FeaturePageComponent } from './pages/feature.component';
import { FeatureTranslationLoader } from './translation.loader';
import { TranslationLoaderGuard, provideWithTranslations } from '@ngx/i18n';
import { NgxI18nTranslationLoaderGuard, provideWithTranslations } from '@ngx/i18n';

export const FeatureRoutes: Routes = [
provideWithTranslations(
{
path: '',
component: FeaturePageComponent,
canActivate: [TranslationLoaderGuard],
canActivate: [NgxI18nTranslationLoaderGuard],
},
FeatureTranslationLoader
),
Expand Down
4 changes: 2 additions & 2 deletions apps/i18n-test/src/feature/pages/feature.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { I18nService } from '@ngx/i18n';
import { NgxI18nService } from '@ngx/i18n';

@Component({
selector: 'app-feature-page',
Expand All @@ -11,5 +11,5 @@ import { I18nService } from '@ngx/i18n';
export class FeaturePageComponent {
public readonly currentLanguage = this.i18nService.currentLanguage;

constructor(private readonly i18nService: I18nService) {}
constructor(private readonly i18nService: NgxI18nService) {}
}
4 changes: 2 additions & 2 deletions apps/i18n-test/src/feature/translation.loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpBackend } from '@angular/common/http';
import { MultiTranslationHttpLoader } from '@ngx/i18n';
import { NgxI18nMultiTranslationHttpLoader } from '@ngx/i18n';

export function FeatureTranslationLoader(http: HttpBackend) {
return new MultiTranslationHttpLoader(http, ['./assets/feature/']);
return new NgxI18nMultiTranslationHttpLoader(http, ['./assets/feature/']);
}
17 changes: 7 additions & 10 deletions apps/i18n-test/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,16 @@ import { BrowserModule, bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { AppRoutingModule } from './app/app-routing.module';
import { NgxI18nModule } from '@ngx/i18n';
import { importNgxI18nProviders } from '@ngx/i18n';

bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(
BrowserModule,
AppRoutingModule,
NgxI18nModule.forRoot({
defaultLanguage: 'nl',
availableLanguages: ['nl', 'fr'],
defaultAssetPaths: ['./assets/shared/'],
})
),
importProvidersFrom(BrowserModule, AppRoutingModule),
importNgxI18nProviders({
defaultLanguage: 'nl',
availableLanguages: ['nl', 'fr'],
defaultAssetPaths: ['./assets/shared/'],
}),
provideHttpClient(withInterceptorsFromDi()),
],
}).catch((err) => console.error(err));
2 changes: 0 additions & 2 deletions libs/i18n/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@
"error",
{
"type": "element",
"prefix": "i18n",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "i18n",
"style": "camelCase"
}
],
Expand Down
46 changes: 34 additions & 12 deletions libs/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ In order for a modular translation system, we provide a `TranslationLoader` to e
Using an array of source paths, the translation loader loads in only the provided translations. If one of the assets has previously been loaded by a different module, the translation will be fetched from the cache.

```ts
import { MultiTranslationHttpLoader } from '@studiohyperdrive/ngx-i18n';
import { NgxI18nMultiTranslationHttpLoader } from '@studiohyperdrive/ngx-i18n';

export function ExampleTranslationLoader(http: HttpBackend) {
return new MultiTranslationHttpLoader(http, ['./path-to-translation/']);
return new NgxI18nMultiTranslationHttpLoader(http, ['./path-to-translation/']);
}
```

Expand All @@ -46,17 +46,17 @@ If no custom `TranslationLoader` is provided, than the module will use a fall-ba

In order to provide a lazy loaded translation system, the translations only get loaded when routing to a specific route.

For this purpose we've provided a `TranslationLoaderGuard` which will automatically fetch all translations when the application routes to this route.
For this purpose we've provided a `NgxI18nTranslationLoaderGuard` which will automatically fetch all translations when the application routes to this route.

At any given time you can query the `I18nLoadingService` to see whether the translations have been loaded into the application. There are two Observables provided, being `translationsLoading$` and `translationsFailed$`;
At any given time you can query the `NgxI18nLoadingService` to see whether the translations have been loaded into the application. There are two Observables provided, being `translationsLoading$` and `translationsFailed$`;

## Implementation

### I18nGuard
### NgxI18nRootService and NgxI18nService

The `@studiohyperdrive/ngx-i18n` package also provides us with a `I18nGuard` which will automatically prefix the routes of your application with a language parameter.
As `@studiohyperdrive/ngx-i18n` works with a modular approach, each feature has its own instance of the `NgxI18nService` which contains its own set of translations. When working within a feature and requiring a translation from the translation service, always use this service.

The name of the route parameter is `language` by default, but can be overwritten in the config file. The same config file will also provide the opportunity to define a set of permitted languages and a default language for when no language is provided.
However, the package also has a root service, `NgxI18nRootService`. Because whilst each feature will handle and load its own translations when needed, the application needs one single source of truth to which language is being used. This root service serves as this source of truth, and will also save the current language to the localStorage.

### Setup

Expand All @@ -80,13 +80,13 @@ export class AppModule {}
Next up, we'll take a look at the lazy loaded feature module. In order to then lazy load our translations, we provide a routing module with the `TranslationResolverGuard` on our root path.

```ts
import { TranslationLoaderGuard } from '@studiohyperdrive/ngx-i18n';
import { NgxI18nTranslationLoaderGuard } from '@studiohyperdrive/ngx-i18n';

const routes = [
{
path: '',
component: FeatureComponent,
canActivate: [TranslationLoaderGuard],
canActivate: [NgxI18nTranslationLoaderGuard],
},
];

Expand All @@ -96,10 +96,10 @@ export const FeatureRoutingModule = RoutingModule.forChild(routes);
Afterwards, we setup a translation loader for our feature module we'll lazy load.

```ts
import { MultiTranslationHttpLoader } from '@studiohyperdrive/ngx-i18n';
import { NgxI18nMultiTranslationHttpLoader } from '@studiohyperdrive/ngx-i18n';

export function FeatureTranslationLoader(http: HttpBackend) {
return new MultiTranslationHttpLoader(http, ['./assets/i18n/shared/', './assets/i18n/feature']);
return new NgxI18nMultiTranslationHttpLoader(http, ['./assets/i18n/shared/', './assets/i18n/feature']);
}
```

Expand All @@ -124,9 +124,31 @@ export const TestRoutes: Routes = [
{
path: '',
component: TestComponent
canActivate: [TranslationLoaderGuard],
},
TestTranslationLoader
)
];
```

### NgxI18nSetLanguageGuard and NgxI18nGuard

In many applications we want the language parameter to be part of the routing. To do so, `@studiohyperdrive/ngx-i18n` provides two guards, the `NgxI18nSetLanguageGuard` and the `NgxI18nGuard`.

On one hand, the `NgxI18nSetLanguageGuard` can be set on the base route of the application to automatically set the current language of the application to the route. If there was previously a current language selected, the language will be fetched from the localStorage and will be used. If not, the provided default language will be used.

The `NgxI18nGuard` will both ensure that, once the language is set, the correct translations are loaded and will prevent users from altering the url and setting a language that is not available. When linked to a language that is not provided as an available language, this guard will default the language back to the default language.

In some setups, the base route of the application does not have a component and currently redirects to a fixed language. In order to circumvent this issue, `@studiohyperdrive/ngx-i18n` also provides a dummy component `NgxI18nEmptyComponent` that can be used instead.

```ts
{
path: '',
canActivate: [NgxI18nSetLanguageGuard],
component: NgxI18nEmptyComponent,
},
{
path: ':language',
canActivate: [NgxI18nGuard],
loadChildren: () => import('../feature/feature.routes').then((m) => m.FeatureRoutes),
},
```
2 changes: 1 addition & 1 deletion libs/i18n/src/lib/abstracts/i18n-service.abstract.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export abstract class AbstractI18nService<Language = string> {
export abstract class NgxI18nAbstractService<Language = string> {
public abstract get currentLanguage(): Language;
}
11 changes: 11 additions & 0 deletions libs/i18n/src/lib/components/empty-component/empty.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';

/**
* This is an empty dummy component that can be used in combination with the NgxI18nSetLanguageGuard when needed
*/
@Component({
selector: 'ngx-i18n-empty',
standalone: true,
template: '',
})
export class NgxI18nEmptyComponent {}
1 change: 1 addition & 0 deletions libs/i18n/src/lib/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './empty-component/empty.component';
32 changes: 15 additions & 17 deletions libs/i18n/src/lib/guards/i18n/i18n.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, Router, convertToParamMap } from '@angular/router';

import { I18nService, RootI18nService } from '../../services';
import { NgxI18nRootService } from '../../services';

import { I18N_CONFIG } from '../../i18n.const';
import { I18nGuard } from './i18n.guard';
import { NgxI18nConfigurationToken } from '../../tokens/i18n.token';
import { NgxI18nGuard } from './i18n.guard';

describe('I18nGuard', () => {
describe('NgxI18nGuard', () => {
const router: any = {
navigate: jasmine.createSpy(),
};
const i18nService: any = {
currentLanguage: 'nl',
getCurrentLanguageForRoute: jasmine.createSpy().and.returnValue('nl'),
availableLanguages: ['nl', 'en'],
setLanguage: jasmine.createSpy(),
setCurrentLanguage: jasmine.createSpy(),
};

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: I18N_CONFIG,
provide: NgxI18nConfigurationToken,
useValue: {
defaultLanguage: 'nl',
availableLanguages: ['nl', 'en'],
},
},
{
provide: I18nService,
provide: NgxI18nRootService,
useValue: i18nService,
},
{
provide: Router,
useValue: router,
},
RootI18nService,
],
});
});
Expand All @@ -46,7 +44,7 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({ language: 'nl' }),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(true);
expect(NgxI18nGuard(route, undefined)).toBe(true);

route = {
parent: {
Expand All @@ -55,7 +53,7 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({}),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(true);
expect(NgxI18nGuard(route, undefined)).toBe(true);
});
});

Expand All @@ -65,8 +63,8 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({ language: 'en' }),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(true);
expect(i18nService.setLanguage).toHaveBeenCalledWith('en');
expect(NgxI18nGuard(route, undefined)).toBe(true);
expect(i18nService.setCurrentLanguage).toHaveBeenCalledWith('en');
expect(router.navigate).toHaveBeenCalledWith(['/', 'en']);

route = {
Expand All @@ -76,8 +74,8 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({}),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(true);
expect(i18nService.setLanguage).toHaveBeenCalledWith('en');
expect(NgxI18nGuard(route, undefined)).toBe(true);
expect(i18nService.setCurrentLanguage).toHaveBeenCalledWith('en');
expect(router.navigate).toHaveBeenCalledWith(['/', 'en']);
});
});
Expand All @@ -88,7 +86,7 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({ language: 'de' }),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(false);
expect(NgxI18nGuard(route, undefined)).toBe(false);
expect(router.navigate).toHaveBeenCalledWith(['/', 'nl']);

route = {
Expand All @@ -98,7 +96,7 @@ describe('I18nGuard', () => {
paramMap: convertToParamMap({}),
} as ActivatedRouteSnapshot;

expect(I18nGuard(route, undefined)).toBe(false);
expect(NgxI18nGuard(route, undefined)).toBe(false);
expect(router.navigate).toHaveBeenCalledWith(['/', 'nl']);
});
});
Expand Down
16 changes: 8 additions & 8 deletions libs/i18n/src/lib/guards/i18n/i18n.guard.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router';

import { I18nService } from '../../services';
import { I18N_CONFIG } from '../../i18n.const';
import { I18nConfig } from '../../i18n.types';
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 I18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean => {
export const NgxI18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean => {
// Iben: Fetch all injectables
const router: Router = inject(Router);
const i18nService = inject(I18nService);
const config: I18nConfig = inject(I18N_CONFIG);
const i18nService = inject(NgxI18nRootService);
const config: NgxI18nConfiguration = inject(NgxI18nConfigurationToken);

// Iben: Get the two language params
const currentLanguage = i18nService.currentLanguage || config.defaultLanguage;
Expand All @@ -28,7 +28,7 @@ export const I18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean
// Iben: If the router language differs, we check if it is available
if (config?.availableLanguages.includes(routeLanguage)) {
// Iben: Update the language
i18nService.setLanguage(routeLanguage);
i18nService.setCurrentLanguage(routeLanguage);

//Iben: Re-route to the new language
router.navigate(['/', routeLanguage]);
Expand All @@ -48,7 +48,7 @@ export const I18nGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean
* @param route - The provided route
* @param config - The provided config
*/
const getLanguage = (route: ActivatedRouteSnapshot, config: I18nConfig): string => {
const getLanguage = (route: ActivatedRouteSnapshot, config: NgxI18nConfiguration): string => {
const language = route?.paramMap.get(config.languageRouteParam || 'language');
const parent = route?.parent;

Expand Down
1 change: 1 addition & 0 deletions libs/i18n/src/lib/guards/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './i18n/i18n.guard';
export * from './translation-loader/translation-loader.guard';
export * from './set-language/set-language.guard';
Loading