diff --git a/.gitignore b/.gitignore index 5a07f57..f7f699e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/pyproject.toml b/pyproject.toml index 80eecd7..e172214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 7877de9..05c44e1 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -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 { diff --git a/src/figures.py b/src/figures.py index 40f5982..1b49db2 100644 --- a/src/figures.py +++ b/src/figures.py @@ -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": ( diff --git a/src/pages/tableau.py b/src/pages/tableau.py index db9e53a..b454dee 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -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( diff --git a/src/utils/grid.py b/src/utils/grid.py index a795be4..fc29a1e 100644 --- a/src/utils/grid.py +++ b/src/utils/grid.py @@ -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"]}
{start_row + i + 1}
' + ) + 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 () : + # 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 diff --git a/src/utils/table.py b/src/utils/table.py index 1d7681a..846e929 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -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 + # 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_"): diff --git a/tests/test_grid.py b/tests/test_grid.py index d077791..bbed6d7 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -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