Redesign the screen editor's element tree and add duplication
The "Éléments de cet écran" tree now renders every level of nesting (previously stopped after one level of children) as a compact single-line list, and right-clicking a row opens a context menu to duplicate the element (and its full subtree) in place, in its current container. Also: all property panels start collapsed instead of some being open by default, the redundant nested element list inside "Ajouter DANS ce conteneur" is removed (it only needs the widget picker), and the now-unneeded "a container is selected" warning banner is gone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3039489e39
commit
bb79f2f93d
@@ -4,5 +4,6 @@ from . import (
|
|||||||
element_geometry,
|
element_geometry,
|
||||||
element_save,
|
element_save,
|
||||||
element_delete,
|
element_delete,
|
||||||
|
element_duplicate,
|
||||||
element_rename,
|
element_rename,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from flask import redirect, url_for, abort
|
||||||
|
|
||||||
|
import screens
|
||||||
|
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
|
||||||
|
def element_duplicate(slug, element_id):
|
||||||
|
element = screens.get_element(slug, element_id)
|
||||||
|
if not element:
|
||||||
|
abort(404)
|
||||||
|
new_id = screens.duplicate_element(slug, element_id)
|
||||||
|
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
|
||||||
@@ -76,6 +76,7 @@ from .elements.add_element import add_element
|
|||||||
from .elements.update_element_geometry import update_element_geometry
|
from .elements.update_element_geometry import update_element_geometry
|
||||||
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.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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import db
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_element(slug, element_id):
|
||||||
|
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
|
||||||
|
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
|
||||||
|
de l'arborescence n'a pas à faire choisir où poser la copie, elle
|
||||||
|
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
|
||||||
|
sélectionner ensuite), ou None si l'élément n'existe pas."""
|
||||||
|
conn = db.connect(slug)
|
||||||
|
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
|
if not root_row:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
root = dict(root_row)
|
||||||
|
source = [
|
||||||
|
dict(r) for r in conn.execute(
|
||||||
|
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def copy_one(src, new_parent_id, is_root):
|
||||||
|
conn = db.connect(slug)
|
||||||
|
max_z = conn.execute(
|
||||||
|
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
|
||||||
|
).fetchone()["m"]
|
||||||
|
x, y = src["x"], src["y"]
|
||||||
|
if is_root and not new_parent_id:
|
||||||
|
# Petit décalage pour que la copie ne recouvre pas exactement
|
||||||
|
# l'original (qui serait alors invisible en dessous).
|
||||||
|
x = min(100, (x or 0) + 4)
|
||||||
|
y = min(100, (y or 0) + 4)
|
||||||
|
name = src.get("name")
|
||||||
|
if is_root and name:
|
||||||
|
name = f"{name} (copie)"
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO _screen_elements
|
||||||
|
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
|
||||||
|
x, y, width, height, z_index, style)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
|
||||||
|
src["content"], src["attributes"], x, y, src["width"], src["height"],
|
||||||
|
(max_z or 0) + 1, src["style"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return new_id
|
||||||
|
|
||||||
|
def walk(src_parent_id, new_parent_id):
|
||||||
|
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
|
||||||
|
new_child_id = copy_one(child, new_parent_id, is_root=False)
|
||||||
|
walk(child["id"], new_child_id)
|
||||||
|
|
||||||
|
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
|
||||||
|
walk(root["id"], new_root_id)
|
||||||
|
return new_root_id
|
||||||
@@ -154,6 +154,33 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
|||||||
.childRow{ padding:7px 10px; font-size:12px; }
|
.childRow{ padding:7px 10px; font-size:12px; }
|
||||||
.childRow .rowTitle{ font-size:12px; }
|
.childRow .rowTitle{ font-size:12px; }
|
||||||
|
|
||||||
|
/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
|
||||||
|
ligne par élément, indentation par imbrication réelle (profondeur
|
||||||
|
illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
|
||||||
|
pour rester lisible même avec beaucoup d'éléments. */
|
||||||
|
.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
|
||||||
|
.treeNode{ display:flex; flex-direction:column; }
|
||||||
|
.treeRowLink{
|
||||||
|
display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
|
||||||
|
text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
|
||||||
|
white-space:nowrap; overflow:hidden;
|
||||||
|
}
|
||||||
|
.treeRowLink:hover{ background:var(--panel2); }
|
||||||
|
.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
|
||||||
|
.treeIcon{ flex:0 0 auto; font-size:12px; }
|
||||||
|
.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
|
||||||
|
|
||||||
|
.contextMenu{
|
||||||
|
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
|
||||||
|
border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
|
||||||
|
}
|
||||||
|
.contextMenu button{
|
||||||
|
display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
|
||||||
|
padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
|
||||||
|
}
|
||||||
|
.contextMenu button:hover{ background:var(--panel2); }
|
||||||
|
|
||||||
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
|
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
|
||||||
.propGroup > summary{
|
.propGroup > summary{
|
||||||
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
|
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
|
||||||
|
|||||||
+82
-44
@@ -29,16 +29,31 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% macro render_tree_node(el) %}
|
||||||
|
<div class="treeNode">
|
||||||
|
<a class="treeRowLink {{ 'selected' if el.id == selected_id else '' }}"
|
||||||
|
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}"
|
||||||
|
data-element-id="{{ el.id }}"
|
||||||
|
oncontextmenu="return showElementContextMenu(event, {{ el.id }});">
|
||||||
|
<span class="treeIcon">{{ widgets.get(el.widget, {}).get('icon', '❔') }}</span>
|
||||||
|
<span class="treeLabel">{{ el|elabel }}</span>
|
||||||
|
</a>
|
||||||
|
{% set kids = children_of.get(el.id, []) %}
|
||||||
|
{% if kids %}
|
||||||
|
<div class="treeChildren">
|
||||||
|
{% for child in kids %}
|
||||||
|
{{ render_tree_node(child) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="builder3" id="builder3">
|
<div class="builder3" id="builder3">
|
||||||
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
||||||
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) -->
|
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) -->
|
||||||
<div class="builderPanel">
|
<div class="builderPanel">
|
||||||
{% if selected_id and el and el.widget in container_widgets %}
|
<details class="propGroup">
|
||||||
<p class="hint" style="background:#2a1f10; border:1px solid #6b4a12; border-radius:8px; padding:8px 12px; margin-bottom:10px;">
|
|
||||||
⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt <strong>« 📥 Ajouter DANS ce conteneur »</strong> dans le panneau de droite.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<details class="propGroup" open>
|
|
||||||
<summary>Ajouter un élément sur l'écran</summary>
|
<summary>Ajouter un élément sur l'écran</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
<div class="widgetGrid">
|
<div class="widgetGrid">
|
||||||
@@ -59,7 +74,7 @@
|
|||||||
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
|
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
|
||||||
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
|
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
|
||||||
toujours le premier du catalogue sans laisser choisir lequel. -->
|
toujours le premier du catalogue sans laisser choisir lequel. -->
|
||||||
<details class="propGroup" {{ 'open' if element_types else '' }}>
|
<details class="propGroup">
|
||||||
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
|
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
{% if element_types %}
|
{% if element_types %}
|
||||||
@@ -83,24 +98,9 @@
|
|||||||
|
|
||||||
<h2>Éléments de cet écran</h2>
|
<h2>Éléments de cet écran</h2>
|
||||||
{% if top_level_elements %}
|
{% if top_level_elements %}
|
||||||
<div class="list">
|
<div class="elementTree">
|
||||||
{% for el in top_level_elements %}
|
{% for el in top_level_elements %}
|
||||||
<a class="listRow {{ 'selected' if el.id == selected_id else '' }}" id="listrow-{{ el.id }}"
|
{{ render_tree_node(el) }}
|
||||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}">
|
|
||||||
<div class="rowTitle">{{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}</div>
|
|
||||||
<div class="rowSub">{{ (el.content or '—')[:30] }}</div>
|
|
||||||
</a>
|
|
||||||
{% if el.widget in container_widgets and children_of.get(el.id) %}
|
|
||||||
<div class="childList">
|
|
||||||
{% for child in children_of.get(el.id) %}
|
|
||||||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
|
||||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
|
||||||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
|
||||||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu contextuel (clic droit sur un élément de l'arborescence) -->
|
||||||
|
<div id="elementContextMenu" class="contextMenu" style="display:none;">
|
||||||
|
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Centre : l'écran, en très grand -->
|
<!-- Centre : l'écran, en très grand -->
|
||||||
<div class="builderCanvasArea">
|
<div class="builderCanvasArea">
|
||||||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||||||
@@ -156,23 +161,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if el.widget in container_widgets %}
|
{% if el.widget in container_widgets %}
|
||||||
<details class="propGroup containerContentGroup" open>
|
<details class="propGroup containerContentGroup">
|
||||||
<summary>📥 Ajouter DANS ce conteneur</summary>
|
<summary>📥 Ajouter DANS ce conteneur</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
{% set children = children_of.get(el.id, []) %}
|
|
||||||
{% if children %}
|
|
||||||
<div class="list">
|
|
||||||
{% for child in children %}
|
|
||||||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
|
||||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
|
||||||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
|
||||||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="hint">Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.</p>
|
|
||||||
{% endif %}
|
|
||||||
<div class="widgetGrid">
|
<div class="widgetGrid">
|
||||||
{% for wkey, w in widgets.items() %}
|
{% for wkey, w in widgets.items() %}
|
||||||
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.id) }}" class="widgetBtnForm">
|
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.id) }}" class="widgetBtnForm">
|
||||||
@@ -193,7 +184,7 @@
|
|||||||
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
||||||
<details class="propGroup" open>
|
<details class="propGroup">
|
||||||
<summary>Position & taille</summary>
|
<summary>Position & taille</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
<div class="posGrid">
|
<div class="posGrid">
|
||||||
@@ -236,7 +227,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<details class="propGroup" {{ 'open' if not ns.started else '' }}>
|
<details class="propGroup">
|
||||||
<summary>{{ c.group or 'Propriétés' }}</summary>
|
<summary>{{ c.group or 'Propriétés' }}</summary>
|
||||||
<div class="propGroupBody">
|
<div class="propGroupBody">
|
||||||
{% set ns.last_group = c.group %}
|
{% set ns.last_group = c.group %}
|
||||||
@@ -587,6 +578,53 @@ function saveGeometry(id, x, y, width, height) {
|
|||||||
|
|
||||||
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
|
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
|
||||||
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
|
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
|
||||||
|
var GAME_SLUG = "{{ game.slug }}";
|
||||||
|
|
||||||
|
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
|
||||||
|
//
|
||||||
|
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
|
||||||
|
// actuel (même parent, ou même écran si posé directement dessus) — voir
|
||||||
|
// screens/elements/duplicate_element.py. La duplication passe par un vrai
|
||||||
|
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
|
||||||
|
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
|
||||||
|
// nous-mêmes ici.
|
||||||
|
var contextMenuElementId = null;
|
||||||
|
|
||||||
|
function showElementContextMenu(event, elementId) {
|
||||||
|
event.preventDefault();
|
||||||
|
contextMenuElementId = elementId;
|
||||||
|
var menu = document.getElementById('elementContextMenu');
|
||||||
|
menu.style.left = event.pageX + 'px';
|
||||||
|
menu.style.top = event.pageY + 'px';
|
||||||
|
menu.style.display = 'block';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideElementContextMenu() {
|
||||||
|
var menu = document.getElementById('elementContextMenu');
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
contextMenuElementId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextMenuDuplicate() {
|
||||||
|
var id = contextMenuElementId;
|
||||||
|
hideElementContextMenu();
|
||||||
|
if (!id) return;
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'post';
|
||||||
|
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.__forgeContextMenuBound) {
|
||||||
|
window.__forgeContextMenuBound = true;
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var menu = document.getElementById('elementContextMenu');
|
||||||
|
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('scroll', hideElementContextMenu, true);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
||||||
//
|
//
|
||||||
@@ -636,9 +674,9 @@ function refreshCanvasOnly() {
|
|||||||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||||||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||||||
// focus/curseur pendant qu'on modifie un réglage.
|
// focus/curseur pendant qu'on modifie un réglage.
|
||||||
var newList = doc.querySelector('.builderPanel .list');
|
var newTree = doc.querySelector('.builderPanel .elementTree');
|
||||||
var curList = document.querySelector('.builderPanel .list');
|
var curTree = document.querySelector('.builderPanel .elementTree');
|
||||||
if (newList && curList) curList.innerHTML = newList.innerHTML;
|
if (newTree && curTree) curTree.innerHTML = newTree.innerHTML;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,70 @@ def test_image_gets_object_fit_cover_by_default(client, game):
|
|||||||
assert "object-fit:cover" in html
|
assert "object-fit:cover" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_top_level_element_creates_independent_copy(client, game):
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "titre"})
|
||||||
|
original_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
client.post(f"/game/{game}/elements/{original_id}/save", data={"ctrl_content": "Bienvenue"})
|
||||||
|
|
||||||
|
resp = client.post(f"/game/{game}/elements/{original_id}/duplicate", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
new_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
assert new_id != original_id
|
||||||
|
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||||
|
assert html.count("Bienvenue") == 2 # original + copie, chacun un élément séparé
|
||||||
|
|
||||||
|
# Modifier la copie ne doit pas toucher l'original (deux lignes indépendantes).
|
||||||
|
client.post(f"/game/{game}/elements/{new_id}/save", data={"ctrl_content": "Copie modifiée"})
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||||
|
assert "Bienvenue" in html
|
||||||
|
assert "Copie modifiée" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_container_deep_copies_children(client, game):
|
||||||
|
"""Régression : dupliquer un conteneur doit copier tout son sous-arbre
|
||||||
|
(pas juste la boîte vide) — voir screens/elements/duplicate_element.py,
|
||||||
|
qui réutilise le même parcours récursif que la copie d'écran-modèle."""
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||||
|
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
|
||||||
|
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Titre enfant"})
|
||||||
|
|
||||||
|
resp = client.post(f"/game/{game}/elements/{container_id}/duplicate", follow_redirects=False)
|
||||||
|
new_container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
assert new_container_id != container_id
|
||||||
|
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||||
|
assert html.count("Titre enfant") == 2 # l'enfant a bien été copié avec son parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_child_element_stays_in_same_container(client, game):
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||||
|
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
|
||||||
|
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
|
||||||
|
client.post(f"/game/{game}/elements/{child_id}/duplicate")
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||||
|
assert html.count("↳") == 0 # l'ancienne arborescence imbriquée dans "Ajouter DANS ce conteneur" a disparu
|
||||||
|
tree_html = html[html.find('class="elementTree"'):]
|
||||||
|
assert tree_html.count("treeChildren") >= 1 # le conteneur a bien 2 enfants imbriqués (le nouveau + l'original)
|
||||||
|
|
||||||
|
|
||||||
|
def test_screen_editor_property_panels_closed_by_default(client, game):
|
||||||
|
screen_id = _create_screen(client, game)
|
||||||
|
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||||
|
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||||
|
|
||||||
|
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
|
||||||
|
assert "<details class=\"propGroup\" open>" not in html
|
||||||
|
assert "Un conteneur est sélectionné" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_delete_element_type_removes_hidden_template_screen(client, game):
|
def test_delete_element_type_removes_hidden_template_screen(client, game):
|
||||||
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
|
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
|
||||||
html = client.get(f"/game/{game}/element-types").data.decode()
|
html = client.get(f"/game/{game}/element-types").data.decode()
|
||||||
|
|||||||
Reference in New Issue
Block a user