IncludeBeer

Création d'une application web de base avec CodeIgniter 4 (5e partie)

Publié le 28 décembre 2020
Image de l'article
Origine de l'image: Bram Naus

Dans la quatrième partie nous avons amélioré la présentation de la liste de recettes avec la pagination. Maintenant, nous allons l'améliorer un peu plus en ajoutant un formulaire de recherche. Le formulaire contient deux champs texte : un champ pour faire une recherche textuelle et un champ pour spécifier le nombre de recettes à afficher par page.

Formulaire de recherche

Pour créer le formulaire de recherche, on pourrait écrire directement le code HTML, mais nous allons tirer profit de l'ensemble de fonctions pour la gestion des formulaires (form helper). Les fonctions form_open() et form_close() sont utilisées pour créer les tags HTML <form> et </form>. Le premier paramètre est l'URI vers lequel soumettre le formulaire. Le deuxième paramètre est pour les attributs HTML du formulaire. Nous allons donc ouvrir le formulaire avec cette ligne :

<?= form_open('/', ['class' => 'form-inline']) ?>

Le framework s'occupe de générer l'URL complet, ce qui va transformer "/" en "http://ci4.test:8888/". Ensuite il ajoute les attributs qu'on lui a demandé d'ajouter, dans cet exemple il va ajouter un attribut pour la classe Bootstrap form-inline. Puisqu'on ne l'a pas spécifiée dans la liste d'attributs, la méthode par défaut est post et la valeur de accept-charset est la valeur du paramètre $charset du fichier de configuration de l'application app/Config/App.php. Ceci va générer la ligne suivante :

<form action="http://ci4.test:8888/" class="form-inline" method="post" accept-charset="utf-8">

Pour créer des champs textes, on utilise la fonction form_input(). Le premier paramètre est le nom du champ. Le deuxième paramètre est sa valeur. Ici on utilise l'opérateur ?? par mesure de précaution pour lui passer une chaîne vide au cas où $rech['texte'] ne serait pas défini. Tout comme pour form_open(), le paramètre suivant est un tableau d'attributs HTML à ajouter au champ <input>. On ajoute donc des classes Bootstrap pour avoir un visuel un peu plus intéressant et un placeholder qui va afficher le mot "Texte" dans la zone de texte. Pour le second champ on ajoute aussi un id et un style CSS :

<?= form_input('rech_texte',
               $rech['texte'] ?? '',
               ['class' => 'form-control my-1 mr-3', 'placeholder' => "Texte"]) ?>

<?= form_input('rech_nb_par_page',
               $rech['nb_par_page'] ?? '',
               ['id' => 'rech_nb_par_page', 'class' => 'form-control my-1 mr-3', 'style' => 'width:70px']) ?>

Ceci va générer les lignes suivantes :

<input type="text" name="rech_texte" value="" class="form-control my-1 mr-3" placeholder="Texte">
<input type="text" name="rech_nb_par_page" value="" id="rech_nb_par_page" class="form-control my-1 mr-3" style="width:70px">

Si vous préférez, vous pouvez tout simplement passer un tableau avec tous les attributs dans le premier paramètre de la fonction form_input() au lieu d'utiliser les deux premiers pour le nom et la valeur :

<?= form_input([
        'name' => 'rech_texte',
        'value' => $rech['texte'] ?? '',
        'class' => 'form-control my-1 mr-3',
        'placeholder' => "Texte"
    ]) ?>

Pour le <label> on utilise la fonction form_label(). Le premier paramètre est le texte à afficher, le second est le id auquel ce label fait référence et le dernier est un tableau d'attributs HTML supplémentaires :

<?= form_label("Nombre par page", 'rech_nb_par_page', ['class' => 'my-1 mr-2']) ?>

Ceci va générer la ligne suivante :

<label for="rech_nb_par_page" class="my-1 mr-2">Nombre par page</label>

Pour le bouton on utilise la fonction form_submit().

<?= form_submit('rech_submit',
                "Recherche",
                ['class' => 'btn btn-outline-primary my-1']) ?>

Ceci va générer la ligne suivante :

<input type="submit" name="rech_submit" value="Recherche" class="btn btn-outline-primary my-1">

Voici la vue complète avec le nouveau formulaire de recherche :

app/Views/liste_recettes.php

