From 301a8668b3cb749f67d6c7f99777c72040f2e3f2 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 13 Jul 2026 17:32:48 +0200 Subject: [PATCH] feat(tableau): resolution ?vue= + bloc URL de partage (#112) --- src/pages/tableau.py | 87 +++++++++++++++++++++++++ tests/saved_views/test_tableau_share.py | 87 +++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/saved_views/test_tableau_share.py diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 987004b..283e190 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,6 +1,7 @@ import json import os from datetime import datetime +from urllib.parse import parse_qs import dash_bootstrap_components as dbc from dash import ( @@ -22,6 +23,7 @@ from src.db import schema 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 resolve as saved_views_resolve from src.saved_views import ui as saved_views_ui from src.utils import get_data_update_timestamp, logger from src.utils.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs @@ -128,6 +130,9 @@ layout = [ dcc.Store(id="tableau-table"), dcc.Store(id="tableau-total"), dcc.Store(id="tableau-total-unique"), + dcc.Store(id="active-view"), + dcc.Store(id="suppress-next", data=0), + dcc.Store(id="vue-resolution"), html.Script( type="application/ld+json", id="dataset_jsonld", @@ -197,6 +202,7 @@ layout = [ [], id="header", ), + html.Div(id="vue-resolve-feedback"), dcc.Loading( overlay_style={"visibility": "visible", "filter": "blur(2px)"}, id="loading-home", @@ -378,6 +384,30 @@ layout = [ ], className="table-toolbar", ), + html.Div( + id="share-url-box", + style={"display": "none"}, + className="d-flex align-items-center gap-2 my-2", + children=[ + dbc.Label( + "URL directe vers cette vue :", + html_for="share-url-input", + className="mb-0", + ), + dcc.Input( + id="share-url-input", + type="text", + readOnly=True, + className="form-control form-control-sm", + style={"maxWidth": "420px"}, + ), + dcc.Clipboard( + target_id="share-url-input", + title="Copier le lien vers cette vue", + style={"cursor": "pointer", "fontSize": "1.1rem"}, + ), + ], + ), html.Div( className="table-meta", children=[ @@ -586,6 +616,63 @@ def toggle_saved_views_bar(_pathname): return saved_views_ui.bar_style(current_user_has_subscription()) +def resolve_vue_from_url(search: str) -> dict | None: + """Extrait ?vue=... de la query string et le résout. Renvoie None s'il n'y a + pas de paramètre `vue` (chargement normal du tableau).""" + params = parse_qs((search or "").lstrip("?")) + values = params.get("vue") + if not values: + return None + return saved_views_resolve.resolve_vue_param(values[0], schema) + + +@callback( + Output("vue-resolution", "data"), + Input("tableau_url", "search"), +) +def store_vue_resolution(search): + resolution = resolve_vue_from_url(search) + return resolution if resolution is not None else no_update + + +def apply_vue_resolution(resolution): + """Mappe le dict de résolution vers les sorties de la grille + stores. Séparé + du callback pour être testable sans contexte Dash.""" + if resolution is None: + return (no_update,) * 6 + if not resolution["found"]: + return ( + no_update, + no_update, + no_update, + None, # active-view : masque le bloc de partage + 0, + html.Div(resolution["error"], className="alert alert-warning py-2"), + ) + return ( + resolution["filter_model"], + resolution["column_state"], + resolution["hidden_columns"], + {"token": resolution["token"], "url": resolution["url"]}, + 1, # suppress-next : neutralise l'écho de l'application (verrou one-shot) + "", + ) + + +@callback( + Output("tableau_grid", "filterModel", allow_duplicate=True), + Output("tableau_grid", "columnState", allow_duplicate=True), + Output("tableau-hidden-columns", "data", allow_duplicate=True), + Output("active-view", "data", allow_duplicate=True), + Output("suppress-next", "data", allow_duplicate=True), + Output("vue-resolve-feedback", "children"), + Input("vue-resolution", "data"), + prevent_initial_call=True, +) +def apply_vue_resolution_cb(resolution): + return apply_vue_resolution(resolution) + + @callback( Output("save-view-modal", "is_open"), Input("btn-save-view", "n_clicks"), diff --git a/tests/saved_views/test_tableau_share.py b/tests/saved_views/test_tableau_share.py new file mode 100644 index 0000000..428423c --- /dev/null +++ b/tests/saved_views/test_tableau_share.py @@ -0,0 +1,87 @@ +import json + +import dash + +import src.app # noqa: F401 # instancie l'app → register_page() +from src.auth import db as auth_db +from src.pages import tableau +from src.saved_views import db as saved_views_db +from src.saved_views import resolve +from src.utils.query_ast import And, Condition, ast_to_dict + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def _seed(uid, name="Ma vue"): + ast = And([Condition("objet", "contains", "route")]) + query = json.dumps( + {"ast": ast_to_dict(ast), "columnState": [{"colId": "montant", "hide": True}]} + ) + return saved_views_db.upsert(uid, "tableau", name, query) + + +def test_resolve_vue_from_url_found(monkeypatch, users_db_path): + monkeypatch.setattr(resolve.ui, "DOMAIN_NAME", "test.colibre.fr") + saved_views_db.init_schema() + uid = _make_user() + token = _seed(uid) + out = tableau.resolve_vue_from_url(f"?vue=ma-vue_{token}") + assert out["found"] is True + assert out["token"] == token + + +def test_resolve_vue_from_url_no_param_returns_none(users_db_path): + saved_views_db.init_schema() + assert tableau.resolve_vue_from_url("") is None + assert tableau.resolve_vue_from_url("?autre=1") is None + + +def test_apply_vue_resolution_found_shows_box(users_db_path): + resolution = { + "found": True, + "filter_model": { + "objet": {"filterType": "text", "type": "contains", "filter": "route"} + }, + "column_state": [{"colId": "montant", "hide": True}], + "hidden_columns": ["montant"], + "token": "abc123", + "url": "https://test.colibre.fr/tableau?vue=ma-vue_abc123", + "error": None, + } + fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution( + resolution + ) + assert fm == resolution["filter_model"] + assert cs == resolution["column_state"] + assert hidden == ["montant"] + assert active == {"token": "abc123", "url": resolution["url"]} + assert suppress == 1 + assert feedback == "" + + +def test_apply_vue_resolution_not_found_shows_alert(users_db_path): + resolution = { + "found": False, + "filter_model": None, + "column_state": None, + "hidden_columns": None, + "token": None, + "url": None, + "error": resolve.NOT_FOUND_MESSAGE, + } + fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution( + resolution + ) + assert fm is dash.no_update + assert cs is dash.no_update + assert active is None + assert suppress == 0 + assert resolve.NOT_FOUND_MESSAGE in str(feedback) + + +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)