Accueil > symfony > Menez le web à la baguette avec symfony (3) : refonte des glasses

Menez le web à la baguette avec symfony (3) : refonte des glasses

L’habit fait le moine

ours-pwn3d-glaceBienvenue dans le troisième épisode de la série “symfony, sex and fun”. Si vous arrivez en cours de route, vous pouvez consulter les articles précédents. Pour l’instant nous disposons d’un module bookmark fonctionnel (ajout, suppression etc.) mais qui n’est pas très joli. Voyons comment symfony permet de gérer les vues.

Note : à partir de maintenant, nous allons faire pas mal de modifications dans les fichiers. Je vous suggère de vous munir d’un bon éditeur PHP si vous souhaitez refaire la démarche chez vous. Pourquoi pas Netbeans et son très bon support de symfony, par exemple ?

L’affichage des vues dans symfony suit le design pattern decorator : chaque application dispose d’un layout global, dans lequel on va injecter le rendu du template de l’action appelée (il va venir le décorer). L’approche généralement utilisée dans d’autres projets suit le chemin inverse : on concatène un en-tête, puis le contenu, et enfin un pied de page, mais ni le header ni le footer ne représentent un document HTML valide.

Jetons un œil au layout (le template global), situé dans apps/frontend/templates/layout.php :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
  <head>
    <?php include_http_metas() ?>
    <?php include_metas() ?>
    <?php include_title() ?>
    <link rel="shortcut icon" href="/favicon.ico" />
    <?php include_stylesheets() ?>
    <?php include_javascripts() ?>
  </head>
  <body>
    <?php echo $sf_content ?>
  </body>
</html>

Plusieurs choses sont à noter :

  • par défaut, symfony fait du rendu HTML (logique, puisque généralement il est utilisé pour construire des applications web),
  • les balises d’en-tête (métadonnées, titre, feuilles de style, javascripts) sont spécifiées ailleurs, et incluses par des fonctions include_*() : les helper,
  • le contenu effectif de la page, issu du rendu du template des actions, est stocké dans une variable nommée $sf_content, définie par le framework.

Les helpers sont une notion empruntée à Ruby on Rails. Ce sont de simples fonctions, prenant éventuellement des paramètres, qui vont générer un morceau de vue HTML. Par exemple, le helper link_to va prendre une URL, un couple module/action ou encore une “adresse de routage” (comme @homepage) en paramètre et générer la balise <a/> correspondante. Pour des questions d’organisation logique, les helpers sont groupés dans des sortes de paquets, en fonction de ce sur quoi ils agissent. On retrouve ainsi les “paquets” UrlHelper, DateHelper, I18NHelper, etc. (par curiosité, vous pouvez les explorer dans la documentation de l’API symfony).

Note : avant de continuer, il faut que je vous parle de la manière dont les configurations sont gérées. Symfony dispose de plusieurs niveaux de configuration :

  • framework (configuration par défaut),
  • projet, dans le répertoire config/,
  • application, dans le répertoire apps/<app>/config,
  • module, dans le répertoire apps/<app>/modules/<module>/config.

Ces niveaux sont fusionnés du haut vers le bas, c’est-à-dire que la configuration du projet peut surcharger celle du framework, ainsi de suite.

En regardant l’arborescence du projet, on s’aperçoit que seul l’application frontend dispose d’un répertoire config (pas le module bookmark). Si on ouvre le fichier apps/frontend/config/view.yml, on retrouve la pièce manquante du puzzle qui constitue la vue rendue :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# You can find more information about this file on the symfony website:
# http://www.symfony-project.org/reference/1_4/en/13-View

default
:
  http_metas
:
    content-type
: text/html

  metas
:
   #title:        symfony project
    #description:  symfony project
    #keywords:     symfony, project
    #language:     en
    #robots:       index, follow

  stylesheets
:   [main.css]
  javascripts
:   []
  has_layout
:    true
  layout
:        layout

C’est donc ici que sont définis les scripts et les feuilles de style CSS à utiliser. Notez les deux dernières lignes : elles précisent que la vue utilise un layout, nommé… layout. Il est donc possible pour un module ou une action d’utiliser un autre layout, voire aucun layout si, par exemple, il/elle génère des PDF à la volée. Adaptons tout cela à nos besoins :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
# apps/frontend/config/view.yml
default
:
  http_metas
