Jobeet FR

Le tutoriel pour Symfony2 en français

Pleine page

Tout site web a des formulaires, du simple formulaire de contact à celui avec beaucoup de champs. L'écriture de formulaire est également l'une des tâches les plus complexes et fastidieuses pour un développeur web: vous devez écrire le formulaire HTML, les règles de validation pour chaque champ, traiter les valeurs pour les stocker dans une BDD, afficher des messages d'erreur, repeupler les champs en cas d'erreurs, et bien plus encore...

Dans le troisième chapitre de ce tutoriel, nous avons utilisé la commande doctrine:generate:crud pour générer un simple contrôleur CRUD pour l'entité Job. Cela a également engendré un formulaire d'offre que vous pouvez trouver dans le fichier /src/Ens/JobeetBundle/Form/JobType.php.


Personnaliser le formulaire d'offre

Le formulaire d'offre est un parfait exemple pour apprendre la personnalisation des formulaires. Voyons comment le personnaliser, étape par étape.

Tout d'abord, changer le "Post a Job" dans la mise en page pour être en mesure de vérifier les modifications directement dans votre navigateur:

<!-- src/Ens/JobeetBundle/Resources/views/layout.html.twig -->
<a href="{{ path('ens_job_new') }}">Post a Job</a>

Puis, modifiez les paramètres de la route ens_job_show dans CreateAction de JobController pour correspondre à la nouvelle route que nous avons créé dans le cinquième chapitre de ce tutoriel:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function createAction()
{
  $entity  = new Job();
  $request = $this->getRequest();
  $form    = $this->createForm(new JobType(), $entity);
  $form->bindRequest($request);
 
  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
 
    $em->persist($entity);
    $em->flush();
 
    return $this->redirect($this->generateUrl('ens_job_show', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'id' => $entity->getId(),
      'position' => $entity->getPositionSlug()
    )));
  }
 
  return $this->render('EnsJobeetBundle:Job:new.html.twig', array(
    'entity' => $entity,
    'form'   => $form->createView()
  ));
}

Par défaut, le formulaire généré par Doctrine affiche des champs pour toutes les colonnes de la table. Mais pour le formulaire d'offre, certains d'entre eux ne doivent pas être modifiables par l'utilisateur final. Modifiez le formulaire Job comme ci-dessous:

// src/Ens/JobeetBundle/Form/JobType.php
 
namespace Ens\JobeetBundle\Form;
 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
 
class JobType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder->add('category');
        $builder->add('type');
        $builder->add('company');
        $builder->add('logo');
        $builder->add('url');
        $builder->add('position');
        $builder->add('location');
        $builder->add('description');
        $builder->add('how_to_apply');
        $builder->add('token');
        $builder->add('is_public');
        $builder->add('email');
    }
 
    public function getName()
    {
        return 'ens_jobeetbundle_jobtype';
    }
}

La configuration du formulaire doit parfois être plus précise que l'introspection à partir du schéma de la BDD. Par exemple, la colonne email est un varchar dans le schéma, mais nous avons besoin de cette colonne pour être validé comme un email. Dans Symfony2, la validation est appliquée à l'objet sous-jacent (par exemple Job). En d'autres termes, la question n'est pas de savoir si le "formulaire" est valide, mais si oui ou non l'objet Job est valide après que le formulaire ait appliqué les données qui lui sont soumises. Pour ce faire, créez un nouveau fichier validation.yml dans le répertoire Resources/config de notre paquet:

# src/Ens/JobeetBundle/Resources/config/validation.yml
 
Ens\JobeetBundle\Entity\Job:
    properties:
    email:
        - NotBlank: ~
        - Email: ~

Même si la colonne type est également un varchar dans le schéma, nous voulons que sa valeur soit limitée à une liste de choix: temps plein, temps partiel, ou freelance.

// src/Ens/JobeetBundle/Form/JobType.php
// ...
 
use Ens\JobeetBundle\Entity\Job;
 
class JobType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        // ...
 
        $builder->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true));
 
        // ...
    }
 
    // ...
 
}

Pour que cela fonctionne, ajoutez les méthodes suivantes en respectant l'entité Job:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
public static function getTypes()
{
  return array('full-time' => 'Full time', 'part-time' => 'Part time', 'freelance' => 'Freelance');
}
 
public static function getTypeValues()
{
  return array_keys(self::getTypes());
}
 
// ...

La méthode GetTypes est utilisée dans le formulaire pour obtenir les types possibles pour une offre d'emploi et getTypeValues sera utilisée dans la validation pour obtenir les valeurs valides pour le champ Type.

# src/Ens/JobeetBundle/Resources/config/validation.yml
 
