Tutoriel · Salesforce · LWC · SOQL

Pagination LWC : tableau, recherche et curseur SOQL

Un tableau de comptes qui charge 25 lignes à la fois : recherche par préfixe, tri serveur et curseur composite pour parcourir les résultats sans OFFSET.

Définir le parcours de pagination

Un tableau LWC qui charge tous les comptes pour n’en afficher que 25 déplace le problème dans le navigateur. Notre organisation fictive comporte assez de comptes pour justifier une lecture serveur : recherche par préfixe du nom, tri par création, boutons précédent et suivant.

Prérequis : une sandbox Salesforce, un projet DX en API 66.0, des comptes de démonstration et les droits de lecture sur Account.Name, Id et CreatedDate. Le choix entre wire et appel impératif est supposé acquis. Ici, chaque clic déclenche une lecture impérative.

OFFSET permet de sauter un nombre de lignes, mais sa valeur maximale est 2 000. Pour un parcours séquentiel plus profond, mémorisez une frontière de tri. Ce contrat ne promet ni accès direct à « la page 500 » ni total exact des résultats.

Construire un curseur sans ambiguïté

Deux comptes peuvent partager la même date de création. Un curseur limité à cette date en oublierait certains. Le couple (CreatedDate, Id) départage les égalités ; l’ordre et le filtre de page suivante doivent utiliser les mêmes champs et la même direction.

-- Ordre décroissant, après la dernière ligne de la page précédente.
SELECT Id, Name, CreatedDate
FROM Account
WHERE CreatedDate < :afterDate
   OR (CreatedDate = :afterDate AND Id < :afterId)
ORDER BY CreatedDate DESC, Id DESC
LIMIT 26

Pour une page de 25 lignes, demandez-en 26 : la ligne supplémentaire indique qu’une page suivante existe. Le curseur est construit à partir de la 25e ligne affichée, pas de la sentinelle. Cette dernière sera donc récupérée au clic suivant.

La recherche, la direction et la taille font partie du parcours. Modifier l’une de ces valeurs invalide les curseurs précédents. Les valeurs reçues du navigateur restent non fiables : elles ne confèrent aucun accès et ne remplacent pas les permissions serveur.

Filtrer et trier côté Apex

public with sharing class AccountPagerController {
    // Une lecture impérative sans cache : chaque navigation relit l’état courant.
    @AuraEnabled
    public static List<Account> fetchPage(
        String search, Datetime afterDate, Id afterId,
        Integer pageSize, String direction
    ) {
        Integer size = pageSize == null ? 25 : Math.max(1, Math.min(100, pageSize));
        String orderDirection = direction == 'asc' ? 'ASC' : 'DESC';
        String comparator = direction == 'asc' ? '>' : '<';
        if ((afterDate == null) != (afterId == null)) {
            throw new AuraHandledException('Le curseur est incomplet.');
        }
        String term = String.isBlank(search) ? '' : search.trim();
        if (term.length() > 80) {
            throw new AuraHandledException('Recherche limitée à 80 caractères.');
        }
        // % et _ sont recherchés littéralement ; le seul joker est celui du suffixe.
        String prefix = term.replace('\\', '\\\\')
            .replace('%', '\\%').replace('_', '\\_') + '%';
        Map<String, Object> binds = new Map<String, Object>{
            'prefix' => prefix, 'afterDate' => afterDate,
            'afterId' => afterId, 'take' => size + 1
        };
        List<String> filters = new List<String>();
        if (term != '') filters.add('Name LIKE :prefix');
        if (afterDate != null) {
            filters.add('(CreatedDate ' + comparator + ' :afterDate'
                + ' OR (CreatedDate = :afterDate AND Id ' + comparator + ' :afterId))');
        }
        String soql = 'SELECT Id, Name, CreatedDate FROM Account';
        if (!filters.isEmpty()) soql += ' WHERE ' + String.join(filters, ' AND ');
        // Les seuls morceaux concaténés viennent des choix fermés ci-dessus.
        soql += ' ORDER BY CreatedDate ' + orderDirection + ', Id ' + orderDirection
            + ' LIMIT :take';
        try {
            return (List<Account>) Database.queryWithBinds(soql, binds, AccessLevel.USER_MODE);
        } catch (QueryException ex) {
            throw new AuraHandledException('Lecture impossible avec vos accès actuels.');
        }
    }
}

