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
|
# Copie jetable de tests/users.test.sqlite, générée par tests/conftest.py
|
||||||
tests/users.runtime.sqlite
|
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
|
# LLM plugins
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pythonpath = ["src"]
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
env = [
|
env = [
|
||||||
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
||||||
|
"CACHE_DIR=tests/cache",
|
||||||
"DEVELOPMENT=true",
|
"DEVELOPMENT=true",
|
||||||
"REBUILD_DUCKDB=true",
|
"REBUILD_DUCKDB=true",
|
||||||
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
|
"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-internal-content-line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ag-cell {
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.ag-header-cell-text {
|
.ag-header-cell-text {
|
||||||
font-family: "Archivo Narrow", sans-serif;
|
font-family: "Archivo Narrow", sans-serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -645,6 +649,43 @@ button.show-hide {
|
|||||||
.ag-cell[col-id="marche"] a {
|
.ag-cell[col-id="marche"] a {
|
||||||
text-decoration: none;
|
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*/
|
/* fin overrides AG Grid*/
|
||||||
|
|
||||||
#btn-copy-url:before {
|
#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.
|
# Permet de sélectionner/copier le texte des infobulles (ex.
|
||||||
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
|
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
|
||||||
"tooltipInteraction": True,
|
"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,
|
"localeText": AG_GRID_LOCALE_FR,
|
||||||
"theme": {
|
"theme": {
|
||||||
"function": (
|
"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 db as saved_views_db
|
||||||
from src.saved_views import ui as saved_views_ui
|
from src.saved_views import ui as saved_views_ui
|
||||||
from src.utils import get_data_update_timestamp, logger
|
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 (
|
from src.utils.query_ast import (
|
||||||
ast_from_dict,
|
ast_from_dict,
|
||||||
ast_to_dict,
|
ast_to_dict,
|
||||||
@@ -522,11 +522,13 @@ def update_hidden_columns_from_checkboxes(selected_columns):
|
|||||||
@callback(
|
@callback(
|
||||||
Output("tableau_grid", "columnDefs"),
|
Output("tableau_grid", "columnDefs"),
|
||||||
Input("tableau-hidden-columns", "data"),
|
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:
|
if hidden_columns is None:
|
||||||
hidden_columns = get_default_hidden_columns("tableau")
|
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(
|
@callback(
|
||||||
@@ -560,16 +562,20 @@ def toggle_tableau_columns(click_open, click_close, is_open):
|
|||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("tableau_grid", "filterModel", allow_duplicate=True),
|
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"),
|
Input("btn-tableau-reset", "n_clicks"),
|
||||||
|
State("tableau_grid", "columnState"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def reset_view(n_clicks):
|
def reset_view(n_clicks, column_state):
|
||||||
# resetColumnState remet les colonnes (tri inclus) à l'état déclaré dans
|
# On ne touche qu'au tri (sort/sortIndex) : on réécrit le columnState tel
|
||||||
# columnDefs — qui reflète déjà la visibilité choisie via le sélecteur de
|
# quel pour préserver la largeur, l'épinglage et l'ordre des colonnes
|
||||||
# colonnes (cf. apply_hidden_columns), donc ça ne touche pas au choix de
|
# choisis par l'utilisateur (auparavant resetColumnState les effaçait
|
||||||
# colonnes affichées, seulement au tri.
|
# aussi, cf. #47).
|
||||||
return {}, True
|
cleared_sort = [
|
||||||
|
{**col, "sort": None, "sortIndex": None} for col in (column_state or [])
|
||||||
|
]
|
||||||
|
return {}, cleared_sort
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
|
|||||||
+77
-19
@@ -55,7 +55,17 @@ def fetch_grid_page(
|
|||||||
offset=start_row,
|
offset=start_row,
|
||||||
)
|
)
|
||||||
page = postprocess_page(page)
|
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:
|
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`
|
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
|
||||||
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
|
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
|
||||||
_SHORT_CODE_COLUMNS = {
|
_SHORT_CODE_COLUMNS = {
|
||||||
"uid",
|
|
||||||
"id",
|
|
||||||
"codeCPV",
|
"codeCPV",
|
||||||
"idAccordCadre",
|
|
||||||
"lieuExecution_typeCode",
|
"lieuExecution_typeCode",
|
||||||
"sourceFile",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
|
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
|
||||||
_WIDE_LABEL_COLUMNS = {"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:
|
def _filter_for(col_type) -> str:
|
||||||
if col_type.is_numeric():
|
if col_type.is_numeric():
|
||||||
@@ -121,23 +142,24 @@ def _filter_for(col_type) -> str:
|
|||||||
def _column_width(col: str, col_type) -> dict:
|
def _column_width(col: str, col_type) -> dict:
|
||||||
"""Largeur par défaut selon la nature de la colonne.
|
"""Largeur par défaut selon la nature de la colonne.
|
||||||
|
|
||||||
`columnSize="responsiveSizeToFit"` (cf. ag_grid()) étire les colonnes
|
Sert de base à l'affichage initial (pas de columnSize="responsiveSizeToFit",
|
||||||
proportionnellement à cette largeur pour remplir l'espace disponible :
|
cf. ag_grid() : les colonnes ne s'étirent/compressent pas automatiquement).
|
||||||
sans ça, les codes/booléens s'étirent autant que les noms/libellés.
|
Pas de maxWidth ici : resizable=True (defaultColDef) doit pouvoir élargir
|
||||||
|
librement une colonne à la souris, sans plafond artificiel.
|
||||||
"""
|
"""
|
||||||
if col == "montant":
|
if col == "montant":
|
||||||
return {"width": 150, "maxWidth": 200}
|
return {"width": 150}
|
||||||
if col_type == pl.Date:
|
if col_type == pl.Date:
|
||||||
return {"width": 140, "maxWidth": 160}
|
return {"width": 220}
|
||||||
if col_type.is_numeric():
|
if col_type.is_numeric():
|
||||||
return {"width": 120, "maxWidth": 150}
|
return {"width": 150}
|
||||||
if col in _BOOLEAN_LIKE_COLUMNS:
|
if col in _BOOLEAN_LIKE_COLUMNS:
|
||||||
return {"width": 110, "maxWidth": 140}
|
return {"width": 110}
|
||||||
if col in _SHORT_CODE_COLUMNS or col.endswith(("_id", "_code")):
|
if col in _SHORT_CODE_COLUMNS or col.endswith(("_code")):
|
||||||
return {"width": 130, "maxWidth": 170}
|
return {"width": 180}
|
||||||
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
|
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
|
||||||
return {"width": 200}
|
return {"width": 300}
|
||||||
return {"width": 170}
|
return {"width": 190}
|
||||||
|
|
||||||
|
|
||||||
def grid_column_defs(hidden_columns=None):
|
def grid_column_defs(hidden_columns=None):
|
||||||
@@ -169,11 +191,22 @@ def grid_column_defs(hidden_columns=None):
|
|||||||
"hide": col in hidden,
|
"hide": col in hidden,
|
||||||
}
|
}
|
||||||
if meta.get("description"):
|
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"] = (
|
col_def["headerTooltip"] = (
|
||||||
f"{meta.get('title', col)} ({col}) — {meta['description']}"
|
f"{meta.get('title', col)} ({col}) — {meta['description']}"
|
||||||
)
|
)
|
||||||
if col in _LINK_COLUMNS:
|
if col in _LINK_COLUMNS:
|
||||||
col_def["cellRenderer"] = "markdown"
|
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":
|
if col != "objet":
|
||||||
col_def.update(_column_width(col, col_type))
|
col_def.update(_column_width(col, col_type))
|
||||||
if col == "objet":
|
if col == "objet":
|
||||||
@@ -183,8 +216,33 @@ def grid_column_defs(hidden_columns=None):
|
|||||||
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
|
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
|
||||||
col_def["wrapText"] = True
|
col_def["wrapText"] = True
|
||||||
col_def["minWidth"] = 360
|
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)
|
defs.append(col_def)
|
||||||
return defs
|
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
|
return dff
|
||||||
|
|
||||||
|
|
||||||
|
_TOOLTIP_SHADOW_COLUMNS = [
|
||||||
|
"acheteur_nom",
|
||||||
|
"titulaire_nom",
|
||||||
|
"acheteur_id",
|
||||||
|
"titulaire_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def add_links(dff: pl.DataFrame):
|
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"]:
|
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
||||||
if col in dff.columns:
|
if col in dff.columns:
|
||||||
if col.startswith("titulaire_"):
|
if col.startswith("titulaire_"):
|
||||||
|
|||||||
+94
-10
@@ -5,7 +5,12 @@ import pytest
|
|||||||
import src.app # noqa: F401 # instancie l'app → register_page() des pages
|
import src.app # noqa: F401 # instancie l'app → register_page() des pages
|
||||||
from src.pages.tableau import get_rows_tableau, reset_view
|
from src.pages.tableau import get_rows_tableau, reset_view
|
||||||
from src.utils import grid as grid_module
|
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")
|
@pytest.fixture(scope="module")
|
||||||
@@ -52,25 +57,27 @@ def test_column_defs_hidden_flag():
|
|||||||
|
|
||||||
|
|
||||||
def test_fetch_grid_page_returns_rows_and_count():
|
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(rows, list)
|
||||||
assert isinstance(total, int)
|
assert isinstance(total, int)
|
||||||
assert total >= len(rows)
|
assert total >= len(rows)
|
||||||
|
assert total_unique <= total
|
||||||
|
|
||||||
if rows:
|
if rows:
|
||||||
# postprocess_page ajoute une colonne 'marche' avec un lien
|
# postprocess_page ajoute une colonne 'marche' avec un lien
|
||||||
assert "marche" in rows[0]
|
assert "marche" in rows[0]
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_grid_page_filter_reduces_count():
|
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"}}
|
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 total_filtered <= total_all
|
||||||
assert rows == [] and total_filtered == 0
|
assert rows == [] and total_filtered == 0
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_grid_page_offset_slicing():
|
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
|
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"}}
|
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
|
||||||
with flask_app.app_context():
|
with flask_app.app_context():
|
||||||
_, total1 = fetch_grid_page(fm, None, 0, 20)
|
_, total1, _ = fetch_grid_page(fm, None, 0, 20)
|
||||||
_, total2 = fetch_grid_page(fm, None, 20, 40)
|
_, total2, _ = fetch_grid_page(fm, None, 20, 40)
|
||||||
_, total3 = fetch_grid_page(fm, None, 40, 60)
|
_, total3, _ = fetch_grid_page(fm, None, 40, 60)
|
||||||
|
|
||||||
assert call_count["n"] == 1
|
assert call_count["n"] == 1
|
||||||
assert total1 == total2 == total3
|
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():
|
def test_reset_view_clears_filter_and_sort():
|
||||||
"""Régression : le bouton Réinitialiser ('Supprime tous les filtres et les
|
"""Régression : le bouton Réinitialiser ('Supprime tous les filtres et les
|
||||||
tris') ne remettait à zéro que filterModel, jamais le tri (#41)."""
|
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 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