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).
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/

TL;DR
- La liste est une
<table class="tableau_donnees ...">: le core la charge seul, clone la lignetr.clonepour 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 lenamefinit parRchdevient un critère côté serveur. - Côté PHP,
ControllerActionfournit l'actionrecherche(pagination incluse). Le modèle surchargesRequeteSelect()etsCritereRecherche(). - 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ément | Version / Remarque |
|---|---|
| PHP | 8.4 |
| E2D - Core | 2.18 |
| E2D - Bdd | 3.0 |
| E2D - Auth | 1.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.
make install ?- 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. - Il lance un
bResetCache(voir le piège n°1 plus bas). - Il pose un mot de passe bcrypt pour
doing. Le hash fourni par la ressource authentification 1.8 n'est pas reconnu parpassword_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 paraGetElements()(liste) et parnGetNbElements()(comptage,$bModeCount = true).sCritereRecherche()traduit les critères reçus en clauseWHERE.
szGetSelect() n'est plus appeléeLes 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ément | Pourquoi |
|---|---|
... AS nIdElement | identifiant attendu par la liste (cases à cocher, boutons de ligne) |
COUNT(*) AS nNbElements en mode comptage | lu par nGetNbElements() pour la pagination |
Sous-requête SELECT * FROM (...) matable | le tri porte sur les noms de colonnes de la sous-requête (ville, date_creation) |
Pas de LIMIT | ajouté par aGetElements() |
aMappingChamps | renomme 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;
}
}
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;
}
}
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
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 / attribut | Rôle |
|---|---|
div_table | conteneur reconnu pour la pagination (.sans_pagination la désactive, 10_par_page change la taille de page) |
container-pagination | rempli par oGetVue() (nb par page, nb trouvés, numéros de page) |
tableau_donnees | chargement automatique de la liste par Liste.js au démarrage |
liste_contact | identifiant de la liste (liste_<nom>) |
route_contact_json_recherche_contact | route_<module>_<alias de routing.yml> : URL appelée |
callback_contact_vCallbackListeElement | callback_<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.clone | ligne 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_resultat | affichée quand nNbElements vaut 0 |
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 champ | Critère reçu par le modèle | Usage |
|---|---|---|
sStatutRch | sStatut | égalité |
sVillePartielRch | sVillePartiel | LIKE '%...%' |
dDateCreationDebutRch / ...FinRch | dDateCreationDebut / ...Fin | bornes d'une période |
sMotClePartielRch | sMotClePartiel | critère « virtuel » sur plusieurs colonnes |
- Le suffixe
Rchest obligatoire :ControllerActionne transmet au modèle que les paramètres qui le portent. - Les suffixes
Partiel,Debut/Finsont des conventions : c'est votresCritereRecherche()qui leur donne un sens. LesCritereRecherche()par défaut deBddgèrePartielpour les propriétéss.... - La classe CSS du champ porte le nom « nettoyé » (
sVillepoursVillePartielRch). Elle est nécessaire aux recherches favorites et au remplissage des listes déroulantes (aSelects.sStatut→select.sStatut). option.cloneest 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'appelervChargeListe()dansvInit(). - 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 à chaquechange) oubouton(bouton « Recherche » injecté).

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
Routes.jsnon généré. Il est produit lors d'unbResetCache. Si la structure de la base a changé (nouvelle table), la ressource Bdd intercepte lebResetCacheet 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 », sinonRoutes.jsn'est pas écrit et la liste reste vide (erreur JSUnexpected token '<').make reset-cacheautomatise cette étape.- Pagination absente. La vue doit être chargée via
oGetVue()et contenir.container-paginationdans.div_table. - Tri sans effet ou erreur SQL. L'
iddu<th>doit êtreth_+ une colonne de la sous-requêtematable, et cette colonne doit figurer dansA_CHAMPS_TRIABLES. - Filtre ignoré. Le
namene finit pas parRch, le champ est hors du<form>de#zone_navigation_2, ou la clé testée danssCritereRecherche()ne correspond pas (sVillePartiel≠sVille). - « présent plusieurs fois dans le DOM ». Un seul formulaire de recherche et une seule liste
liste_<nom>par page. - YAML modifié sans effet. Lancez un
bResetCacheaprès chaque modification deconf.yml,routing.ymlouactions.yml.
Sécurité
- Tri :
szOrderByvient du navigateur etControllerActionle transmet tel quel. Le pare-feu du core bloque les motifs grossiers ((SELECT,;), mais seule la liste blanche du modèle garantit une clauseORDER BYsûre. - Critères : échappez avec
$this->rConnexion->quote()(PDO) plutôt queaddslashes(), 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ément | Convention |
|---|---|
| Modèle | Hé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 Action | Hérite de ControllerAction ($sTable), qui fournit l'action recherche ; ajoute dynamisation_recherche (aSelects) |
| Controller HTML | Liste → #zone_navigation_3, recherche → #zone_navigation_2, via oGetVue() ; vue vues/<mode>.html présente |
| Vue liste | div_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 |
| Nommage | Pré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é) |