Aller au contenu principal

Afficher une collection paginée et filtrable - VueJs

remarque

Ce cookbook décrit la mise en place côté frontend VueJs 3 d'un composant de table réutilisable qui consomme une API paginée (API Platform), avec détection automatique des colonnes triables/filtrables.

L'objectif : fournir un composant générique BaseTable configurable via props, qui gère pagination, tri et filtres sans code spécifique à chaque entité.

Deux modes d'affichage sont supportés :

  • Pagination classique : navigation par pages avec boutons de pagination
  • Scroll infini : chargement automatique des données au défilement
Code source complet

Le code complet (composants, services, modèles) est disponible dans le repo d'exemples Doing.

➡ VueJs : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/collections/vuejs/

Les extraits ci-dessous se concentrent sur les parties importantes pour comprendre le pattern (composant table, service, modèles, exemple customer).

VueJs : 1h

Comprendre le cas d'usage

Cas d'usage typique back-office / dashboard :

  • Côté API, on a une ressource paginée /customers qui retourne :
    • les items (member),
    • le total (totalItems),
    • les métadonnées de recherche (search.mapping) indiquant les champs triables/filtrables.
  • Côté frontend, on veut :
    • un composant table réutilisable qui s'adapte à n'importe quelle entité,
    • une détection automatique des colonnes triables/filtrables depuis l'API,
    • une pagination côté serveur (pas de chargement de toutes les données).
Quand utiliser ce pattern ?

Utilise ce pattern dès que :

  • tu dois afficher des listes paginées issues d'une API REST/JSON-LD,
  • tu veux un composant table générique réutilisable sur plusieurs entités,
  • tu veux éviter de dupliquer la logique de pagination/tri/filtres.

Pré-requis

ÉlémentVersion / Remarque
VueJs3.5+
TypeScript5.x
API BackendAPI Platform 4.x avec pagination activée
HTTP ClientAxios

Déclarer les modèles TypeScript

But de cette étape : définir les types qui structurent les données de la table.

Interface TableColumn

Définit la structure d'une colonne :

src/core/models/table/tableColumn.ts
import type { InputFilterType } from "./inputFilterType";

export interface TableColumn {
field: string;
label: string;
sortable?: boolean;
filterable?: boolean;
filterType?: InputFilterType;
}

Propriétés clés :

  • field : le nom du champ dans l'objet JSON retourné par l'API.
  • label : le libellé affiché dans l'en-tête.
  • sortable / filterable : indique si la colonne supporte le tri/filtre (auto-détecté via l'API).
  • filterType : type d'input pour le filtre (text, number, date).

Enum InputFilterType

Définit les types de filtres disponibles :

src/core/models/table/inputFilterType.ts
export enum InputFilterType {
TEXT = "text",
NUMBER = "number",
DATE = "date",
}

Interface TableQueryParams

Définit les paramètres de requête envoyés à l'API :

src/core/models/table/tableQueryParams.ts
export interface TableQueryParams {
page?: number;
itemsPerPage?: number;
orders?: Order[];
[key: string]: unknown;
}

interface Order {
field: string;
direction: "asc" | "desc";
}

L'index signature [key: string]: unknown permet de passer n'importe quel filtre dynamique.

Créer le service de table générique

But : fournir une fonction réutilisable pour appeler n'importe quel endpoint de collection API Platform.

Fonction fetchCollection et construction des query params

src/core/services/tableService.ts
import { InputFilterType } from "../models/table/inputFilterType";
import type { TableColumn } from "../models/table/tableColumn";
import type { TableQueryParams } from "../models/table/tableQueryParams";
import Network from "../utils/network";

const apiUrl = import.meta.env.VITE_API_URL;

/**
* Fetch générique d'une collection API Platform
*/
export const fetchCollection = async <T>(
endpoint: string,
params: TableQueryParams & Record<string, unknown>
): Promise<T> => {
const queryString = _buildQueryParams(params).toString();
const url = `${apiUrl}${endpoint}${queryString ? `?${queryString}` : ""}`;

const response = await Network.execute({
method: "GET",
url,
headers: {
Accept: "application/ld+json",
},
});

return response.data;
};

/**
* Construit dynamiquement les queryParams à partir des paramètres de requête
*/
const _buildQueryParams = (
params: TableQueryParams & Record<string, unknown>
): URLSearchParams => {
const queryParams = new URLSearchParams();

Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") return;

// Gestion spéciale pour les orders
if (key === "orders" && Array.isArray(value)) {
value.forEach((order: { field: string; direction: string }) => {
queryParams.append(`order[${order.field}]`, order.direction);
});
return;
}

// Gestion des tableaux
if (Array.isArray(value)) {
value.forEach((v) => queryParams.append(`${key}[]`, String(v)));
return;
}

// Valeurs simples
queryParams.append(key, String(value));
});

