Aujourd'hui nous allons voir comment dynamiser un système de filtre produits en utilisant du JavaScript et des requêtes Ajax. L'objectif est de faire en sorte qu'un changement au niveau du filtre actualise le listing sans forcément réactualiser la page. On aura en revanche quelques petites contraintes :

  • Un changement des paramètres du filtre doit entraîner un changement de l'URL afin que l'utilisateur puisse partager le lien d'une recherche.
  • Des animations doivent permettre à l'utilisateur de comprendre les changements de positions des différents produits.
  • On ne souhaite pas dupliquer le code et le serveur sera toujours responsable de l'affichage des contenus (l'HTML sera généré côté serveur).

Côté serveur

Comme spécifié plus haut nous allons essayer de minimiser les changements à effectuer côté serveur. Nous allons donc réutiliser le code que l'on a mis en place pour la génération statique et faire en sorte que lorsque une requête ajax arrive le serveur ne renvoie que les parties qui nous intéresse point évidemment le fonctionnement on va changer suivant la technologie que vous utilisez côté serveur mais dans le cadre de symphonie voici à quoi ressemble mon contrôleur.

if ($request->get('ajax')) {
    return new JsonResponse([
        'content' => $this->renderView('product/_products.html.twig', ['products' => $products]),
        'sorting' => $this->renderView('product/_sorting.html.twig', ['products' => $products]),
        'pagination' => $this->renderView('product/_pagination.html.twig', ['products' => $products]),
        'pages' => ceil($products->getTotalItemCount() / $products->getItemNumberPerPage()),
        'min' => $min,
        'max' => $max
    ]);
}

On pourrait ici se baser sur les en-têtes HTTP afin de déterminer si la requête est une requête ajax. Cependant, afin de ne pas avoir de problèmes au niveau de la mise en cache, on va plutôt préférer l'utilisation d'un paramètre dans l' url.

Côté client

Maintenant que nous avons fait la partie côté serveur nous allons pouvoir mettre en place le code côté client. Le principe est relativement simple dès qu'une recherche va être effectué nous allons lancer une requête ajax afin de récupérer les nouveaux produits et remplacer notre contenu par le nouveau contenu renvoyé par le serveur.


/**
 * @property {HTMLElement} pagination
 * @property {HTMLElement} content
 * @property {HTMLElement} sorting
 * @property {HTMLFormElement} form
 */
export default class Filer {

  /**
   * @param {HTMLElement|null} element
   */
  constructor (element) {
    if (element === null) {
      return
    }
    this.pagination = element.querySelector('.js-filter-pagination')
    this.content = element.querySelector('.js-filter-content')
    this.sorting = element.querySelector('.js-filter-sorting')
    this.form = element.querySelector('.js-filter-form')
    this.bindEvents()
  }

  /**
   * Ajoute les comportements aux différents éléments
   */
  bindEvents () {
    const aClickListener = e => {
      if (e.target.tagName === 'A') {
        e.preventDefault()
        this.loadUrl(e.target.getAttribute('href'))
      }
    }
    this.sorting.addEventListener('click', aClickListener)
    this.pagination.addEventListener('click', aClickListener)
    this.form.querySelectorAll('input').forEach(input => {
      input.addEventListener('change', this.loadForm.bind(this))
    })
  }

  async loadForm () {
    const data = new FormData(this.form)
    const url = new URL(this.form.getAttribute('action') || window.location.href)
    const params = new URLSearchParams()
    data.forEach((value, key) => {
      params.append(key, value)
    })
    return this.loadUrl(url.pathname + '?' + params.toString())
  }

  async loadUrl (url, append = false) {
    this.showLoader()
    const params = new URLSearchParams(url.split('?')[1] || '')
    params.set('ajax', 1)
    const response = await fetch(url.split('?')[0] + '?' + params.toString(), {
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    })
    if (response.status >= 200 && response.status < 300) {
      const data = await response.json()
      this.flipContent(data.content, append)
      this.sorting.innerHTML = data.sorting
      this.pagination.innerHTML = data.pagination
    } else {
      console.error(response)
    }
    this.hideLoader()
  }

  showLoader () {
    // Code à écrire
  }

  hideLoader () {
    // Code à écrire
  }
}

Afin de gérer l'URL nous allons utiliser la méthode history.replaceState(). Ceci nous permet de remplacer l'entrée courante dans l'historique de l'utilisateur et changera l'URL affichée dans la barre d'adresse.

