Aller au contenu principal

Comment mettre en place une liste avec recherche dans Easy2Do ?

Ce cookbook montre comment construire la brique la plus courante d'une application Easy2Do : une liste paginée et triable, alimentée en AJAX, et filtrée par un moteur de recherche placé dans le panneau latéral « Recherche ».

L'exemple s'appuie sur un module contact (annuaire) avec quatre types de filtres : un mot-clé (recherche partielle sur plusieurs colonnes), un texte libre (ville), une liste déroulante alimentée par le serveur (statut) et une période (dates de création).

Code source complet

Le projet complet (Docker, module contact, jeu de 45 contacts) est disponible dans le repo d'exemples Doing.

➡ Easy2Do : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/e2d-liste-recherche/

Easy2Do : 1h30 Total : 1h30

Liste des contacts paginée

TL;DR​

  • La liste est une <table class="tableau_donnees ..."> : le core la charge seul, clone la ligne tr.clone pour chaque résultat et remplit chaque cellule dont la classe porte le nom d'une propriété JSON.
  • Le formulaire de recherche est injecté dans #zone_navigation_2. Chaque champ dont le name finit par Rch devient un critère côté serveur.
  • Côté PHP, ControllerAction fournit l'action recherche (pagination incluse). Le modèle surcharge sRequeteSelect() et sCritereRecherche().
  • Le tri par colonne passe par <th id="th_<colonne>" class="tri"> et doit être filtré par une liste blanche dans le modèle.

Prérequis​

ÉlémentVersion / Remarque
PHP8.4
E2D - Core2.18
E2D - Bdd3.0
E2D - Auth1.8

Ce cookbook suppose connue la création d'un module (voir Comment créer un module de base ?).

Mise en place de l'environnement​

git clone --recurse-submodules git@bitbucket.org:doingfr/cookbook-liste-recherche.git
cd cookbook-liste-recherche
cp surcharge_conf.php.example surcharge_conf.php
make up # PHP 8.4, nginx, MariaDB, phpMyAdmin, Mailcatcher
make install # tables authentification + contact, bResetCache, utilisateur de démo

L'application est disponible sur http://localhost:8081/admin/login (doing / doing). La liste des contacts est la page d'accueil de l'administration.

Que fait make install ?
  1. Il appelle ?bModeInstallModule=1&szModulesPourInstall=authentification, puis ...=contact. Les deux appels sont séparés : si un module est déjà installé, son script SQL échoue et bloque les suivants.
  2. Il lance un bResetCache (voir le piège n°1 plus bas).
  3. Il pose un mot de passe bcrypt pour doing. Le hash fourni par la ressource authentification 1.8 n'est pas reconnu par password_verify() en PHP 8.4.

Comment ça marche​

Page admin/contact/accueil.html (ContactAdminHTML)
├─ #zone_navigation_3 ← vues/liste_contact.html (table.tableau_donnees + pagination)
└─ #zone_navigation_2 ← vues/recherche_contact.html (form, champs ...Rch)

Au chargement (Liste.js) :
POST /admin/contact/recherche.json?nPage=1&nNbElementsParPage=20[&szOrderBy=ville ASC]
corps JSON = champs du formulaire : {"sMotClePartielRch": "lou", "sStatutRch": "client", ...}
→ ControllerAction::aRechercheElements()
retire le suffixe Rch → $aRecherche = ['sMotClePartiel' => 'lou', 'sStatut' => 'client']
oGetInfosPagination() → Contact::nGetNbElements() → sRequeteSelect(..., $bModeCount = true)
aGetElements() → Contact::sRequeteSelect() + LIMIT
← {"aElements": [...], "aPagination": {nPage, nStart, nNbElements, nNbPages, nMinRange, nMaxRange}}
→ clonage de tr.clone, pagination, tri, puis callback_contact_vCallbackListeElement
→ vChargeEvenementsChampsRecherche() : chaque "change" d'un champ recharge la liste (page 1)

Structure du module​

modules/
└── contact/
├── config/
│ ├── actions.yml ← action de dynamisation du formulaire
│ ├── conf.yml ← namespaces, vue accueil_contact, JS
│ └── routing.yml ← page HTML + 2 routes JSON
├── controllers/
│ ├── ContactAdminAction.class.php
│ └── ContactAdminHTML.class.php
├── installation/
│ └── installation.sql
├── JS/
│ ├── ContactAdmin.js ← hérite de Recherche
│ └── Contact.js ← point d'entrée, vInit()
├── models/
│ └── Contact.class.php
└── vues/
├── accueil_contact.html ← vue du mode (obligatoire, même vide)
├── liste_contact.html
└── recherche_contact.html