<?php
/**
 * @var string $titre_page                       Le titre de la page (créée automatiquement par CI via le tableau $data)
 * @var string $sous_titre_page                  Le sous-titre de la page (créée automatiquement par CI via le tableau $data)
 * @var array $rech                              Critères de recherche
 * @var array $recettes                          Liste des recettes (créée automatiquement par CI via le tableau $data)
 * @var App\Entities\Recette $recette            Une recette (créée par l'instruction foreach)
 * @var \CodeIgniter\Pager\PagerRenderer $pager  Instance de la classe de pagination
 */
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<title><?= esc($titre_page) ?></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
      integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk"
      crossorigin="anonymous">

<style type="text/css">
.titre
{
    padding: 3rem 1.5rem;
}

article
{
    padding: 1.5rem 1.5rem;
}
</style>

</head>

<body>

<main role="main" class="container">
    <div class="titre">
        <h1>
            <?= esc($titre_page) ?>
            <small class="text-muted"><?= esc($sous_titre_page) ?></small>
        </h1>
    </div>

    <div class="container">

    <h3>Liste des recettes</h3>
    <div class="my-3">
        <?= form_open('/', ['class' => 'form-inline']) ?>
            <?= form_input('rech_texte',
                           $rech['texte'] ?? '',
                           ['class' => 'form-control my-1 mr-3', 'placeholder' => "Texte"]) ?>

            <?= form_label("Nombre par page", 'rech_nb_par_page', ['class' => 'my-1 mr-2']) ?>

            <?= form_input('rech_nb_par_page',
                           $rech['nb_par_page'] ?? '',
                           ['id' => 'rech_nb_par_page', 'class' => 'form-control my-1 mr-3', 'style' => 'width:70px']) ?>

            <?= form_submit('rech_submit',
                            "Recherche",
                            ['class' => 'btn btn-outline-primary my-1']) ?>
        <?= form_close() ?>
        </div>

        <ul>
<?php foreach ($recettes as $recette): ?>
            <li><?= anchor('recette/' . $recette->slug, $recette->titre) ?></li>
<?php endforeach; ?>
        </ul>

        <?= $pager->links('default', 'bootstrap') ?>

    </div>

</main>

<footer>
    <p class="text-center">&copy; 2020 Mon site de recettes</p>
</footer>

</body>
</html>

La route get/post

Le formulaire de recherche est soumis vers la route '/' avec la méthode post. On doit donc modifier le fichier de configuration des routes, sinon on va se retrouver avec une erreur 404. On pourrait ajouter la route post ainsi :

$routes->get('/', 'RecettesController::index');
$routes->post('/', 'RecettesController::index');

Mais le plus simple, est d'utiliser la fonction match() pour associer la même route aux méthodes post et get en une seule commande. On va donc remplacer la route vers '/' par cette ligne :

$routes->match(['get', 'post'], '/', 'RecettesController::index');

Voici la nouvelle liste de routes, incluant maintenant la route pour soumettre le formulaire de recherche :

app/Config/Routes.php

$routes->addPlaceholder('slug', '[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*');
$routes->match(['get', 'post'], '/', 'RecettesController::index');
$routes->get('recette/(:num)', 'RecettesController::recetteParId/$1');
$routes->get('recette/(:slug)', 'RecettesController::recetteParSlug/$1');

Le contrôleur RecettesController

Maintenant qu'on peux soumettre un formulaire, on doir lire les valeurs soumises. La route '/' est dirigée vers la fonction index() du contrôleur RecettesController pour les deux méthodes get et post. Cette fonction se charge d'afficher la liste et c'est aussi à cette fonction que le formulaire est envoyé pour filtrer la liste. C'est donc à cet endroit qu'on obtient les valeurs du formulaire. La fonction $this->request->getMethod() nous informe de quel type de requête s'il s'agit. Si c'est une requête post, on obtient les valeurs du formulaire avec $this->request->getPost(). Il suffit de passer le nom du champ en paramètre. Tous les critères de recherche sont sauvegardés dans la variable $rech de type array :

if ($this->request->getMethod() === 'post')
{
    $rech = [
        'texte' => $this->request->getPost('rech_texte'),
        'nb_par_page' => $this->request->getPost('rech_nb_par_page'),
    ];
}

On passe la variable $rech en paramètre à la fonction getListeRecettes($rech) pour que la recherche et la pagination tiennent compte de nos critères de recherche. On la passe aussi à la vue pour que le formulaire soit automatiquement remplis avec les valeurs de la dernière recherche effectuée.