:
    content-type
: text/html

  metas
:
    title
:       Plum Bookmark Manager
    description
: Plum Bookmark Manager is a web application intended to store and manage your bookmarks
    keywords
:    plum, bookmark, manager, php, symfony

  stylesheets
:   [main.css]
  javascripts
:   []
  has_layout
:    true
  layout
:        layout

De plus, en modifiant le layout et la CSS main.css, on peut facilement obtenir un résultat visuel plus agréable : un-design-qu-il-est-beauMais en général, ce n’est pas de cette façon que les bookmarks sont présentés. Il faudrait que le lien soit cliquable, que la date d’ajout soit mieux formatée, et que l’ID disparaisse (il ne nous intéresse pas, de toute manière). Allons faire un tour dans apps/frontend/modules/bookmark/templates/indexSuccess.php, et modifions-le comme ceci :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php use_helper('Date'); ?>
<h1>Bookmarks List</h1>

<table id="bookmarksList">
  <tbody>
  <?php foreach ($bookmarks as $bookmark): ?>
    <tr>
      <td><?php echo format_date($bookmark->getCreatedAt(), 'p') ?></td>
      <td><?php echo link_to($bookmark->getDescription(), $bookmark->getUrl(), array('popup' => 'true')) ?></td>
    </tr>
  <?php endforeach; ?>
  </tbody>
</table>

<script type="text/javascript">stripe("bookmarksList");</script>
<a href="<?php echo url_for('bookmark/new') ?>">New</a>

La table a été épurée, et des helpers sont maintenant utilisés : format_date et link_to. Ils permettent respectivement d’obtenir une date plus lisible et un lien HTML avec une description. Comme format_date n’est pas accessible par défaut dans les templates, il a fallu inclure DateHelper grâce à la directive use_helper('Date') (le suffixe “Helper” est ajouté automatiquement).

Taguez-les tous !

La deuxième partie à revoir après le design, c’est la gestion des tags. En effet, il nous reste deux classes qui ne sont pas encore utilisées : Tag et BookmarkTag. La seconde étant une table de liaison, on ne va pas la manipuler directement (i.e. nous n’allons pas en créer un module). Par contre, il peut être intéressant d’avoir un module tag :


1
$ ./symfony doctrine:generate-module frontend tag Tag

Plutôt que d’utiliser les formulaires générés pour insérer des données de tests, nous allons utiliser des fixtures, un jeu de données spécifié dans un fichier, qui pourra être utilisé à n’importe quel moment pour réinitialiser la base de données :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# data/fixtures/fixtures.yml
Bookmark
:
  excilys
: # cette clé sert de "clé primaire" pour BookmarkTag
    url
: http://www.excilys.com
    description
: Site du groupe Excilys
  google
:
    url
: http://www.google.fr
    description
: Un moteur de recherche peu connu

Tag
:
  informatique
:
    name
: Informatique
  dev
:
    name
: Dev

BookmarkTag
:
  bt1
:
    Bookmark
: excilys
    Tag
: informatique
  bt2
:
    Bookmark
: excilys
    Tag
: dev
  bt3
:
    Bookmark
: google
    Tag
: informatique

Ces données peuvent être insérées en base avec la commande suivante (qui efface les éventuelles données déjà présentes) :


1
$ symfony doctrine:data-load

Avec un nuage de tags, SVP…

Nous pouvons maintenant ajouter une action au module tag, par exemple pour générer un nuage de tags. Pour cela, nous allons créer une méthode qui va chercher les tags utilisés en base, ainsi que le nombre de fois qu’ils sont utilisés. Cela se passe dans la classe BookmarkTagTable (un DAO étendant Doctrine_Table) :


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// lib/model/doctrine/BookmarkTagTable.class.php
class BookmarkTagTable extends Doctrine_Table
{
  /**
  * Retrieves all the tags with the number of times they are attached to
  * a bookmark (weight).
  */

  public function retrieveTagsWithWeight()
  {
    $q = $this->createQuery('bt')
        ->leftJoin('bt.Tag t')
        ->select('bt.tag_id, t.name, count(bt.tag_id)')
        ->groupBy('bt.tag_id')
        ->orderBy('t.name ASC');

    return $q->fetchArray();
  }
}

