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