Comment créer un formulaire de création/édition dans Easy2Do ?
Ce cookbook montre comment construire un formulaire de création et d'édition dans Easy2Do : un calque ouvert depuis une liste, dynamisé à l'ouverture (vide en création, pré-rempli en modification), validé côté navigateur puis côté serveur, et enregistré avec des requêtes préparées.
Il passe en revue les principaux types de champs : pour chacun, comment l'écrire en HTML, comment le core le remplit, quel format arrive côté PHP et comment l'enregistrer. L'exemple prolonge le module contact du cookbook liste avec recherche.
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-formulaire/

TL;DR
- Le formulaire est une vue chargée dans un calque déclaré dans
blocs.yml. Le mode de la vue est déclaré enformulairesavecclasse:dansconf.yml: le core y injecte alors les règles de validation. - À l'ouverture, une action
load_datarécupère les données.vChargeFormulaireData()remplit chaque champ dont la classe CSS correspond à une clé deoElement, ainsi que les listes déroulantes (aSelects) et les radios. - Ce que le core ne sait pas remplir (valeurs multiples, Select2, TinyMCE, fichier) se complète dans la callback du module.
- L'enregistrement est une action
save_data+save_formulaire: validation jQuery Validate (szRegles), envoi en JSON, validation serveur avec les mêmes critères, colonnes construites par liste blanche,bInsert/bUpdate. - Les fichiers ne passent pas par
save_data: ils s'envoient dans une requêtemultipartséparée.
Prérequis
| Élément | Version / Remarque |
|---|---|
| PHP | 8.4 |
| E2D - Core | 2.18 |
| E2D - Bdd | 3.0 |
| E2D - Auth | 1.8 |
| Package Plugins Light | 1.0 (jQuery-Mask, Select2, TinyMCE, datepicker FR) |
package-plugins-light s'ajoute en sous-module :
git submodule add -b release-1.0 git@bitbucket.org:doingfr/package-plugins-light.git lib/externe/dist/package-plugins-light
Le .gitignore par défaut de Bitbucket ignore les dossiers dist/ : retirez cette ligne, sinon le sous-module ne peut pas être ajouté (ou ajoutez-le avec git submodule add -f).
Mise en place de l'environnement
git clone --recurse-submodules git@bitbucket.org:doingfr/cookbook-formulaire.git
cd cookbook-formulaire
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:8082/admin/login (doing / doing). Le bouton Ajouter de la liste ouvre le formulaire en création ; l'icône crayon d'une ligne l'ouvre en modification (la fiche de Camille Martin est entièrement renseignée).
Comment ça marche
Chargement de la page
└─ blocs.yml : calque_edition_contact → #modal_calque_edition_contact
└─ GET admin/contact/edition.html (mode edition_contact, « formulaires », classe Contact)
→ vue du formulaire + règles de validation chiffrées dans .szRegles
Clic « Ajouter » (variable_1_0) ou « Modifier » (variable_1_<id>)
└─ action btn_ouverture_edition_contact (load_data)
POST admin/contact/<id>/dynamisation-edition.json
← { oElement: {...}, aSelects: {...}, aMultiples: {...} }
→ ContactAdmin.vDynamisationEditionJSON()
new Modal(...) → vChargeFormulaireData() → champs avancés → oOpenModal()
Clic « Enregistrer »
└─ action btn_enregistre_edition_contact (save_data + save_formulaire)
1. jQuery Validate avec les règles de .szRegles (stop si invalide)
2. POST admin/contact/<id>/enregistre-edition.json (formulaire sérialisé en JSON)
3. ContactAdminAction::aEnregistreEdition() : validation serveur, liste blanche, bInsert/bUpdate
← { bSucces, szErreur, szSucces, oElement: { nIdElement } }
4. bSucces = true → toast + ContactAdmin.vApresEnregistrementEdition()
(envoi de la photo si besoin) → rechargement de la liste → fermeture du calque
bSucces = false → alerte avec szErreur, le calque reste ouvert
Structure du module
Par rapport au cookbook liste avec recherche, on ajoute :
modules/contact/
├── config/
│ ├── actions.yml ← + ouverture et enregistrement de l'édition
│ ├── blocs.yml ← calque d'édition
│ ├── conf.yml ← + mode edition_contact en « formulaires », bibliothèques JS
│ └── routing.yml ← + vue d'édition, dynamisation, enregistrement, photo
├── controllers/
│ └── ContactAdminAction.class.php ← dynamisation, enregistrement, photo, validation
├── JS/
│ └── ContactAdmin.js ← règles JS, calque, champs avancés, photo
├── models/
│ └── Contact.class.php ← listes de valeurs, contexte « edition », aGetCriteres
├── templates/admin-fullscreen/themes/defaut/css/contact.css
└── vues/
├── edition_contact.html ← le formulaire
└── liste_contact.html ← + boutons Ajouter / Modifier
1. La table
Chaque type de champ a sa colonne. Les colonnes facultatives acceptent NULL ; les valeurs multiples utilisent le type SET.
CREATE TABLE IF NOT EXISTS `contact` (
`id_contact` int(11) NOT NULL AUTO_INCREMENT,
`civilite` enum('M','Mme') DEFAULT NULL, -- radio
`nom` varchar(80) NOT NULL DEFAULT '',
`prenom` varchar(80) NOT NULL DEFAULT '',
`date_naissance` date DEFAULT NULL, -- date
`photo` varchar(255) DEFAULT NULL, -- fichier
`email` varchar(150) NOT NULL DEFAULT '',
`telephone` varchar(20) DEFAULT NULL,
`site_web` varchar(255) DEFAULT NULL,
`ville` varchar(80) NOT NULL DEFAULT '',
`statut` enum('prospect','client','inactif') NOT NULL DEFAULT 'prospect', -- select
`canaux` set('email','telephone','courrier') NOT NULL DEFAULT '', -- cases multiples
`centres_interet` set('web','mobile','ia','data','securite','design') NOT NULL DEFAULT '', -- Select2 multiple
`heure_rappel` time DEFAULT NULL, -- heure
`nb_salaries` int(11) DEFAULT NULL, -- entier
`chiffre_affaires` decimal(12,2) DEFAULT NULL, -- décimal
`newsletter` tinyint(1) NOT NULL DEFAULT 0, -- interrupteur
`note` text DEFAULT NULL, -- zone de texte
`presentation` text DEFAULT NULL, -- texte riche
`date_creation` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_contact`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. Le calque
Le bloc
Un calque est un bloc dont l'identifiant commence par calque_. Au chargement de la page, le core crée le conteneur #modal_calque_edition_contact et y charge la vue de la route.
# modules/contact/config/blocs.yml
calque_edition_contact:
route: html_edition_contact
module: contact
priorite: 1
ordre: 1
zones_affichage:
- admin
auto_reset: oui # le HTML d'origine est restauré à chaque fermeture
largeur: large # full | large | medium | small
callback: ''
auto_reset: oui (valeur par défaut) est essentiel : à la fermeture, le calque retrouve son HTML d'origine. Sans lui, les valeurs, les classes variable_1_<id> ajoutées au bouton et les champs cachés de la saisie précédente resteraient en place.
Les routes
# modules/contact/config/routing.yml
html_edition_contact:
reecriture: admin/contact/edition.html
route: { zone: admin, module: contact, controller: contact, mode: edition_contact }
json_dynamisation_edition_contact:
reecriture: admin/contact/([0-9]*)/dynamisation-edition.json
route: { zone: admin, module: contact, controller: contact, action: dynamisation_edition, variables: [nIdElement] }
json_enregistre_edition_contact:
reecriture: admin/contact/([0-9]*)/enregistre-edition.json
route: { zone: admin, module: contact, controller: contact, action: enregistre_edition, variables: [nIdElement] }
json_envoi_photo_contact:
reecriture: admin/contact/([0-9]*)/envoi-photo.json
route: { zone: admin, module: contact, controller: contact, action: envoi_photo, variables: [nIdElement] }
La vue en « formulaires »
Déclarer le mode du calque dans formulaires avec classe: demande au core de lire les critères de validation du modèle et de les injecter dans le champ .szRegles de chaque formulaire de la vue. Les bibliothèques se déclarent sur la page qui contient le calque.
# modules/contact/config/conf.yml
aVues:
contact:
admin:
simples:
accueil_contact:
ressources:
CSS:
lib_externe_dist:
- package-plugins-light/JS/Select2/css/select2.min.css
modules:
- contact/templates/admin-fullscreen/themes/defaut/css/contact.css
JS:
lib_externe_dist:
- package-plugins-light/JS/jQuery-Mask/jquery.mask.js
- package-plugins-light/JS/Select2/select2.min.js
- package-plugins-light/JS/TinyMCE/js/tinymce/tinymce.min.js
modules:
- contact/JS/ContactAdmin.js
- contact/JS/Contact.js
formulaires:
edition_contact:
classe: Contact # → Contact::aGetCriteres()
ressources:
CSS: []
JS: []
specifiques: []
Le core inclut toujours jQuery, jQuery UI (datepicker), jQuery Validate et le wrapper TinyMCE.js, mais pas jQuery-Mask, Select2 ni la bibliothèque TinyMCE elle-même : sans les déclarer, les masques de saisie sont ignorés et select2() / tinymce n'existent pas.
3. Les boutons et les actions
Dans la liste, le même bouton ouvre le calque en création ou en modification : seule la variable change.
<!-- modules/contact/vues/liste_contact.html -->
<nav class="action-bar">
<div class="container-pagination"></div>
<span class="btn btn-primary btn-picto btn_action action_contact_btn_ouverture_edition_contact variable_1_0">
<i class="material-icons">person_add</i> <span>Ajouter</span>
</span>
</nav>
...
<tr class="clone">
<!-- .btn_form_edition reçoit variable_1_<nIdElement> (Liste.vCallbackLigne) -->
<td class="action">
<span class="btn_form_edition btn_action action_contact_btn_ouverture_edition_contact">
<i class="material-icons">mode_edit</i>
</span>
</td>
...
</tr>
Pour chaque ligne, Liste.js ajoute automatiquement variable_1_<nIdElement> aux boutons .btn_form_edition : le JSON de la liste doit donc contenir nIdElement.

# modules/contact/config/actions.yml
btn_ouverture_edition_contact:
action:
module: contact
route: json_dynamisation_edition_contact
type: load_data
callback:
module: contact
fonction: vDynamisationEditionJSON
alerte: non
btn_enregistre_edition_contact:
action:
module: contact
route: json_enregistre_edition_contact
type: save_data
save_formulaire: formulaire_edition_contact # id du <form>
callback:
module: contact
fonction: vApresEnregistrementEdition
alerte: oui
mode_alerte: toast # message szSucces en toast, puis callback
4. La vue du formulaire
Le squelette d'un calque d'édition :
<!-- modules/contact/vues/edition_contact.html -->
<div class="un_bloc modal">
<span class="modal-title">
<h2 class="titre_edition_contact">Nouveau contact</h2>
<span class="close"><i class="material-icons">close</i></span>
</span>
<div class="contenu_scroll">
<div class="contenu_calque modal-content">
<form name="formulaire_edition_contact" id="formulaire_edition_contact" method="POST" action="#">
<!-- Règles de validation, injectées par le core (obligatoire pour valider) -->
<input type="hidden" name="szParams" class="szRegles" />
<div class="grid">
<div class="column-12">
<div class="section-block">
<span class="section-title">Identité</span>
<div class="section-content grid">
<div class="column-6 un_champ form-field">
<label for="sNomContact">Nom *</label>
<input type="text" id="sNomContact" name="sNom" class="sNom champ" maxlength="80" />
</div>
...
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<div class="boutons_form form-action-bar">
<a href="#" class="btn btn-primary btn-picto bouton btn_action action_contact_btn_enregistre_edition_contact">
<i class="material-icons">save</i><span>Enregistrer</span>
</a>
</div>
</div>
Trois attributs par champ, trois rôles différents :
| Attribut | Rôle |
|---|---|
class="sNom" | remplissage : vChargeFormulaireData cherche .sNom pour y mettre oElement.sNom |
class="champ" | validation : seuls les .champ reçoivent des règles |
name="sNom" | envoi : clé du JSON envoyé et des critères de validation (aGetCriteres('sNom')) |

Récapitulatif des types de champs
| Type | HTML | Rempli par | Reçu en PHP ($_POST) | Enregistré |
|---|---|---|---|---|
| Texte, email, URL | input type="text|email|url" | core (oElement) | "..." | tel quel |
| Téléphone | input type="tel" | core | "..." | tel quel |
| Date | input type="text" class="datepicker e2d-mask e2d-format-date" | core | "10/12/1985" | converti en Y-m-d |
| Heure | input type="text" class="e2d-mask e2d-format-time" | core | "09h30" | converti en H:i:s |
| Entier | input type="number" | core | "12" | tel quel ou NULL |
| Décimal | input type="text" (virgule) | core | "98500,75" | virgule → point |
| Radio | input type="radio" × n, même name | core | "Mme" (absent si rien coché) | liste blanche |
| Liste déroulante | select + option.clone | core (aSelects) | "client" | liste blanche |
| Cases multiples | input type="checkbox" name="aCanaux[]" | module (aMultiples) | "aCanaux[]": "email" ou ["email","courrier"] | liste blanche, SET |
| Sélection multiple | select multiple class="select2" | core (options) + module (valeurs) | "aCentresInteret[]": [...] | liste blanche, SET |
| Interrupteur | hidden 0 + checkbox 1, même name | core | "0" ou ["0","1"] | dernière valeur |
| Zone de texte | textarea | core | "..." | tel quel ou NULL |
| Texte riche | textarea + TinyMCE | core + module (éditeur) | "<p>...</p>" | HTML nettoyé |
| Fichier | input type="file" | module (aperçu) | absent : requête séparée $_FILES | fichier + nom en base |
Champs texte, email, téléphone, URL
<input type="text" id="sNomContact" name="sNom" class="sNom champ" maxlength="80" />
<input type="email" id="sEmailContact" name="sEmail" class="sEmail champ" maxlength="150" />
<input type="tel" id="sTelephoneContact" name="sTelephone" class="sTelephone champ" maxlength="20" />
<input type="url" id="sSiteWebContact" name="sSiteWeb" class="sSiteWeb champ" maxlength="255" placeholder="https://" />
Le thème du core ne stylise pas input[type="tel"] ni les select : le module reprend le style des champs texte dans son CSS.
Date et heure
N'utilisez pas type="date" ni type="time" : le core ne les remplit pas. On utilise un champ texte, le datepicker jQuery UI (initialisé par vChargeFormulaireData) et un masque de saisie (jQuery-Mask).
<input type="text" id="dDateNaissanceContact" name="dDateNaissance"
class="dDateNaissance champ datepicker e2d-mask e2d-format-date" placeholder="jj/mm/aaaa" autocomplete="off" />
<input type="text" id="sHeureRappelContact" name="sHeureRappel"
class="sHeureRappel champ e2d-mask e2d-format-time" placeholder="14h30" autocomplete="off" />
| Classe | Masque |
|---|---|
e2d-format-date | 00/00/0000 |
e2d-format-datetime | 00/00/0000 00:00:00 |
e2d-format-time | 00h00 |
e2d-format-int | ### |
e2d-format-float, e2d-format-decimal | ###0,00 |
Le serveur envoie la date déjà formatée (10/12/1985) et la reconvertit à l'enregistrement.
Nombres
<!-- Entier -->
<input type="number" id="nNbSalariesContact" name="nNbSalaries" class="nNbSalaries champ" min="0" step="1" />
<!-- Décimal : champ texte, saisie à virgule -->
<input type="text" id="fChiffreAffairesContact" name="fChiffreAffaires" class="fChiffreAffaires champ" inputmode="decimal" />
Le serveur envoie "1250000,50" et convertit la virgule en point à l'enregistrement.
Boutons radio
<div class="column-12">
<div class="un_champ form-field">
<label>Civilité *</label>
<input type="radio" id="sCiviliteMContact" name="sCivilite" value="M" class="sCivilite champ radio" />
<label for="sCiviliteMContact">Monsieur</label>
<input type="radio" id="sCiviliteMmeContact" name="sCivilite" value="Mme" class="sCivilite champ radio" />
<label for="sCiviliteMmeContact">Madame</label>
</div>
</div>
Le radio dont la value vaut oElement.sCivilite est coché. La classe radio suivie d'un <label> donne le style du thème. Le .un_champ est enveloppé dans sa colonne : en cas d'erreur, le core encadre .un_champ et insère le message juste après.
Liste déroulante
<select id="sStatutContact" name="sStatut" class="sStatut champ non_convertible">
<option class="clone" value=""></option>
</select>
Les options viennent du serveur (aSelects.sStatut = [{valeur, libelle}, ...]) : le core clone option.clone pour chacune et sélectionne celle qui vaut oElement.sStatut. Sans option.clone, aucune option n'est ajoutée. La classe non_convertible évite que le core transforme le select en selectmenu jQuery UI à l'ouverture d'une alerte.
Cases à cocher multiples
<div class="checkbox-row">
<input type="checkbox" id="aCanauxEmailContact" name="aCanaux[]" value="email" class="checkbox" />
<label for="aCanauxEmailContact">Email</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="aCanauxCourrierContact" name="aCanaux[]" value="courrier" class="checkbox" />
<label for="aCanauxCourrierContact">Courrier</label>
</div>
vChargeFormulaireData ne sait pas cocher plusieurs valeurs : le serveur les envoie dans une clé dédiée (aMultiples.aCanaux = ["email", "courrier"]) que le module traite (section 5). Côté PHP, la clé arrive avec ses crochets ($_POST['aCanaux[]']), sous forme de chaîne s'il n'y a qu'une case cochée, de tableau sinon, et absente si aucune ne l'est.
Sélection multiple (Select2)
<select id="aCentresInteretContact" name="aCentresInteret[]" class="aCentresInteret select2 non_convertible" multiple="multiple">
<option class="clone" value=""></option>
</select>
Les options passent par aSelects comme une liste simple ; Select2 est initialisé et les valeurs sélectionnées appliquées par le module.
La route générique json_load_select_research de la ressource Base construit sa requête SQL avec la table, les colonnes et le tri envoyés par le navigateur : ne l'utilisez pas. Pour une recherche distante, créez une route dédiée dans le module, avec ses propres critères (sCritereRecherche) et une liste blanche.
Interrupteur (booléen)
<div class="switch">
<input type="hidden" name="nNewsletter" value="0" />
<span class="switch-action-name">Non</span>
<input type="checkbox" id="nNewsletterContact" name="nNewsletter" value="1" class="nNewsletter champ" />
<label for="nNewsletterContact" class="nNewsletter"></label>
<span class="switch-action-name">Oui</span>
</div>
Une case non cochée n'est pas envoyée : le champ caché de même name garantit une valeur 0. Case cochée, le serveur reçoit ["0", "1"] : on garde la dernière valeur. La case est cochée à l'ouverture si oElement.nNewsletter vaut "1".
Zone de texte et texte riche
<textarea id="sNoteContact" name="sNote" class="sNote champ" rows="3" maxlength="1000"></textarea>
<textarea id="sPresentationContact" name="sPresentation" class="sPresentation champ editeur_riche_contact" rows="6"></textarea>
Le core remplit le textarea ; TinyMCE est initialisé ensuite par le module via le wrapper TinyMCE du core. Deux points :
- TinyMCE édite une copie : il faut appeler
tinymce.triggerSave()avant que le core sérialise le formulaire (le module le fait aumousedowndu bouton Enregistrer). - Le HTML reçu doit être nettoyé côté serveur (balises et attributs autorisés).
Fichier
<input type="file" id="rPhotoContact" name="rPhoto" class="rPhoto" accept="image/jpeg,image/png,image/webp" />
<img class="apercu_photo_contact cache" src="" alt="Photo du contact" />
Le core envoie les formulaires en JSON : les fichiers ne partent pas avec save_data. Le module envoie la photo dans une requête multipart/form-data séparée, après l'enregistrement, quand l'id du contact est connu (section 8).

5. La dynamisation
Côté PHP
ControllerAction gère déjà dynamisation_edition, mais sa méthode est privée et renvoie le modèle brut (avec ses propriétés techniques). On intercepte donc l'action dans le constructeur pour renvoyer un JSON préparé pour le formulaire : clés = classes des champs, valeurs au format de saisie.
// modules/contact/controllers/ContactAdminAction.class.php
public function __construct($szAction = '')
{
$nIdElement = (int) ($_REQUEST['nIdElement'] ?? 0);
switch ($szAction) {
case 'dynamisation_edition':
echo json_encode($this->aDynamisationEditionContact($nIdElement));
break;
case 'envoi_photo':
echo json_encode($this->aEnvoiPhoto($nIdElement));
break;
default:
// recherche, enregistre_edition (→ aEnregistreEdition), suppression...
parent::__construct($szAction);
break;
}
}
private function aDynamisationEditionContact($nIdElement = 0)
{
$aRetour = [
'oElement' => ['nIdElement' => 0, 'sStatut' => 'prospect', 'nNewsletter' => '0'], // valeurs par défaut
'aSelects' => [
'sStatut' => Contact::aGetOptions(Contact::A_STATUTS),
'aCentresInteret' => Contact::aGetOptions(Contact::A_CENTRES_INTERET),
],
'aMultiples' => ['aCanaux' => [], 'aCentresInteret' => []],
];
if ($nIdElement <= 0) {
return $aRetour; // création
}
$oContact = $this->oNew('Contact', [$nIdElement]);
if ((int) $oContact->nIdContact !== $nIdElement) {
return ['szErreur' => self::S_ERREUR_INTROUVABLE];
}
$aRetour['oElement'] = [
'nIdElement' => $nIdElement,
'sCivilite' => $oContact->sCivilite,
'sNom' => $oContact->sNom,
'dDateNaissance' => $this->sFormateDate($oContact->dDateNaissance, 'Y-m-d', 'd/m/Y'),
'sHeureRappel' => $this->sFormateDate($oContact->sHeureRappel, 'H:i:s', 'H\hi'),
'fChiffreAffaires' => $oContact->fChiffreAffaires === null ? '' : str_replace('.', ',', $oContact->fChiffreAffaires),
'nNewsletter' => (string) $oContact->nNewsletter,
'sPresentation' => $oContact->sPresentation,
'sPhotoUrl' => $oContact->sPhoto ? '/data/contact/' . $oContact->sPhoto : '',
// ... autres champs
];
// Valeurs multiples (colonnes SET) : traitées par le JS du module.
$aRetour['aMultiples'] = [
'aCanaux' => array_values(array_filter(explode(',', (string) $oContact->sCanaux))),
'aCentresInteret' => array_values(array_filter(explode(',', (string) $oContact->sCentresInteret))),
];
return $aRetour;
}
Le modèle charge la fiche complète grâce au contexte edition de sRequeteSelect() : la liste ne sélectionne que ses colonnes, la fiche ajoute les autres.
// modules/contact/models/Contact.class.php (extrait de sRequeteSelect)
if ($sContexte == 'edition') {
$sChamps .= ',
CON.civilite, CON.date_naissance, CON.photo, CON.telephone, CON.site_web,
CON.canaux, CON.centres_interet, CON.heure_rappel, CON.nb_salaries,
CON.chiffre_affaires, CON.newsletter, CON.note, CON.presentation
';
}
Côté JavaScript
// modules/contact/JS/ContactAdmin.js
var S_ID_CALQUE_EDITION = 'modal_calque_edition_contact';
this.vDynamisationEditionJSON = function(oReponseJSON, oParams)
{
if (typeof oReponseJSON.szErreur != 'undefined') {
this.vAlerteErreur(oReponseJSON.szErreur);
return;
}
var nIdElement = oReponseJSON.oElement.nIdElement || 0;
var oModal = new Modal(S_ID_CALQUE_EDITION, nIdElement, oReponseJSON);
var eFormulaire = oModal.eModal.find('form');
// L'id est transmis à la route d'enregistrement par la classe variable_1_<id>.
oModal.eModal.find('.action_contact_btn_enregistre_edition_contact').addClass('variable_1_' + nIdElement);
oModal.eModal.find('.titre_edition_contact').text(nIdElement > 0 ? 'Modifier le contact' : 'Nouveau contact');
// TinyMCE : recopie du contenu dans le textarea avant la sérialisation.
oModal.eModal.find('.action_contact_btn_enregistre_edition_contact').on('mousedown', function() {
if (typeof tinymce != 'undefined') { tinymce.triggerSave(); }
});
oParams.eFormulaire = eFormulaire; // formulaire ciblé (recommandé, indispensable en multicalques)
oParams.oModal = oModal;
this.vChargeFormulaireData(oReponseJSON, oParams, function(oReponseJSON, oParams) {
oThis.vInitialiseChampsAvances(oParams.eFormulaire, oParams.oModal.eModal, oReponseJSON);
oParams.oModal.oOpenModal();
aInstancesCalques[S_ID_CALQUE_EDITION] = oParams.oModal;
});
};
vChargeFormulaireData vide le formulaire, remplit les champs simples, les radios, les cases et les listes (aSelects), initialise datepickers et masques, puis appelle la callback. Le module y ajoute ce que le core ne gère pas :
this.vInitialiseChampsAvances = function(eFormulaire, eModal, oReponseJSON)
{
// Select2 : dropdownParent pour que la liste s'affiche au-dessus du calque.
eFormulaire.find('select.select2').select2({ width: '100%', placeholder: 'Choisir…', dropdownParent: eModal });
// Valeurs multiples (cases à cocher et sélections multiples).
this.vChargeValeursMultiples(eFormulaire, oReponseJSON.aMultiples || {});
// Texte riche : le calque est restauré à chaque fermeture (auto_reset),
// on supprime l'instance précédente avant d'en créer une nouvelle.
if (typeof tinymce != 'undefined') {
tinymce.remove('.editeur_riche_contact');
new TinyMCE({
aParamSelector: [{
szSelector: '.editeur_riche_contact',
nHeight: 200,
sToolbar: 'undo redo | bold italic underline | bullist numlist | link | removeformat',
sMenubar: '',
}],
});
}
// Aperçu de la photo existante, puis de celle choisie.
var ePhoto = eFormulaire.find('.apercu_photo_contact');
if (oReponseJSON.oElement.sPhotoUrl) {
ePhoto.attr('src', oReponseJSON.oElement.sPhotoUrl).removeClass('cache');
}
eFormulaire.find('.rPhoto').on('change', function() {
if (this.files.length > 0) {
ePhoto.attr('src', URL.createObjectURL(this.files[0])).removeClass('cache');
}
});
};
// aMultiples = { aCanaux: ['email', 'courrier'] } pour des champs nommés aCanaux[].
this.vChargeValeursMultiples = function(eFormulaire, aMultiples)
{
$.each(aMultiples, function(sNom, aValeurs) {
var eChamps = eFormulaire.find('[name="' + sNom + '[]"]');
if (eChamps.is('select')) {
eChamps.val(aValeurs).trigger('change');
} else {
eChamps.each(function() {
$(this).prop('checked', aValeurs.indexOf($(this).val()) !== -1);
});
}
});
};
aMultiples séparée ?Si les valeurs multiples étaient dans oElement, vChargeFormulaireData comparerait le tableau à la valeur de chaque case avec == : ['email'] == 'email' est vrai mais ['email','courrier'] == 'email' est faux. Le résultat dépendrait du nombre de valeurs.
6. La validation
Les critères, dans le modèle
Les critères sont déclarés une seule fois, dans le modèle, indexés par le name des champs. Le core les utilise côté navigateur (.szRegles), le contrôleur côté serveur (aValiderDonneesRequete).
// modules/contact/models/Contact.class.php
public function aGetCriteres($sNomChamp = '', $sType = 'tableau')
{
$aConfig = [
'sCivilite' => ['required' => 1],
'sNom' => ['required' => 1, 'maxlength' => 80],
'sPrenom' => ['required' => 1, 'maxlength' => 80],
'dDateNaissance' => ['dateFr' => 'true'],
'sEmail' => ['required' => 1, 'maxlength' => 150, 'email' => 'true'],
'sTelephone' => ['maxlength' => 20, 'telephone' => 'true'],
'sSiteWeb' => ['maxlength' => 255, 'url' => 'true'],
'sVille' => ['required' => 1, 'maxlength' => 80],
'sStatut' => ['required' => 1],
'sHeureRappel' => ['heure' => 'true'],
'nNbSalaries' => ['digits' => 'true', 'min' => 0, 'max' => 1000000],
'fChiffreAffaires' => ['decimalFr' => 'true'],
'sNote' => ['maxlength' => 1000],
];
$aCriteres = $aConfig[$sNomChamp] ?? [];
if ($sType == 'chaine') {
return $this->szGetCriteresValidation($aCriteres); // format jQuery Validate
}
return $aCriteres;
}
aGetCriteresBdd::aGetCriteres() ne lit aCritereValidation() (généré par le bResetCache) que si une classe RegleValidation existe, ce qui n'est pas le cas : sans surcharge, il ne renvoie aucun critère. Les valeurs non numériques s'écrivent 'true' ou entre DBL_QUOTE (DBL_QUOTE.'.sMotDePasse'.DBL_QUOTE).
Côté navigateur
Le core valide le formulaire au clic sur Enregistrer, uniquement s'il contient .szRegles. Les règles standards de jQuery Validate (required, maxlength, min, max, digits, url…) fonctionnent telles quelles ; les règles spécifiques se déclarent dans le JS du module :
// modules/contact/JS/ContactAdmin.js (en tête de fichier)
$.validator.addMethod('dateFr', function(sValeur, eChamp) {
if (this.optional(eChamp)) { return true; }
var aMorceaux = sValeur.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (aMorceaux === null) { return false; }
var dDate = new Date(aMorceaux[3], aMorceaux[2] - 1, aMorceaux[1]);
return dDate.getDate() == aMorceaux[1] && dDate.getMonth() == aMorceaux[2] - 1;
}, 'Date attendue au format jj/mm/aaaa.');
$.validator.addMethod('heure', function(sValeur, eChamp) {
return this.optional(eChamp) || /^([01]\d|2[0-3])h[0-5]\d$/.test(sValeur);
}, 'Heure attendue au format 14h30.');
// + telephone, decimalFr ; email est redéfini car la version du core
// refuse un email facultatif laissé vide.
date et email du coreLa règle date de FormValidator.js attend JJ-MM-AAAA (tirets) alors que le datepicker et le masque produisent JJ/MM/AAAA : utilisez une règle dédiée. Ses règles date et email refusent aussi la valeur vide, ce qui rend le champ obligatoire de fait.
Côté serveur
La validation navigateur se contourne : le contrôleur revalide avec les mêmes critères grâce à aValiderDonneesRequete(), et déclare les mêmes règles spécifiques :
// modules/contact/controllers/ContactAdminAction.class.php
protected function vAjouterMethodesValidationCustom()
{
$this->vAjouterUneMethodeValidation('dateFr', function ($sValeur) {
$oDate = \DateTime::createFromFormat('!d/m/Y', $sValeur);
return $oDate !== false && $oDate->format('d/m/Y') === $sValeur;
});
$this->vAjouterUneMethodeValidation('heure', function ($sValeur) {
return preg_match('/^([01]\d|2[0-3])h[0-5]\d$/', $sValeur) === 1;
});
$this->vAjouterUneMethodeValidation('url', function ($sValeur) {
return filter_var($sValeur, FILTER_VALIDATE_URL) !== false && preg_match('#^https?://#', $sValeur) === 1;
});
// + telephone, digits, decimalFr
}
aValiderDonneesRequete($sModel, $aChamps) renvoie ['bValide', 'aErreurs' => [champ => [['sCritere', 'mParams']]], 'aMessageLisible']. Une valeur vide ne passe que le critère required. Les messages lisibles du core affichent le nom technique (sNom) et comptent les longueurs en octets : le module construit ses propres messages à partir de aErreurs.
7. L'enregistrement
Ce que reçoit le serveur
Au clic, le core sérialise le formulaire en JSON (Content-Type: application/json) et le décode dans $_POST / $_REQUEST. Pour la fiche complète :
{
"sCivilite": "Mme", "sNom": "Lovelace", "sPrenom": "Ada",
"dDateNaissance": "10/12/1985", "sEmail": "ada.lovelace@exemple.fr",
"sTelephone": "02 40 12 34 56", "sSiteWeb": "https://ada.exemple.fr", "sVille": "Nantes",
"sStatut": "client", "sHeureRappel": "09h30",
"aCanaux[]": ["email", "courrier"], "aCentresInteret[]": ["ia", "data"],
"nNbSalaries": "12", "fChiffreAffaires": "98500,75", "nNewsletter": ["0", "1"],
"sNote": "…", "sPresentation": "<p>…</p>", "szParams": "…"
}
Le contrôleur
ControllerAction route l'action enregistre_edition vers aEnregistreEdition($nIdElement), à écrire dans le contrôleur du module :
protected function aEnregistreEdition($nIdElement = 0)
{
$nIdElement = (int) $nIdElement;
$aRetour = ['bSucces' => false, 'szErreur' => '', 'oElement' => ['nIdElement' => $nIdElement]];
// 1. Existence de l'élément en modification.
$oContact = $this->oNew('Contact', [$nIdElement]);
if ($nIdElement > 0 && (int) $oContact->nIdContact !== $nIdElement) {
$aRetour['szErreur'] = self::S_ERREUR_INTROUVABLE;
return $aRetour;
}
// 2. Validation avec les mêmes critères que le navigateur.
$aValidation = $this->aValiderDonneesRequete('Contact', self::A_CHAMPS_VALIDES);
$aErreurs = $this->aMessagesErreur($aValidation['aErreurs']);
if (isset(Contact::A_STATUTS[$this->sValeur('sStatut')]) === false) {
$aErreurs[] = 'Statut : valeur non autorisée.';
}
if (empty($aErreurs) === false) {
$aRetour['szErreur'] = implode('<br>', $aErreurs);
return $aRetour;
}
// 3. Colonnes construites à partir d'une liste blanche, valeurs converties.
$aChamps = [
'nom' => $this->sValeur('sNom'),
'date_naissance' => $this->sFormateDate($this->sValeur('dDateNaissance'), 'd/m/Y', 'Y-m-d'),
'canaux' => implode(',', $this->aValeursAutorisees('aCanaux', Contact::A_CANAUX)),
'heure_rappel' => $this->sFormateDate($this->sValeur('sHeureRappel'), 'H\hi', 'H:i:s'),
'chiffre_affaires' => str_replace([' ', ','], ['', '.'], $this->sValeur('fChiffreAffaires')),
'newsletter' => $this->sValeur('nNewsletter') === '1' ? 1 : 0,
'presentation' => $this->sNettoieHtml($this->sValeur('sPresentation')),
// ... autres colonnes
];
// Colonnes facultatives : une valeur vide est enregistrée à NULL.
$aChampsNull = [];
foreach (['civilite', 'date_naissance', 'telephone', 'site_web', 'heure_rappel', 'nb_salaries', 'chiffre_affaires', 'note', 'presentation'] as $sColonne) {
if ($aChamps[$sColonne] === '') {
$aChampsNull[] = $sColonne;
unset($aChamps[$sColonne]);
}
}
// 4. Requêtes préparées de Bdd.
if ($nIdElement > 0) {
$aRetour['bSucces'] = $oContact->bUpdate($aChamps, $aChampsNull);
} else {
$aRetour['bSucces'] = $oContact->bInsert($aChamps, $aChampsNull);
$aRetour['oElement']['nIdElement'] = (int) $oContact->nIdContact;
}
if ($aRetour['bSucces'] === false) {
$aRetour['szErreur'] = 'L\'enregistrement a échoué.';
$this->vLog('error', 'Contact : ' . $oContact->sMessagePDO);
} else {
$aRetour['szSucces'] = 'Contact enregistré.';
}
return $aRetour;
}
Les petits utilitaires qui lisent $_POST :
// Valeur simple. Deux champs de même name (interrupteur) arrivent en tableau : la dernière l'emporte.
private function sValeur($sNom)
{
$mValeur = $_POST[$sNom] ?? '';
if (is_array($mValeur) === true) {
$mValeur = end($mValeur);
}
return is_scalar($mValeur) === true ? trim((string) $mValeur) : '';
}
// Valeurs multiples (name="aXxx[]"), filtrées par une liste blanche.
private function aValeursAutorisees($sNom, array $aListe)
{
$mValeur = $_POST[$sNom . '[]'] ?? ($_POST[$sNom] ?? []);
return array_values(array_intersect((array) $mValeur, array_keys($aListe)));
}
bInsert($aChamps, $aChampsNull) et bUpdate($aChamps, $aChampsNull) utilisent des requêtes préparées pour les valeurs. Les noms de colonnes, eux, sont insérés tels quels : ne construisez jamais $aChamps à partir des clés reçues.
Le retour côté navigateur
| Retour | Comportement du core |
|---|---|
bSucces: true | alerte: oui + mode_alerte: toast : toast avec szSucces, puis callback. alerte: non : callback immédiate. |
bSucces: false | alerte d'erreur avec szErreur (HTML autorisé), la callback n'est pas appelée, le calque reste ouvert. |
bSucces absent | rien ne se passe : renvoyez toujours un booléen. |
this.vApresEnregistrementEdition = function(oReponseJSON, oParams)
{
var nIdElement = oReponseJSON.oElement.nIdElement;
var eFichier = $('#' + S_ID_CALQUE_EDITION + ' .rPhoto');
var vTermine = function() {
oThis.vChargeListe('', $('#zone_navigation_3 .liste_contact'));
vFermeCalque(S_ID_CALQUE_EDITION);
};
if (eFichier.length > 0 && eFichier[0].files.length > 0) {
this.vEnvoiePhoto(nIdElement, eFichier[0].files[0], vTermine);
} else {
vTermine();
}
};
8. La photo
L'envoi se fait après l'enregistrement : en création, c'est le seul moment où l'id du contact est connu.
this.vEnvoiePhoto = function(nIdElement, oFichier, oCallback)
{
var oDonnees = new FormData();
oDonnees.append('rPhoto', oFichier);
$.ajax({
url: this.szGetRoute(undefined, 'contact', 'json_envoi_photo_contact', [nIdElement]),
type: 'POST',
data: oDonnees,
processData: false,
contentType: false,
dataType: 'json',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
}).done(function(oReponse) {
if (oReponse.bSucces !== true) {
oThis.vAlerteErreur('Contact enregistré, mais la photo a été refusée : ' + oReponse.szErreur);
}
oCallback();
});
};
Côté serveur, le fichier est contrôlé sur son contenu réel et renommé :
private function aEnvoiPhoto($nIdElement = 0)
{
// ... existence du contact, $_FILES['rPhoto'], erreurs d'upload, taille (2 Mo)
// Type réel du fichier (et non son nom ni le type annoncé par le navigateur).
$sType = (new \finfo(FILEINFO_MIME_TYPE))->file($aFichier['tmp_name']);
if (isset(self::A_TYPES_PHOTO[$sType]) === false || getimagesize($aFichier['tmp_name']) === false) {
$aRetour['szErreur'] = 'Formats acceptés : JPG, PNG, WEBP.';
return $aRetour;
}
// Nom généré côté serveur : jamais le nom fourni par le client.
$sNomFichier = 'contact-' . $nIdElement . '-' . bin2hex(random_bytes(8)) . '.' . self::A_TYPES_PHOTO[$sType];
move_uploaded_file($aFichier['tmp_name'], $sDossier . $sNomFichier);
// ... mise à jour de la colonne photo, suppression de l'ancienne photo
}
aUploadDocumentAvecMd5FileL'utilitaire d'upload du core reprend l'extension du nom fourni par le client : un fichier photo.php serait enregistré tel quel dans un dossier servi par le serveur web. Contrôlez le type réel et générez l'extension vous-même. Le projet bloque en plus l'exécution de PHP dans /data/ (docker/nginx/default.conf).
Pensez aussi aux limites de taille : client_max_body_size (nginx, 1 Mo par défaut), upload_max_filesize et post_max_size (PHP, 2 Mo et 8 Mo par défaut).
9. Vérifier
- Ajouter puis Enregistrer sans rien saisir : les champs obligatoires passent en rouge.
- Remplir tous les champs, choisir une photo, enregistrer : toast « Contact enregistré », la liste se recharge.
- Rouvrir le contact : tous les champs sont pré-remplis (cases, sélection multiple, interrupteur, texte riche, photo).
- Rouvrir Ajouter : le formulaire est vierge (
auto_reset). - Contourner le navigateur :
curl -s -b cookies.txt -X POST \
-H "X-Requested-With: XMLHttpRequest" -H "Content-Type: application/json" \
-d '{"sCivilite":"Dr","sNom":"Test","sPrenom":"X","sEmail":"pas-un-email","sVille":"Nantes","sStatut":"vip"}' \
http://localhost:8082/admin/contact/0/enregistre-edition.json
{"bSucces":false,"szErreur":"Email : format invalide.<br>Civilité : valeur non autorisée.<br>Statut : valeur non autorisée.", ...}
Pièges fréquents
- Aucune validation : le formulaire n'a pas de
<input class="szRegles">, le mode n'est pas déclaré enformulairesavecclasse:, ou le modèle ne surcharge pasaGetCriteres. - Liste déroulante vide : il manque l'
<option class="clone">, ou la clé deaSelectsne correspond pas à la classe du select. Un select déjà chargé (classecharge) n'est plus rechargé tant que le calque n'est pas réinitialisé. - Champ non pré-rempli : sa classe ne correspond à aucune clé de
oElement, c'est untype="date"/type="time", ou c'est une valeur multiple (à traiter dans le module). - Interrupteur toujours à 0 : la valeur reçue est
["0", "1"]; lire la dernière valeur. - Valeurs multiples perdues : la clé PHP est
aCanaux[](avec crochets), chaîne ou tableau selon le nombre de valeurs. - Texte riche non enregistré :
tinymce.triggerSave()n'est pas appelé avant l'envoi. - Modification enregistrée sur le mauvais élément : sans
auto_reset, les classesvariable_1_<id>s'accumulent sur le bouton d'enregistrement. - Selects transformés en selectmenu : à chaque alerte, le core applique
selectmenu()à tous les selects de la page ; ajouteznon_convertibleaux selects natifs et Select2. - Calque introuvable : l'identifiant du bloc doit commencer par
calque_; le calque créé a pour idmodal_<id du bloc>.
Sécurité
- Validation serveur systématique, avec les mêmes critères que le navigateur (
aValiderDonneesRequete). - Listes blanches : colonnes écrites, valeurs d'énumération (
A_STATUTS,A_CIVILITES), valeurs multiples (array_intersect). - Requêtes préparées pour les valeurs (
bInsert,bUpdate) ; jamais de nom de colonne venant du navigateur. - Existence de l'élément vérifiée avant modification (et, dans un vrai projet, les droits de l'utilisateur sur cet élément).
- Texte riche nettoyé côté serveur : balises autorisées et suppression de tous les attributs sauf
hrefenhttp(s)/mailto(strip_tagsseul conserveonclick,onerror…). - Fichiers : type réel (
finfo), taille, nom généré, pas d'exécution dans le dossier de dépôt. - CSRF : le core ne fournit pas de jeton pour les formulaires Ajax. La protection repose sur l'en-tête JSON et le cookie de session ; configurez
SameSite=LaxouStrictsur le cookie de session. Le champszReglesest de l'obfuscation, pas une protection.
Résumé des conventions Easy2Do
| Élément | Convention |
|---|---|
| Calque | Bloc calque_<nom> dans blocs.yml (auto_reset: oui), id modal_calque_<nom> |
| Vue | Mode déclaré en formulaires + classe: <Modèle> ; <form id> + <input class="szRegles"> |
| Champ | class="<clé JSON> champ", name="<clé envoyée>", id unique ; .un_champ.form-field dans une colonne .column-N |
| Liste déroulante | option.clone + aSelects.<classe> = [{valeur, libelle}] ; non_convertible |
| Valeurs multiples | name="aXxx[]", remplies par le module (aMultiples), lues dans $_POST['aXxx[]'] |
| Booléen | hidden 0 + checkbox 1, même name ; dernière valeur reçue |
| Dates, heures, nombres | champs texte + datepicker / e2d-mask e2d-format-* ; conversion à l'aller et au retour côté PHP |
| Ouverture | Action load_data → new Modal() → vChargeFormulaireData(..., callback) → oOpenModal() |
| Enregistrement | Action save_data + save_formulaire ; variable_1_<id> sur le bouton ; retour bSucces / szErreur / oElement.nIdElement |
| Validation | aGetCriteres dans le modèle ; règles spécifiques en JS ($.validator.addMethod) et en PHP (vAjouterMethodesValidationCustom) |
| Fichiers | Requête multipart séparée, après l'enregistrement |