Turn free-typed field/object names into dropdowns in the Répéteur and Donnée liée filters
Two settings previously required typing an exact field or object name by hand:
- "champ" (Répéteur's filtre_champ/filtre2_champ, Donnée liée's data_filtre_champ/data_filtre2_champ) is now a dropdown of the actual fields on the object already chosen for that widget — pre-populated server-side (controls_with_values.py) and live-updated client-side without a reload when you change the object (bindDefinitionFieldSelects(), generalizing the existing Jauge champ/row_id pattern).
- "valeur" (the comparison value, which could already reference another object's field via a "{{Objet.champ}}" string typed by hand) is now a "Valeur fixe" / "Donnée d'un autre objet" toggle — the dynamic mode shows an object dropdown and a dependent field dropdown, and picking from them reconstructs that internal reference string automatically (initFilterValuePickers()/updateFilterValueFromDynamic()). The underlying stored value and _resolve_filter_value's parsing are unchanged, so existing elements using the old typed syntax still load correctly and populate the pickers on open.
Guards against writing a literal "{{...}}" pair directly in the Jinja template source (caught by a real render error while building this — Jinja parses {{/}} anywhere in the file, including inside comments) by building that syntax from split string literals in JS instead.
Verified end to end: repeater panel renders the pickers, saving with a dynamic reference filters correctly against live data (current_level changing which row shows), and reloading pre-selects the right object/field in the dropdowns. Full suite green (89).
This commit is contained in:
@@ -13,14 +13,14 @@ from ...flow.constants import CONDITION_OPERATORS
|
|||||||
DATA_BINDING_CONTROLS = [
|
DATA_BINDING_CONTROLS = [
|
||||||
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
|
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"),
|
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
|
||||||
c_text("data_filtre_champ", "Trouver la ligne où ce champ",
|
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
|
||||||
"attr:_data_filtre_champ", placeholder="ex. number", default="", group="Donnée liée"),
|
"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_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, ou {{NomObjet.champ}} pour une donnée qui change en cours de partie",
|
c_text("data_filtre_valeur", "...cette valeur",
|
||||||
"attr:_data_filtre_valeur", placeholder="ex. 3 ou {{game.current_level}}", default="", group="Donnée liée"),
|
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
|
||||||
c_text("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
|
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
|
||||||
"attr:_data_filtre2_champ", placeholder="ex. parcour_id", default="", group="Donnée liée"),
|
"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_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, ou {{NomObjet.champ}}",
|
c_text("data_filtre2_valeur", "...cette valeur",
|
||||||
"attr:_data_filtre2_valeur", placeholder="ex. 1 ou {{game.current_parcours}}", default="", group="Donnée liée"),
|
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ def controls_with_values(slug, el):
|
|||||||
# dans screen_edit.html), ceci ne couvre que le premier affichage/une
|
# dans screen_edit.html), ceci ne couvre que le premier affichage/une
|
||||||
# navigation vers un élément déjà réglé.
|
# navigation vers un élément déjà réglé.
|
||||||
jauge_options = data_definition_options(slug, attributes.get("_definition_id"))
|
jauge_options = data_definition_options(slug, attributes.get("_definition_id"))
|
||||||
|
# Le Texte/Titre lié à une donnée (voir DATA_BINDING_CONTROLS) garde SON
|
||||||
|
# objet dans un attribut séparé (_data_definition_id) — jamais le même
|
||||||
|
# élément que le Répéteur/la Jauge, donc jamais besoin des deux options
|
||||||
|
# en même temps, mais un calcul dédié quand même pour rester correct si
|
||||||
|
# ça change un jour.
|
||||||
|
data_binding_options = data_definition_options(slug, attributes.get("_data_definition_id"))
|
||||||
result = []
|
result = []
|
||||||
for control in meta.get("controls", []):
|
for control in meta.get("controls", []):
|
||||||
control = dict(control)
|
control = dict(control)
|
||||||
@@ -32,9 +38,11 @@ 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"] == "champ":
|
if control["key"] in ("champ", "filtre_champ", "filtre2_champ"):
|
||||||
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], f["name"]) for f in jauge_options["fields"]]
|
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], f["name"]) for f in jauge_options["fields"]]
|
||||||
if control["key"] == "champ_nom":
|
if control["key"] == "champ_nom":
|
||||||
control["options"] = [("", "— aucun —")] + [(f["name"], f["name"]) for f in jauge_options["fields"]]
|
control["options"] = [("", "— aucun —")] + [(f["name"], f["name"]) 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"], f["name"]) 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
|
||||||
|
|||||||
@@ -240,16 +240,16 @@ WIDGETS = {
|
|||||||
c_textarea("content", "Modèle d'affichage texte — utilise {{nom_du_champ}}", "content", default="{{nom}}", group="Contenu"),
|
c_textarea("content", "Modèle d'affichage texte — utilise {{nom_du_champ}}", "content", default="{{nom}}", group="Contenu"),
|
||||||
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
|
c_color("color", "Couleur du texte", "style:color", "#e8eaf0", group="Texte"),
|
||||||
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"),
|
c_slider("font-size", "Taille du texte", "style:font-size", min=10, max=40, default=16, group="Texte"),
|
||||||
c_text("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ",
|
c_select("filtre_champ", "Filtrer : n'afficher que les lignes où ce champ",
|
||||||
"attr:_filtre_champ", placeholder="ex. niveau", default="", group="Filtre"),
|
"attr:_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Filtre"),
|
||||||
c_select("filtre_operateur", "...est", "attr:_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
|
c_select("filtre_operateur", "...est", "attr:_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
|
||||||
c_text("filtre_valeur", "...cette valeur, ou {{NomObjet.champ}} pour comparer à une donnée qui change en cours de partie",
|
c_text("filtre_valeur", "...cette valeur",
|
||||||
"attr:_filtre_valeur", placeholder="ex. 3 ou {{Partie.niveau_courant}}", default="", group="Filtre"),
|
"attr:_filtre_valeur", placeholder="ex. 3", default="", group="Filtre"),
|
||||||
c_text("filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
|
c_select("filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
|
||||||
"attr:_filtre2_champ", placeholder="ex. parcour_id", default="", group="Filtre"),
|
"attr:_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Filtre"),
|
||||||
c_select("filtre2_operateur", "...est", "attr:_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
|
c_select("filtre2_operateur", "...est", "attr:_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Filtre"),
|
||||||
c_text("filtre2_valeur", "...cette valeur, ou {{NomObjet.champ}}",
|
c_text("filtre2_valeur", "...cette valeur",
|
||||||
"attr:_filtre2_valeur", placeholder="ex. 1 ou {{Partie.parcours_courant}}", default="", group="Filtre"),
|
"attr:_filtre2_valeur", placeholder="ex. 1", default="", group="Filtre"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"jauge": {
|
"jauge": {
|
||||||
|
|||||||
@@ -380,6 +380,41 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="hidden" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value or c.default }}">
|
<input type="hidden" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value or c.default }}">
|
||||||
</div>
|
</div>
|
||||||
|
{% elif c.key.endswith('_valeur') %}
|
||||||
|
<!-- Valeur de comparaison d'un filtre — soit une valeur fixe
|
||||||
|
tapée à la main, soit une donnée d'un AUTRE objet qui change
|
||||||
|
en cours de partie (ex: le niveau courant), choisie via deux
|
||||||
|
listes déroulantes plutôt que tapée à la main sous la forme
|
||||||
|
interne attendue — voir initFilterValuePickers() et
|
||||||
|
updateFilterValueFromDynamic() plus bas, qui reconstruisent
|
||||||
|
cette syntaxe en coulisses dans le champ caché ci-dessous,
|
||||||
|
seul réellement envoyé au serveur. -->
|
||||||
|
<div class="control filterValueControl" data-current-value="{{ c.value }}">
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select class="filterValueMode" onchange="onFilterValueModeChange(this)">
|
||||||
|
<option value="fixe">Valeur fixe</option>
|
||||||
|
<option value="dynamique">Donnée d'un autre objet (change en jeu)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filterValueFixe" style="margin-top:6px;">
|
||||||
|
<input class="input is-small" type="text" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="filterValueDynamique" style="display:none; margin-top:6px;">
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select class="filterValueObjSel" onchange="onFilterValueObjChange(this)">
|
||||||
|
<option value="">— objet —</option>
|
||||||
|
{% for d in definitions %}
|
||||||
|
<option value="{{ d.name }}" data-definition-id="{{ d.id }}">{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="select is-small is-fullwidth" style="margin-top:6px;">
|
||||||
|
<select class="filterValueFieldSel" onchange="onFilterValueFieldChange(this)">
|
||||||
|
<option value="">— champ —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% elif c.type in ('text', 'url') %}
|
{% elif c.type in ('text', 'url') %}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input class="input {{ 'uploadTextField' if c.allow_upload else '' }}" type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" id="field-{{ c.key }}">
|
<input class="input {{ 'uploadTextField' if c.allow_upload else '' }}" type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" id="field-{{ c.key }}">
|
||||||
@@ -1429,6 +1464,9 @@ function initBuilderPanel() {
|
|||||||
|
|
||||||
bindPropsAutosave();
|
bindPropsAutosave();
|
||||||
bindJaugeDefinitionSelect();
|
bindJaugeDefinitionSelect();
|
||||||
|
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
|
||||||
|
bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']);
|
||||||
|
initFilterValuePickers();
|
||||||
restoreTreeCollapsedState();
|
restoreTreeCollapsedState();
|
||||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||||
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
|
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
|
||||||
@@ -1460,6 +1498,103 @@ function bindJaugeDefinitionSelect() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Réglages "champ" d'un filtre (Répéteur/Donnée liée) : listes
|
||||||
|
// déroulantes peuplées avec les VRAIS champs de l'objet choisi, jamais
|
||||||
|
// tapées à la main — même principe que bindJaugeDefinitionSelect() ci-dessus,
|
||||||
|
// généralisé pour s'appliquer à un nombre quelconque de listes "champ"
|
||||||
|
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
|
||||||
|
// ou data_filtre_champ + data_filtre2_champ). ----------
|
||||||
|
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||||
|
var defSel = document.getElementById(defSelId);
|
||||||
|
if (!defSel) return;
|
||||||
|
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
|
||||||
|
if (!fieldSels.length) return;
|
||||||
|
defSel.addEventListener('change', function () {
|
||||||
|
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||||
|
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('');
|
||||||
|
fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
|
||||||
|
// objet ----------
|
||||||
|
//
|
||||||
|
// La valeur envoyée au serveur reste une simple chaîne "texte" (voir
|
||||||
|
// _resolve_filter_value côté Python) — soit une valeur fixe, soit une
|
||||||
|
// référence à un champ d'un autre objet dans un format interne précis. Le
|
||||||
|
// mode "dynamique" ci-dessous se contente de reconstruire cette chaîne à
|
||||||
|
// partir de deux listes déroulantes (objet, champ) plutôt que de la faire
|
||||||
|
// taper à la main. OPEN/CLOSE évitent d'écrire les deux accolades l'une
|
||||||
|
// contre l'autre dans le source (Jinja les interpréterait comme le DÉBUT
|
||||||
|
// d'une expression s'il les rencontrait telles quelles).
|
||||||
|
var _FILTER_REF_OPEN = '{' + '{';
|
||||||
|
var _FILTER_REF_CLOSE = '}' + '}';
|
||||||
|
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
|
||||||
|
|
||||||
|
function _filterValueDefinitionId(objName) {
|
||||||
|
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||||
|
return opt ? opt.dataset.definitionId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||||
|
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||||
|
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
|
||||||
|
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + f.name + '</option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterValueFromDynamic(wrap) {
|
||||||
|
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||||
|
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||||
|
var input = wrap.querySelector('.filterValueFixe input');
|
||||||
|
input.value = (objSel.value && fieldSel.value) ? (_FILTER_REF_OPEN + objSel.value + '.' + fieldSel.value + _FILTER_REF_CLOSE) : '';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterValueModeChange(sel) {
|
||||||
|
var wrap = sel.closest('.filterValueControl');
|
||||||
|
var fixe = wrap.querySelector('.filterValueFixe');
|
||||||
|
var dyn = wrap.querySelector('.filterValueDynamique');
|
||||||
|
if (sel.value === 'dynamique') {
|
||||||
|
fixe.style.display = 'none';
|
||||||
|
dyn.style.display = 'block';
|
||||||
|
updateFilterValueFromDynamic(wrap);
|
||||||
|
} else {
|
||||||
|
fixe.style.display = 'block';
|
||||||
|
dyn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterValueObjChange(sel) {
|
||||||
|
var wrap = sel.closest('.filterValueControl');
|
||||||
|
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
|
||||||
|
updateFilterValueFromDynamic(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterValueFieldChange(sel) {
|
||||||
|
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFilterValuePickers() {
|
||||||
|
document.querySelectorAll('.filterValueControl').forEach(function (wrap) {
|
||||||
|
var modeSel = wrap.querySelector('.filterValueMode');
|
||||||
|
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||||
|
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||||
|
var current = (wrap.dataset.currentValue || '').trim();
|
||||||
|
var m = _FILTER_REF_RE.exec(current);
|
||||||
|
if (m) {
|
||||||
|
modeSel.value = 'dynamique';
|
||||||
|
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||||
|
wrap.querySelector('.filterValueDynamique').style.display = 'block';
|
||||||
|
objSel.value = m[1].trim();
|
||||||
|
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), m[2].trim());
|
||||||
|
} else {
|
||||||
|
modeSel.value = 'fixe';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
|
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
|
||||||
// charge par pjax.js (navigation globale sans rechargement, voir
|
// charge par pjax.js (navigation globale sans rechargement, voir
|
||||||
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
|
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
|
||||||
|
|||||||
Reference in New Issue
Block a user