diff --git a/screens/rendering/filter_repeater_rows.py b/screens/rendering/filter_repeater_rows.py
index a800acc2..314ac28f 100644
--- a/screens/rendering/filter_repeater_rows.py
+++ b/screens/rendering/filter_repeater_rows.py
@@ -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()
diff --git a/screens/rendering/render_repeater.py b/screens/rendering/render_repeater.py
index 7c295975..631cbb7e 100644
--- a/screens/rendering/render_repeater.py
+++ b/screens/rendering/render_repeater.py
@@ -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
diff --git a/templates/screen_edit.html b/templates/screen_edit.html
index d44ae71a..433d4da5 100644
--- a/templates/screen_edit.html
+++ b/templates/screen_edit.html
@@ -684,6 +684,7 @@
Valeur fixe
Donnée d'un autre objet (change en jeu)
+ Variable globale (change en jeu)
@@ -704,6 +705,19 @@
+
+
+
+ — variable —
+ {% for v in global_variables %}
+ {{ v.name }}
+ {% endfor %}
+
+
+ {% if not global_variables %}
+
Aucune variable globale pour l'instant — crées-en une ↗
+ {% endif %}
+
{% elif c.type in ('text', 'url') %}
@@ -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';
}
diff --git a/tests/test_filter_value_global_variable.py b/tests/test_filter_value_global_variable.py
new file mode 100644
index 00000000..da922f37
--- /dev/null
+++ b/tests/test_filter_value_global_variable.py
@@ -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 'niveau_actuel ' 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