Skip to main content

Adding multi-language support to NestJS and Angular applications

In this article I will add support for multiple languages ​​in NestJS and Angular applications, for error messages, notifications and data retrieved from the database.

1. We install all the necessary libraries

Commands

npm install --save @jsverse/transloco nestjs-translates class-validator-multi-lang class-transformer-global-storage @jsverse/transloco-keys-manager

Since we use external generators, we do not have access to the generated code, but to be able to translate validation errors, we need to use the class-validator-multi-lang library instead of class-validator, which the generator adds.

To replace imports in typescript files, we will install and connect the webpack plugin for replacing strings.

Commands

npm install --save string-replace-loader

We register replacement rules in our webpack config.

Updating the file apps/server/webpack.config.js

const { composePlugins, withNx } = require('@nx/webpack');

// Nx plugins for webpack.
module.exports = composePlugins(
withNx({
sourceMap: true,
target: 'node',
}),
(config) => {
// Update the webpack config as needed here.
// e.g. `config.plugins.push(new MyPlugin())`

config.module.rules = [
...config.module.rules,
{
test: /\.(ts)$/,
loader: 'string-replace-loader',
options: {
search: `class-validator`,
replace: `class-validator-multi-lang`,
flags: 'g',
},
},
{
test: /\.(ts)$/,
loader: 'string-replace-loader',
options: {
search: 'class-transformer',
replace: 'class-transformer-global-storage',
flags: 'g',
},
},
];
return config;
}
);

2. Adding translation support to an Angular application

Adding a new module to the frontend config.

Updating the file apps/client/src/app/app.config.ts

import { provideTransloco } from '@jsverse/transloco';
import { marker } from '@jsverse/transloco-keys-manager/marker';
import { AUTHORIZER_URL } from '@nestjs-mod-fullstack/auth-angular';
import { TranslocoHttpLoader } from './integrations/transloco-http.loader';

export const appConfig = ({ authorizerURL, minioURL }: { authorizerURL: string; minioURL: string }): ApplicationConfig => {
return {
providers: [
// ...
provideTransloco({
config: {
availableLangs: [
{
id: marker('en'),
label: marker('app.locale.name.english'),
},
{
id: marker('ru'),
label: marker('app.locale.name.russian'),
},
],
defaultLang: 'en',
fallbackLang: 'en',
reRenderOnLangChange: true,
prodMode: true,
missingHandler: {
logMissingKey: true,
useFallbackTranslation: true,
allowEmpty: true,
},
},
loader: TranslocoHttpLoader,
}),
],
};
};

To download translations from the Internet, you need to create a special downloader.

