PhraseApp Integration

Introduction

PhraseApp is an automated localization processes that empowers their users to edit language files via an in context click and edit interface. Since many applications need to be localized dynamically, PhraseApp serves as a powerful tool that can add and edit form translations in real time. The tutorial that follows showcase’s how users can overlay the PhraseApp service on top of an Angular 2+ based application.

Source Code

A demo of this application and affiliate source code is available online. We encourage you to walk through the full guide, but if you would like to have this application up and running quickly, hen feel free to run the application from the source code. You can also use the source code as a reference when walking through this tutorial. Click on the button below to download the source code for this application.

Repositories

Before diving into the PhraseApp integration, here is a list of several resource, including application modules that we will be importing throughout this walk-through. Additionally, you will be expected to have:

Feel free to antiquate yourself with the following repositories and libraries:

  • @formio.js - view live on github
  • @angular-formio - view live on github
  • @ngx-translate/core - view live on github
  • @ngx-translate/http-loader - view live on github
  • @ngx-translate-phraseapp - view live on github
  • Angular - view the official documentation at angular.io

For specific information regarding our Core Renderer, formio.js, you can find supplemental materials on our wiki.

For specific information regarding our Angular 2+ Wrapper, angular-formio, you can find supplemental materials on our wiki.

For a general overview of how translations work in formio.js, please visit this section on help.form.io.

Application Setup

To get started, you will need to make sure that you have the following accounts and tools installed on your machine.

  • A Form.io Account - visit portal.form.io to register
  • A PhraseApp Account - visit phraseapp.com to register
  • An installation of Node.js - visit node.org for download instructions, we recommend using the latest LTS version

While not required, we will be using the Angular CLI to build this demo application. Please reference cli.angular.io for additional support. To install and create the application’s development environment, please run the following commands as an administrator from within either the Mac Terminal or Windows PowerShell:

npm install -g @angular/cli
ng new translations --style=scss
cd translations
npm install
ng serve

If everything is configured correctly, a default Angular 2+ application should be live at localhost:4200.

Angular-Formio

To get started we will be adding some additional dependencies to the application. If your project is live, please terminate your local server by hitting Ctrl + C or Command + C depending on your setup. Then run the following npm commands in directory of your project.

npm install --save angular-formio@^2.0.0-alpha.1
npm install --save @ngx-translate/core@^9.1.1
npm install --save @ngx-translate/http-loader@^2.0.1
npm install --save ngx-translate-phraseapp@^0.1.5

As a point of reference, the above installation commands have explicitly included the version property for each module in case some incompatibility is introduced in the future.

Other Dependencies

While The following libraries are optional, we will be including them in this application as they add both styling and structure to the application.

If you’d like to include bootstrap styling run the following command:

npm install --save bootstrap@^4.0.0

If you’d like to include bootswatch templates run the following command:

npm install --save bootswatch@^4.0.0

Once installed, you will need to include bootstrap and bootswatch libraries in the application by inserting the following code in the src/styles.scss file:

@import "~bootswatch/dist/pulse/_variables.scss";
@import "~bootstrap/scss/bootstrap.scss";
@import "~bootswatch/dist/pulse/_bootswatch.scss";

Bootstrap requests jquery so if you are planning on using the above dependency add:

npm install --save jquery@^3.3.0

If you would like to include font-awesome to the project, please run the following command:

npm install --save font-awesome@^4.7.0

Once installed, include font-awesome in the the angular-cli.json file at the root level.

{
  "apps": {
    "styles": [
      "styles.scss",
      "../node_modules/font-awesome/scss/font-awesome.scss"
    ],
    "addons": [
      "../node_modules/font-awesome/fonts/*.+(otf|eot|svg|ttf|woff|woff2)"
    ],
    "scripts": [
      "../node_modules/jquery/dist/jquery.js",
      "../node_modules/bootstrap/dist/js/bootstrap.js"
    ],
  }
}

