Aplicación web multi-idioma con Angular y NGX-Translate

por | Dic 4, 2018 | Angular, NGX-Translate | 1 Comentario

Angular Multilanguage Codemain

Introducción

Esta ocasión vamos a aprender a desarrollar e implementar una aplicación web multi-idioma, usando Angular 6 y NGX-Translate. Se dará por hecho que se tienen unas nociones básicas del funcionamiento de Angular, y se omitirán los pasos correspondientes a la instalación de los requerimientos.

Requerimientos

A continuación se detalla el software que deberemos tener instalado en nuestro equipo antes de iniciar el tutorial:

Angular

Creación del proyecto

Lo primero que deberemos hacer será crear un nuevo proyecto, en el que posteriormente implementaremos NGX-Translate para así desarrollar una aplicación web multi-idioma.

Abrimos una ventana de terminal y nos movemos a la carpeta en la que deseemos crear el nuevo proyecto, una vez en ella ejecutaremos el siguiente comando:

ng new angular-multilanguage

Ésto nos creará un proyecto nuevo llamado angular-multilanguage dentro de la carpeta en la que estábamos situados. Sin cerrar la ventana de terminal, nos movemos a la carpeta del proyecto y lo arrancamos mediante los siguientes comandos:

cd angular-multilanguage
ng serve

Una vez haya terminado la compilación del proyecto, si abrimos nuestro navegador e introducimos en la barra de dirección localhost:4200, de inmediato nos cargará la página web.

Instalación de dependencias

Para simplificar la gestión de estilos procederemos a instalar Bootstrap, y NGBootstrap para el uso de componentes optimizados en Angular. También instalaremos NGXTranslate para poder gestionar los diferentes idiomas de la web. Si el proceso de ng serve aún sigue ejecutándose, debemos pararlo antes de proceder con la instalación. Una vez parado, ejecutaremos el siguiente comando:

npm install bootstrap

Seguidamente instalaremos NGBootstrap:

npm install --save @ng-bootstrap/ng-bootstrap

Y por último, NGXTranslate:

npm install --save @ngx-translate/core@10
npm install --save @ngx-translate/http-loader@3

Configuración de dependencias

Para poder usar los estilos de Bootstrap necesitaremos importar los estilos css en nuestro archivo de configuración angular.json de la siguiente manera:

"styles": [
    "node_modules/bootstrap/dist/css/bootstrap.min.css",
    "styles.scss"
]

Ahora configuraremos el archivo app.module.ts con todo lo necesario para que la app funcione, y nos quedará de la siguiente manera:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

import { AppComponent } from './app.component';
import { MainComponent } from './components/main/main.component';

@NgModule({
  declarations: [
    AppComponent,
    MainComponent
  ],
  imports: [
    BrowserModule,
    NgbModule,
    TranslateModule.forRoot(),
    HttpClientModule,
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [ HttpClient ]
      }
    })
  ],
  providers: [],
  bootstrap: [ AppComponent ]
})

export class AppModule { }

export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http);
}

Desarrollo

Desde la ventana de terminal, con el ng serve detenido, procedemos a crear un nuevo componente mediante el siguiente comando:

ng generate component components/main

Con esto lograremos generar un nuevo componente llamado main dentro de la carpeta components (la cual si no tenemos se creará automáticamente).

Una vez hecho esto nos dirigiremos al archivo app.component.html y reemplazaremos el contenido por lo siguiente:

<!-- Componente Main -->
<app-main></app-main>

Ahora iremos al archivo main.component.html y sustituiremos el contenido por el siguiente:

<div class="container pt-5">
  <div class="row">
    <div class="col-4 offset-4 text-center">
      <!-- Dropdown -->
      <div ngbDropdown class="d-inline-block">
        <input ngbDropdownToggle type="button" class="btn btn-outline-primary" id="dropdownIdioma" value="Idioma" />
        <div ngbDropdownMenu aria-labelledby="dropdownIdioma">
          <input type="button" class="dropdown-item" value="Español" (click)="changeLanguageToSpanish()" />
          <input type="button" class="dropdown-item" value="Inglés" (click)="changeLanguageToEnglish()" />
        </div>
      </div>
      <!-- /Dropdown -->
      <!-- Text Bind -->
      <input type="text" class="form-control mt-5" [(ngModel)]="text" placeholder="Introduce texto aquí" />
      <p class="mt-2">El texto introducido es: {{ text }}</p>
      <!-- /Text Bind -->
    </div>
  </div>