Ens\JobeetBundle\Entity\Job:
    properties:
        type:
            - NotBlank: ~
            - Choice: { callback: getTypeValues }
        email:
            - NotBlank: ~
            - Email: ~

Pour chaque champ, Symfony génère automatiquement un label (qui sera utilisé dans la balise <label> rendue). Ceci peut être changé avec l'option label:

$builder->add('logo', null, array('label' => 'Company logo'));
$builder->add('how_to_apply', null, array('label' => 'How to apply?'));
$builder->add('is_public', null, array('label' => 'Public?'));

Vous devez également ajouter des contraintes de validation pour le reste des champs:

# src/Ens/JobeetBundle/Resources/config/validation.yml
 
Ens\JobeetBundle\Entity\Job:
    properties:
        category:
            - NotBlank: ~
        type:
            - NotBlank: ~
            - Choice: {callback: getTypeValues}
        company:
            - NotBlank: ~
        position:
            - NotBlank: ~
        location:
            - NotBlank: ~
        description:
            - NotBlank: ~
        how_to_apply:
            - NotBlank: ~
        token:
            - NotBlank: ~
        email:
            - NotBlank: ~
            - Email: ~

Gestion des uploads de fichiers dans Symfony2

Pour gérer le fichier uploadé dans le formulaire, nous allons utiliser un champ file "virtuel". Pour cela, nous allons ajouter une nouvelle propriété file à l'entité Job:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
public $file;

Maintenant nous avons besoin de remplacer le logo avec le widget file et le modifier en un input de type file:

// src/Ens/JobeetBundle/Form/JobType.php
// ...
 
$builder->add('file', 'file', array('label' => 'Company logo', 'required' => false));
 
// ...

Pour vous assurer que le fichier uploadé est une image valide, nous allons utiliser la contrainte de validation Image:

# src/Ens/JobeetBundle/Resources/config/validation.yml
Ens\JobeetBundle\Entity\Job:
    properties:
        # ...
        file:
            - Image: ~

Lorsque le formulaire est soumis, le champ file sera une instance de UploadedFile. Il peut être utilisé pour déplacer le fichier vers un emplacement permanent. Après cela, nous allons définir la propriété logo au nom du fichier uploadé.

// src/Ens/JobeedBundle/Controller/JobController.php
// ...
 
public function createAction()
{
  // ...
 
  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
 
    $entity->file->move(__DIR__.'/../../../../web/uploads/jobs', $entity->file->getClientOriginalName());
    $entity->setLogo($entity->file->getClientOriginalName());
 
    $em->persist($entity);
    $em->flush();
 
    return $this->redirect($this->generateUrl('ens_job_show', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'id' => $entity->getId(),
      'position' => $entity->getPositionSlug()
    )));
  }
  // ...
}

Vous devez créer le répertoire logo (web/uploads/jobs/) et vérifier qu'il est accessible en écriture par le serveur web.

Même si cette implémentation fonctionne, la meilleure façon est de gérer l'upload de fichiers à l'aide de l'entité Doctrine Job.

Tout d'abord, ajoutez la ligne suivante à l'entité Job:

protected function getUploadDir()
{
    return 'uploads/jobs';
}
 
protected function getUploadRootDir()
{
    return __DIR__.'/../../../../web/'.$this->getUploadDir();
}
 
public function getWebPath()
{
    return null === $this->logo ? null : $this->getUploadDir().'/'.$this->logo;
}
 
public function getAbsolutePath()
{
    return null === $this->logo ? null : $this->getUploadRootDir().'/'.$this->logo;
}

La propriété logo enregistre le chemin relatif au fichier et est conservé dans la BDD. getAbsolutePath() est une méthode pratique qui renvoie le chemin absolu vers le fichier alors que getWebPath() est une méthode qui renvoie le chemin web, qui peut être utilisé dans un template pour créer un lien vers le fichier uploadé.

Nous ferons la mise en œuvre de telle sorte que l'opération de la BDD et le déplacement du fichier soient atomiques: si il y a un problème persistant de l'entité ou si le fichier ne peut pas être enregistré, rien ne se passera. Pour ce faire, nous avons besoin de déplacer le fichier de telle sort que Doctrine persiste l'objet dans la BDD. Ceci peut être accompli par hooking dans le lifecycleCallbacks de l'entité Job. Comme nous l'avons fait dans le troisième chapitre du tutoriel Jobeet, nous allons modifier le fichier Job.orm.yml et y ajouter les lifecycleCallbacks preUpload, upload et removeUpload:

# src/Ens/JobeetBundle/Resources/config/doctrine/Job.orm.yml
Ens\JobeetBundle\Entity\Job:
# ...
 
  lifecycleCallbacks:
    prePersist: [ preUpload, setCreatedAtValue, setExpiresAtValue ]
    preUpdate: [ preUpload, setUpdatedAtValue ]
    postPersist: [ upload ]
    postUpdate: [ upload ]
    postRemove: [ removeUpload ]

