Angular JS – Decorators and Dynamic Templates to handle a multi-language scenario with $locale + Templates Pre-Caching

Let’s imagine a classic multi-language scenario where we want to have the same page in 2 (or many) different languages. What we normally can do is to setup a family of different URIs (“/it/my-page” and “/uk/my-page” for instance) whose output is the same, with the only difference of the title and the internationalization (i18n) script. Based on that we want to handle all the other differencies (in the data or in the HTML) with Angular and we want to do that in the most transparent way.

Here is an example of the output that we want to receive from the server for the IT and the UK page:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>La mia pagina IT</title>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.7/angular.min.js"></script>
    <script src="i18n/angular-locale_it-it.js"></script>
    <script src="MyApp.js"></script>
</head>
<body>
    <div ng-app="myApp">
        <my-directive></my-directive>
    </div>
</body>
</html>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>My UK Page</title>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.7/angular.min.js"></script>
    <script src="i18n/angular-locale_en-gb.js"></script>
    <script src="MyApp.js"></script>
</head>
<body>
    <div ng-app="myApp">
        <my-directive></my-directive>
    </div>
</body>
</html>

As we can see the only differences are in the title and the i18n script that we are injecting. The i18n scripts contain many information about the specific languages such as the days’ and months’ names, the currency, the date format and so on. They can of course include copies as well. The content of these scripts can be accessed via the $locale service. We can get these files by installing the whole i18n module (via bower or npm) or, if we are interested in just few of them, we can download them from the Github repository here: https://github.com/angular/bower-angular-i18n.

Here is our app’s code:

angular.module("myApp.directives", []);
angular.module("myApp", [
    "myApp.directives",
    "ngLocale"
]);

angular.module("myApp.directives").directive("myDirective", ["$locale", function ($locale) {
    return {
        restrict: "E",
        replace: true,
        template:
            "<div>" +
                "<h2>myDirective</h2>" +
                "<h3>{{labels.title}}</h3>" +
            "</div>",
        controller: ["$scope", function ($scope) {
            $scope.labels = $locale.labels;
        }]
    };
}]);

$locale.labels is a custom object that we created inside the i18n script for our copies.

Here is the output:

As expected we have the right translations.


Adding Dynamic Templates

To be able to use translated copies is a good beginning, and the i18n scripts can be used for any kind of string, not just copies, that could be different from country to country, including URIs and any other kind of configuration.

But this is often not enough. For instance some country could require a different HTML. Here is a situation where the Dynamic Templates are useful. In this example we want to use them to display the countries’ flags:

angular.module("myApp.directives").directive("myDirective", ["$locale", function ($locale) {
    return {
        restrict: "E",
        replace: true,
        template: function () {
            if ($locale.localeID == "en_GB") {
 
                //UK-specific
                return "<div>" +
                    "<h2>myDirective</h2>" +
                    "<img src='images/Flag_UK.png' width='200'></img>" +
                    "<h3>{{labels.title}}</h3>" +
                "</div>";
            } else {
 
                //IT-specific (default)
                return "<div>" +
                    "<h2>myDirective</h2>" +
                    "<img src='images/Flag_IT.png' width='200'></img>" +
                    "<h3>{{labels.title}}</h3>" +
                "</div>";
            }
        },
        controller: ["$scope", function ($scope) {
            $scope.labels = $locale.labels;
        }]
    };
}]);

Here is the output:

As expected the template is dynamically selected depending on the i18n localeID.


Adding Decorated Services

Another thing that is often different from country to country is data and logic. Since in a well designed application data is coming from services and logic is implemented in services (not in directives, that should just contain the view-model’s logic) it would be useful if we could get a country-specific instance (if exists) of the services we inject.

In this example we want to use a service to get the countries’ capital name. We’ll use Decorators to return the right instance depending on the i18n localeID. In this way the complexity coming from the multi-language scenario will be transparent for our directive’s logic.

angular.module("myApp.directives", []);
angular.module("myApp.services", []);
angular.module("myApp", [
    "myApp.directives",
    "myApp.services",
    "ngLocale"
])
.config(["$provide", function ($provide) {
 
    //If is there a locale-specific version it will
    //get that, otherwise it will get the default one
    $provide.decorator("capitalService", ["$delegate", "$injector", "$locale",
        function ($delegate, $injector, $locale) {
            if ($injector.has("capitalService_" + $locale.localeID)) {
                return $injector.get("capitalService_" + $locale.localeID);
            }
            return $delegate;
        }
    ]);
}]);