La méthode renvoie une liste d’enregistrements, avec au plus une sentinelle. Aucun champ ou sens de tri arbitraire n’est concaténé depuis le navigateur. Les valeurs métier sont liées avec queryWithBinds() ; AccessLevel.USER_MODE impose les accès utilisateur.

Le préfixe est une recherche littérale suivie d’un joker, pas une recherche libre par motif SOQL. Le nom seul n’est pas un index magique : vérifiez le plan sur le volume et la distribution de votre org, comme dans le guide des requêtes SOQL sélectives. Une extraction complète relève d’un autre parcours, par exemple la Bulk API.

Gérer la navigation dans le composant

// lwc/accountPager/accountPager.js
import { LightningElement } from 'lwc';
import fetchPage from '@salesforce/apex/AccountPagerController.fetchPage';

const PAGE_SIZE = 25;
const COLUMNS = [
    { label: 'Compte', fieldName: 'Name', type: 'text' },
    { label: 'Création', fieldName: 'CreatedDate', type: 'date' },
];

export default class AccountPager extends LightningElement {
    columns = COLUMNS;
    rows = [];
    draftSearch = '';
    search = '';
    direction = 'desc';
    busy = false;
    hasNext = false;
    message = '';
    cursors = [null];
    pageIndex = 0;
    orderOptions = [
        { label: 'Création : plus récents en premier', value: 'desc' },
        { label: 'Création : plus anciens en premier', value: 'asc' },
    ];

    connectedCallback() { this.load(null, 0, true); }
    get previousDisabled() { return this.busy || this.pageIndex === 0; }
    get nextDisabled() { return this.busy || !this.hasNext; }
    get pageLabel() { return `Page ${this.pageIndex + 1}`; }

    handleInput(event) { this.draftSearch = event.target.value; }
    handleSearch() {
        if (this.busy) return;
        this.search = this.draftSearch.trim();
        this.load(null, 0, true);
    }
    handleOrder(event) {
        if (this.busy) return;
        this.direction = event.detail.value;
        this.load(null, 0, true);
    }
    handleNext() {
        if (this.nextDisabled) return;
        const last = this.rows[this.rows.length - 1];
        this.load({ date: last.CreatedDate, id: last.Id }, this.pageIndex + 1);
    }
    handlePrevious() {
        if (this.previousDisabled) return;
        this.load(this.cursors[this.pageIndex - 1], this.pageIndex - 1);
    }

    async load(cursor, targetIndex, reset = false) {
        if (this.busy) return;
        this.busy = true;
        this.message = '';
        // Un filtre ou un tri nouveau ne doit jamais laisser une ancienne page navigable.
        if (reset) {
            this.rows = [];
            this.cursors = [null];
            this.pageIndex = 0;
            this.hasNext = false;
        }
        try {
            const records = await fetchPage({
                search: this.search, direction: this.direction,
                afterDate: cursor?.date ?? null, afterId: cursor?.id ?? null,
                pageSize: PAGE_SIZE,
            });
            this.rows = records.slice(0, PAGE_SIZE);
            this.hasNext = records.length > PAGE_SIZE;
            this.cursors = this.cursors.slice(0, targetIndex + 1);
            this.cursors[targetIndex] = cursor;
            this.pageIndex = targetIndex;
            this.message = this.rows.length ? '' : 'Aucun compte accessible.';
        } catch {
            this.message = 'Chargement impossible. Réessayez ou vérifiez vos accès.';
        } finally {
            this.busy = false;
        }
    }
}

La pile conserve le curseur de départ de chaque page visitée. « Précédent » réexécute la requête correspondante ; ce n’est pas une copie figée des anciennes données. Pendant un appel, les commandes sont bloquées. En cas d’erreur de navigation, la page courante est conservée ; après changement de recherche ou de tri, l’ancien parcours est effacé.

La recherche est soumise au clic pour ne pas consommer une requête à chaque caractère. Pour une recherche au fil de la saisie, ajoutez temporisation et identifiant de requête afin d’écarter les réponses périmées.

Afficher le tableau et les commandes