Maintenant, exécutez la commande Doctrine generate:entities pour ajouter ces nouvelles méthodes à l'entité Job:

php app/console doctrine:generate:entities EnsJobeetBundle

Modifiez l'entité Job et modifiez les méthodes ajoutées à ce qui suit:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
/**
* @ORM\prePersist
*/
public function preUpload()
{
  if (null !== $this->file) {
    // do whatever you want to generate a unique name
    $this->logo = uniqid().'.'.$this->file->guessExtension();
  }
}
 
/**
* @ORM\postPersist
*/
public function upload()
{
  if (null === $this->file) {
    return;
  }
 
  // if there is an error when moving the file, an exception will
  // be automatically thrown by move(). This will properly prevent
  // the entity from being persisted to the database on error
  $this->file->move($this->getUploadRootDir(), $this->logo);
 
  unset($this->file);
}
 
/**
* @ORM\postRemove
*/
public function removeUpload()
{
  if ($file = $this->getAbsolutePath()) {
    unlink($file);
  }
}
 
// ...

La classe fait maintenant tout ce qu'il faut: elle génère un nom de fichier unique avant la persistance, déplace le fichier après la persistance, et supprime le fichier si l'entité est déjà supprimée. Maintenant que le déplacement du fichier est géré atomiquement par l'entité, il faut supprimer le code que nous avons ajouté plus tôt dans le contrôleur pour gérer l'upload:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function createAction()
{
  $entity  = new Job();
  $request = $this->getRequest();
  $form    = $this->createForm(new JobType(), $entity);
  $form->bindRequest($request);
 
  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
 
    $em->persist($entity);
    $em->flush();
 
    return $this->redirect($this->generateUrl('ens_job_show', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'id' => $entity->getId(),
      'position' => $entity->getPositionSlug()
    )));
  }
 
  return $this->render('EnsJobeetBundle:Job:new.html.twig', array(
    'entity' => $entity,
    'form'   => $form->createView()
  ));
}
 
// ...

Le template du formulaire

Maintenant que la classe du formulaire a été personnalisée, nous avons besoin de l'afficher. Ouvrez le template new.html.twig et modifiez-le:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/new.html.twig -->
{% extends 'EnsJobeetBundle::layout.html.twig' %}
 
{% form_theme form _self %}
 
{% block field_errors %}
{% spaceless %}
  {% if errors|length > 0 %}
    <ul class="error_list">
      {% for error in errors %}
        <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li>
      {% endfor %}
    </ul>
  {% endif %}
{% endspaceless %}
{% endblock field_errors %}
 
{% block stylesheets %}
  {{ parent() }}
  <link rel="stylesheet" href="{{ asset('bundles/ensjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}
 
{% block content %}
  <h1>Job creation</h1>
  <form action="{{ path('ens_job_create') }}" method="post" {{ form_enctype(form) }}>
    <table id="job_form">
      <tfoot>
        <tr>
          <td colspan="2">
            <input type="submit" value="Preview your job" />
          </td>
        </tr>
      </tfoot>
      <tbody>
        <tr>
          <th>{{ form_label(form.category) }}</th>
          <td>
            {{ form_errors(form.category) }}
            {{ form_widget(form.category) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.type) }}</th>
          <td>
            {{ form_errors(form.type) }}
            {{ form_widget(form.type) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.company) }}</th>
          <td>
            {{ form_errors(form.company) }}
            {{ form_widget(form.company) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.file) }}</th>
          <td>
            {{ form_errors(form.file) }}
            {{ form_widget(form.file) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.url) }}</th>
          <td>
            {{ form_errors(form.url) }}
            {{ form_widget(form.url) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.position) }}</th>
          <td>
            {{ form_errors(form.position) }}
            {{ form_widget(form.position) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.location) }}</th>
          <td>
            {{ form_errors(form.location) }}
            {{ form_widget(form.location) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.description) }}</th>
          <td>
            {{ form_errors(form.description) }}
            {{ form_widget(form.description) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.how_to_apply) }}</th>
          <td>
            {{ form_errors(form.how_to_apply) }}
            {{ form_widget(form.how_to_apply) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.token) }}</th>
          <td>
            {{ form_errors(form.token) }}
            {{ form_widget(form.token) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.is_public) }}</th>
          <td>
            {{ form_errors(form.is_public) }}
            {{ form_widget(form.is_public) }}
            <br /> Whether the job can also be published on affiliate websites or not.
          </td>
        </tr>
        <tr>
          <th>{{ form_label(form.email) }}</th>
          <td>
            {{ form_errors(form.email) }}
            {{ form_widget(form.email) }}
          </td>
        </tr>
      </tbody>
    </table>
 
    {{ form_rest(form) }}
  </form>
{% endblock %}

