Donnée liée : conditions ET/OU illimitées + conditions de logique sur une variable globale

Deux fonctionnalités demandées, développées et corrigées dans cet
échange :

1. "Donnée liée" (Texte/Titre) : le réglage à 2 filtres fixes (toujours
   combinés en ET) devient une liste de conditions ILLIMITÉE, avec un
   choix ET/OU pour les combiner (screens/widgets/controls/c_clause_list.py,
   screens/clause_list_codec.py). Rétrocompatible avec les anciens
   éléments (_data_filtre_champ/_data_filtre2_champ), convertis à la
   volée à la lecture, sans migration. Après un premier essai à la
   présentation trop compacte et technique (retour utilisateur : "pas de
   champ technique, pas de notation bizarre {{ }}"), la présentation
   finale reprend EXACTEMENT l'ancien style (labels "Champ"/"...est"/
   "...cette valeur", même sélecteur de valeur fixe/dynamique/variable
   déjà existant, jamais la syntaxe brute), simplement répétée par
   condition (templates/partials/clause_row.html), avec un bouton
   "+ Ajouter une condition" bien visible et une liste scrollable
   (static/style.css, .clauseListWrap). Le même moteur (filter_repeater_
   rows.py généralisé) profite aussi au Répéteur de données en interne.

2. Nœud Condition de la Logique de la scène : peut désormais tester une
   VARIABLE GLOBALE en plus d'un champ d'objet (cond_source/cond_variable/
   cond_variable_chemin — screens/flow/ensure_flow_schema.py), sur la
   clause principale ET chaque clause supplémentaire (ET/OU). Évalué côté
   CLIENT (templates/play.html, evaluateConditionClause), contre un
   nouveau gameData.variables exposé par full_game_payload.py — tenu à
   jour par refreshRuntimeData() après toute action qui modifie une
   variable, sans changement supplémentaire nécessaire. Le panneau de
   condition reste utilisable même sans aucun objet défini dans le jeu
   (avant, il disparaissait entièrement).