//----------
// SERVICES
//----------
 
//UK-specific
angular.module("myApp.services").service("capitalService_en_GB", [function () {
    var _self = this;
 
    _self.getCapital = function () {
        return "London";
    };
}]);
 
//IT-specific (default)
angular.module("myApp.services").service("capitalService", [function () {
    var _self = this;
 
    _self.getCapital = function () {
        return "Roma";
    };
}]);


//------------
// DIRECTIVES
//------------
 
angular.module("myApp.directives").directive("myDirective", ["$locale", function ($locale) {
    return {
        restrict: "E",
        replace: true,
        template: function () {
            if ($locale.localeID == "en_GB") {
 
                //UK-specific
                return "<div>" +
                    "<h2>myDirective</h2>" +
                    "<img src='images/Flag_UK.png' width='200'></img>" +
                    "<h3>{{labels.title}}</h3>" +
                    "<p>{{labels.capitalIs}} {{capital}}</p>" +
                "</div>";
            } else {
 
                //IT-specific (default)
                return "<div>" +
                    "<h2>myDirective</h2>" +
                    "<img src='images/Flag_IT.png' width='200'></img>" +
                    "<h3>{{labels.title}}</h3>" +
                    "<p>{{labels.capitalIs}} {{capital}}</p>" +
                "</div>";
            }
        },
        controller: ["$scope", "capitalService", function ($scope, capitalService) {
            $scope.labels = $locale.labels;
            $scope.capital = capitalService.getCapital();
        }]
    };
}]);

Here is the output:

As expected, depending on the i18n localeID, i’m getting the right instance of capitalService.


Scanning for Decorators

Decorators are great, but they have to be setup one by one. This is going to produce tons of parboiled code in an enterprise project, definitely not what we want to have to deal with. To solve this problem we would like to be able to auto-generate all of this configuration by scanning for the components and detecting which of them needs a decoration. In order for this to work we’ll follow the convention that we’ll append the i18n localeID to the components that are specific for a language.

The following algorithm works with both services and directives. In this example we’ll use it to decorate the capitalService as before and to replace the Dynamic Template with a decorated directive (decorating a directive should be avoided, only the template should dynamically change. If a directive’s logic needs to change from country to country the variable part should be moved out into a decorated service).

The algorithm has been implemented as an Angular provider, here is the code:

angular.module("myApp.directives", []);
angular.module("myApp.services", []);
angular.module("myApp", [
    "myApp.directives",
    "myApp.services",
    "ngLocale"
])
 
//For providers we must add "Provider" at the end!
.config(["decoratorsProvider", function (decoratorsProvider) {
     decoratorsProvider.bootstrapDecorators(["myApp.directives", "myApp.services"], ["en_GB"]);
}]);
 
 
//------------
// DIRECTIVES
//------------
 
//UK-specific
angular.module("myApp.directives").directive("myDirective_en_GB", ["$locale", function ($locale) {
    return {
        restrict: "E",
        replace: true,
        template:
            "<div>" +
                "<h2>myDirective_en_GB</h2>" +
                "<img src='images/Flag_UK.png' width='200'></img>" +
                "<h3>{{labels.title}}</h3>" +
                "<p>{{labels.capitalIs}} {{capital}}</p>" +
            "</div>",
        controller: ["$scope", "capitalService", function ($scope, capitalService) {
            $scope.labels = $locale.labels;
            $scope.capital = capitalService.getCapital();
        }]
    };
}]);
 
//IT-specific (default)
angular.module("myApp.directives").directive("myDirective", ["$locale", function ($locale) {
    return {
        restrict: "E",
        replace: true,
        template:
            "<div>" +
                "<h2>myDirective</h2>" +
                "<img src='images/Flag_IT.png' width='200'></img>" +
                "<h3>{{labels.title}}</h3>" +
                "<p>{{labels.capitalIs}} {{capital}}</p>" +
            "</div>",
        controller: ["$scope", "capitalService", function ($scope, capitalService) {
            $scope.labels = $locale.labels;
            $scope.capital = capitalService.getCapital();
        }]
    };
}]);
 
 
//----------
// SERVICES
//----------
 
//UK-specific
angular.module("myApp.services").service("capitalService_en_GB", [function () {
    var _self = this;
 
    _self.getCapital = function () {
        return "London";
    };
}]);
 