Nous pourrions afficher le formulaire en utilisant simplement la ligne de code suivante, mais comme nous avons besoin de plus de personnalisation, nous avons choisi d'afficher chaque champ de formulaire à la main.

{{ form_widget(form) }}

En appelant form_widget(form), chaque champ du formulaire est affiché, accompagné d'un message d'erreur et d'un label (s'il en existe un). Aussi facile que cela soit, ce n'est pas très souple. Habituellement, vous aurez envie de rendre chaque champ de formulaire individuellement, de sorte de pouvoir contrôler la façon dont le formulaire ressemble.

Nous avons également utilisé une technique appelée Form Theming pour personnaliser la façon dont les erreurs de formulaire seront affichées. Vous pouvez en lire plus à ce sujet dans la documentation officielle de Symfony2.

Faites la même chose avec le template edit.html.twig:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/edit.html.twig -->
{% extends 'EnsJobeetBundle::layout.html.twig' %}
 
{% form_theme edit_form _self %}
 
{% block field_errors %}
{% spaceless %}
  {% if errors|length > 0 %}
    <ul>
      {% for error in errors %}
        <li>{{ error.messageTemplate|trans(error.messageParameters, 'validators') }}</li>
      {% endfor %}
    </ul>
  {% endif %}
{% endspaceless %}
{% endblock field_errors %}
 
{% block stylesheets %}
  {{ parent() }}
  <link rel="stylesheet" href="{{ asset('bundles/ensjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}
 
{% block content %}
  <h1>Job edit</h1>
  <form action="{{ path('ens_job_update', { 'id': entity.id }) }}" method="post" {{ form_enctype(edit_form) }}>
    <table id="job_form">
      <tfoot>
        <tr>
          <td colspan="2">
            <input type="submit" value="Preview your job" />
          </td>
        </tr>
      </tfoot>
      <tbody>
        <tr>
          <th>{{ form_label(edit_form.category) }}</th>
          <td>
            {{ form_errors(edit_form.category) }}
            {{ form_widget(edit_form.category) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.type) }}</th>
          <td>
            {{ form_errors(edit_form.type) }}
            {{ form_widget(edit_form.type) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.company) }}</th>
          <td>
            {{ form_errors(edit_form.company) }}
            {{ form_widget(edit_form.company) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.file) }}</th>
          <td>
            {{ form_errors(edit_form.file) }}
            {{ form_widget(edit_form.file) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.url) }}</th>
          <td>
            {{ form_errors(edit_form.url) }}
            {{ form_widget(edit_form.url) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.position) }}</th>
          <td>
            {{ form_errors(edit_form.position) }}
            {{ form_widget(edit_form.position) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.location) }}</th>
          <td>
            {{ form_errors(edit_form.location) }}
            {{ form_widget(edit_form.location) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.description) }}</th>
          <td>
            {{ form_errors(edit_form.description) }}
            {{ form_widget(edit_form.description) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.how_to_apply) }}</th>
          <td>
            {{ form_errors(edit_form.how_to_apply) }}
            {{ form_widget(edit_form.how_to_apply) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.token) }}</th>
          <td>
            {{ form_errors(edit_form.token) }}
            {{ form_widget(edit_form.token) }}
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.is_public) }}</th>
          <td>
            {{ form_errors(edit_form.is_public) }}
            {{ form_widget(edit_form.is_public) }}
            <br /> Whether the job can also be published on affiliate websites or not.
          </td>
        </tr>
        <tr>
          <th>{{ form_label(edit_form.email) }}</th>
          <td>
            {{ form_errors(edit_form.email) }}
            {{ form_widget(edit_form.email) }}
          </td>
        </tr>
      </tbody>
    </table>
 
    {{ form_rest(edit_form) }}
  </form>
{% endblock %}

Les actions du formulaire

Nous avons maintenant une classe de formulaire et un template qui l'affiche. Maintenant, il est temps de le faire réellement fonctionner avec certaines actions. Le formulaire d'offre est géré par quatre méthodes dans JobController:

  • - newAction: Affiche un formulaire vierge pour créer une nouvelle offre
  • - createAction: Traite le formulaire (validation, repopulation du formulaire), et crée une nouvelle offre avec les valeurs soumises par l'utilisateur
  • - editAction: Affiche un formulaire pour modifier une offre existante
  • - updateAction: Traite le formulaire (validation, repopulation du formulaire), et met à jour une offre avec les valeurs soumises par l'utilisateur

