Ember.js ,Think different ! (Etape 4 - les composants)

Les frameworks front-end modernes font la part belle à la création de composants. Ember n’est pas en reste sur ce point et l’objectif de ce post est de montrer comment un composant élaboré peut être réalisé et à quel point il peut simplifier le code à produire.

Cet article fait partie d’une série composée des posts suivants :

  • Introduction à Ember – Rappel rapide sur les mécanismes mis en place par ce framework,
  • Etape 1  – Application simple avec données ‘rack’ retournées directement par le modèle sous forme d’un tableau JSON. Cette étape sera complété par l’utilisation de addons Ember pour rendre l’application plus esthétique et ergonomique.
  • Etape 2 (ce post) –  Passage sur un modèle géré par Ember Data. Etant donné qu’aucun back-office ne sera déployé, nous introduirons le framework de mock Mirage pour intercepter les appels HTTP.
  • Etape 3 – Introduction d’une relation rack —> bottle, et enrichissement des données mockées.
  • Etape 4 (ce post) –  Construction d’un composant graphique permettant une représentation d’un rack et simplifiant les templates d’affichage
  • Etape 5 – Mise en place d’un backoffice construit à l’aide de JHipster. Nous profiterons des capacités de cet outil pour obtenir rapidement un modèle persisté en base, les services Rest permettant de l’administrer ainsi qu’une interface d’administration. Nous verrons au passage quelques problématiques liées à la sécurité.
  • Etape 6 – Mise en place d’un backoffice sur services Liferay et packaging de l’application Ember au sein d’une portlet. Nous avons choisi un backoffice Liferay pour sa facilité à construire un modèle de persistance mais surtout pour bien démontrer l’utilisation potentielle des Adapters et des Serializers, même si un backoffice plus conforme aux standards Rest n’en sera que plus aisé à utiliser.

Le code de cet article est disponible sur mon gitlab dans les branches step_4_x.

Step 4.1 – Affichage graphique du rack porte bouteilles

Un composant offre la possibilité d’utiliser ses propres balises dans le code des templates ainsi que la possibilité de l’utiliser dans de multiples pages.

Ils sont eux même constitués de deux fichiers :

  • Un premier étendant Ember.Component, calqué sur les principes d’un contrôleur ;
  • Un second représentant le template à afficher.  

La seule contrainte imposée par Ember est que le nom des composants doit obligatoirement contenir le caractère ‘-’.

Dans notre cas, nous allons construire un composant permettant la représentation graphique d’un casier à vin (le Rack).

Pour les créer, on utilise à nouveau le générateur d’Ember CLI :

ember g component rack-display

Le composant que nous allons créer va s’appuyer sur une représentation SVG du rack. On construit donc un ‘Path’ dans le pseudo contrôleur qui sera affiché dans le template (je vous laisse réviser vos cours de trigonométrie !) :

// app/components/rack-display.js
import Ember from 'ember';

export default Ember.Component.extend({
  id: 'rack',
  width: 800,
  height: 600,
  nbColumns: 4,
  nbRows: 4,
  tickSize: 8,
  margin: 10,
  spacing: 5,
  style: 1,
  bottles: null,

  frame: {
    frameBackgroundColor: '#DDDDDD',
    frameBorderColor: '#999999',
    frameBorderWidth: 3,
    frameCornerSize: 50,
    frameCornerShadow: true,
 },

 rack: {
    strokeColor: '#3322FF',
    strokeWidth: 3,
 },

 rackPath: Ember.computed('width', 'height', 'nbColumns', 'nbRows', 'margin', 'spacing', 'tickSize', function() {
    let bottles = this.get('bottles');
    for (var i=0; i<bottles.content.length;i++) {
       // pre-fetch data in relationship with rack (but do nothing with it :-())
       let bottle = bottles.objectAt(i);
    }
    let usefulWidth = this.get('width') - 2*this.get('margin');
    let usefulHeight = this.get('height') - 2*this.get('margin');
    let tick = this.get("tickSize");
    let incX = usefulWidth / this.get('nbColumns')/2;
    let incY = usefulHeight / this.get('nbRows')/2;
    let rayon = Math.min(incX - this.get('spacing'), incY);
    let line = "";
    for (var row=0; row < this.get('nbRows'); row++) {
       let cY = this.get('margin')+incY+row*2*incY;
       //line += "M "+this.get('rack.marginX')+" "+offsetY;
       for (var col=0; col < this.get('nbColumns'); col++) {
          let cX = this.get('margin')+incX+col*2*incX;
          line += "M "+(cX-tick)+" "+cY+" h "+2*tick+" M "+cX+" "+(cY-tick)+" v "+2*tick+" ";
          if (this.get('style')===1) {
            line += "M "+(cX-rayon)+" "+cY+ "a"+rayon+" "+rayon+" 0 0 0 "+2*rayon+" 0";
         }
       }
    }
    return line;
 })
});

L’utilisation d’Ember.computed permet d’avoir un lien dynamique (two way binding) entre la valeur des champs passés en paramètre et la valeur calculée. Cela ne sera pas utilisé dans notre exemple, mais permet des modes de fonctionnement impressionnants.

Le template rack-display.hbs est alors simplement :

<p>{{yield}}</p>

<svg version="1.1"
     style='font-family:"Lucida Grande", "Lucida Sans Unicode", Arial, Helvetica, sans-serif;font-size:12px;'
     xmlns='http://www.w3.org/2000/svg' width={{width}} height={{height}}>
     <!-- Bounding box -->
    <rect x="0" y="0" width={{width}}  height={{height}}
           rx={{frame.frameCornerSize}} ry={{frame.frameCornerSize}}
           fill={{frame.frameBackgroundColor}}
           stroke={{frame.frameBorderColor}} stroke-width={{frame.frameBorderWidth}}></rect>

     <path d={{rackPath}} fill="none" stroke={{rack.strokeColor}} stroke-width={{rack.strokeWidth}}></path>
