Vue.js2 : pagination avancée
Mercredi 9 novembre 2016 00:34
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 --seedVous devriez avoir ainsi 1000 utilisateurs :
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
- 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
- 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
- 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
- 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
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
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
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
<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
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.Par bestmomo
Aucun commentaire