</div>

Y el archivo main.component.ts por el momento quedará de la siguiente manera:

import { Component, OnInit } from '@angular/core';

@Component({...})

export class MainComponent implements OnInit {

  text: string;

  constructor() { }

  ngOnInit() { }

  // Se cambia el idioma a Español
  changeLanguageToSpanish(): void {
    console.log('Idioma cambiado al Español');
  }

  // Se cambia el idioma a Inglés
  changeLanguageToEnglish(): void {
    console.log('Idioma cambiado al Inglés');
  }

}

A la hora de compilar la web, nos debe dar un error que diga: Can’t bind to ‘ngModel’ since it isn’t a known property of ‘input’. Esto es porque no hemos importado el módulo de formularios en el app.module.ts:

import { FormsModule } from '@angular/forms';

@NgModule({
  ...
  imports: [ FormsModule ],
  ...
})

export class AppModule { }

Una vez hecho esto, ya compilará correctamente.

Hasta ahora hemos conseguido crear una página web sencilla, que dispone de un desplegable para cambiar de idioma, una caja de texto para introducir lo que se nos ocurra, y un párrafo en el que se muestra un texto fijo seguido de lo que introducimos en la caja de texto. La única funcionalidad que tiene el desplegable es mostrar por consola un mensaje indicando a qué idioma se ha cambiado, por lo que a continuación se detallarán los pasos necesarios para implementar la funcionalidad de multi-idioma.

NGXTranslate

Para este ejemplo básico sólo haremos uso de dos idiomas (Español e Inglés), pero se pueden incluir todos los que sean necesarios. También es importante recalcar, que la dificultad de iniciar una aplicación web multi-idioma de cero es muchísimo menor a convertir una ya existente que se inició sin contemplar la posibilidad de esta necesidad en un futuro, pero no es imposible.

Lo primero que haremos será crear una carpeta llamada i18n dentro de src/app/assets. En ella se incluirán los ficheros JSON con los literales que requiera la aplicación traducidos en los diferentes idiomas. El nombre de estos ficheros debería ser {idioma}.json, así que en nuestro caso crearemos uno llamado es.json y otro llamado en.json.

Una vez creados, el fichero de los literales en español (es.json) debería quedar así:
{
    "main": {
        "language": "Idioma",
        "spanish": "Español",
        "english": "Inglés",
        "input_placeholder": "Introduce texto aquí",
        "introduced_text": "El texto introducido es: {{ text }}"
    }
}
Y el fichero con los literales en inglés (en.json) así:
{
    "main": {
        "language": "Language",
        "spanish": "Spanish",
        "english": "English",
        "input_placeholder": "Introduce text here",
        "introduced_text": "Introduced text is: {{ text }}"
    }
}
Se puede observar que en este último fichero hay un poco de redundancia clave:valor, pero es debido a que he decidido usar el inglés para nombrar las claves (por convención). También, la clave padre que agrupa los literales del componente main se llama como éste, por lo que si tuviéramos otro componente llamado login, lo suyo es que la clave padre que agrupe sus literales se llamara igual. Una vez hecho esto, procederemos a incluir las claves de los literales en nuestro archivo main.component.html:
<div class="container pt-5">
  <div class="row">
    <div class="col-4 offset-4 text-center">
      <!-- Dropdown -->
      <div ngbDropdown class="d-inline-block">
        <!-- Usando el pipe translate -->
        <input ngbDropdownToggle type="button" class="btn btn-outline-primary" id="dropdownIdioma" value="{{ 'main.language' | translate }}" />
        <div ngbDropdownMenu aria-labelledby="dropdownIdioma">
          <!-- Usando el pipe translate -->
          <input type="button" class="dropdown-item" value="{{ 'main.spanish' | translate }}" (click)="changeLanguageToSpanish()" />
          <!-- Usando la directiva translate -->
          <button type="button" [translate]="'main.english'" class="dropdown-item" (click)="changeLanguageToEnglish()"></button>
        </div>
      </div>
      <!-- /Dropdown -->
      <!-- Text Bind -->
      <input type="text" class="form-control mt-5" [(ngModel)]="text" placeholder="{{ 'main.input_placeholder' | translate }}" />
      <!-- Usando el pipe translate con parámetros -->
      <p class="mt-2">{{ 'main.introduced_text' | translate: { text: (text || '') } }}</p>
      <!-- /Text Bind -->
    </div>
  </div>