Les méthodes appelées sont suffisamment explicites pour que je ne les détaille pas. L’étape suivante consiste à ajouter l’action qui va appeler ce DAO, puis la vue qui affichera le nuage :


1
2
3
4
5
6
7
// apps/frontend/modules/tag/actions/actions.class.php
public function executeTagCloud(sfWebRequest $request)
{
  $this->tags = Doctrine::getTable('BookmarkTag')->retrieveTagsWithWeight();
  $this->baseFontSize = doubleval(sfConfig::get('app_fontSizes_base'));
  $this->maxFontSize = doubleval(sfConfig::get('app_fontSizes_max'));
}

On définit deux tailles de police : minimale et maximale. Plutôt que de coder en dur ces valeurs, on les externalise dans le fichier de configuration de l’application, que l’on peut lire grâce à sfConfig::get('app_key_name') :


1
2
3
4
5
# apps/frontend/config/app.yml
all
:
  fontSizes
:
    base
: 0.8
    max
: 2.0

Notez que l’imbrication yaml se traduit en une concaténation avec des underscores au niveau du nom de la propriété (ex. app_fontSizes_base).

Voici la vue :


1
2
3
4
5
6
7
8
9
<?php
foreach($tags as $tag) {
  $count = intval($tag['count']);
  $fontSize = min($baseFontSize + ($count / 10) - 0.1, $maxFontSize);

  echo link_to($tag['Tag']['name'], 'bookmark/byTag?name='.$tag['Tag']['name'],
      array('style' => "font-size: ${fontSize}em;"));
  echo ' ';
}

Le code fait un calcul savant maladroit de taille de police en fonction du poids (nombre d’associations entre ce tag et un bookmark), avec des bornes mini et maxi. Un autre algorithme possible calculerait la taille d’un tag relativement à sa fréquence d’utilisation par rapport aux autres (par exemple, si tous les tags sont utilisés 100 fois, ils auront la même petite taille, puisqu’aucun ne se démarque vraiment).

Il serait bien de pouvoir intégrer ce nuage de tags sur toutes les pages, idéalement il faudrait l’intégrer dans le layout global.

Les components

tag-cloud

Notre nuage de tags

Comment rendre un template dans un autre ? Pour faire ce genre de tâches, symfony propose la notion de component : dans le layout global, on va ajouter une directive d’inclusion de composant, qui fait référence à un couple module/composant. Les composants sont différents des actions. Ils résident dans un fichier actions/components.class.php, et leur templates sont sous la forme _<nomComposant>.php.

Pour migrer notre action en composant, il faut donc créer le fichier apps/frontend/modules/tag/actions/components.class.php, y déplacer la méthode executeTagCloud(), et renommer le template tagCloudSuccess.php en _tagCloud.php.

Le résultat du rendu de notre composant sera intégré à l’endroit de son inclusion :


1
2
3
4
5
6
7
<body>
...
  <div id="right-column">
    <?php include_component('tag', 'tagCloud'); ?>
  </div>
...
</body>

En adaptant la CSS, on peut obtenir ce design :

integrated-tag-cloud

A fond les forms !

Un framework dédié aux formulaires est présent dans symfony, il est assez complexe à appréhender lorsqu’on débute, je vais donc essayer d’être le plus clair possible et de ne pas m’égarer. Au niveau du vocabulaire, un formulaire est représenté par une classe *Form, contenant entre autre des widgets et des validators. Les premiers sont utilisés pour le rendu HTML (par exemple on a un widget input text et un widget combobox). Les validateurs, quant à eux, permettent de vérifier que les données entrées pour un widget donné sont “valides” ; il y a donc un validateur par widget.

Ouvrons la classe BookmarkForm, générée par doctrine:build. Elle est vide, ce qui est normal puisque le comportement par défaut est défini dans sa classe parente gérée par le framework, BaseBookmarkForm. La première chose que je vous propose de faire, c’est de désactiver le champ created_at, puisqu’il est censé devoir être valorisé automatiquement :


1
2
3
4
5
6
7
class BookmarkForm extends BaseBookmarkForm
{
  public function configure()
  {
    unset($this['created_at']);
  }
}

Il faudra également supprimer les références à created_at dans les templates de vues.

Toujours dans la méthode configure(), il serait intéressant d’utiliser un validateur d’URL pour notre champ url :


