Archives par étiquette : AngularJs

Implémenter un « annuler / rétablir » avec AngularJS et le Command Pattern

Edit 18/11/2014

Une démo de l’implém. présentée dans cet article est visible à l’adresse suivante : http://bobey.github.io/angular-undo-redo/

Introduction

Les applications web que nous sommes capables de développer rivalisent depuis quelques années déjà avec leurs équivalents desktop. Il n’y a qu’à voir pour s’en convaincre, la puissance des outils en ligne tels que Google Docs ou Google Spreadsheet qui, notamment depuis leur refonte avec le Material Design de chez Google, n’ont vraiment plus grand chose à envier aux applis natives.

Pour arriver à un tel niveau d’interactivité au sein du navigateur, il a fallut évidemment compter sur Ajax, les librairies JS type prototype ou jQuery, l’émergence d’HTML 5 et, plus récemment, des frameworks frontend MV* type Backbone, EmberJS ou Angular. Avec ces derniers, nous sommes en mesure de développer des applications toujours plus complexes et toujours plus user-friendly. Les composants d’UI réutilisables open-sourcés (directives Angular, Components Ember ou React, …) sont légion sur Github et nous permettent à nous, développeurs, de nous concentrer sur la partie métier de notre application.

Mais si il y a bien une fonctionnalité qu’on a toujours du mal à implémenter au sein de nos applications, c’est le Annuler / Rétablir (ou le Undo / Redo, Cancel / Restore, …).

Les libs undo / redo pour Angular

Pour AngularJS, il existe plusieurs directives et services permettant d’ajouter un annuler / rétablir en observant automatiquement les modifications effectuées sur un objet du scope. Je pense notamment à Angular-Chronicle et Angular History. Ce type d’approches est très pratique lorsque les interactions de l’utilisateur avec l’application se limitent à la MAJ des propriétés de quelques objets de tel ou tel scope. En revanche, elles trouvent vite leur limite dans une application plus complexe où le frontend dialogue par exemple avec le backend via une API REST pour ajouter au supprimer des éléments dans une base de données quelconque.

Le Command Pattern

Pour répondre à cette problématique de manière plus globale qu’en observant automatiquement les changements sur un objet du scope, une des approches les plus connues est l’utilisation d’un dérivé du Pattern Command.

Sur Wikipedia, voilà ce qu’on dit en introduction pour expliquer ce qu’est le command pattern :

In object-oriented programming, the command pattern is a behavioral design pattern in which an object is used to represent and encapsulate all the information needed to call a method at a later time. This information includes the method name, the object that owns the method and values for the method parameters.

Ce pattern est beaucoup utilisé pour implémenter les comportements d’UI (clic sur un bouton d’IHM) mais est aussi particulièrement bien adapté pour modéliser notre fameuse fonctionnalité undo / redo. Toujours pour citer Wikipédia :

If all user actions in a program are implemented as command objects, the program can keep a stack of the most recently executed commands. When the user wants to undo a command, the program simply pops the most recent command object and executes its undo() method.

Command Pattern et AngularJS

Nous allons voir comment architecturer notre application Angular autour du Command Pattern pour rendre possible l’annulation sur la création / suppression des éléments d’un listing d’utilisateurs.

Commençons par décrire le partial associé au listing des utilisateurs. Pour chaque utilisateur, on affiche simplement le username et un bouton de suppression à côté.
On place également un bouton d’ajout d’un nouvel utilisateur au listing, au dessus de ce dernier. On prévoit enfin deux boutons Annuler et Rétablir qui, dans une application réelle auraient plutôt leur place dans une directive spécifique, affichée de manière générique sur le layout global.

Ci-dessous, le code épuré associé :

// src/partials/users/list.html
<h1>Liste utilisateurs</h1>

<a href data-ng-click="listCtrl.undo()">Annuler</a> -
<a href data-ng-click="listCtrl.redo()">Rétablir</a> -
<a href data-ng-click="listCtrl.addUser()">Ajouter</a>

