Remplace le confirm() natif par une modale custom qui avertit des suppressions en cascade
Depuis le dernier correctif, supprimer un élément supprime aussi en silence tout nœud de la Logique de la scène qui le référence (déclencheur/ action, voir delete_element.py) - nécessaire pour éviter le plantage "FOREIGN KEY constraint failed", mais l'utilisateur n'était jamais prévenu qu'un bout de sa logique disparaissait en même temps. Nouvelle route GET .../elements/<id>/delete-impact (element_delete_ impact.py) : calcule, sans rien supprimer, combien de nœuds de la Logique de la scène référencent cet élément OU l'un de ses descendants (partage element_descendant_ids.py avec delete_element.py, pour rester exactement cohérent avec ce qui sera réellement supprimé). Les deux boutons "supprimer" de screen_edit.html (élément sélectionné, et onglet d'un widget Onglets) ouvrent maintenant une modale custom (deleteConfirmModal, même famille que le sélecteur d'icônes) au lieu du confirm() natif du navigateur : elle interroge cette route juste après ouverture et affiche un avertissement dédié si le nombre remonté est non nul, avant que l'utilisateur ne confirme quoi que ce soit - impossible à faire avec confirm(), dont le texte est figé au moment du rendu de la page. La confirmation soumet ensuite le formulaire normalement (via requestSubmit(), intercepté par pjax.js comme n'importe quel autre formulaire). Ajoute deux tests pour la nouvelle route (impact nul, impact non nul sans rien supprimer). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1e86077e45
commit
6894c5fc95
@@ -6,6 +6,7 @@ from . import (
|
|||||||
element_move_to_container,
|
element_move_to_container,
|
||||||
element_save,
|
element_save,
|
||||||
element_delete,
|
element_delete,
|
||||||
|
element_delete_impact,
|
||||||
element_duplicate,
|
element_duplicate,
|
||||||
element_rename,
|
element_rename,
|
||||||
element_tab_add,
|
element_tab_add,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/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)})
|
||||||
@@ -80,6 +80,7 @@ from .elements.reorder_element import reorder_element
|
|||||||
from .elements.move_element_to_container import move_element_to_container
|
from .elements.move_element_to_container import move_element_to_container
|
||||||
from .elements.save_element_controls import save_element_controls
|
from .elements.save_element_controls import save_element_controls
|
||||||
from .elements.delete_element import delete_element
|
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.duplicate_element import duplicate_element
|
||||||
from .elements.rename_element import rename_element
|
from .elements.rename_element import rename_element
|
||||||
from .elements.element_label import element_label
|
from .elements.element_label import element_label
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
import db
|
import db
|
||||||
|
|
||||||
from ..flow.ensure_flow_schema import ensure_flow_schema
|
from ..flow.ensure_flow_schema import ensure_flow_schema
|
||||||
|
from .element_descendant_ids import element_descendant_ids
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def delete_element(slug, element_id):
|
def delete_element(slug, element_id):
|
||||||
@@ -34,7 +11,7 @@ def delete_element(slug, element_id):
|
|||||||
if not row:
|
if not row:
|
||||||
conn.close()
|
conn.close()
|
||||||
return
|
return
|
||||||
ids = _collect_ids(conn, row["screen_id"], element_id)
|
ids = element_descendant_ids(conn, row["screen_id"], element_id)
|
||||||
placeholders = ",".join("?" * len(ids))
|
placeholders = ",".join("?" * len(ids))
|
||||||
# Un nœud de la Logique de la scène (déclencheur "clic sur cet
|
# 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
|
# é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
|
# é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,
|
# 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
|
# 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(
|
conn.execute(
|
||||||
f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
|
f"DELETE FROM _flow_nodes WHERE trigger_element_id IN ({placeholders}) OR target_element_id IN ({placeholders})",
|
||||||
ids + ids,
|
ids + ids,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -487,6 +487,30 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Confirmation de suppression d'un élément — remplace le confirm()
|
||||||
|
natif du navigateur pour pouvoir avertir, le cas échéant, que des
|
||||||
|
nœuds de la Logique de la scène seront aussi supprimés (voir
|
||||||
|
handleDeleteElementSubmit()/element_delete_impact.py) : le
|
||||||
|
confirm() natif ne pouvait afficher qu'un texte figé au moment du
|
||||||
|
rendu, jamais une info calculée après coup. -->
|
||||||
|
<div class="modal" id="deleteConfirmModal">
|
||||||
|
<div class="modal-background" onclick="closeDeleteConfirmModal()"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">🗑️ <span id="deleteConfirmMessage">Supprimer cet élément ?</span></p>
|
||||||
|
<button class="delete" aria-label="Fermer" onclick="closeDeleteConfirmModal()"></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<p class="hint">Cette action est irréversible.</p>
|
||||||
|
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #e2685f);"></p>
|
||||||
|
</section>
|
||||||
|
<footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;">
|
||||||
|
<button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button>
|
||||||
|
<button type="button" class="button is-small is-danger" onclick="confirmDeleteElement()">🗑️ Supprimer</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not selected_id %}
|
{% if not selected_id %}
|
||||||
<div class="hint" style="padding:20px 0;">Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
<div class="hint" style="padding:20px 0;">Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -496,7 +520,7 @@
|
|||||||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||||||
<h2 class="subtitle is-6" style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="has-text-grey">— {{ w.get('label', el.widget) }}</span>{% endif %}</h2>
|
<h2 class="subtitle is-6" style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="has-text-grey">— {{ w.get('label', el.widget) }}</span>{% endif %}</h2>
|
||||||
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=el.id) }}"
|
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=el.id) }}"
|
||||||
onsubmit="return confirm('Supprimer cet élément ?');">
|
data-confirm-message="Supprimer cet élément ?" onsubmit="return handleDeleteElementSubmit(event, this);">
|
||||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,7 +548,8 @@
|
|||||||
<div class="field has-addons" style="margin:0;">
|
<div class="field has-addons" style="margin:0;">
|
||||||
<div class="control is-expanded"><a class="button is-small is-fullwidth" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=tab.id) }}">📥 Contenu de l'onglet</a></div>
|
<div class="control is-expanded"><a class="button is-small is-fullwidth" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=tab.id) }}">📥 Contenu de l'onglet</a></div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=tab.id) }}" onsubmit="return confirm('Supprimer cet onglet et tout son contenu ?');">
|
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=tab.id) }}"
|
||||||
|
data-confirm-message="Supprimer cet onglet et tout son contenu ?" onsubmit="return handleDeleteElementSubmit(event, this);">
|
||||||
<button type="submit" class="button is-small is-danger">🗑️</button>
|
<button type="submit" class="button is-small is-danger">🗑️</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -999,6 +1024,61 @@ function filterIconGallery(query) {
|
|||||||
// posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une
|
// 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é
|
// icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché
|
||||||
// "field-icon_class") au lieu d'en créer un nouveau. ----------
|
// "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() {
|
function openIconPicker() {
|
||||||
var modal = document.getElementById('iconPickerModal');
|
var modal = document.getElementById('iconPickerModal');
|
||||||
if (modal) modal.classList.add('is-active');
|
if (modal) modal.classList.add('is-active');
|
||||||
|
|||||||
@@ -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))
|
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):
|
def test_deleting_an_element_used_as_a_flow_trigger_does_not_crash(client, game):
|
||||||
screen_id = _create_screen(client, game)
|
screen_id = _create_screen(client, game)
|
||||||
element_id = _add_element(client, game, screen_id, "bouton")
|
element_id = _add_element(client, game, screen_id, "bouton")
|
||||||
|
|||||||
Reference in New Issue
Block a user