Améliorations sur le style de l'ag grid, ajout nb marchés uniques #47
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user