Vue.js

Vue.js2 : pagination avancée

Dans le précédent article on a vu un exemple de pagination très simplifié. Je vous propose dans le présent article de poursuivre l’exemple en construisant un composant de pagination digne de ce nom qui soit à la fois pratique, complet et esthétique.

On va donc partir de la situation telle qu’on l’a laissée précédemment en transformant la pagination en composant indépendant.

Je vous ai mis un zip contenant tout le code final ici.

Côté Laravel

Du côté de Laravel on va garder le même code mais on va ajouter des utilisateurs. Dans le seeder on va en prévoir 1000 :

<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        factory(App\User::class, 1000)->create();;
    }
}

Alors régénérez votre base de données (on l’a prévue en sqlite) :

php artisan migrate:reset
php atisan migrate --seed

Vous devriez avoir ainsi 1000 utilisateurs :

img67

Quelle pagination ?

Il existe de nombreuses façons de créer une pagination, plus ou moins judicieuses. J’aime bien cet article, même s’il est en anglais. En résumé il préconise :

  • des zones cliquables confortables
  • éviter les soulignements
  • identifier la page active
  • bien espacer les liens
  • prévoir des liens « précédent » et « suivant »
  • prévoir des liens « premier » et ‘ »dernier » séparés

S’ensuit une galerie de bonnes et mauvaises paginations.

On va essayer dans cet article de respecter ces préconisations en y ajoutant la possibilité de déterminer le nombre d’enregistrements par page.

On va partir du principe qu’on affiche par défaut 10 enregistrements, comme on en a 1000 ça nous fait 100 pages. Au départ on affiche la page 1 et la pagination va se présneter ainsi :

img68

On a :

  • la page active (1) mise en évidence
  • une troncature
  • les deux dernières pages
  • un bouton de changement de page vers le haut
  • un bouton de saut pour 10 pages vers le haut

Si maintenant on affiche la page 5 :

img69

On a :

  • un bouton de changement de page vers le bas
  • deux pages avant la troncature
  • une troncature
  • un bouton de changement de page vers le haut
  • un bouton de saut pour 10 pages vers le haut

Si maintenant on affiche la page 15 :

img70

Cette fois on a :

  • un bouton de saut pour 10 pages vers le bas
  • un bouton de changement de page vers le bas
  • les deux premières pages
  • une troncature
  • la page active encadré de 2 pages vers le bas et vers le haut
  • une troncature
  • les dernières pages
  • un bouton de changement de page vers le haut
  • un bouton de saut pour 10 pages vers le haut

Prenons un dernier exemple avec la page 96 :

img71

Là on a :

  • un bouton de saut pour 10 pages vers le bas
  • un bouton de changement de page vers le bas
  • les deux premières pages
  • une troncature
  • la page active encadré avec 2 pages vers le bas
  • les dernières pages
  • un bouton de changement de page vers le haut

Je pense que vous avez compris les principes mis en oeuvre. Il me semble que c’est une façon efficace de gérer une pagination.

Un dernier cas concerne la réduction sur petit support, dans ce cas le plus simple est de ne conserver que les boutons de déplacement :

img72

Pour compléter tout ça on va prévoir une liste déroulante pour choisir le nombre d’enregistrements par page :

img73

Le composant principal

Voici le nouveau code du composant principal (App) :

