Ajoute les événements personnalisés (éditeur + exécution)

Deuxième moitié de la fonctionnalité "événements" (voir le commit
précédent pour le backend) : un nouvel onglet "📣 Événements" dans
screen_edit.html (scène ET modèle, même route) pour créer/modifier/
supprimer un événement (nom, description, "a un paramètre élément" oui/
non) et voir où il est déjà écouté/déclenché ; deux nouveaux types de
nœud dans le graphe de logique ; exécution réelle côté client
(play.html).

- templates/screen_edit.html :
  - Onglet Événements : barre de création + tableau (patron
    form/name="..." + attribut `form="eventEditFormN"` déjà utilisé par
    l'onglet Variables du tableau de bord, pour éditer plusieurs champs
    d'une ligne sans emboîter un <form> dans un <tr>). Colonne "Utilisé
    par" avec liens directs vers les écrans/modèles concernés
    (list_custom_event_usages, déjà en place côté backend).
  - Nœud Déclencheur "Sur un événement personnalisé" : un simple sélecteur
    d'événement (trigger_custom_event_id) — ni élément ni écran, retrouvé
    par un scan global côté client (comme "À l'affichage de l'écran").
  - Nœud Action "Déclencher un événement" : sélecteur d'événement
    (target_custom_event_id), avec élément/ligne concernés affichés
    seulement si l'événement a has_element_param (réutilise le sélecteur
    d'élément existant + "Ligne cliquée" pour la ligne, pas de définition
    d'objet ici donc pas de liste de lignes fixes possible).
  - Nœud Action "Modifier un élément" : nouvelle case "Utiliser l'élément
    transmis par l'événement en cours" (target_element_from_event) — v1,
    seule cette action l'expose (extensible plus tard sans nouveau
    changement de schéma).
  - nodeLabel()/submitNodeForm()/toggleFlowTriggerFields()/
    toggleFlowActionFields() étendus en conséquence, CUSTOM_EVENTS_MAP
    (id -> nom/has_element_param) exposé côté JS pour le rendu des
    libellés et l'affichage conditionnel des champs paramètre.

- templates/play.html :
  - window.dispatchGameEvent(eventId, elementId, rowId) : scan de
    gameData.flows (toutes scènes ET modèles à la fois, même principe que
    findTriggerNode()/runScreenShowTriggers()) pour trouver chaque
    écouteur, pose window.lastEventParams puis exécute son graphe
    (runFlowFrom) — au même titre que window.lastClickedRowId pour "Ligne
    cliquée".
  - runActionNode : nouvelle branche "declencher_evenement" (résout
    CLICKED_ROW_ID pour la ligne transmise, comme "Modifier une donnée")
    ; branche "modifier_element" étendue pour résoudre dynamiquement
    l'élément depuis window.lastEventParams quand
    target_element_from_event est actif.
  - readFieldValue (évaluation de condition) et l'appel serveur de
    "Modifier une donnée" (routes/flow/flow_node_run_data.py) résolvent
    désormais aussi EVENT_ROW_ID (-2), au même endroit que CLICKED_ROW_ID
    (-1) déjà en place.

- routes/screens/screen_edit.py : passe custom_events/
  custom_event_usages/custom_events_map_json au template (même patron
  que global_variables déjà threadé pour le sélecteur de variable dans
  le formulaire de Condition).