Lorsque vous accédez à la page /job/new, une instance du formulaire pour un nouvel objet Job est créée en appelant la méthode createForm et transmise au template (newAction).

Lorsque l'utilisateur soumet le formulaire (createAction), le formulaire est lié (méthode bindRequest()) aux valeurs soumises par l'utilisateur et la validation est déclenchée.

Une fois que le formulaire est lié, il est possible de vérifier sa validité en utilisant la méthode isValid():

  • - Si le formulaire est valide (retourne true), l'offre est enregistrée dans la BDD ($em->persist($entity)), et l'utilisateur est redirigé vers la page de prévisualisation de l'offre
  • - Sinon, le template new.html.twig s'affiche à nouveau avec les valeurs soumises par l'utilisateur et les messages d'erreur associés

La modification d'une offre existante est assez similaire. La seule différence entre les actions new et edit est que l'objet Job à modifier est passé en second argument de la méthode createForm. Cet objet sera utilisé pour les valeurs par défaut du widget dans le template.

Vous pouvez également définir des valeurs par défaut pour le formulaire de création. Pour cela, nous allons passer un objet Job pré-modifié à la méthode createForm pour définir la valeur de type par défaut à temps plein:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function newAction()
{
  $entity = new Job();
  $entity->setType('full-time');
  $form   = $this->createForm(new JobType(), $entity);
 
  return $this->render('EnsJobeetBundle:Job:new.html.twig', array(
    'entity' => $entity,
    'form'   => $form->createView()
  ));
}
 
// ...
PROTECTION DU FORMULAIRE D'OFFRE AVEC UN JETON

Tout doit fonctionner correctement maintenant. Désormais, l'utilisateur doit saisir le jeton pour l'offre. Mais comme nous ne voulons pas compter sur l'utilisateur pour fournir un jeton unique, le jeton de l'offre doit être généré automatiquement quand une nouvelle offre est créée. Créez une nouvelle méthode setTokenValue() de l'entité Job pour ajouter la logique qui génère le jeton avant qu'une nouvelle offre ne soit enregistrée:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
public function setTokenValue()
{
  if(!$this->getToken())
  {
    $this->token = sha1($this->getEmail().rand(11111, 99999));
  }
}
 
// ...

Ajoutez cette méthode au lifecycleCallbacks PrePersist pour l'entité Job:

# src/End/JobeetBundle/Resources/config/doctrine/Job.orm.yml
# ...
 
  lifecycleCallbacks:
    prePersist: [ setTokenValue, preUpload, setCreatedAtValue, setExpiresAtValue ]
    # ...

Régénérez les entités Doctrine pour appliquer cette modification:

php app/console doctrine:generate:entities EnsJobeetBundle

Vous pouvez maintenant supprimer le champ token du formulaire:

// src/Ens/JobeetBundle/Form/JobType.php
// ...
 
public function buildForm(FormBuilder $builder, array $options)
{
    $builder->add('category');
    $builder->add('type', 'choice', array('choices' => Job::getTypes(), 'expanded' => true));
    $builder->add('company');
    $builder->add('file', 'file', array('label' => 'Company logo', 'required' => false));
    $builder->add('url');
    $builder->add('position');
    $builder->add('location');
    $builder->add('description');
    $builder->add('how_to_apply', null, array('label' => 'How to apply?'));
    $builder->add('is_public', null, array('label' => 'Public?'));
    $builder->add('email');
}
 
// ...

Supprimez-le aussi des templates new.html.twig et edit.html.twig:

<!-- src/Ens/JobeetBundle/Resources/views/Job/new.html.twig -->
<tr>
  <th>{{ form_label(form.token) }}</th>
  <td>
    {{ form_errors(form.token) }}
    {{ form_widget(form.token) }}
  </td>
</tr>
<!-- src/Ens/JobeetBundle/Resources/views/Job/edit.html.twig -->
<tr>
  <th>{{ form_label(edit_form.token) }}</th>
  <td>
    {{ form_errors(edit_form.token) }}
    {{ form_widget(edit_form.token) }}
  </td>
</tr>

Et dans le fichier validation.yml:

# src/Ens/JobeetBundle/Resources/config/validation.yml
# ...

token:
    - NotBlank: ~

Si vous vous souvenez des scénarios du deuxième chapitre, une offre ne peut être modifiée que si l'utilisateur connaît le jeton associé. À l'heure actuelle, il est assez facile de modifier ou de supprimer n'importe quelle offre en essayant de deviner l'URL. C'est parce que l'URL saisie ressemble à /job/ID/edit, où ID est la clé primaire de l'offre.

Nous allons changer les routes de sorte que vous puissiez modifier ou supprimer une tâche uniquement si vous connaissez le jeton secret:

