vendredi 18 avril 2014

Association de malfaiteurs dans GWT

...ou comment gérer une structure composite multiple dans les cellules d'un tableau.

A la façon d'un tutoriel, je vous propose étape par étape comment réaliser un tableau sophistiqué avec GWT et Sencha GXT, les problèmes rencontrés et les solutions adoptées. Un prérequis raisonnable pour la lecture de cet article est une connaissance de GWT, même de niveau débutant ; quant à Sencha GXT ce n'est pas nécessaire : on va découvrir cette librairie ensemble.

Effet souhaité

Plutôt qu'un long discours, l'idée est d'obtenir ceci :

Un casse réussi, c'est un casse bien préparé !

Ce tableau permet d'ajouter plusieurs items (de la liste de gauche) dans une cellule de grille (partie à droite), avec plusieurs mécanismes d'ajouts dont le Drag N Drop.


Les étapes pour réaliser ce tableau :
  1. Définir les objets du modèle
  2. Faire une ébauche du tableau
  3. Gérer le Drag N Drop
  4. Définir les grilles
  5. Gérer la colonne qui contient la structure composite multiple
  6. Traiter les cas problématiques
Si vous voulez essayer de faire tourner le code en même temps que la lecture de cet article, le mieux est de commencer avec le POM du projet,  le module GWT, et la page d'accueil HTML sous Github.

Objets du domaine

