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