return queryParams;
};

...

Points importants :

  • endpoint : chemin relatif (/customers, /products, etc.).
  • params : contient page, itemsPerPage, orders, et tous les filtres dynamiques.
  • Le header Accept: application/ld+json est requis pour obtenir les métadonnées hydra.
Format des paramètres de tri

API Platform attend le format order[field]=asc|desc. La fonction transforme automatiquement orders: [{ field: 'fullName', direction: 'asc' }] en order[fullName]=asc.

Enrichissement automatique des colonnes

src/core/services/tableService.ts
...

export const enhanceColumnsFromApi = (
items: Array<object>,
searchMapping?: Array<{
variable: string;
property: string;
required: boolean;
}>,
columns?: TableColumn[]
) => {
if (!searchMapping || searchMapping.length === 0 || !columns) return columns;

// Identifier les champs triables (ceux avec order[...])
const sortableFields = searchMapping
.filter((m) => m.variable.startsWith("order["))
.map((m) => m.property);

// Identifier les champs filtrables (ceux sans order[...])
const filterableFields = searchMapping
.filter((m) => !m.variable.startsWith("order["))
.map((m) => m.property);

// Enrichir les colonnes
return columns?.map((col) => ({
...col,
sortable: col.sortable ?? sortableFields.includes(col.field),
filterable: col.filterable ?? filterableFields.includes(col.field),
filterType: col.filterType ?? _detectFieldType(col.field, items),
}));
};

const _detectFieldType = (
field: string,
items: Array<object>
): InputFilterType => {
if (items.length === 0) return InputFilterType.TEXT;

const value = (items[0] as Record<string, unknown>)[field];

if (value === null || value === undefined) return InputFilterType.TEXT;
if (typeof value === "number") return InputFilterType.NUMBER;
if (value instanceof Date) return InputFilterType.DATE;
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return InputFilterType.DATE;
}

return InputFilterType.TEXT;
};

Idée clé : l'API Platform retourne les métadonnées search.mapping qui indiquent :

  • les paramètres order[xxx] → champs triables,
  • les autres paramètres → champs filtrables.

Le service enrichit automatiquement les colonnes avec ces informations.

Créer le composant BaseTable

But : un composant réutilisable qui gère toute la logique de table.

Template du composant

src/core/components/table/BaseTable.vue
<template>
<div class="base-table">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th v-for="column in enhancedColumns" :key="column.field">
{{ column.label }}
<button
v-if="column.sortable"
class="sort-btn"
@click="toggleSort(column.field)"
>
{{ getSortIcon(column.field) }}
</button>
</th>
</tr>
<tr v-if="hasFilters">
<th
v-for="column in enhancedColumns"
:key="'filter-' + column.field"
>
<input
v-if="column.filterable"
v-model="filters[column.field]"
:type="column.filterType"
:placeholder="`Filtrer par ${column.label.toLowerCase()}...`"
class="filter-input"
@input="onFilterChange"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0 && !loading">
<td :colspan="enhancedColumns.length" class="no-data">
Aucun résultat
</td>
</tr>
<tr
v-for="(item, index) in items"
:key="item[props.itemKey] || index"
>
<td v-for="column in enhancedColumns" :key="column.field">
<slot
:name="`cell-${column.field}`"
:item="item"
:value="item[column.field]"
>
{{ item[column.field] }}
</slot>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="loading-overlay">
<BaseLoader />
</div>
</div>
<TablePagination
:current-page="currentPage"
:total-items="totalItems"
:entity-name="entityName"
:items-per-page="itemsPerPage"
@page-change="goToPage"
/>
</div>
</template>

Points clés du template :

  • Bouton de tri affiché uniquement si column.sortable.
  • Ligne de filtres affichée uniquement si au moins une colonne est filtrable.
  • Slots nommés cell-{field} pour personnaliser le rendu des cellules.
  • Composant TablePagination pour la navigation.

Fonctions du composant

src/core/components/table/BaseTable.vue
<script setup lang="ts" generic="T extends Record<string, any>">
import { ref, computed, onMounted, onUnmounted } from "vue";
import TablePagination from "./TablePagination.vue";
import { TableColumn } from "@/core/models/table/tableColumn";
import { TableQueryParams } from "@/core/models/table/tableQueryParams";
import { enhanceColumnsFromApi } from "@/core/services/tableService";
import BaseLoader from "../BaseLoader.vue";

defineOptions({
name: "BaseTable",
});