<ul>
    <li ng-repeat="user in listCtrl.users | orderBy:'name'">
        <a href ng-click="listCtrl.removeUser(user)">Supprimer</a> -
        <strong>{{ user.username }}</strong>
    </li>
</ul>

Associé à ce template, un controller Angular « classique » pourrait être implémenté de la manière suivante :

// src/js/controllers/user-list-controller.js
/** @ngInject */
function UserListController(users, User, UserService) {

    this.users = users;

    this.undo = function() {
        // TODO
    };

    this.redo = function() {
        // TODO
    };

    this.removeUser = function(user) {

        UserService.remove(user).then(function() {
            $state.reload();
        });
    };

    this.addUser = function() {

        // Dans un cas réel, ces données proviendraient sans doute de la soumission d'un formulaire quelconque...
        var user = new User({
            username: 'obalais',
            firstname: 'Olivier',
            lastname: 'Balais'
        });

        UserService.save(user).then(function() {
            $state.reload();
        });
    };
}

UserListController.resolve = {

    /** @ngInject */
    users: function(UserService) {
        return UserService.getUsers();
    }
};

angular
    .module('awesomeapp.controllers')
    .controller('UserListController', UserListController);

La logique de récupération / sauvegarde de nos utilisateurs est externalisée dans un service Angular UserService et notre controller ne fait qu’appeler ce service et mettre à jour son scope en fonction des réponses reçues. Le UserService en question peut échanger avec une API REST, via des Web sockets, stocker et retrouver les utilisateurs en mémoire ou dans le localStorage, les détails d’implémentation ne sont pas important…

Il est temps maintenant de modifier notre implémentation en s’inspirant du Command Pattern. Le principe théorique est relativement simple. Comme expliqué sur Wikipédia, il s’agit de représenter toutes les actions utilisateurs annulables sous forme de commandes implémentant la logique d’exécution et la logique d’annulation puis de conserver dans une stack la liste de ces commandes exécutées.

Voici donc une interface possible pour nos commandes :

function Command () {
}

Command.prototype = {
    execute: function() {
        // return promise
    },

    reverse: function() {
        // return promise
    }
};

Pour gérer l’empilage et dépilage de ces commandes, il nous faut définir un service UndoService.
Ce dernier expose trois méthodes executeCommand, undo et redo.
Une implémentation possible est détaillée ci-dessous :

/** @ngInject */
function UndoService ($q) {

    var undos = [],
        redos = [];

    /* jshint -W004 */
    var UndoService = {};

    UndoService.undo = function() {

        if (!undos.length) {

            var deferred = $q.defer();
            deferred.resolve('nothing to undo');

            return deferred.promise;
        }

        var command = undos.pop();

        return command.reverse().then(function(data) {
            redos.push(command);

            return data;
        });
    };

    UndoService.redo = function() {
        if (!redos.length) {
            var deferred = $q.defer();
            deferred.resolve();

            return deferred.promise;
        }

        var command = redos.pop();

        return command.execute().then(function(data) {
            undos.push(command);

            return data;
        });
    };

    UndoService.executeCommand = function(command) {
        return command.execute().then(function(data) {
            undos.push(command);

            return data;
        });
    };

    return UndoService;
}

angular.module('awesomeapp.services')
    .factory('UndoService', UndoService);

Parmi les quelques parti-pris, on note que l’exécution d’une commande réinitialise systématiquement la stack des redos. On constate également que suite à un undo, on vérifie que la promesse a bien été résolue avant d’ajouter la commande dans la stack des redos, et vice-versa.

Pour pouvoir refactorer notre UserListController, nous avons besoin d’implémenter deux commandes : UserCreateCommand et UserDeleteCommand.

Commençons par la commande UserCreateCommand :