# src/End/JobeetBundle/Resources/config/routing/job.yml
# ...

ens_job_edit:
    pattern:  /{token}/edit
    defaults: { _controller: "EnsJobeetBundle:Job:edit" }
 
ens_job_update:
    pattern:  /{token}/update
    defaults: { _controller: "EnsJobeetBundle:Job:update" }
    requirements: { _method: post }
 
ens_job_delete:
    pattern:  /{token}/delete
    defaults: { _controller: "EnsJobeetBundle:Job:delete" }
    requirements: { _method: post }

Maintenant, modifiez JobController pour utiliser le jeton à la place de l'ID:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function editAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);
 
  if (!$entity) {
    throw $this->createNotFoundException('Unable to find Job entity.');
  }
 
  $editForm = $this->createForm(new JobType(), $entity);
  $deleteForm = $this->createDeleteForm($token);
 
  return $this->render('EnsJobeetBundle:Job:edit.html.twig', array(
    'entity'      => $entity,
    'edit_form'   => $editForm->createView(),
    'delete_form' => $deleteForm->createView(),
  ));
}
 
public function updateAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);
 
  if (!$entity) {
    throw $this->createNotFoundException('Unable to find Job entity.');
  }
 
  $editForm   = $this->createForm(new JobType(), $entity);
  $deleteForm = $this->createDeleteForm($token);
 
  $request = $this->getRequest();
 
  $editForm->bindRequest($request);
 
  if ($editForm->isValid()) {
    $em->persist($entity);
    $em->flush();
 
    return $this->redirect($this->generateUrl('ens_job_edit', array('token' => $token)));
  }
 
  return $this->render('EnsJobeetBundle:Job:edit.html.twig', array(
    'entity'      => $entity,
    'edit_form'   => $editForm->createView(),
    'delete_form' => $deleteForm->createView(),
  ));
}
 
public function deleteAction($token)
{
  $form = $this->createDeleteForm($token);
  $request = $this->getRequest();
 
  $form->bindRequest($request);
 
  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
    $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);
 
    if (!$entity) {
      throw $this->createNotFoundException('Unable to find Job entity.');
    }
 
    $em->remove($entity);
    $em->flush();
  }
 
  return $this->redirect($this->generateUrl('ens_job'));
}
 
private function createDeleteForm($token)
{
  return $this->createFormBuilder(array('token' => $token))
    ->add('token', 'hidden')
    ->getForm()
  ;
}

Dans le template d'offre show.html.twig, modifiez le paramètre de route ens_job_edit:

<a href="{{ path('ens_job_edit', { 'token': entity.token }) }}">

Faites de même pour la route ens_job_update dans le template d'offre edit.html.twig:

<form action="{{ path('ens_job_update', { 'token': entity.token }) }}" method="post" {{ form_enctype(edit_form) }}>

Maintenant, toutes les routes liées aux offres, à l'exception de job_show_user, incorporent le jeton. Par exemple, la route pour modifier une offre est maintenant sur le schéma suivant:

http://jobeet.local/job/TOKEN/edit

La page de prévisualisation

La page de prévisualisation est la même que l'affichage de la page d'offre. La seule différence est que la page de prévisualisation sera accessible à l'aide du jeton de l'offre plutôt que l'ID de l'offre:

# src/End/JobeetBundle/Resources/config/routing/job.yml
# ...
 
ens_job_show:
    pattern:  /{company}/{location}/{id}/{position}
    defaults: { _controller: "EnsJobeetBundle:Job:show" }
    requirements:
        id:  \d+
 
ens_job_preview:
    pattern:  /{company}/{location}/{token}/{position}
    defaults: { _controller: "EnsJobeetBundle:Job:preview" }
    requirements:
        token:  \w+
 
# ...

L'action preview (ici la différence avec l'action show, c'est que l'offre est récupérée à partir de la BDD en utilisant le jeton fourni à la place de l'ID):

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function previewAction($token)
{
  $em = $this->getDoctrine()->getEntityManager();
 
  $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);
 
  if (!$entity) {
    throw $this->createNotFoundException('Unable to find Job entity.');
  }
 
  $deleteForm = $this->createDeleteForm($entity->getId());
 
  return $this->render('EnsJobeetBundle:Job:show.html.twig', array(
    'entity'      => $entity,
    'delete_form' => $deleteForm->createView(),
  ));
}
 
// ...

Si l'utilisateur va à l'URL avec le jeton, nous allons ajouter une barre d'administration dans la partie supérieure. Au début du template show.html.twig, incluez un template pour mettre la barre d'administration et supprimez le lien Modifier en bas:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/show.html.twig -->
<!-- ... -->
 