</div>
Se puede observar que he cambiado el tercer input por un button, esto ha sido para poder implementar las traducciones mediante pipe y directive o directiva, y así tener un ejemplo más claro de como funciona cada una. Ahora deberemos actualizar nuestro archivo main.component.ts:
import { Component, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

@Component({...})

export class MainComponent implements OnInit {

  text: string;

  constructor(private translate: TranslateService) { }

  ngOnInit() { }

  // Se cambia el idioma a Español
  changeLanguageToSpanish(): void {
    this.translate.use('es');
    console.log('Idioma cambiado al Español');
  }

  // Se cambia el idioma a Inglés
  changeLanguageToEnglish(): void {
    this.translate.use('en');
    console.log('Idioma cambiado al Inglés');
  }

}
Además, deberemos especificar en el app.component.ts que idioma se usará por defecto:
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

@Component({...})

export class AppComponent {

  constructor(translate: TranslateService) {
    translate.setDefaultLang('es');
    translate.use('es');
  }
}
Sé lo que estáis pensando… ¡el título de la página en el navegador no es multi-idioma! Bueno pues vamos a ello. Necesitaremos crear un nodo nuevo en los archivos json de los idiomas, éste contendrá los literales generales de la app. Así quedaría es.json:
{
    "app": {
        "title": "Angular Multi-Idioma"
    },

    "main": {
        "language": "Idioma",
        "spanish": "Español",
        "english": "Inglés",
        "input_placeholder": "Introduce texto aquí",
        "introduced_text": "El texto introducido es: {{ text }}"
    }
}
Y de esta forma en.json:
{
    "app": {
        "title": "Angular Multilanguage"
    },

    "main": {
        "language": "Language",
        "spanish": "Spanish",
        "english": "English",
        "input_placeholder": "Introduce text here",
        "introduced_text": "Introduced text is: {{ text }}"
    }
}
Lo último que nos queda por hacer es actualizar el app.component.ts para que escuche el evento del cambio de idioma, y en ese momento, recuperar la traducción del título y establecerla mediante el service que Angular nos brinda (private titleService: Title):
import { Component } from '@angular/core';
import { TranslateService, LangChangeEvent } from '@ngx-translate/core';
import { Title } from '@angular/platform-browser';

@Component({...})

export class AppComponent {

  constructor(private translate: TranslateService, private titleService: Title) {
    this.translate.setDefaultLang('es');
    this.translate.use('es');

    this.translate.onLangChange.subscribe((event: LangChangeEvent) => {
      this.translate.get('app.title').subscribe((res: string) => {
        this.titleService.setTitle(res);
      });
    });
  }
}

Bonus

Como extra, ya que cada vez es más esencial, vamos a elaborar test unitarios básicos para testear nuestros componentes. El archivo app.component.spec.ts lo dejaremos preparado únicamente para que no ‘explote‘, pero sin testear funcionalidad:

import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { MainComponent } from './components/main/main.component';
import { TranslateTestingModule } from 'ngx-translate-testing';
import { FormsModule } from '@angular/forms';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { HttpLoaderFactory } from './app.module';

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        MainComponent
      ],
      imports: [
        TranslateTestingModule,
        FormsModule,
        HttpClientModule,
        TranslateModule.forRoot({
          loader: {
            provide: TranslateLoader,
            useFactory: HttpLoaderFactory,
            deps: [ HttpClient ]
          }
        })
      ]
    }).compileComponents();
  }));

  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
});
Y el archivo main.component.spec.ts quedaría de la siguiente manera:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MainComponent } from './main.component';
import { TranslateTestingModule } from 'ngx-translate-testing';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core';
import { HttpLoaderFactory } from '../../app.module';
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { AppComponent } from '../../app.component';