<template>
  <div class="ui raised container segment">
    <message  
      type="positive" 
      header="Serveur mis à jour avec succès !" 
      :show="success" 
      @close="closeSuccess">
    </message>
    <message 
      type="negative" 
      header="Echec de la communication avec le serveur !" 
      :show="danger" 
      @close="closeDanger">
    </message>
    <message 
      type="negative" 
      header="Il y a des erreurs dans la validation des données saisies :" 
      :list="[ validation.name, validation.email ]"
      :show="validation.name != '' || validation.email != ''" 
      @close="closeValidation">
    </message>
    <div class="ui three column grid">
      <div class="row">
        <div class="column"></div>
        <div class="column"><h1>Liste des utilisateurs</h1></div>
        <div class="column right aligned" v-show="!edition">  
          <select v-model="paginationSelect" class="ui menu dropdown">
            <option value="5">5 lignes</option>
            <option value="10">10 lignes</option>
            <option value="20">20 lignes</option>
            <option value="50">50 lignes</option>
          </select>
        </div>
      </div>    
      <div class="row" v-if="users.length == 0" style="height: 100px">
        <div class="ui active inverted centered dimmer">
          <div class="ui text loader">Chargement</div>
        </div>
        <p></p>
      </div>
    </div>    
    <table class="ui celled table" v-if="users.length > 0">
      <thead>
        <tr>
         <th>Nom</th>
         <th>Email</th>
         <th></th>
         <th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(user, index) in userShowed">
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>
            <button class="fluid ui orange button" :class="{ disabled: edition }" data-tooltip="Modifier cet utilisateur" data-position="top center" @click="edit(index)">
              <i class="edit icon"></i>
            </button>
          </td>
          <td>
            <button class="fluid ui red button" :class="{ disabled: edition }" data-tooltip="Supprimer cet utilisateur" data-position="top center" @click="del(index)">
              <i class="remove user icon"></i>
            </button>
          </td>  
        </tr>  
        <tr class="ui form">
          <td>
            <div class="ui field" :class="{ error: validation.name }">
              <input type="text" v-model="user.name" placeholder="Nom">
            </div>
          </td>
          <td>
            <div class="ui field" :class="{ error: validation.email }">
              <input type="email" class="form-control" v-model="user.email" placeholder="Email">
            </div>
          </td>
          <td colspan="2" v-if="!edition">
            <button class="fluid ui blue button" data-tooltip="Ajouter un utilisateur" data-position="top center" @click="add()">
              <i class="add user icon"></i>
            </button>
          </td>
          <td v-if="edition">
            <button class="fluid ui blue button" data-tooltip="Mettre à jour cet utilisateur" data-position="top center" @click="update()">
              <i class="add user icon"></i>
            </button>
          </td>
          <td v-if="edition">
            <button class="fluid ui violet button" data-tooltip="Annuler la modification" data-position="top center" @click="undo()">
              <i class="undo icon"></i>
            </button>
          </td>
        </tr>
      </tbody>       
    </table>
    <pagination :records="maxUsers" :number-per-page="paginationNumber" :hide="edition" @changepage="changePage"></pagination>
  </div>  
</template>

<script>
import Message from './Message.vue'
import Pagination from './Pagination.vue'

export default {
  name: 'application',
  resource: null,
  data() {
    return {
      users: [],
      userShowed: [],
      user: { name: '', email: '' },
      save: { index: 0, user: {} },
      success: false,
      danger: false,
      edition: false,
      validation: { name: '', email: '' },
      paginationIndex: 1,
      paginationNumber: 10,
      paginationSelect: 10
    }
  },
  computed: {
    maxUsers() {
      return this.users.length
    }
  },
  watch: {
    paginationSelect() {
      this.paginationNumber = _.toInteger(this.paginationSelect)
      this.paginationIndex = 1
      this.refreshPage()
    }
  },
  mounted() {
    this.resource = this.$resource('/users{/id}')
    this.resource.get().then((response) => {
      this.users = response.body
      this.userShowed = this.users.slice(0, this.paginationNumber)
    }, (response) => {
      this.danger = true
    })
  },
  methods: {
    add() {
        this.resetMessages()
        this.resource.save(this.user).then((response) => {
          this.success = true
          this.users.push(this.user)
          this.user = { name: '', email: '' }
        }, (response) => {
        this.setValidation(response)
      });
    },
    update() {
        this.resetMessages()
        this.resource.update({id: this.user.id}, this.user).then((response) => {
          this.success = true
          this.userShowed.splice(this.save.index, 0, this.user)
          this.users[this.save.index + this.getStartPagination] = _.clone(this.user)
          this.edition = false
          this.user = { name: '', email: '' }
        }, (response) => {
        this.setValidation(response)
      });
    },
    del(index) {
      let that = this
      index = index + this.getStartPagination()
      this.resetMessages()
      this.$swal({
        title: 'Vous êtes sûr de vous ?',
        text: "Il n'y aura aucun retour en arrière possible !",
        type: 'warning',
        showCancelButton: true,
        confirmButtonColor: '#3085d6',
        cancelButtonColor: '#d33',
        confirmButtonText: 'Oui supprimer !',
        cancelButtonText: 'Non, surtout pas !',
      }).then(function() {
        that.resource.delete({id: that.users[index].id}).then((response) => {
          that.success = true
          that.users.splice(index, 1)
          that.changePage(1)
        }, (response) => {
          that.danger = true 
        });        
      }).done()
    },
    edit(index) {
      this.resetMessages()
      this.save.index = index
      this.user = this.userShowed[index]
      this.save.user = _.clone(this.user)
      this.edition = true
      this.userShowed.splice(index, 1)
    },
    undo() {
      this.userShowed.splice(this.save.index, 0, this.save.user)
      this.user = { name: '', email: '' }
      this.edition = false
    },
    resetMessages() {
      this.success = false
      this.danger = false
      this.closeValidation()
    },
    setValidation(response) {
      this.validation.name = response.body.name ? response.body.name[0] : ''
      this.validation.email = response.body.email ? response.body.email[0] : ''
    },
    closeSuccess() {
      this.success = false
    },
    closeDanger() {
      this.danger = false
    },
    closeValidation() {
      this.validation = { name: '', email: ''}
    },
    changePage(index) {
      this.paginationIndex = index
      this.refreshPage()
    },
    refreshPage() {
      let start = this.getStartPagination()
      this.userShowed = this.users.slice(start, start + this.paginationNumber)
    },
    getStartPagination() {
      return (this.paginationIndex - 1) * this.paginationNumber
    }
  },
  components: {
    Message, 
    Pagination
  }
}
</script>

