Dev #1
@@ -6,6 +6,7 @@ from . import (
|
||||
element_move_to_container,
|
||||
element_save,
|
||||
element_delete,
|
||||
element_delete_impact,
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
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.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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</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 %}
|
||||
<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 %}
|
||||
@@ -496,7 +520,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -524,7 +548,8 @@
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user