diff --git a/src/pages/tableau.py b/src/pages/tableau.py index d7d8068..9a11642 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -23,7 +23,7 @@ from src.figures import ag_grid, make_column_picker 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 +from src.utils import get_data_update_timestamp, logger from src.utils.grid import fetch_grid_page, grid_column_defs from src.utils.seo import META_CONTENT from src.utils.table import ( @@ -417,7 +417,11 @@ def get_rows_tableau(request): return no_update, no_update filter_model = request.get("filterModel") or None sort_model = request.get("sortModel") or None - if filter_model: + # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de + # défilement infini, avec le même filterModel tant que le filtre ne change + # pas. Ne compter qu'une recherche par changement de filtre/tri (bloc 0), + # pas une par bloc chargé au défilement. + if filter_model and request.get("startRow", 0) == 0: track_search(json.dumps(filter_model), "tableau") rows, total = fetch_grid_page( filter_model, @@ -610,8 +614,21 @@ def apply_saved_view(n_clicks, ids): row = saved_views_db.get(triggered["index"], current_user.id) if not row: return no_update, no_update - view = json.loads(row["query"]) - return view.get("filterModel") or {}, view.get("columnState") or [] + try: + view = json.loads(row["query"]) + filter_model = view.get("filterModel") or {} + column_state = view.get("columnState") or [] + except (json.JSONDecodeError, TypeError, AttributeError): + # Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"] + # est encore une query string (ex. "filtres=a&tris=b"), pas du JSON. On + # échoue proprement plutôt que de planter le callback ; pas de + # migration automatique de l'ancien format. + logger.warning( + "Vue sauvegardée au format pré-migration, impossible de l'appliquer : " + f"id={row['id']!r} name={row['name']!r}" + ) + return no_update, no_update + return filter_model, column_state @callback( diff --git a/tests/saved_views/test_apply_saved_view.py b/tests/saved_views/test_apply_saved_view.py new file mode 100644 index 0000000..47f8d6c --- /dev/null +++ b/tests/saved_views/test_apply_saved_view.py @@ -0,0 +1,71 @@ +"""Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue +sauvegardée) ne doit pas planter si row["query"] est encore au format +pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne +build_view_query avant que Task 10 ne migre save_view vers du JSON +{"filterModel": ..., "columnState": ...}. +""" + +from unittest.mock import patch + +import dash + +import src.app # noqa: F401 # instancie l'app → register_page() des pages +from src.auth import db as auth_db +from src.pages import tableau +from src.saved_views import db as saved_views_db + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def _fake_user(user_id, authenticated=True): + user = type("U", (), {})() + user.is_authenticated = authenticated + user.id = user_id + return user + + +class _Ctx: + triggered_id = None + + +def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_path): + saved_views_db.init_schema() + uid = _make_user() + saved_views_db.upsert(uid, "tableau", "Vue historique", "filtres=a&tris=b") + view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] + + _Ctx.triggered_id = {"type": "saved-view-item", "index": view_id} + monkeypatch.setattr(tableau, "ctx", _Ctx) + + with patch.object(tableau, "current_user", _fake_user(uid)): + filter_model, column_state = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + + assert filter_model is dash.no_update + assert column_state is dash.no_update + + +def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): + saved_views_db.init_schema() + uid = _make_user() + query = ( + '{"filterModel": {"objet": {"filterType": "text", "filter": "route"}}, ' + '"columnState": [{"colId": "montant", "sort": "desc"}]}' + ) + saved_views_db.upsert(uid, "tableau", "Vue récente", query) + view_id = saved_views_db.list_views(uid, "tableau")[0]["id"] + + _Ctx.triggered_id = {"type": "saved-view-item", "index": view_id} + monkeypatch.setattr(tableau, "ctx", _Ctx) + + with patch.object(tableau, "current_user", _fake_user(uid)): + filter_model, column_state = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + + assert filter_model == {"objet": {"filterType": "text", "filter": "route"}} + assert column_state == [{"colId": "montant", "sort": "desc"}] diff --git a/tests/test_grid.py b/tests/test_grid.py index 8303966..89abf48 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,3 +1,7 @@ +from unittest.mock import patch + +import src.app # noqa: F401 # instancie l'app → register_page() des pages +from src.pages.tableau import get_rows_tableau from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs @@ -51,3 +55,16 @@ def test_export_dataframe_applies_filter(): fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}} df = export_dataframe(fm, None, hidden_columns=[]) assert df.height == 0 + + +def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block(): + """Régression revue finale #41 : AG Grid envoie une getRowsRequest par bloc + de défilement infini, avec le même filterModel tant que le filtre ne + change pas. track_search ne doit être appelé qu'une fois par filtre (au + premier bloc, startRow == 0), pas une fois par bloc défilé.""" + fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}} + with patch("src.pages.tableau.track_search") as mocked: + get_rows_tableau({"filterModel": fm, "startRow": 0, "endRow": 100}) + get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200}) + get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300}) + mocked.assert_called_once()