On va rester sur un modèle très simple. Notre exemple traite d'une "association de malfaiteurs" : on a d'une part des gangsters qui ont un nom et une aptitude ("Rantanplan" qui n'est pas terrible, "Joe" qui est vraiment brillant etc), et d'autre part les holdups qui ont une cible ("Banque", "Bijouterie", etc) auquel on associe une liste de gangsters :

public class Gangster implements Serializable {

    private int id;
    private Aptitude aptitude;
    private String name;

    private static int COUNTER = 0;

    public Gangster(Aptitude aptitude, String name) {
        this(COUNTER++, aptitude, name);
    }

    public Gangster(int id, Aptitude aptitude, String name) {
        this.id = id;
        this.aptitude = aptitude;
        this.name = name;
    }

    // + getters and setters
}
public enum Aptitude {

    BEST, BETTER, AVERAGE, BAD, WORST

}
public class Holdup implements Serializable {

    private int id;
    private String target;
    private List<Gangster> gangsters = new ArrayList<Gangster>();

    private static int COUNTER = 0;

    public Holdup() {
        this.id = COUNTER++;
    }

    public Holdup(String target) {
        this();
        this.target = target;
    }

    public Holdup(String target, List<Gangster> gangsters) {
        this(target);
        this.gangsters = gangsters;
    }

    // + getters and setters
}

Squelette de la solution

Je me suis inspiré d'un exemple existant :
http://www.sencha.com/examples/#ExamplePlace:gridtogrid

Si vous consultez le code source de cet exemple, vous verrez qu'on a une bonne base de départ. J'ai donc gardé un seul des deux tableaux, adapté le modèle de sorte à prendre mes propres objets métiers (sans proxy et en les chargeant en dur).

Je me retrouve avec un tableau comportant une grille de gangsters et une grille de holdups :

public class Conspiracy implements EntryPoint, IsWidget {

    public void onModuleLoad() {
        RootPanel.get().add(this);
    }

    Label tip = new Label("Select items from the left list                                 to add in the right table.");

    public Widget asWidget() {
        // define a frame with 2 grids
        FramedPanel panel = new FramedPanel();
        panel.setHeadingText("Criminal conspiracy");
        panel.setPixelSize(800, 400);

        ListStore<Gangster> gangsters = getGangsters();
        Grid<Gangster> gangstersGrid = getGangstersGrid(gangsters);
        ListStore<Holdup> holdups = getHoldups();
        Grid<Holdup> holdUpGrid = getHoldupsGrid(holdups);

        // TODO : DnD management

        // draw the layout :
        //      ------------------------------
        //      |  Gangsters   |   Holdups   |
        //      |----------------------------|
        //      |           Tips             |
        //      ------------------------------
        VerticalLayoutContainer vContainer =
            new VerticalLayoutContainer();
        HorizontalLayoutContainer hContainer =
            new HorizontalLayoutContainer();
        hContainer.add(gangstersGrid,
            new HorizontalLayoutData(.3, 1, new Margins(5)));
        hContainer.add(holdUpGrid,
            new HorizontalLayoutData(.7, 1,
                                   new Margins(5, 5, 5, 0)));
        vContainer.add(hContainer,new VerticalLayoutData(1, 1));
        vContainer.add(tip,new VerticalLayoutData(-1, -1));
        panel.add(vContainer);

        return panel;
    }
}

Les méthodes getGangsters() et getHoldups() créent les listes de valeurs ; dans mon exemple, elles sont claquées en dur mais il est évident que dans une application réelle, celles-ci seraient chargées depuis une base de données (ci-dessus j'ai omis le code qui ajoute les items à une liste, ceci étant sans intérêt).

Ensuite getGangstersGrid() et getHoldupsGrid() permettent de créer les grilles qui affichent ces données, c'est à dire de définir les colonnes de la grille. On peut se contenter pour l'instant de fournir le minimum requis comme dans l'exemple d'origine, on reviendra dessus plus tard (partie également omise dans le code ci-dessus).

Implémentation du comportement spécifique du Drag N Drop

  • dans le premier exemple que la cible peut être n'importe quelle zone l'élément qu'on fait glisser : le tableau est trié automatiquement sur ses éléments ;
  • le second tableau n'est pas trié : l'élément ajouté peut être inséré avant ou après une ligne du tableau.
L'ajout se fait entre 2 lignes, il n'est pas possible de Dropper la sélection dans une cellule
Ce n'est hélas ni l'un ni l'autre de ces comportements qui nous convient. Les grilles de Sencha GXT disposent en effet d'un "modèle de sélection" qui permet de sélectionner un ou plusieurs items mais celui-ci ne réagit pas aux événements de survol de la souris, dommage.

Ce qui manque simplement à la grille cible, c'est de pouvoir réagir aux mouvements de la souris, de sorte qu'on puisse identifier la cellule survolée : celle qui deviendra la cible du Drop.

Après une recherche rapide dans le Web, je suis tombé sur le code suivant. C'est exactement ce dont j'ai besoin ; j'ai alors juste créé la classe GridHoverHandler qui n'est qu'une pâle copie de ce code, et à partir duquel je peux capturer la cellule cible du Drop au survol de la souris. Il ne me reste plus qu'à gérer la source du Drag et la cible du Drop :

    Holdup holdupHover; // the target line where to drop items

    public Widget asWidget() {
        // ...

        // track mouse hover on the target grid
        new GridHoverHandler<Holdup>(holdUpGrid) {
            @Override
            public void setObjHover(Holdup object,
                                    boolean isHovered) {
                if (isHovered) {
                    // drop target
                    holdupHover = object;
                } else {
                    holdupHover = null;
                }
            }
        };
        GridDragSource<Gangster> gangstersDragSource =
          new GridDragSource<Gangster>(gangstersGrid) {
            @Override
            // disable MOVE operation
            protected void onDragDrop(DndDropEvent event) {}
        };
        GridDropTarget<Holdup> target =
          new GridDropTarget<Holdup>(holdUpGrid) {
            @Override
            protected void onDragDrop(DndDropEvent event) {
                if (holdupHover != null) {
                    Object o = event.getData();
                    List<Gangster> gangsters =
                                       holdupHover.getGangsters();
                    if (o instanceof List) {
                        List<Gangster> g2 = (List<Gangster>) o;
                        for (Gangster g: g2) {
                            if (! gangsters.contains(g)) {
                                gangsters.add(g);
                            }
                        }
                    }
                    holdUpGrid.getView().refresh(false);
                 }
            }
        };

    }

Les points importants sont :
  • surcharger onDragDrop() de la source, sinon l'élément déplacé sera supprimé d'où il vient ; or on veut les conserver dans la liste de départ et ainsi pouvoir les sélectionner plusieurs fois
  • les données de l'événement DndDropEvent sont une liste de gangsters, étant donné que la grille permet la sélection multiple une fois un élément ajouté à la grille cible, il faut penser à mettre à jour la vue, ce que fait holdUpGrid.getView().refresh(false)
Là, j'apprécie particulièrement le confort donné par Sencha pour gérer le Drag N Drop : on a une classe de base déjà très complète qu'il suffit de surcharger pour implémenter ses propres comportements spécifiques. Et comme on a accès aux sources, il suffit de regarder comment c'est fait pour identifier où ajouter ses propres traitements.

Définition des grilles


Le point suivant concerne les cellules dans les grilles. Commençons par créer la grille des gangsters :
    private Grid<Gangster> getGangstersGrid(
            ListStore<Gangster> gangsters) {
        return new Grid<Gangster>(
            gangsters,
            createGangsterColumnList());
    }

    private ColumnModel<Gangster> createGangsterColumnList() {
        ColumnConfig<Gangster, String> nameCol =
            new ColumnConfig<Gangster, String>(/* TODO */);
        nameCol.setHeader(SafeHtmlUtils.fromString("Gangster"));

        ColumnConfig<Gangster, Aptitude> aptitudeCol =
            new ColumnConfig<Gangster, Aptitude>(/* TODO */);
        aptitudeCol.setHeader(SafeHtmlUtils.fromString("Apt."));
        aptitudeCol.setWidth(40);

        // TODO : render the aptitude cell properly

        List<ColumnConfig<Gangster, ?>> list =
            new ArrayList<ColumnConfig<Gangster, ?>>();
        list.add(nameCol);
        list.add(aptitudeCol);
        return new ColumnModel<Gangster>(list);
    }

Il y a plusieurs concepts qui entrent en jeu ici :
  1. La grille se construit à partir de l'objet ColumnModel qui définit la configuration des colonnes, dont il suffit de fournir la liste
  2. Chaque configuration de colonne est créée avec un ColumnConfig basé d'une part sur le modèle de données <Gangster> et sur le type de donnée traité par la colonne, qui est <String> pour la première colonne et <Aptitude> pour la deuxième. Il faut fournir au constructeur le moyen de fournir les valeurs à partir d'un gangster particulier (c'est ce qui est attendu dans le commentaire /* TODO */).
  3. Il faut trouver un moyen d'afficher l'aptitude avec un dégradé de couleur ; en CSS c'est trivial, en GWT ça n'est pas bien compliqué.

Fort heureusement, Sencha permet de générer un extracteur de valeurs depuis un gangster avec GWT.create(). Il suffit de créer une interface (qui étend PropertyAccess) dont les règles de nommage indiquent comment se fait le mapping ; si les noms de champs de la structure ne conviennent pas, les annotations fournies permettent de les redéfinir. Le code est le suivant :

    interface GangsterProperties extends PropertyAccess<Gangster> {
        @Path("id")
        ModelKeyProvider<Gangster> id();
        @Path("name")
        ValueProvider<Gangster, String> name();
        @Path("aptitude")
        ValueProvider<Gangster, Aptitude> aptitude();

        // Generate the instance that maps the model to the methods
        static GangsterProperties INSTANCE =
            GWT.create(GangsterProperties.class);
    }

Il ne reste qu'à utiliser ces mappings dans la définition des colonnes :

nameCol = new ColumnConfig<Gangster, String>(
    GangsterProperties.INSTANCE.name());
aptitudeCol = new ColumnConfig<Gangster, Aptitude>(
    GangsterProperties.INSTANCE.aptitude());

Concernant la mise en forme de l'aptitude (comme ceci :  ), on veut obtenir un dégradé de couleur qui s'exprime en CSS avec cela :

background: linear-gradient(to left, red, rgba(255,0,0,0))
Les deux dernières valeurs red et rgba(255,0,0,0) dépendent de l'aptitude, j'ai donc simplement ajouté la méthode public abstract String gradient() à l'énumération Aptitude dont chaque valeur retournera un dégradé spécifique.

En GWT, l'insertion de CSS et de HTML ne se fait pas directement : GWT nous force à travailler sur des données saines et prévient ainsi les failles de sécurité comme le XSS et l'injection par CSS. GWT offre des classes adaptées à la production de données HTML et CSS sécurisées. Ce qui donne pour la CSS :

    private SafeStyles getBackgroundStyle(Aptitude aptitude) {
        return new SafeStylesBuilder()
          .trustedNameAndValue("background",
            "linear-gradient(to left, "+aptitude.gradient()+")")
          .toSafeStyles();
    }

...et pour le HTML, GWT permet de générer le HTML sain à partir d'un template :

    public interface AptitudeTemplates extends SafeHtmlTemplates {
        @Template("<div style=\"{0} display: inline-block; 
                       width: 20px ;\">&nbsp;</div>")
        SafeHtml render(SafeStyles bgStyle);

        static AptitudeTemplates INSTANCE =
                     GWT.create(AptitudeTemplates.class);
    }

On voit bien que le HTML est construit avec le CSS précédent (c'est le paramètre {0} qui est passé à la méthode), lui même obtenu de l'aptitude du gangster.

Il reste encore à l'utiliser dans la définition de la colonne de la grille, la personnalisation de la cellule étant basée sur une AbstractCell :

        aptitudeCol.setCell(new AbstractCell<Aptitude>() {
            @Override
            public void render(Cell.Context context,
                    Aptitude value, SafeHtmlBuilder sb) {
                sb.append(AptitudeTemplates.INSTANCE.render(
                    getBackgroundStyle(value)));
            }
        });

Tout cela nous permet d'obtenir une grille de gangsters mise en forme comme souhaité. On peut ensuite agrémenter la grille de quelques comportements supplémentaires :
  • lorsqu'une cellule est sélectionnée, le "tip" en bas du tableau affiche une aide appropriée
  • lorsqu'on double-clic sur un gangster, il est ajouté à tous les holdups sélectionnés dans la seconde grille, ou un avertissement indique qu'aucun holdup n'est sélectionné
(vous pouvez consulter ces détails dans le code source sous Github)

Affichage dans une cellule d'une structure composite multiple


Pour la grille des holdups, le principe est à peu près le même (je vous laisse faire le portage du code des gangsters) ; sauf que dans la grille des holdups on a une cellule qui doit afficher une List<Gangster> et sur chacun des éléments on doit gérer le Drag N Drop et la possibilité de le supprimer, ainsi que le Drag N Drop sur l'ensemble des éléments d'une cellule.

Comme pour les gangsters, la solution est dans l'utilisation d'une AbstractCell à qui est confiée la gestion de l'affichage, et à laquelle on va ajouter la gestion des événements du drop.

Pourquoi ne pas utiliser de widget dans une cellule ? Les frameworks Javascript sont confrontés aux problèmes des contenus riches affichés dans des tableaux de valeur, avec potentiellement un nombre élevé de valeurs diverses à afficher dans de jolis widgets. Utiliser des widgets est pratique mais coûteux en ressources et en performances lorsqu'on en a trop : cela force le navigateur a maintenir en parallèle l'arborescence du DOM avec celle des widgets, avec une surconsommation de mémoire et de gestion des événements à propager à ces widgets.

GWT est soucieux des performances, et considère que dans une grille le seul widget qui vaut la peine d'être géré est la grille, le contenu des cellules devant être produit avec du HTML. Le prix de la performance est qu'il faut gérer soit même les événements au lieu de les confier au widget (puisqu'il n'y en a plus dans la cellule). Fort heureusement, des composants standards existent déjà pour les cellules (bouton, date picker, etc), tous basés sur AbstractCell, et rien n'empêche de les utiliser, de les modifier, de les combiner, etc.

Dans ce qui suit, nous allons nous contenter d'utiliser un rendu HTML, puis après, nous utiliserons l'un de ces composants standards. Tout d'abord le code pour l'affichage :

    @Template("<button>{0} <span style=\"{2} border: 1; "
      + " display: inline-block; width: 20px ;\"> </span>"
      + " {1}$ <img src=\"cross.png\"></button>")
    SafeHtml render(String gangster, int price, SafeStyles bgStyle);
    gangstersCol.setCell(
            new AbstractEventCell<List<Gangster>>() {
        @Override
        public void render(Cell.Context context,
                List<Gangster> value, SafeHtmlBuilder sb) {
            if (value == null) {
                return;
            }
            for (Gangster gangster: value) {
                int price = gangster.getName().length();
                sb.append(
                    GangstersTemplates.INSTANCE.render(
                      gangster.getName(),
                      price,
                      getBackgroundStyle(gangster.getAptitude())));
            }
        }



Pas parfait : il faudrait que lorsque la cellule est pleine de gangsters, les suivants soient visibles. Cherchons comment faire puis corrigeons cela :

            sb.append(
              SafeHtmlUtils.fromSafeConstant(
                "<div style=\"white-space: normal;\">"));
            for (Gangster gangster: value) {
                int price = gangster.getName().length();
                sb.append(
                    GangstersTemplates.INSTANCE.render(
                      gangster.getName(),
                      price,
                      getBackgroundStyle(gangster.getAptitude())));
            }
            sb.append(SafeHtmlUtils.fromSafeConstant("</div>"));

C'est mieux !

Occupons-nous maintenant des événements de la souris à gérer ; l'idée étant de savoir si on a cliqué sur l'icône de fermeture ou sur le bouton pour initier un Drag N Drop ; pour cela, on va ajouter un attribut HTML sur le bouton (data-index) et l'image (data-close-index) :

    @Template("<button data-index=\"{3}\">{0} <span style=\"{2}"
        + " border: 1; display: inline-block; width: 20px ;\">"
        + "&nbsp;</span> {1}$ <img src=\"cross.png\""
        + " data-close-index=\"{3}\"></button>")
    SafeHtml render(String gangster, int price, SafeStyles bgStyle, int index);

...et passer l'index à la méthode render() :

            int i = 0;
            for (...) {
                ...
                GangstersTemplates.INSTANCE.render(
                  gangster.getName(),
                  price,
                  getBackgroundStyle(gangster.getAptitude()),
                  i++));

Le reste concerne la gestion pure des événements avec l'exploitation des attributs data-index et data-close-index ; je déclare une variable qui m'indique quel gangster est survolé par la souris :

    Gangster gangsterHover; // an item to drag in the holdup grid

...puis je remplace AbstractCell par AbstractEventCell pour faciliter la prise en charge des événements. La documentation de AbstractCell donne un exemple de cellule supportant les événements.

    gangstersCol.setCell(
        new AbstractEventCell<List<Gangster>>(
            BrowserEvents.CLICK,
            BrowserEvents.MOUSEDOWN,
            BrowserEvents.MOUSEUP) {

        @Override
        public void onBrowserEvent(
                Cell.Context context,
                com.google.gwt.dom.client.Element parent,
                List<Gangster> value, NativeEvent event,
                ValueUpdater<List<Gangster>> valueUpdater) {
            // Handle the click event.
            if (BrowserEvents.CLICK.equals(event.getType())) {
                // Ignore clicks that occur outside of the
                // outermost element.
                EventTarget eventTarget = event.getEventTarget();
                String index = Element.as(eventTarget)
                    .getAttribute("data-close-index");
                if (index != null && index.length() > 0) {
                    value.remove(Integer.parseInt(index));
                    valueUpdater.update(value);
                }
            }
            if (BrowserEvents.MOUSEDOWN.equals(event.getType())) {
                gangsterHover = null;
                EventTarget eventTarget = event.getEventTarget();
                String index = Element.as(eventTarget)
                    .getAttribute("data-close-index");
                if (index != null && index.length() > 0) {
                    value.remove(Integer.parseInt(index));
                    valueUpdater.update(value);
                } else {
                    Element buttonElem = Element.as(
                        event.getEventTarget())
                    for (; buttonElem != null; 
                            buttonElem = buttonElem
                                            .getParentElement()) {
                        index = buttonElem
                                      .getAttribute("data-index");
                        if (index != null && index.length() > 0) {
                            gangsterHover = value.get(
                                Integer.parseInt(index));
                        }
                    }
                }
            } else if (BrowserEvents.MOUSEUP.equals(
                    event.getType())) {
                gangsterHover = null;
            }
            super.onBrowserEvent(
                context, parent, value, event, valueUpdater);
        }

    }

Dans le code ci-dessus, un clic sur un élément ayant l'attribut data-close-index me permet d'obtenir l'item à supprimer. Si j'obtiens dans la hiérarchie un élément ayant l'attribut data-index en appuyant la souris, c'est sûrement un début de Drag alors je conserve le gangster concerné dans la variable gangsterHover. Je dois aussi ajouter à la grille des holdups le support du Drag :

    GridDragSource<Holdup> holdupDragSource =
            new GridDragSource<Holdup>(holdUpGrid) {

        @Override // disable MOVE operation
        protected void onDragDrop(DndDropEvent event) {}

        @Override
        protected void onDragStart(DndDragStartEvent event) {
            Element r = grid.getView().findRow(
                event.getDragStartEvent().getStartElement()
            ).cast();
            if (r == null) {
                event.setCancelled(true);
                return;
            }
            int size = 0;
            if (gangsterHover == null) {
                super.onDragStart(event); // hold the line
                Object o = event.getData();
                if (o != null) {
                    List<Gangster> gangsters =
                                   new ArrayList<Gangster>();
                    List<Holdup> holdups = (List<Holdup>) o;
                    for (Holdup h: holdups) {
                        for (Gangster g: h.getGangsters()) {
                            if (! gangsters.contains(g)) {
                                gangsters.add(g);
                            }
                        }
                    }
                    size = gangsters.size();
                    if (size > 0) {
                        event.setData(gangsters);
                     } else {
                        event.setData(null);
                        event.setCancelled(true);
                    }
                }
            } else {
                size = 1;
                event.setData(gangsterHover);
            }
            if (! event.isCancelled()) {
                if (getStatusText() == null) {
                    event.getStatusProxy().update(getMessages()
                        .itemsSelected(size));
                } else {
                    event.getStatusProxy().update(
                        Format.substitute(getStatusText(), size));
                }
            }
        }
    };

Rien ne s'invente : c'est vraiment en regardant dans le code de Sencha que j'ai trouvé le moyen d'obtenir l'élément sur lequel on a cliqué. La variable size sert à afficher pendant le Drag le nombre d'items qui ont été sélectionnés.

Il faut aussi penser dans GridDropTarget à traiter non pas seulement les listes de gangsters sélectionnés, mais aussi un gangster tout seul ce qui correspond à la partie du code où gangsterHover != null.

Ze bug with Firefox


C'est assez rare de se prendre une incompatibilité de navigateur avec GWT : dans 99,99% des usages, GWT se charge de gommer les différences entre les navigateurs et tout se passe en général comme le dit le code Java.

Le problème est qu'en descendant au niveau du code HTML on peut se retrouver face à une incompatibilité entre les navigateurs. C'est ce que j'ai constaté dans ce programme : il se trouve que mes cellules sont affichées avec des boutons <button> (comme ceci : ).

Or, Firefox ne gère pas les clics sur les boutons comme attendu : si on clique sur la petite croix (pour supprimer l'item), la cible du clic doit être l'image <img>, c'est ce qui se passe sur Chrome, Safari, etc, mais pas Firefox : la cible du clic dans Firefox est le <button>. J'ai bien essayé d'enregistrer un événement sur l'image en plus du bouton, rien n'y fait ; dès lors, comment faire pour distinguer un clic sur le bouton d'un clic sur l'image ? Avec un bouton, on ne peut pas.

Vous pouvez tester ce défaut en visitant cette page avec 2 navigateurs différents (dont Firefox).
Je dois reconnaître que trouver la cause de ce problème telle qu'exposée ci-dessus n'a pas été immédiat. J'ai d'abord suivi une fausse piste croyant que les événements de souris n'étaient pas capturés correctement (voir ce qui concerne "event bubbling"). Il n'en est rien.

La solution adoptée est très simple : au lieu de recourir au <button> dans le template HTML, je l'ai remplacé par un <div> dont le contenu est construit avec le ButtonCell de Sencha qui lui-même n'est pas basé sur du <button> :

    @Template("<div data-index=\"{3}\">{0} "
        + "<span style=\"{2} border: 1; display: inline-block;"
        + " width: 20px ;\"> </span>"
        + " {1}$ <img src=\"cross.png\" "
        + " data-close-index=\"{3}\"></div>")
    SafeHtml render(String gangster, int price, SafeStyles bgStyle, int index);

La cellule devient :

    ButtonCell<Gangster> button = new ButtonCell<Gangster>();

    @Override
    public void render(Cell.Context context,
            List<Gangster> value, SafeHtmlBuilder sb) {
        if (value == null) {
            return;
        }
        int i = 0;
        SafeHtmlBuilder sb2 = new SafeHtmlBuilder();
        for (Gangster gangster: value) {
            int price = gangster.getName().length();
            sb2.append(
              SafeHtmlUtils.fromSafeConstant(
                "<div style=\"display: inline-block; "
              + "padding: 1px 3px 0;\">"));
            button.setHTML(
              GangstersTemplates.INSTANCE.render(
                gangster.getName(),
                price,
                getBackgroundStyle(
                  gangster.getAptitude()),
                i++));
            button.render(context, gangster, sb2);
            sb2.append(
              SafeHtmlUtils.fromSafeConstant(
                "</div>"));
        }
        sb.append(
          GangstersTemplates.INSTANCE.renderAll(
            sb2.toSafeHtml()));
    }
        @Template("<div style=\"white-space: normal;\">{0}</div>")
        SafeHtml renderAll(SafeHtml content);

Dans le code ci-dessus, la variable button (de type ButtonCell) sert de modèle à chaque Gangster (dans l'itération) : on met dans le contenu du bouton le HTML produit, puis on demande au bouton de se dessiner en HTML. Dans le résultat global, il n'y a plus de <button>.

Le code est presque complet, il suffit d'ajouter quelques arrangements et fonctionnalités comme par exemple supprimer tous les gangsters sélectionnés à l'aide d'un menu contextuel ; ces aspects étant triviaux, je vous laisse consulter le détail dans le code source du projet sous Github.

Conclusion

Faut-il généraliser ? La question est légitime : à la vue du code, on comprend qu'une certaine généricité faciliterait la réutilisabilité. Cependant, avec les templates qui doivent être sur-mesure et la personnalisation qui doit être apportée (car tous les comportements implémentés ici ne sont pas universels mais spécifiques) on peut légitimement se demander si ça vaut le coup. De mon point de vue, non. Parfois, il vaut mieux simplement adapter une bonne recette pour obtenir exactement l'effet souhaité que d'essayer de tordre une usine à gaz compliquée à comprendre et à mettre en œuvre.

Voilà, vous disposez maintenant de tout le nécessaire pour organisez vos prochains braquages avec succès !




Semantic Mismatch
ERR-06 : ArgumentNumberException
DORMIR accept only a single OREILLE
-- See log for details



Aucun commentaire:

Enregistrer un commentaire