const TRANSLATIONS_ES = require('../../../assets/i18n/es.json');
const TRANSLATIONS_EN = require('../../../assets/i18n/en.json');

describe('MainComponent', () => {
  let component: MainComponent;
  let fixture: ComponentFixture<MainComponent>;
  let translate: TranslateService;
  let http: HttpTestingController;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        MainComponent
      ],
      imports: [
        TranslateTestingModule,
        FormsModule,
        HttpClientTestingModule,
        TranslateModule.forRoot({
          loader: {
            provide: TranslateLoader,
            useFactory: HttpLoaderFactory,
            deps: [ HttpClient ]
          }
        })
      ],
      providers: [ TranslateService ]
    }).compileComponents();
    translate = TestBed.get(TranslateService);
    http = TestBed.get(HttpTestingController);
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(MainComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should load translations', () => {
    TranslateTestingModule
    .withTranslations('es', TRANSLATIONS_ES)
    .withTranslations('en', TRANSLATIONS_EN)
    .withDefaultLanguage('es');
  });
  
  it('should load and change between translations', async(() => {
    spyOn(translate, 'getBrowserLang').and.returnValue('es');
    const fixture = TestBed.createComponent(AppComponent);
    const compiled = fixture.debugElement.nativeElement;
    
    // El DOM debería estar vacío ya que no se han renderizado las traducciones aún
    expect(compiled.querySelector('#dropdownIdioma').value).toEqual('');
    
    http.expectOne('/assets/i18n/es.json').flush(TRANSLATIONS_ES);
    http.expectNone('/assets/i18n/en.json');

    // Se comprueba que no hay peticiones pendientes antes de seguir
    http.verify();

    fixture.detectChanges();
    // El contenido debería estar traducido en español ya
    expect(compiled.querySelector('#dropdownIdioma').value).toEqual(TRANSLATIONS_ES.main.language);

    // Se cambia el idioma a Inglés
    translate.use('en');
    http.expectOne('/assets/i18n/en.json').flush(TRANSLATIONS_EN);

    // Se comprueba que no hay peticiones pendientes antes de seguir de nuevo
    http.verify();

    // El DOM no debería haber renderizado aún las traducciones
    expect(compiled.querySelector('#dropdownIdioma').value).toEqual(TRANSLATIONS_ES.main.language);

    fixture.detectChanges();
    // Las traducciones en inglés ya deberían haberse renderizado
    expect(compiled.querySelector('#dropdownIdioma').value).toEqual(TRANSLATIONS_EN.main.language);
  }));
});
Para ejecutar los tests, tendremos que abrir un terminal en la raíz del proyecto, y ejecutar el siguiente comando:
ng test

Automáticamente, se abrirá una ventana de nuestro navegador predeterminado con el resultado. Si hemos seguido todos los pasos correctamente todos los test deberían pasar sin errores.

Si durante la realización del tutorial nos diera el siguiente error: ERROR in node_modules/@types/jasmine/index.d.ts(138,47): error TS1005: ‘;’ expected es muy probable que sea debido a las versiones que tenemos configuradas de Jasmine y Typescript, podemos ver una tabla de compatibilidades en el siguiente issue, y deberemos configurar las versiones que deseemos en nuestro archivo package.json:

"devDependencies": {
    ...
    "@types/jasmine": "~2.8.8",
    "typescript": "^2.7.2"
    ...
}
Para volver a descargar las dependencias con las nuevas versiones especificadas, en el terminal escribimos:
npm install

Resultado

A continuación podréis ver una captura del proyecto recién creado y otra de como ha quedado al final del tutorial.

Recursos

El código fuente del proyecto está subido a GitHub, desde el siguiente enlace podréis acceder a él.