With all of the dependencies installed, feel free to relaunch launch the application by running ng serve. You can leave the development server active while we start working inside the src directory as the project will now automatically update as we make changes to the application.

Site Structure

To structure the application, we are going to create all the components and modules we need upfront. Since we’re using the Angular’s CLI tool, we can generate the necessary components via command line.

We shall start by creating the home and navigation component. We will use the navigation to change between an edit and view mode for the in context editor. Should you decide to add authentication to the project, you can lock down the edit mode such that only admins have the ability to add or alter translations.

ng g component home
ng g component navigation

Next, create a module that will be responsible for handling the PhraseApp editor logic.

ng g module phraseapp

Finally, create two components that will handle the view and edit modes.

ng g component phraseapp/phraseapp-view
ng g component phraseapp/phraseapp-edit

Once done the src/app structure should look like this, excluding css and spec.ts files:

  • home ▼
    • home.component.html
    • home.component.ts
  • navigation ▼
    • navigation.component.html
    • navigation.component.ts
  • phraseapp ▼
    • phraseapp-edit ▼
      • phraseapp-edit.component.html
      • phraseapp-edit.component.ts
    • phraseapp-view ▼
      • phraseapp-view.component.html
      • phraseapp-view.component.ts
    • phraseapp.module.ts
  • app.component.html
  • app.component.ts
  • app.module.ts

If you inspect the app.module.ts, you can see that our components have been automatically added to the project. Additionally, in our assets folder, create the following two files inside a i18n directory.

  • assets ▼
    • i18n ▼
      • en.json
      • es.json

Inside the en.json file, place the following JSON object:

{
  "activeLang": "English",
  "title": "Translations"
}

Inside the es.json file, place the following JSON object:

{
  "activeLang": "Español",
  "title": "Traducción"
}

While these files are not strictly necessary, we can use them with PhraseApp to localize the translation exports for inclusion during the compile process. This can be useful when building an application that may need to work in an offline configuration. Feel welcome to localize the data instead of retrieving the files from the API as shown later in this walk-through.

App Routing

Starting with the app.module.ts let’s importing the routing module and configure our navigation stack. While we’re here we’ll also include all the translation modules. For full explanations of why certain modules were added and how there being used please refer to the official repositories at the top of the guide.

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouterModule, Routes } from '@angular/router';
import { HttpClientModule, HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { NavigationComponent } from './navigation/navigation.component';
import { PhraseAppViewComponent } from './phraseapp/phraseapp-view/phraseapp-view.component';
import { PhraseAppModule } from './phraseapp/phraseapp.module';

// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http);
}

const navStack: Routes = [
  { path: '', component: HomeComponent},
  { path: 'phraseapp/view', component: PhraseAppViewComponent },
  { path: '**', pathMatch: 'full', redirectTo: ''}
];

@NgModule({
  declarations: [
    AppComponent,
    NavigationComponent,
    PhraseAppViewComponent,
    HomeComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    PhraseAppModule,
    RouterModule.forRoot(navStack, {useHash: true}),
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      }
    })
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

at this point HTTP_INTERCEPTORS and TranslateCompiler arn’t configured but we’ll come back to those later. as for the rest of the code, we’ve add angular’s router which will interact with our navigation component. The translation provider that we’ve included will allow us to access those json files which you’ll see utilized in the navigation component. and finally, we’ve added the PhraseAppModule which will handle the edit view and out HTTP module that will be responsible for handling out asynchronous request to the PhraseApp APIs.

for the home.component.html feel free to change it to whatever you’d like. we’ll be setting it to:

<p>This is demo application for PhraseApp Translations</p>

In the apps.component.ts lets add the TranslateService service like so, and set a default language.

import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  constructor(translate: TranslateService) {
    translate.setDefaultLang('en');
  }
}

in the apps.component.html give the router-outlet a place to injection our components