{% block content %}
  {% if app.request.get('token') %}
    {% include 'EnsJobeetBundle:Job:admin.html.twig' with {'job': entity} %}
  {% endif %}
 
  <!-- ... -->
 
{% endblock %}

Ensuite, créez le template admin.html.twig:

<!-- /src/Ens/JobeetBundle/Resources/views/Job/admin.html.twig -->
 
<div id="job_actions">
  <h3>Admin</h3>
  <ul>
    {% if not job.isActivated %}
      <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Edit</a></li>
      <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Publish</a></li>
    {% endif %}
    <li>
      <form action="{{ path('ens_job_delete', { 'token': job.token }) }}" method="post">
        {{ form_widget(delete_form) }}
        <button type="submit" onclick="if(!confirm('Are you sure?')) { return false; }">Delete</button>
      </form>
    </li>
    {% if job.isActivated %}
      <li {% if job.expiresSoon %} class="expires_soon" {% endif %}>
        {% if job.isExpired %}
          Expired
        {% else %}
          Expires in <strong>{{ job.getDaysBeforeExpires }}</strong> days
        {% endif %}
 
        {% if job.expiresSoon %}
          - <a href="">Extend</a> for another 30 days
        {% endif %}
      </li>
    {% else %}
      <li>
        [Bookmark this <a href="{{ url('ens_job_preview', { 'token': job.token, 'company': job.companyslug, 'location': job.locationslug, 'position': job.positionslug }) }}">URL</a> to manage this job in the future.]
      </li>
    {% endif %}
  </ul>
</div>

Il y a beaucoup de code, mais la plupart du code est simple à comprendre.

Pour rendre le template plus lisible, nous avons ajouté un ensemble de raccourcis de méthodes dans la classe d'entité Job:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
public function isExpired()
{
  return $this->getDaysBeforeExpires() < 0;
}
 
public function expiresSoon()
{
  return $this->getDaysBeforeExpires() < 5;
}
 
public function getDaysBeforeExpires()
{
  return ceil(($this->getExpiresAt()->format('U') - time()) / 86400);
}

La barre d'administration affiche les différentes actions en fonction du statut de l'offre:

Nous allons maintenant rediriger les actions create et update de JobController ver la nouvelle page de prévisualisation:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...

public function createAction()
{
  // ...
 
  if ($form->isValid()) {
    // ...
 
    return $this->redirect($this->generateUrl('ens_job_preview', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'token' => $entity->getToken(),
      'position' => $entity->getPositionSlug()
    )));
  }
 
  // ...
}
 
public function updateAction($token)
{
  // ...
 
  if ($editForm->isValid()) {
    // ...
 
    return $this->redirect($this->generateUrl('ens_job_preview', array(
      'company' => $entity->getCompanySlug(),
      'location' => $entity->getLocationSlug(),
      'token' => $entity->getToken(),
      'position' => $entity->getPositionSlug()
    )));
  }
 
  // ...
}

Activation et publication des offres

Dans la section précédente, il y a un lien pour publier l'offre. Le lien doit être modifié pour pointer vers une nouvelle action publish. Pour cela, nous allons créer une nouvelle route:

# src/Ens/JobeetBundle/Resources/config/routing/job.yml
# ...
 
ens_job_publish:
    pattern:  /{token}/publish
    defaults: { _controller: "EnsJobeetBundle:Job:publish" }
    requirements: { _method: post }

