Custom Poll module - part 1 - Présentation et Initialisation

Posté le: sam 16/03/2019 - 11:57 Par: rcowebdev

c’est en forgeant qu’on devient forgereon et bien je vous propose de créer un module de sondage, oui ca existe déjà, je sais, mais je pense que ca va couvrir pas mal de notions Drupalienne 8 (je ne vais pas m’attarder sur les problématiques d’ACL, de traduction, de design, etc). Je ne passe pas non plus par le module squeleton ou Console afin de bien comprendre ce que je suis en train de faire.

Je fais le module en même temps que j’écris ces lignes, tout ne sera pas parfait (soyez indulgents).

Le module de sondage proposera :

  • en FO :

    • voir une question posée

    • sélectionner une réponse

    • voir les résultats du sondage, un vote par utilisateur / IP (l’idéal serait de passer par un compte utilisateur mais bon)

    • Afficher le bloc Poll

  • en BO

    • créer un sondage / une question et lui assigner des réponses

    • créer des réponses

    • créer un bloc Poll avec le formulaire de configuration qui va bien

    • positionner le bloc Poll où l’on veut

    • avoir les résultats de chaque question par réponse une fois le vote soumis

On aurait en BDD

  • poll_question : table regroupant les questions
  • poll_answer : table regroupant les réponses
  • poll_question_answer : table de liaison question / réponses
  • poll_result : table regroupant les résultats

Bon let’s go, on commence par créer le module (qui se nomme Poll pour ceux du fond qui ne suivent pas).

mkdir modules/custom/poll
touch modules/custom/poll/poll.info.yml

poll.info.yml

name: Poll Module
description: Exercice Poll module
package: Custom

type: module
core: 8.x
touch modules/custom/poll/poll.install

On crée les tables qu’il faut via poll.install

<?php
use Drupal\Core\Database\Database;

/**
* Implements hook_schema().
*
* Defines the database tables used by this module.
*
* @see hook_schema()
*
* @ingroup poll
*/
function poll_schema() {
  $schema['poll_question'] = [
    'description' => 'Poll questions',
    'fields' => [
      'id' => [
        'type' => 'serial',
        'not null' => TRUE,
        'description' => 'Question ID',
      ],
      'name' => [
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
        'description' => 'Question label',
      ]
    ],
    'primary key' => ['id']
  ];

  $schema['poll_answer'] = [
    'description' => 'Poll answers',
    'fields' => [
      'id' => [
        'type' => 'serial',
        'not null' => TRUE,
        'description' => 'Answer ID',
      ],
      'name' => [
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
        'description' => 'Answer label',
      ]
    ],
    'primary key' => ['id']
  ];

  $schema['poll_question_answer'] = [
    'description' => 'Poll question / answer',
    'fields' => [
      'id' => [
        'type' => 'serial',
        'not null' => TRUE,
        'description' => 'ID',
      ],
      'answer_id' => [
        'type' => 'int',
        'not null' => TRUE,
        'description' => 'Answer ID',
      ],
      'question_id' => [
        'type' => 'int',
        'not null' => TRUE,
        'description' => 'Question ID',
      ],
    ],
    'primary key' => ['id']
  ];

  $schema['poll_result'] = [
    'description' => 'Poll results',
    'fields' => [
      'id' => [
        'type' => 'serial',
        'not null' => TRUE,
        'description' => 'Result ID',
      ],
      'answer_id' => [
        'type' => 'int',
        'not null' => TRUE,
        'description' => 'Answer ID',
      ],
      'question_id' => [
        'type' => 'int',
        'not null' => TRUE,
        'description' => 'Question ID',
      ],
      'value' => [
        'type' => 'int',
        'not null' => TRUE,
        'description' => 'Value',
      ],
    ],
    'primary key' => ['id']
  ];
  return $schema;
}

/**
* uninstall
*/
function poll_uninstall() {
  $db = Database::getConnection()
    ->schema();

    $db->dropTable('poll_question');
    $db->dropTable('poll_answer');
    $db->dropTable('poll_question_answer');
    $db->dropTable('poll_result');
}

Ce genre de modification se fait à l’installation du module mais ca devient vite relou de désinstaller puis réinstaller le module via l’interface pour voir que ca ne fonctionne toujours pas, DRUSH a prévu le coup mais ce que je ne savais pas c’est que sa nomenclature a changé depuis D7, donc on oublie

drush dis

au profit de

drush pm-uninstall