// src/js/commands/user-create-command.js
/** @ngInject */
function UserCreateCommand (UserService) {

    /* jshint -W004 */
    function UserCreateCommand (user) {
        this.userToCreate = user;
        this.createdUser = null;
    }

    UserCreateCommand.prototype = {
        execute: function() {
            return UserService.save(this.user).then(function(createdUser) {

                this.createdUser = createdUser;
                return createdUser;
            }.bind(this));
        },

        reverse: function() {
            return UserService.remove(this.createdUser).then(function() {
                this.createdUser = null;
            }.bind(this));
        }
    };

    return UserCreateCommand;
}

angular
    .module('awesomeapp.commands')
    .factory('UserCreateCommand', UserCreateCommand);

Et maintenant la commande UserDeleteCommand :

// src/js/commands/user-delete-command.js
/** @ngInject */
function UserDeleteCommand (UserCreateCommand) {

    /* jshint -W004 */
    function UserDeleteCommand (user) {

        var userToCreate = Angular.copy(user);
        userToCreate.setNew(false);
        this.createCommand = new UserCreateCommand(userToCreate);
        this.createCommand.createdUser = user;
    }

    UserDeleteCommand.prototype = {
        execute: function() {
            return this.createCommand.reverse();
        },

        reverse: function() {
            return this.createCommand.execute();
        }
    };

    return UserDeleteCommand;
}

angular
    .module('awesomeapp.commands')
    .factory('UserDeleteCommand', UserDeleteCommand);

Comme vous pouvez le voir, l’implémentation de cette dernière repose sur la logique dores et déjà définie dans la commande UserCreateCommand, évitant ainsi de dupliquer inutilement du code.

Il ne nous reste alors plus qu’à modifier notre controller pour passer systématiquement par le UndoService :

// src/js/controllers/user-list-controller.js
/** @ngInject */
function UserListController(users, User, UserCreateCommand, UserDeleteCommand, UndoService) {

    this.users = users;

    this.undo = function() {
        UndoService.undo().then(function() {
            $state.reload();
        });
    };

    this.redo = function() {

        UndoService.redo().then(function() {
            $state.reload();
        });
    };

    this.removeUser = function(user) {

        var deleteCommand = new UserDeleteCommand(user);

        UndoService.executeCommand(deleteCommand).then(function() {
            $state.reload();
        });
    };

    this.addUser = function() {

        var user = new User({
            username: 'olivier.balais',
            firstname: 'Olivier',
            lastname: 'Balais'
        });

        var createCommand = new UserCreateCommand(user);

        UndoService.executeCommand(createCommand).then(function() {
            $state.reload();
        });
    };
}

UserListController.resolve = {

    /** @ngInject */
    users: function(UserService) {
        return UserService.getUsers();
    }
};

angular
    .module('awesomeapp.controllers')
    .controller('UserListController', UserListController);

Le mot de la fin

L’architecture de base est en place et nous avons vu deux commandes basiques pour créer et supprimer des utilisateurs.

Toute la difficulté désormais pour le développeur réside dans l’implémentation de ces différentes commandes notamment dans les cas plus complexes où la modification d’une entité a des impacts sur d’autres objets de l’application. Chaque commande doit contenir TOUTE la logique nécessaire à son exécution bien sûr, mais surtout à son reverse.

Vous pouvez retrouver le code de cet article sur ce dépôt Github.

Accéder à un service AngularJs depuis la console

Quand on débogue une application Angular il est bien pratique de pouvoir accéder et utiliser ses services depuis la console.
Si vous n’avez pas violemment injecté tous vos modules et services dans des variables globales, il n’est pas évident au premier abord de les récupérer.

Le bout de code suivant vous permet de récupérer l’instance du DI et donc de récupérer les services de notre application :

// récupération de l'injecteur de dépendances
// dans cet exemple, élément root (`ng-app`) affecté au noeud `html`.
var injector = angular.element("html").injector();

Une fois l’instance du DI récupérée, on peut utiliser la méthode get(...) associée :

// récupération du service GouzigouzaService
// dans cet exemple, élément root (`ng-app`) affecté au noeud `html`.
var monService = angular.element("html").injector().get('GouzigouzaService');

monService.plop(); // exécuter `plop`
monService.value; // accéder à la valeur `value`

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 :