Tutoriel Vidéo Symfony Créer un filtre produit

Télécharger la vidéo Télécharger les sources

Dans cette vidéo je vous propose de découvrir ensemble comment créer un système de filtre produit sur le framework Symfony. l'objectif est de permettre à l'utilisateur de sélectionner les produits en fonction des différentes catégories, d'un prix minimum et maximum et de pouvoir organiser les produits par prix ou par promotion.

Ce tutoriel sera suivi par un autre tutoriel consacré à la mise en place d'un filtre dynamique en utilisant du JavaScript (ce qui permettra de rafraîchir le listing produits sans forcément avoir besoin de soumettre le formulaire ou de recharger la page).

Le filtre de recherche

Le point clé de notre système est la conception du filtre qui va permettre à l'utilisateur de rechercher les produits. Pour créer ce système on va commencer par créer un objet qui va représenter les données de la recherche. Cet objet sera un simple objet PHP qui aura comme propriété les différentes options de recherche.

<?php
namespace App\Data;

use App\Entity\Category;

class SearchData
{

    /**
     * @var int
     */
    public $page = 1;

    /**
     * @var string
     */
    public $q = '';

    /**
     * @var Category[]
     */
    public $categories = [];

    /**
     * @var null|integer
     */
    public $max;

    /**
     * @var null|integer
     */
    public $min;

    /**
     * @var boolean
     */
    public $promo = false;

}

La création d'un tel objet permet de connaître la forme des paramètres qui seront passés au système de recherche (par rapport à l'utilisation d'un simple tableau).
Ensuite il va nous falloir créer le formulaire qui va permettre de remplir notre recherche :

<?php

namespace App\Form;

use App\Data\SearchData;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SearchForm extends AbstractType
{

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('q', TextType::class, [
                'label' => false,
                'required' => false,
                'attr' => [
                    'placeholder' => 'Rechercher'
                ]
            ])
            ->add('categories', EntityType::class, [
                'label' => false,
                'required' => false,
                'class' => Category::class,
                'expanded' => true,
                'multiple' => true
            ])
            ->add('min', NumberType::class, [
                'label' => false,
                'required' => false,
                'attr' => [
                    'placeholder' => 'Prix min'
                ]
            ])
            ->add('max', NumberType::class, [
                'label' => false,
                'required' => false,
                'attr' => [
                    'placeholder' => 'Prix max'
                ]
            ])
            ->add('promo', CheckboxType::class, [
                'label' => 'En promotion',
                'required' => false,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => SearchData::class,
            'method' => 'GET',
            'csrf_protection' => false
        ]);
    }

    public function getBlockPrefix()
    {
        return '';
    }

}

L'avantage est ici que l'on peut se reposer sur les types de champs offerts par Symfony pour avoir directement les filtres nécessaires. On notera l'utilisation d'une méthode GET qui permettra de passer les paramètres dans l'url et la méthode getBlockPrefix() qui permet de retirer le préfixe afin d'avoir des paramètres les plus simple possible.

Traitement de la recherche

Le traitement de la recherche va se faire simplement au niveau du controller grâce à l'utilisation de la classe de formulaire que l'on a créé précédemment.


    /**
     * @Route("/", name="product")
     */
    public function index(ProductRepository $repository, Request $request)
    {
        $data = new SearchData();
        $data->page = $request->get('page', 1);
        $form = $this->createForm(SearchForm::class, $data);
        $form->handleRequest($request);
        $products = $repository->findSearch($data);
        return $this->render('product/index.html.twig', [
            'products' => $products,
            'form' => $form->createView()
        ]);
    }

L'avantage est que l'on peut maintenant envoyer l'objet représentant notre recherche à notre repository afin d'effectuer la recherche de produits.

    /**
     * Récupère les produits en lien avec une recherche
     * @return PaginationInterface
     */
    public function findSearch(SearchData $search): PaginationInterface
    {

        $query = $this
            ->createQueryBuilder('p')
            ->select('c', 'p')
            ->join('p.categories', 'c');

        if (!empty($search->q)) {
            $query = $query
                ->andWhere('p.name LIKE :q')
                ->setParameter('q', "%{$search->q}%");
        }

        if (!empty($search->min)) {
            $query = $query
                ->andWhere('p.price >= :min')
                ->setParameter('min', $search->min);
        }

        if (!empty($search->max)) {
            $query = $query
                ->andWhere('p.price <= :max')
                ->setParameter('max', $search->max);
        }

        if (!empty($search->promo)) {
            $query = $query
                ->andWhere('p.promo = 1');
        }

        if (!empty($search->categories)) {
            $query = $query
                ->andWhere('c.id IN (:categories)')
                ->setParameter('categories', $search->categories);
        }

        return $this->paginator->paginate(
            $query,
            $search->page,
            9
        );
    }

    private function getSearchQuery(SearchData $search, $ignorePrice = false): QueryBuilder
    {
    }