On retrouve l’essentiel de ce qu’on avait déjà mis en place mais avec quelques modifications…

La liste déroulante

Dans le template on a ajouté la liste déroulante pour le nombre d’enregistrements par page :

<div class="column right aligned" v-show="!edition">  
  <select v-model="paginationSelect" class="ui menu dropdown">
    <option value="5">5 lignes</option>
    <option value="10">10 lignes</option>
    <option value="20">20 lignes</option>
    <option value="50">50 lignes</option>
  </select>
</div>

On cache la liste en mode édition avec la directive v-show.

On voit une liaison de données avec la directive v-model. On va retrouver la valeur dans le data avec une valeur par défaut de 10 :

data() {
  return {
    ...
    paginationSelect: 10
  }
},

On surveille les changements dans la liste avec une propriété dont je ne vous ai pas encore parlé (watch) :

watch: {
  paginationSelect() {
    this.paginationNumber = _.toInteger(this.paginationSelect)
    this.paginationIndex = 1
    this.refreshPage()
  }
},

Avec Vue.js il y a deux façons de réagir à un changement de valeur : les propriétés calculées (computed properties) et les observateurs (watchers). Selon les cas il est plus judicieux d’utiliser l’un ou l’autre. Les observateurs sont intéressants quand on veut accomplir une action spécifique comme c’est le cas ici. Quand la valeur change dans la liste déroulante :

  • on transforme la valeur en integer (dans la liste on a des chaînes de caractères)
  • on réinitialise la page actuelle à 1
  • on rafraîchit l’affichage

Remarquez que j’utilise Lodash parce qu’il est déjà par défaut chargé par Elixir, alors autant s’en servir !

L’intégration de la pagination

Dans le template on va évidemment aussi trouver le composant de la pagination :

<pagination :records="maxUsers" :number-per-page="paginationNumber" :hide="edition" @changepage="changePage"></pagination>

Et on le déclare dans le JavaScript :

components: {
  ... 
  Pagination
}

On voit qu’on va transmettre des valeurs pour 3 propriétés :

  • records : le nombre total d’enregistrements donné par la propriété calculée maxUsers
  • number-per-page : le nombre d’enregistrements par page donné par la liste déroulante
  • hide : l’effacement de la pagination qui servira lorsqu’on veut éditer un utilisateur

D’autre part on écoute l’événement changePage qui va nous indiquer le changement de page. En cas de changement de page on actualise l’index et on rafraîchit la page :

changePage(index) {
  this.paginationIndex = index
  this.refreshPage()
},

Cet index nous est nécessaire pour repérer l’utilisateur pour les actions de suppression et édition. On peut en effet déterminer où commence la page actuelle :

getStartPagination() {
  return (this.paginationIndex - 1) * this.paginationNumber
}

Le rafraîchissement de la page se fait avec cette fonction :

refreshPage() {
  let start = this.getStartPagination()
  this.userShowed = this.users.slice(start, start + this.paginationNumber)
},

Avec ça on est parés au niveau du composant principal…

Le composant de pagination

c’est dans le composant de pagination que se trouve toute la logique correspondante ainsi que le code HTML et CSS. Voici le code complet du composant :

<template>
  <div id="pagination" v-if="paginationEnabled">
    <div class="ui pagination menu" v-show="chunkLeft" @click="bigLeft">
      <a class="item"><i class="angle double left icon"></i></a>
    </div>
    <div class="ui pagination menu" v-show="buttonLeft" @click="left">
      <a class="item"><i class="angle left icon"></i></a>
    </div>
    <div id="numbers" class="ui pagination menu">
      <a v-for="(item, index) in pagination" class="item" :class="{ active: item.active, disabled: item.disabled }" @click="changePage(index)">
        {{ item.text }}
      </a>
    </div>
    <div class="ui pagination menu" v-show="buttonRight" @click="right">
      <a class="item"><i class="angle right icon"></i></a>
    </div>
    <div class="ui pagination menu" v-show="chunkRight" @click="bigRight">
      <a class="item"><i class="angle double right icon"></i></a>
    </div>
  </div>
