diff --git a/core/auth_guard.py b/core/auth_guard.py index 126f1b11..5f60bbd7 100644 --- a/core/auth_guard.py +++ b/core/auth_guard.py @@ -31,6 +31,7 @@ _PUBLIC_ENDPOINTS = { "forgot_password", "reset_password", "game_play_public", "runtime_payload_public", "flow_node_run_data_public", "flow_node_run_variable_public", + "flow_node_run_add_row_public", } diff --git a/routes/flow/__init__.py b/routes/flow/__init__.py index d28a34e3..9864306b 100644 --- a/routes/flow/__init__.py +++ b/routes/flow/__init__.py @@ -6,5 +6,6 @@ from . import ( flow_edge_delete, flow_node_run_data, flow_node_run_variable, + flow_node_run_add_row, runtime_payload, ) diff --git a/routes/flow/flow_node_run_add_row.py b/routes/flow/flow_node_run_add_row.py new file mode 100644 index 00000000..374a99a3 --- /dev/null +++ b/routes/flow/flow_node_run_add_row.py @@ -0,0 +1,21 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//flow/nodes//run-add-row", methods=["POST"]) +def flow_node_run_add_row(slug, node_id): + """Exécuté côté serveur au moment du clic, en mode jouable, pour un + nœud Action de type "ajouter_ligne" (Phase 4) — même principe que + flow_node_run_data.py, mais crée une ligne VIDE plutôt que d'en + modifier une existante. Renvoie l'id de la nouvelle ligne, à + transmettre par le client (voir runActionNode() dans + static/js/play/actions.js) aux nœuds suivants qui la ciblent via + LAST_INSERTED_ROW_ID.""" + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "ajouter_ligne": + return jsonify({"ok": False}), 400 + row_id = screens.apply_add_row_action(slug, node) + return jsonify({"ok": row_id is not None, "row_id": row_id}) diff --git a/routes/flow/flow_node_run_data.py b/routes/flow/flow_node_run_data.py index 0d362df8..d788f8f3 100644 --- a/routes/flow/flow_node_run_data.py +++ b/routes/flow/flow_node_run_data.py @@ -22,5 +22,13 @@ def flow_node_run_data(slug, node_id): 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")} + elif node.get("target_row_id") == screens.LAST_INSERTED_ROW_ID: + # Phase 4 — enchaînement "Ajouter une ligne" -> "Modifier une + # donnée" : la vraie ligne (créée juste avant par le nœud + # précédent, voir flow_node_run_add_row.py) n'est connue qu'au + # moment du clic, transmise par le client (voir runActionNode() + # dans static/js/play/actions.js). + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("last_inserted_row_id")} ok = screens.apply_data_action(slug, node) return jsonify({"ok": ok}) diff --git a/routes/play/game_play.py b/routes/play/game_play.py index e677703e..fed186ca 100644 --- a/routes/play/game_play.py +++ b/routes/play/game_play.py @@ -21,4 +21,5 @@ def game_play(slug): runtime_payload_url=url_for("runtime_payload", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable", slug=slug, node_id=999999), + flow_node_run_add_row_url=url_for("flow_node_run_add_row", slug=slug, node_id=999999), ) diff --git a/routes/public_play/__init__.py b/routes/public_play/__init__.py index 8d21da09..6984c1bb 100644 --- a/routes/public_play/__init__.py +++ b/routes/public_play/__init__.py @@ -1 +1,7 @@ -from . import game_play_public, runtime_payload_public, flow_node_run_data_public, flow_node_run_variable_public +from . import ( + game_play_public, + runtime_payload_public, + flow_node_run_data_public, + flow_node_run_variable_public, + flow_node_run_add_row_public, +) diff --git a/routes/public_play/flow_node_run_add_row_public.py b/routes/public_play/flow_node_run_add_row_public.py new file mode 100644 index 00000000..1f4ae06d --- /dev/null +++ b/routes/public_play/flow_node_run_add_row_public.py @@ -0,0 +1,25 @@ +from flask import jsonify, abort + +import db +import screens + +from core.flask_app import app +from core.player_identity import get_or_create_player_id, set_player_id_cookie + + +@app.route("/jouer//flow/nodes//run-add-row", methods=["POST"]) +def flow_node_run_add_row_public(slug, node_id): + """Miroir public de routes/flow/flow_node_run_add_row.py (aperçu + créateur) — voir routes/public_play/game_play_public.py pour le + principe général.""" + if not db.is_public_played(slug): + abort(404) + player_id, is_new = get_or_create_player_id() + node = screens.get_flow_node(slug, node_id) + if not node or node.get("node_type") != "action" or node.get("action_type") != "ajouter_ligne": + return jsonify({"ok": False}), 400 + row_id = screens.apply_add_row_action(slug, node, player_id) + resp = app.make_response(jsonify({"ok": row_id is not None, "row_id": row_id})) + if is_new: + set_player_id_cookie(resp, player_id) + return resp diff --git a/routes/public_play/flow_node_run_data_public.py b/routes/public_play/flow_node_run_data_public.py index 08576fd9..605bac16 100644 --- a/routes/public_play/flow_node_run_data_public.py +++ b/routes/public_play/flow_node_run_data_public.py @@ -22,6 +22,10 @@ def flow_node_run_data_public(slug, node_id): 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")} + elif node.get("target_row_id") == screens.LAST_INSERTED_ROW_ID: + # Phase 4 — voir routes/flow/flow_node_run_data.py. + body = request.get_json(silent=True) or {} + node = {**node, "target_row_id": body.get("last_inserted_row_id")} ok = screens.apply_data_action(slug, node, player_id) resp = app.make_response(jsonify({"ok": ok})) if is_new: diff --git a/routes/public_play/game_play_public.py b/routes/public_play/game_play_public.py index 6381a5e1..b142810d 100644 --- a/routes/public_play/game_play_public.py +++ b/routes/public_play/game_play_public.py @@ -29,6 +29,7 @@ def game_play_public(slug): runtime_payload_url=url_for("runtime_payload_public", slug=slug), flow_node_run_data_url=url_for("flow_node_run_data_public", slug=slug, node_id=999999), flow_node_run_variable_url=url_for("flow_node_run_variable_public", slug=slug, node_id=999999), + flow_node_run_add_row_url=url_for("flow_node_run_add_row_public", slug=slug, node_id=999999), ) ) if is_new: diff --git a/routes/screens/screen_edit.py b/routes/screens/screen_edit.py index 1d666183..2123c7e3 100644 --- a/routes/screens/screen_edit.py +++ b/routes/screens/screen_edit.py @@ -37,6 +37,10 @@ def screen_edit(slug, screen_id): definitions = db.list_definitions(slug) definitions_data = {str(d["id"]): screens.data_definition_options(slug, d["id"]) for d in definitions} + # Phase 4 — nodeLabel() (static/js/screen_edit/flow-editor.js) affiche + # le nom de l'objet visé par un nœud "Ajouter une ligne" ; DEFINITIONS_DATA + # (ci-dessus) ne porte que lignes/champs, jamais le nom lui-même. + definitions_names = {d["id"]: d["name"] for d in definitions} # list_flow_blocks() rattache paresseusement les nœuds "orphelins" # (block_id NULL, jeux créés avant les blocs) à un "Bloc principal" — @@ -94,4 +98,5 @@ def screen_edit(slug, screen_id): custom_events_map_json=json.dumps(custom_events_map), blocks_view=blocks_view, flow_blocks_json=json.dumps(flow_blocks), + definitions_names_json=json.dumps(definitions_names), ) diff --git a/screens/__init__.py b/screens/__init__.py index 3fdcca30..eecae938 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -56,7 +56,7 @@ from .labels.element_visibility import ELEMENT_VISIBILITY_VALUES, ELEMENT_VISIBI from .labels.data_operations import DATA_OPERATIONS, DATA_OPERATION_LABELS from .flow.constants import ( - TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, + TRIGGER_EVENTS, CONDITION_OPERATORS, CONDITION_OPERATOR_LABELS, CLICKED_ROW_ID, LAST_INSERTED_ROW_ID, ) from .rendering.render_element_html import render_element_html @@ -117,6 +117,7 @@ from .flow.blocks.blocks_view import blocks_view from .data_actions.apply_data_action import apply_data_action from .data_actions.apply_variable_action import apply_variable_action +from .data_actions.apply_add_row_action import apply_add_row_action from .animations.ensure_animation_schema import ensure_animation_schema from .animations.list_animation_clips import list_animation_clips diff --git a/screens/data_actions/apply_add_row_action.py b/screens/data_actions/apply_add_row_action.py new file mode 100644 index 00000000..755bcd58 --- /dev/null +++ b/screens/data_actions/apply_add_row_action.py @@ -0,0 +1,19 @@ +import db + + +def apply_add_row_action(slug, action, player_id=db.PLAYER_SHARED): + """Exécute au moment du clic (mode jouable) une action "ajouter_ligne" : + crée une ligne VIDE dans l'objet visé (aucun champ renseigné ici — + c'est à un ou plusieurs nœuds "Modifier une donnée" enchaînés, + ciblant LAST_INSERTED_ROW_ID, de le faire ensuite, voir + screens/flow/constants.py). `player_id` (état par joueur, Phase 1) : + la ligne appartient à CE joueur pour un objet per_player (voir + db/rows/insert_row.py). Renvoie l'id de la nouvelle ligne, ou None en + cas d'échec (objet introuvable).""" + definition_id = action.get("target_definition_id") + if not definition_id: + return None + definition = db.get_definition(slug, definition_id) + if not definition: + return None + return db.insert_row(slug, definition, {}, player_id) diff --git a/screens/flow/constants.py b/screens/flow/constants.py index 0a86530b..786797a5 100644 --- a/screens/flow/constants.py +++ b/screens/flow/constants.py @@ -21,6 +21,17 @@ # AUTOINCREMENT SQLite). CLICKED_ROW_ID = -1 +# Phase 4 (moteur) — "la ligne qu'un nœud Action 'Ajouter une ligne' +# vient de créer" : un id de ligne n'existe qu'APRÈS l'insertion, donc +# jamais connu à la création du nœud, exactement comme CLICKED_ROW_ID +# ci-dessus. Résolue au moment de l'exécution +# (window.lastInsertedRowId, voir static/js/play/actions.js) — permet +# d'enchaîner "Ajouter une ligne" -> "Modifier une donnée"/condition +# ciblant cette même ligne fraîchement créée, sans avoir à inventer un +# nouveau format de payload multi-champs pour le nœud "Ajouter une +# ligne" lui-même (qui ne crée qu'une ligne VIDE). +LAST_INSERTED_ROW_ID = -3 + TRIGGER_EVENTS = [ ("clic", "Au clic"), ("soumission", "À la soumission"), diff --git a/screens/labels/action_type_labels.py b/screens/labels/action_type_labels.py index df15c443..aa24a6a1 100644 --- a/screens/labels/action_type_labels.py +++ b/screens/labels/action_type_labels.py @@ -9,5 +9,6 @@ ACTION_TYPE_LABELS = { "activer_onglet": "Afficher cet élément, masquer tous ses frères", "attendre": "Attendre quelques secondes avant de continuer", "declencher_evenement": "Déclencher un événement", + "ajouter_ligne": "Ajouter une ligne à un objet", "rien": "Ne rien faire", } diff --git a/static/js/play/actions.js b/static/js/play/actions.js index 220f8a8c..a603a722 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -173,12 +173,13 @@ function runActionNode(node) { return new Promise(function(resolve) { setTimeout(resolve, ms); }); } else if (node.action_type === 'modifier_donnee') { const runDataUrl = FORGE_PLAY_URLS.flowNodeRunData.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/last_inserted_row_id : utilisés côté serveur + // SEULEMENT si ce nœud vise respectivement CLICKED_ROW_ID ou + // LAST_INSERTED_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, last_inserted_row_id: window.lastInsertedRowId }) }) .then(r => r.json()) .then(() => refreshRuntimeData()); @@ -187,6 +188,19 @@ function runActionNode(node) { return fetch(runVarUrl, {method: 'POST'}) .then(r => r.json()) .then(() => refreshRuntimeData()); + } else if (node.action_type === 'ajouter_ligne') { + // Phase 4 — crée une ligne VIDE côté serveur ; son id devient + // window.lastInsertedRowId pour que les nœuds "Modifier une donnée" + // enchaînés (ciblant LAST_INSERTED_ROW_ID, "➕ Dernière ligne + // ajoutée") sachent quelle ligne renseigner. refreshRuntimeData() + // affiche la nouvelle ligne dans tout Répéteur lié dès ce round-trip. + const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id); + return fetch(runAddRowUrl, { method: 'POST' }) + .then(r => r.json()) + .then(function(json) { + window.lastInsertedRowId = json.row_id; + return refreshRuntimeData(); + }); } // 'rien' : aucune action volontairement. return Promise.resolve(); diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index b5a4d35d..da36760f 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -101,6 +101,8 @@ function nodeLabel(node) { // n'est jamais transmis au déclenchement. var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id]; base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »'; + } else if (node.action_type === 'ajouter_ligne') { + base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?'); } return base; } @@ -320,9 +322,18 @@ function closeNodeForm() { // 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; +// LAST_INSERTED_ROW_ID (-3, voir screens/flow/constants.py) : "la ligne +// qu'un nœud Action 'Ajouter une ligne' vient de créer" (Phase 4) — même +// principe que CLICKED_ROW_ID ci-dessus, résolue au moment de l'exécution +// (window.lastInsertedRowId, voir static/js/play/actions.js) puisqu'un +// id de ligne n'existe qu'APRÈS l'insertion, jamais connu à la création +// du nœud. Permet d'enchaîner "Ajouter une ligne" -> "Modifier une +// donnée"/condition ciblant cette même ligne fraîchement créée. +var FLOW_LAST_INSERTED_ROW_ID = -3; function _flowRowOptionsHtml(info) { var options = ''; + options += ''; options += info.rows.length ? info.rows.map(function(r){ return ''; }).join('') : ''; @@ -468,6 +479,7 @@ function toggleFlowActionFields() { var variableFields = document.getElementById('nf-action-variable-fields'); var waitFields = document.getElementById('nf-action-wait-fields'); var eventFields = document.getElementById('nf-action-event-fields'); + var addRowFields = document.getElementById('nf-action-add-row-fields'); var isOnglet = typeSel.value === 'activer_onglet'; screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none'; elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none'; @@ -475,6 +487,7 @@ function toggleFlowActionFields() { variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none'; waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none'; if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none'; + if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none'; document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none'; document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue(); @@ -641,6 +654,11 @@ function submitNodeForm() { // screens/custom_events/) — c'est à l'écouteur de décider quoi // faire, avec ses propres réglages habituels. payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10); + } else if (actionType === 'ajouter_ligne') { + // Phase 4 — crée une ligne VIDE ; ses champs se renseignent via un ou + // plusieurs nœuds "Modifier une donnée" enchaînés, ciblant + // FLOW_LAST_INSERTED_ROW_ID ("➕ Dernière ligne ajoutée"). + payload.target_definition_id = parseInt(document.getElementById('nf-action-add-row-definition').value, 10); } } diff --git a/templates/play.html b/templates/play.html index 5257e0c4..c5715d78 100644 --- a/templates/play.html +++ b/templates/play.html @@ -81,17 +81,19 @@ // les URLs de routes dont ces fichiers ont besoin (actions.js, // bindings.js), construites ici une seule fois — passées par la route // elle-même (runtime_payload_url/flow_node_run_data_url/ - // flow_node_run_variable_url) plutôt que construites ici avec des noms - // de endpoint fixes : ce même template sert aussi bien l'aperçu - // créateur (/game//play, routes/play/game_play.py) que la partie - // publique par joueur (/jouer/, routes/public_play/), deux - // familles de routes distinctes (voir état par joueur, Phase 1). + // flow_node_run_variable_url/flow_node_run_add_row_url) plutôt que + // construites ici avec des noms de endpoint fixes : ce même template + // sert aussi bien l'aperçu créateur (/game//play, + // routes/play/game_play.py) que la partie publique par joueur + // (/jouer/, routes/public_play/), deux familles de routes + // distinctes (voir état par joueur, Phase 1). let gameData = {{ payload_json|safe }}; let screensData = gameData.screens; var FORGE_PLAY_URLS = { runtimePayload: {{ runtime_payload_url|tojson }}, flowNodeRunData: {{ flow_node_run_data_url|tojson }}, flowNodeRunVariable: {{ flow_node_run_variable_url|tojson }}, + flowNodeRunAddRow: {{ flow_node_run_add_row_url|tojson }}, }; diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 0222754a..94e9d4c6 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -439,6 +439,19 @@ {% endif %} + +