1. La table SQL​

Pensez aux index sur les colonnes filtrées et triées.

-- modules/contact/installation/installation.sql
CREATE TABLE IF NOT EXISTS `contact` (
`id_contact` int(11) NOT NULL AUTO_INCREMENT,
`nom` varchar(80) NOT NULL DEFAULT '',
`prenom` varchar(80) NOT NULL DEFAULT '',
`email` varchar(150) NOT NULL DEFAULT '',
`ville` varchar(80) NOT NULL DEFAULT '',
`statut` enum('prospect','client','inactif') NOT NULL DEFAULT 'prospect',
`date_creation` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_contact`),
KEY `idx_contact_nom` (`nom`),
KEY `idx_contact_ville` (`ville`),
KEY `idx_contact_statut` (`statut`),
KEY `idx_contact_date_creation` (`date_creation`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- + 45 contacts de démonstration (3 pages de 20)

2. Le modèle​

Le modèle hérite de Bdd et surcharge deux méthodes :

  • sRequeteSelect() construit la requête. Elle est appelée par aGetElements() (liste) et par nGetNbElements() (comptage, $bModeCount = true).
  • sCritereRecherche() traduit les critères reçus en clause WHERE.
szGetSelect() n'est plus appelée

Les anciens modèles (et le tutoriel Easy2Do historique) surchargent szGetSelect() et szGetCriteresRecherche(). Depuis la ressource Bdd 3.0, Bdd définit lui-même un sRequeteSelect() par défaut, qui est toujours prioritaire sauf si bUtilisation_sRequeteSelect: false est défini dans la configuration. Un modèle qui ne surcharge que szGetSelect() est donc ignoré. Surchargez sRequeteSelect().

Contraintes imposées par le core :

ÉlémentPourquoi
... AS nIdElementidentifiant attendu par la liste (cases à cocher, boutons de ligne)
COUNT(*) AS nNbElements en mode comptagelu par nGetNbElements() pour la pagination
Sous-requête SELECT * FROM (...) matablele tri porte sur les noms de colonnes de la sous-requête (ville, date_creation)
Pas de LIMITajouté par aGetElements()
aMappingChampsrenomme les colonnes en propriétés (nom → sNom) après la requête : ce sont ces noms qui arrivent dans le JSON
<?php
// modules/contact/models/Contact.class.php

namespace APP\Modules\Contact\Models;

use APP\Modules\Base\Lib\Bdd as Bdd;

class Contact extends Bdd
{
// Source unique des statuts : requête, critères et liste déroulante.
const A_STATUTS = [
'prospect' => 'Prospect',
'client' => 'Client',
'inactif' => 'Inactif',
];

// Colonnes de la sous-requête autorisées pour le tri.
const A_CHAMPS_TRIABLES = ['nom', 'prenom', 'email', 'ville', 'statut', 'date_creation'];

const S_TRI_DEFAUT = 'nom ASC, prenom ASC';

public $nIdContact;
public $sNom;
public $sPrenom;
public $sEmail;
public $sVille;
public $sStatut;
public $dDateCreation;

public function __construct($nIdElement = 0)
{
$this->sNomTable = 'contact';
$this->sAliasTable = 'CON';
$this->sNomClePrimaire = 'id_contact';

$this->aMappingChamps = [
'id_contact' => 'nIdContact',
'nom' => 'sNom',
'prenom' => 'sPrenom',
'email' => 'sEmail',
'ville' => 'sVille',
'statut' => 'sStatut',
'statut_libelle' => 'sStatutLibelle',
'date_creation' => 'dDateCreation',
'date_creation_formatee' => 'sDateCreationFormatee',
];

// Connexion uniquement : le chargement de l'élément se fait
// ci-dessous avec un critère indexé par nom de propriété.
parent::__construct();

if ($nIdElement > 0) {
$aContact = $this->aGetElements(['nIdContact' => $nIdElement]);
if (isset($aContact[0]) === true) {
foreach ($aContact[0] as $sPropriete => $mValeur) {
$this->$sPropriete = $mValeur;
}
}
}
}

public function sRequeteSelect(
array $aRecherche = [],
string $sOrderBy = '',
bool $bModeCount = false,
int $nStart = 0,
int $nNbElements = 0,
string $sGroupBy = '',
string $sContexte = ''
): string {
if ($bModeCount === true) {
$sChamps = 'COUNT(*) AS nNbElements';
} else {
$sChamps = '
CON.id_contact AS nIdElement,
CON.id_contact, CON.nom, CON.prenom, CON.email, CON.ville,
CON.statut, ' . $this->sGetClauseCase('CON.statut', self::A_STATUTS) . ' AS statut_libelle,
CON.date_creation,
' . $this->sDateFormat('CON.date_creation', '%d/%m/%Y') . ' AS date_creation_formatee
';
}

$sRequete = '
SELECT *
FROM
(
SELECT ' . $sChamps . '
FROM contact AS CON
WHERE 1=1
' . $this->sCritereRecherche($aRecherche) . '
) matable
';

if ($bModeCount === false) {
if ($sGroupBy != '') {
$sRequete .= ' GROUP BY ' . $sGroupBy . ' ';
}
$sRequete .= ' ORDER BY ' . $this->sGetOrderByValide($sOrderBy) . ' ';
}

return $sRequete;
}

protected function sCritereRecherche(array $aCritere = []): string
{
$sRequete = '';

if (isset($aCritere['nIdContact']) === true && (int) $aCritere['nIdContact'] > 0) {
$sRequete .= ' AND CON.id_contact = ' . (int) $aCritere['nIdContact'];
}

// Mot-clé : recherche partielle sur le nom, le prénom et l'email.
if (isset($aCritere['sMotClePartiel']) === true && trim($aCritere['sMotClePartiel']) != '') {
$sMotCle = $this->rConnexion->quote('%' . trim($aCritere['sMotClePartiel']) . '%');
$sRequete .= '
AND (CON.nom LIKE ' . $sMotCle . '
OR CON.prenom LIKE ' . $sMotCle . '
OR CON.email LIKE ' . $sMotCle . ')
';
}

if (isset($aCritere['sVillePartiel']) === true && trim($aCritere['sVillePartiel']) != '') {
$sRequete .= ' AND CON.ville LIKE ' . $this->rConnexion->quote('%' . trim($aCritere['sVillePartiel']) . '%');
}

// Statut : seules les valeurs connues sont acceptées.
if (isset($aCritere['sStatut']) === true && isset(self::A_STATUTS[$aCritere['sStatut']]) === true) {
$sRequete .= ' AND CON.statut = ' . $this->rConnexion->quote($aCritere['sStatut']);
}

// Période de création (dates saisies au format jj/mm/aaaa).
$dDebut = $this->sGetDateSql($aCritere['dDateCreationDebut'] ?? '');
if ($dDebut != '') {
$sRequete .= ' AND CON.date_creation >= ' . $this->rConnexion->quote($dDebut . ' 00:00:00');
}
$dFin = $this->sGetDateSql($aCritere['dDateCreationFin'] ?? '');
if ($dFin != '') {
$sRequete .= ' AND CON.date_creation <= ' . $this->rConnexion->quote($dFin . ' 23:59:59');
}

return $sRequete;
}

// Filtre le tri reçu du navigateur (liste blanche colonnes + sens).
private function sGetOrderByValide($sOrderBy = '')
{
$aOrderBy = [];

foreach (explode(',', $sOrderBy) as $sUnTri) {
$aUnTri = preg_split('/\s+/', trim($sUnTri));
$sChamp = $aUnTri[0] ?? '';
$sSens = strtoupper($aUnTri[1] ?? 'ASC');

if (in_array($sChamp, self::A_CHAMPS_TRIABLES, true) === true && in_array($sSens, ['ASC', 'DESC'], true) === true) {
$aOrderBy[] = $sChamp . ' ' . $sSens;
}
}

if (empty($aOrderBy) === true) {
return self::S_TRI_DEFAUT;
}

return implode(', ', $aOrderBy);
}

// Convertit jj/mm/aaaa en aaaa-mm-jj (chaîne vide si date invalide).
private function sGetDateSql($sDate = '')
{
$oDate = \DateTime::createFromFormat('!d/m/Y', trim((string) $sDate));

if ($oDate === false || $oDate->format('d/m/Y') !== trim((string) $sDate)) {
return '';
}

return $oDate->format('Y-m-d');
}

// Options de la liste déroulante des statuts (format aSelects de Formulaire.js).
public function aGetOptionsStatut()
{
$aOptions = [];

foreach (self::A_STATUTS as $sValeur => $sLibelle) {
$aOptions[] = ['valeur' => $sValeur, 'libelle' => $sLibelle];
}

return $aOptions;
}
}
Pourquoi surcharger le constructeur ?

Bdd::__construct($nIdElement) charge l'élément avec le critère ['id_contact' => $nIdElement], c'est-à-dire indexé par nom de colonne. Or sCritereRecherche() attend des noms de propriétés (nIdContact), si bien que new Contact(12) chargerait le premier contact venu. Le constructeur ci-dessus reprend le modèle du core (core/modules/easy2do/models/Recherche.class.php).

3. Le controller Action​

ControllerAction câble l'action recherche : il collecte les paramètres ...Rch, calcule la pagination et appelle aGetElements(). On n'ajoute que l'action dynamisation_recherche, qui renvoie les options des listes déroulantes du formulaire.

<?php
// modules/contact/controllers/ContactAdminAction.class.php

namespace APP\Modules\Contact\Controllers;

use APP\Core\Lib\Interne\PHP\ControllerAction as ControllerAction;

class ContactAdminAction extends ControllerAction
{
// ControllerAction en déduit le modèle « Contact ».
protected $sTable = 'contact';

public function __construct($szAction = '')
{
switch ($szAction) {
case 'dynamisation_recherche':
echo json_encode($this->aDynamisationRecherche());
break;

default:
// recherche, dynamisation_edition, suppression...
parent::__construct($szAction);
break;
}
}

private function aDynamisationRecherche()
{
$oContact = $this->oNew('Contact');

return [
'aSelects' => [
'sStatut' => $oContact->aGetOptionsStatut(),
],
];
}
}

4. Le controller HTML​

La liste va dans #zone_navigation_3 (zone de résultats du modèle admin-fullscreen), la recherche dans #zone_navigation_2 (panneau « Recherche », avec son bouton de réinitialisation .btn_vide_recherche).

<?php
// modules/contact/controllers/ContactAdminHTML.class.php

namespace APP\Modules\Contact\Controllers;

use APP\Core\Lib\Interne\PHP\AffichageHTML as AffichageHTML;

class ContactAdminHTML extends AffichageHTML
{
public function szGetContenuCentralHTML()
{
// Insère les ressources JS/CSS déclarées dans conf.yml.
$szContenu = parent::szGetContenuCentralHTML();

// oGetVue() injecte aussi la pagination dans les .container-pagination.
$sFichierContenu = $this->szGetFichierPourInclusion('modules', 'contact/vues/liste_contact.html');
$oContenu = $this->oGetVue($sFichierContenu);
$this->objQpModele->find('#zone_navigation_3')->html($oContenu->find('body')->html());

$sFichierContenu = $this->szGetFichierPourInclusion('modules', 'contact/vues/recherche_contact.html');
$oContenu = $this->oGetVue($sFichierContenu);
$this->objQpModele->find('#zone_navigation_2')->html($oContenu->find('body')->html());

return $szContenu;
}
}
Vue du mode obligatoire

parent::szGetContenuCentralHTML() charge vues/<mode>.html. Sans fichier vues/accueil_contact.html (même réduit à <div></div>), la page affiche seulement « Attention : la vue accueil_contact est introuvable dans le module contact ».

5. La configuration YAML​

# modules/contact/config/conf.yml
version: 1.0.0

aNamespace:
HTML:
ContactAdminHTML: APP\Modules\Contact\Controllers\ContactAdminHTML
data:
Contact: APP\Modules\Contact\Models\Contact
ContactAdminAction: APP\Modules\Contact\Controllers\ContactAdminAction

aVues:
contact:
admin:
simples:
accueil_contact:
ressources:
CSS: []
JS:
modules:
# Le parent (ContactAdmin) avant l'enfant (Contact).
- contact/JS/ContactAdmin.js
- contact/JS/Contact.js
formulaires: []
specifiques: []
# modules/contact/config/routing.yml
html_accueil_contact:
reecriture: admin/contact/accueil.html
route:
zone: admin
module: contact
controller: contact
mode: accueil_contact

json_recherche_contact:
reecriture: admin/contact/recherche.json
route:
zone: admin
module: contact
controller: contact
action: recherche

json_dynamisation_recherche_contact:
reecriture: admin/contact/dynamisation-recherche.json
route:
zone: admin
module: contact
controller: contact
action: dynamisation_recherche
# modules/contact/config/actions.yml
btn_dynamisation_recherche_contact:
action:
module: contact
route: json_dynamisation_recherche_contact
type: load_data
callback:
module: contact # classe JS Contact
fonction: vDynamisationFormulaireRecherche
alerte: non

Pour en faire la page d'accueil de l'administration, ajoutez dans config/conf.yml du projet :

page_accueil:
admin:
module: contact
controller: contact
mode: accueil_contact
nginx

Avec Apache, le .htaccess est régénéré à chaque bResetCache. Avec nginx (Docker du projet), reportez les routes à la main dans docker/nginx/default.conf :

rewrite ^/admin/contact/accueil\.html$ /index.php?szZone=admin&szModule=contact&szController=contact&szMode=accueil_contact last;
rewrite ^/admin/contact/recherche\.json$ /index.php?szZone=admin&szModule=contact&szController=contact&szAction=recherche last;
rewrite ^/admin/contact/dynamisation-recherche\.json$ /index.php?szZone=admin&szModule=contact&szController=contact&szAction=dynamisation_recherche last;

6. La vue liste​

<!-- modules/contact/vues/liste_contact.html -->
<div class="div_table">
<nav class="action-bar">
<div class="container-pagination"></div>
</nav>

<table class="material-table tableau_donnees liste_contact align_middle route_contact_json_recherche_contact callback_contact_vCallbackListeElement">
<thead>
<tr>
<th id="th_nom" class="tri">Nom</th>
<th id="th_prenom" class="tri">Prénom</th>
<th id="th_email" class="tri">Email</th>
<th id="th_ville" class="tri">Ville</th>
<th id="th_statut" class="tri">Statut</th>
<th id="th_date_creation" class="tri">Créé le</th>
</tr>
</thead>
<tbody>
<tr class="clone">
<td class="sNom"></td>
<td class="sPrenom"></td>
<td class="sEmail"></td>
<td class="sVille"></td>
<td class="sStatutLibelle"></td>
<td class="sDateCreationFormatee align-center"></td>
</tr>
<tr class="aucun_resultat cache">
<td colspan="6" class="align-center">Aucun contact ne correspond à la recherche.</td>
</tr>
</tbody>
</table>

<nav class="action-bar">
<div class="container-pagination"></div>
</nav>
</div>
Classe / attributRôle
div_tableconteneur reconnu pour la pagination (.sans_pagination la désactive, 10_par_page change la taille de page)
container-paginationrempli par oGetVue() (nb par page, nb trouvés, numéros de page)
tableau_donneeschargement automatique de la liste par Liste.js au démarrage
liste_contactidentifiant de la liste (liste_<nom>)
route_contact_json_recherche_contactroute_<module>_<alias de routing.yml> : URL appelée
callback_contact_vCallbackListeElementcallback_<classe JS>_<méthode> : appelée après chaque chargement
th id="th_<colonne>" class="tri"tri au clic, envoie szOrderBy=<colonne> ASC|DESC. <colonne> = colonne de la sous-requête matable, pas la propriété PHP. Ctrl/Shift + clic : tri multiple
tr.cloneligne modèle clonée pour chaque élément. Chaque <td class="sXxx"> reçoit .text() de la propriété sXxx (donc échappée)
tr.aucun_resultataffichée quand nNbElements vaut 0
Ne reconstruisez pas les lignes à la main

Vider le tbody dans une callback puis concaténer du HTML détruit tr.clone (le rechargement suivant échoue), contourne l'échappement et ouvre une faille XSS. Formatez plutôt les valeurs en SQL (date_creation_formatee, statut_libelle) et laissez le core remplir les cellules. Pour un traitement par ligne, utilisez la classe ligne_callback_<classe>_<methode> sur la table : la méthode reçoit (oValeur, oCloneLigne, aParamsLignes, oReponseJSON) et doit retourner la ligne.

7. La vue recherche​

<!-- modules/contact/vues/recherche_contact.html -->
<div class="recherche">
<form name="formulaire_recherche_contact" id="formulaire_recherche_contact" method="POST" action="#">
<div class="moteur_recherche">
<div class="form-field">
<label for="sMotCleContactRch">Nom, prénom ou email</label>
<input type="text" id="sMotCleContactRch" name="sMotClePartielRch" class="sMotCle champ bdd_field_varchar e2d-mask e2d-format-varchar" value="" />
</div>
<div class="form-field">
<label for="sVilleContactRch">Ville</label>
<input type="text" id="sVilleContactRch" name="sVillePartielRch" class="sVille champ bdd_field_varchar e2d-mask e2d-format-varchar" value="" />
</div>
<div class="form-field">
<label for="sStatutContactRch">Statut</label>
<select id="sStatutContactRch" name="sStatutRch" class="sStatut champ" data-valeur_defaut="">
<option class="clone" value="">Tous les statuts</option>
</select>
</div>
<div class="form-field">
<label for="dDateCreationDebutContactRch">Créé entre le</label>
<input type="text" id="dDateCreationDebutContactRch" name="dDateCreationDebutRch" class="dDateCreationDebut champ datepicker bdd_field_date e2d-mask e2d-format-date" value="" />
</div>
<div class="form-field">
<label for="dDateCreationFinContactRch">et le</label>
<input type="text" id="dDateCreationFinContactRch" name="dDateCreationFinRch" class="dDateCreationFin champ datepicker bdd_field_date e2d-mask e2d-format-date" value="" />
</div>
</div>
</form>
</div>

Conventions de nommage :

name du champCritère reçu par le modèleUsage
sStatutRchsStatutégalité
sVillePartielRchsVillePartielLIKE '%...%'
dDateCreationDebutRch / ...FinRchdDateCreationDebut / ...Finbornes d'une période
sMotClePartielRchsMotClePartielcritère « virtuel » sur plusieurs colonnes
  • Le suffixe Rch est obligatoire : ControllerAction ne transmet au modèle que les paramètres qui le portent.
  • Les suffixes Partiel, Debut / Fin sont des conventions : c'est votre sCritereRecherche() qui leur donne un sens. Le sCritereRecherche() par défaut de Bdd gère Partiel pour les propriétés s....
  • La classe CSS du champ porte le nom « nettoyé » (sVille pour sVillePartielRch). Elle est nécessaire aux recherches favorites et au remplissage des listes déroulantes (aSelects.sStatut → select.sStatut).
  • option.clone est le modèle cloné pour chaque option reçue. Il reste dans la liste : on s'en sert comme option « Tous ».

8. Les classes JavaScript​

Contact.js est le point d'entrée : le core l'instancie et appelle vInit() sur les pages du module contact. Il hérite de ContactAdmin, qui hérite de Recherche (→ Liste → Formulaire → Core).

// modules/contact/JS/Contact.js
function Contact()
{
// La classe hérite du fichier JS de la zone courante.
if (szZoneCourante == 'admin') {
ContactAdmin.apply(this, arguments);
}

var oThis = this;

this.vInit = function()
{
if (szZoneCourante == 'admin') {
var oParams = {
eFormulaire: $('#zone_navigation_2 form'),
eListe: $('#zone_navigation_3 .liste_contact'),
};

// Récupère les options des listes déroulantes de la recherche.
this.vExecuteAction('', 'contact', 'btn_dynamisation_recherche_contact', oParams);
}
};
};
// modules/contact/JS/ContactAdmin.js
function ContactAdmin()
{
Recherche.apply(this, arguments);
var oThis = this;

// Remplit les listes déroulantes (aSelects), initialise datepickers et masques.
this.vDynamisationFormulaireRecherche = function(oReponseJSON, oParams)
{
this.vChargeFormulaireData(oReponseJSON, oParams);
};

// Appelée après chaque chargement de la liste (classe callback_ de la table).
this.vCallbackListeElement = function(oReponseJSON, oParams)
{
this.vAfficheFilAriane('<h1>Contacts</h1>');

// Recharge la liste (page 1) à chaque modification d'un champ de recherche.
this.vChargeEvenementsChampsRecherche('', oParams);
};
};
  • La liste se charge seule grâce à tableau_donnees : inutile d'appeler vChargeListe() dans vInit().
  • Les écouteurs de recherche sont posés dans la callback de la liste, comme dans les ressources du core (planning, docusign). Le core les débranche avant de les reposer.
  • Le mode de déclenchement se règle dans la configuration : mode_recherche: listener (par défaut, rechargement à chaque change) ou bouton (bouton « Recherche » injecté).

Recherche filtrée sur la ville et le statut

9. Vérifier​

Le format de réponse se contrôle sans navigateur, une fois connecté :

curl -s -b cookies.txt -X POST \
-H "X-Requested-With: XMLHttpRequest" -H "Content-Type: application/json" \
-d '{"sMotClePartielRch":"lou","sStatutRch":"client"}' \
"http://localhost:8081/admin/contact/recherche.json?nPage=1&nNbElementsParPage=20&szOrderBy=ville%20DESC"
{
"aElements": [
{
"nIdElement": 15, "nIdContact": 15, "sNom": "Garcia", "sPrenom": "Louise",
"sEmail": "louise.garcia@exemple.fr", "sVille": "Toulouse", "sStatut": "client",
"sStatutLibelle": "Client", "dDateCreation": "2025-05-14 11:45:00",
"sDateCreationFormatee": "14/05/2025"
}
],
"aPagination": { "nPage": "1", "nStart": 0, "nNbElements": 3, "nNbPages": 1, "nMinRange": 1, "nMaxRange": 1 }
}

Pièges fréquents​

  1. Routes.js non généré. Il est produit lors d'un bResetCache. Si la structure de la base a changé (nouvelle table), la ressource Bdd intercepte le bResetCache et affiche « La base de données a été modifiée depuis le dernier bResetCache ». Il faut cliquer sur « J'ai pris en compte les changements dans le code », sinon Routes.js n'est pas écrit et la liste reste vide (erreur JS Unexpected token '<'). make reset-cache automatise cette étape.
  2. Pagination absente. La vue doit être chargée via oGetVue() et contenir .container-pagination dans .div_table.
  3. Tri sans effet ou erreur SQL. L'id du <th> doit être th_ + une colonne de la sous-requête matable, et cette colonne doit figurer dans A_CHAMPS_TRIABLES.
  4. Filtre ignoré. Le name ne finit pas par Rch, le champ est hors du <form> de #zone_navigation_2, ou la clé testée dans sCritereRecherche() ne correspond pas (sVillePartiel ≠ sVille).
  5. « présent plusieurs fois dans le DOM ». Un seul formulaire de recherche et une seule liste liste_<nom> par page.
  6. YAML modifié sans effet. Lancez un bResetCache après chaque modification de conf.yml, routing.yml ou actions.yml.

Sécurité​

  • Tri : szOrderBy vient du navigateur et ControllerAction le transmet tel quel. Le pare-feu du core bloque les motifs grossiers ((SELECT, ;), mais seule la liste blanche du modèle garantit une clause ORDER BY sûre.
  • Critères : échappez avec $this->rConnexion->quote() (PDO) plutôt que addslashes(), castez les entiers, validez les valeurs d'énumération (A_STATUTS) et les dates (DateTime::createFromFormat). Une valeur invalide est ignorée, sans erreur.
  • Affichage : le mapping par classes utilise .text(). N'injectez jamais de HTML construit à partir des données.

Résumé des conventions Easy2Do​

ÉlémentConvention
ModèleHérite de Bdd, surcharge sRequeteSelect() (avec nIdElement, COUNT(*) AS nNbElements, sous-requête matable) et sCritereRecherche()
Tri<th id="th_<colonne matable>" class="tri"> + liste blanche dans le modèle
Controller ActionHérite de ControllerAction ($sTable), qui fournit l'action recherche ; ajoute dynamisation_recherche (aSelects)
Controller HTMLListe → #zone_navigation_3, recherche → #zone_navigation_2, via oGetVue() ; vue vues/<mode>.html présente
Vue listediv_table > container-pagination + table.tableau_donnees.liste_<nom>.route_<module>_<alias>.callback_<Classe>_<méthode>, tr.clone, tr.aucun_resultat
Vue recherche<form> dans .recherche, name suffixés Rch (Partiel, Debut/Fin), classe = nom nettoyé
JS<Module> (vInit) → <Module>Admin → Recherche ; vChargeEvenementsChampsRecherche('', oParams) dans la callback de liste
NommagePréfixes de type (s, n, d, a, o, b, v) pour variables et méthodes, cf. règles de codage E2D
Après modif YAML?bResetCache=1 (et validation du jeton si la base a changé)