Vérifié : 153 tests pytest (nouveaux : test_data_binding_clause_list.py,
test_condition_variable.py) + logique JS d'évaluation des conditions
vérifiée isolément avec Node (variable scalaire, objet avec chemin
chaîné, tableau par index, variable introuvable, booléen, rétrocompatibilité
legacy) + rendu des deux pages (éditeur/jeu) vérifié sur le vrai projet
"test" en plus des jeux de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-28 09:05:02 +02:00
co-authored by Claude Sonnet 5
parent 7c237d6f1c
commit 8cffbeac68
21 changed files with 971 additions and 93 deletions
+45
View File
@@ -0,0 +1,45 @@
import json
# Partagé par control_value.py (widgets) et filter_repeater_rows.py
# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne
# jamais introduire de dépendance circulaire entre ces deux packages.
def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None):
"""Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un
attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres
fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et
legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est
absent — pour ne rien casser sur les éléments créés avant l'ajout des
clauses illimitées (aucune migration de données nécessaire, la
conversion se fait à la volée, ici, à la lecture). Renvoie toujours
(clauses, combinateur), combinateur valant "et" par défaut."""
raw = attrs.get(clauses_attr)
clauses = None
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
clauses = parsed
except (TypeError, ValueError):
clauses = None
if clauses is None and legacy_prefix:
clauses = []
for suffix in ("", "2"):
champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip()
if champ:
clauses.append({
"champ": champ,
"operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal",
"valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "",
})
combinateur = attrs.get(combinator_attr) or "et"
return clauses or [], combinateur
def encode_clauses(clauses):
"""Sérialise une liste de clauses en JSON, en retirant celles sans nom
de champ (une ligne ajoutée puis jamais remplie) — "" si la liste
nettoyée est vide, pour ne rien écrire de superflu en base."""
cleaned = [c for c in clauses if (c.get("champ") or "").strip()]
return json.dumps(cleaned) if cleaned else ""
+10
View File
@@ -6,6 +6,7 @@ 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.is_overlay_only import _is_overlay_only_element_type
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
@@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
# de dialogue" (render_element_html.py décide alors, au rendu, de
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
# démarre masqué par défaut, exactement comme une superposition
# posée directement (voir default_style_for_widget.py) — sinon un
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
# sa pose, avant même qu'une action ne l'affiche.
style["display"] = "none"
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
+27
View File
@@ -4,6 +4,7 @@ import db
from ..widgets.widget_meta import widget_meta
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from ..clause_list_codec import encode_clauses
from .get_element import get_element
@@ -53,6 +54,32 @@ def save_element_controls(slug, element_id, form):
toggle_groups[name].add(control["token"])
continue
if ctype == "clause_list":
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
# "_valeur[]"), reconstituées ici en une liste de clauses par
# position (zip) — même principe que field_name[]/field_type[]
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
champs = form.getlist(field_name + "_champ[]")
operateurs = form.getlist(field_name + "_operateur[]")
valeurs = form.getlist(field_name + "_valeur[]")
clauses = [
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
"valeur": valeurs[i] if i < len(valeurs) else ""}
for i, champ in enumerate(champs)
]
encoded = encode_clauses(clauses)
if encoded:
attributes[control["clauses_attr"]] = encoded
else:
attributes.pop(control["clauses_attr"], None)
combinateur = form.get(field_name + "_combinateur") or "et"
if combinateur == "ou":
attributes[control["combinator_attr"]] = combinateur
else:
attributes.pop(control["combinator_attr"], None)
continue
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
+4
View File
@@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = {
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
# comment les combiner. Absents => comportement legacy (une seule clause).
"cond_clauses", "cond_combinator",
# Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir
# ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut
# "objet" ou "variable" ; absent => "objet", comportement historique).
"cond_source", "cond_variable", "cond_variable_chemin",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable",
+16
View File
@@ -56,5 +56,21 @@ def ensure_flow_schema(slug):
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
if "cond_source" not in cols:
# Un nœud Condition peut désormais tester soit un champ d'objet
# (comportement historique, cond_source absent ou "objet"), soit
# une VARIABLE GLOBALE (cond_source="variable" — cond_variable :
# son nom ; cond_variable_chemin : chemin optionnel ".champ"/
# "[index]" si elle est de type objet/tableau, même syntaxe que
# "Chemin dans la variable" de la Condition de visibilité — voir
# visibility_condition_controls.py). Chaque clause de cond_clauses
# (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté
# CLIENT (templates/play.html, evaluateConditionClause) contre
# gameData.variables, exposé par full_game_payload.py.
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT")
if "cond_variable" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT")
if "cond_variable_chemin" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT")
conn.commit()
conn.close()
+9
View File
@@ -67,7 +67,16 @@ def full_game_payload(slug):
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
# SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de
# ça. refreshRuntimeData() récupère un payload entier (donc des
# variables à jour) après toute action qui en modifie une.
variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"variables": variables,
}
+25 -13
View File
@@ -3,6 +3,8 @@ import re
import db
from ..clause_list_codec import decode_clauses
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
@@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type):
return False
def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
def _clause_matches(slug, definition, row, clause):
field_name = (clause.get("champ") or "").strip()
if not field_name:
return rows
expected = _resolve_filter_value(slug, raw_value or "")
return True
operator = clause.get("operateur") or "egal"
expected = _resolve_filter_value(slug, clause.get("valeur") or "")
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = _field_column(definition, field_name)
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
return _compare(row.get(col), operator, expected, field_type)
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
"""Ne garde, parmi les lignes, que celles qui satisfont la liste de
clauses données (un nombre quelconque), combinées entre elles par ET
(toutes vraies) ou par OU (au moins une vraie) selon `combinator`
("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
reconstruites depuis _filtre_champ/_filtre2_champ, voir
filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
Titre (clauses illimitées, voir resolve_bound_row.py)."""
if not clauses:
return rows
if combinator == "ou":
return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)]
return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)]
def filter_repeater_rows(slug, definition, rows, attrs):
@@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs):
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
par exemple, que le niveau du parcours en cours (2 conditions : le bon
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"),
)
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"),
)
return rows
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
+48 -15
View File
@@ -117,19 +117,35 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
# générique "conteneur" (add_element.py, "default_widget") — utile
# pour la plupart des modèles, mais QUAND le modèle entier n'est
# qu'une "Superposition / boîte de dialogue", cette enveloppe
# (fond, bordure, position normale sur le canevas — voir
# widgets/registry.py "conteneur") resterait visible EN PERMANENCE
# à l'endroit où l'exemplaire a été déposé, alors que la
# superposition à l'intérieur gère déjà entièrement sa propre
# apparence et son propre masquage (position:fixed plein écran,
# démarre masquée) — vécu comme "un conteneur vide apparaît sur la
# scène, pas la boîte de dialogue" (en réalité la boîte de dialogue
# existe bien, juste masquée comme prévu ; c'est le conteneur
# AUTOUR qui n'aurait jamais dû être visible). On court-circuite
# donc entièrement l'enveloppe et on renvoie directement le
# contenu du modèle.
return _mark(child_html)
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
# de ce widget (widgets/registry.py "conteneur") lui donnait un
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
# déposé, alors que la superposition à l'intérieur gère déjà
# entièrement sa propre apparence et son propre masquage
# (position:fixed plein écran, démarre masquée) — vécu comme "un
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
# (en réalité la boîte de dialogue existe bien, juste masquée comme
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
# propre apparence visuelle).
#
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
# précédent le faisait, en renvoyant `child_html` directement) :
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
# précis (id=el["id"], distinct de l'id interne du modèle,
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
# CET exemplaire est, par ex., 77) — une action "Modifier un
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
# partagé par tous les exemplaires, du modèle. La court-circuiter
# entièrement faisait disparaître cet id du DOM, rendant
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
# visibilité de la modale depuis la scène"). On garde donc la
# balise (avec son id), mais SANS la classe "box" du widget
# générique — juste une balise transparente, invisible tant
# qu'elle ne porte ni fond ni bordure."""
attrs.pop("class", None)
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()]
@@ -154,12 +170,29 @@ def _render_element_type_children(el, slug, ctx):
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
générique "conteneur" de l'exemplaire doit être court-circuitée)."""
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
apparence visuelle)."""
template_tree = _load_template_tree(slug, el["element_type_id"])
if not template_tree:
return None, False
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
if is_overlay_only:
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
# masquée par défaut, exactement comme la superposition elle-même)
# — la superposition INTERNE au modèle ne doit donc plus appliquer
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
# ailleurs (sinon double masquage : rendre l'enveloppe visible
# depuis la scène qui l'accueille resterait sans aucun effet
# visible tant que la superposition interne, elle, reste "Masqué"
# dans le modèle). On retire donc "display" de sa copie de style
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
# display:flex de base (render_overlay.py) s'applique alors.
overlay_root = dict(template_tree["top"][0])
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
template_tree["top"][0] = overlay_root
html = "".join(
render_element_html(c, slug, template_tree["children_map"], ctx)
for c in template_tree["top"]
)
return html, _is_overlay_only_element_type(slug, el["element_type_id"])
return html, is_overlay_only
+9 -11
View File
@@ -1,13 +1,17 @@
import db
from .filter_repeater_rows import filter_repeater_rows, _field_column
from .filter_repeater_rows import filter_rows_by_clauses, _field_column
from ..clause_list_codec import decode_clauses
def _resolve_bound_row_ctx(slug, attrs):
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
réglées (un nombre quelconque, combinées par ET/OU — attr:
_data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien
format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments
créés avant l'ajout des clauses illimitées) — même moteur que le
filtre du Répéteur de données, voir filter_repeater_rows.py — et
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
@@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs):
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, {
"_filtre_champ": attrs.get("_data_filtre_champ"),
"_filtre_operateur": attrs.get("_data_filtre_operateur"),
"_filtre_valeur": attrs.get("_data_filtre_valeur"),
"_filtre2_champ": attrs.get("_data_filtre2_champ"),
"_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
"_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
})
clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre")
rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
if not rows:
return None
row = rows[0]
@@ -1,26 +1,20 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ..controls.c_clause_list import c_clause_list
from ...flow.constants import CONDITION_OPERATORS
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux
# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU —
# mêmes clauses, même moteur que le Répéteur de données, voir
# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente
# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet
# "état de partie" qui ne garde qu'une seule ligne).
DATA_BINDING_CONTROLS = [
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
"attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre_valeur", "...cette valeur",
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
"attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre2_valeur", "...cette valeur",
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
c_clause_list("data_filtres", "Trouver la ligne où...",
clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur",
operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"),
]
+29 -1
View File
@@ -1,5 +1,7 @@
import re
from ..clause_list_codec import decode_clauses
def _control_value(el, control):
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
@@ -7,8 +9,34 @@ def _control_value(el, control):
ctype = control["type"]
style = el.get("style") or {}
if ctype == "clause_list":
attrs = el.get("attributes") or {}
clauses, combinateur = decode_clauses(
attrs, control["clauses_attr"], control["combinator_attr"],
legacy_prefix=control.get("legacy_prefix"),
)
return {"clauses": clauses, "combinateur": combinateur}
if ctype == "preset":
for key, _label, props in control["options"]:
# Trié par nombre de propriétés NON VIDES exigées, décroissant : une
# option comme "Visible normalement" (visibility_control.py), dont
# les critères ({"visibility": ""}) ne portent QUE sur "visibility"
# (par choix assumé, pour ne jamais toucher "display" — voir son
# commentaire), matchait sinon TOUJOURS en premier dès que
# "visibility" est absent, MÊME quand "display:none" (posé par
# "Masqué", un critère plus spécifique à 1 propriété non vide contre
# 0 pour "Visible") est également présent -- le panneau affichait
# alors "Visible normalement" sélectionné sur un élément en réalité
# masqué, et comme un <select> ne redéclenche un enregistrement que
# sur un VRAI changement de valeur, il devenait impossible de le
# rendre visible depuis le panneau (choisir l'option déjà affichée
# ne fait rien). Les options plus spécifiques (plus de critères non
# vides) doivent donc toujours être vérifiées EN PREMIER.
options = sorted(
control["options"],
key=lambda o: -sum(1 for v in o[2].values() if v),
)
for key, _label, props in options:
if all((style.get(k) or "") == (v or "") for k, v in props.items()):
return key
return control.get("default")
+18
View File
@@ -0,0 +1,18 @@
def c_clause_list(key, label, clauses_attr, combinator_attr, operators, legacy_prefix=None, group=None):
"""Réglage "liste de conditions" — un nombre ILLIMITÉ de clauses
(champ/opérateur/valeur), combinées entre elles par ET ou par OU (voir
aussi les nœuds Condition du graphe de logique, qui ont le même besoin
— screens/flow/constants.py, cond_clauses/cond_combinator — même
principe, appliqué ici à la Donnée liée d'un Texte/Titre).
Volontairement PAS de "target" (contrairement aux autres réglages) :
stocké dans DEUX attributs séparés (clauses_attr : JSON de la liste ;
combinator_attr : "et"/"ou"), traités par un code dédié à chaque étape
(control_value.py, save_element_controls.py, controls_with_values.py,
templates/screen_edit.html) plutôt que par le chemin générique "un
réglage = une seule valeur = une seule clé de style/attribut"."""
return {
"type": "clause_list", "key": key, "label": label,
"clauses_attr": clauses_attr, "combinator_attr": combinator_attr,
"operators": operators, "legacy_prefix": legacy_prefix, "default": None, "group": group,
}
+6 -2
View File
@@ -78,8 +78,12 @@ def controls_with_values(slug, el, template_definition_id=None):
control["options"] = [("", empty_label)] + [(f["name"], _field_label(f)) for f in non_relation_fields]
if control["key"] in ("filtre_champ", "filtre2_champ"):
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"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["type"] == "clause_list":
# Options du sélecteur "champ" de CHAQUE ligne de condition —
# les mêmes champs que data_filtre_champ ci-dessus (même objet
# lié, attr:_data_definition_id), exposées une seule fois ici
# plutôt que dupliquées par ligne (nombre de lignes variable).
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["key"] == "content":
# Une fois "Lier à un objet de données" réglé (voir
# DATA_BINDING_CONTROLS), propose les champs de CET objet en