Video Tutorial Ajax Product Filter


Today we will see how to dynamize a filter system produced using JavaScript and Ajax queries. The goal is to make a change in the filter refresh the listing without necessarily updating the page. On the other hand we will have some small constraints:

  • A change to the filter settings must result in a change to the URL so that the user can share the link of a search.
  • Animations must allow the user to understand the changes of positions of the different products.
  • We do not want to duplicate the code and the server will always be responsible for displaying the content (the HTML will be generated on the server side).

Server side

As mentioned above we will try to minimize the changes to be made on the server side. So we will reuse the code that we put in place for the static generation and ensure that when an ajax request arrives the server only returns the parts we are interested in obviously the operation will change depending on the technology you use server side but as part of symphony here is what my controller looks like.

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
    ));
}

Here we could use HTTP headers to determine if the request is an ajax request. However, in order not to have problems with caching, we will rather prefer the use of a parameter in the url.

Customer side

Now that we have done the server side we will be able to set up the client side code. The principle is relatively simple as soon as a search is done we will launch an ajax request to retrieve the new products and replace our content by the new content returned by the server.



/ **
 * @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 ()
  }

  / **
   * Add behaviors to different elements
   * /
  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 to write
  }

  hideLoader () {
    // Code to write
  }
}

In order to manage the URL we will use the method history.replaceState (). This allows us to replace the current entry in the user's history and change the URL displayed in the address bar.

// params contains the url
params.delete ('ajax') // We do not want the parameter "ajax" to be found in the URL
history.replaceState ({}, '', url.split ('?') (0) + '?' + params.toString ())

In the state as soon as the user makes a change the content will be directly replaced, which is not necessarily ideal from a user experience point of view. It would be more interesting to use animations so that the user can easily locate the products that have been moved, added or deleted by the filter. to set up this animation we will base ourselves on the FLIP animation technique. This is an animation technique that has already been mentioned in another tutorial and that allows to create interpolations easily based on the position of the elements before and after a change. We will base ourselves here on the flip-toolkit library.

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

class Filter {

  // .....

  / **
   * Replaces elements of the grid with a flip animation effect
   * @param {string} content
   * @param {boolean} append content must be added or replaced?
   * /
  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 ()
  }

}

See more

Finally we will seek to replace the paging system that is present by default on our page by a button system see more. This button may be replaced by an infinite loading system in which the user scrolls the page.

In the constructor we will detect the current page

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

And we can use these parameters later

class Filter {

  // ...

  / **
   * Add behaviors to different elements
   * /
  bindEvents () {
    // ...
    if (this.moreNav) {
      this.pagination.innerHTML = ''
      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 ()
  }

  // ...
}