il le dit clairement en plus donc bon, pourquoi pas.

Drupal 8 does not support disabling modules. Use pm-uninstall instead.

Histoire d’être gentil, j’ai ajouté la suppression des tables lors de la désinstallation. On a les tables qui vont biens, on s’attaque au BO pour les populer !

On va commencer par celui de la gestion des réponses (le plus simple)

  • Lister / Créer / Supprimer et modifier les réponses

il nous faut pour le moment deux fichiers; un de routing et un autre pour le menu

  • poll.links.menu.yml

  • poll.routing.yml

poll.routing.yml

poll.answer.overview:
  path: '/admin/poll_answer/list'
  defaults:
    _controller: '\Drupal\poll\Controller\AnswerController::overview'
    _title: 'Poll Answers'
  requirements:
    _permission: 'administer poll'
poll.answer.delete:
  path: '/admin/poll_answer/delete/{id}'
  defaults:
    _form: '\Drupal\poll\Form\Answer\DeleteForm'
    _title: 'Poll Answer delete'
  requirements:
    _permission: 'administer poll'

poll.links.menu.yml

poll.answer:
  title: 'Poll Answer'
  description: 'Administer Poll s answers'
  route_name: poll.answer.overview
  parent: poll.question.overview
  weight: -1

A chaque modification de fichiers de configuration, bien penser à vider les caches. Ah et pour info,

drush cc

de D7 a été remplacé par

drush cache-rebuild

(je viens tout juste – encore - de me faire avoir :/)

Pour la liste des réponses en BO, il nous faut créer un dossier Controller dans lequel on place notre controller Answer

src/Controller/AnswerController.php

<?php

namespace Drupal\poll\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Answer controller.
 */
class AnswerController extends ControllerBase {

  /**
   * The database connection.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $connection;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('database')
    );
  }

  /**
   * Construct
   *
   * @param \Drupal\Core\Database\Connection $databaseConnection
   */
  public function __construct(Connection $connection) {
    $this->connection = $connection;
  }

  /**
  * overview
  */
  public function overview() {

    $rows = [];

    $header = [
      [
        'data' => $this->t('ID'),
        'field' => 'pa.id',
        'class' => [RESPONSIVE_PRIORITY_MEDIUM],
      ],
      [
        'data' => $this->t('Name'),
        'field' => 'pa.name',
        'class' => [RESPONSIVE_PRIORITY_MEDIUM],
      ],
      [
        'data' => $this->t('Operations'),
        'class' => [RESPONSIVE_PRIORITY_LOW],
      ],
    ];

    $query = $this->connection->select('poll_answer', 'pa')
      ->extend('\Drupal\Core\Database\Query\PagerSelectExtender')
      ->extend('\Drupal\Core\Database\Query\TableSortExtender');
    $query->fields('pa', [
      'id',
      'name'
    ]);
    
    $pollAnwers = $query
      ->limit(50)
      ->orderByHeader($header)
      ->execute();

    foreach ($pollAnwers as $pollAnswer) {
      $rows[] = [
        'data' => [
        	$pollAnswer->id,
          	$this->t($pollAnswer->name),
          	$this->l($this->t('Edit'), new Url('poll.answer.edit', ['id' => $pollAnswer->id])),
            $this->l($this->t('Delete'), new Url('poll.answer.delete', ['id' => $pollAnswer->id]))
        ]
      ];
    }

    $build['poll_answer_table'] = [
      '#type' 	=> 'table',
      '#header' => $header,
      '#rows' 	=> $rows,
      '#empty' 	=> $this->t('No answer available.'),
    ];
    $build['poll_answer_pager'] = ['#type' => 'pager'];

    return $build;
  }
}

Bon là il ne récupère aucune donnée, normal, la table est vide, ajoutons un peu de données de test en BDD (oui à la bourrin)

DELETE FROM poll_answer;

INSERT INTO poll_answer (name) VALUES
('je vais bien, merci')
,('bof, la création de module ce n\’est pas simple')
,('l\'enfer mec !')
,('J\' aime bien')
,('Je ne suis pas trop fan')
,('Beurk')
,('Lundi')
,('Mardi')
,('Mercredi')
,('Jeudi')
,('Vendredi');

Hey, on a un joli tableau listant nos enregistrements via la route

/admin/poll_answer/list

Certes il est beau, mais il s’agirait quand même de pouvoir agir un peu dessus.

On va se créer un petit formulaire qui va gérer la création et la modification de nos réponses. Dans le fichier de routing, on y ajoute