Nouveaux tests dans tests/test_custom_events.py : forme exacte du
payload runtime exposé au JS (types entiers, pas des chaînes — une
comparaison stricte "===" échouerait silencieusement sinon),
target_element_from_event bien persisté, résolution serveur d'
EVENT_ROW_ID depuis le corps de la requête. 210 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-29 19:34:24 +02:00
co-authored by Claude Sonnet 5
parent dcbec16818
commit f436190f90
5 changed files with 361 additions and 17 deletions
+54 -6
View File
@@ -788,10 +788,25 @@
bindHoverTexts();
bindHoverTriggers();
return Promise.resolve();
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
} else if (node.action_type === 'modifier_element' && (node.target_element_id || node.target_element_from_event)) {
// target_element_from_event (voir screens/custom_events/) : résout
// dynamiquement "l'élément transmis par l'événement en cours"
// (window.lastEventParams, posé par dispatchGameEvent() juste avant
// d'exécuter ce graphe) plutôt qu'une cible fixe choisie dans
// l'éditeur — contourne la contrainte de clé étrangère de
// target_element_id, qui empêche d'y stocker un sentinel comme
// EVENT_ROW_ID directement (voir screens/flow/ensure_flow_schema.py).
const resolvedElementId = node.target_element_from_event
? (window.lastEventParams ? window.lastEventParams.element_id : null)
: node.target_element_id;
const targetEl = resolvedElementId ? document.querySelector('[data-element-id="' + resolvedElementId + '"]') : null;
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
let dispatchRowId = node.target_row_id;
if (dispatchRowId === -1) dispatchRowId = window.lastClickedRowId; // CLICKED_ROW_ID
window.dispatchGameEvent(node.target_custom_event_id, node.target_element_id || null, dispatchRowId != null ? dispatchRowId : null);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
@@ -827,12 +842,16 @@
return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') {
const runDataUrl = "{{ url_for('flow_node_run_data', slug=game.slug, node_id=999999) }}".replace('999999', node.id);
// clicked_row_id : utilisé côté serveur SEULEMENT si ce nœud vise la
// ligne cliquée (CLICKED_ROW_ID, voir flow_node_run_data.py) — sans
// effet sinon, donc toujours transmis sans condition ici.
// clicked_row_id/event_row_id : utilisés côté serveur SEULEMENT si ce
// nœud vise respectivement CLICKED_ROW_ID ou EVENT_ROW_ID (voir
// flow_node_run_data.py) — sans effet sinon, donc toujours transmis
// sans condition ici.
return fetch(runDataUrl, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId })
body: JSON.stringify({
clicked_row_id: window.lastClickedRowId,
event_row_id: window.lastEventParams ? window.lastEventParams.row_id : null,
})
})
.then(r => r.json())
.then(() => refreshRuntimeData());
@@ -855,6 +874,12 @@
// résolue seulement au moment de l'évaluation via le dernier clic
// capturé (voir bindClicks() plus haut).
if (rowId === -1) rowId = window.lastClickedRowId;
// EVENT_ROW_ID (-2, voir screens/flow/constants.py) : "la ligne de
// Répéteur transmise par l'événement personnalisé en cours" — même
// principe que CLICKED_ROW_ID ci-dessus, résolue via
// window.lastEventParams (posé par dispatchGameEvent()) plutôt que le
// dernier clic capturé.
if (rowId === -2) rowId = window.lastEventParams ? window.lastEventParams.row_id : null;
const rows = gameData.data[String(definitionId)] || [];
const row = rows.find(r => r.id === rowId);
return row ? row[fieldName] : undefined;
@@ -1008,6 +1033,29 @@
step(nodeId);
}
// Déclenche un événement personnalisé (voir screens/custom_events/,
// action "declencher_evenement") : scan GLOBAL de gameData.flows, même
// principe que findTriggerNode()/runScreenShowTriggers() ci-dessus — un
// écouteur (nœud Déclencheur trigger_event="evenement") peut vivre sur
// N'IMPORTE QUELLE autre scène ou modèle, pas seulement l'écran affiché.
// elementId/rowId (potentiellement null si l'événement n'a pas de
// paramètre) sont posés sur window.lastEventParams AVANT de lancer
// chaque graphe trouvé, pour que les nœuds en aval (ex. "Modifier un
// élément" avec target_element_from_event, voir applyElementProperty
// plus haut/runActionNode plus bas) puissent le résoudre — au même
// titre que window.lastClickedRowId pour "Ligne cliquée (Répéteur)".
window.dispatchGameEvent = function(eventId, elementId, rowId) {
for (const screenIdKey in gameData.flows) {
const flow = gameData.flows[screenIdKey];
flow.nodes
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
.forEach(function(n) {
window.lastEventParams = { element_id: elementId, row_id: rowId };
runFlowFrom(n.id, screenIdKey);
});
}
};
// L'écran affiché au démarrage doit être choisi AVANT le premier
// bindClicks() : la recherche du nœud Déclencheur associé à un élément se
// fait sur l'écran courant (window.currentScreenId), qui doit donc déjà