Présentement, les critères de recherche sont seulement obtenus au moment de soumettre une nouvelle recherche. Si on passe à la page suivante après avoir fait une recherche, on affiche la liste complète car le formulaire n'est pas soumis quand on clique sur les liens de la pagination. On va donc sauvegarder les critères dans la session. Pour sauvgarder un item, il suffit d'appeler session()->set() avec le nom et la valeur de l'item. Pour récupérer cette valeur, il suffit d'appeller session() avec le nom de l'item :

// Sauver les critères de recherche dans la session
session()->set('rech_recette', $rech);

// Obtenir les critères de recherche de la session
$rech = session('rech_recette');

Donc, après avoir vérifié si un formulaire de recherche a été soumis, on va vérifier si une recherche a préalablement été sauvegardée dans la session. Si c'est le cas, on va l'obtenir, sinon on définit un critère vide. Pour terminer, on fait un minimum de validation pour le nombre de pages. Si on reçoit 0, un nombre négatif ou aucune valeur, on prend la valeur par défaut de la configuration. On limite le nombre à 100 items maximum par page pour éviter qu'un utilisateur demande la base de données au complet en entrant un nombre énorme. Dernière chose, il faut charger les fonctions de formulaire avec helper('form') pour qu'elles soient disponibles quand on va charger la vue. Ceci a pour effet de charger le fichier system/Helpers/form_helper.php. Voici le contrôlleur dans son entièreté avec tous ces changements :

app/Controllers/RecettesController.php

<?php namespace App\Controllers;

use App\Libraries\MesRecettes;

class RecettesController extends BaseController
{
    /**
     * Liste des recettes
     * @return string
     */
    public function index()
    {
        // Si un formulaire de recherche a été soumis
        if ($this->request->getMethod() === 'post')
        {
            // Obtenir les critères de recherche du formulaire
            $rech = [
                'texte' => $this->request->getPost('rech_texte'),
                'nb_par_page' => $this->request->getPost('rech_nb_par_page'),
            ];
        }
        // Sinon, si des critères de recherche ont été sauvegardés dans la session
        else if (session('rech_recette') !== null)
        {
            // Obtenir les critères de recherche de la session
            $rech = session('rech_recette');
        }
        else
        {
            // Critères de recherche par défaut
            $rech = [
                'texte' => null,
                'nb_par_page' => null,
            ];
        }

        if ($rech['nb_par_page'] !== null)
        {
            // Convertir la valeur en 'int' (nombre entier)
            $rech['nb_par_page'] = (int)$rech['nb_par_page'];

            // Si négatif ou 0, mettre null pour prendre la valeur définit dans
            // la configuration du "Pager"
            if ($rech['nb_par_page'] <= 0)
            {
                $rech['nb_par_page'] = null;
            }

            // Maximum de 100 recettes par page
            if ($rech['nb_par_page'] > 100)
            {
                $rech['nb_par_page'] = 100;
            }
        }

        // Sauver les critères de recherche dans la session
        session()->set('rech_recette', $rech);

        // Créer une instance de notre librairie
        $mesRecettes = new MesRecettes();

        // Rassembler toutes les données utilisées par la vue dans un tableau $data
        $data = [
            'titre_page' => "Mes recettes",
            'sous_titre_page' => "Je vous présente mes recettes favorites...",
            'recettes' => $mesRecettes->getListeRecettes($rech),
            // Passer les critères de recherche à la vue
            'rech' => $rech,
            // Passer l'instance de la classe de pagination à la vue
            'pager' => $mesRecettes->recetteModel->pager,
        ];

        // Charger les fonctions utilitaires pour les formulaires
        helper('form');

        /* Chacun des items du tableau $data sera accessible dans la vue
         * par des variables portant le même nom que la clé :
         * $titre_page, $sous_titre_page, $recettes, $rech et $pager
         */
        return view('liste_recettes', $data);
    }

    /**
     * Une seule recette
     * @param int $id
     * @return string
     */
    public function recetteParId (int $id)
    {
        // Créer une instance de notre librairie
        $mesRecettes = new MesRecettes();

        $data = [];

        /* Obtenir la recette pour l'id reçu en paramètre.
         * Si la recette n'existe pas, on emet une exception de page non trouvée (erreur 404)
         */
        if ( ! $data['recette'] = $mesRecettes->getRecetteParId($id))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recette', $data);
    }