Notre recherche étant relativement complexe et pouvant contenir plusieurs paramètres on va préférer mettre en place une requête personnalisée plutôt que de se reposer sur ce qui est offert par défaut par le bundle paginator. En revanche pour la partie organisation des contenus on laissera KnpPaginatorBundle gérer les choses.

Le filtre prix

Pour offrir une interface utilisateur plus agréable au niveau de la sélection des prix on va utiliser un système de slider. Ce système permettra à l'utilisateur de changer le prix minimum et le prix maximum par simple glisser déposer.

import noUiSlider from 'nouislider'
import 'nouislider/distribute/nouislider.css'

const slider = document.getElementById('price-slider')

if (slider) {
    const min = document.getElementById('min')
    const max = document.getElementById('max')
    const minValue = Math.floor(parseInt(slider.dataset.min, 10) / 10) * 10
    const maxValue = Math.ceil(parseInt(slider.dataset.max, 10) / 10) * 10
    const range = noUiSlider.create(slider, {
        start: [min.value || minValue, max.value || maxValue],
        connect: true,
        step: 10,
        range: {
            'min': minValue,
            'max': maxValue
        }
    })
    range.on('slide', function (values, handle) {
        if (handle === 0) {
            min.value = Math.round(values[0])
        }
        if (handle === 1) {
            max.value = Math.round(values[1])
        }
    })
    range.on('end', function (values, handle) {
        if (handle===0) {
            min.dispatchEvent(new Event('change'))
        } else {
            max.dispatchEvent(new Event('change'))
        }
    })
}

Le problème est qu'il nous faut alors trouver le prix minimum et le prix maximum de notre listing produit. On peut se reposer pour cela sur la recherche que l'on a déjà effectué (en retirant les critères liés au prix). Ceci nous permettra d'extraire un prix minimum et un prix maximum que l'on pourra utiliser au niveau de notre slider.

    /**
     * Récupère les produits en lien avec une recherche
     * @return PaginationInterface
     */
    public function findSearch(SearchData $search): PaginationInterface
    {
        $query = $this->getSearchQuery($search)->getQuery();
        return $this->paginator->paginate(
            $query,
            $search->page,
            9
        );
    }

    /**
     * Récupère le prix minimum et maximum correspondant à une recherche
     * @return integer[]
     */
    public function findMinMax(SearchData $search): array
    {
        $results = $this->getSearchQuery($search, true)
            ->select('MIN(p.price) as min', 'MAX(p.price) as max')
            ->getQuery()
            ->getScalarResult();
        return [(int)$results[0]['min'], (int)$results[0]['max']];
    }

    private function getSearchQuery(SearchData $search, $ignorePrice = false): QueryBuilder
    {
        $query = $this
            ->createQueryBuilder('p')
            ->select('c', 'p')
            ->join('p.categories', 'c');

        if (!empty($search->q)) {
            $query = $query
                ->andWhere('p.name LIKE :q')
                ->setParameter('q', "%{$search->q}%");
        }

        if (!empty($search->min) && $ignorePrice === false) {
            $query = $query
                ->andWhere('p.price >= :min')
                ->setParameter('min', $search->min);
        }

        if (!empty($search->max) && $ignorePrice === false) {
            $query = $query
                ->andWhere('p.price <= :max')
                ->setParameter('max', $search->max);
        }

        if (!empty($search->promo)) {
            $query = $query
                ->andWhere('p.promo = 1');
        }

        if (!empty($search->categories)) {
            $query = $query
                ->andWhere('c.id IN (:categories)')
                ->setParameter('categories', $search->categories);
        }

        return $query;
    }

Et on utilise les attributs "data" pour envoyer ces informations à notre javascript.

{{ form_start(form, {attr: {class: 'filter js-filter-form'}}) }}

  <div class="spinner-border js-loading" role="status" aria-hidden="true" style="display: none">
    <span class="sr-only">Loading...</span>
  </div>

  {{ form_row(form.q) }}

  <h4>Catégories</h4>
  {{ form_row(form.categories) }}

  <h4>Prix</h4>
  <div class="row">
    <div class="col-md-6">
      {{ form_row(form.min) }}
    </div>
    <div class="col-md-6">
      {{ form_row(form.max) }}
    </div>
  </div>
  <div id="price-slider" data-min="{{ min }}" data-max="{{ max }}" style="margin-bottom: 3rem;"></div>

  <h4>Promotions</h4>
  {{ form_row(form.promo) }}

<button type="submit" class="btn btn-primary w-100">Filtrer</button>

{{ form_end(form) }}