</svg>

A noter la présence du marqueur yield permettant d’afficher le contenu présent à l’intérieur des balises du composant.

Le composant est prêt à être utilisé dans les templates du projet. On peut maintenant simplifier l’affichage du détail d’un Rack :

<div class="col-md-12 phase-container">
  {{#rack-display  nbColumns=model.nbColumns nbRows=model.nbRows bottles=model.bottles}}
      <p><strong>Rack Name:</strong> {{model.name}} ({{model.nbRows}} rows by {{model.nbColumns}} columns)</p>
  {{/rack-display}}
  {{outlet}}
</div>

Pour simplifier la navigation entre la page de détail et la page de liste, on crée un bouton ‘Back’ en ajoutant au template les lignes suivantes :

<div class="col-md-12 phase-container">
  <p><button {{action "back"}}>Back</button></p>
  {{#rack-display  nbColumns=model.nbColumns nbRows=model.nbRows bottles=model.bottles}}
      <p><strong>Rack Name:</strong> {{model.name}} ({{model.nbRows}} rows by {{model.nbColumns}} columns)</p>
  {{/rack-display}}
  {{outlet}}
</div>

Et comme on a ajouté une action, il s’agit maintenant de déclarer explicitement un contrôleur pour la page détail du Rack (il était jusqu’alors déclaré implicitement par le framework) :

// app/controllers/rack.js
import Ember from 'ember';

export default Ember.Controller.extend({
  actions: {
    back() {
     this.transitionToRoute('racks');
    }
  }
});

Step 4.2 – Les poupées russe : un composant dans un composant !

On a maintenant une représentation du casier, mais pas des bouteilles qu’il contient.

On pourrait bien entendu enrichir le composant directement, mais pour la beauté de l’exercice, nous allons passer par un nouveau composant utilisé par le composant précédent, à la façon d’une poupée russe.

Pour les créer, on utilise à nouveau le générateur d’EmberCLI :

ember g component bottle-display

On construit alors un pseudo contrôleur pour le composant, capable de nous fournir pour une bouteille donnée du casier les coordonnées cx, cy du centre de l’icône ainsi que le rayon du cercle représentatif. On note au passage l’utilisation de ‘tagName’ qui va surcharger la balise par défaut

encadrant chaque instanciation du composant dans la page. En effet, le template de ce composant est destiné à être intégré comme un fragment SVG.

// app/components/bottle-display.js
import Ember from 'ember';

export default Ember.Component.extend({
  tagName: 'g',
  bottle: null,
  nbRows:4,
  nbColumns:4,
  flipped: false,
  width: 100,
  height: 200,
  margin: 10,
  spacing: 10,
  bottleColor: '#22EE11',
  bottleFill: '#00AA00',
  bottleStrokeWidth: 2,
  bottleSize: 0.80,

  incX: Ember.computed('nbColumns', 'width', 'margin', 'spacing', function() {
     let usefulWidth = this.get('width') - 2*this.get('margin');
     return usefulWidth / this.get('nbColumns')/2;
  }),
  incY: Ember.computed('nbRows', 'height', 'margin', function() {
     let usefulHeight = this.get('height') - 2*this.get('margin');
     return usefulHeight / this.get('nbRows')/2;
  }),
  cx: Ember.computed('bottle', 'incX', 'margin', function() {
     let xcolumn = this.get('bottle').get('xcolumn');
     return this.get('margin')+this.get('incX')+xcolumn*2*this.get('incX');
  }),
  cy: Ember.computed('bottle', 'incY', 'margin', function() {
     let yrow = this.get('bottle').get('yrow');
     return this.get('margin')+this.get('incY')+yrow*2*this.get('incY');
  }),
  radius: Ember.computed('incX', 'incY', 'spacing', 'bottleSize', function() {
     return Math.min(this.get('incX') - this.get('spacing'), this.get('incY')) * this.get('bottleSize');
  })
});

Le template associé est relativement simple :

{{yield}}
<circle cx={{cx}} cy={{cy}} r={{radius}} fill={{bottleFill}} stroke={{bottleColor}} stroke-width={{bottleStrokeWidth}}  />```

<div class="code-embed-infos"><span class="code-embed-name"></span></div></div>
## <span style="font-weight: 400; font-size: 16px;">Le composant ainsi créé peut être utilisé au sein du template rack-display.hbs comme un fragment SVG :</span>

<div class="code-embed-wrapper">```
<code class="language-markup code-embed-code"><p>{{yield}}</p>

<svg version="1.1"
     style='font-family:"Lucida Grande", "Lucida Sans Unicode", Arial, Helvetica, sans-serif;font-size:12px;'
     xmlns='http://www.w3.org/2000/svg' width={{width}} height={{height}}>
     <!-- Bounding box -->
    <rect x="0" y="0" width={{width}}  height={{height}}
           rx={{frame.frameCornerSize}} ry={{frame.frameCornerSize}}
           fill={{frame.frameBackgroundColor}}
           stroke={{frame.frameBorderColor}} stroke-width={{frame.frameBorderWidth}}></rect>

     <path d={{rackPath}} fill="none" stroke={{rack.strokeColor}} stroke-width={{rack.strokeWidth}}></path>

     {{#each bottles as |bottle|}}
        {{#bottle-display bottle=bottle height=height width=width nbRows=nbRows nbColumns=nbColumns margin=margin spacing=spacing}}
        {{/bottle-display}}
     {{/each}}
</svg>

Le composant ainsi créé affiche notre casier et les bouteilles qu’il contient :