AngularJs logicless templates : limitez la logique métier au sein de vos templates

Ceux qui ont utilisé un moteur de template logicless comme Mustache ou Handlebars savent immédiatement de quoi je parle. Un template doit contenir le minimum possible de logique métier. Et avec Mustachejs ou Handlebarsjs, le concept est poussé loin puisque c’est tout simplement impossible.

Logic-less templates – Semantic templates

J’ai découvert Handlebars sur mon premier projet EmberJs et son utilisation m’a paru bien déroutante au début. Voici un exemple de template Handlebars :

{{#if person.isAdmin}}
Welcome in admin area, <strong>{{person.firstName}} {{person.lastName}}</strong>!
{{/if}}

Jusque là, rien d’anormal ! Comment faire maintenant si je veux modifier un peu ma condition pour afficher ce bloc si la personne est Admin ou si elle est Super Admin ? L’extrait de code suivant semble tout indiqué :

{{#if person.isAdmin or person.isSuperAdmin}}
Welcome in admin area, <strong>{{person.firstName}} {{person.lastName}}</strong>!
{{/if}}

Pourtant c’est impossible ! Nous sommes dans un moteur de template logicless et nous n’avons pas la possibilité d’utiliser ce type de condition complexe.
Nous sommes dès lors obligés d’extraire la logique de notre template et d’injecter une condition simple, calculée en dehors. Exemple :

{{#if isAdminAreaAuthorized}}
Welcome in admin area, <strong>{{person.firstName}} {{person.lastName}}</strong>!
{{/if}}

Cela semble très contraignant au début mais on s’y fait finalement vite.
Ce type de template est en revanche parfaitement possible au sein d’une application AngularJs :

<p ng-show="person.isAdmin || person.isSuperAdmin">
    Welcome in admin area, <strong>{{person.firstName}} {{person.lastName}}</strong>!
</p>

Sur l’extrait de code précédent, la logique implémentée au sein du template reste assez concise. Mais je suis trop souvent tombé sur ce genre de templates sur des applications AngularJS :

<p ng-show="!isWorkInProgress && currentItem.price > 1000 && (user.id == currentItem.authorId || user.isAdmin || user.isSuperAdmin)">
  {{ currentItem.title }}
</p>

<p>Un paragraphe qui s'affiche toujours</p>

<p ng-show="!isWorkInProgress && currentItem.price > 1000  && (user.id == currentItem.authorId || user.isAdmin || user.isSuperAdmin)">
  {{ currentItem.description }}
</p>

Problèmes

Nous avons ici plusieurs problèmes :

  • Les conditions sont assez obscures et difficiles à comprendre
  • Les conditions sont répétées en plusieurs endroits au sein du même template
  • Mélange de logique métier et de logique d’affichage
  • La logique business est probablement dispatchée en différents endroits du code (des controllers, des services, des templates, …)

Difficile pour un intégrateur de travailler sur un template de ce type. Et quid du changement de condition pour l’affichage de nos blocs ? Nous sommes bons pour un chercher / remplacer douloureux.

Les moteurs de templates logic-less tels que que Mustache ou Handlebars sont loin de faire l’unanimité parmi les développeurs. En cause, leur aspect trop contraignant et l’obligation d’extraire la logique purement de présentation en même temps que la logique business. C’est vrai que finalement, la condition isWorkInProgress est liée uniquement à l’affichage et peut trouver légitimement sa place au sein du template.
Néanmoins, ces moteurs de templates logicless ont le mérite de forcer le développeur à extraire la logique business du template pour la centraliser au sein du controller ou de services dédiés.

Pour avoir le point de vue de personnes se positionnant contre les moteurs de templates logic-less, je vous conseille la lecture de The Case Against Logic-less Templates et de cet autre article dans la même veine : Cult of logicless templates.

AngularJS : extraction de la logique vers les controllers et services

En ce qui concerne vos applications AngularJS, de même qu’il est important de simplifier au maximum les controllers, il est important de limiter le plus possible la logique métier implémentée au sein des templates.

Dans les faits, le template précédent pourrait être repensé de la sorte :

<p ng-show="isItemViewableByUser(currentItem, user)">
  {{ currentItem.title }}
</p>

<p>Un paragraphe qui s'affiche toujours</p>

<p ng-show="isItemViewableByUser(currentItem, user)">
  {{ currentItem.description }}
</p>

Notre template est plus clair. Le nommage sémantique de la fonction qui conditionne l’affichage (isItemViewableByUser) rend le code compréhensible immédiatement. De plus, si la logique métier qui conditionne l’affichage change, le template n’a plus à changer.

Nous injectons cette logique dans le scope via le controller associé. Et pour ne pas faire l’erreur de remonter telle quelle une condition métier au sein du controller, n’oubliez pas d’extraire à son tour la logique métier de ce dernier vers un service dédié, rendant le tout plus facile à tester unitairement, plus facile à comprendre et donc plus facile à maintenir :

function MyCtrl($scope, UserService) {
    $scope.isWorkInProgress = false;
    // [...]
    $scope.isItemViewableByUser = function(item, user) {
        // Au choix, logique remontée au sein du Model
        // cf http://blog.overnetcity.com/2014/03/15/angularjs-models-donnees-model-data
        return !isWorkInProgress && user.canSeeItem(item);
        // ou au sein d'un service dédié
        return !isWorkInProgress && UserService.doesUserCanSeeItem(user, item);
    };
}

Pour aller plus loin

Quelques ressources pour aller plus loin :

AngularJs : Où sont mes models ?

Lorsqu’on commence à développer une nouvelle application avec le Framework Angular, il est très aisé d’obtenir un résultat rapide et fonctionnel. C’est l’un des atouts majeurs d’AngularJs vis-à-vis de ses concurrents.

Même si AngularJs semble plus proche du pattern MVVM (MVW selon Igor Mina pour ModelViewWhatever) que MVC, on fait vite l’analogie avec les frameworks MVC backends classiques que la plupart des développeurs ont l’habitude d’utiliser (Symfony, Spring, …).

The $scope object could be considered the ViewModel that is being decorated by a function that we call a Controller.

MVC. Modèle, Vue, Contrôleur… Mais au fait, où est le modèle sur Angular (angular models) ? Lorsque j’ai découvert AngularJs, ayant eu auparavant l’occasion de développer quelques grosses applications avec EmberJs, je m’attendais à hériter d’un type NgModel quelconque. Mais la philosophie Angular est radicalement différente de celle d’Ember (http://emberjs.com/guides/models) à ce sujet :

Unlike other frameworks, there is no need to inherit from proprietary types in order to wrap the model in accessors methods. Angular models are plain old JavaScript objects. This makes your code easy to test, maintain, reuse, and again free from boilerplate.

Donc en gros, un modèle, c’est un objet javascript tout simple. Voici donc un article de blog :

{
  title: "Article 1",
  description: "Gouzigouza",
  content: "The content"
}

Cela tombe bien car cela ressemble étrangement à ce que je pourrais récupérer directement depuis mon backend via une API REST en faisant quelque chose de ce genre via $http ou grâce à $resource :

function MonController($http) {

  $http.get('url/vers/mon/api/articles/1').then(function(articleData) {
    // Injection dans le scope pour afficher mon article dans une vue
    $scope.article = articleData.data;

  }).then(null, function(rejection) {
    // manage error
  });
}

Notez que la logique de récupération d’un article ici implémentée dans un controller aurait tout intérêt à être elle-même extraite dans un service dédié :

angular.module('monModule').factory('ArticlesService', function($http) {

  return {
    getArticle: function(id) {

      return $http.get('url/vers/mon/api/articles/' + id).then(function(articleData) {
        return articleData.data;
      });
    }
  }
}

Un problème simple

Tout cela est très bien mais hormis dans un contexte de consultation simple de la donnée, il est peu probable qu’il n’y ait pas un peu de traitement à faire sur cet objet pour l’utiliser. Par exemple, si je veux gérer une description courte à afficher dans ma vue.

Logique dans le controller ?

Une première approche un peu naïve serait de faire quelque chose du genre :

function MonController($http) {
  // ...
  $scope.shortDescription = function(description) {
    return description.substring(0, 100);
  };
}

Pas terrible de laisser cette logique dans notre controller. Face à ce problème, différents points de vues s’affrontent et il est parfois difficile de s’y retrouver.

La logique au sein du modèle

Une première solution serait peut-être d’implémenter notre logique justement au niveau de notre modèle en faisant quelque chose du style :

function Article (data) {

  this.title = data.title;
  this.description = data.description;
  // ...
}

Article.getShortDescription = function() {
  return this.description.substring(0, 100);
}

Ou plus simplement avec un angular.extend par exemple :

function Article (data) {

  angular.extend(this, {
    title: "",
    description: "",

    getShortDescription: function() {
      return this.description.substring(0, 100);
    }
  }, data);
}

Pour rendre ce bout de code exploitable et injectable proprement, il suffit d’utiliser une nouvelle Factory :

angular.module('monModule').factory('Article', function() {

  var Article = function (data) {
    angular.extend(this, {
      title: "",
      description: "",

      getShortDescription: function() {
        return this.description.substring(0, 100);
      }
    }, data);
  }

  return Article;
}

Il nous suffit alors de modifier notre service de récupération d’un article pour faire quelque chose de ce type :

angular.module('monModule').factory('ArticlesService', function($http, Article) {

  return {
    getArticle: function(id) {

      return $http.get('url/vers/mon/api/articles/' + id).then(function(articleData) {
        return new Article(articleData.data);
      });
    }
  }
}

Et depuis un controller par exemple :

angular.module('monModule').controller('ArticleController', function(ArticlesService) {

  ArticlesService.getArticle(100).then(function(article) {
    $scope.shortDescription = article.getShortDescription();
  }).then(null, function(article) {
    // handle error
  };
}

Une autre approche : les filtres Angular

Certains militent contre le besoin de créer des services spécifiques pour représenter un modèle. L’argument qui prévaut est le suivant :

Your model lives on the server

Grosso modo, aucune logique business ne doit être implémentée au sein d’un projet Angular et la seule logique qui appartient au front, est la logique de présentation.

L’auteur de cet article préfère traiter notre problème via l’utilisation d’un filtre Angular.

A filter formats the value of an expression for display to the user. They can be used in view templates, controllers or services and it is easy to define your own filter.

Pour répondre différemment à notre problème on peut alors implémenter un filtre de la manière suivante :

angular.module('monModule', []).filter('summerizeArticle', function() {

  return function(article) {
    return article.description.length > 100 ? article.description.substring(0, 100) + "..." : article.description;
  };
});

Et si la problématique est suffisamment générique pour être valable hors du contexte d’un article, on peut alors modifier notre filtre de la sorte :

angular.module('monModule', []).filter('summerize', function() {

  return function(input) {
    return input.length > 100 ? input.substring(0, 100) + "..." : input;
  };
});

Quelques ressources intéressantes pour aller plus loin :

EmberJs – De l’intérêt d’utiliser les Namespaces Ember ou comment connaître le nom de la classe d’une instance d’Ember.Object

Certaines parties de Ember/Ember-Data sont assez obscures pour les nouveaux venus. Parfois, les comportements du framework peuvent paraître relever de la magie aussi longtemps qu’on ne se plonge pas dans le code pour en isoler certains mécanismes.

C’était pour moi le cas concernant les requêtes émises par EmberData en fonction des modèles définit dans votre application.

Considérons le bout de code suivant :

MyApp.User = DS.Model.extend({
    firstname: DS.attr('string'),
    lastname: DS.attr('string')
    // ...
});

MyApp.User.find();

Lorsqu’on exécute un find sur ce modèle, EmberData est suffisamment intelligent pour lancer une requête GET sur /url/de/votre/api/user.

Mais comment ?

Quand on décortique l’adapter, on constate qu’au moment de construire la requête Ajax, ce dernier fait appel à la méthode rootForType du Serializer :

rootForType: function(type) {
    var typeString = type.toString();
    // [...]
    var parts = typeString.split(".");
    var name = parts[parts.length - 1];
    return name.replace(/([A-Z])/g, '_$1').toLowerCase().slice(1);
}

C’est en fait tout bête, le Serializer fait un toString sur notre classe du modèle.

MyApp.User.toString() // => MyApp.User

Et pour obtenir le même résultat depuis une instance, EmberData utilise tout simplement l’attribut constructor de l’objet :

MyApp.User.find(someId).then(function(someUser) {
    someUser.contructor.toString(); // => MyApp.User
});

On continue néanmoins à nager en pleine magie, comment Ember est-il capable d’afficher ce résultat sur un toString() d’un objet quelconque.
Ember redéfinie de manière intelligente la méthode toString de la manière suivante :

Ember.Mixin.prototype.toString = function() {
  if (!Ember.BOOTED && !this[NAME_KEY]) {
    processAllNamespaces();
  }

  var ret;
  if (this[NAME_KEY]) {
    ret = this[NAME_KEY];
  } else {
    var str = superClassString(this);
    if (str) {
      ret = "(subclass of " + str + ")";
    } else {
      ret = "(unknown mixin)";
    }
    this.toString = makeToString(ret);
  }

  return ret;
};

En lisant ce bout de code, on constate qu’un traitement particulier est fait à l’initialisation de l’application Ember pour parser tous les Namespaces.
Ember parcourt de manière récursive l’ensemble des Namespaces déclarés et surcharge pour chacune des classes appartenant à ces namespaces la méthode toString, de manière à renvoyer un path de la forme MyNameSpace.MySubNameSpace.MyAmazingClass.

Tout simplement !

Et je dois dire que ce comportement est bien pratique pour calculer certaines propriétés de manière générique, en se basant justement sur le nom de notre classe via la méthode toString.

Exemple :

// Au sein d'une classe héritant de Ember.Object et appartenant à un Namespace quelconque
myPropertyWhichReallyNeedMyClassname: function() {
    var parts = this.constructor.toString().split('.'),
        name = parts[parts.length - 1];

    return 'Something' + name;
}.property(),

Plus d’infos ici : http://www.emberist.com/2012/04/09/naming-conventions.html
Et dans le code évidemment : https://github.com/emberjs/ember.js/blob/master/packages/ember-runtime/lib/system/namespace.js

EmberJs – Construire une application autour de panels et d’onglets

Le router de emberjs se base massivement sur des conventions de nommage qui rendent ultra rapide le développement d’une application, notamment si celle-ci est basée sur un workflow standard de type CRUD.
Je liste mes entités, je peux voir une entité, je peux éditer une entité…
Exemples d’URLs de ce type :

  • /posts
  • /posts/1/show
  • /posts/1/edit
  • /authors
  • /authors/1/show
  • /authors/1/edit

Dans ce genre de cas, il n’est presque jamais nécessaire de surcharger les routes générées en mémoire par ember.

En revanche, quand une application devient un peu plus complexe dans sa structure, il devient indispensable de définir des routes spécifiques.

Aujourd’hui, je vais présenter une approche permettant de construire une application emberjs autour d’un layout découpé en panneaux (panels), chacun de ces panneaux étant capable d’afficher un ou plusieurs onglets. Evidemment, en ayant comme objectif que l’état de ces différents onglets soit géré par le router pour pouvoir le conserver via l’URL sur un reloading de la page.

Ci-dessous un petit schéma pour expliciter visuellement le contexte :

Application ember panes with tabs

Commençons par définir notre application et le layout associé :

// main.js
App = Ember.Application.create({});
// index.html
<script type="text/x-handlebars" data-template-name="application">
  <h1>This is a sample app</h1>
  {{outlet leftPanel}}
  {{outlet rightPanel}}
</script>

Pour savoir en permanence quels sont les onglets sélectionnés dans chacun des panneaux de gauche et de droite, il nous faut les stocker quelque part. L’endroit idéal est le contrôleur d’application qui se trouve hiérarchiquement au dessus de nos deux panels.
On lui ajoute donc deux propriétés leftTab et rightTab auxquelles on associe les onglets à afficher par défaut.
Ensuite, il nous faut définir les contrôleurs pour le panneau de gauche et le panneau de droite. Ils restent vides dans un premier temps mais ce sont eux qui s’occuperont de notifier le router lorsque l’utilisateur souhaite changer l’un ou l’autre des onglets sélectionnés.

App.ApplicationController = Ember.Controller.extend({
    leftTab: 'leftPanelTab1',
    rightTab: 'rightPanelTab1'
});
 
App.LeftPanelController = Ember.Controller.extend({
    // ...
});
 
App.RightPanelController = Ember.Controller.extend({
    // ...
});

Ajoutons les templates associés à nos panneaux. Ces derniers contiennent les liens vers les différents onglets et un outlet dans lequel notre router va injecter l’onglet sélectionné :

<script type="text/x-handlebars" data-template-name="leftPanel">
<div>Left panel
    <a href="#" {{action selectTab "leftPanelTab1"}}>Left panel tab 1</a>
    <a href="#" {{action selectTab "leftPanelTab2"}}>Left panel tab 2</a>
    {{outlet}}
</div>
</script>

<script type="text/x-handlebars" data-template-name="rightPane">
<div>Right panel
    <a href="#" {{action selectTab "rightPanelTab1"}}>Right panel tab 1</a>
    <a href="#" {{action selectTab "rightPanelTab2"}}>Right panel tab 2</a>
    {{outlet}}
</div>
</script>

Pour injecter nos panneaux au sein de notre layout, il est nécessaire d’implémenter la route ApplicationRoute :

App.ApplicationRoute = Ember.Route.extend({
    renderTemplate: function() {
        this.render();
        this.render('leftPanel', {
            outlet: 'leftPanel',
            into: 'application'
        });
 
        this.render('rightPanel', {
            outlet: 'rightPanel',
            into: 'application'
        });
    }
});

Pour notifier notre application que l’utilisateur souhaite changer l’onglet de l’un ou l’autre des panneaux, on utilise ici un helper action pour déclencher une action au niveau de nos contrôleurs, en lui spécifiant en paramètre l’onglet cliqué.

Il nous faut donc maintenant intercepter ces actions dans les contrôleurs et remonter l’information au niveau de notre router en les modifiant ainsi :

App.LeftPanelController = Ember.Controller.extend({
    selectTab: function(tab) {
        this.send('leftTabChanged', tab);
    }
});
 
App.RightPanelController = Ember.Controller.extend({
    selectTab: function(tab) {
        this.send('rightTabChanged', tab);
    }
});

Pour représenter l’état de nos panneaux (quels onglets sont affichés), nous allons implémenter une route prenant en paramètres l’onglet à afficher dans chaque zone.

App.PanelsRoute = Ember.Route.extend({
    serialize: function(params, paramNames) {
        return params;
    },
    renderTemplate: function(controller, params) {
 
        this.render(params.leftTab, {
            into: 'leftPanel'
        });
 
 
        this.render(params.rightTab, {
            into: 'rightPanel'
        });
    }
});

Et pour obtenir une belle URL associée, nous pouvons définir le mapping suivant :

App.Router.map(function() {
    this.resource('panels', { path: '/:leftTab/:rightTab' });
});

Enfin, pour terminer et lier l’ensemble, nous allons modifier notre ApplicationRoute afin d’y gérer les évènements associés aux changements d’onglets.
Quand un évènement nous notifiant du changement d’onglet dans le panneau de gauche remonte jusqu’au router, on redirige vers la route panels en lui injectant les nouveaux onglets sélectionnés.

App.ApplicationRoute = Ember.Route.extend({
    events: {
        leftTabChanged: function(tab) {
            this.controllerFor('application').set('leftTab', tab);
            this.transitionTo('panels', {
                leftTab: tab,
                rightTab: this.controllerFor('application').get('rightTab')
            });
 
        },
        rightTabChanged: function(tab) {
            this.controllerFor('application').set('rightTab', tab);
            this.transitionTo('panels', {
                leftTab:this.controllerFor('application').get('leftTab'),
                rightTab:tab
            });
        }
    },

    renderTemplates: // ...
}

Une autre solution pour répondre à ce même problème serait d’injecter dans nos controlers de panels l’onglet sélectionné dans le panneau opposé. On peut dans ce cas simplifier le code et utiliser directement des helpers linkTo. La solution présentée ici offre néanmoins une grande souplesse pour opérer des traitements intermédiaires lors de la selection par l’utilisateur d’un onglet dans un ou l’autre des panneaux.

Ci-dessous le code complet de cet exemple :

App = Ember.Application.create({});

App.Router.map(function() {
    this.resource('panels', { path: '/:leftTab/:rightTab' });
});

App.ApplicationRoute = Ember.Route.extend({
    events: {
        leftTabChanged: function(tab) {
            this.controllerFor('application').set('leftTab', tab);
            this.transitionTo('panels', {
                leftTab: tab,
                rightTab: this.controllerFor('application').get('rightTab')
            });

        },
        rightTabChanged: function(tab) {
            this.controllerFor('application').set('rightTab', tab);
            this.transitionTo('panels', {
                leftTab:this.controllerFor('application').get('leftTab'),
                rightTab:tab
            });
        }
    },

    renderTemplate: function() {
        this.render();
        this.render('leftPanel', {
            outlet: 'leftPanel',
            into: 'application'
        });

        this.render('rightPanel', {
            outlet: 'rightPanel',
            into: 'application'
        });
    }
});

App.IndexRoute = Ember.Route.extend({
    redirect: function() {
        this.transitionTo('panels', {
            leftTab: this.controllerFor('application').get('leftTab'),
            rightTab: this.controllerFor('application').get('rightTab')
        });
    }
});

App.PanelsRoute = Ember.Route.extend({
    serialize: function(params, paramNames) {
        return params;
    },
    renderTemplate: function(controller, params) {

        this.render(params.leftTab, {
            into: 'leftPanel'
        });


        this.render(params.rightTab, {
            into: 'rightPanel'
        });
    }
});

App.ApplicationController = Ember.Controller.extend({
    leftTab: 'leftPanelTab1',
    rightTab: 'rightPanelTab1'
});

App.LeftPanelController = Ember.Controller.extend({
    selectTab: function(tab) {
        this.send('leftTabChanged', tab);
    }
});

App.LeftPanelView = Ember.View.extend({
    classNames: ['pane']
});

App.RightPanelController = Ember.Controller.extend({
    selectTab: function(tab) {
        this.send('rightTabChanged', tab);
    }
});

App.RightPanelView = Ember.View.extend({
    classNames: ['pane']
});
<script type="text/x-handlebars" data-template-name="application">
  <h1>This is a sample app</h1>
  {{outlet leftPanel}}
  {{outlet rightPanel}}
</script>

<script type="text/x-handlebars" data-template-name="leftPanel">
    <a href="#lefttab1" {{action selectTab "leftPanelTab1"}}>Left pane tab 1</a>
    <a href="#lefttab2" {{action selectTab "leftPanelTab2"}}>Left pane tab 2</a>
    <div class="tab-container">{{outlet}}</div>
</script>

<script type="text/x-handlebars" data-template-name="leftPanelTab1">
Left Panel Tab1
</script>

<script type="text/x-handlebars" data-template-name="leftPanelTab2">
Left Panel Tab2
</script>

<script type="text/x-handlebars" data-template-name="rightPanel">
    <a href="#righttab1" {{action selectTab "rightPanelTab1"}}>Right pane tab 1</a>
    <a href="#righttab2" {{action selectTab "rightPanelTab2"}}>Right pane tab 2</a>
    <div class="tab-container">{{outlet}}</div>
</script>

<script type="text/x-handlebars" data-template-name="rightPanelTab1">
    Right Panel Tab1
</script>

<script type="text/x-handlebars" data-template-name="rightPanelTab2">
Right Panel Tab2
</script>

Le résultat est visible sur ce jsfiddle : http://jsfiddle.net/obalais/L78yu
Et le gist associé ci-besoin : https://gist.github.com/bobey/5118200