const props = defineProps({
entityName: {
type: String,
default: "éléments",
},
itemKey: {
type: String,
default: "id",
},
itemsPerPage: {
type: Number,
default: 20,
},
columns: {
type: Array as () => TableColumn[],
required: true,
},
fetchData: {
type: Function,
required: true,
},
});

const items = ref<T[]>([]);
const currentPage = ref(1);
const totalItems = ref(0);
const loading = ref(false);
const filters = ref<Record<string, unknown>>({});
const sortOrders = ref<Array<{ field: string; direction: "asc" | "desc" }>>([]);
const enhancedColumns = ref<TableColumn[]>(props.columns);

const hasFilters = computed(() =>
enhancedColumns.value.some((col) => col.filterable)
);

...

Props importantes :

  • columns : définition initiale des colonnes.
  • fetchData : fonction asynchrone fournie par le parent pour charger les données.
  • entityName : libellé affiché dans la pagination ("Total: 42 clients").

Chargement des données

src/core/components/table/BaseTable.vue
...

const loadData = async () => {
loading.value = true;
try {
const activeFilters = Object.entries(filters.value).reduce(
(acc, [key, value]) => {
if (value !== "" && value !== null && value !== undefined) {
acc[key] = value;
}
return acc;
},
{} as Record<string, unknown>
);

const params: TableQueryParams & Record<string, unknown> = {
page: currentPage.value,
itemsPerPage: props.itemsPerPage,
...activeFilters,
...(sortOrders.value.length > 0 && { orders: sortOrders.value }),
};

const response = await props.fetchData(params);
items.value = response.items;
totalItems.value = response.totalItems;

// Enrichir les colonnes avec les métadonnées de l'API
if (response.search?.mapping) {
enhancedColumns.value =
enhanceColumnsFromApi(
items.value,
response.search.mapping,
props.columns
) ?? props.columns;
}
} catch (error) {
console.error("Erreur lors du chargement des données:", error);
} finally {
loading.value = false;
}
};

...
Auto-enrichissement des colonnes

Après le premier chargement, les colonnes sont automatiquement enrichies avec les infos de tri/filtre depuis response.search.mapping. Pas besoin de configurer manuellement sortable: true sur chaque colonne !

Gestion du tri

src/core/components/table/BaseTable.vue
...

const toggleSort = (field: string) => {
const existingSort = sortOrders.value.find((order) => order.field === field);

if (!existingSort) {
sortOrders.value = [{ field, direction: "asc" }];
} else if (existingSort.direction === "asc") {
existingSort.direction = "desc";
} else {
sortOrders.value = sortOrders.value.filter(
(order) => order.field !== field
);
}

currentPage.value = 1;
loadData();
};

const getSortIcon = (field: string) => {
const order = sortOrders.value.find((o) => o.field === field);
if (!order) return "↕";
return order.direction === "asc" ? "↑" : "↓";
};

...

Cycle de tri : Neutre → ASC → DESC → Neutre.

Gestion des filtres avec debounce

src/core/components/table/BaseTable.vue
...

let filterTimeout: number | undefined;

const onFilterChange = () => {
clearTimeout(filterTimeout);
filterTimeout = window.setTimeout(() => {
currentPage.value = 1;
loadData();
}, 500);
};

onMounted(() => {
loadData();
});

onUnmounted(() => {
clearTimeout(filterTimeout);
});

</script>

Un debounce de 500ms évite les appels API à chaque frappe.

Créer le composant TablePagination

But : afficher la pagination avec navigation et compteur.

src/core/components/table/TablePagination.vue
<template>
<div v-if="totalItems > 0" class="pagination">
<span class="total-info">Total: {{ totalItems }} {{ entityName }}</span>
<div class="pagination-controls">
<button
v-for="page in pageNumbers"
:key="page"
:class="['page-btn', { active: page === currentPage }]"
:disabled="page === currentPage || typeof page !== 'number'"
@click="handlePageClick(page as number)"
>
{{ page }}
</button>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from "vue";

defineOptions({
name: "TablePagination",
});

const props = defineProps({
currentPage: {
type: Number,
required: true,
},
totalItems: {
type: Number,
required: true,
},
itemsPerPage: {
type: Number,
required: true,
},
entityName: {
type: String,
default: "éléments",
},
});

const emit = defineEmits<{
pageChange: [value: number];
}>();

const totalPages = computed(() => {
return Math.ceil(props.totalItems / props.itemsPerPage);
});

const pageNumbers = computed(() => {
const total = totalPages.value;
const current = props.currentPage;

if (total <= 5) {
return Array.from({ length: total }, (_, i) => i + 1);
}

const pages: (number | string)[] = [1];
if (current > 3) {
pages.push("...");
}

const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);

for (let i = start; i <= end; i++) {
pages.push(i);
}

if (current < total - 2) {
pages.push("...");
}

pages.push(total);
return pages;
});

