Ajoute l'onglet "Données" au panneau objet, retire object_view.html

Suite de la demande : le panneau "Modifier un objet" (crayon ✏️, onglet
Objets du tableau de bord) a maintenant 2 sous-onglets — "Champs" (déjà
en place) et "Données", qui reprend data_list.html + data_form.html
(retirés) : ajouter une entrée (une ligne compacte avec le bon type de
champ par colonne — texte/nombre/case à cocher/date/relation, comme
l'ancien formulaire), modifier/supprimer une entrée existante (tableau
dense, cellules éditables en ligne, même principe que "Champs
existants").

Sous-onglets scopés au panneau de LEUR objet (switchObjectPanelTab(),
classes .objectPanelTabs/.objectPanelTabPanel distinctes de .builderTabs/
.builderTabPanel) — plusieurs objets ont chacun leurs propres
sous-onglets indépendants sur la même page, sans jamais interférer avec
les onglets du tableau de bord lui-même.

routes/games/game_dashboard.py fournit maintenant, par objet : ses lignes
(rows_by_definition), les libellés lisibles de ses champs relation
(relation_labels_by_definition, pour l'affichage) et leurs options
(relation_options_by_definition, pour les <select>), ainsi que
referenced_by_definition (avertissement permanent si un autre objet a
une relation vers celui-ci — repris de l'ancien object_view.py, affiché
maintenant en continu plutôt qu'après une tentative de suppression
échouée).

data_form.py (partagé par data_new/data_edit), data_delete.py et
object_delete.py redirigent maintenant vers le tableau de bord
(?edit=<id>&subtab=data, +?blocked_row=<id> si la suppression d'une
entrée est bloquée par une relation) au lieu de object_view/object_edit.

Piège évité : de nombreux tests déduisent l'id d'un objet fraîchement
créé du DERNIER SEGMENT du chemin dans le header Location d'une
redirection (.../objects/<id>) — rediriger object_new directement vers
le tableau de bord (chemin sans id) cassait donc 33 tests d'un coup.
Fix : object_view.py reste en place, mais seulement comme redirecteur
(plus de page rendue) — object_new redirige toujours vers lui (chemin
qui se termine par l'id, donc les tests continuent de fonctionner), qui
redirige à son tour vers le tableau de bord.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-27 12:13:05 +02:00
co-authored by Claude Sonnet 5
parent b8dfdcb205
commit 0e390a679b
9 changed files with 305 additions and 337 deletions
+28
View File
@@ -22,6 +22,30 @@ def game_dashboard(slug):
# simplement montré/masqué en JS, plutôt qu'un aller-retour serveur à
# l'ouverture (voir openObjectEditPanel()).
definitions_full = {d["id"]: db.get_definition(slug, d["id"]) for d in definitions}
# Pour l'onglet "Données" de ce même panneau (remplace data_list.html/
# object_view.py, retirés) : les lignes déjà enregistrées de chaque
# objet, les libellés lisibles de ses champs relation (pour l'affichage
# du tableau) et les options possibles de ces mêmes champs relation
# (pour le formulaire "Ajouter une entrée").
rows_by_definition = {}
relation_labels_by_definition = {}
relation_options_by_definition = {}
referenced_by_definition = {}
for d in definitions:
full = definitions_full[d["id"]]
rows_by_definition[d["id"]] = db.list_rows(slug, full)
relation_labels = {}
relation_options = {}
for f in full["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
relation_options[f["id"]] = options
relation_labels_by_definition[d["id"]] = relation_labels
relation_options_by_definition[d["id"]] = relation_options
referenced_by_definition[d["id"]] = db.definitions_referencing(slug, d["id"])
screen_list = screens.list_screens(slug)
element_types = screens.list_element_types(slug)
element_type_usage = {t["id"]: screens.element_type_usage_count(slug, t["id"]) for t in element_types}
@@ -29,6 +53,10 @@ def game_dashboard(slug):
return render_template(
"game_dashboard.html", game=game, definitions=definitions,
definitions_full=definitions_full,
rows_by_definition=rows_by_definition,
relation_labels_by_definition=relation_labels_by_definition,
relation_options_by_definition=relation_options_by_definition,
referenced_by_definition=referenced_by_definition,
screen_list=screen_list, element_types=element_types,
element_type_usage=element_type_usage, variables=variables,
variable_types=db.GLOBAL_VARIABLE_TYPES, field_types=db.FIELD_TYPES,
+5 -4
View File
@@ -13,8 +13,9 @@ def data_delete(slug, definition_id, row_id):
referencing = db.rows_referencing(slug, definition_id, row_id)
if referencing:
# Suppression bloquée : d'autres lignes pointent vers celle-ci via
# une relation. On revient à la liste, qui explique pourquoi via un
# paramètre de requête simple plutôt qu'une session/flash message.
return redirect(url_for("object_view", slug=slug, definition_id=definition_id, blocked_row=row_id))
# une relation. On revient au panneau de cet objet (onglet
# Données), qui explique pourquoi via un paramètre de requête
# simple plutôt qu'une session/flash message.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data", blocked_row=row_id))
db.delete_row(slug, definition, row_id)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
+9 -17
View File
@@ -1,32 +1,24 @@
from flask import render_template, request, redirect, url_for, abort
from flask import request, redirect, url_for, abort
import db
def _data_form(slug, definition_id, row):
game = db.game_meta(slug)
"""Ajouter/modifier une entrée se fait désormais depuis l'onglet
"Données" du panneau déplaçable de son objet (voir game_dashboard.html,
ouvert par le crayon ✏️ de l'onglet Objets) plutôt qu'une page dédiée
(data_form.html, retirée) — cette fonction ne fait donc plus que
traiter la soumission de ce formulaire. Un GET direct redirige vers le
tableau de bord, en rouvrant le panneau de cet objet sur son onglet
Données."""
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
# Pour chaque champ de type relation, on prépare les options du <select>
# à partir des lignes déjà enregistrées de l'objet lié.
relation_options = {}
for f in definition["fields"]:
if f["type"] == "relation":
relation_options[f["id"]] = db.relation_options(slug, f["relation_definition_id"])
if request.method == "POST":
if row is None:
db.insert_row(slug, definition, request.form)
else:
db.update_row(slug, definition, row["id"], request.form)
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
return render_template(
"data_form.html",
game=game,
definition=definition,
relation_options=relation_options,
row=row,
)
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id, subtab="data"))
+6 -5
View File
@@ -10,9 +10,10 @@ def object_delete(slug, definition_id):
referenced_by = db.definitions_referencing(slug, definition_id)
if referenced_by:
# Suppression bloquée : d'autres objets ont une relation vers
# celui-ci. On revient à l'écran de l'objet, qui affiche déjà cet
# avertissement (referenced_by), plutôt que de casser ces relations
# silencieusement.
return redirect(url_for("object_view", slug=slug, definition_id=definition_id))
# celui-ci. On revient à son panneau, qui affiche déjà cet
# avertissement (referenced_by_definition, voir game_dashboard.py)
# en permanence, pas seulement après une tentative — plutôt que de
# casser ces relations silencieusement.
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
db.delete_definition(slug, definition_id)
return redirect(url_for("game_dashboard", slug=slug))
return redirect(url_for("game_dashboard", slug=slug, tab="objects"))
+12 -27
View File
@@ -1,33 +1,18 @@
from flask import render_template, request, abort
import db
from flask import redirect, url_for
from core.flask_app import app
@app.route("/game/<slug>/objects/<int:definition_id>")
def object_view(slug, definition_id):
game = db.game_meta(slug)
definition = db.get_definition(slug, definition_id)
if not definition:
abort(404)
rows = db.list_rows(slug, definition)
# Pour l'affichage : traduit chaque id de relation en un libellé lisible
# (ex. "Salle serveur (#1)") au lieu d'un identifiant numérique brut.
relation_labels = {}
for f in definition["fields"]:
if f["type"] == "relation":
options = db.relation_options(slug, f["relation_definition_id"])
relation_labels[f["id"]] = {opt["id"]: opt["label"] for opt in options}
referenced_by = db.definitions_referencing(slug, definition_id)
blocked_row_id = request.args.get("blocked_row", type=int)
blocked_row_refs = db.rows_referencing(slug, definition_id, blocked_row_id) if blocked_row_id else None
return render_template(
"data_list.html", game=game, definition=definition, rows=rows,
relation_labels=relation_labels, referenced_by=referenced_by,
blocked_row_id=blocked_row_id, blocked_row_refs=blocked_row_refs,
)
"""Ancienne page de visualisation des données d'un objet (data_list.html) —
retirée : les données vivent désormais dans l'onglet "Données" du
panneau déplaçable de cet objet (voir game_dashboard.html). Cette
route reste néanmoins en place, comme pur redirecteur, pour deux
raisons : un lien externe/ancien vers cette URL continue de mener
quelque part de sensé, et surtout beaucoup de tests (et certains
formulaires) déduisent l'id d'un objet fraîchement créé du DERNIER
segment du chemin renvoyé par le header Location d'une redirection
(ex. .../objects/<id>) — object_new redirige donc ICI (chemin qui se
termine par l'id), qui redirige ensuite vers le tableau de bord."""
return redirect(url_for("game_dashboard", slug=slug, tab="objects", edit=definition_id))
+14
View File
@@ -38,6 +38,11 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
.formField{ margin-bottom:6px; }
button{ background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:9px 14px; cursor:pointer; font-size:13px; }
/* Un <button> qui doit se comporter comme un <a> visuellement (ex. ouvrir
un panneau plutôt que suivre un href, mais garder l'apparence d'un lien
dans un tableau) — annule le style bouton par défaut ci-dessus. */
.linkLikeButton{ background:none; border:none; padding:0; color:var(--accent); text-decoration:none; font-size:inherit; }
.linkLikeButton:hover{ text-decoration:underline; }
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
button.smallBtn{ padding:5px 10px; font-size:11.5px; }
button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
@@ -438,6 +443,15 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
.builderTabPanel.active{ display:flex; }
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
objet" (voir game_dashboard.html) — classes distinctes de .builderTabs/
.builderTabBtn/.builderTabPanel (utilisées par le style, réutilisé tel
quel) pour que switchDashTab() (onglets du tableau de bord lui-même,
ciblage global) n'aille jamais les toucher par erreur. */
.objectPanelTabs{ margin:14px 0 10px; }
.objectPanelTabPanel{ display:none; flex-direction:column; }
.objectPanelTabPanel.active{ display:flex; }
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
-70
View File
@@ -1,70 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ 'Modifier' if row else 'Ajouter' }} — {{ definition.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content %}
<h1 class="title">{{ 'Modifier l\'entrée #' ~ row.id if row else 'Ajouter une entrée' }} — {{ definition.name }}</h1>
<form method="post" class="formPassthrough">
<div class="twoCol">
<div class="colLeft">
<h2>Objet</h2>
<div class="card">
<p class="hint">Formulaire généré automatiquement à partir de la définition de « {{ definition.name }} » — aucun champ n'est codé en dur ici.</p>
<div class="actionsRow">
<button type="submit" class="primary">{{ 'Enregistrer les modifications' if row else 'Enregistrer en base' }}</button>
</div>
<p class="hint" style="margin-top:10px;"><a href="{{ url_for('object_view', slug=game.slug, definition_id=definition.id) }}">← Retour à « {{ definition.name }} »</a></p>
</div>
</div>
<div class="colRight">
<h2>Champs</h2>
<div class="fieldsTableWrap" style="padding:10px;">
{% for f in definition.fields %}
{% set col = f.name|colname %}
{% set current = row[col] if row else none %}
<div class="field">
<label class="label" for="field_{{ f.id }}">{{ f.name }}{% if f.required %} *{% endif %}</label>
<div class="control">
{% if f.type == 'texte' %}
<input class="input" type="text" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'texte_long' %}
<textarea class="textarea" id="field_{{ f.id }}" name="{{ col }}" rows="4" {{ 'required' if f.required else '' }}>{{ current or '' }}</textarea>
{% elif f.type == 'nombre_entier' %}
<input class="input" type="number" step="1" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'nombre_decimal' %}
<input class="input" type="number" step="any" id="field_{{ f.id }}" name="{{ col }}" value="{{ current if current is not none else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" id="field_{{ f.id }}" name="{{ col }}" {{ 'checked' if current else '' }}> Oui</label>
{% elif f.type == 'date' %}
<input class="input" type="date" id="field_{{ f.id }}" name="{{ col }}" value="{{ current or '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'relation' %}
{% set current_rel = row[col ~ '_id'] if row else none %}
<div class="select is-fullwidth">
<select id="field_{{ f.id }}" name="{{ col }}_id" {{ 'required' if f.required else '' }}>
<option value="">—</option>
{% for opt in relation_options.get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if current_rel == opt.id else '' }}>{{ opt.label }}</option>
{% endfor %}
</select>
</div>
{% if not relation_options.get(f.id) %}
<p class="help">Aucune entrée n'existe encore pour l'objet lié — crée-en une d'abord si tu veux pouvoir la sélectionner ici.</p>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</form>
{% endblock %}
-124
View File
@@ -1,124 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ definition.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content %}
<h1 class="title">{{ definition.name }}
<a class="button is-small" href="{{ url_for('object_edit', slug=game.slug, definition_id=definition.id) }}" title="Modifier cet objet">✏️</a>
</h1>
<p class="hint mb-4">Table SQL : {{ definition.table_name }} — {{ definition.fields|length }} champ(s)</p>
{% if referenced_by %}
<div class="warningBanner">
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
{{ referenced_by|map(attribute='name')|join(', ') }}.
La suppression de cet objet est bloquée tant que ces relations existent.
</div>
{% endif %}
{% if blocked_row_id %}
<div class="warningBanner">
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée par
{% for ref in blocked_row_refs %}{{ ref.count }} entrée(s) de « {{ ref.definition_name }} » (champ « {{ ref.field_name }} »){% if not loop.last %}, {% endif %}{% endfor %}.
Modifie ou supprime d'abord ces entrées.
</div>
{% endif %}
<div class="twoCol">
<div class="colLeft">
<h2>Ajouter</h2>
<div class="card">
<p class="hint">Une entrée, c'est une ligne de la table SQL « {{ definition.table_name }} ».</p>
<div class="actionsRow">
<a class="button is-primary is-outlined is-fullwidth" href="{{ url_for('data_new', slug=game.slug, definition_id=definition.id) }}">+ Ajouter une entrée</a>
</div>
</div>
</div>
<div class="colRight">
<h2>Données enregistrées</h2>
{% if rows %}
{% macro row_field_display(f, r) %}
{%- if f.type == 'relation' -%}
{{ relation_labels.get(f.id, {}).get(r[(f.name|colname) ~ '_id'], '—') }}
{%- elif f.type == 'booleen' -%}
{{ 'Oui' if r[f.name|colname] else 'Non' }}
{%- else -%}
{{ r[f.name|colname] }}
{%- endif -%}
{% endmacro %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable dataTable fieldsTable">
<thead>
<tr>
<th>#</th>
{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
{% for f in definition.fields %}
<td>{{ row_field_display(f, r) }}</td>
{% endfor %}
<td>
<div class="buttons is-inline-flex are-small">
<button type="button" class="button is-small" title="Voir cette entrée en détail" onclick="openRowDetail({{ r.id }})">👁️</button>
<a class="button is-small" href="{{ url_for('data_edit', slug=game.slug, definition_id=definition.id, row_id=r.id) }}">✏️</a>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=definition.id, row_id=r.id) }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for r in rows %}
<div class="modal" id="rowDetail{{ r.id }}">
<div class="modal-background" onclick="closeRowDetail({{ r.id }})"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Entrée #{{ r.id }}</p>
<button class="delete" aria-label="Fermer" onclick="closeRowDetail({{ r.id }})"></button>
</header>
<section class="modal-card-body">
{% for f in definition.fields %}
<div class="rowDetailField">
<div class="rowDetailLabel">{{ f.name }}</div>
<div class="rowDetailValue">{{ row_field_display(f, r) }}</div>
</div>
{% endfor %}
</section>
</div>
</div>
{% endfor %}
{% else %}
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
{% endif %}
</div>
</div>
<script>
function openRowDetail(id) {
var modal = document.getElementById('rowDetail' + id);
if (modal) modal.classList.add('is-active');
}
function closeRowDetail(id) {
var modal = document.getElementById('rowDetail' + id);
if (modal) modal.classList.remove('is-active');
}
if (!window.__forgeRowDetailEscBound) {
window.__forgeRowDetailEscBound = true;
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
document.querySelectorAll('.modal.is-active').forEach(function (m) { m.classList.remove('is-active'); });
});
}
</script>
{% endblock %}
+231 -90
View File
@@ -75,7 +75,7 @@
<tbody>
{% for d in definitions %}
<tr>
<td><a href="{{ url_for('object_view', slug=game.slug, definition_id=d.id) }}">{{ d.name }}</a></td>
<td><button type="button" class="linkLikeButton" onclick="openObjectEditPanel({{ d.id }})">{{ d.name }}</button></td>
<td class="has-text-grey">{{ d.table_name }}</td>
<td class="fieldsTableActions">
<button type="button" class="button is-small" onclick="openObjectEditPanel({{ d.id }})" title="Modifier cet objet">✏️</button>
@@ -275,94 +275,214 @@
</form>
<p class="help mb-3">Table SQL : {{ definition.table_name }}</p>
<h2>Ajouter un champ</h2>
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=d.id) }}" class="dashCreateBar objectAddFieldBar">
<input type="text" name="field_name" placeholder="Nom du champ" required autocomplete="off">
<select name="field_type" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
<select name="field_relation" class="relationTarget" style="display:none;">
{% for od in existing_definitions %}
<option value="{{ od.id }}">{{ od.name }}</option>
{% endfor %}
</select>
<input type="number" step="any" name="field_min" placeholder="Mini" class="boundsTarget" style="display:none;">
<input type="number" step="any" name="field_max" placeholder="Maxi" class="boundsTarget" style="display:none;">
<select name="field_required">
<option value="0">Non obligatoire</option>
<option value="1">Obligatoire</option>
</select>
<button type="submit" class="primary">+ Ajouter</button>
</form>
<h2>Champs existants</h2>
{% if definition.fields %}
{% for f in definition.fields %}
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldEditForm{{ f.id }}"></form>
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldDeleteForm{{ f.id }}"
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr><th>Nom</th><th>Type</th><th>Objet lié</th><th>Mini</th><th>Maxi</th><th>Obligatoire</th><th></th></tr>
</thead>
<tbody>
{% for f in definition.fields %}
<tr>
<td><input class="input is-small" type="text" form="fieldEditForm{{ f.id }}" name="field_name" value="{{ f.name }}" aria-label="Nom du champ « {{ f.name }} »" required></td>
<td>{{ field_types.get(f.type, {}).get('label', f.type) }}</td>
<td>
{% if f.type == 'relation' %}
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_relation" aria-label="Objet lié pour « {{ f.name }} »">
{% for od in existing_definitions %}
<option value="{{ od.id }}" {{ 'selected' if od.id == f.relation_definition_id else '' }}>{{ od.name }}</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0" aria-label="Valeur mini pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100" aria-label="Valeur maxi pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_required" aria-label="« {{ f.name }} » obligatoire ?">
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
</select>
</div>
</td>
<td class="fieldsTableActions">
<button type="submit" form="fieldEditForm{{ f.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="fieldDeleteForm{{ f.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if referenced_by_definition.get(d.id) %}
<div class="warningBanner">
⚠️ D'autres objets ont une relation vers « {{ definition.name }} » :
{{ referenced_by_definition[d.id]|map(attribute='name')|join(', ') }}.
La suppression de cet objet est bloquée tant que ces relations existent.
</div>
<p class="help">Mini/Maxi (bornage automatique) : laisser vide = pas de limite.</p>
{% else %}
<p class="help">Aucun champ pour l'instant.</p>
{% endif %}
<!-- Sous-onglets Champs/Données de CE panneau — classes distinctes de
.builderTabs/.builderTabBtn/.builderTabPanel (onglets du tableau de
bord lui-même) pour ne jamais interférer avec eux : switchDashTab()
cible ces classes globalement, alors que switchObjectPanelTab()
reste scopé au panneau de CET objet (plusieurs objets = plusieurs
jeux de sous-onglets indépendants sur la même page). -->
<div class="builderTabs objectPanelTabs">
<button type="button" class="builderTabBtn active" data-optab="fields" onclick="switchObjectPanelTab(this, 'fields')">Champs <span class="dashTabCount">{{ definition.fields|length }}</span></button>
<button type="button" class="builderTabBtn" data-optab="data" onclick="switchObjectPanelTab(this, 'data')">Données <span class="dashTabCount">{{ rows_by_definition[d.id]|length }}</span></button>
</div>
<div class="objectPanelTabPanel active" data-optab-panel="fields">
<div class="dashPanelHeader">
<h2>Ajouter un champ</h2>
</div>
<form method="post" action="{{ url_for('object_field_add', slug=game.slug, definition_id=d.id) }}" class="dashCreateBar objectAddFieldBar">
<input type="text" name="field_name" placeholder="Nom du champ" required autocomplete="off">
<select name="field_type" class="fieldTypeSelect">
{% for key, val in field_types.items() %}
<option value="{{ key }}">{{ val.label }}</option>
{% endfor %}
</select>
<select name="field_relation" class="relationTarget" style="display:none;">
{% for od in existing_definitions %}
<option value="{{ od.id }}">{{ od.name }}</option>
{% endfor %}
</select>
<input type="number" step="any" name="field_min" placeholder="Mini" class="boundsTarget" style="display:none;">
<input type="number" step="any" name="field_max" placeholder="Maxi" class="boundsTarget" style="display:none;">
<select name="field_required">
<option value="0">Non obligatoire</option>
<option value="1">Obligatoire</option>
</select>
<button type="submit" class="primary">+ Ajouter</button>
</form>
<h2>Champs existants</h2>
{% if definition.fields %}
{% for f in definition.fields %}
<form method="post" action="{{ url_for('object_field_edit', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldEditForm{{ f.id }}"></form>
<form method="post" action="{{ url_for('object_field_delete', slug=game.slug, definition_id=d.id, field_id=f.id) }}" id="fieldDeleteForm{{ f.id }}"
onsubmit="return confirm('Retirer le champ « {{ f.name }} » ? Les données déjà enregistrées dans ce champ seront perdues.');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable fieldsTable">
<thead>
<tr><th>Nom</th><th>Type</th><th>Objet lié</th><th>Mini</th><th>Maxi</th><th>Obligatoire</th><th></th></tr>
</thead>
<tbody>
{% for f in definition.fields %}
<tr>
<td><input class="input is-small" type="text" form="fieldEditForm{{ f.id }}" name="field_name" value="{{ f.name }}" aria-label="Nom du champ « {{ f.name }} »" required></td>
<td>{{ field_types.get(f.type, {}).get('label', f.type) }}</td>
<td>
{% if f.type == 'relation' %}
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_relation" aria-label="Objet lié pour « {{ f.name }} »">
{% for od in existing_definitions %}
<option value="{{ od.id }}" {{ 'selected' if od.id == f.relation_definition_id else '' }}>{{ od.name }}</option>
{% endfor %}
</select>
</div>
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_min" value="{{ f.min_value if f.min_value is not none else '' }}" placeholder="ex. 0" aria-label="Valeur mini pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
{% if f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="any" form="fieldEditForm{{ f.id }}" name="field_max" value="{{ f.max_value if f.max_value is not none else '' }}" placeholder="ex. 100" aria-label="Valeur maxi pour « {{ f.name }} »">
{% else %}
<span class="has-text-grey">—</span>
{% endif %}
</td>
<td>
<div class="select is-small">
<select form="fieldEditForm{{ f.id }}" name="field_required" aria-label="« {{ f.name }} » obligatoire ?">
<option value="0" {{ 'selected' if not f.required else '' }}>Non</option>
<option value="1" {{ 'selected' if f.required else '' }}>Oui</option>
</select>
</div>
</td>
<td class="fieldsTableActions">
<button type="submit" form="fieldEditForm{{ f.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="fieldDeleteForm{{ f.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="help">Mini/Maxi (bornage automatique) : laisser vide = pas de limite.</p>
{% else %}
<p class="help">Aucun champ pour l'instant.</p>
{% endif %}
</div>
<div class="objectPanelTabPanel" data-optab-panel="data">
{% set blocked_row_id = request.args.get('blocked_row') if request.args.get('edit') == d.id|string else none %}
{% if blocked_row_id %}
<div class="warningBanner">
⚠️ Impossible de supprimer l'entrée #{{ blocked_row_id }} : elle est référencée ailleurs. Modifie ou supprime d'abord ces entrées.
</div>
{% endif %}
<div class="dashPanelHeader">
<h2>Ajouter une entrée</h2>
</div>
{% if definition.fields %}
<form method="post" action="{{ url_for('data_new', slug=game.slug, definition_id=d.id) }}" class="dashCreateBar">
{% for f in definition.fields %}
{% set col = f.name|colname %}
{% if f.type == 'texte' %}
<input type="text" name="{{ col }}" placeholder="{{ f.name }}{{ ' *' if f.required else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'texte_long' %}
<textarea name="{{ col }}" placeholder="{{ f.name }}{{ ' *' if f.required else '' }}" rows="1" {{ 'required' if f.required else '' }}></textarea>
{% elif f.type == 'nombre_entier' %}
<input type="number" step="1" name="{{ col }}" placeholder="{{ f.name }}{{ ' *' if f.required else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'nombre_decimal' %}
<input type="number" step="any" name="{{ col }}" placeholder="{{ f.name }}{{ ' *' if f.required else '' }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'booleen' %}
<label class="checkbox" style="white-space:nowrap; align-self:center;"><input type="checkbox" name="{{ col }}"> {{ f.name }}</label>
{% elif f.type == 'date' %}
<input type="date" name="{{ col }}" title="{{ f.name }}" {{ 'required' if f.required else '' }}>
{% elif f.type == 'relation' %}
<select name="{{ col }}_id" title="{{ f.name }}" {{ 'required' if f.required else '' }}>
<option value="">{{ f.name }} : —</option>
{% for opt in relation_options_by_definition[d.id].get(f.id, []) %}
<option value="{{ opt.id }}">{{ opt.label }}</option>
{% endfor %}
</select>
{% endif %}
{% endfor %}
<button type="submit" class="primary">+ Ajouter</button>
</form>
<h2>Données enregistrées</h2>
{% set rows = rows_by_definition[d.id] %}
{% if rows %}
{% for r in rows %}
<form method="post" action="{{ url_for('data_edit', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataEditForm{{ r.id }}"></form>
<form method="post" action="{{ url_for('data_delete', slug=game.slug, definition_id=d.id, row_id=r.id) }}" id="dataDeleteForm{{ r.id }}"
onsubmit="return confirm('Supprimer définitivement l\'entrée #{{ r.id }} ?');"></form>
{% endfor %}
<div class="fieldsTableWrap">
<table class="table is-fullwidth is-hoverable dataTable fieldsTable">
<thead>
<tr><th>#</th>{% for f in definition.fields %}<th>{{ f.name }}</th>{% endfor %}<th></th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.id }}</td>
{% for f in definition.fields %}
{% set col = f.name|colname %}
<td>
{% if f.type == 'texte' %}
<input class="input is-small" type="text" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'texte_long' %}
<textarea class="textarea is-small" form="dataEditForm{{ r.id }}" name="{{ col }}" rows="1">{{ r[col] or '' }}</textarea>
{% elif f.type in ('nombre_entier', 'nombre_decimal') %}
<input class="input is-small" type="number" step="{{ '1' if f.type == 'nombre_entier' else 'any' }}" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] if r[col] is not none else '' }}">
{% elif f.type == 'booleen' %}
<label class="checkbox"><input type="checkbox" form="dataEditForm{{ r.id }}" name="{{ col }}" {{ 'checked' if r[col] else '' }}></label>
{% elif f.type == 'date' %}
<input class="input is-small" type="date" form="dataEditForm{{ r.id }}" name="{{ col }}" value="{{ r[col] or '' }}">
{% elif f.type == 'relation' %}
<div class="select is-small">
<select form="dataEditForm{{ r.id }}" name="{{ col }}_id">
<option value="">—</option>
{% for opt in relation_options_by_definition[d.id].get(f.id, []) %}
<option value="{{ opt.id }}" {{ 'selected' if r[col ~ '_id'] == opt.id else '' }}>{{ opt.label }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</td>
{% endfor %}
<td class="fieldsTableActions">
<button type="submit" form="dataEditForm{{ r.id }}" class="button is-small">Enregistrer</button>
<button type="submit" form="dataDeleteForm{{ r.id }}" class="button is-small is-danger">🗑️</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="hint">Aucune donnée enregistrée pour l'instant.</p>
{% endif %}
{% else %}
<p class="hint">Ajoute d'abord au moins un champ pour pouvoir enregistrer des données.</p>
{% endif %}
</div>
<div class="dangerZone">
<h3>Supprimer cet objet</h3>
<form method="post" action="{{ url_for('object_delete', slug=game.slug, definition_id=d.id) }}"
@@ -531,10 +651,15 @@ function makeFloatPanelResizable(panel, handle) {
typeSelect.addEventListener('change', syncAddFieldVisibility);
syncAddFieldVisibility();
});
window.openObjectEditPanel = function (definitionId) {
window.openObjectEditPanel = function (definitionId, subtab) {
document.querySelectorAll('.objectEditPanel').forEach(function (panel) {
panel.classList.toggle('hidden', panel.dataset.definitionId !== String(definitionId));
});
if (subtab) {
var panel = document.querySelector('.objectEditPanel[data-definition-id="' + definitionId + '"]');
var btn = panel && panel.querySelector('.objectPanelTabs [data-optab="' + subtab + '"]');
if (btn) switchObjectPanelTab(btn, subtab);
}
};
window.closeObjectEditPanel = function (definitionId) {
var panel = document.querySelector('.objectEditPanel[data-definition-id="' + definitionId + '"]');
@@ -542,12 +667,28 @@ function makeFloatPanelResizable(panel, handle) {
};
// Réouverture automatique après un aller-retour serveur (ajout/édition/
// suppression d'un champ, renommage...) : ?edit=<id>, posé par les
// suppression d'un champ ou d'une entrée, renommage...) : ?edit=<id>
// (et ?subtab=data pour rouvrir sur le bon sous-onglet), posés par les
// routes concernées (voir routes/objects/) sur leur redirection vers ce
// tableau de bord — sans ça, chaque action fermait le panneau et
// obligeait à recliquer sur le crayon pour voir le résultat.
// obligeait à recliquer pour voir le résultat.
var requestedEdit = {{ (request.args.get('edit') or '')|tojson }};
if (requestedEdit) openObjectEditPanel(requestedEdit);
var requestedSubtab = {{ (request.args.get('subtab') or '')|tojson }};
if (requestedEdit) openObjectEditPanel(requestedEdit, requestedSubtab);
})();
// Sous-onglets Champs/Données d'UN panneau précis (voir plus haut) — scopé
// au panneau ancêtre du bouton cliqué, jamais global (plusieurs objets
// peuvent chacun avoir leurs sous-onglets sur leur propre panneau, en même
// temps, sur la même page).
function switchObjectPanelTab(btn, tab) {
var panel = btn.closest('.objectEditPanel');
panel.querySelectorAll('.objectPanelTabs .builderTabBtn').forEach(function (b) {
b.classList.toggle('active', b.dataset.optab === tab);
});
panel.querySelectorAll('.objectPanelTabPanel').forEach(function (p) {
p.classList.toggle('active', p.dataset.optabPanel === tab);
});
}
</script>
{% endblock %}