Améliorations sur le style de l'ag grid, ajout nb marchés uniques #47

This commit is contained in:
Colin Maudry
2026-07-12 20:44:43 +02:00
parent f580459bd5
commit 6dd7dca271
8 changed files with 256 additions and 39 deletions
+5
View File
@@ -22,6 +22,11 @@ users.sqlite
# Copie jetable de tests/users.test.sqlite, générée par tests/conftest.py
tests/users.runtime.sqlite
# Cache disque isolé pour pytest (CACHE_DIR, cf. pyproject.toml) : évite de
# partager /tmp/colibre-cache avec le serveur dev (résultats de requêtes
# faussés par les données de test après un rmtree() croisé, cf. src/app.py)
tests/cache/
# LLM plugins
.superpowers/
.codegraph/
+1
View File
@@ -52,6 +52,7 @@ pythonpath = ["src"]
testpaths = ["tests"]
env = [
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
"CACHE_DIR=tests/cache",
"DEVELOPMENT=true",
"REBUILD_DUCKDB=true",
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
+41
View File
@@ -636,6 +636,10 @@ button.show-hide {
--ag-internal-content-line-height: 20px;
}
.ag-cell {
padding-top: 3px;
}
.ag-header-cell-text {
font-family: "Archivo Narrow", sans-serif;
font-size: 18px;
@@ -645,6 +649,43 @@ button.show-hide {
.ag-cell[col-id="marche"] a {
text-decoration: none;
}
/* Numéro de ligne sous la loupe : repère de position pendant le défilement,
discret pour ne pas concurrencer le lien. */
.ag-cell[col-id="marche"] {
text-align: center;
}
.marche-row-number {
color: #bbb;
font-size: 12px;
line-height: 1.4;
}
/* Infobulle : largeur bornée avec retour à la ligne plutôt qu'une largeur
qui suit tout le texte. .ag-tooltip = infobulle native (ex. colonne
"objet" tronquée) ; .ag-tooltip-custom = infobulle rendue par un
tooltipComponent personnalisé (ex. en-têtes de colonnes, cf.
grid_column_defs) — AG Grid n'ajoute PAS la classe .ag-tooltip dans ce
cas, il faut donc cibler les deux. */
.ag-tooltip,
.ag-tooltip-custom {
max-width: 400px;
white-space: normal;
word-wrap: break-word;
background-color: #fdd;
}
/* .ag-tooltip-custom n'a, contrairement à .ag-tooltip, aucun style visuel
par défaut côté AG Grid (voir dash_ag_grid.min.js : seuls position/z-index
sont posés) — on reprend ici les mêmes variables de thème que .ag-tooltip
pour un rendu identique (bordure grise, padding, coins arrondis). */
.ag-tooltip-custom {
border: var(--ag-tooltip-border);
border-radius: var(--ag-border-radius);
color: var(--ag-tooltip-text-color);
padding: var(--ag-widget-container-vertical-padding)
var(--ag-widget-container-horizontal-padding);
}
/* fin overrides AG Grid*/
#btn-copy-url:before {
+3
View File
@@ -1131,6 +1131,9 @@ def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
# Permet de sélectionner/copier le texte des infobulles (ex.
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
"tooltipInteraction": True,
# Délai avant apparition des infobulles natives (200ms au lieu
# des 2000ms par défaut d'AG Grid).
"tooltipShowDelay": 200,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
+16 -10
View File
@@ -24,7 +24,7 @@ from src.pages._compte_shell import current_user_has_subscription
from src.saved_views import db as saved_views_db
from src.saved_views import ui as saved_views_ui
from src.utils import get_data_update_timestamp, logger
from src.utils.grid import fetch_grid_page, grid_column_defs
from src.utils.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs
from src.utils.query_ast import (
ast_from_dict,
ast_to_dict,
@@ -522,11 +522,13 @@ def update_hidden_columns_from_checkboxes(selected_columns):
@callback(
Output("tableau_grid", "columnDefs"),
Input("tableau-hidden-columns", "data"),
State("tableau_grid", "columnState"),
)
def apply_hidden_columns(hidden_columns):
def apply_hidden_columns(hidden_columns, column_state):
if hidden_columns is None:
hidden_columns = get_default_hidden_columns("tableau")
return grid_column_defs(hidden_columns)
defs = grid_column_defs(hidden_columns)
return apply_persisted_layout(defs, column_state)
@callback(
@@ -560,16 +562,20 @@ def toggle_tableau_columns(click_open, click_close, is_open):
@callback(
Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_grid", "resetColumnState", allow_duplicate=True),
Output("tableau_grid", "columnState", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"),
State("tableau_grid", "columnState"),
prevent_initial_call=True,
)
def reset_view(n_clicks):
# resetColumnState remet les colonnes (tri inclus) à l'état déclaré dans
# columnDefs — qui reflète déjà la visibilité choisie via le sélecteur de
# colonnes (cf. apply_hidden_columns), donc ça ne touche pas au choix de
# colonnes affichées, seulement au tri.
return {}, True
def reset_view(n_clicks, column_state):
# On ne touche qu'au tri (sort/sortIndex) : on réécrit le columnState tel
# quel pour préserver la largeur, l'épinglage et l'ordre des colonnes
# choisis par l'utilisateur (auparavant resetColumnState les effaçait
# aussi, cf. #47).
cleared_sort = [
{**col, "sort": None, "sortIndex": None} for col in (column_state or [])
]
return {}, cleared_sort
@callback(
+77 -19
View File
@@ -55,7 +55,17 @@ def fetch_grid_page(
offset=start_row,
)
page = postprocess_page(page)
return page.to_dicts(), total, total_unique
rows = page.to_dicts()
# Numéro de ligne absolu (position dans les résultats filtrés/triés),
# ajouté sous le lien loupe : repère visuel pendant le défilement infini
# de la grille. Calculé ici (pas dans postprocess_page, partagé avec les
# dash_table paginées d'acheteur.py/titulaire.py) car il dépend de
# `start_row`, propre au chargement par bloc de l'AG Grid.
for i, row in enumerate(rows):
row["marche"] = (
f'{row["marche"]}<div class="marche-row-number">{start_row + i + 1}</div>'
)
return rows, total, total_unique
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame:
@@ -98,17 +108,28 @@ _BOOLEAN_LIKE_COLUMNS = {
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
_SHORT_CODE_COLUMNS = {
"uid",
"id",
"codeCPV",
"idAccordCadre",
"lieuExecution_typeCode",
"sourceFile",
}
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
_WIDE_LABEL_COLUMNS = {"sourceDataset"}
# Colonnes avec infobulle systématique sur les cellules (valeur complète au
# survol, pas seulement quand le texte est tronqué).
_CELL_TOOLTIP_COLUMNS = {
"procedure",
"considerationsSociales",
"considerationsEnvironnementales",
"objet",
"ccag",
"modalitesExecution",
"acheteur_nom",
"titulaire_nom",
"acheteur_id",
"titulaire_id",
}
def _filter_for(col_type) -> str:
if col_type.is_numeric():
@@ -121,23 +142,24 @@ def _filter_for(col_type) -> str:
def _column_width(col: str, col_type) -> dict:
"""Largeur par défaut selon la nature de la colonne.
`columnSize="responsiveSizeToFit"` (cf. ag_grid()) étire les colonnes
proportionnellement à cette largeur pour remplir l'espace disponible :
sans ça, les codes/booléens s'étirent autant que les noms/libellés.
Sert de base à l'affichage initial (pas de columnSize="responsiveSizeToFit",
cf. ag_grid() : les colonnes ne s'étirent/compressent pas automatiquement).
Pas de maxWidth ici : resizable=True (defaultColDef) doit pouvoir élargir
librement une colonne à la souris, sans plafond artificiel.
"""
if col == "montant":
return {"width": 150, "maxWidth": 200}
return {"width": 150}
if col_type == pl.Date:
return {"width": 140, "maxWidth": 160}
return {"width": 220}
if col_type.is_numeric():
return {"width": 120, "maxWidth": 150}
return {"width": 150}
if col in _BOOLEAN_LIKE_COLUMNS:
return {"width": 110, "maxWidth": 140}
if col in _SHORT_CODE_COLUMNS or col.endswith(("_id", "_code")):
return {"width": 130, "maxWidth": 170}
return {"width": 110}
if col in _SHORT_CODE_COLUMNS or col.endswith(("_code")):
return {"width": 180}
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
return {"width": 200}
return {"width": 170}
return {"width": 300}
return {"width": 190}
def grid_column_defs(hidden_columns=None):
@@ -169,11 +191,22 @@ def grid_column_defs(hidden_columns=None):
"hide": col in hidden,
}
if meta.get("description"):
# Texte brut (pas de markdown) : le renderer "markdown" côté
# dash-ag-grid n'est enregistré que comme cellRenderer, pas
# comme tooltipComponent — posé sur un en-tête, il retombe sur
# le rendu natif d'AG Grid, qui affiche la chaîne telle quelle
# (donc "**gras**" apparaissait littéralement avec les astérisques).
col_def["headerTooltip"] = (
f"{meta.get('title', col)} ({col}) — {meta['description']}"
)
if col in _LINK_COLUMNS:
col_def["cellRenderer"] = "markdown"
if col in _CELL_TOOLTIP_COLUMNS:
# Valeur complète en infobulle. Pour les colonnes-liens
# (_LINK_COLUMNS), la cellule contient du HTML (<a href=...>) :
# on pointe vers la copie texte brut posée par add_links() dans
# src.utils.table plutôt que d'afficher ce balisage tel quel.
col_def["tooltipField"] = f"{col}_tooltip" if col in _LINK_COLUMNS else col
if col != "objet":
col_def.update(_column_width(col, col_type))
if col == "objet":
@@ -183,8 +216,33 @@ def grid_column_defs(hidden_columns=None):
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
col_def["wrapText"] = True
col_def["minWidth"] = 360
# Le texte tronqué par la hauteur de ligne fixe reste consultable
# en entier via l'infobulle native AG Grid au survol.
col_def["tooltipField"] = "objet"
defs.append(col_def)
return defs
def apply_persisted_layout(
defs: list[dict], column_state: list[dict] | None
) -> list[dict]:
"""Réapplique la largeur et l'ordre des colonnes personnalisés par
l'utilisateur (columnState restauré via la persistance AG Grid) par-dessus
des columnDefs fraîchement régénérés par grid_column_defs(), qui eux
ignorent tout état précédent et ne portent que la largeur par défaut et
l'ordre du schéma.
Sans ça, tout changement de columnDefs (à chaque chargement de page ou
changement de colonnes affichées) écrase silencieusement le
redimensionnement/réordonnancement fait par l'utilisateur, cf. #47.
"""
if not column_state:
return defs
order = {col["colId"]: i for i, col in enumerate(column_state)}
widths = {col["colId"]: col["width"] for col in column_state if col.get("width")}
for col_def in defs:
field = col_def["field"]
if field in widths:
col_def["width"] = widths[field]
# Colonnes déjà connues dans leur ordre persisté, colonnes nouvelles
# (ex. ajout au schéma, jamais vues dans columnState) à la fin dans
# leur ordre par défaut.
defs.sort(key=lambda d: order.get(d["field"], len(order)))
return defs
+19
View File
@@ -50,7 +50,26 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
return dff
_TOOLTIP_SHADOW_COLUMNS = [
"acheteur_nom",
"titulaire_nom",
"acheteur_id",
"titulaire_id",
]
def add_links(dff: pl.DataFrame):
# Copie du texte brut avant réécriture en HTML ci-dessous, pour que la
# grille AG Grid (cf. grid_column_defs dans src.utils.grid) puisse
# afficher une infobulle lisible sur ces colonnes plutôt que le balisage
# <a href=...> brut.
dff = dff.with_columns(
[
pl.col(c).alias(f"{c}_tooltip")
for c in _TOOLTIP_SHADOW_COLUMNS
if c in dff.columns
]
)
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
if col in dff.columns:
if col.startswith("titulaire_"):
+94 -10
View File
@@ -5,7 +5,12 @@ import pytest
import src.app # noqa: F401 # instancie l'app → register_page() des pages
from src.pages.tableau import get_rows_tableau, reset_view
from src.utils import grid as grid_module
from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs
from src.utils.grid import (
apply_persisted_layout,
export_dataframe,
fetch_grid_page,
grid_column_defs,
)
@pytest.fixture(scope="module")
@@ -52,25 +57,27 @@ def test_column_defs_hidden_flag():
def test_fetch_grid_page_returns_rows_and_count():
rows, total = fetch_grid_page(None, None, 0, 20)
rows, total, total_unique = fetch_grid_page(None, None, 0, 20)
assert isinstance(rows, list)
assert isinstance(total, int)
assert total >= len(rows)
assert total_unique <= total
if rows:
# postprocess_page ajoute une colonne 'marche' avec un lien
assert "marche" in rows[0]
def test_fetch_grid_page_filter_reduces_count():
_, total_all = fetch_grid_page(None, None, 0, 1)
_, total_all, _ = fetch_grid_page(None, None, 0, 1)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
rows, total_filtered = fetch_grid_page(fm, None, 0, 20)
rows, total_filtered, _ = fetch_grid_page(fm, None, 0, 20)
assert total_filtered <= total_all
assert rows == [] and total_filtered == 0
def test_fetch_grid_page_offset_slicing():
rows, _ = fetch_grid_page(None, None, 0, 5)
rows, _, _ = fetch_grid_page(None, None, 0, 5)
assert len(rows) <= 5
@@ -113,9 +120,9 @@ def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatc
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with flask_app.app_context():
_, total1 = fetch_grid_page(fm, None, 0, 20)
_, total2 = fetch_grid_page(fm, None, 20, 40)
_, total3 = fetch_grid_page(fm, None, 40, 60)
_, total1, _ = fetch_grid_page(fm, None, 0, 20)
_, total2, _ = fetch_grid_page(fm, None, 20, 40)
_, total3, _ = fetch_grid_page(fm, None, 40, 60)
assert call_count["n"] == 1
assert total1 == total2 == total3
@@ -124,6 +131,83 @@ def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatc
def test_reset_view_clears_filter_and_sort():
"""Régression : le bouton Réinitialiser ('Supprime tous les filtres et les
tris') ne remettait à zéro que filterModel, jamais le tri (#41)."""
filter_model, reset_column_state = reset_view(1)
column_state = [{"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 200}]
filter_model, new_column_state = reset_view(1, column_state)
assert filter_model == {}
assert reset_column_state is True
assert new_column_state == [
{"colId": "objet", "sort": None, "sortIndex": None, "width": 200}
]
def test_reset_view_preserves_column_width():
"""Le bouton Réinitialiser ne doit remettre à zéro que les filtres et les
tris, pas la largeur (ni le pinning/ordre) des colonnes."""
column_state = [
{"colId": "objet", "sort": "asc", "sortIndex": 0, "width": 350, "hide": True},
{
"colId": "montant",
"sort": None,
"sortIndex": None,
"width": 120,
"hide": False,
},
]
_, new_column_state = reset_view(1, column_state)
assert [c["width"] for c in new_column_state] == [350, 120]
assert [c["hide"] for c in new_column_state] == [True, False]
assert all(c["sort"] is None and c["sortIndex"] is None for c in new_column_state)
def test_apply_persisted_layout_restores_width_and_order():
"""Régression : la largeur et l'ordre des colonnes choisis par
l'utilisateur étaient perdus au rechargement de la page, écrasés par les
columnDefs régénérés par apply_hidden_columns à chaque chargement (#47)."""
defs = [
{"field": "marche", "width": 60},
{"field": "objet", "minWidth": 360},
{"field": "montant", "width": 150},
{"field": "acheteur_nom", "width": 200},
]
# L'utilisateur a redimensionné "montant" et déplacé "acheteur_nom" avant "objet"
column_state = [
{"colId": "marche", "width": 60},
{"colId": "acheteur_nom", "width": 200},
{"colId": "objet", "width": 500},
{"colId": "montant", "width": 320},
]
result = apply_persisted_layout(defs, column_state)
assert [d["field"] for d in result] == [
"marche",
"acheteur_nom",
"objet",
"montant",
]
assert next(d for d in result if d["field"] == "montant")["width"] == 320
# "objet" n'a pas de clé "width" dans columnDefs (minWidth seulement, cf.
# grid_column_defs) : columnState en fournit une, elle doit être reprise.
assert next(d for d in result if d["field"] == "objet")["width"] == 500
def test_apply_persisted_layout_places_unknown_columns_last():
"""Une colonne absente du columnState persisté (jamais vue par
l'utilisateur, ex. ajout au schéma) doit rester à la fin plutôt que de
faire planter le tri."""
defs = [
{"field": "marche", "width": 60},
{"field": "objet", "minWidth": 360},
{"field": "nouvelle_colonne", "width": 130},
]
column_state = [
{"colId": "objet", "width": 400},
{"colId": "marche", "width": 60},
]
result = apply_persisted_layout(defs, column_state)
assert [d["field"] for d in result] == ["objet", "marche", "nouvelle_colonne"]
def test_apply_persisted_layout_noop_without_prior_state():
"""Premier chargement (jamais de columnState persisté) : columnDefs
inchangés, pas d'erreur sur None/[]."""
defs = [{"field": "marche", "width": 60}, {"field": "objet", "minWidth": 360}]
assert apply_persisted_layout(defs, None) == defs
assert apply_persisted_layout(defs, []) == defs