<div class="container">
  <div class="row" style="padding: 10px 0">
    <div class="col-md-12">
      <app-navigation></app-navigation>
    </div>
  </div>
PhraseApp
  <div class="row">
    <div class="col-md-12">
      <router-outlet></router-outlet>
    </div>
  </div>
</div>

In the navigation.component.html let’s bootstrap navbar class and configure our links.

<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
  <a class="navbar-brand" routerLink="/"><i class="fa fa-home"></i> {{ 'title' | translate }}</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
  </button>

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
        <a class="nav-link" routerLink="/">Home</a>
      </li>

      <li class="nav-item dropdown" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
        <a class="nav-link dropdown-toggle" id="navbarDropdown1" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" >
          PhraseApp
        </a>

        <div class="dropdown-menu" aria-labelledby="navbarDropdown">
          <a class="dropdown-item" routerLink="/phraseapp/edit">Edit Mode</a>
          <a class="dropdown-item" routerLink="/phraseapp/view">View Mode</a>
        </div>
      </li>
    </ul>
  </div>
</nav>

Finally, we have to configure the phraseapp.module.ts with the following:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader, TranslateCompiler } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { PhraseAppCompiler } from 'ngx-translate-phraseapp';

import { PhraseappEditComponent } from './phraseapp-edit/phraseapp-edit.component';

const navStack: Routes = [
  { path: 'phraseapp',
    children: [
      { path: 'edit', component: PhraseAppEditComponent },
    ]
  }
];

// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient) {
  return new TranslateHttpLoader(http);
}

@NgModule({
  imports: [
    CommonModule,
    HttpClientModule,
    RouterModule.forChild(navStack),
    TranslateModule.forRoot({
      loader: {
        provide: TranslateLoader,
        useFactory: HttpLoaderFactory,
        deps: [HttpClient]
      },
      compiler: {
        provide: TranslateCompiler,
        useClass: PhraseAppCompiler
      }
    }),
  ],
  declarations: [PhraseAppEditComponent]
})
export class PhraseAppModule { }

With the core configuration in place, feel free to test everything using ng serve. While on the surface not a lot has changed, you’ll notice that the title in the navbar pulls from the en.json file, the routing allows you to traverse the site as the routes correctly apply an active class. Lastly going to a bad URL redirects you back to the home page.

Building The Form

The form we’ll be using for this example can be found at https://examples.form.io/phraseapp. Feel free to import the JSON into your own project or make your own.

There are few things to note. First all the components are prefixed with [[__phrase_ and sufixed with _]]. This format is how PhraseApp identifies what items in the DOM can be translated. You’ll see where these settings are defined later in the walk through should you choose to change them. Also make sure the entire label is lowercase format. Finally, the match_primary_text field has a custom validation script which will error if the text does not match the primary_text field.

PhraseApp Interface

With our form built, make sure the form access is configured for your intended use case. In this example, permissions are set to anonymous. Now we need to create and retrieve two items from phraseapp.com. The first is a ProjectId and the second is an Access Token. additionally, we’ll have to define which languages our project should support. Once you’ve logged in go ahead and create a new project.

Project Create

Now that the project appears in your dashboard, hover over the and moreproject settingsAPI and copy the ProjectId

Project Settings

Next, enter the project and click locales, in this demo I’ve setup and English and Spanish Language.

Project Locales

Next, we need to create an Access Token, you can find the option under your username’s drop down menu.

Where is Access Token

We don’t have any tokens yet, go ahead and click Generate A Token

Generate an Access Token

Since we only want people to read the translations and no change then, set the scope to read only.

Access Token Settings

Finally, copy down your project’s access token.

The Actual Token

Creating our interceptor

Before we configure the view mode we, need to create an interceptor to handle the Access Token. This is to lock down the edit access to only people who have PhraseApp login credentials with respect current the project.

under phraseappphraseapp-view create a fifth file called phraseapp-interceptor.ts and add the following code:

import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs/observable';