// params contient l'url
params.delete('ajax') // On ne veut pas que le paramètre "ajax" se retrouve dans l'URL
history.replaceState({}, '', url.split('?')[0] + '?' + params.toString())

En l'état dès que l'utilisateur effectue un changement le contenu va directement être remplacé, ce qui n'est pas forcément idéal d'un point de vue expérience utilisateur. Il serait plus intéressant d'utiliser des animations pour que l'utilisateur puisse repérer facilement les produits qui ont été déplacés, ajoutés ou supprimés par le filtre. pour mettre en place cette animation nous allons nous baser sur la technique d'animation FLIP. C'est une technique d'animation qui a déjà été évoquée dans un autre tutoriel et qui permet de créer des interpolations facilement en se basant sur la position des éléments avant et après un changement. Nous allons nous baser ici sur la librairie flip-toolkit.

import {Flipper, spring} from 'flip-toolkit'

class Filter {

  // .....

  /**
   * Remplace les éléments de la grille avec un effet d'animation flip
   * @param {string} content
   * @param {boolean} append le contenu doit être ajouté ou remplacé ?
   */
  flipContent (content, append) {
    const springConfig = 'gentle'
    const exitSpring = function (element, index, onComplete) {
      spring({
        config: 'stiff',
        values: {
          translateY: [0, -20],
          opacity: [1, 0]
        },
        onUpdate: ({ translateY, opacity }) => {
          element.style.opacity = opacity;
          element.style.transform = `translateY(${translateY}px)`;
        },
        onComplete
      })
    }
    const appearSpring = function (element, index) {
      spring({
        config: 'stiff',
        values: {
          translateY: [20, 0],
          opacity: [0, 1]
        },
        onUpdate: ({ translateY, opacity }) => {
          element.style.opacity = opacity;
          element.style.transform = `translateY(${translateY}px)`;
        },
        delay: index * 20
      })
    }
    const flipper = new Flipper({
      element: this.content
    })
    this.content.children.forEach(element => {
      flipper.addFlipped({
        element,
        spring: springConfig,
        flipId: element.id,
        shouldFlip: false,
        onExit: exitSpring
      })
    })
    flipper.recordBeforeUpdate()
    if (append) {
      this.content.innerHTML += content
    } else {
      this.content.innerHTML = content
    }
    this.content.children.forEach(element => {
      flipper.addFlipped({
        element,
        spring: springConfig,
        flipId: element.id,
        onAppear: appearSpring
      })
    })
    flipper.update()
  }

}

Voir plus

Enfin nous allons chercher à remplacer le système de pagination qui est présent par défaut sur notre page par un système de bouton voir plus. Ce bouton pourra être à te à me remplacer par un système de chargement infini dans ce que l'utilisateur fait défiler la page.

Dans le constructeur on va détecter la page courante

this.page = parseInt(new URLSearchParams(window.location.search).get('page') || 1)
this.moreNav = this.page === 1

Et on pourra utiliser ces paramètres dans la suite

class Filter {

  // ...

  /**
   * Ajoute les comportements aux différents éléments
   */
  bindEvents () {
    // ...
    if (this.moreNav) {
      this.pagination.innerHTML = '<button class="btn btn-primary">Voir plus</button>'
      this.pagination.querySelector('button').addEventListener('click', this.loadMore.bind(this))
    } else {
      this.pagination.addEventListener('click', aClickListener)
    }
    // ...
  }

  async loadMore () {
    const button = this.pagination.querySelector('button')
    button.setAttribute('disabled', 'disabled')
    this.page++
    const url = new URL(window.location.href)
    const params = new URLSearchParams(url.search)
    params.set('page', this.page)
    await this.loadUrl(url.pathname + '?' + params.toString(), true)
    button.removeAttribute('disabled')
  }

  async loadUrl (url, append = false) {
    this.showLoader()
    const params = new URLSearchParams(url.split('?')[1] || '')
    params.set('ajax', 1)
    const response = await fetch(url.split('?')[0] + '?' + params.toString(), {
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    })
    if (response.status >= 200 && response.status < 300) {
      const data = await response.json()
      this.flipContent(data.content, append)
      this.sorting.innerHTML = data.sorting
      if (!this.moreNav) {
        this.pagination.innerHTML = data.pagination
      } else if (this.page === data.pages) {
        this.pagination.style.display = 'none';
      } else {
        this.pagination.style.display = null;
      }
      this.updatePrices(data)
      params.delete('ajax')
      history.replaceState({}, '', url.split('?')[0] + '?' + params.toString())
    } else {
      console.error(response)
    }
    this.hideLoader()
  }

  // ...
}