Expose relation fields in the champ pickers and fix their column resolution

data_definition_options() used to exclude relation-type fields from its field list entirely, so a filter/binding could never reference "the linked object" of a row — and even when a relation field's clean name was typed manually (as the earlier "level.parcour" example needed), it silently matched nothing: every column lookup for a filter/repeater field used the field's own name, but a relation is actually stored in a "<field>_id" column (see create_definition.py), so the lookup always missed.

Relation fields now appear in the champ dropdowns (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) labeled with the object they point to (e.g. "parcour (→ parcours)"), and a new _field_column() helper in filter_repeater_rows.py resolves the right "<field>_id" column whenever the field turns out to be a relation — used consistently by the filter comparison itself, the "{{Objet.champ}}" dynamic-value resolver, the repeater's row content ({{champ}}), and the Donnée liée row context. Jauge's own champ/champ_nom pickers (which need an actual displayable value, not an id) still exclude relations, both server- and client-side.

Verified end to end: the dropdown shows the relation field with its target-object label, and filtering "level" rows by the clean relation field name "parcour" (not "parcour_id") against a dynamic {{game.current_parcours}} reference now actually matches, alongside the existing "number" filter. Full suite green (89).
This commit is contained in:
william
2026-08-25 06:48:04 +02:00
parent 343732d7aa
commit aa3be503ce
6 changed files with 66 additions and 15 deletions
+15 -2
View File
@@ -10,6 +10,19 @@ import db
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$") _REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
def _field_column(definition, field_name):
"""Nom de la vraie colonne SQL d'un champ — un champ "relation" est
stocké dans une colonne "<champ>_id" (voir create_definition.py), jamais
sous son nom "propre" : sans ce cas particulier, filtrer ou comparer sur
un champ relation ne trouvait jamais la bonne colonne et ne matchait
donc jamais rien."""
col = db.slugify(field_name).replace("-", "_")
field = next((f for f in definition["fields"] if f["name"] == field_name), None)
if field and field["type"] == "relation":
col += "_id"
return col
def _resolve_filter_value(slug, raw_value): def _resolve_filter_value(slug, raw_value):
"""Si raw_value est une référence "{{Objet.champ}}", va lire la valeur """Si raw_value est une référence "{{Objet.champ}}", va lire la valeur
ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par ACTUELLE de ce champ sur la ligne la plus récente de cet objet (par
@@ -29,7 +42,7 @@ def _resolve_filter_value(slug, raw_value):
full = db.get_definition(slug, d["id"]) full = db.get_definition(slug, d["id"])
rows = db.list_rows(slug, full) rows = db.list_rows(slug, full)
if rows: if rows:
return rows[0].get(db.slugify(field_name).replace("-", "_")) return rows[0].get(_field_column(full, field_name))
return None return None
return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter return None # objet de référence introuvable — filtre ne matchera rien plutôt que planter
@@ -81,7 +94,7 @@ def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
return rows return rows
expected = _resolve_filter_value(slug, raw_value or "") expected = _resolve_filter_value(slug, raw_value or "")
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte") field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = db.slugify(field_name).replace("-", "_") col = _field_column(definition, field_name)
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)] return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
+2 -2
View File
@@ -5,7 +5,7 @@ import db
from .style_string import _style_string from .style_string import _style_string
from .auto_id import _auto_id from .auto_id import _auto_id
from .apply_ctx import _apply_ctx from .apply_ctx import _apply_ctx
from .filter_repeater_rows import filter_repeater_rows from .filter_repeater_rows import filter_repeater_rows, _field_column
from ..element_types.load_template_tree import _load_template_tree from ..element_types.load_template_tree import _load_template_tree
@@ -32,7 +32,7 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
# qu'on sait que l'objet est valide.) # qu'on sait que l'objet est valide.)
rows = db.list_rows(slug, definition) rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, attrs_raw) rows = filter_repeater_rows(slug, definition, rows, attrs_raw)
field_cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in definition["fields"]} field_cols = {f["name"]: _field_column(definition, f["name"]) for f in definition["fields"]}
# Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) : # Modèle de ligne = un élément de jeu (conteneur stylisé réutilisable) :
# on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié # on recharge son arbre EN DIRECT à chaque ligne de données (jamais copié
+2 -2
View File
@@ -1,6 +1,6 @@
import db import db
from .filter_repeater_rows import filter_repeater_rows from .filter_repeater_rows import filter_repeater_rows, _field_column
def _resolve_bound_row_ctx(slug, attrs): def _resolve_bound_row_ctx(slug, attrs):
@@ -35,4 +35,4 @@ def _resolve_bound_row_ctx(slug, attrs):
if not rows: if not rows:
return None return None
row = rows[0] row = rows[0]
return {f["name"]: str(row.get(db.slugify(f["name"]).replace("-", "_"), "") or "") for f in definition["fields"]} return {f["name"]: str(row.get(_field_column(definition, f["name"]), "") or "") for f in definition["fields"]}
+19 -5
View File
@@ -6,6 +6,15 @@ from .data_definition_options import data_definition_options
from ..element_types.list_element_types import list_element_types from ..element_types.list_element_types import list_element_types
def _field_label(f):
"""Libellé affiché dans une liste déroulante de champs — précise l'objet
visé pour un champ "relation" (ex. "parcour (→ parcours)"), pour qu'on
sache ce qu'on choisit sans avoir à aller vérifier dans l'objet."""
if f["type"] == "relation" and f.get("relation_definition_name"):
return f"{f['name']} (→ {f['relation_definition_name']})"
return f["name"]
def controls_with_values(slug, el): def controls_with_values(slug, el):
"""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
@@ -38,11 +47,16 @@ def controls_with_values(slug, el):
control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types] control["options"] = [("", "(aucun — utiliser le modèle de texte ci-dessous)")] + [(str(t["id"]), t["name"]) for t in types]
if control["key"] == "row_id": if control["key"] == "row_id":
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(str(r["id"]), r["label"]) for r in jauge_options["rows"]] control["options"] = [("", "(sélectionner un objet d'abord)")] + [(str(r["id"]), r["label"]) for r in jauge_options["rows"]]
if control["key"] in ("champ", "filtre_champ", "filtre2_champ"): if control["key"] in ("champ", "champ_nom"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], f["name"]) for f in jauge_options["fields"]] # La Jauge affiche une VALEUR (champ) ou un NOM (champ_nom) tel
if control["key"] == "champ_nom": # quel — un champ "relation" n'y a pas de sens (c'est un id, pas
control["options"] = [("", "— aucun —")] + [(f["name"], f["name"]) for f in jauge_options["fields"]] # une valeur à afficher), donc exclu ici uniquement.
non_relation_fields = [f for f in jauge_options["fields"] if f["type"] != "relation"]
empty_label = "— aucun —" if control["key"] == "champ_nom" else "(sélectionner un objet d'abord)"
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"): if control["key"] in ("data_filtre_champ", "data_filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], f["name"]) 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"]]
result.append({**control, "value": _control_value(el, control)}) result.append({**control, "value": _control_value(el, control)})
return result return result
+14 -1
View File
@@ -23,5 +23,18 @@ def data_definition_options(slug, definition_id):
{"id": r["id"], "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}"} {"id": r["id"], "label": f"{r.get(display_col)} (#{r['id']})" if display_col else f"Ligne #{r['id']}"}
for r in rows for r in rows
] ]
field_options = [{"name": f["name"], "type": f["type"]} for f in full["fields"] if f["type"] != "relation"] # Les champs "relation" sont INCLUS (contrairement à avant) : un filtre
# de Répéteur/Donnée liée doit pouvoir comparer, par exemple, "le
# parcours lié" d'un niveau — ils restent identifiables via leur "type"
# pour les endroits qui n'en veulent pas (ex: le champ numérique suivi
# par une Jauge, voir controls_with_values.py). relation_definition_name
# (le nom de l'objet visé) sert uniquement à afficher un libellé clair
# dans la liste déroulante, ex. "parcour (→ parcours)".
definitions_by_id = {d["id"]: d["name"] for d in db.list_definitions(slug)}
field_options = []
for f in full["fields"]:
entry = {"name": f["name"], "type": f["type"]}
if f["type"] == "relation":
entry["relation_definition_name"] = definitions_by_id.get(f.get("relation_definition_id"))
field_options.append(entry)
return {"rows": row_options, "fields": field_options} return {"rows": row_options, "fields": field_options}
+14 -3
View File
@@ -1481,6 +1481,13 @@ function initBuilderPanel() {
// est déclaré plus bas dans ce script (donc encore "undefined" au moment où // est déclaré plus bas dans ce script (donc encore "undefined" au moment où
// initBuilderPanel() tourne en tout premier) — sans conséquence ici, // initBuilderPanel() tourne en tout premier) — sans conséquence ici,
// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind. // puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind.
// Libellé d'un champ dans une liste déroulante — précise l'objet visé pour
// un champ "relation" (ex. "parcour (→ parcours)"), même règle que
// _field_label() côté Python (controls_with_values.py).
function fieldOptionLabel(f) {
return (f.type === 'relation' && f.relation_definition_name) ? (f.name + ' (→ ' + f.relation_definition_name + ')') : f.name;
}
function bindJaugeDefinitionSelect() { function bindJaugeDefinitionSelect() {
var defSel = document.getElementById('field-definition_id'); var defSel = document.getElementById('field-definition_id');
var rowSel = document.getElementById('field-row_id'); var rowSel = document.getElementById('field-row_id');
@@ -1492,7 +1499,11 @@ function bindJaugeDefinitionSelect() {
rowSel.innerHTML = info.rows.length rowSel.innerHTML = info.rows.length
? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('') ? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="">Aucune donnée dans cet objet</option>'; : '<option value="">Aucune donnée dans cet objet</option>';
var fieldOptions = info.fields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join(''); // La Jauge affiche une valeur/un nom tel quel — un champ "relation" (un
// id, pas une valeur) n'a pas sa place ici, même exclusion que côté
// serveur (voir controls_with_values.py).
var simpleFields = info.fields.filter(function (f) { return f.type !== 'relation'; });
var fieldOptions = simpleFields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join('');
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>'; champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions; champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
}); });
@@ -1512,7 +1523,7 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
defSel.addEventListener('change', function () { defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] }; var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' + var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join(''); info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
fieldSels.forEach(function (sel) { sel.innerHTML = opts; }); fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
}); });
} }
@@ -1540,7 +1551,7 @@ function _filterValueDefinitionId(objName) {
function populateFilterValueFields(fieldSel, defId, selectedName) { function populateFilterValueFields(fieldSel, defId, selectedName) {
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] }; var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) { fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + f.name + '</option>'; return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + fieldOptionLabel(f) + '</option>';
}).join(''); }).join('');
} }