Skip to Content

Chapitre 7.3 — JavaScript Moodle en profondeur

Partie 7 : Frontend — Chapitre 3/6 Public : dev React/Next.js. On a les templates (7.2) ; on écrit maintenant le JavaScript qui les anime. Version de référence : Moodle 5.2 (modules ES6 dans amd/src/, transpilés en AMD par Grunt/Babel, exécutés via un loader RequireJS).

⏱️ TL;DR

  • Le JS de Moodle s’écrit en ES6 dans amd/src/, est transpilé (grunt amd) en AMD dans amd/build/, et chargé par un loader RequireJS. En prod on sert build/ minifié ; en dev, $CFG->cachejs = false.
  • Un module exporte typiquement une fonction init() appelée depuis le PHP ($PAGE->requires->js_call_amd('component/module', 'init', [args])) ou depuis un template ({{#js}}).
  • L’écosystème core/* fournit tout : core/ajax (appeler des web services), core/str (chaînes), core/templates (rendre du Mustache), core/notification (alertes, confirmations), core/modal + core/modal_form (modales, formulaires en modale), core/fragment (fragments HTML+JS générés côté PHP), core/toast (toasts), core_form/changechecker (alerte modifications non enregistrées), core/pubsub (événements).
  • On ne fait jamais de fetch brut vers une URL Moodle : on passe par core/ajax → une external function déclarée et sécurisée (Partie 9).
  • Délégation d’événements obligatoire : le DOM appartient au PHP et est souvent re-rendu ; on écoute sur un ancêtre stable avec un sélecteur, pas sur des nœuds éphémères.
  • Pièges : define caché (ne pas mélanger AMD et ES modules), jQuery résiduel (à éviter en 5.2), top-level await (interdit dans un module AMD), oublier de rebuild.

🎯 Objectifs

  • Écrire, builder et charger un module AMD ES6.
  • Appeler un web service proprement via core/ajax et re-render via core/templates.
  • Utiliser les modules core/* essentiels (modales, notifications, toasts, str).
  • Structurer le JS en délégation d’événements compatible avec le re-rendu serveur.
  • Éviter les pièges classiques (build, jQuery, top-level await).

1. Le pipeline JS : de amd/src/ au navigateur

Vous écrivez de l’ES6 moderne (imports, const, arrow functions, async/await) dans amd/src/. Un build le transforme en AMD (Asynchronous Module Definition, le format que le loader RequireJS de Moodle comprend), dans amd/build/, minifié.

public/mod/simplecheck/amd/ ├── src/ │ └── checklist.js ← VOUS écrivez ici (ES6) └── build/ ├── checklist.min.js ← généré par grunt (servi en prod) └── checklist.min.js.map

1.1 Le build

# Une fois, à la racine du dépôt Moodle nvm use # la version de Node imposée par package.json (.nvmrc fourni) npm install # installe grunt et l'outillage # Compiler le JS d'un plugin npx grunt amd --root=mod/simplecheck # Recompiler automatiquement à chaque sauvegarde (dev) npx grunt watch

En développement, réglez $CFG->cachejs = false; dans config.php : Moodle sert alors amd/src/ (via un transpileur à la volée), et vos modifications sont visibles sans rebuild ni purge. En production, cachejs est activé et Moodle sert amd/build/ minifié — d’où l’obligation de committer build/ dans une release (chapitre 6.7) ou de le générer en CI.

⚠️ Piège n°1 du débutant JS Moodle : « mon changement JS ne s’applique pas ». Causes, par ordre : (1) cachejs est true et vous n’avez pas rebuildé (grunt amd) ; (2) vous avez oublié de purger les caches ; (3) vous avez édité amd/build/ à la main (il sera écrasé — on n’édite jamais build/). En dev, mettez cachejs = false et le problème disparaît.

1.2 Anatomie d’un module

// mod/simplecheck/amd/src/checklist.js import Ajax from 'core/ajax'; import Notification from 'core/notification'; /** * Initialise une instance de checklist. * * @param {string} rootId l'id de l'élément racine (fourni par {{uniqid}}) */ export const init = (rootId) => { const root = document.getElementById(rootId); if (!root) { return; } registerEvents(root); }; const registerEvents = (root) => { // délégation : on écoute sur la racine stable, pas sur chaque checkbox. root.addEventListener('change', (e) => { if (e.target.matches('input[type="checkbox"]')) { saveCheck(root, e.target); } }); }; const saveCheck = async (root, checkbox) => { const itemId = parseInt(checkbox.name.match(/\[(\d+)\]/)[1], 10); try { await Ajax.call([{ methodname: 'mod_simplecheck_set_check', args: {itemid: itemId, checked: checkbox.checked ? 1 : 0}, }])[0]; } catch (error) { Notification.exception(error); checkbox.checked = !checkbox.checked; // rollback visuel en cas d'échec } };

Conventions structurantes :

  • export const init = (…) => {…} : le point d’entrée. Nommé init par convention ; c’est ce que le PHP/{{#js}} appelle.
  • Imports en tête : import X from 'core/x'. Le build résout ces imports en dépendances AMD.
  • Un module = un fichier = une responsabilité. Le nom du module est component/fichier : ici mod_simplecheck/checklist.
  • JSDoc sur les fonctions exportées : requis par le coding style et le lint (Partie 11).

2. Appeler le module depuis PHP ou un template

Deux façons de déclencher init() :

Depuis PHP (dans view.php, un renderer…) :

$PAGE->requires->js_call_amd('mod_simplecheck/checklist', 'init', ['simplecheck-' . $uniqueid]);

Depuis un template (rappel 7.2) :

<div id="simplecheck-{{uniqid}}"> … </div> {{#js}} require(['mod_simplecheck/checklist'], (Checklist) => Checklist.init("simplecheck-{{uniqid}}")); {{/js}}

Les deux font la même chose : charger le module (le loader le fetch si besoin) puis appeler init avec des arguments. Le passage par id d’élément (issu de {{uniqid}}) est le pont entre le HTML serveur et le JS — l’hydratation manuelle du chapitre précédent.

💡 Pour un dev React : js_call_amd(component/module, 'init', [args]) ≈ un ReactDOM.hydrateRoot(element, <Component {...args} />) explicite et local. Le serveur a rendu le HTML (via Mustache) ; vous « montez » le comportement sur un élément précis. Pas de root global, pas de reconciliation : un îlot, un init.


3. core/ajax : parler au serveur, jamais en fetch brut

Règle d’or : le JS n’appelle jamais une URL Moodle en fetch/XMLHttpRequest direct. Il appelle une external function (une fonction de web service, Partie 9) via core/ajax.

import Ajax from 'core/ajax'; // Ajax.call prend un TABLEAU de requêtes, renvoie un TABLEAU de promesses. const [progressPromise] = Ajax.call([{ methodname: 'mod_simplecheck_get_progress', args: {cmid: 42}, }]); const progress = await progressPromise; // { percentage: 60, ... }

Pourquoi ce détour :

  • Sécurité : l’external function déclare ses paramètres (PARAM_*), vérifie require_login, le contexte et les capabilities côté serveur. Un fetch brut contournerait tout ce cadre.
  • Sesskey et session : core/ajax gère automatiquement le jeton et l’endpoint (/lib/ajax/service.php).
  • Batching : plusieurs appels en un seul aller-retour réseau (le tableau).
  • Contrat typé : l’external function définit la forme des paramètres et du retour (chapitre 9), comme un endpoint typé.

⚠️ Piège : Ajax.call([...]) renvoie un tableau de promesses, pas une promesse unique. On récupère [0] (ou par déstructuration). Oublier l’indice est l’erreur classique (await Ajax.call([...]) renvoie le tableau, pas la donnée).

💡 Pour un dev React : core/ajax est votre client d’API typé (à la tRPC/React Query), où les « routes » sont les external functions. Vous ne construisez pas d’URL ni de méthode HTTP : vous nommez une fonction serveur et passez des args. Le batching évoque le request batching d’Apollo/tRPC.


4. core/templates : re-render sans recharger

Couplé à core/ajax, core/templates rend le même Mustache que le serveur (7.2) :

import Templates from 'core/templates'; const refresh = async (root, context) => { const {html, js} = await Templates.renderForPromise('mod_simplecheck/checklist', context); Templates.replaceNodeContents(root, html, js); // remplace le contenu ET exécute le JS du template };
  • renderForPromise(nom, context) renvoie {html, js} : le HTML rendu et le JS éventuel du template (le {{#js}}).
  • replaceNodeContents(node, html, js) injecte le HTML et exécute le JS associé — indispensable, sinon les {{#js}} du fragment re-rendu ne s’initialisent pas.
  • Autres helpers : Templates.appendNodeContents, Templates.prependNodeContents.

⚠️ Piège : n’injectez jamais du HTML rendu via element.innerHTML = html en ignorant le js. Vous perdriez l’initialisation des sous-composants ({{#js}}) et casseriez l’hydratation. Utilisez toujours Templates.replaceNodeContents/appendNodeContents.


5. Modales, notifications, toasts

5.1 core/notification — alertes et confirmations

import Notification from 'core/notification'; // Confirmation (remplace le confirm() natif, stylé et accessible). Notification.confirm( 'Supprimer', 'Confirmer la suppression de cette tâche ?', 'Supprimer', 'Annuler', () => doDelete() // callback si confirmé ); // Afficher une erreur d'exception (ex. échec d'un Ajax.call). try { /* … */ } catch (e) { Notification.exception(e); } // Alerte simple / toast d'info via addNotification. Notification.addNotification({message: 'Enregistré', type: 'success'});

5.2 core/toast — notifications éphémères

import {add as addToast} from 'core/toast'; addToast('Modifications enregistrées', {type: 'success'});

5.3 core/modal et core/modal_form — fenêtres modales

import Modal from 'core/modal'; const modal = await Modal.create({ title: 'Détails', body: Templates.render('mod_simplecheck/details', context), // une promesse de HTML show: true, });

Pour un formulaire dans une modale (le pattern le plus fréquent — ajout/édition sans quitter la page), core/modal_form charge un moodleform via core/fragment et gère sa soumission AJAX :

import ModalForm from 'core/modal_form'; const modalForm = new ModalForm({ formClass: 'mod_simplecheck\\form\\item_form', // la classe moodleform (FQCN échappé) args: {cmid: 42}, modalConfig: {title: 'Ajouter un item'}, }); modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, (e) => { // rafraîchir la liste après soumission réussie refreshList(); }); modalForm.show();

💡 Pour un dev React : core/modal_form est un composant de formulaire en dialog clé en main : il charge le formulaire (rendu serveur), le soumet en AJAX, gère la validation serveur et émet des events (FORM_SUBMITTED) auxquels vous réagissez. C’est votre <Dialog> + <Form> + mutation, mais fourni par le framework et rendu par le serveur (le formulaire est un moodleform PHP, pas du JSX). Vous ne réimplémentez ni la validation ni le markup.


6. core/fragment : du HTML+JS généré par PHP à la demande

Parfois vous voulez injecter un morceau d’interface calculé côté PHP (avec accès complet aux APIs serveur) dans une page déjà chargée. C’est core/fragment : il appelle une fonction de fragment PHP (component_output_fragment_<name>) qui renvoie du HTML et du JS.

import Fragment from 'core/fragment'; import Templates from 'core/templates'; const html = await Fragment.loadFragment( 'mod_simplecheck', // component 'itemdetails', // nom du fragment → fonction PHP mod_simplecheck_output_fragment_itemdetails contextId, {itemid: 7} );

Côté PHP, une fonction dédiée construit ce fragment avec toute la puissance du serveur (DB, capabilities, renderers). C’est le mécanisme qui alimente core/modal_form (le formulaire est un fragment).

⚠️ Piège : un fragment n’est pas une external function. C’est un rendu HTML côté serveur exposé au JS. La sécurité (contexte, capabilities) reste votre responsabilité dans la fonction PHP du fragment — un require_capability y est aussi obligatoire que dans une page.


7. Structurer le JS pour un DOM qui appartient au PHP

Le principe du chapitre 7.1 : le DOM appartient au serveur et peut être re-rendu (par core/templates, par un rechargement de fragment). Deux conséquences pratiques.

7.1 Délégation d’événements (obligatoire)

N’attachez pas d’écouteurs sur des nœuds éphémères (une checkbox qui sera remplacée au prochain re-render). Écoutez sur un ancêtre stable avec un sélecteur :

// ✅ délégation : survit au re-render du contenu interne root.addEventListener('click', (e) => { const btn = e.target.closest('[data-action="delete"]'); if (btn) { onDelete(btn.dataset.itemid); } }); // ❌ fragile : cet écouteur meurt si le bouton est re-rendu document.querySelector('[data-action="delete"]').addEventListener('click', onDelete);

7.2 Ancrage par data-*, pas par structure

Ciblez des data-* stables posés dans le template (data-region, data-action, data-itemid), pas des chemins DOM fragiles (div > ul > li:nth-child(2)). Le template peut changer sa structure ; les data-* sont un contrat entre le HTML serveur et le JS.

💡 Pour un dev React : c’est l’inverse de votre habitude où React possède le DOM. Ici, un re-render serveur peut remplacer vos nœuds sans prévenir le JS. La délégation d’événements + les data-* sont l’équivalent d’écouter au niveau d’un conteneur stable et d’identifier les cibles par attribut — un pattern « event delegation » que vous connaissiez avant React, et qui redevient nécessaire.


8. Les pièges classiques (récap)

⚠️ define caché / mélange AMD-ESM. Écrivez de l’ES6 (import/export) dans amd/src/ ; le build produit l’AMD. N’écrivez pas de define(...) à la main et ne mélangez pas les deux syntaxes dans un même fichier.

⚠️ jQuery résiduel. Du vieux code Moodle utilise import $ from 'jquery'. En 5.2, le core s’en éloigne (Bootstrap 5 est sans jQuery). N’introduisez pas de nouvelle dépendance jQuery : le DOM natif suffit (querySelector, closest, matches, dataset).

⚠️ Top-level await interdit. Un module AMD ne supporte pas l’await au niveau racine du module. Mettez vos await dans une fonction async (comme init ou un handler), jamais directement dans le corps du module.

⚠️ Oublier le rebuild. En prod (cachejs=true), un changement de amd/src/ sans grunt amd ne se voit pas. En dev, cachejs=false évite le problème.

⚠️ Ajax.call renvoie un tableau. const [p] = Ajax.call([...]) puis await p. Pas await Ajax.call([...]).


9. Carte de l’écosystème core/*

ModuleUsage typique
core/ajaxappeler une external function
core/templatesrendre/re-render un Mustache côté client
core/strcharger des chaînes de langue en JS
core/notificationalertes, confirm, afficher une exception
core/toastmessage éphémère de succès
core/modal, core/modal_formmodales, formulaire en modale
core/fragmentinjecter du HTML+JS calculé par PHP
core/pubsubpublier/s’abonner à des événements applicatifs
core_form/changecheckerprévenir « modifications non enregistrées »

📚 Aller plus loin : la référence des modules est moodledev.io/docs/5.2/guides/javascript . Lisez le JS de mod_forum/amd/src/ et de core_course/amd/src/ (l’éditeur de cours) : ce sont les exemples les plus riches et à jour du core.


✏️ Exercices

Exercice 1 — Mon JS ne s’applique pas. En production, vous modifiez amd/src/checklist.js mais rien ne change dans le navigateur. Trois causes possibles et la solution de dev ?

✅ Solution

Causes : (1) cachejs = true et pas de rebuild → Moodle sert l’ancien amd/build/ ; (2) caches non purgés ; (3) vous avez édité amd/build/ directement (écrasé au prochain build — on n’édite que src/). Solution : lancer grunt amd pour recompiler puis purger les caches ; en développement, régler $CFG->cachejs = false; pour servir src/ directement et voir les changements sans rebuild.

Exercice 2 — fetch interdit. Un dev appelle directement fetch('/mod/simplecheck/save.php?item=7') depuis son module. Pourquoi est-ce à proscrire, et quelle est la bonne approche ?

✅ Solution

Un fetch brut vers une URL Moodle contourne le cadre de sécurité : pas de validation typée des paramètres, pas de gestion du sesskey, pas de contrat de retour, et il faudrait écrire une page ad hoc côté serveur. Bonne approche : déclarer une external function (mod_simplecheck_set_check, Partie 9) qui valide require_login, le contexte, la capability et les paramètres (PARAM_*), puis l’appeler via core/ajax : Ajax.call([{methodname, args}])[0]. Le web service est le seul pont JS→serveur autorisé.

Exercice 3 — Écouteur qui meurt. Après un rafraîchissement AJAX de la liste, les boutons « supprimer » ne réagissent plus. Le code fait document.querySelectorAll('[data-action=delete]').forEach(b => b.addEventListener('click', onDelete)) à l’init. Pourquoi, et comment corriger ?

✅ Solution

Le re-render (Templates.replaceNodeContents) remplace les boutons par de nouveaux nœuds ; les écouteurs attachés aux anciens nœuds disparaissent avec eux. Correctif : délégation d’événements sur un ancêtre stable (la racine, qui n’est pas remplacée) : root.addEventListener('click', e => { const b = e.target.closest('[data-action=delete]'); if (b) onDelete(b.dataset.itemid); }). L’écouteur unique survit à tous les re-renders du contenu interne.

Exercice 4 — Formulaire en modale. Vous voulez ajouter un item via une modale sans recharger la page, avec validation serveur. Quel module utilisez-vous et quel est le flux ?

✅ Solution

core/modal_form : on l’instancie avec formClass (le FQCN du moodleform PHP, ex. mod_simplecheck\\form\\item_form) et des args (ex. cmid). Le module charge le formulaire (rendu serveur via core/fragment), l’affiche en modale, le soumet en AJAX en appliquant la validation serveur du moodleform, et émet FORM_SUBMITTED en cas de succès. On écoute cet event pour rafraîchir la liste. Aucun markup ni validation à réécrire côté JS : le formulaire reste un moodleform PHP.

Exercice 5 — Top-level await. Ce module lève une erreur au chargement : const config = await Ajax.call([...])[0]; écrit directement dans le corps du fichier. Pourquoi, et comment corriger ?

✅ Solution

Le top-level await n’est pas supporté dans un module AMD (le format cible du build) : l’await ne peut vivre que dans une fonction async. Correctif : déplacer l’appel dans init (ou une fonction async appelée par init) : export const init = async () => { const config = await Ajax.call([...])[0]; … };. Le corps du module ne doit contenir que des déclarations synchrones (imports, fonctions).


🧠 Quiz de révision

Q1. Décrivez le pipeline d’un module JS Moodle, de l’écriture au navigateur.

Réponse

On écrit de l’ES6 dans amd/src/ ; grunt amd (Babel) le transpile en AMD minifié dans amd/build/ ; le loader RequireJS de la page le charge et l’exécute. En prod on sert build/ (nécessite un rebuild à chaque changement) ; en dev, cachejs=false sert src/ directement.

Q2. Comment déclenche-t-on la fonction init d’un module, et d’où vient l’id qu’on lui passe ?

Réponse

Depuis PHP via $PAGE->requires->js_call_amd('component/module', 'init', [args]), ou depuis un template via {{#js}} require([...], m => m.init("...")) {{/js}}. L’id passé provient généralement de {{uniqid}} dans le template, servant de point d’ancrage entre le HTML serveur et le JS (hydratation d’îlot).

Q3. Pourquoi passer par core/ajax plutôt qu’un fetch direct ?

Réponse

Parce que core/ajax appelle une external function qui valide côté serveur (login, contexte, capabilities, types PARAM_*), gère le sesskey et l’endpoint, permet le batching, et fournit un contrat typé. Un fetch brut contournerait tout ce cadre de sécurité. Ajax.call([...]) renvoie un tableau de promesses (prendre [0]).

Q4. Pourquoi la délégation d’événements est-elle nécessaire dans Moodle ?

Réponse

Parce que le DOM appartient au serveur et peut être re-rendu (par core/templates/fragments), remplaçant les nœuds et leurs écouteurs. En écoutant sur un ancêtre stable avec un sélecteur (et en ciblant par data-*), le comportement survit aux re-renders.

Q5. Citez deux pièges spécifiques au JS Moodle et leur solution.

Réponse

(1) Oubli du rebuild en prod (cachejs=true) → lancer grunt amd ; en dev, cachejs=false. (2) Top-level await interdit en AMD → mettre l’await dans une fonction async. Autres : ne pas introduire de jQuery neuf (DOM natif), ne pas oublier que Ajax.call renvoie un tableau, ne pas éditer amd/build/.

Chapitre suivant : 04 — React dans Moodle, le tutoriel de bout en bout — les deux stratégies (React natif du core 5.2 via {{#react}} vs React embarqué dans un plugin via esbuild → AMD), la toolchain complète, le passage de données PHP → React, le tutoriel local_reactdemo (React 19 + TypeScript + hooks + web services), et le verdict honnête « quand React est-il justifié ? ».