</template>

<script>
export default {
  name: 'pagination',
  props: {
    'records': {
      type: Number,
      required: true
    },
    'numberPerPage': {
      type: Number,
      required: false,
      default: 10
    },
    'chunk': {
      type: Number,
      required: false,
      default: 10
    },
    'hide': {
      type: Boolean,
      required: false,
      default: false
    }
  },
  data() {
    return {
      current: 1,
      adjacent: 2,
      pagination: []
    }
  },
  computed: {
    paginationEnabled() {
      return this.records > this.numberPerPage && !this.hide
    },
    buttonLeft() {
      return this.current > 1
    },
    buttonRight() {
      return this.current < this.totalPages()
    },
    chunkLeft() {
      return this.current >= this.chunk
    },
    chunkRight() {
      return this.current <= this.totalPages() - this.chunk
    }
  },
  watch: {
    numberPerPage() {
      this.current = 1
      this.createPagination()
    },
    records() {
      this.current = 1
      this.createPagination()
    },
    current() {
      this.createPagination()
    }
  },
  methods: {
    changePage(index) {
      if(!this.pagination[index].disabled) {
        this.current = this.pagination[index].text
        this.$emit('changepage', this.current)
      }
    },
    createPagination() {
      let total = this.totalPages()
      this.pagination = []
      let encadrement = this.adjacent * 2
      // Sans troncature
      if(total < 7 + encadrement) {
        this.addPages(..._.range(1, total + 1))
      // Avec troncature
      } else {
        // Troncature à droite
        if (this.current < 2 + encadrement) {
          this.addPages(..._.range(1, 4 + encadrement))
          this.addTroncature()
          this.addLastPages(total)       
        }
        // Deux troncatures
        else if ((encadrement + 1 < this.current) && (this.current < total - encadrement)) {
          this.addFirstPages()     
          this.addTroncature()
          this.addPages(..._.range(this.current - this.adjacent, this.current + this.adjacent + 1))
          this.addTroncature()
          this.addLastPages(total)
        }
        // Troncature à gauche
        else {
          this.addFirstPages()
          this.addTroncature()
          this.addPages(..._.range(total - 2 - encadrement, total + 1))    
        }
      }
    },
    addTroncature() {
      this.pagination.push({ text: '...', active: false, disabled: true })
    },
    addFirstPages() {
      this.addPages(1, 2)
    },
    addLastPages(total) {
      this.addPages(total - 1, total)
    },
    addPages(...valeurs) {
      let that = this
      _.forEach(valeurs, function(valeur) {
        that.pagination.push({ text: valeur, active: that.current == valeur, disabled: false })
      });
    },
    totalPages() {
      return _.ceil(this.records / this.numberPerPage)
    },
    left() {
      this.$emit('changepage', --this.current)
    },
    right() {
      this.$emit('changepage', ++this.current)
    },
    bigLeft() {
      this.current -= this.chunk
      this.$emit('changepage', this.current)
    },
    bigRight() {
      this.current += this.chunk
      this.$emit('changepage', this.current)
    }
  }
}
</script>

<style>
.ui.pagination.menu .item {
  background-color: rgba(33,133,208,.2);
}
.ui.pagination.menu .active.item {
  background-color: rgba(33,133,208,.5);
}
@media screen and (max-width: 768px) {
  #numbers { display: none; }
}
#pagination {
  display:flex; 
  justify-content:center
}
</style>

Les propriétés

Pour faire les choses correctement j’ai prévu une validation au niveau des propriétés (props) :

props: {
  'records': {
    type: Number,
    required: true
  },
  'numberPerPage': {
    type: Number,
    required: false,
    default: 10
  },
  'chunk': {
    type: Number,
    required: false,
    default: 10
  },
  'hide': {
    type: Boolean,
    required: false,
    default: false
  }
},

On contrôle le type de donnée, le fait que la valeur soit obligatoire ou pas et on prévoit une valeur par défaut le cas échéant.

J’ai prévu une propriété optionnelle chunk qui correspond au déplacement de plusieurs pages en avant ou en arrière avec une valeur par défaut de 10.

Le template et les boutons