//IT-specific (default)
angular.module("myApp.services").service("capitalService", [function () {
    var _self = this;
 
    _self.getCapital = function () {
        return "Roma";
    };
}]);
 
 
//-----------
// PROVIDERS
//-----------
 
angular.module("myApp").provider("decorators", ["$provide", function ($provide) {
    var _self = this;
 
    _self.bootstrapDecorators = function (modulesNames, localeIDs) {
        scanDecorators().forEach(createDecorator);
 
        function scanDecorators() {
            var decorators = [];
            modulesNames.forEach(function (moduleName) {
                var module = angular.module(moduleName);
                module._invokeQueue.forEach(function (element) {
                    var elementType = element[1];
                    var elementName = element[2][0];
 
                    localeIDs.forEach(function (localeID) {
                        if (elementName.endsWith("_" + localeID)) {
                            var originalName = elementName.replace("_" + localeID, "");
                            if (!decorators.some(function (decorator) {
                                return decorator.originalName == originalName;
                            })) {
                                decorators.push({
                                    originalName: originalName,
                                    elementType: elementType
                                });
                            }
                        }
                    });
                });
            });
            return decorators;
        };
 
        function createDecorator(decorator) {
 
            //For directives we must add "Directive" at the end!
            var originalName = decorator.elementType == "directive"
                ? decorator.originalName + "Directive"
                : decorator.originalName;
 
            $provide.decorator(originalName, ["$delegate", "$injector", "$locale",
                function ($delegate, $injector, $locale) {
                    var decoratedName = decorator.originalName + "_" + $locale.localeID;
 
                    //For directives we must add "Directive" at the end!
                    decoratedName = decorator.elementType == "directive"
                        ? decoratedName + "Directive"
                        : decoratedName;
 
                    if ($injector.has(decoratedName)) {
                        return $injector.get(decoratedName);
                    }
                    return $delegate;
                }
            ]);
        }
    };
 
    _self.$get = angular.noop;
}]);

Here is the output:


Detecting Dynamic Templates

Now that we are able to automatically setup decorators based on a naming convention it would be cool to be able to do the same with Dynamic Templates. In this example we’ll use a service to automatically get the localized version of a template based on the current localeID. In order for this to work we need to have all the templates available into the Angular template cache, so we’ll be able to scan for them. Downloading all the templates in advance is anyway a good practice, since it gives a more reactive user experience.

To simplify the example we removed the services and the decorators, so we’ll be able to better focus on the new concept. Here is the code:

angular.module("myApp.templates", []);
angular.module("myApp.directives", []);
angular.module("myApp.services", []);
angular.module("myApp", [
    "myApp.templates",
    "myApp.directives",
    "myApp.services",
    "ngLocale"
]);
 
 
//-----------
// TEMPLATES
//-----------
 
//We pre-load all the templates into the template cache. It's possible
//to auto-generate this from the real .html files, for instance via Grunt.
angular.module("myApp.templates").run(["$templateCache", function ($templateCache) {
 
    //UK-specific
    $templateCache.put("/myApp/templates/myDirective_en_GB.html",
        "<div>" +
            "<h2>myDirective</h2>" +
            "<img src='images/Flag_UK.png' width='200'></img>" +
            "<h3>{{labels.title}}</h3>" +
        "</div>");
 
    //IT-specific (default)
    $templateCache.put("/myApp/templates/myDirective.html",
        "<div>" +
            "<h2>myDirective</h2>" +
            "<img src='images/Flag_IT.png' width='200'></img>" +
            "<h3>{{labels.title}}</h3>" +
        "</div>");
}]);
 
 
//------------
// DIRECTIVES
//------------
 
angular.module("myApp.directives").directive("myDirective",
    ["$locale", "templatesManager", function ($locale, templatesManager) {
        return {
            restrict: "E",
            replace: true,
            templateUrl: templatesManager.getLocalizedTemplate("/myApp/templates/myDirective.html"),
            controller: ["$scope", function ($scope) {
                $scope.labels = $locale.labels;
            }]
        };
    }]);
 
 
//----------
// SERVICES
//----------
 
angular.module("myApp.services").service("templatesManager",
    ["$templateCache", "$locale", function ($templateCache, $locale) {
        var _self = this;
 
        _self.getLocalizedTemplate = function (templateName) {
            var localizedName = templateName.replace(".html", "_" + $locale.localeID + ".html");
 
            if (!!$templateCache.get(localizedName)) {
                return localizedName;
            } else {
                return templateName;
            }
        };
    }]);
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s