Le sélecteur de champ apparaît maintenant sur tout nouveau widget d'un élément de jeu lié à un objet

Le tour précédent exigeait que CHAQUE widget règle sa propre "Donnée
liée" (_data_definition_id) pour voir apparaître le sélecteur de champ -
mais un élément de jeu ("Mail card"...) créé avec un "Objet lié" (voir
element_types.html, bound_definition_id) a précisément pour but d'éviter
ce réglage widget par widget : ses {{champ}} sont censés venir de CET
objet, fourni plus tard par le Répéteur qui l'utilisera comme modèle de
ligne. D'où le bug remonté : un nouveau Titre/Texte posé dans un tel
élément de jeu n'affichait jamais le sélecteur.

Ajoute get_element_type_by_template_screen(slug, screen_id), pour
retrouver depuis l'éditeur d'un écran-modèle l'entrée du catalogue (et
donc l'objet lié) dont il est la recette. screen_edit.py le calcule pour
le panneau de propriétés et le passe à controls_with_values(), qui
l'utilise comme repli pour le champ "Contenu" SEULEMENT si ce widget n'a
pas déjà sa propre "Donnée liée" réglée (priorité conservée au réglage le
plus spécifique).

Ajoute tests/test_element_type_bound_field_picker.py (apparition sans
réglage supplémentaire, absence sans objet lié, priorité à la "Donnée
liée" du widget si réglée).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 13:38:10 +02:00
co-authored by Claude Sonnet 5
parent 7e80ff952a
commit 9c9e3c5379
5 changed files with 121 additions and 6 deletions
+6 -1
View File
@@ -22,7 +22,12 @@ def screen_edit(slug, screen_id):
if selected_id: if selected_id:
selected_el = next((e for e in elements if e["id"] == selected_id), None) selected_el = next((e for e in elements if e["id"] == selected_id), None)
if selected_el: if selected_el:
selected_controls = screens.controls_with_values(slug, selected_el) template_definition_id = None
if screen.get("is_template"):
owning_type = screens.get_element_type_by_template_screen(slug, screen_id)
if owning_type:
template_definition_id = owning_type.get("bound_definition_id")
selected_controls = screens.controls_with_values(slug, selected_el, template_definition_id)
children_of = {} children_of = {}
for el in elements: for el in elements:
if el.get("parent_id"): if el.get("parent_id"):
+1
View File
@@ -85,6 +85,7 @@ from .elements.rename_element import rename_element
from .elements.element_label import element_label from .elements.element_label import element_label
from .element_types.list_element_types import list_element_types from .element_types.list_element_types import list_element_types
from .element_types.get_element_type_by_template_screen import get_element_type_by_template_screen
from .element_types.create_element_type import create_element_type from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_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.element_type_usage_count import element_type_usage_count
@@ -0,0 +1,16 @@
import db
from ..screens_repo.ensure_schema import ensure_schema
def get_element_type_by_template_screen(slug, screen_id):
"""L'entrée du catalogue (voir _element_types) dont l'écran-modèle EST
cet écran, s'il y en a une — utile pour retrouver, depuis l'éditeur
d'un écran-modèle, l'objet éventuellement lié à cet élément de jeu
(bound_definition_id, réglé à la création — voir element_types.html)
sans devoir tout lister soi-même."""
ensure_schema(slug)
conn = db.connect(slug)
row = conn.execute("SELECT * FROM _element_types WHERE template_screen_id = ?", (screen_id,)).fetchone()
conn.close()
return dict(row) if row else None
+22 -5
View File
@@ -15,11 +15,21 @@ def _field_label(f):
return f["name"] return f["name"]
def controls_with_values(slug, el): def controls_with_values(slug, el, template_definition_id=None):
"""Pour le panneau de propriétés : la liste des réglages du widget de """Pour le panneau de propriétés : la liste des réglages du widget de
cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage cet élément, chacun avec sa valeur actuelle déjà résolue. Le réglage
"définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu "définition_id" du Répéteur de données (et de la Jauge) a besoin du jeu
courant pour lister les objets disponibles — d'où le paramètre slug.""" courant pour lister les objets disponibles — d'où le paramètre slug.
`template_definition_id` : l'objet lié à l'ÉLÉMENT DE JEU en cours
d'édition (bound_definition_id, réglé à sa création — voir
element_types.html), passé par screen_edit.py seulement quand on
édite un écran-modèle qui en a un. Sert de repli pour le champ
"Contenu" (voir plus bas) : si CE widget n'a pas sa propre "Donnée
liée" réglée, ses {{champ}} font quand même référence à l'objet lié
de l'élément de jeu (c'est lui qui fournira les valeurs une fois posé
dans un Répéteur), donc le sélecteur de champ doit lister SES champs
par défaut plutôt que de rester vide."""
meta = widget_meta(el.get("widget")) meta = widget_meta(el.get("widget"))
attributes = el.get("attributes") or {} attributes = el.get("attributes") or {}
# Pour la Jauge : quel enregistrement suivre (row_id) et quels champs # Pour la Jauge : quel enregistrement suivre (row_id) et quels champs
@@ -36,6 +46,9 @@ def controls_with_values(slug, el):
# en même temps, mais un calcul dédié quand même pour rester correct si # en même temps, mais un calcul dédié quand même pour rester correct si
# ça change un jour. # ça change un jour.
data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id")) data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id"))
# Repli pour "content" (voir plus bas) quand ce widget n'a pas sa
# propre "Donnée liée" : les champs de l'objet lié à l'élément de jeu.
template_bound_options = data_definition_options(slug, template_definition_id) if template_definition_id else None
# Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) : elle # Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) : elle
# aussi garde SON objet dans un attribut séparé (_visibilite_cond_ # aussi garde SON objet dans un attribut séparé (_visibilite_cond_
# definition_id), jamais partagé avec le Répéteur/la Jauge/Donnée liée. # definition_id), jamais partagé avec le Répéteur/la Jauge/Donnée liée.
@@ -67,12 +80,16 @@ def controls_with_values(slug, el):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]] control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]]
if control["key"] in ("data_filtre_champ", "data_filtre2_champ"): if control["key"] in ("data_filtre_champ", "data_filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]] control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["key"] == "content" and attributes.get("_data_definition_id"): if control["key"] == "content":
# Une fois "Lier à un objet de données" réglé (voir # Une fois "Lier à un objet de données" réglé (voir
# DATA_BINDING_CONTROLS), propose les champs de CET objet en # DATA_BINDING_CONTROLS), propose les champs de CET objet en
# liste déroulante à côté du texte — plutôt que de devoir taper # liste déroulante à côté du texte — plutôt que de devoir taper
# "{{nom_du_champ}}" à la main sans savoir quels noms existent # "{{nom_du_champ}}" à la main sans savoir quels noms existent
# (voir insertFieldAtCursor() dans screen_edit.html). # (voir insertFieldAtCursor() dans screen_edit.html). À défaut,
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]] # retombe sur l'objet lié à l'élément de jeu (voir docstring).
if attributes.get("_data_definition_id"):
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
elif template_bound_options:
control["field_options"] = [(f["name"], _field_label(f)) for f in template_bound_options["fields"]]
result.append({**control, "value": _control_value(el, control)}) result.append({**control, "value": _control_value(el, control)})
return result return result
@@ -0,0 +1,76 @@
"""Sélecteur de champ du panneau de propriétés ("+ Ajouter" à côté de
"Contenu", voir insertFieldAtCursor() dans screen_edit.html) : doit
apparaître dès qu'un objet est disponible dans le contexte, pas seulement
quand le widget a lui-même une "Donnée liée" réglée."""
import re
import screens
def _create_email_object(client, slug):
resp = client.post(f"/game/{slug}/objects/new", data={
"object_name": "Email", "field_name[]": ["sujet"], "field_type[]": ["texte"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
return int(resp.headers["Location"].rstrip("/").split("/")[-1])
def test_new_widget_inside_a_bound_element_type_gets_the_field_picker_without_extra_setup(client, game):
"""Régression : une "Mail card" (élément de jeu créé avec "Objet lié" =
Email) doit proposer le sélecteur de champ sur tout nouveau Titre/Texte
posé dans son écran-modèle, sans qu'il faille en plus régler "Lier à un
objet de données" sur CE widget précis — l'objet lié à l'élément de jeu
suffit."""
def_id = _create_email_object(client, game)
resp = client.post(f"/game/{game}/element-types", data={
"name": "Mail card", "icon": "📧", "bound_definition_id": str(def_id),
}, follow_redirects=False)
assert resp.status_code == 302
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card")
template_screen_id = et["template_screen_id"]
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
assert 'id="fieldPicker-content"' in html
assert '<option value="sujet">' in html
def test_field_picker_absent_when_element_type_has_no_bound_object(client, game):
resp = client.post(f"/game/{game}/element-types", data={"name": "Carte simple", "icon": "🃏"}, follow_redirects=False)
assert resp.status_code == 302
et = next(t for t in screens.list_element_types(game) if t["name"] == "Carte simple")
template_screen_id = et["template_screen_id"]
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
assert 'id="fieldPicker-content"' not in html
def test_widget_own_data_binding_still_takes_priority_over_element_type_bound_object(client, game):
"""Non-régression : si le widget a SA PROPRE "Donnée liée" réglée (sur
un objet différent de celui de l'élément de jeu), c'est elle qui doit
fournir les champs du sélecteur, pas l'objet lié de l'élément de jeu."""
email_id = _create_email_object(client, game)
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Partie", "field_name[]": ["score"], "field_type[]": ["nombre_entier"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
partie_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
resp = client.post(f"/game/{game}/element-types", data={
"name": "Mail card 2", "icon": "📧", "bound_definition_id": str(email_id),
}, follow_redirects=False)
et = next(t for t in screens.list_element_types(game) if t["name"] == "Mail card 2")
template_screen_id = et["template_screen_id"]
resp = client.post(f"/game/{game}/screens/{template_screen_id}/elements/add", data={"widget": "titre"}, follow_redirects=False)
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{element_id}/save", data={"ctrl_data_definition_id": str(partie_id)})
html = client.get(f"/game/{game}/screens/{template_screen_id}/edit?selected={element_id}").data.decode()
assert '<option value="score">' in html
assert '<option value="sujet">' not in html