Au niveau du template on a déjà une condition globale :

<div id="pagination" v-if="paginationEnabled">

La directive v-if permet d’effacer la pagination dans deux cas :

computed: {
  paginationEnabled() {
    return this.records > this.numberPerPage && !this.hide
  },
  • si le nombre d’enregistrements est inférieur ou égal au nombre d’enregistrements par page
  • si on est en mode édition

Pour les boutons de déplacement on a une logique simple. Si on prend par exemple celui d’une page à gauche :

<div class="ui pagination menu" v-show="buttonLeft" @click="left">
  <a class="item"><i class="angle left icon"></i></a>
</div>

Une directive v-show permet de le faire apparaître uniquement quand c’est nécessaire :

buttonLeft() {
  return this.current > 1
},

Très logiquement c’est quand on en est au moins à la page 2. D’autre part si on clique sur le bouton on a un événement left :

left() {
  this.$emit('changepage', --this.current)
},

On émet l’événement changePage pour le composant parent en lui transmettant le nouvel index.

Pour les boutons des pages on a une simple directive v-for :

<a v-for="(item, index) in pagination" class="item" :class="{ active: item.active, disabled: item.disabled }" @click="changePage(index)">
  {{ item.text }}
</a>

La logique

On a ces données :

data() {
  return {
    current: 1,
    adjacent: 2,
    pagination: []
  }
},
  • la page courante current
  • le nombre de pages adjecentes à afficher adjacent fixée à 2
  • un tableau contenant les informations pour les boutons des pages pagination

Le tableau pagination est rempli avec la méthode createPagination aidée par quelques autres méthodes secondaires :

createPagination() {
  let total = this.totalPages()
  this.pagination = []
  let encadrement = this.adjacent * 2
  // Sans troncature
  if(total < 7 + encadrement) {
    this.addPages(..._.range(1, total + 1))
  // Avec troncature
  } else {
    // Troncature à droite
    if (this.current < 2 + encadrement) {
      this.addPages(..._.range(1, 4 + encadrement))
      this.addTroncature()
      this.addLastPages(total)       
    }
    // Deux troncatures
    else if ((encadrement + 1 < this.current) && (this.current < total - encadrement)) {
      this.addFirstPages()     
      this.addTroncature()
      this.addPages(..._.range(this.current - this.adjacent, this.current + this.adjacent + 1))
      this.addTroncature()
      this.addLastPages(total)
    }
    // Troncature à gauche
    else {
      this.addFirstPages()
      this.addTroncature()
      this.addPages(..._.range(total - 2 - encadrement, total + 1))    
    }
  }
},
addTroncature() {
  this.pagination.push({ text: '...', active: false, disabled: true })
},
addFirstPages() {
  this.addPages(1, 2)
},
addLastPages(total) {
  this.addPages(total - 1, total)
},
addPages(...valeurs) {
  let that = this
  _.forEach(valeurs, function(valeur) {
    that.pagination.push({ text: valeur, active: that.current == valeur, disabled: false })
  });
},
totalPages() {
  return _.ceil(this.records / this.numberPerPage)
},

C’est la principale méthode du composant. je ne vais pas entrer dans le détail du fonctionnement, j’ai ajouté quelque commentaires pour s’y retrouver.

J’ai profité de la compilation en ES6 pour utiliser le paramètre du reste et l’opérateur de décomposition qui rendent la syntaxe concise, surtout associé à Lodash.

Un peu d’observation

J’ai aussi prévu d’observer (watch) tous les changements qui justifient un recalcul de la pagination :

watch: {
  numberPerPage() {
    this.current = 1
    this.createPagination()
  },
  records() {
    this.current = 1
    this.createPagination()
  },
  current() {
    this.createPagination()
  }
},
  • si le nombre d’enregistrement par page (numberPerPage) change
  • si le nombre total d’enregistrements (records) change
  • si la page courante (current) change

Le style

J’ai aussi prévu un peu de style :

<style>
.ui.pagination.menu .item {
  background-color: rgba(33,133,208,.2);
}
.ui.pagination.menu .active.item {
  background-color: rgba(33,133,208,.5);
}
@media screen and (max-width: 768px) {
  #numbers { display: none; }
}
#pagination {
  display:flex; 
  justify-content:center
}
</style>

On a ainsi un peu de couleur, un petit effet responsive et un centrage.

Conclusion

On a ainsi une application bien organisée avec deux composants réutilisables : un pour les messages et l’autre pour la pagination.

 

 

Print Friendly, PDF & Email

Laisser un commentaire