<!-- lwc/accountPager/accountPager.html -->
<template>
    <lightning-card title="Comptes de démonstration">
        <div class="slds-p-around_medium">
            <lightning-input label="Nom commençant par" value={draftSearch}
                maxlength="80" onchange={handleInput} disabled={busy}></lightning-input>
            <lightning-button label="Rechercher" onclick={handleSearch}
                disabled={busy}></lightning-button>
            <lightning-combobox label="Ordre de création" value={direction}
                options={orderOptions} onchange={handleOrder}
                disabled={busy}></lightning-combobox>
            <template lwc:if={busy}>
                <lightning-spinner alternative-text="Chargement des comptes"
                    size="small"></lightning-spinner>
            </template>
            <p role="status">{message}</p>
            <lightning-datatable key-field="Id" data={rows} columns={columns}
                hide-checkbox-column></lightning-datatable>
            <div class="slds-m-top_medium">
                <lightning-button label="Précédent" onclick={handlePrevious}
                    disabled={previousDisabled}></lightning-button>
                <span class="slds-m-horizontal_small">{pageLabel}</span>
                <lightning-button label="Suivant" onclick={handleNext}
                    disabled={nextDisabled}></lightning-button>
            </div>
        </div>
    </lightning-card>
</template>
<?xml version="1.0" encoding="UTF-8"?>
<!-- lwc/accountPager/accountPager.js-meta.xml -->
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>true</isExposed>
    <targets><target>lightning__AppPage</target></targets>
</LightningComponentBundle>

Ajoutez le fichier de métadonnées de la classe Apex en API 66.0, statut Active, et l’accès à cette classe dans un permission set. Placez ensuite le composant sur une page d’application. key-field="Id" respecte la casse du champ renvoyé par Apex.

Le tri porte sur l’ensemble des résultats via le sélecteur ; les colonnes ne proposent pas un tri local trompeur sur 25 lignes. Salesforce ne prend pas en charge lightning-datatable sur mobile : prévoir une liste adaptée pour cet usage. Le responsive de cet article ne transforme pas le composant Salesforce en composant mobile.

Tester les frontières et les permissions

Cas de démonstrationRésultat attendu
0, 25 puis 26 comptes accessiblesÉtat vide ; puis Suivant désactivé ; puis Suivant activé.
Plus de 25 lignes ayant la même CreatedDateChaque Id rencontré une seule fois en avançant, grâce au départage par Id.
Tri ascendant et descendantOrdre et opérateur du curseur inversés ensemble.
Recherche contenant une apostrophe, % ou _Valeur liée ; jokers recherchés littéralement ; aucune extension arbitraire du filtre.
Recherche ou tri modifié depuis la page 3Retour au premier curseur, pas réutilisation de la frontière précédente.
Champ ou compte non accessibleRefus explicite ou exclusion selon les permissions ; jamais d’élévation côté navigateur.

Dans un test Apex, Test.setCreatedDate() permet de placer plusieurs enregistrements de test sur la même frontière temporelle. Complétez par des tests Jest des états et de la pile de curseurs, puis un test dans Lightning avec un utilisateur aux droits limités.

La pagination n’est pas un instantané transactionnel : insertions, suppressions et changements de visibilité entre deux appels peuvent modifier le contenu d’une page. Le tri par une date de création immuable réduit les déplacements, sans figer le jeu de données. Les exemples doivent être validés dans votre sandbox avant utilisation.

Sources et prolongements

Sources officielles Salesforce : limite d’OFFSET, contrat de lightning-datatable et requêtes liées et accès utilisateur.

Pour pousser un changement Salesforce vers un portail Symfony, le problème devient celui des événements : voir Salesforce et Mercure en temps réel. Le curseur d’une liste LWC et un identifiant de reprise d’événement répondent à des contrats distincts.

🍪 Gestion des cookies
Ce site utilise des cookies pour mesurer l'audience (Google Analytics) et lire des vidéos (YouTube). Vous pouvez personnaliser votre choix.
Mentions légales
Réglages des cookies
Essentiels Nécessaires au fonctionnement du site — ne peuvent pas être désactivés.
Google Analytics Analyse d'audience (pages vues, sources de trafic). Aucune donnée personnelle identifiante.
YouTube Permet de lire les vidéos YouTube intégrées sur cette page.