Un exemplaire d'élément de jeu posé sur un écran reste maintenant lié à son modèle
Jusqu'ici, poser un élément de jeu depuis le catalogue ("Mes éléments de
jeu") copiait tout son arbre en base (instantiate_template_tree) : chaque
exemplaire devenait indépendant, y compris de son propre modèle - modifier
l'élément de jeu dans son éditeur n'avait plus aucun effet sur les
exemplaires déjà posés ailleurs.
Change ce comportement pour qu'un exemplaire reste TOUJOURS lié à son
modèle, sur le même principe déjà utilisé par un modèle de ligne de
Répéteur (jamais copié, rechargé en direct à chaque affichage - voir
_load_template_tree/_render_repeater) : add_element.py ne crée plus
qu'UNE SEULE ligne plate (avec sa position/taille propres à cet
exemplaire) au lieu de copier tout l'arbre, et render_element_html.py
recharge le contenu depuis l'écran-modèle à chaque rendu quand
element_type_id est réglé. Modifier l'élément de jeu dans son propre
éditeur met donc à jour tous ses exemplaires déjà posés, sur n'importe
quel écran (y compris ceux placés AVANT ce correctif, qui portaient déjà
element_type_id sur leur ligne de premier niveau), sans avoir à les
retoucher un par un.
Contrepartie assumée (discutée avec l'utilisateur avant ce changement) :
un exemplaire ne peut plus être personnalisé individuellement à
l'INTÉRIEUR (texte, couleur d'un enfant précis...) - seules sa position et
sa taille sur l'écran restent propres à chaque exemplaire. Pour changer le
contenu, il faut désormais passer par l'éditeur de l'élément de jeu
lui-même.
instantiate_template_tree.py, devenu inutilisé, est supprimé.
Ajoute tests/test_element_type_live_instances.py (mise à jour d'un
exemplaire déjà posé, propagation jusqu'à "Jouer", position toujours
indépendante par exemplaire) et met à jour un commentaire de test devenu
obsolète dans test_screens_and_elements.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
79fe04c6ac
commit
e9a991ed13
@@ -90,7 +90,6 @@ from .element_types.create_element_type import create_element_type
|
||||
from .element_types.update_element_type import update_element_type
|
||||
from .element_types.element_type_usage_count import element_type_usage_count
|
||||
from .element_types.delete_element_type import delete_element_type
|
||||
from .element_types.instantiate_template_tree import instantiate_template_tree
|
||||
|
||||
from .legacy_actions.list_actions import list_actions
|
||||
from .legacy_actions.add_action import add_action
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import db
|
||||
|
||||
|
||||
def instantiate_template_tree(slug, template_screen_id, target_screen_id, target_parent_id=None):
|
||||
"""Copie EN PROFONDEUR le contenu d'un écran-modèle vers un écran réel
|
||||
(ou à l'intérieur d'un conteneur d'un écran réel) : chaque élément copié
|
||||
devient une vraie ligne indépendante en base, modifiable séparément par
|
||||
la suite — exactement le comportement d'un élément de jeu "à l'ancienne"
|
||||
posé sur un écran. Renvoie la liste des ids créés au premier niveau."""
|
||||
conn = db.connect(slug)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY id", (template_screen_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
source = [dict(r) for r in rows]
|
||||
created_top_ids = []
|
||||
|
||||
def copy_one(src, new_parent_id):
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (target_screen_id,)
|
||||
).fetchone()["m"]
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, name, content, attributes, x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
target_screen_id, new_parent_id, src["widget"], src.get("name"),
|
||||
src["content"], src["attributes"], src["x"], src["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_id = copy_one(child, new_parent_id)
|
||||
walk(child["id"], new_id)
|
||||
|
||||
for t in [s for s in source if not s.get("parent_id")]:
|
||||
new_id = copy_one(t, target_parent_id)
|
||||
created_top_ids.append(new_id)
|
||||
walk(t["id"], new_id)
|
||||
|
||||
return created_top_ids
|
||||
@@ -6,7 +6,6 @@ from ..widgets.registry import WIDGETS
|
||||
from ..widgets.widget_meta import widget_meta
|
||||
from ..widgets.default_style_for_widget import default_style_and_attributes
|
||||
from ..element_types.list_element_types import list_element_types
|
||||
from ..element_types.instantiate_template_tree import instantiate_template_tree
|
||||
|
||||
|
||||
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
|
||||
@@ -16,30 +15,24 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
|
||||
alors plus (il suit le flux du parent), mais on garde des valeurs par
|
||||
défaut pour rester cohérent si jamais il en sort un jour.
|
||||
|
||||
element_type_id : si fourni ET que cet élément de jeu a un écran-modèle
|
||||
(le cas normal désormais — voir create_element_type), tout son arbre est
|
||||
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est
|
||||
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable
|
||||
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget
|
||||
unique avec un contenu par défaut."""
|
||||
element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
|
||||
comme n'importe quel widget), qui garde juste une référence vers cet
|
||||
élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
|
||||
copié en base : il est rechargé EN DIRECT à chaque affichage (voir
|
||||
render_element_html.py), exactement comme un modèle de ligne de
|
||||
Répéteur (voir _load_template_tree/_render_repeater). Modifier
|
||||
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
||||
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
||||
retoucher un par un — au prix de ne plus pouvoir personnaliser
|
||||
individuellement le CONTENU d'un exemplaire précis (sa position/taille
|
||||
sur l'écran, elles, restent propres à chaque exemplaire, comme
|
||||
d'habitude)."""
|
||||
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
|
||||
if element_type_id:
|
||||
types = {t["id"]: t for t in list_element_types(slug)}
|
||||
et = types.get(int(element_type_id))
|
||||
if et and et.get("template_screen_id"):
|
||||
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id)
|
||||
if created:
|
||||
conn = db.connect(slug)
|
||||
conn.execute(
|
||||
"UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return created[0]
|
||||
# Modèle vide (aucun élément dedans pour l'instant) : on retombe
|
||||
# sur l'ancien comportement plat ci-dessous, pour ne jamais
|
||||
# échouer silencieusement à poser quelque chose.
|
||||
|
||||
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
|
||||
if et:
|
||||
widget_key = et.get("default_widget") or widget_key
|
||||
meta = widget_meta(widget_key)
|
||||
# Réglages dont la valeur par défaut a un effet visuel voulu dès la
|
||||
# création (ex: une image démarre avec "cover" pour se recadrer
|
||||
|
||||
@@ -17,6 +17,7 @@ from .render_onglets import _render_onglets
|
||||
from .render_icone import _render_icone
|
||||
from .resolve_bound_row import _resolve_bound_row_ctx
|
||||
from .visibility_condition import is_element_visible
|
||||
from ..element_types.load_template_tree import _load_template_tree
|
||||
|
||||
|
||||
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
|
||||
@@ -106,7 +107,9 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
|
||||
|
||||
content = _apply_ctx(el.get("content") or "", ctx)
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
child_html = _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else None
|
||||
if child_html is None:
|
||||
child_html = _render_children(el, slug, children_map, ctx)
|
||||
|
||||
if tag in ("ul", "ol"):
|
||||
items = [line.strip() for line in content.split("\n") if line.strip()]
|
||||
@@ -115,3 +118,25 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
|
||||
|
||||
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
|
||||
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
|
||||
|
||||
|
||||
def _render_element_type_children(el, slug, ctx):
|
||||
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
|
||||
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
|
||||
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
|
||||
copiés en base — exactement le même principe qu'un modèle de ligne de
|
||||
Répéteur (voir _render_repeater/_load_template_tree). Modifier
|
||||
l'élément de jeu dans son propre éditeur met donc à jour tous ses
|
||||
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
|
||||
retoucher un par un. Renvoie None (pas "") si l'élément de jeu n'a
|
||||
plus de modèle valide, pour que l'appelant retombe sur le rendu
|
||||
générique (d'éventuels enfants en base issus d'une version antérieure
|
||||
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
|
||||
vide."""
|
||||
template_tree = _load_template_tree(slug, el["element_type_id"])
|
||||
if not template_tree:
|
||||
return None
|
||||
return "".join(
|
||||
render_element_html(c, slug, template_tree["children_map"], ctx)
|
||||
for c in template_tree["top"]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Un exemplaire d'élément de jeu posé directement sur un écran (catalogue)
|
||||
est désormais TOUJOURS lié à son modèle, comme un modèle de ligne de
|
||||
Répéteur (voir add_element.py, render_element_html.py) : modifier
|
||||
l'élément de jeu dans son propre éditeur met à jour tous ses exemplaires
|
||||
déjà posés, sans avoir à les retoucher un par un — au prix de ne plus
|
||||
pouvoir personnaliser individuellement le contenu d'un exemplaire précis."""
|
||||
import re
|
||||
|
||||
import screens
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _create_card_element_type(client, slug, name="Carte"):
|
||||
resp = client.post(f"/game/{slug}/element-types", data={"name": name, "icon": "🃏"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
et = next(t for t in screens.list_element_types(slug) if t["name"] == name)
|
||||
resp = client.post(f"/game/{slug}/screens/{et['template_screen_id']}/elements/add", data={"widget": "titre"}, follow_redirects=False)
|
||||
titre_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{titre_id}/save", data={"ctrl_content": "Version 1"})
|
||||
return et, titre_id
|
||||
|
||||
|
||||
def test_editing_the_template_updates_an_already_placed_instance(client, game):
|
||||
et, titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Version 1" in html
|
||||
|
||||
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Version 2"})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Version 2" in html
|
||||
assert "Version 1" not in html
|
||||
|
||||
|
||||
def test_editing_the_template_updates_the_play_page_too(client, game):
|
||||
et, titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Contenu mis à jour"})
|
||||
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert "Contenu mis à jour" in html
|
||||
|
||||
|
||||
def test_instance_position_on_screen_is_independent_per_instance(client, game):
|
||||
"""Non-régression : la position/taille sur l'écran reste propre à
|
||||
chaque exemplaire (seul le CONTENU intérieur est désormais partagé)."""
|
||||
et, _titre_id = _create_card_element_type(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
first_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
|
||||
follow_redirects=False,
|
||||
)
|
||||
second_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
first = screens.get_element(game, first_id)
|
||||
second = screens.get_element(game, second_id)
|
||||
assert (first["x"], first["y"]) != (second["x"], second["y"])
|
||||
@@ -120,7 +120,9 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object):
|
||||
|
||||
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
|
||||
|
||||
# ---- Case A : posé directement sur un écran réel -> copie indépendante ----
|
||||
# ---- Case A : posé directement sur un écran réel -> lié en direct au
|
||||
# modèle (voir add_element.py/render_element_html.py), mais {{sujet}}
|
||||
# reste non résolu hors Répéteur (pas de ligne de données courante) ----
|
||||
real_screen_id = _create_screen(client, slug, "Accueil")
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{real_screen_id}/elements/add",
|
||||
|
||||
Reference in New Issue
Block a user