From b094342097ae016445413dd70f36525ba97f6019 Mon Sep 17 00:00:00 2001 From: william Date: Tue, 25 Aug 2026 17:19:41 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20"Ligne=20cliqu=C3=A9e=20(R=C3=A9p?= =?UTF-8?q?=C3=A9teur)"=20comme=20cible=20pour=20une=20Condition/action=20?= =?UTF-8?q?de=20la=20Logique=20de=20la=20sc=C3=A8ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème remonté : un déclencheur "Au clic" posé sur un Répéteur exécute le MÊME graphe pour n'importe quelle ligne cliquée - or une Condition ("Si is_opened est égal à Non") ou une action "Modifier une donnée" ne pouvaient viser qu'une ligne FIXE, choisie à la création du nœud dans l'éditeur. Impossible donc de dire "modifie le champ DE LA LIGNE QUE JE VIENS DE CLIQUER", puisque cette ligne n'est justement jamais connue à l'avance. Nouvelle valeur sentinelle CLICKED_ROW_ID (-1, screens/flow/constants.py, ne collisionne jamais avec un vrai id de ligne) proposée en tête de TOUTE liste déroulante "Ligne concernée" (clause principale et clauses supplémentaires d'un nœud Condition, cible d'une action "Modifier une donnée") : "🖱️ Ligne cliquée (Répéteur)". Résolution au moment de l'exécution, pas à la création du nœud : - Condition (évaluée côté client) : readFieldValue() (play.html) résout -1 en window.lastClickedRowId, déjà capturé par bindClicks() au clic sur une ligne de Répéteur (déjà utilisé par "Ouvrir la ligne cliquée"). - Action "Modifier une donnée" (exécutée côté serveur) : le client envoie clicked_row_id dans le corps de la requête POST ; flow_node_run_data.py ne s'en sert que si le nœud vise justement CLICKED_ROW_ID, sinon la ligne fixe stockée sur le nœud reste utilisée normalement. Ajoute tests/test_flow_clicked_row.py (ligne cliquée seule modifiée, absence de clic = no-op plutôt que plantage, non-régression d'une cible fixe, présence de l'option dans l'éditeur). Co-Authored-By: Claude Sonnet 5 --- routes/flow/flow_node_run_data.py | 12 +++- screens/__init__.py | 2 +- screens/flow/constants.py | 11 +++ templates/play.html | 14 +++- templates/screen_edit.html | 26 +++++-- tests/test_flow_clicked_row.py | 110 ++++++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/test_flow_clicked_row.py diff --git a/routes/flow/flow_node_run_data.py b/routes/flow/flow_node_run_data.py index 63d24434..0d362df8 100644 --- a/routes/flow/flow_node_run_data.py +++ b/routes/flow/flow_node_run_data.py @@ -1,4 +1,4 @@ -from flask import jsonify +from flask import jsonify, request import screens @@ -10,9 +10,17 @@ def flow_node_run_data(slug, node_id): """Exécuté côté serveur au moment du clic, en mode jouable, pour un nœud Action de type "Modifier une donnée d'un objet" — identique au fonctionnement de l'ancienne action_run_data, mais lit sa config depuis - un nœud du graphe de logique plutôt que depuis la table _actions.""" + un nœud du graphe de logique plutôt que depuis la table _actions. + + Si le nœud vise CLICKED_ROW_ID (voir "Ligne cliquée (Répéteur)" dans + screen_edit.html), la vraie ligne n'est connue qu'au moment du clic — + le client (play.html) la transmet dans le corps JSON de cette requête, + et elle remplace ici target_row_id avant d'exécuter l'action.""" node = screens.get_flow_node(slug, node_id) if not node or node.get("node_type") != "action" or node.get("action_type") != "modifier_donnee": return jsonify({"ok": False}), 400 + if node.get("target_row_id") == screens.CLICKED_ROW_ID: + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("clicked_row_id")} ok = screens.apply_data_action(slug, node) return jsonify({"ok": ok}) diff --git a/screens/__init__.py b/screens/__init__.py index 652114b3..3ad5dc2e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -56,7 +56,7 @@ from .labels.element_action_properties import ( from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBILITY_LABELS from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS -from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS +from .flow.constants import TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID from .rendering.render_element_html import render_element_html from .rendering.trigger_for import _trigger_for diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 7fa3b03d..89050b47 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -10,6 +10,17 @@ # casser sur les jeux déjà créés, mais n'est plus exposé dans l'éditeur ni # utilisé par le mode jouable. +# Valeur sentinelle pour cond_row_id/target_row_id (nœud Condition/action +# "Modifier une donnée") : "la ligne de Répéteur sur laquelle on vient de +# cliquer", résolue au moment de l'exécution (window.lastClickedRowId côté +# client — voir play.html) plutôt que figée à la création du nœud. Utile +# quand le déclencheur est "Au clic" sur un Répéteur : la ligne cliquée +# n'est jamais connue à l'avance (chaque ligne du Répéteur exécute le MÊME +# graphe), donc choisir une ligne précise dans l'éditeur n'a pas de sens +# ici — -1 ne collisionne jamais avec un vrai id de ligne (toujours >= 1, +# AUTOINCREMENT SQLite). +CLICKED_ROW_ID = -1 + TRIGGER_EVENTS = [ ("clic", "Au clic"), ("soumission", "À la soumission"), diff --git a/templates/play.html b/templates/play.html index 818dbb3a..349d6fe8 100644 --- a/templates/play.html +++ b/templates/play.html @@ -533,7 +533,13 @@ 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); - return fetch(runDataUrl, {method: 'POST'}) + // 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. + return fetch(runDataUrl, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clicked_row_id: window.lastClickedRowId }) + }) .then(r => r.json()) .then(() => refreshRuntimeData()); } else if (node.action_type === 'modifier_variable') { @@ -549,6 +555,12 @@ // Lit la valeur actuelle d'un champ d'objet dans l'instantané de données // du jeu (gameData.data), pour l'évaluation d'une condition. function readFieldValue(definitionId, rowId, fieldName) { + // CLICKED_ROW_ID (-1, voir flow_node_run_data.py) : "la ligne de + // Répéteur sur laquelle on vient de cliqué" — jamais connue à l'avance + // dans l'éditeur (choisie ici via "🖱️ Ligne cliquée (Répéteur)"), + // résolue seulement au moment de l'évaluation via le dernier clic + // capturé (voir bindClicks() plus haut). + if (rowId === -1) rowId = window.lastClickedRowId; const rows = gameData.data[String(definitionId)] || []; const row = rows.find(r => r.id === rowId); return row ? row[fieldName] : undefined; diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 433d4da5..6d4102a7 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -2175,15 +2175,31 @@ function closeNodeForm() { currentNodeFormType = null; } +// CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : "la ligne de +// Répéteur sur laquelle on vient de cliquer" — pour un déclencheur "Au +// clic" posé sur un Répéteur, chaque ligne exécute le MÊME graphe et la +// ligne cliquée n'est jamais connue à l'avance ; choisir une ligne fixe +// dans l'éditeur n'a donc pas de sens pour ce cas. Résolue au moment de +// l'exécution (window.lastClickedRowId, voir play.html), pas à la +// création du nœud — proposée en tête de TOUTE liste "Ligne concernée", +// avant les lignes réelles de l'objet. +var FLOW_CLICKED_ROW_ID = -1; + +function _flowRowOptionsHtml(info) { + var options = ''; + options += info.rows.length + ? info.rows.map(function(r){ return ''; }).join('') + : ''; + return options; +} + function onFlowDefinitionChange(prefix) { var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition'); if (!defSel) return; var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] }; var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row'); var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field'); - rowSel.innerHTML = info.rows.length - ? info.rows.map(function(r){ return ''; }).join('') - : ''; + rowSel.innerHTML = _flowRowOptionsHtml(info); fieldSel.innerHTML = info.fields.length ? info.fields.map(function(f){ return ''; }).join('') : ''; @@ -2247,9 +2263,7 @@ function onFlowClauseDefinitionChange(idx) { if (!defSel || !rowSel || !fieldSel) return; flowConditionExtraClauses[idx].definition_id = defSel.value; var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] }; - rowSel.innerHTML = info.rows.length - ? info.rows.map(function(r){ return ''; }).join('') - : ''; + rowSel.innerHTML = _flowRowOptionsHtml(info); fieldSel.innerHTML = info.fields.length ? info.fields.map(function(f){ return ''; }).join('') : ''; diff --git a/tests/test_flow_clicked_row.py b/tests/test_flow_clicked_row.py new file mode 100644 index 00000000..6c40c56b --- /dev/null +++ b/tests/test_flow_clicked_row.py @@ -0,0 +1,110 @@ +"""CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : une action +"Modifier une donnée" (ou un nœud Condition) peut viser "la ligne de +Répéteur sur laquelle on vient de cliquer" plutôt qu'une ligne fixe - +indispensable quand le déclencheur "Au clic" est posé sur un Répéteur, +puisque chaque ligne exécute le même graphe et la ligne cliquée n'est +jamais connue à l'avance dans l'éditeur.""" +import json +import re + +import db +import screens + + +def _create_screen(client, slug, name="Accueil"): + resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False) + return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + + +def _create_email_object(client, slug): + resp = client.post(f"/game/{slug}/objects/new", data={ + "object_name": "Email", "field_name[]": ["is_opened"], "field_type[]": ["booleen"], + "field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""], + }, follow_redirects=False) + def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={}) + client.post(f"/game/{slug}/objects/{def_id}/data/new", data={}) + return def_id + + +def test_modifier_donnee_targeting_clicked_row_updates_only_that_row(client, game): + def_id = _create_email_object(client, game) + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": -1, "target_field": "is_opened", + "data_operation": "definir_bool_vrai", "data_value": "", + }), + content_type="application/json", + ) + node = resp.get_json() + assert node["target_row_id"] == -1 + + resp = client.post( + f"/game/{game}/flow/nodes/{node['id']}/run-data", + data=json.dumps({"clicked_row_id": 2}), + content_type="application/json", + ) + assert resp.get_json() == {"ok": True} + + definition = db.get_definition(game, def_id) + row1 = db.get_row(game, definition, 1) + row2 = db.get_row(game, definition, 2) + assert not row1["is_opened"] + assert row2["is_opened"] + + +def test_modifier_donnee_targeting_clicked_row_without_a_click_is_a_noop(client, game): + """Pas de ligne cliquée transmise (ex. déclenché autrement qu'un vrai + clic sur une ligne de Répéteur) -> ne doit rien modifier, pas planter.""" + def_id = _create_email_object(client, game) + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": -1, "target_field": "is_opened", + "data_operation": "definir_bool_vrai", "data_value": "", + }), + content_type="application/json", + ) + node = resp.get_json() + + resp = client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data", data=json.dumps({}), content_type="application/json") + assert resp.get_json() == {"ok": False} + + definition = db.get_definition(game, def_id) + assert not db.get_row(game, definition, 1)["is_opened"] + assert not db.get_row(game, definition, 2)["is_opened"] + + +def test_ordinary_fixed_row_target_is_unaffected(client, game): + """Non-régression : une action visant une ligne FIXE (pas -1) continue + d'ignorer tout clicked_row_id transmis.""" + def_id = _create_email_object(client, game) + screen_id = _create_screen(client, game) + resp = client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({ + "node_type": "action", "action_type": "modifier_donnee", + "target_definition_id": def_id, "target_row_id": 1, "target_field": "is_opened", + "data_operation": "definir_bool_vrai", "data_value": "", + }), + content_type="application/json", + ) + node = resp.get_json() + client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data", data=json.dumps({"clicked_row_id": 2}), content_type="application/json") + + definition = db.get_definition(game, def_id) + assert db.get_row(game, definition, 1)["is_opened"] + assert not db.get_row(game, definition, 2)["is_opened"] + + +def test_clicked_row_option_appears_in_the_flow_node_editor(client, game): + _create_email_object(client, game) + screen_id = _create_screen(client, game) + html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode() + assert "FLOW_CLICKED_ROW_ID" in html + assert "Ligne cliquée" in html