@Injectable()
export class PhraseAppInterceptor implements HttpInterceptor {
  intercept (request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const accessRequest = request.clone({
      headers: request.headers.set('Authorization', 'token <AccessTokenGoesHere>')
    });
    return next.handle(accessRequest);
  }
}

with this in place we need to revisit the app.module.ts file and append the providers.

...
import { PhraseAppInterceptor } from './phraseapp/phraseapp-view/phraseapp-interceptor';
...
@NgModule({
  ...
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: PhraseAppInterceptor,
    multi: true,
  }],
  ...

Configuring Edit Mode

With our form built, make sure the form access is configured for your intended use case. In this example, permissions are set to anonymous. Starting with out phraseapp-edit.component.ts import the following HTML:

<h4>PhraseApp Edit Mode</h4>
<hr>

<div class="card" style="padding: 10px">
  <div class="card-block">
    <div id="PhraseEdit"></div>
  </div>
</div><hr>

<h5>Error Messages</h5>
<div class="card" style="margin-bottom: 120px; padding: 10px" >
  <div class="row">
    <div class="col-3">
      <div class="card p-2 px-3">
        Required
      </div>
    </div>

    <div class="col-5">
      <div class="card p-2 px-3">
        [[__phrase_required__]]
      </div>
    </div>
  </div>

  <div class="row">
    <div class="col-3">
      <div class="card p-2 px-3">
        Error
      </div>
    </div>

    <div class="col-5">
      <div class="card p-2 px-3">
        [[__phrase_error__]]
      </div>
    </div>
  </div>

  <div class="row">
    <div class="col-3">
      <div class="card p-2 px-3">
        Invalid Email
      </div>
    </div>

    <div class="col-5">
      <div class="card p-2 px-3">
        [[__phrase_invalid_email__]]
      </div>
    </div>
  </div>
</div>

Then, in the corresponding component controller phraseapp-edit.component.ts include:

import { Component, OnInit } from '@angular/core';
import { PhraseAppCompiler } from 'ngx-translate-phraseapp';
import { initializePhraseAppEditor } from 'ngx-translate-phraseapp';
import { createForm } from 'formiojs';

@Component({
  selector: 'app-phraseapp-edit',
  templateUrl: './phraseapp-edit.component.html',
  styleUrls: ['./phraseapp-edit.component.scss']
})

export class PhraseAppEditComponent implements OnInit {
  formRemote: string;
  phraseConfig: object;

  constructor() {
    this.formRemote = '<formURLGoesHere>';

    this.phraseConfig = {
      projectId: '<projectIdGoesHere>',
      phraseEnabled: true,
      prefix: '[[__',
      suffix: '__]]',
    };

    initializePhraseAppEditor(this.phraseConfig);
  }

  ngOnInit() {
    createForm(document.getElementById('PhraseEdit'), this.formRemote, {
      readOnly: false,
    });
  }
}

Here we’re doing a couple of things. First, we’re creating the Form on our HTML’s target #PhraseEdit. Second, were initialize the PhraseApp Editor and we’re passing in the aforementioned configurations. If you’ve been coding along make sure you update the formRemote and projectId that we just created from phraseapp.com.

Configuring View Mode

With the interceptor built, we can now configure the view mode. Add the following code to phraseapp-view.component.html

<h4>PhraseApp View Mode: {{ 'activeLang' | translate }}</h4>

<div class="row">
  <div class="col-sm-4 offset-2">
    <button class="btn btn-primary btn-md btn-block " (click)="switchLanguage('en')">en</button>
  </div>
  <div class="col-sm-4">
    <button class="btn btn-primary btn-md btn-block" (click)="switchLanguage('es')">es</button>
  </div>
</div>

<hr>

<div class="card" style="padding: 10px">
  <div class="card-block">
    <div id="PhraseView"></div>
  </div>
</div>

Then, in the corresponding component controller phraseapp-view.component.ts include:

import { Component, OnInit } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { createForm } from 'formiojs';

