Decoupled Global Localization with Polymer


Thu 15 September 2016 By Gabriel Barata

We recently released a preview version of our Polymer-based new Web UI, which is in its early stages, but already packs a lot of functionality. Our new Web UI relies on nuxeo-ui-elements, the generic building blocks that can be used to build rich web apps for the Nuxeo Platform. These consist of several custom elements and behaviors, some of which aim at solving well-known yet complex problems. Today we’re going to address one of these: localization.

The Problem

Early this year we faced a challenge: we needed our Web UI to be localized into multiple languages. But our approach to localization had to be flexible enough to work for any Polymer element, while allowing details like message loading, language, and the translation function to be handled and defined by the application using those elements. Since Polymer did not provide any out-of-the-box solution to address localization, we had to come up with our own. We started by adding a global function to Polymer.Base and had it bound to all the text contents, properties, and attributes that required text to be translated in Polymer elements.

<span>[[i18n('label.app.usersAndGroups')]]</span>

And, this did the job quite well, at least while we only had one language. As soon as we started to experiment with multiple languages, we needed the bindings to be reevaluated across the several elements using the function even if neither the label keys nor the localization function had changed, specially because the language was changed by an external script. We found ourselves needing more: a global function that would work with data binding across several elements, but that could also be setup and used outside a Polymer element, while still updating the bindings in the elements using it.

The Solution

The solution to this problem consisted of creating a Polymer Behavior with a function-type property, to which the respective global function would be assigned. (Yes! Even though it’s not really documented, properties in Polymer can be of type Function and can be bound!)
This function property can then be used in computed bindings by all Polymer elements extending the behavior. The behavior should also watch for changes in this global function, either provoked by another element or by some other external code and trigger an update of any its computed bindings, thus causing translations to be updated across all elements implementing the behavior.

This approach allowed us to tackle the localization problem. We started by adding a standalone script named nuxeo-i18n.js, with the main intent of providing application level customization, such as defining the translation function, changing the language or configuring new message loading strategies. This script exposes a global translation function that accepts the label key and its default value, as well as a number of parameters that can be replaced on the resulting string. A global language variable is also provided, which holds the current translation language.

window.nuxeo.I18n.translate = function (key, defaultValue) {
  var language = window.nuxeo.I18n.language || 'en';
  var value = (window.nuxeo.I18n[language] && window.nuxeo.I18n[language][key]) || defaultValue || key;
  var params = Array.prototype.slice.call(arguments, 2);
  for (var i = 0; i < params.length; i++) {
    value = value.replace('{' + i + '}', params[i]);
  } // improve this to use both numbered and named parameters
  return value;
};

Another global function is responsible for loading the locale resources for the current language, which can be invoked both from within a Polymer element or from some other external script. This function fires a document level event to notify that a new locale was loaded (we decided not to use iron-slots to avoid a strict dependency on Polymer)

window.nuxeo.I18n.loadLocale = function() {
  return window.nuxeo.I18n.localeResolver ? window.nuxeo.I18n.localeResolver().then(function() {
    document.dispatchEvent(new Event('i18n-locale-loaded'));
  }) : new Promise(function() {});
};

The loadLocale function relies on a locale resolver, which is an object that knows how to load the resources. This provides for a modular and configurable approach to loading translation resources, allowing custom resolvers to be specified for different types configurations.
In the New Web UI we’re using a locale resolver to load JSON objects via asynchronous XHR. It is defined as follows:

window.nuxeo.I18n.localeResolver = new XHRLocaleResolver(msgFolder);

function XHRLocaleResolver(msgFolder) {
  return function() {
    return new Promise(function(resolve,reject) {
      var language = window.nuxeo.I18n.language || 'en';
      var url = msgFolder +  '/messages.' + language + '.json';
      var xhr = new XMLHttpRequest();
      xhr.open('GET', url, true);
      xhr.onreadystatechange = function() {
        if (xhr.readyState == 4 && xhr.status == 200) {
          window.nuxeo.I18n[language] = JSON.parse(this.response); // cache this locale.
          resolve(this.response);
        }
      };
      xhr.onerror = function() {
        console.error("Failed to load " + url);
        reject(this.statusText);
      };
      xhr.send();
    });
  }
}

We also created an i18n behavior that relies on nuxeo-i18n.js, where elements extending this behavior, after being attached to the document node, register an event listener to check whether or not a new locale was loaded. This listener runs a function that reassigns a global translation function to a function-type property named i18n.

Nuxeo.I18nBehavior = {
  properties: {
    i18n: {
      type: Function,
      notify: true,
      value: function() {
        return window.nuxeo.I18n.translate;
      }
    }
  },

  attached: function() {
    this.localeLoadedHandler = this.refreshI18n.bind(this);
    document.addEventListener('i18n-locale-loaded', this.localeLoadedHandler);
  },

  detached: function() {
    document.removeEventListener('i18n-locale-loaded', this.localeLoadedHandler);
  },

  refreshI18n: function() {
    this.i18n = window.nuxeo.I18n.translate;
  }
};

This i18n function can then be bound to a property, attribute or textContent of an element, just like in the example below:

<span>[[i18n('label.app.usersAndGroups', 'Users & Groups')]]</span>

All elements that need translation in the new Web UI as well as in nuxeo-ui-elements implement this behavior. All that needs to be done for it to work is to first setup the locale resolver in a script, as show above, and then simply change the language, which will update all labels, by doing:

window.nuxeo.I18n.language = 'en';
window.nuxeo.I18n.loadLocale();

This approach renders the logic behind changing languages and loading locale resources very easy and pluggable, and it keeps data binding in check even if resources are loaded either from a Polymer element or by some other external JavaScript code.

Note: In the beginning of May, the Polymer team pre-released the AppLocalizeBehavior, a behavior similar to our own localization behavior, but with no support for custom global functions and without the logic to update them once the function changed externally.


Tagged: Polymer, How to, Nuxeo-Platform