Comment créer un module de base ?
Ce cookbook explique comment créer un module complet dans une application Easy2Do, en partant de zéro : structure des fichiers, modèle, controllers, vues HTML et classe JS. L'exemple s'appuie sur un module Articles (liste + édition + suppression).
Le code complet (structure de répertoires, configuration) est disponible dans le repo d'exemples Doing.
➡ Easy2Do : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/e2d-module-base/
TL;DR
- Créer la structure de répertoires d'un module Easy2Do.
- Déclarer le modèle, les controllers et les vues.
- Câbler la configuration YAML du module.
- Écrire la classe JS héritant de
Liste.
Prérequis
| Élément | Version / Remarque |
|---|---|
| PHP | 8.4 |
| E2D - Core | 2.18 |
| E2D - Bdd | 3.0 |
| E2D - Auth | 1.8 |
Mise en place de l'environnement
Le projet est conteneurisé avec Docker. Les services disponibles sont : PHP, Nginx, MariaDB, phpMyAdmin et Mailcatcher.
make up # démarre les conteneurs
make urls # affiche les URLs utiles
Les URLs par défaut :
| Service | URL |
|---|---|
| Application | http://localhost:8080 |
| phpMyAdmin | http://localhost:5050 |
| Mailcatcher | http://localhost:1080 |
Installation de la base de données
Easy2Do dispose d'un mécanisme natif pour exécuter les scripts installation.sql de chaque module via un paramètre d'URL. Il suffit d'appeler l'application avec les paramètres bModeInstallModule et szModulesPourInstall.
1. Ressource authentification (tables utilisateur et utilisateur_fingerprint) :
http://localhost:8080/?bModeInstallModule=1&szModulesPourInstall=authentification
Ce script crée les tables nécessaires au système d'authentification d'Easy2Do et insère un utilisateur par défaut :
| Identifiant | Mot de passe |
|---|---|
doing | doing |
2. Module articles (table article) :
http://localhost:8080/?bModeInstallModule=1&szModulesPourInstall=articles
Ce script crée la table article et insère quelques articles de démonstration.
Il est possible d'installer plusieurs modules simultanément en séparant leurs noms par un - :
http://localhost:8080/?bModeInstallModule=1&szModulesPourInstall=authentification-articles
Structure d'un module Easy2Do
Un module suit toujours la même organisation :
modules/
└── articles/
├── config/
│ └── conf.yml ← namespace, vues, assets
├── controllers/
│ ├── ArticleAdminAction.class.php
│ └── ArticleAdminHTML.class.php
├── installation/
│ └── installation.sql ← script de création de table
├── JS/
│ └── Articles.js ← classe JS héritant de Liste
├── models/
│ └── Article.class.php
└── vues/
├── liste.html
└── edition.html
1. La table SQL
Commençons par créer la table qui persistera les données du module.
-- modules/articles/installation/installation.sql
CREATE TABLE `article` (
`id_article` int(11) NOT NULL,
`titre` varchar(255) NOT NULL DEFAULT '',
`contenu` text NOT NULL,
`statut` enum('actif','inactif') NOT NULL DEFAULT 'actif',
`date_creation` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `article`
ADD PRIMARY KEY (`id_article`);
ALTER TABLE `article`
MODIFY `id_article` int(11) NOT NULL AUTO_INCREMENT;
2. Le modèle
Le modèle hérite de Bdd, qui fournit les méthodes CRUD standard (aGetElements, bInsert, bUpdate, bDelete). La correspondance colonnes SQL ↔ propriétés PHP se déclare dans aMappingChamps.
<?php
// modules/articles/models/Article.class.php
namespace APP\Modules\Articles\Models;
use APP\Modules\Base\Lib\Bdd as Bdd;
class Article extends Bdd
{
public $nIdArticle;
public $szTitre;
public $szContenu;
public $szStatut;
public $dDateCreation;
public function __construct($nIdElement = 0)
{
$this->sNomTable = 'article';
$this->sAliasTable = 'ART';
$this->sNomClePrimaire = 'id_article';
$this->aMappingChamps = [
'id_article' => 'nIdArticle',
'titre' => 'szTitre',
'contenu' => 'szContenu',
'statut' => 'szStatut',
'date_creation' => 'dDateCreation',
];
parent::__construct($nIdElement);
}
}
3. Le controller Action
ArticleAdminAction hérite de ControllerAction, qui câble automatiquement les actions CRUD standard : recherche, dynamisation_edition, enregistre_edition, suppression.
Il suffit de surcharger aEnregistreEdition() pour la logique métier de sauvegarde.
<?php
// modules/articles/controllers/ArticleAdminAction.class.php
namespace APP\Modules\Articles\Controllers;
use APP\Core\Lib\Interne\PHP\ControllerAction as ControllerAction;
class ArticleAdminAction extends ControllerAction
{
protected $sTable = 'article';
protected function aEnregistreEdition($nIdElement = 0)
{
$aRetour = ['bSucces' => false, 'szErreur' => '', 'nIdElement' => 0];
$aChamps = [
'titre' => isset($_REQUEST['szTitre']) ? trim($_REQUEST['szTitre']) : '',
'contenu' => isset($_REQUEST['szContenu']) ? trim($_REQUEST['szContenu']) : '',
'statut' => isset($_REQUEST['szStatut']) ? trim($_REQUEST['szStatut']) : 'actif',
];
if ($nIdElement > 0) {
$oArticle = $this->oNew('Article', [$nIdElement]);
$aRetour['bSucces'] = $oArticle->bUpdate($aChamps);
$aRetour['nIdElement'] = $nIdElement;
} else {
$oArticle = $this->oNew('Article');
$aRetour['bSucces'] = $oArticle->bInsert($aChamps);
if ($aRetour['bSucces'] === true) {
$aRetour['nIdElement'] = $oArticle->nIdArticle;
}
}
if ($aRetour['bSucces'] === false) {
$aRetour['szErreur'] = $oArticle->sMessagePDO ?? 'Erreur inconnue.';
}
return $aRetour;
}
}
4. Le controller HTML
ArticleAdminHTML hérite de AffichageHTML. La méthode szGetContenuCentralHTML() est surchargée pour :
- Charger les assets JS/CSS du module.
- Pré-remplir le formulaire côté PHP (via QueryPath) si un article existant est demandé.
<?php
// modules/articles/controllers/ArticleAdminHTML.class.php
namespace APP\Modules\Articles\Controllers;
use APP\Core\Lib\Interne\PHP\AffichageHTML as AffichageHTML;
class ArticleAdminHTML extends AffichageHTML
{
public function szGetContenuCentralHTML()
{
// Assets
$this->bSetScriptJavascript(
$this->szGetFichierPourInclusion('templates', 'admin-fullscreen/js/main.js', 'url')
);
$this->bSetScriptJavascript(
$this->szGetFichierPourInclusion('modules', 'articles/JS/Articles.js', 'url')
);
// Vue HTML (liste.html ou edition.html selon $this->szMode)
$szContenu = parent::szGetContenuCentralHTML();
$oQP = html5qp($szContenu);
switch ($this->szMode) {
case 'edition':
$nIdElement = isset($_REQUEST['nIdElement']) ? (int) $_REQUEST['nIdElement'] : 0;
if ($nIdElement > 0) {
$oArticle = $this->oNew('Article', [$nIdElement]);
$oQP->find('#nIdElement')->attr('value', $oArticle->nIdArticle);
$oQP->find('#szTitre')->attr('value', $oArticle->szTitre);
$oQP->find('#szContenu')->text($oArticle->szContenu);
$oQP->find('#szStatut option[value="' . $oArticle->szStatut . '"]')->attr('selected', 'selected');
$oQP->find('#titre-page')->text('Modifier : ' . $oArticle->szTitre);
}
break;
default:
$oArticle = $this->oNew('Article');
$nNbArticles = count($oArticle->aGetElements());
$oQP->find('#nb-articles')->text($nNbArticles);
break;
}
return $oQP->find('body')->innerHTML();
}
}
5. La configuration YAML du module
Le fichier conf.yml déclare les namespaces PHP des controllers et la liste des vues avec leurs assets associés.
# modules/articles/config/conf.yml
version: 1.0.0
aNamespace:
data:
Article: APP\Modules\Articles\Models\Article
ArticleAdminAction: APP\Modules\Articles\Controllers\ArticleAdminAction
HTML:
ArticleAdminHTML: APP\Modules\Articles\Controllers\ArticleAdminHTML
aVues:
article:
admin:
simples:
liste:
ressources:
CSS:
modules:
- articles/templates/admin-fullscreen/themes/defaut/css/articles.css
JS: []
edition:
ressources:
CSS:
modules:
- articles/templates/admin-fullscreen/themes/defaut/css/articles.css
JS: []
formulaires: []
specifiques: []
Après toute modification d'un fichier YAML, ajouter le paramètre ?bResetCache=1 à l'URL pour forcer la régénération du cache.
6. Les vues HTML
Vue liste
La vue liste s'appuie sur Liste.js. Les classes CSS sur le <table> indiquent au framework la route à appeler et le callback à déclencher après chargement.
<!-- modules/articles/vues/liste.html -->
<div class="page-header">
<h1>
Articles
<span id="nb-articles" class="badge badge-info">0</span>
</h1>
<div class="page-actions">
<a href="/admin/articles/0/edition" class="btn btn-primary">
<i class="material-icons">add</i>
Nouvel article
</a>
</div>
</div>
<div class="barre_recherche">
<div class="recherche_rapide">
<input type="text" id="szTitreRch" name="szTitreRch"
placeholder="Rechercher par titre..." class="form-control" />
</div>
</div>
<div class="div_table">
<table class="table liste_articles
route_articles_json_articles_recherche
callback_articles_vCallbackChargementListe"
id="liste_articles">
<thead>
<tr>
<th>#</th><th>Titre</th><th>Statut</th>
<th>Date de création</th><th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Ligne template clonée par Liste.js pour chaque résultat -->
<tr class="clone cache">
<td class="nIdArticle">0</td>
<td><a class="szTitre" href="#"></a></td>
<td><span class="szStatut"></span></td>
<td class="dDateCreation"></td>
<td>
<a class="btn btn-sm btn-primary btn_edition_article" href="#">
<i class="material-icons">edit</i>
</a>
<button class="btn btn-sm btn-danger btn_supprime_article" type="button">
<i class="material-icons">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div id="pagination_articles" class="pagination"></div>
Vue édition
<!-- modules/articles/vues/edition.html -->
<div class="page-header">
<h1 id="titre-page">Nouvel article</h1>
</div>
<form id="formulaire_edition_article" name="formulaire_edition_article"
action="#" method="post">
<input type="hidden" name="nIdElement" id="nIdElement" value="0" />
<div class="form-group">
<label for="szTitre">Titre <span class="obligatoire">*</span></label>
<input type="text" id="szTitre" name="szTitre"
class="form-control" placeholder="Titre de l'article" required />
</div>
<div class="form-group">
<label for="szContenu">Contenu</label>
<textarea id="szContenu" name="szContenu"
class="form-control" rows="12"></textarea>
</div>
<div class="form-group">
<label for="szStatut">Statut</label>
<select id="szStatut" name="szStatut" class="form-control">
<option value="actif">Actif</option>
<option value="inactif">Inactif</option>
</select>
</div>
<div class="form-actions">
<!--
btn_action : déclenché par Core.js au clic
action_articles_ : module ciblé
btn_enregistre_article : clé de l'action dans actions.yml
variable_1_0 : nIdElement (0 = création)
-->
<button type="submit" id="btn_enregistre_article"
class="btn btn-primary btn_action action_articles_btn_enregistre_article variable_1_0">
<i class="material-icons">save</i>
Enregistrer
</button>
<a href="/admin/articles/liste" class="btn btn-secondary">Annuler</a>
</div>
</form>
7. La classe JavaScript
La classe JS hérite de Liste (→ Formulaire → Core → Utiles). Le framework l'instancie automatiquement au chargement de la page via Routes.js et appelle vInit().
// modules/articles/JS/Articles.js
function Articles()
{
Liste.apply(this, arguments);
var oThis = this;
// ── Initialisation ─────────────────────────────────────────
this.vInit = function()
{
if ($('#liste_articles').length > 0) {
oThis.vChargeListe('articles');
}
if ($('#formulaire_edition_article').length > 0) {
var nIdElement = parseInt($('#nIdElement').val(), 10);
if (nIdElement > 0) {
oThis.vExecuteAction('', 'articles', 'btn_charge_edition_article', {
aVariables: [nIdElement]
});
}
}
oThis.vChargeEvenements();
};
// ── Événements ─────────────────────────────────────────────
this.vChargeEvenements = function()
{
$(document).on('click', '.btn_supprime_article', function(oEvent) {
oEvent.preventDefault();
var nIdArticle = $(this).data('id');
oThis.vAlerteConfirmation('Confirmer la suppression ?', function() {
oThis.vExecuteAction('', 'articles', 'btn_supprime_article', {
aVariables: [nIdArticle]
});
});
});
};
// ── Callbacks ──────────────────────────────────────────────
// Pré-remplissage du formulaire d'édition
this.vCallbackChargementEditionArticle = function(oReponseJSON)
{
if (typeof oReponseJSON.oElement === 'undefined') { return; }
var o = oReponseJSON.oElement;
$('#szTitre').val(o.szTitre || '');
$('#szContenu').val(o.szContenu || '');
$('#szStatut').val(o.szStatut || 'actif');
$('#nIdElement').val(o.nIdArticle || 0);
// Met à jour la variable de route sur le bouton save
var nId = o.nIdArticle || 0;
$('#btn_enregistre_article')
.removeClass(function(i, cls) {
return (cls.match(/(^|\s)variable_1_\S+/g) || []).join(' ');
})
.addClass('variable_1_' + nId);
$('#titre-page').text(o.szTitre ? 'Modifier : ' + o.szTitre : 'Nouvel article');
};
// Callback après enregistrement
this.vCallbackEnregistrementArticle = function(oReponseJSON)
{
if (oReponseJSON.bSucces === true) {
oThis.vAlerteSucces('Article enregistré.', function() {
window.location.href = '/admin/articles/liste';
});
} else {
oThis.vAlerteErreur(oReponseJSON.szErreur || 'Une erreur est survenue.');
}
};
// Callback après suppression
this.vCallbackSuppressionArticle = function(oReponseJSON)
{
if (oReponseJSON.bSucces === true) {
oThis.vAlerteSucces('Article supprimé.');
oThis.vChargeListe('articles');
} else {
oThis.vAlerteErreur(oReponseJSON.szErreur || 'Erreur lors de la suppression.');
}
};
}
8. Exposer des API REST depuis un module
Easy2Do embarque une couche RESTful (module restful) qui permet d'exposer les actions existantes d'un module via des endpoints HTTP standard. L'authentification s'appuie sur un JWT stocké en cookie.
8.1 Prérequis — activer le module restful
Le module restful est fourni sous forme de sous-module Git. Il doit être déclaré dans la configuration d'Easy2Do et sa clé JWT configurée.
# modules/restful/config/conf.yml
szCheminFichierCleJWT: /var/www/keys/JWTKey.txt
8.2 Décrire les ressources REST (services.yml)
Chaque ressource REST exposée par le module est décrite dans modules/articles/config/services.yml. Le fichier indique quels modules/controllers/actions sont ciblés pour chaque verbe HTTP.
# modules/articles/config/services.yml
# GET /api/Articles + POST /api/Articles
Articles:
aMethodes:
Read:
zone: site
module: articles
controller: article
action: liste
Create:
zone: admin
module: articles
controller: article
action: enregistre_edition
# PUT /api/Article/[nIdElement] + DELETE /api/Article/[nIdElement]
Article:
aVariables:
nIdElement:
szType: int
bRequis: true
aMethodes:
Update:
zone: admin
module: articles
controller: article
action: enregistre_edition
Delete:
zone: admin
module: articles
controller: article
action: suppression
# GET /api/Articles/ParStatut/[szStatut]
ParStatut:
szServiceBase: Articles
aVariables:
szStatut:
szType: array
bRequis: true
aOptions:
- actif
- inactif
- archive
aMethodes:
Read:
zone: admin
module: articles
controller: article
action: listeParStatut
8.3 Câbler les routes dans .htaccess
Chaque endpoint nécessite une règle de réécriture par verbe HTTP (générée automatiquement lors d'un bResetCache). Le flag [E=bApi:1] signale à Easy2Do que la requête est de type API (gestion JWT, format JSON).
# API articles
# GET /api/Articles → liste publique
RewriteCond %{REQUEST_METHOD} =GET
RewriteRule ^api/Articles$ index.php?szZone=site&szModule=articles&szController=article&szAction=liste&szService=Articles [E=bApi:1,QSA]
# POST /api/Articles → création (authentifié)
RewriteCond %{REQUEST_METHOD} =POST
RewriteRule ^api/Articles$ index.php?szZone=admin&szModule=articles&szController=article&szAction=enregistre_edition&szService=Articles [E=bApi:1,QSA]
# OPTIONS /api/Articles → CORS preflight
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteRule ^api/Articles$ index.php?szZone=admin&szModule=restful&szController=restful&szAction=handle_options&szService=Articles [E=bApi:1,QSA]
# PUT /api/Article/[id] → modification (authentifié)
RewriteCond %{REQUEST_METHOD} =PUT
RewriteRule ^api/Article(?>/)([0-9]+)$ index.php?szZone=admin&szModule=articles&szController=article&szAction=enregistre_edition&szService=Article&nIdElement=$1 [E=bApi:1,QSA]
# DELETE /api/Article/[id] → suppression (authentifié)
RewriteCond %{REQUEST_METHOD} =DELETE
RewriteRule ^api/Article(?>/)([0-9]+)$ index.php?szZone=admin&szModule=articles&szController=article&szAction=suppression&szService=Article&nIdElement=$1 [E=bApi:1,QSA]
# OPTIONS /api/Article/[id] → CORS preflight
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteRule ^api/Article(?>/)([0-9]+)$ index.php?szZone=admin&szModule=restful&szController=restful&szAction=handle_options&szService=Article&nIdElement=$1 [E=bApi:1,QSA]
# GET /api/Articles/ParStatut/[statut] → liste filtrée (authentifié)
RewriteCond %{REQUEST_METHOD} =GET
RewriteRule ^api/Articles/ParStatut(?>/)((?>actif|inactif|archive))$ index.php?szZone=admin&szModule=articles&szController=article&szAction=listeParStatut&szService=ParStatut&szStatut=$1 [E=bApi:1,QSA]
# OPTIONS /api/Articles/ParStatut/[statut] → CORS preflight
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteRule ^api/Articles/ParStatut(?>/)((?>actif|inactif|archive))$ index.php?szZone=admin&szModule=restful&szController=restful&szAction=handle_options&szService=ParStatut&szStatut=$1 [E=bApi:1,QSA]
# END API
8.4 Tableau récapitulatif des endpoints
| Verbe | Endpoint | Auth JWT | Action cible |
|---|---|---|---|
GET | /api/Articles | Non | ArticlePublicAction::aListe() |
POST | /api/Articles | Oui | ArticleAdminAction::aEnregistreEdition() |
PUT | /api/Article/{nIdElement} | Oui | ArticleAdminAction::aEnregistreEdition() |
DELETE | /api/Article/{nIdElement} | Oui | ControllerAction::suppression() |
GET | /api/Articles/ParStatut/{szStatut} | Oui | ArticleAdminAction::aListeParStatut() |
POST | /api/Connexion | Non | Auth → retourne le JWT |
POST /api/Connexion
Content-Type: application/json
{ "szLogin": "doing", "szPassword": "doing" }
Le token JWT est retourné dans la réponse et stocké en cookie automatiquement par Easy2Do. Pour les appels cross-origin, inclure Authorization: Bearer <token> dans les headers.
Résumé des conventions Easy2Do
| Élément | Convention |
|---|---|
| Modèle | Hérite de Bdd, déclare sNomTable, sAliasTable, sNomClePrimaire, aMappingChamps |
| Controller Action | Hérite de ControllerAction, déclare $sTable, surcharge aEnregistreEdition() |
| Controller HTML | Hérite de AffichageHTML, surcharge szGetContenuCentralHTML() pour les assets et le pré-remplissage |
| Classe JS | Hérite de Liste, implémente vInit(), vChargeEvenements() et les callbacks |
| Vues | Fichiers HTML dans vues/ — liste.html (szMode vide) et edition.html (szMode = edition) |
| Après modif YAML | Ajouter ?bResetCache=1 à l'URL pour régénérer le cache |