    /**
     * Une seule recette
     * @param string $slug
     * @return string
     */
    public function recetteParSlug (string $slug)
    {
        // Créer une instance de notre librairie
        $mesRecettes = new MesRecettes();

        $data = [];

        /* Obtenir la recette pour l'id reçu en paramètre.
         * Si la recette n'existe pas, on emet une exception de page non trouvée (erreur 404)
         */
        if ( ! $data['recette'] = $mesRecettes->getRecetteParSlug($slug))
        {
            throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
        }

        return view('recette', $data);
    }
}

La librairie MesRecettes

La seule chose qui reste à faire c'est de modifier la librairie MesRecettes pour utiliser les critères de recherche dans l'obtention des données. On reçoit les critères de recherche via un nouveau paramètre : getListeRecettes(array $rech). Si on fait une recherche par texte, on ajoute des clauses WHERE LIKE pour chercher dans le titre et les instructions avec les fonctions like() et orLike() :

if ( ! empty($rech['texte']))
{
    $this->recetteModel
        ->like('titre', $rech['texte'])
        ->orLike('instructions', $rech['texte']);
}

Pour le nombre de page, on passe la valeur à la fonction paginate(). S'il n'y pas de valeur, on passe null, ce qui a pour effet de prendre la valeur par défaut dans la configuration :

$nb_par_page = ! empty($rech['nb_par_page']) ? $rech['nb_par_page'] : null;

$recettes = $this->recetteModel
    ->orderBy('id')
    ->paginate($nb_par_page);

Par exemple, si on cherche le texte "cuire au four" avec 10 recettes par page, la requête générée serait la suivante :

  SELECT `id`, `slug`, `titre` 
    FROM `recette` 
   WHERE `titre` LIKE '%cuire au four%' ESCAPE '!' 
      OR `instructions` LIKE '%cuire au four%' ESCAPE '!' 
ORDER BY `id`
   LIMIT 10

Ceci peut être validé grâce à la debug toolbar, dans l'onglet Database. À noter que cette barre est disponible seulement quand l'environnement de l'application est différent de production. Voir cet article pour savoir comment définir l'environnement.

Voici donc la librairie MesRecettes dans son entièreté avec ces quelques changements :

app/Libraries/MesRecettes.php

<?php namespace App\Libraries;

use App\Models\RecetteModel;
use App\Models\IngredientModel;

class MesRecettes
{
    public $recetteModel;
    public $ingredientModel;

    public function __construct()
    {
        $this->recetteModel = new RecetteModel();
        $this->ingredientModel = new IngredientModel();
    }

    /**
     * Obtenir la liste de recettes
     * @param array $rech
     * @return array
     */
    public function getListeRecettes (array $rech)
    {
        // Obtenir seulement les champs id, slug et titre
        $this->recetteModel->select('id, slug, titre');

        // Si on recherche par texte, chercher dans le titre et les instructions
        if ( ! empty($rech['texte']))
        {
            $this->recetteModel
                ->like('titre', $rech['texte'])
                ->orLike('instructions', $rech['texte']);
        }

        // Si on ne demande pas un nombre de page spécifique, prendre la valeur par défaut
        $nb_par_page = ! empty($rech['nb_par_page']) ? $rech['nb_par_page'] : null;

        // Ajouter le tri et la pagination, puis retourner les résultats
        $recettes = $this->recetteModel
            ->orderBy('id')
            ->paginate($nb_par_page);

        return $recettes;
    }

    /**
     * Obtenir une recette par son id
     * @param int $id
     * @return object|NULL
     */
    public function getRecetteParId (int $id)
    {
        // Obtenir la recette par son id
        $recette = $this->recetteModel->find($id);

        if ($recette !== null)
        {
            $recette->ingredients = $this->ingredientModel
                ->where( ['id_recette' => $recette->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recette;
    }

    /**
     * Obtenir une recette par son 'slug'
     * @param string $slug
     * @return object|NULL
     */
    public function getRecetteParSlug (string $slug)
    {
        // Obtenir la recette par son slug
        $recette = $this->recetteModel->where('slug', $slug)->first();

        if ($recette !== null)
        {
            $recette->ingredients = $this->ingredientModel
                ->where( ['id_recette' => $recette->id] )
                ->orderBy('id')
                ->findAll();
        }

        return $recette;
    }
}

Cinquième partie terminée !

Vous pouvez maintenant rafaîchir la page http://ci4.test:8888 qui affichera maintenant un formulaire de recherche. Les valeurs du formulaire sont sauvegardées dans la session ce qui permet au formulaire de "se souvenir" des valeurs recherchées.