@Component({
  selector: 'app-phraseapp-view',
  templateUrl: './phraseapp-view.component.html',
  styleUrls: ['./phraseapp-view.component.scss']
})

export class PhraseappViewComponent implements OnInit {
  PhraseId: string;
  PhraseBase: string;
  formRemote: string;

  constructor(private translate: TranslateService, private http: HttpClient) {
    this.PhraseId = '<youPhraseAppIdGoesHere>';
    this.PhraseBase = 'https://api.phraseapp.com/api/v2/projects/' + this.PhraseId + '/locales/';
    this.formRemote = 'https://examples.form.io/phraseapp';
  }
  switchLanguage(language: string) {
    (<any>window).setLanguage(language);
    this.translate.use(language);
  }

  getLanguages():Promise<any> {
    return this.http.get(this.PhraseBase).toPromise();
  }

  getTranslations(data) {
    let promises:any = [];
    for (let i = 0; i < data.length; i++) {
      promises.push(this.http.get(this.PhraseBase + data[i]['id'] + '/translations').toPromise());
    }
    return promises
  }

  createConversions(phrases) {
    return new Promise((resolve) => {
      let toMergeBase = {};
      let toMergePhrase = {};
      let newLanguage = {};
      for (let i = 0; i < phrases.length; i++) {
        for (let propt in phrases[i]) {
          let injectLang = phrases[i][propt]['locale']['code'];
          let injectKeyBase = phrases[i][propt]['key']['name'];
          let injectKeyPhrase = '[[__phrase_' + phrases[i][propt]['key']['name'] + '__]]';
          let injectValue = phrases[i][propt]['content'] ;

          toMergeBase = { [injectKeyBase] : injectValue} ;
          toMergePhrase = { [injectKeyPhrase] : injectValue} ;

          let prevValue = newLanguage[injectLang] || {};
          newLanguage[injectLang] = Object.assign(prevValue, toMergePhrase, toMergeBase);
        }

        resolve(newLanguage);
      }
    });
  }

  ngOnInit() {
    this.getLanguages().then((languages: any[])  => {
      Promise.all(this.getTranslations(languages)).then((phrases: any[]) => {
        this.createConversions(phrases).then((i18n: any[]) => {
          createForm(document.getElementById('PhraseView'), this.formRemote, {
            readOnly: false, i18n : i18n
          }).then(form => {
            (<any>window).setLanguage = function (lang) {
              form.language = lang;
            };
          });
        });
      });
    });
  }
}

To summarize whats happening here, because this is where bulk of the code is handled, we start by getting the locales from then PhraseApp API. This makes the configuration work whether there is 1 or 60 languages in your PhraseApp portal. Once the list of locales is returned we then fetch the remote object that contains all translations as provided by PhraseApp. Finally we need to convert and pass the return into the form’s construction. for a simple versions of whats happening you reference our Form.io Translations example.

Finally, there is one more hitch we have to account for in this demo. If you visit the Edit Mode and the visit other pages, the PhraseApp interface persists. This is because once the app is initialized via the initializePhraseAppEditor() function, it affects the entire state. To fix this we need to add router properties to our app.component.ts file.

import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Router, NavigationStart } from '@angular/router';


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  previousUrl: string;
  currentUrl: string;

  constructor(public translate: TranslateService, public router: Router) {
    translate.setDefaultLang('en');
    this.router.events.subscribe((event) => {
      if (event instanceof NavigationStart) {
        this.currentUrl = event.url;
        if(this.currentUrl === '/phraseapp/edit' && this.previousUrl !== '/phraseapp/edit' && this.previousUrl) {
          this.previousUrl = this.currentUrl;
          window.location.reload(true);
        }
        if(this.previousUrl === '/phraseapp/edit' && this.currentUrl !== '/phraseapp/edit') {
          window.location.reload(true);
        }
        this.previousUrl = this.currentUrl;
      }
    });
  }
}