Aller au contenu principal

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

Code source complet

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/

Easy2Do : 1h Total : 1h

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émentVersion / Remarque
PHP8.4
E2D - Core2.18
E2D - Bdd3.0
E2D - Auth1.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 :

ServiceURL
Applicationhttp://localhost:8080
phpMyAdminhttp://localhost:5050
Mailcatcherhttp://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 :

IdentifiantMot de passe
doingdoing

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.

Plusieurs modules en une seule fois

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 :

  1. Charger les assets JS/CSS du module.
  2. 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: []
Réinitialiser le cache

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 (→ FormulaireCoreUtiles). 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

VerbeEndpointAuth JWTAction cible
GET/api/ArticlesNonArticlePublicAction::aListe()
POST/api/ArticlesOuiArticleAdminAction::aEnregistreEdition()
PUT/api/Article/{nIdElement}OuiArticleAdminAction::aEnregistreEdition()
DELETE/api/Article/{nIdElement}OuiControllerAction::suppression()
GET/api/Articles/ParStatut/{szStatut}OuiArticleAdminAction::aListeParStatut()
POST/api/ConnexionNonAuth → retourne le JWT
Obtenir un 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émentConvention
ModèleHérite de Bdd, déclare sNomTable, sAliasTable, sNomClePrimaire, aMappingChamps
Controller ActionHérite de ControllerAction, déclare $sTable, surcharge aEnregistreEdition()
Controller HTMLHérite de AffichageHTML, surcharge szGetContenuCentralHTML() pour les assets et le pré-remplissage
Classe JSHérite de Liste, implémente vInit(), vChargeEvenements() et les callbacks
VuesFichiers HTML dans vues/liste.html (szMode vide) et edition.html (szMode = edition)
Après modif YAMLAjouter ?bResetCache=1 à l'URL pour régénérer le cache