1
2
3
4
$this->validatorSchema['url'] = new sfValidatorAnd(array(
  $this->validatorSchema['url'],
  new sfValidatorUrl()
));

L’astuce ici consiste à utiliser un “super-validateur” qui va faire un “et logique” entre le validateur par défaut (pour la taille maximale de 255 caractères) et un sfValidatorUrl.

Si l’on essaie d’ajouter un nouveau bookmark, la vue est maintenant minimaliste. Si l’on tente de rentrer une URL invalide, des messages d’erreur nous préviennent :

Message en cas d'URL invalide

Message en cas d'URL invalide

Regardons de plus près le fonctionnement du processus de soumission. Une fois les données entrées, l’action create va exécuter les étapes suivantes (dans la méthode processForm) :

  • binder les données POST sur l’objet *Form adéquat,
  • valider les données en appelant la méthode validate(),
  • réafficher la vue en cas d’erreur,
  • sinon persister les objets en cas de succès, puis rediriger vers la liste de bookmarks.

Passez le bonjour agile

Après réflexion, le modèle de données n’est pas forcément le meilleur. Pour associer les tags à un bookmark, on utilise généralement un seul champ de saisie dans lequel on séparera les tags par des virgules. Après validation du formulaire, il faudra donc rechercher tous les éventuels enregistrements correspondant à ces tags en base, pour pouvoir ajouter les relations tag-bookmark.

Il serait peut-être plus judicieux et plus pratique de ne pas avoir une relation n-m (notre classe BookmarkTag), mais 1-n. Certes, il y aura des doublons dans la base, mais on gagnera surement en simplicité.

Fort heureusement pour nous, nous avons vu que symfony permet facilement de modifier le modèle de données. Il suffit d’éditer le fichier config/doctrine/schema.yml et de reconstruire les classes du domaine, de formulaires, etc.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# schema.yml
Bookmark
:
  actAs
:
    Timestampable
:
      updated
:
        disabled
: true
  columns
:
    url
: { type: string(255), notnull: true }
    description
: { type: string(255) }

Tag
:
  columns
:
    bookmark_id
: { type: integer }
    name
: { type: string(255), notnull: true }
  relations
:
    Bookmark
: { onDelete: CASCADE, local: bookmark_id, foreign: id }

Avant de reconstruire les classes, il faut au préalable supprimer les fichiers *BookmarkTag* dans lib/, sinon Doctrine continuera de créer la table BookmarkTag en BDD (pour une raison qui m’échappe). Il ne faut évidemment pas oublier de déplacer le code gérant le nuage de tags dans les classes/vues de gestion de Tag (au lieu de BookmarkTag précédemment).


1
$ ./symfony doctrine:build --all

Pour obtenir le nouveau comportement décrit plus haut, il faut à présent modifier :

  • la classe BookmarkForm pour y ajouter un widget correspondant à notre liste de tags,
  • la vue permettant l’insertion d’un bookmark pour y ajouter un champ de saisie de type texte.

1
2
3
4
5
6
7
8
// lib/form/doctrine/BookmarkForm.class.php

public function configure()
{
  // ...
  $this->widgetSchema['tags'] = new sfWidgetFormInputText();
  $this->validatorSchema['tags'] = new sfValidatorString();
}
1
2
3
4
5
6
7
<!-- apps/frontend/modules/bookmark/templates/_form.php -->
<tr>
  <th>Tags :</th>
  <td>
    <?php echo $form['tags'] ?>
  </td>
</tr>

L’astuce est ensuite de convertir la valeur récupérée dans le widget ‘tags’ en autant de tags. Pour cela, il faut intervenir juste la sauvegarde du formulaire, dans actions.class.php :