poll.routing.yml

poll.question.edit:
  path: '/admin/poll_question/edit/{id}'
  defaults:
    _form: '\Drupal\poll\Form\Question\EditForm'
    _title: 'Poll Question edit'
    id: null
  requirements:
    _permission: 'administer poll'

Bien noté id : null dans le noeud défaults, car comme je l’ai dit on veut l’ajout (sans id) et l’édition (avec id). J’ai croisé des modules du core qui préféraient créer 2 classes, à chaque classe sa responsabilité après tout, soite, mais bon, pour l’exercice ce n’était pas nécessaire (Rq si vous ne mettez pas ce paramètre, ce sera la 404 direct).

A noter aussi le noeud _form qui vient remplacer le noeud _controller (là par contre, j’ai tiqué, et je m’inspire du core pour écrire ce module, à priori ces Forms ne passent pas par des controlleurs mais ont une logique bien à eux (et vu qu’ils sont parfois dans les fichiers de routing, ils en deviennent “presque” des controlleurs, enfin c’est l’impression que ca m’a donné).

Remarque la structure du FormBase comfirme un peu ma théorie.

use DependencySerializationTrait;
use LinkGeneratorTrait;
use LoggerChannelTrait;
use MessengerTrait;
use RedirectDestinationTrait;
use StringTranslationTrait;
use UrlGeneratorTrait;
...
validateForm
...
buildForm
...
submitForm
…

Bref, la classe EditForm ressemble à ca

src/Form/Answer/EditForm.php

<?php

namespace Drupal\Poll\Form\Answer;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Url;

/**
 * Configure answer edit form
 */
class EditForm extends FormBase {

    /**
     * The database connection.
     *
     * @var \Drupal\Core\Database\Connection
     */
    protected $connection;

    /**
     * Answer id
     *
     * @var int
     */
    protected $answerId = null;

    /**
     * Constructs
     *
     * @param \Drupal\Core\Database\Connection $connection The database connection
     */
    public function __construct(Connection $connection) {
        $this->connection = $connection;
    }

    /**
     * {@inheritdoc}
     */
    public function getCancelUrl() {
        return new Url('poll.answer.overview');
    }

    /**
     * create
     *
     * @param ContainerInterface $container
     */
    public static function create(ContainerInterface $container) {
        return new static(
            $container->get('database')
        );
    }

    /**
     * {@inheritdoc}
     */
    public function getFormId() {
        return 'poll_admin_answer';
    }

    /**
     * @param array              $form
     * @param FormStateInterface $form_state
     *
     * @return void
     */
    public function validateForm(array &$form, FormStateInterface $form_state) {
      if (strlen($form_state->getValue('poll_answer')) == 0) {
        $form_state->setErrorByName('poll_answer', $this->t('You must specify an answer (alphanumeric).'));
      }
    }

    /**
     * @param array              $form
     * @param FormStateInterface $form_state
     * @param int|null           $answerId
     *
     * @return array
     */
    public function buildForm(array $form, FormStateInterface $form_state, $id = null) {
        $this->answerId     = $id;
        $editedAnswer       = $this->getAnswer();

        if ($id > 0 && !$editedAnswer) {
            throw new \Exception($this->t('Poll Answer - The answer provided does not exist'));
        }

        $form['poll_answer'] = [
            '#type'             => 'textfield',
            '#title'            => $this->t('Answer'),
            '#default_value'    => $editedAnswer,
            '#required'         => true
        ];

        $form['submit'] = [
            '#type'             => 'submit',
            '#title'            => $this->t('Save'),
            '#default_value'    => "Save",
        ];

        return $form;
    }

    /**
     * {@inheritdoc}
     */
    public function submitForm(array &$form, FormStateInterface $form_state) 
    {
        $answer = $form_state->getValue('poll_answer');
        
        if ($this->answerId > 0) {
            $this->connection->update('poll_answer')
            ->fields(['name' => $answer])
            ->condition('id', $this->answerId, "=")
            ->execute();

            $this->messenger()->addMessage($this->t('The answer has been updated'));
        } else {
            $this->connection->insert('poll_answer')
            ->fields(['name' => $answer])
            ->execute();

            $this->messenger()->addMessage($this->t('The answer has been created'));
        }

        $response = Url::fromRoute('poll.answer.overview');
        $form_state->setRedirectUrl($response);
    }

    /**
     *
     * @return mixed
     */
    private function getAnswer() {
        if (is_null($this->answerId))
            return null;

        return $this->connection->select('poll_answer')
            ->fields('poll_answer', ['name'])
            ->condition('id', $this->answerId, "=")
            ->execute()
            ->fetchAll()[0]
            ->name;
    }
}

Pourquoi ->fetchAll()[0] ? Parce que je n'ai pas trouvé de méthode genre findOneBy(), en terme de performance ca ne change rien donc bon. 

On va maintenant créer un bouton d’action sur la liste des réponses “Add an answer”. Pour cela il nous faut créer un fichier poll.links.action.yml.

poll.links.action.yml

poll.answer.edit:
  route_name: poll.answer.edit
  title: 'Add answer'
  appears_on:
    - poll.answer.overview

On se rend compte assez vite que les fichiers poll.routing.yml, poll.links.menu.yml et poll.links.action.yml sont étroitements liés notamment au niveau du nommage de certains noeuds.

On va maintenant ajouter une action de suppression de nos réponses via le controller et ajouter une classe Form/Answer/DeleteForm qui étend la classe ConfirmFormBase et elle ressemble à ca

src/Form/Answer/DeleteForm.php

<?php

namespace Drupal\Poll\Form\Answer;

use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Url;
use Drupal\Core\Database\Connection;

class DeleteForm extends ConfirmFormBase {

	/**
	 * The database connection.
	 *
	 * @var \Drupal\Core\Database\Connection
	 */
	protected $connection;

	/**
	 * Answer to delete
	 *
	 * @var int
	 */
	protected $answerId = null;

	/**
	 * Constructs
	 *
	 * @param \Drupal\Core\Database\Connection $connection The database connection
	 */
	public function __construct(Connection $connection) {
		$this->connection = $connection;
	}

	/**
	 * create
	 *
	 * @param ContainerInterface $container
	 */
	public static function create(ContainerInterface $container) {
		return new static(
			$container->get('database')
		);
	}

	/**
	 * {@inheritdoc}
	 */
	public function getFormId() {
	    return 'poll_admin_answer';
	}

	/**
	 * {@inheritdoc}
	 */
	public function getQuestion() {
		return $this->t('Do you really want to remove this answer ?');
	}

	/**
	 * {@inheritdoc}
	 */
	public function getCancelUrl() {
		return new Url('poll.answer.overview');
	}

	/**
	 * {@inheritdoc}
	 */
	public function buildForm(array $form, FormStateInterface $form_state, $id = null) {
		$this->answerId = $id;
		return parent::buildForm($form, $form_state);
	}

	/**
	 * {@inheritdoc}
	 */
	public function submitForm(array &$form, FormStateInterface $form_state) {
		$this->connection->delete('poll_answer')
			->condition('id', $this->answerId)
			->execute();

		$this->messenger()->addMessage($this->t('The answer has been deleted'));
		
		$response = Url::fromRoute('poll.answer.overview');
		$form_state->setRedirectUrl($response); 
	}
}

Pour info, la subtilité qui m’avait échappé au début, comment diable le code peut il se souvenir de la variable answerId settée dans la méthode buildForm pour l’utiliser dans la méthode submitForm alors que ce n’est pas la même éxécution, en fait la méthode buildForm est exécutée lors de la construction du formulaire MAIS AUSSI avant la méthode submitForm lors de la soumission du formulaire (donc pas vraiment de magie, je suis décu :).

En passant, les méthodes

	/**
	 * {@inheritdoc}
	 */
	public function getFormId() {
	    return 'poll_admin_answer';
	}

	/**
	 * {@inheritdoc}
	 */
	public function getQuestion() {
		return $this->t('Do you really want to remove this answer ?');
	}

	/**
	 * {@inheritdoc}
	 */
	public function getCancelUrl() {
		return new Url('poll.answer.overview');
	}

sont obligatoires pour respecter le contrat d’interface.

Du coup, on commence à être bon sur les réponses

  • Ajout / Edition / Suppression

  • Onglet dans le menu

  • Listing

Mais pas tout à fait, il nous manque

  • redirection sur la liste après soumission

$response = Url::fromRoute('poll.answer.overview');
$form_state->setRedirectUrl($response);
  • Ajouter un flash message (erreur ou succès)
$this->messenger()->addMessage($this->t('The answer has been created'));

On va pouvoir passer aux questions :)

Mots clés
Créer un module Drupal 8
Drupal 8
Dossier