const handlePageClick = (page: number) => {
emit("pageChange", page);
};
</script>

La pagination affiche intelligemment les pages avec ellipses (...) pour les grandes collections.

Exemple d'utilisation : Customer (pagination)

But : montrer comment utiliser notre composant générique pour une entité spécifique.

Modèle Customer

src/modules/connected/customer/models/customer.ts
export interface Customer {
id: number;
fullName: string;
age: number;
city: string;
}

export interface CustomerCollection {
totalItems: number;
member: Customer[];
search?: {
mapping: Array<{
variable: string;
property: string;
required: boolean;
}>;
};
}

Service Customer

src/modules/connected/customer/services/customerService.ts
import type { CustomerCollection } from "@/modules/connected/customer/models/customer";
import { fetchCollection } from "@/core/services/tableService";
import type { TableQueryParams } from "@/core/models/table/tableQueryParams";

const getCustomers = async (
params: TableQueryParams
): Promise<CustomerCollection> => {
return fetchCollection<CustomerCollection>("/customers", params);
};

export default { getCustomers };

Le service réutilise la fonction générique fetchCollection avec le bon typage.

Vue Customer

src/modules/connected/customer/views/CustomersView.vue
<template>
<BaseTable
:columns="columns"
:fetch-data="fetchCustomers"
entity-name="clients"
:items-per-page="5"
/>
</template>

<script setup lang="ts">
import BaseTable from "@/core/components/table/BaseTable.vue";
import customerService from "@/modules/connected/customer/services/customerService";
import { TableColumn } from "@/core/models/table/tableColumn";
import { TableQueryParams } from "@/core/models/table/tableQueryParams";

defineOptions({
name: "CustomersView",
});

const columns: TableColumn[] = [
{ field: "id", label: "ID" },
{ field: "fullName", label: "Nom complet" },
{ field: "age", label: "Âge" },
{ field: "city", label: "Ville" },
];

const fetchCustomers = async (params: TableQueryParams) => {
const response = await customerService.getCustomers(params);
return {
items: response.member,
totalItems: response.totalItems,
search: response.search,
};
};
</script>

Points importants :

  • On déclare les colonnes avec leurs field, label.
  • On passe une fonction fetchCustomers qui retourne le format attendu par BaseTable.
  • L'API enrichit automatiquement les colonnes pour ajouter tri/filtres.

Personnaliser le rendu des cellules

Grâce aux slots nommés, on peut personnaliser l'affichage de n'importe quelle colonne :

<template>
<BaseTable
:columns="columns"
:fetch-data="fetchCustomers"
entity-name="clients"
>
<!-- Slot pour personnaliser la cellule "age" -->
<template #cell-age="{ value }">
<span :class="{ 'text-red-500': value < 18 }"> {{ value }} ans </span>
</template>

<!-- Slot pour ajouter des actions -->
<template #cell-id="{ item }">
<button @click="editCustomer(item)">✏️</button>
<button @click="deleteCustomer(item)">🗑️</button>
</template>
</BaseTable>
</template>

Checklist de mise en prod

PointOK ?
Service générique fetchCollection et composant BaseTable réutilisable
Pagination côté serveur fonctionnelle
Auto-détection des colonnes triables/filtrables
Debounce sur les filtres
Mode scroll infini avec chargement automatique
Reset de la liste sur changement de filtre/tri (scroll infini)

FAQ

Comment ajouter un filtre personnalisé qui n'est pas une colonne ?

Tu peux passer des paramètres supplémentaires dans la fonction fetchData :

const fetchCustomers = async (params: TableQueryParams) => {
const response = await customerService.getCustomers({
...params,
status: "active", // Filtre fixe
});
return { items: response.member, totalItems: response.totalItems };
};
Comment désactiver l'auto-détection des colonnes ?

Définis explicitement sortable et filterable sur chaque colonne :

const columns: TableColumn[] = [
{ field: "id", label: "ID", sortable: false, filterable: false },
{ field: "fullName", label: "Nom", sortable: true, filterable: true },
];

Le service n'écrasera pas les valeurs déjà définies.

Comment choisir entre pagination et scroll infini ?

Utilise la pagination classique quand :

  • L'utilisateur doit pouvoir accéder rapidement à une page précise
  • Le contexte est un back-office / dashboard avec des données tabulaires
  • L'utilisateur veut voir le nombre total de pages

Utilise le scroll infini quand :

  • L'interface est mobile ou tactile
  • Les données sont parcourues séquentiellement (timeline, feed)
  • Tu veux une expérience plus fluide sans clics de navigation