1
2
3
4
5
6
7
8
9
protected function processForm(sfWebRequest $request, sfForm $form)
{
  // ...
  if ($form->isValid())
  {
    $bookmark = $form->save();

    Doctrine::getTable('Bookmark')->createTags($bookmark->getId(), $form->getValue('tags'));
    // ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BookmarkTable extends Doctrine_Table
{
    public static function createTags($bookmarkId, $tagList)
    {
        $tags = explode(',', $tagList);

        foreach ($tags as $tag) {
            $t = new Tag();
            $t->setBookmarkId($bookmarkId);
            $t->setName(trim($tag));
            $t->save();
        }
    }
}

Vérifions si tout fonctionne bien :

Création d'un bookmark avec des tags associés

Création d'un bookmark avec des tags associés

Ceci n’est qu’une ébauche assez maladroite. Dans le monde merveilleux des Bisounours, il faudrait vérifier que l’on n’insère pas plusieurs fois le même tag sur le même bookmark, en particulier lorsqu’on utilise le formulaire d’édition (qui contient lui aussi le champ ‘tags’). Ceci peut faire un parfait exercice pour le lecteur assidu, d’autant plus que nous approchons dangereusement de la limite de taille (raisonnable) pour un article :D. Si vous cherchez un autre exercice pour cogiter le soir dans le métro, vous pouvez également essayer d’afficher la liste des tags associés à un bookmark.

Conclusion

Nous voici déjà à la fin de cet article (ohhhhh… :(). Ce que nous pouvons en retenir, c’est qu’à chaque problème, symfony propose une solution. Les problèmes que l’on peut rencontrer sont souvent les mêmes d’un projet à l’autre, c’est pourquoi les concepteurs du framework le développent de manière à ce que l’on perde le moins de temps possible.

symfony est suffisamment flexible et agile pour permettre à tout moment de modifier le modèle de données et pouvoir profiter de ces modifications assez rapidement, notamment grâce à la génération automatique de code. J’ajouterais néanmoins qu’il est préférable, à mon sens, de bien réfléchir au modèle dès le départ pour éviter de perdre trop de temps par la suite.

Il est également intéressant de noter que l’on peut faire des thèmes assez jolis à base de rose, et que ça ne fait pas forcément girly (le lecteur visé se reconnaitra :P).

Accès au code source :

http://code.google.com/p/excilys/source/browse/projects#projects/plum/tags/plum_article_3

[cci]
Share
  1. Olivier
    23/05/2010 à 16:52 | #1

    Super article, merci.
    ça serais intéressant le même article avec Symfony 2 pour voir les différences de conception.

  2. 15/06/2010 à 10:12 | #2

    Super série d’article ! je les gardes en stock pour les formations que j’aurai à donner !
    Bon pour le rose par contre…. c’est un peu girly quand même…

  3. Ludo
    16/08/2010 à 14:44 | #3

    Quel est le nom de classe pour apps/frontend/modules/tag/actions/components.class.php? extends quoi?

    J’ai essayé avec “class tagComponennts extends sfComponents”, mais j’ai une erreur : “The component does not exist: “tag”, “tagCloud”.”

  4. Ludo
    16/08/2010 à 14:48 | #4

    bon ce que j’ai essayé était juste, il y avait juste une faute de frappe : “class tagComponents extends sfComponents”

  5. 16/08/2010 à 14:50 | #5

    C’est bien class tagComponents extends sfComponents mais avec un seul ‘n’ à tagComponents, je pense que tu as fait une faute de frappe…

    (voir http://code.google.com/p/excilys/source/browse/projects/plum/tags/plum_article_3/trunk/apps/frontend/modules/tag/actions/components.class.php)

    Edit : pwn3d, trop lent à répondre :p

  6. Ludo
    16/08/2010 à 15:30 | #6

    Oui, j’ai posé la question un peu trop vite! :)

    Par contre j’ai un souci avec “unset($this[‘created_at’]);” qui est sensé supprimer la date de création dans le formulaire : “Widget “created_at” does not exist.”

    Merci pour le tutorial!

  7. Ludo
    16/08/2010 à 15:33 | #7

    @Ludo
    Et j’ai encore une fois parlé trop vite : “Il faudra également supprimer les références à created_at dans les templates de vues.”

  8. 16/08/2010 à 16:01 | #8

    Au 3e problème je te facture l’assistance, même si tu les résous toi-même :-D

  9. Ludo
    16/08/2010 à 18:04 | #9

    3ème problème – qui ne vient pas de moi cette foisd-ci ;) : il manque le refactoring du tag cloud après la suppression de la table/model BookmarkTag :

    components.class.php
    _tagCloud.php
    TagTable.class.php

  10. 19/04/2017 à 17:48 | #10

    Global Message Fro this offshoot

  11. 13/05/2017 à 23:27 | #11

    Accustomed Message Fro this product

  1. 23/07/2014 à 10:24 | #1
  2. 01/02/2016 à 15:40 | #2