Filtre admin

Filtre admin

Système de filtres admin — Comment ça marche

Runique · Axum + SeaORM + Tera · 2026-03-22

Vue d'ensemble

Le système de filtres affiche dans une sidebar les valeurs distinctes d'une colonne, et filtre la liste en cliquant dessus. Tout est SQL — aucune donnée en mémoire.

Déclaration (admin.rs)
       ↓
   Parser          → lit la macro admin!
       ↓
   Générateur      → produit admin_panel.rs
       ↓
   Handler         → orchestre les requêtes
       ↓
   Template        → rend la sidebar

Étape 1 — Déclarer un filtre

Dans src/admin.rs , le dev déclare les colonnes à filtrer :

list_filter: [
    ["lang", "Langue"],         // défaut : 10 valeurs par page
    ["block_type", "Type", 5],  // limite explicite : 5 valeurs par page
]

Chaque entrée = ["colonne", "Libellé"] ou ["colonne", "Libellé", limite] .

Règle : ne jamais filtrer sur une FK ou un id . Valeurs brutes illisibles. Bons candidats : booléens, énumérations, codes courts.

Étape 2 — Le parser

Le daemon ( runique start ) lit src/admin.rs token par token et construit une structure intermédiaire :

pubstructResourceDef {
pub list_filter: Vec<(String, String, u64)>,
//                    col     label   limit
}

Le 3ème élément est optionnel — si absent, la limite par défaut est 10 .

Étape 3 — Le générateur

À partir de la structure, le daemon génère deux blocs dans admin_panel.rs .

La configuration d'affichage

let meta = meta.display(
    DisplayConfig::new()
        .list_filter(vec![
            ("lang", "Langue", 10u64),
            ("block_type", "Type", 5u64),
        ])
);

Cette config est sérialisée et accessible dans Tera via resource.display.list_filter .

La closure de filtres

Pour chaque colonne, deux requêtes SQL sont générées :

Comptage — pour savoir combien de pages existent :

SELECTCOUNT(DISTINCT lang) FROM doc_page WHERE lang ISNOTNULL

Valeurs paginées — seulement ce qui est affiché :

SELECTDISTINCTCAST(lang ASTEXT)
FROM doc_page
WHERE lang ISNOTNULL
ORDERBY lang ASC
LIMIT10OFFSET20-- page 2 × 10

CAST(... AS TEXT) uniformise le type : booléens, entiers et chaînes passent tous par là.

Le résultat est un HashMap<String, (Vec<String>, u64)> : chaque colonne → ses valeurs + son total distinct.

Étape 4 — Le handler

Dans admin_main.rs , deux séries de paramètres URL sont parsées :

filter_lang=fr          → filtre actif sur la colonne lang
fp_lang=2               → page 2 dans la sidebar du groupe lang

Les trois requêtes tournent en parallèle grâce à tokio::join! :

tokio::join!(
    list_fn(db, list_params),    // entrées de la table
    count_fn(db, search),        // total pour la pagination principale
    filter_fn(db, filter_pages), // valeurs distinctes par colonne
)

Étape 5 — La pagination des filtres

C'est la partie centrale. Plutôt que charger 100 valeurs en JS, on pagine côté serveur.

Pour chaque colonne, le handler calcule filter_meta :

current_page  → page affichée actuellement
total_pages   → nombre total de pages
prev_qs       → query string complet du lien "page précédente"
next_qs       → query string complet du lien "page suivante"

prev_qs et next_qs sont précalculés en Rust car Tera ne peut pas construire des query strings complexes. Le template n'a plus qu'à écrire :

<a href="?{{ meta.prev_qs }}&page=1">‹</a>
<a href="?{{ meta.next_qs }}&page=1">›</a>

Ces liens préservent automatiquement le tri, la recherche et les autres filtres actifs.

Étape 6 — Le template

La sidebar s'affiche uniquement si list_filter est non vide :

{% for filter_entry in resource.display.list_filter %}
  {% set col    = filter_entry[0] %}
  {% set label  = filter_entry[1] %}
  {% set values = filter_values[col] %}
  {% set meta   = filter_meta[col] %}
  {% if values | length > 0 %}
<!-- groupe visible -->
  {% endif %}
{% endfor %}

Chaque valeur est un lien qui ajoute filter_{col}={val} à l'URL. Cliquer applique un WHERE col = val côté SQL.

Étape 7 — Repli par groupe

Chaque groupe peut être replié individuellement. L'état est sauvegardé dans localStorage par ressource + colonne :

clé : runique_fg_doc_page_lang
      → '1' = ouvert
      → '0' = replié

Au chargement, chaque groupe restaure son état. Au clic sur le titre, le corps est caché/montré et l'état est sauvegardé.

Étape 8 — Diagnostic

Si une colonne n'existe pas en base, la requête échoue. Au lieu de passer silencieusement, le code généré log une erreur :

ERROR [runique admin] list_filter `doc_block.lang` :
      colonne introuvable en DB — column "lang" does not exist

Le dev voit immédiatement quelle colonne poser dans list_filter est invalide.

Résumé des choix de conception

ChoixAlternative écartéePourquoi
PaginationserveurCharger 100 valeurs en
JS
Scalable, sans limite arbitraire
ChoixAlternative écartéePourquoi
Limite per-colonneLimite globaleChaque colonne a des besoins
différents
tokio::join!Requêtes séquentiellesLes 3 sont indépendantes
tracing::error!sur colonne
absente
unwrap_orsilencieuxDiagnostique immédiat
localStorage par ressource+colonneÉtat globalDeux ressources indépendantes