From 2a229518294303754561d9934cc5b6815cc68fae Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 13 Jul 2026 17:34:29 +0200 Subject: [PATCH] feat(tableau): verrou de derive + affichage du bloc de partage (#112) --- src/pages/tableau.py | 57 +++++++++++++++++--- tests/saved_views/test_tableau_share.py | 69 +++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 283e190..aae2f4a 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -673,6 +673,34 @@ def apply_vue_resolution_cb(resolution): return apply_vue_resolution(resolution) +@callback( + Output("share-url-box", "style"), + Output("share-url-input", "value"), + Input("active-view", "data"), +) +def render_share_box(active_view): + if active_view and active_view.get("url"): + return {}, active_view["url"] + return {"display": "none"}, "" + + +@callback( + Output("active-view", "data", allow_duplicate=True), + Output("suppress-next", "data", allow_duplicate=True), + Input("tableau_grid", "filterModel"), + Input("tableau_grid", "columnState"), + State("suppress-next", "data"), + prevent_initial_call=True, +) +def hide_share_box_on_change(_filter_model, _column_state, suppress): + # Verrou « sale » à sens unique. L'application d'une vue modifie elle-même + # filterModel/columnState (écho) : on l'absorbe une fois (suppress>0), puis + # tout changement réel masque la box en effaçant active-view. + if suppress and suppress > 0: + return no_update, suppress - 1 + return None, 0 + + @callback( Output("save-view-modal", "is_open"), Input("btn-save-view", "n_clicks"), @@ -686,6 +714,8 @@ def toggle_save_view_modal(_open): Output("save-view-modal", "is_open", allow_duplicate=True), Output("save-view-feedback", "children"), Output("saved-views-refresh", "data"), + Output("active-view", "data", allow_duplicate=True), + Output("suppress-next", "data", allow_duplicate=True), Input("btn-save-view-confirm", "n_clicks"), State("save-view-name", "value"), State("tableau_grid", "filterModel"), @@ -696,17 +726,26 @@ def save_view(_n, name, filter_model, column_state): has_sub = current_user_has_subscription() clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) if error: - return True, html.Span(error, style={"color": "red"}), no_update + return ( + True, + html.Span(error, style={"color": "red"}), + no_update, + no_update, + no_update, + ) # On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut # d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState, # indépendant de l'UI". ast = filtermodel_to_ast(filter_model, schema) query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []}) - saved_views_db.upsert(current_user.id, "tableau", clean_name, query) + token = saved_views_db.upsert(current_user.id, "tableau", clean_name, query) + active = {"token": token, "url": saved_views_ui.build_view_url(clean_name, token)} return ( False, html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), clean_name, + active, + 0, # la sauvegarde ne modifie pas la grille → pas d'écho à absorber ) @@ -726,6 +765,8 @@ def populate_saved_views_menu(_pathname, _refresh): Output("tableau_grid", "filterModel"), Output("tableau_grid", "columnState"), Output("tableau-hidden-columns", "data", allow_duplicate=True), + Output("active-view", "data", allow_duplicate=True), + Output("suppress-next", "data", allow_duplicate=True), Input({"type": "saved-view-item", "index": ALL}, "n_clicks"), State({"type": "saved-view-item", "index": ALL}, "id"), prevent_initial_call=True, @@ -733,10 +774,10 @@ def populate_saved_views_menu(_pathname, _refresh): def apply_saved_view(n_clicks, ids): triggered = ctx.triggered_id if not triggered or not any(n_clicks): - return no_update, no_update, no_update + return no_update, no_update, no_update, no_update, no_update row = saved_views_db.get(triggered["index"], current_user.id) if not row: - return no_update, no_update, no_update + return no_update, no_update, no_update, no_update, no_update try: view = json.loads(row["query"]) # L'AST canonique est stocké (pas le filterModel brut d'AG Grid) : @@ -756,14 +797,18 @@ def apply_saved_view(n_clicks, ids): "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, no_update + return no_update, no_update, no_update, no_update, no_update # tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes # (update_checkboxes_from_hidden_columns) et la régénération des # columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait # désynchronisé du columnState rappelé (revue finale #41). Même extraction # que download_data. hidden_columns = [c["colId"] for c in column_state if c.get("hide")] - return filter_model, column_state, hidden_columns + active = { + "token": row["token"], + "url": saved_views_ui.build_view_url(row["name"], row["token"]), + } + return filter_model, column_state, hidden_columns, active, 1 @callback( diff --git a/tests/saved_views/test_tableau_share.py b/tests/saved_views/test_tableau_share.py index 428423c..7f42795 100644 --- a/tests/saved_views/test_tableau_share.py +++ b/tests/saved_views/test_tableau_share.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch import dash @@ -85,3 +86,71 @@ def test_apply_vue_resolution_not_found_shows_alert(users_db_path): def test_apply_vue_resolution_none_is_noop(users_db_path): out = tableau.apply_vue_resolution(None) assert all(v is dash.no_update for v in out) + + +def _fake_user(user_id): + u = type("U", (), {})() + u.is_authenticated = True + u.id = user_id + return u + + +class _Ctx: + triggered_id = None + + +def test_render_share_box_visible_when_active(): + style, value = tableau.render_share_box( + {"token": "abc123", "url": "https://x/tableau?vue=a_abc123"} + ) + assert style == {} + assert value == "https://x/tableau?vue=a_abc123" + + +def test_render_share_box_hidden_when_none(): + style, value = tableau.render_share_box(None) + assert style == {"display": "none"} + assert value == "" + + +def test_hide_lock_consumes_echo_then_hides(): + # 1er changement = écho de l'application (suppress=1) → garde la box. + active, suppress = tableau.hide_share_box_on_change({}, [], 1) + assert active is dash.no_update + assert suppress == 0 + # Changement réel suivant (suppress=0) → masque. + active2, suppress2 = tableau.hide_share_box_on_change({"objet": {}}, [], 0) + assert active2 is None + assert suppress2 == 0 + + +def test_apply_saved_view_sets_active_and_suppress(monkeypatch, users_db_path): + monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr") + saved_views_db.init_schema() + uid = _make_user() + token = _seed(uid, "Ma vue") + 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)): + out = tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) + # (filter_model, column_state, hidden, active-view, suppress-next) + assert out[3] == { + "token": token, + "url": f"https://test.colibre.fr/tableau?vue=ma-vue_{token}", + } + assert out[4] == 1 + + +def test_save_view_shows_box(monkeypatch, users_db_path): + monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr") + saved_views_db.init_schema() + uid = _make_user() + monkeypatch.setattr(tableau, "current_user_has_subscription", lambda: True) + with patch.object(tableau, "current_user", _fake_user(uid)): + out = tableau.save_view(1, "Nouvelle", {}, []) + # (is_open, feedback, refresh, active-view, suppress-next) + assert out[3]["url"].startswith("https://test.colibre.fr/tableau?vue=nouvelle_") + assert out[4] == 0 # sauvegarde ne modifie pas la grille → pas d'écho