Aller au contenu principal

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.

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-formulaire/

Easy2Do : 2h Total : 2h

Fiche contact ouverte en modification

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é en formulaires avec classe: dans conf.yml : le core y injecte alors les règles de validation.
  • À l'ouverture, une action load_data récupère les données. vChargeFormulaireData() remplit chaque champ dont la classe CSS correspond à une clé de oElement, 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ête multipart séparée.

Prérequis​

ÉlémentVersion / Remarque
PHP8.4
E2D - Core2.18
E2D - Bdd3.0
E2D - Auth1.8
Package Plugins Light1.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
.gitignore

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: []
Bibliothèques

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.

Bouton Ajouter et icône de modification dans la liste

# 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 :

AttributRô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'))

Validation à l&#39;enregistrement d&#39;un formulaire vide

Récapitulatif des types de champs​

TypeHTMLRempli parReçu en PHP ($_POST)Enregistré
Texte, email, URLinput type="text|email|url"core (oElement)"..."tel quel
Téléphoneinput type="tel"core"..."tel quel
Dateinput type="text" class="datepicker e2d-mask e2d-format-date"core"10/12/1985"converti en Y-m-d
Heureinput type="text" class="e2d-mask e2d-format-time"core"09h30"converti en H:i:s
Entierinput type="number"core"12"tel quel ou NULL
Décimalinput type="text" (virgule)core"98500,75"virgule → point
Radioinput type="radio" × n, même namecore"Mme" (absent si rien coché)liste blanche
Liste déroulanteselect + option.clonecore (aSelects)"client"liste blanche
Cases multiplesinput type="checkbox" name="aCanaux[]"module (aMultiples)"aCanaux[]": "email" ou ["email","courrier"]liste blanche, SET
Sélection multipleselect multiple class="select2"core (options) + module (valeurs)"aCentresInteret[]": [...]liste blanche, SET
Interrupteurhidden 0 + checkbox 1, même namecore"0" ou ["0","1"]dernière valeur
Zone de textetextareacore"..."tel quel ou NULL
Texte richetextarea + TinyMCEcore + module (éditeur)"<p>...</p>"HTML nettoyé
Fichierinput type="file"module (aperçu)absent : requête séparée $_FILESfichier + 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" />
ClasseMasque
e2d-format-date00/00/0000
e2d-format-datetime00/00/0000 00:00:00
e2d-format-time00h00
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.

Select2 en Ajax

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 au mousedown du 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).

Bas du formulaire : interrupteur, zone de texte, texte riche

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);
});
}
});
};
Pourquoi une clé 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;
}
Surcharger aGetCriteres

Bdd::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.
Règles date et email du core

La 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​

RetourComportement du core
bSucces: truealerte: oui + mode_alerte: toast : toast avec szSucces, puis callback. alerte: non : callback immédiate.
bSucces: falsealerte d'erreur avec szErreur (HTML autorisé), la callback n'est pas appelée, le calque reste ouvert.
bSucces absentrien 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
}
aUploadDocumentAvecMd5File

L'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​

  1. Ajouter puis Enregistrer sans rien saisir : les champs obligatoires passent en rouge.
  2. Remplir tous les champs, choisir une photo, enregistrer : toast « Contact enregistré », la liste se recharge.
  3. Rouvrir le contact : tous les champs sont pré-remplis (cases, sélection multiple, interrupteur, texte riche, photo).
  4. Rouvrir Ajouter : le formulaire est vierge (auto_reset).
  5. 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​

  1. Aucune validation : le formulaire n'a pas de <input class="szRegles">, le mode n'est pas déclaré en formulaires avec classe:, ou le modèle ne surcharge pas aGetCriteres.
  2. Liste déroulante vide : il manque l'<option class="clone">, ou la clé de aSelects ne correspond pas à la classe du select. Un select déjà chargé (classe charge) n'est plus rechargé tant que le calque n'est pas réinitialisé.
  3. Champ non pré-rempli : sa classe ne correspond à aucune clé de oElement, c'est un type="date" / type="time", ou c'est une valeur multiple (à traiter dans le module).
  4. Interrupteur toujours à 0 : la valeur reçue est ["0", "1"] ; lire la dernière valeur.
  5. Valeurs multiples perdues : la clé PHP est aCanaux[] (avec crochets), chaîne ou tableau selon le nombre de valeurs.
  6. Texte riche non enregistré : tinymce.triggerSave() n'est pas appelé avant l'envoi.
  7. Modification enregistrée sur le mauvais élément : sans auto_reset, les classes variable_1_<id> s'accumulent sur le bouton d'enregistrement.
  8. Selects transformés en selectmenu : à chaque alerte, le core applique selectmenu() à tous les selects de la page ; ajoutez non_convertible aux selects natifs et Select2.
  9. Calque introuvable : l'identifiant du bloc doit commencer par calque_ ; le calque créé a pour id modal_<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 href en http(s) / mailto (strip_tags seul conserve onclick, 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=Lax ou Strict sur le cookie de session. Le champ szRegles est de l'obfuscation, pas une protection.

Résumé des conventions Easy2Do​

ÉlémentConvention
CalqueBloc calque_<nom> dans blocs.yml (auto_reset: oui), id modal_calque_<nom>
VueMode déclaré en formulaires + classe: <Modèle> ; <form id> + <input class="szRegles">
Champclass="<clé JSON> champ", name="<clé envoyée>", id unique ; .un_champ.form-field dans une colonne .column-N
Liste déroulanteoption.clone + aSelects.<classe> = [{valeur, libelle}] ; non_convertible
Valeurs multiplesname="aXxx[]", remplies par le module (aMultiples), lues dans $_POST['aXxx[]']
Booléenhidden 0 + checkbox 1, même name ; dernière valeur reçue
Dates, heures, nombreschamps texte + datepicker / e2d-mask e2d-format-* ; conversion à l'aller et au retour côté PHP
OuvertureAction load_data → new Modal() → vChargeFormulaireData(..., callback) → oOpenModal()
EnregistrementAction save_data + save_formulaire ; variable_1_<id> sur le bouton ; retour bSucces / szErreur / oElement.nIdElement
ValidationaGetCriteres dans le modèle ; règles spécifiques en JS ($.validator.addMethod) et en PHP (vAjouterMethodesValidationCustom)
FichiersRequête multipart séparée, après l'enregistrement