Nous pouvons maintenant modifier le lien "Publish" (nous allons utiliser un formulaire ici, comme lors de la suppression d'une offre, nous aurons donc une requête POST):

<!-- src/Ens/JobeetBundle/Resources/views/job/admin.html.twig -->
<!-- ... -->
 
{% if not job.isActivated %}
  <li><a href="{{ path('ens_job_edit', { 'token': job.token }) }}">Edit</a></li>
  <li>
    <form action="{{ path('ens_job_publish', { 'token': job.token }) }}" method="post">
      {{ form_widget(publish_form) }}
      <button type="submit">Publish</button>
    </form>
  </li>
{% endif %}
 
<!-- ... -->

La dernière étape consiste à créer l'action publish, le formulaire publish et modifier l'action preview pour envoyer le formulaire publish vers le template:

// src/Ens/JobeetBundle/Controller/JobController.php
// ...
 
public function previewAction($token)
{
  // ...
 
  $deleteForm = $this->createDeleteForm($entity->getId());
  $publishForm = $this->createPublishForm($entity->getToken());
 
  return $this->render('EnsJobeetBundle:Job:show.html.twig', array(
    'entity'      => $entity,
    'delete_form' => $deleteForm->createView(),
    'publish_form' => $publishForm->createView(),
  ));
}
 
public function publishAction($token)
{
  $form = $this->createPublishForm($token);
  $request = $this->getRequest();
 
  $form->bindRequest($request);
 
  if ($form->isValid()) {
    $em = $this->getDoctrine()->getEntityManager();
    $entity = $em->getRepository('EnsJobeetBundle:Job')->findOneByToken($token);
 
    if (!$entity) {
      throw $this->createNotFoundException('Unable to find Job entity.');
    }
 
    $entity->publish();
    $em->persist($entity);
    $em->flush();
 
    $this->get('session')->setFlash('notice', 'Your job is now online for 30 days.');
  }
 
  return $this->redirect($this->generateUrl('ens_job_preview', array(
    'company' => $entity->getCompanySlug(),
    'location' => $entity->getLocationSlug(),
    'token' => $entity->getToken(),
    'position' => $entity->getPositionSlug()
  )));
}
 
private function createPublishForm($token)
{
  return $this->createFormBuilder(array('token' => $token))
    ->add('token', 'hidden')
    ->getForm()
  ;
}
 
// ...

La méthode publishAction() utilise une nouvelle méthode publish() qui peut être définie comme suit:

// src/Ens/JobeetBundle/Entity/Job.php
// ...
 
public function publish()
{
  $this->setIsActivated(true);
}
 
// ...

Vous pouvez maintenant tester la nouvelle fonctionnalité de publication dans votre navigateur.

Mais nous avons encore quelque chose à corriger. Les emplois non-activés ne doivent pas être accessibles, ce qui signifie qu'ils ne doivent pas apparaître sur la page d'accueil Jobeet et ne doivent pas être accessibles via leur URL. Nous devons modifier les méthodes de JobRepository pour ajouter cette exigence:

// src Ens/JobeetBundle/Repository/JobRepository.php
 
namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;
 
class JobRepository extends EntityRepository
{
  public function getActiveJobs($category_id = null, $max = null, $offset = null)
  {
    $qb = $this->createQueryBuilder('j')
    ->where('j.expires_at > :date')
    ->setParameter('date', date('Y-m-d H:i:s', time()))
    ->andWhere('j.is_activated = :activated')
    ->setParameter('activated', 1)
    ->orderBy('j.expires_at', 'DESC');
 
    if($max)
    {
      $qb->setMaxResults($max);
    }
 
    if($offset)
    {
      $qb->setFirstResult($offset);
    }
 
    if($category_id)
    {
      $qb->andWhere('j.category = :category_id')
        ->setParameter('category_id', $category_id);
    }
 
    $query = $qb->getQuery();
 
    return $query->getResult();
  }
 
  public function countActiveJobs($category_id = null)
  {
    $qb = $this->createQueryBuilder('j')
    ->select('count(j.id)')
    ->where('j.expires_at > :date')
    ->setParameter('date', date('Y-m-d H:i:s', time()))
    ->andWhere('j.is_activated = :activated')
    ->setParameter('activated', 1);
 
    if($category_id)
    {
      $qb->andWhere('j.category = :category_id')
        ->setParameter('category_id', $category_id);
    }
 
    $query = $qb->getQuery();
 
    return $query->getSingleScalarResult();
  }
 
  public function getActiveJob($id)
  {
    $query = $this->createQueryBuilder('j')
      ->where('j.id = :id')
      ->setParameter('id', $id)
      ->andWhere('j.expires_at > :date')
      ->setParameter('date', date('Y-m-d H:i:s', time()))
      ->andWhere('j.is_activated = :activated')
      ->setParameter('activated', 1)
      ->setMaxResults(1)
      ->getQuery();
 
    try {
      $job = $query->getSingleResult();
    } catch (\Doctrine\Orm\NoResultException $e) {
      $job = null;
    }
 
    return $job;
  }
}

Pareil pour la méthode getWithJobs() de CategoryRepository:

// src/Ens/JobeetBundle/Repository/CategoryRepository.php
 
namespace Ens\JobeetBundle\Repository;
use Doctrine\ORM\EntityRepository;
 
class CategoryRepository extends EntityRepository
{
  public function getWithJobs()
  {
    $query = $this->getEntityManager()->createQuery(
      'SELECT c FROM EnsJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date AND j.is_activated = :activated'
    )->setParameter('date', date('Y-m-d H:i:s', time()))->setParameter('activated', 1);
 
    return $query->getResult();
  }
}

Vous pouvez maintenant le tester dans votre navigateur. Tous les emplois non-activés ont disparu de la page d'accueil, même si vous connaissez son URL, ils ne sont plus accessibles. Ils sont cependant accessibles si l'on connaît le jeton de l'offre. Dans ce cas, l'aperçu de l'offre sera affiché avec la barre d'administration.


Chapitre précédent Chapitre suivant


Une question ? Une réaction ?

comments powered by Disqus