diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py index 80d0cbac..ad59e67d 100644 --- a/routes/elements/__init__.py +++ b/routes/elements/__init__.py @@ -6,6 +6,7 @@ from . import ( element_move_to_container, element_save, element_delete, + element_delete_impact, element_duplicate, element_rename, element_tab_add, diff --git a/routes/elements/element_delete_impact.py b/routes/elements/element_delete_impact.py new file mode 100644 index 00000000..025adefe --- /dev/null +++ b/routes/elements/element_delete_impact.py @@ -0,0 +1,16 @@ +from flask import jsonify + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//delete-impact") +def element_delete_impact(slug, element_id): + """Appelé par la modale de confirmation de suppression (screen_edit.html) + AVANT que l'utilisateur ne confirme, pour l'avertir si des nœuds de la + Logique de la scène (déclencheur/action) seraient aussi supprimés en + même temps que l'élément — voir delete_element.py, qui les supprime + silencieusement sinon (nécessaire pour éviter un plantage "FOREIGN KEY + constraint failed", voir son commentaire).""" + return jsonify({"flow_node_count": screens.flow_nodes_referencing_element(slug, element_id)}) diff --git a/screens/__init__.py b/screens/__init__.py index 88f2f55e..652114b3 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -80,6 +80,7 @@ from .elements.reorder_element import reorder_element from .elements.move_element_to_container import move_element_to_container from .elements.save_element_controls import save_element_controls from .elements.delete_element import delete_element +from .elements.flow_nodes_referencing_element import flow_nodes_referencing_element from .elements.duplicate_element import duplicate_element from .elements.rename_element import rename_element from .elements.element_label import element_label diff --git a/screens/elements/delete_element.py b/screens/elements/delete_element.py index 165915d7..b1d37aac 100644 --- a/screens/elements/delete_element.py +++ b/screens/elements/delete_element.py @@ -1,30 +1,7 @@ import db from ..flow.ensure_flow_schema import ensure_flow_schema - - -def _collect_ids(conn, screen_id, root_id): - """Rassemble l'id de cet élément et de TOUS ses descendants — ceux - qu'une suppression en cascade (parent_id ON DELETE CASCADE, voir - ensure_schema.py) va de toute façon supprimer automatiquement en même - temps, sans passer par ce fichier. Nécessaire pour nettoyer AUSSI les - références de la Logique de la scène vers un simple DESCENDANT, pas - seulement vers l'élément explicitement supprimé.""" - rows = conn.execute( - "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,) - ).fetchall() - children_of = {} - for r in rows: - if r["parent_id"]: - children_of.setdefault(r["parent_id"], []).append(r["id"]) - ids = [root_id] - stack = [root_id] - while stack: - current = stack.pop() - for child_id in children_of.get(current, []): - ids.append(child_id) - stack.append(child_id) - return ids +from .element_descendant_ids import element_descendant_ids def delete_element(slug, element_id): @@ -34,7 +11,7 @@ def delete_element(slug, element_id): if not row: conn.close() return - ids = _collect_ids(conn, row["screen_id"], element_id) + ids = element_descendant_ids(conn, row["screen_id"], element_id) placeholders = ",".join("?" * len(ids)) # Un nœud de la Logique de la scène (déclencheur "clic sur cet # élément"/action "Modifier cet élément"...) ou une ancienne action du @@ -45,7 +22,9 @@ def delete_element(slug, element_id): # élément ne doit pas pouvoir être supprimé "par erreur" en cascade # depuis un nœud de logique qu'on modifie). Sans ce nettoyage préalable, # PRAGMA foreign_keys=ON (voir db/connection.py) fait échouer la - # suppression elle-même avec "FOREIGN KEY constraint failed". + # suppression elle-même avec "FOREIGN KEY constraint failed". Voir + # element_delete_impact.py pour prévenir l'utilisateur AVANT qu'il + # confirme, plutôt que de supprimer ces nœuds en silence. conn.execute( f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})", ids + ids, diff --git a/screens/elements/element_descendant_ids.py b/screens/elements/element_descendant_ids.py new file mode 100644 index 00000000..aa10b3cb --- /dev/null +++ b/screens/elements/element_descendant_ids.py @@ -0,0 +1,23 @@ +def element_descendant_ids(conn, screen_id, root_id): + """Id de cet élément et de TOUS ses descendants — ceux qu'une + suppression en cascade (parent_id ON DELETE CASCADE, voir + ensure_schema.py) supprime automatiquement en même temps que lui. + Partagé entre delete_element.py (nettoyage des références de la + Logique de la scène avant suppression) et flow_nodes_referencing_ + element.py (pour prévenir l'utilisateur AVANT qu'il confirme la + suppression, voir routes/elements/element_delete_impact.py).""" + rows = conn.execute( + "SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (screen_id,) + ).fetchall() + children_of = {} + for r in rows: + if r["parent_id"]: + children_of.setdefault(r["parent_id"], []).append(r["id"]) + ids = [root_id] + stack = [root_id] + while stack: + current = stack.pop() + for child_id in children_of.get(current, []): + ids.append(child_id) + stack.append(child_id) + return ids diff --git a/screens/elements/flow_nodes_referencing_element.py b/screens/elements/flow_nodes_referencing_element.py new file mode 100644 index 00000000..ec7257f4 --- /dev/null +++ b/screens/elements/flow_nodes_referencing_element.py @@ -0,0 +1,28 @@ +import db + +from ..flow.ensure_flow_schema import ensure_flow_schema +from .element_descendant_ids import element_descendant_ids + + +def flow_nodes_referencing_element(slug, element_id): + """Nombre de nœuds de la Logique de la scène (déclencheur "clic sur cet + élément", action "Modifier cet élément"/"Activer cet onglet"...) qui + référencent cet élément OU L'UN DE SES DESCENDANTS — donc qui seraient + supprimés SI on supprimait cet élément (voir delete_element.py). Utilisé + pour prévenir l'utilisateur AVANT qu'il confirme la suppression (voir + la modale de confirmation dans screen_edit.html), plutôt que de les + supprimer en silence.""" + ensure_flow_schema(slug) + conn = db.connect(slug) + row = conn.execute("SELECT screen_id FROM _screen_elements WHERE id = ?", (element_id,)).fetchone() + if not row: + conn.close() + return 0 + ids = element_descendant_ids(conn, row["screen_id"], element_id) + placeholders = ",".join("?" * len(ids)) + count = conn.execute( + f"SELECT COUNT(*) AS c FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})", + ids + ids, + ).fetchone()["c"] + conn.close() + return count diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 11b7fb91..d44ae71a 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -487,6 +487,30 @@ + + + {% if not selected_id %}
Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.
{% else %} @@ -496,7 +520,7 @@

{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} — {{ w.get('label', el.widget) }}{% endif %}

+ data-confirm-message="Supprimer cet élément ?" onsubmit="return handleDeleteElementSubmit(event, this);">
@@ -524,7 +548,8 @@
-
+
@@ -999,6 +1024,61 @@ function filterIconGallery(query) { // posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une // icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché // "field-icon_class") au lieu d'en créer un nouveau. ---------- +// ---------- Confirmation de suppression d'un élément (modale custom, pas +// le confirm() natif du navigateur) — voir deleteConfirmModal plus haut. +// Interroge element_delete_impact.py pour savoir si des nœuds de la +// Logique de la scène seraient aussi supprimés, et l'affiche si oui, +// avant que l'utilisateur confirme quoi que ce soit. ---------- +var _pendingDeleteForm = null; + +function handleDeleteElementSubmit(event, form) { + if (form.dataset.confirmed === '1') { + // Deuxième passage, après clic sur "Supprimer" dans la modale (voir + // confirmDeleteElement()) : laisse cette fois la soumission suivre son + // cours normalement (pjax.js l'intercepte comme n'importe quel autre + // formulaire, voir static/pjax.js). + form.dataset.confirmed = ''; + return true; + } + event.preventDefault(); + _pendingDeleteForm = form; + document.getElementById('deleteConfirmMessage').textContent = form.dataset.confirmMessage || 'Supprimer cet élément ?'; + var warnEl = document.getElementById('deleteConfirmFlowWarning'); + warnEl.style.display = 'none'; + warnEl.textContent = ''; + document.getElementById('deleteConfirmModal').classList.add('is-active'); + var m = form.getAttribute('action').match(/\/elements\/(\d+)\/delete$/); + if (m) { + fetch("/game/{{ game.slug }}/elements/" + m[1] + "/delete-impact") + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.flow_node_count) return; + var n = data.flow_node_count; + var plural = n > 1 ? 's' : ''; + warnEl.textContent = '⚠️ ' + n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' + + (n > 1 ? 'nt' : '') + ' cet élément et sera' + (n > 1 ? 'ont' : '') + ' aussi supprimée' + plural + '.'; + warnEl.style.display = 'block'; + }) + .catch(function () {}); + } + return false; +} + +function closeDeleteConfirmModal() { + var modal = document.getElementById('deleteConfirmModal'); + if (modal) modal.classList.remove('is-active'); + _pendingDeleteForm = null; +} + +function confirmDeleteElement() { + if (!_pendingDeleteForm) return; + var form = _pendingDeleteForm; + _pendingDeleteForm = null; + closeDeleteConfirmModal(); + form.dataset.confirmed = '1'; + if (form.requestSubmit) form.requestSubmit(); else form.submit(); +} + function openIconPicker() { var modal = document.getElementById('iconPickerModal'); if (modal) modal.classList.add('is-active'); diff --git a/tests/test_delete_element_referenced_by_flow.py b/tests/test_delete_element_referenced_by_flow.py index da926739..1d134cc1 100644 --- a/tests/test_delete_element_referenced_by_flow.py +++ b/tests/test_delete_element_referenced_by_flow.py @@ -20,6 +20,31 @@ def _add_element(client, slug, screen_id, widget="conteneur"): return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) +def test_delete_impact_reports_zero_when_unused_by_flow(client, game): + screen_id = _create_screen(client, game) + element_id = _add_element(client, game, screen_id, "bouton") + resp = client.get(f"/game/{game}/elements/{element_id}/delete-impact") + assert resp.get_json() == {"flow_node_count": 0} + + +def test_delete_impact_reports_referencing_flow_nodes_before_deletion(client, game): + """La modale de confirmation (screen_edit.html) interroge cette route + AVANT que l'utilisateur ne confirme, pour l'avertir que des nœuds de + la Logique de la scène seraient aussi supprimés — sans jamais rien + supprimer elle-même.""" + screen_id = _create_screen(client, game) + element_id = _add_element(client, game, screen_id, "bouton") + client.post( + f"/game/{game}/screens/{screen_id}/flow/nodes/add", + data=json.dumps({"node_type": "trigger", "trigger_event": "clic", "trigger_element_id": element_id}), + content_type="application/json", + ) + resp = client.get(f"/game/{game}/elements/{element_id}/delete-impact") + assert resp.get_json() == {"flow_node_count": 1} + # Rien n'a été supprimé par cette simple consultation. + assert screens.get_element(game, element_id) is not None + + def test_deleting_an_element_used_as_a_flow_trigger_does_not_crash(client, game): screen_id = _create_screen(client, game) element_id = _add_element(client, game, screen_id, "bouton")