Ajoute "Variable globale" au sélecteur "Valeur fixe / Donnée d'un autre objet"
Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre
du Répéteur, Donnée liée, condition de visibilité) proposait déjà une
valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de
comparer à une variable globale (voir db/global_vars/), qui change elle
aussi en cours de partie mais n'est rattachée à aucun objet précis.
Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans
ambiguïté de "{{Objet.champ}}", qui attend toujours un point) :
_resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle
via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un
champ d'objet. Le panneau de propriétés gagne un troisième mode
"Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet",
avec la liste déroulante des variables existantes.
Corrige au passage un bug latent découvert en testant bout en bout : un
Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en
mode jouable avec TypeError - render_repeater.py substitue lui aussi
directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte
aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis
l'ajout de la condition de visibilité - déjà corrigé pour le chemin
générique (apply_ctx.py) mais pas pour ce chemin séparé.
Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans
le même message) existe déjà depuis un tour précédent (voir
insertFieldAtCursor()) - vérifié toujours fonctionnel.
Ajoute tests/test_filter_value_global_variable.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6894c5fc95
commit
7d48445443
@@ -9,6 +9,12 @@ import db
|
||||
# d'un Répéteur, voir _apply_ctx / applyOpenRowBindings côté JS).
|
||||
_REF_PATTERN = re.compile(r"^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$")
|
||||
|
||||
# {{$nom_variable}} : référence à une VARIABLE GLOBALE (voir db/global_vars/)
|
||||
# — le "$" (jamais utilisé dans un nom d'objet/de champ) distingue sans
|
||||
# ambiguïté cette syntaxe de _REF_PATTERN ci-dessus, qui, elle, attend
|
||||
# toujours un point ("Objet.champ").
|
||||
_VAR_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
|
||||
@@ -29,11 +35,19 @@ def _resolve_filter_value(slug, raw_value):
|
||||
convention, un objet utilisé comme "état de partie" — niveau courant,
|
||||
outils débloqués... — ne garde qu'une seule ligne, mise à jour en place
|
||||
par des actions "Modifier une donnée" plutôt que d'en créer une
|
||||
nouvelle à chaque fois). Sinon, renvoie raw_value tel quel (valeur
|
||||
fixe)."""
|
||||
nouvelle à chaque fois). Si c'est une référence "{{$nom_variable}}", va
|
||||
lire la valeur ACTUELLE de cette variable globale (voir
|
||||
db/global_vars/). Sinon, renvoie raw_value tel quel (valeur fixe)."""
|
||||
if not raw_value:
|
||||
return raw_value
|
||||
m = _REF_PATTERN.match(raw_value.strip())
|
||||
stripped = raw_value.strip()
|
||||
|
||||
var_match = _VAR_REF_PATTERN.match(stripped)
|
||||
if var_match:
|
||||
variable = db.get_global_variable(slug, var_match.group(1).strip())
|
||||
return variable.get("value") if variable else None # variable supprimée depuis : ne matche rien plutôt que planter
|
||||
|
||||
m = _REF_PATTERN.match(stripped)
|
||||
if not m:
|
||||
return raw_value
|
||||
object_name, field_name = m.group(1).strip(), m.group(2).strip()
|
||||
|
||||
@@ -59,6 +59,13 @@ def _render_repeater(el, meta, slug, children_map, ctx=None, parent_flex_directi
|
||||
else:
|
||||
line = template
|
||||
for fname, val in row_ctx.items():
|
||||
# "_forge_play_mode" (voir render_element_html.py) est un
|
||||
# indicateur interne posé dans ce même ctx, pas une valeur
|
||||
# de champ {{...}} — à ignorer ici (même garde que
|
||||
# apply_ctx.py, qui gère le même ctx pour le chemin
|
||||
# générique).
|
||||
if fname == "_forge_play_mode":
|
||||
continue
|
||||
line = line.replace("{{" + fname + "}}", val)
|
||||
inner = html_lib.escape(line)
|
||||
# data-row-id : identifiant STABLE de la vraie ligne de données
|
||||
|
||||
+47
-12
@@ -684,6 +684,7 @@
|
||||
<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>
|
||||
<option value="variable">Variable globale (change en jeu)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filterValueFixe" style="margin-top:6px;">
|
||||
@@ -704,6 +705,19 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filterValueVariable" style="display:none; margin-top:6px;">
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select class="filterValueVarSel" onchange="onFilterValueVarChange(this)">
|
||||
<option value="">— variable —</option>
|
||||
{% for v in global_variables %}
|
||||
<option value="{{ v.name }}">{{ v.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if not global_variables %}
|
||||
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_variables', slug=game.slug) }}" target="_blank">crées-en une ↗</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif c.type in ('text', 'url') %}
|
||||
<div class="control">
|
||||
@@ -1700,6 +1714,11 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
var _FILTER_REF_OPEN = '{' + '{';
|
||||
var _FILTER_REF_CLOSE = '}' + '}';
|
||||
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
|
||||
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
||||
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
||||
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ").
|
||||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}]+)\\s*\\}\\}$');
|
||||
|
||||
function _filterValueDefinitionId(objName) {
|
||||
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||
@@ -1721,18 +1740,23 @@ function updateFilterValueFromDynamic(wrap) {
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function updateFilterValueFromVariable(wrap) {
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.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';
|
||||
}
|
||||
var varWrap = wrap.querySelector('.filterValueVariable');
|
||||
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
|
||||
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
|
||||
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
|
||||
if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap);
|
||||
else if (sel.value === 'variable') updateFilterValueFromVariable(wrap);
|
||||
}
|
||||
|
||||
function onFilterValueObjChange(sel) {
|
||||
@@ -1745,19 +1769,30 @@ function onFilterValueFieldChange(sel) {
|
||||
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
|
||||
}
|
||||
|
||||
function onFilterValueVarChange(sel) {
|
||||
updateFilterValueFromVariable(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 varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var current = (wrap.dataset.currentValue || '').trim();
|
||||
var m = _FILTER_REF_RE.exec(current);
|
||||
if (m) {
|
||||
var varMatch = _VAR_REF_RE.exec(current);
|
||||
var objMatch = _FILTER_REF_RE.exec(current);
|
||||
if (varMatch) {
|
||||
modeSel.value = 'variable';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
wrap.querySelector('.filterValueVariable').style.display = 'block';
|
||||
varSel.value = varMatch[1].trim();
|
||||
} else if (objMatch) {
|
||||
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());
|
||||
objSel.value = objMatch[1].trim();
|
||||
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim());
|
||||
} else {
|
||||
modeSel.value = 'fixe';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Le sélecteur "Valeur fixe / Donnée d'un autre objet" (tout contrôle
|
||||
"..._valeur", voir screen_edit.html) propose désormais aussi "Variable
|
||||
globale" — syntaxe interne "{{$nom_variable}}" (voir _VAR_REF_PATTERN,
|
||||
filter_repeater_rows.py), au même titre que "{{Objet.champ}}" pour un
|
||||
objet."""
|
||||
import re
|
||||
|
||||
import db
|
||||
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_niveau_object(client, slug):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "3"})
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "5"})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_repeater_filter_resolves_a_global_variable_reference(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$niveau_actuel}}",
|
||||
})
|
||||
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 1
|
||||
|
||||
db.update_global_variable_value(game, "niveau_actuel", "5")
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 1
|
||||
|
||||
|
||||
def test_deleted_variable_reference_matches_nothing_rather_than_crashing(client, game):
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id),
|
||||
"ctrl_filtre_champ": "numero",
|
||||
"ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{$inexistante}}",
|
||||
})
|
||||
payload = client.get(f"/game/{game}/runtime-payload").get_json()
|
||||
html = payload["screens"][0]["elements"][0]["rendered_html"]
|
||||
assert html.count("repeaterItem") == 0
|
||||
|
||||
|
||||
def test_variable_picker_appears_in_the_properties_panel(client, game):
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "niveau_actuel", "type": "nombre_entier", "default_value": "3"})
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id)})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={rep_id}").data.decode()
|
||||
assert "filterValueVarSel" in html
|
||||
assert '<option value="niveau_actuel">niveau_actuel</option>' in html
|
||||
|
||||
|
||||
def test_repeater_without_template_still_renders_in_play_mode(client, game):
|
||||
"""Non-régression : un Répéteur sans modèle de ligne (texte brut avec
|
||||
{{champ}}) plantait en mode jouable avec TypeError: replace() argument
|
||||
2 must be str, not bool - le ctx porte aussi _forge_play_mode (voir
|
||||
render_element_html.py), un booléen jamais destiné à une substitution
|
||||
{{champ}}."""
|
||||
def_id = _create_niveau_object(client, game)
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"}, follow_redirects=False)
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{rep_id}/save", data={"ctrl_definition_id": str(def_id), "ctrl_content": "Numéro : {{numero}}"})
|
||||
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
assert resp.status_code == 200
|
||||
assert b"Num\xc3\xa9ro : 3" in resp.data
|
||||
Reference in New Issue
Block a user