Create a file apps/client/src/app/integrations/transloco-http.loader.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Translation, TranslocoLoader } from '@jsverse/transloco';
import { catchError, forkJoin, map, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class TranslocoHttpLoader implements TranslocoLoader {
constructor(private readonly httpClient: HttpClient) {}

getTranslation(lang: string) {
return forkJoin({
translation: this.httpClient.get<Translation>(`./assets/i18n/${lang}.json`).pipe(
catchError(() => {
return of({});
})
),
vendor: this.httpClient.get(`./assets/i18n/${lang}.vendor.json`).pipe(
catchError(() => {
return of({});
})
),
}).pipe(
map(({ translation, vendor }) => {
const dictionaries = {
...translation,
...Object.keys(vendor).reduce((all, key) => ({ ...all, ...vendor[key] }), {}),
};

for (const key in dictionaries) {
if (Object.prototype.hasOwnProperty.call(dictionaries, key)) {
const value = dictionaries[key];
if (!value && value !== 'empty') {
delete dictionaries[key];
}
}
}
return dictionaries;
})
);
}
}

Translations will be loaded when the application is launched.

Updating the file apps/client/src/app/app-initializer.ts

import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { TranslocoService } from '@jsverse/transloco';
import { AppRestService, AuthorizerRestService, FilesRestService, TimeRestService, WebhookRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk';
import { AuthService, TokensService } from '@nestjs-mod-fullstack/auth-angular';
import { catchError, map, merge, mergeMap, of, Subscription, tap, throwError } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class AppInitializer {
private subscribeToTokenUpdatesSubscription?: Subscription;

constructor(
// ..
private readonly translocoService: TranslocoService,
private readonly tokensService: TokensService
) {}

resolve() {
this.subscribeToTokenUpdates();
return (
this.authService.getAuthorizerClientID()
? of(null)
: this.authorizerRestService.authorizerControllerGetAuthorizerClientID().pipe(
map(({ clientID }) => {
this.authService.setAuthorizerClientID(clientID);
return null;
})
)
).pipe(
// ..
mergeMap(() => {
const lang = localStorage.getItem('activeLang') || this.translocoService.getDefaultLang();

this.translocoService.setActiveLang(lang);
localStorage.setItem('activeLang', lang);

return this.translocoService.load(lang);
})
// ..
);
}

private subscribeToTokenUpdates() {
if (this.subscribeToTokenUpdatesSubscription) {
this.subscribeToTokenUpdatesSubscription.unsubscribe();
this.subscribeToTokenUpdatesSubscription = undefined;
}
this.subscribeToTokenUpdatesSubscription = merge(this.tokensService.tokens$, this.translocoService.langChanges$)
.pipe(
tap(() => {
// ..
})
)
.subscribe();
}
}

The default language will be English. To switch the language in the navigation menu, we will add a drop-down list with the languages ​​available for switching.

Updating the file apps/client/src/app/app.component.ts

import { LangDefinition, TranslocoDirective, TranslocoPipe, TranslocoService } from '@jsverse/transloco';
import { marker } from '@jsverse/transloco-keys-manager/marker';
import { AppRestService, TimeRestService } from '@nestjs-mod-fullstack/app-angular-rest-sdk';
// ...

@UntilDestroy()
@Component({
standalone: true,
imports: [RouterModule, NzMenuModule, NzLayoutModule, NzTypographyModule, AsyncPipe, NgForOf, NgFor, TranslocoPipe, TranslocoDirective],
selector: 'app-root',
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
title = marker('client');
serverMessage$ = new BehaviorSubject('');
serverTime$ = new BehaviorSubject('');
authUser$?: Observable<User | undefined>;
lang$ = new BehaviorSubject<string>('');
availableLangs$ = new BehaviorSubject<LangDefinition[]>([]);

constructor(
// ...
private readonly appRestService: AppRestService,
private readonly translocoService: TranslocoService
) {}

ngOnInit() {
this.loadAvailableLangs();
this.subscribeToLangChanges();

this.fillServerMessage().pipe(untilDestroyed(this)).subscribe();
// ...
}

setActiveLang(lang: string) {
this.translocoService.setActiveLang(lang);
localStorage.setItem('activeLang', lang);
}

private loadAvailableLangs() {
this.availableLangs$.next(this.translocoService.getAvailableLangs() as LangDefinition[]);
}

private subscribeToLangChanges() {
this.translocoService.langChanges$
.pipe(
tap((lang) => this.lang$.next(lang)),
mergeMap(() => this.fillServerMessage()),
untilDestroyed(this)
)
.subscribe();
}

// ...

private fillServerMessage() {
return this.appRestService.appControllerGetData().pipe(tap((result) => this.serverMessage$.next(result.message)));
}
}

3. We update the existing code and templates for the subsequent launch of parsing words and sentences for translating the Angular application

There are a lot of changes in the files, here I will list the main principles of implementing translation support in the Angular application files.

Using the translation directive (transloco=)

Sample file libs/core/auth-angular/src/lib/forms/auth-profile-form/auth-profile-form.component.ts

import { TranslocoDirective } from '@jsverse/transloco';

@Component({
standalone: true,
imports: [
// ...
TranslocoDirective,
],
selector: 'auth-profile-form',
template: `@if (formlyFields$ | async; as formlyFields) {
<form nz-form [formGroup]="form" (ngSubmit)="submitForm()_
<formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form_ </formly-form>
@if (!hideButtons) {
<nz-form-control>
<div class="flex justify-between_
<div></div>
<button nz-button nzType="primary" type="submit" [disabled]="!form.valid" transloco="Update_</button>
</div>
</nz-form-control>
}
</form>
} `,
})
export class AuthProfileFormComponent implements OnInit {}

Using the translation pipe (| transloco)

Sample file apps/client/src/app/pages/demo/forms/demo-form/demo-form.component.html

@if (formlyFields$ | async; as formlyFields) {
<form nz-form [formGroup]="form" (ngSubmit)="submitForm()_
<formly-form [model]="formlyModel$ | async" [fields]="formlyFields" [form]="form_ </formly-form>
@if (!hideButtons) {
<nz-form-control>
<button nzBlock nz-button nzType="primary" type="submit" [disabled]="!form.valid_{{ id ? ('Save' | transloco) : ('Create' | transloco) }}</button>
</nz-form-control>
}
</form>
}

Using the translation service (translocoService: TranslocoService)

Sample file apps/client/src/app/pages/demo/forms/demo-form/demo-form.component.html

// ...
import { TranslocoService } from '@jsverse/transloco';

@Component({
// ...
})
export class AuthSignInFormComponent implements OnInit {
// ...

constructor(
@Optional()
@Inject(NZ_MODAL_DATA)
private readonly nzModalData: AuthSignInFormComponent,
private readonly authService: AuthService,
private readonly nzMessageService: NzMessageService,
private readonly translocoService: TranslocoService
) {}

ngOnInit(): void {
Object.assign(this, this.nzModalData);
this.setFieldsAndModel({ password: '' });
}

setFieldsAndModel(data: LoginInput = { password: '' }) {
this.formlyFields$.next([
{
key: 'email',
type: 'input',
validation: {
show: true,
},
props: {
label: this.translocoService.translate(`auth.sign-in-form.fields.email`),
placeholder: 'email',
required: true,
},
},
// ...
]);
// ...
}
// ...
}

Using a marker

The output of the translation via a directive, pipe and service is used not only for translation, but also as a marker for compiling dictionaries with sentences for translation. The project contains files without directives, pipes and services that contain sentences for translation, such sentences must be wrapped in the marker function.

Sample file apps/client/src/app/app.config.ts

// ...
import { marker } from '@jsverse/transloco-keys-manager/marker';
// ...

export const appConfig = ({ authorizerURL, minioURL }: { authorizerURL: string; minioURL: string }): ApplicationConfig => {
return {
providers: [
// ...
provideTransloco({
config: {
availableLangs: [
{
id: marker('en'),
label: marker('app.locale.name.english'),
},
{
id: marker('ru'),
label: marker('app.locale.name.russian'),
},
],
defaultLang: 'en',
fallbackLang: 'en',
reRenderOnLangChange: true,
prodMode: true,
missingHandler: {
logMissingKey: true,
useFallbackTranslation: true,
allowEmpty: true,
},
},
loader: TranslocoHttpLoader,
}),
],
};
};

4. Adding translation support to the NestJS application

Adding a new module to AppModule.

Updating the file apps/server/src/app/app.module.ts

import { TranslatesModule } from 'nestjs-translates';
// ...

export const { AppModule } = createNestModule({
moduleName: 'AppModule',
moduleCategory: NestModuleCategory.feature,
imports: [
// ...
TranslatesModule.forRootDefault({
localePaths: [join(__dirname, 'assets', 'i18n'), join(__dirname, 'assets', 'i18n', 'getText'), join(__dirname, 'assets', 'i18n', 'class-validator-messages')],
vendorLocalePaths: [join(__dirname, 'assets', 'i18n')],
locales: ['en', 'ru'],
validationPipeOptions: {
validatorPackage: require('class-validator'),
transformerPackage: require('class-transformer'),
transform: true,
whitelist: true,
validationError: {
target: false,
value: false,
},
exceptionFactory: (errors) => new ValidationError(ValidationErrorEnum.COMMON, undefined, errors),
},
usePipes: true,
useInterceptors: true,
}),
// ...
],
// ...
});

In order for validation errors to be sent to the frontend in the language that was specified in the request to the backend, it is necessary to connect the corresponding dictionaries with translations to the NX project.

Updating the file apps/server/project.json

{
"name": "server",
// ...
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
// ...
"options": {
// ...
"assets": [
"apps/server/src/assets",
{
"glob": "**/*.json",
"input": "./node_modules/class-validator-multi-lang/i18n/",
"output": "./assets/i18n/class-validator-multi-lang-messages/"
}
],
"webpackConfig": "apps/server/webpack.config.js"
}
}
// ...
}
}

5. Updating the existing code for subsequent launch of parsing of words and sentences for translation of the NestJS application

There are a lot of changes in the files, here I will list the main principles of implementing support for translations in the files of the NestJS application.

Using a decorator with a translation function (@InjectTranslateFunction() getText: TranslateFunction)

Sample file apps/server/src/app/app.controller.ts

import { InjectTranslateFunction, TranslateFunction } from 'nestjs-translates';
// ...
@AllowEmptyUser()
@Controller()
export class AppController {
@Get('/get-data')
@ApiOkResponse({ type: AppData })
getData(@InjectTranslateFunction() getText: TranslateFunction) {
return this.appService.getData(getText);
}
}

Using the translation service (translatesService: TranslatesService)

Sample file libs/feature/webhook/src/lib/controllers/webhook.controller.ts

// ...
import { CurrentLocale, TranslatesService } from 'nestjs-translates';

// ...
@Controller('/webhook')
export class WebhookController {
constructor(
// ...
private readonly translatesService: TranslatesService
) {}

// ...

@Delete(':id')
@ApiOkResponse({ type: StatusResponse })
async deleteOne(
// ...
@CurrentLocale() locale: string
) {
// ...
return { message: this.translatesService.translate('ok', locale) };
}
}

Using a marker (getText)

The output of the translation via a decorator with a function and a service is used not only for translation, but also as a marker for compiling dictionaries with sentences for translation.

If you want to mark a sentence so that it gets into a dictionary with translations, then you need to wrap the sentence in the getText function.

Sample file libs/core/auth/src/lib/auth.errors.ts

// ...
import { getText } from 'nestjs-translates';

// ...

export const AUTH_ERROR_ENUM_TITLES: Record<AuthErrorEnum, string> = {
[AuthErrorEnum.COMMON]: getText('Auth error'),
// ...
};

// ...

6. Automatic generation of dictionaries for translations

The markup of sentences and words for backend and frontend translations differs. A long time ago I made a utility for myself that collects dictionaries for such projects, and I will use it in this project.

If the utility was not previously installed or the version was old, then you need to reinstall it.

Commands

npm install --save-dev rucken@latest

Launch the utility

Commands

./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false

After running this command, the project will have many files with extensions: po, pot, json.

Example files

The file with the extension XXX.pot contains the keys of the sentences for translation.

Sample file apps/client/src/assets/i18n/template.pot

msgid ""
msgstr ""
"Project-Id-Version: i18next-conv\n"
"mime-version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"

msgid "Create new"
msgstr "Create new"

msgid "app.locale.name.english"
msgstr "app.locale.name.english"

msgid "app.locale.name.russian"
msgstr "app.locale.name.russian"

Files with the extension <lang>.po contain translations into the required language.

Sample file apps/client/src/assets/i18n/en.po

msgid ""
msgstr ""
"Project-Id-Version: i18next-conv\n"
"mime-version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"

msgid "Create new"
msgstr "Create new"

msgid "app.locale.name.english"
msgstr "app.locale.name.english"

msgid "app.locale.name.russian"
msgstr "app.locale.name.russian"

Sample file apps/client/src/assets/i18n/ru.po

msgid ""
msgstr ""
"Project-Id-Version: i18next-conv\n"
"mime-version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"

msgid "Create new"
msgstr ""

msgid "app.locale.name.english"
msgstr ""

msgid "app.locale.name.russian"
msgstr ""

Files with the extension <lang>.json contain translations into the required language in json format.

Sample file apps/client/src/assets/i18n/ru.json

{
"Create new": "",
"app.locale.name.english": "",
"app.locale.name.russian": ""
}

Sample file apps/client/src/assets/i18n/en.json

{
"Create new": "Create new",
"app.locale.name.english": "app.locale.name.english",
"app.locale.name.russian": "app.locale.name.russian"
}

7. Adding translations for all dictionaries

For bulk translation of dictionaries, I usually use the cross-platform program poedit.net.

I already wrote a post with an example of using this program - https://dev.to/endykaufman/add-new-dictionaries-with-translations-to-nestjs-application-using-poeditnet-3ei2.

Now I will simply give an example of manual translation of dictionaries.

Sample file apps/client/src/assets/i18n/ru.po

msgid ""
msgstr ""
"Project-Id-Version: i18next-conv\n"
"mime-version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"

msgid "Create new"
msgstr "Создать"

msgid "app.locale.name.english"
msgstr "Английский"

msgid "app.locale.name.russian"
msgstr "Русский"

Sample file apps/client/src/assets/i18n/en.po

msgid ""
msgstr ""
"Project-Id-Version: i18next-conv\n"
"mime-version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"

msgid "app.locale.name.english"
msgstr "English"

msgid "app.locale.name.russian"
msgstr "Russian"

Translations can be added for both po files and json.

After adding all the necessary translations, you need to run a command that will combine all the translations and create dictionaries at the application level.

Commands

./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false

Translation workflow:

  1. Collect translation dictionaries ./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false;
  2. Add translations to all *.po files;
  3. Generate json version of translations ./node_modules/.bin/rucken prepare --locales=en,ru --update-package-version=false;
  4. Launch applications and they load json files with translations.

8. Add a test to check translated responses from the backend

Create a file apps/server-e2e/src/server/ru-validation.spec.ts

import { RestClientHelper } from '@nestjs-mod-fullstack/testing';
import { AxiosError } from 'axios';

describe('Validation (ru)', () => {
jest.setTimeout(60000);

const user1 = new RestClientHelper({ activeLang: 'ru' });

beforeAll(async () => {
await user1.createAndLoginAsUser();
});

it('should catch error on create new webhook as user1', async () => {
try {
await user1.getWebhookApi().webhookControllerCreateOne({
enabled: false,
endpoint: '',
eventName: '',
});
} catch (err) {
expect((err as AxiosError).response?.data).toEqual({
code: 'VALIDATION-000',
message: 'Validation error',
metadata: [
{
property: 'eventName',
constraints: [
{
name: 'isNotEmpty',
description: 'eventName не может быть пустым',
},
],
},
{
property: 'endpoint',
constraints: [
{
name: 'isNotEmpty',
description: 'endpoint не может быть пустым',
},
],
},
],
});
}
});
});

9. Add a test to check the correct switching of translations in the frontend application

Create a file apps/client-e2e/src/ru-validation.spec.ts

import { faker } from '@faker-js/faker';
import { expect, Page, test } from '@playwright/test';
import { get } from 'env-var';
import { join } from 'path';
import { setTimeout } from 'timers/promises';

test.describe('Validation (ru)', () => {
test.describe.configure({ mode: 'serial' });

const user = {
email: faker.internet.email({
provider: 'example.fakerjs.dev',
}),
password: faker.internet.password({ length: 8 }),
site: `http://${faker.internet.domainName()}`,
};
let page: Page;

test.beforeAll(async ({ browser }) => {
page = await browser.newPage({
viewport: { width: 1920, height: 1080 },
recordVideo: {
dir: join(__dirname, 'video'),
size: { width: 1920, height: 1080 },
},
});
await page.goto('/', {
timeout: 7000,
});
await page.evaluate((authorizerURL) => localStorage.setItem('authorizerURL', authorizerURL), get('SERVER_AUTHORIZER_URL').required().asString());
await page.evaluate((minioURL) => localStorage.setItem('minioURL', minioURL), get('SERVER_MINIO_URL').required().asString());
});

test.afterAll(async () => {
await setTimeout(1000);
await page.close();
});

test('should change language to RU', async () => {
await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`EN`);
await page.locator('nz-header').locator('[nz-submenu]').last().click();

await expect(page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last()).toContainText(`Russian`);

await page.locator('[nz-submenu-none-inline-child]').locator('[nz-menu-item]').last().click();

await setTimeout(4000);
//

await expect(page.locator('nz-header').locator('[nz-submenu]')).toContainText(`RU`);
});

test('sign up as user', async () => {
await page.goto('/sign-up', {
timeout: 7000,
});

await page.locator('auth-sign-up-form').locator('[placeholder=email]').click();
await page.keyboard.type(user.email.toLowerCase(), {
delay: 50,
});
await expect(page.locator('auth-sign-up-form').locator('[placeholder=email]')).toHaveValue(user.email.toLowerCase());

await page.locator('auth-sign-up-form').locator('[placeholder=password]').click();
await page.keyboard.type(user.password, {
delay: 50,
});
await expect(page.locator('auth-sign-up-form').locator('[placeholder=password]')).toHaveValue(user.password);

await page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]').click();
await page.keyboard.type(user.password, {
delay: 50,
});
await expect(page.locator('auth-sign-up-form').locator('[placeholder=confirm_password]')).toHaveValue(user.password);

await expect(page.locator('auth-sign-up-form').locator('button[type=submit]')).toHaveText('Зарегистрироваться');

await page.locator('auth-sign-up-form').locator('button[type=submit]').click();

await setTimeout(5000);

await expect(page.locator('nz-header').locator('[nz-submenu]').first()).toContainText(`Вы вошли в систему как ${user.email.toLowerCase()}`);
});

test('should catch error on create new webhook', async () => {
await page.locator('webhook-grid').locator('button').first().click();

await setTimeout(7000);

await page.locator('[nz-modal-footer]').locator('button').last().click();

await setTimeout(4000);

await expect(page.locator('webhook-form').locator('formly-validation-message').first()).toContainText('поле "адрес" не может быть пустым');
await expect(page.locator('webhook-form').locator('formly-validation-message').last()).toContainText('поле "событие" не может быть пустым');
});
});

10. We launch the infrastructure with applications in development mode and check the operation through E2E tests

Commands

npm run pm2-full:dev:start
npm run pm2-full:dev:test:e2e

Conclusion

In this post I added support for working with multiple languages ​​in NestJS and Angular applications, as well as switching them in real time.

I created dictionaries for all sentences that need to be translated and added translations into English and Russian.

The user's selected language is saved in localstorage and is used as the active one when the page is fully reloaded, in future posts it will be saved to the database.

Plans

In the next post I will add support for working with time zones, as well as saving the user-selected time zone to the database...

#angular #translates #nestjsmod #fullstack #2024-12-03