diff --git a/src/assets/dashAgGridFunctions.js b/src/assets/dashAgGridFunctions.js new file mode 100644 index 0000000..0661589 --- /dev/null +++ b/src/assets/dashAgGridFunctions.js @@ -0,0 +1,30 @@ +// Fonctions AG Grid exposées à Dash AG Grid via le namespace +// window.dashAgGridFunctions (cf. eventListeners de la grille du Tableau). +var dagfuncs = (window.dashAgGridFunctions = window.dashAgGridFunctions || {}); + +// Sources d'événements AG Grid qui ne correspondent PAS à une action de +// l'utilisateur : application programmatique d'une vue (props filterModel / +// columnState → source 'api') et initialisation de la grille. On les ignore ; +// toute autre source (ex. 'columnFilter', 'uiColumnSorted', 'toolPanelUi', +// 'columnMenu') est une modification volontaire de l'état par l'utilisateur. +var SHARE_PROGRAMMATIC_SOURCES = [ + "api", + "gridInitializing", + "gridOptionsChanged", + "gridOptionsUpdated", + "columnDefsUpdated", +]; + +// Masque le bloc « URL directe » (share-url-box) dès que l'utilisateur modifie +// filtre / tri / visibilité de colonne, en effaçant le store active-view. +// L'écho de l'application d'une vue (source 'api') est ignoré : plus de course +// d'ordonnancement ni de diff d'état fragile. +dagfuncs.hideShareOnUserAction = function (params) { + var source = params && params.source; + if (SHARE_PROGRAMMATIC_SOURCES.indexOf(source) !== -1) { + return; + } + if (window.dash_clientside && window.dash_clientside.set_props) { + window.dash_clientside.set_props("active-view", { data: null }); + } +}; diff --git a/src/figures.py b/src/figures.py index e110bab..709a1fc 100644 --- a/src/figures.py +++ b/src/figures.py @@ -1184,6 +1184,7 @@ def ag_grid( column_defs: list[dict], persisted_props=("filterModel", "columnState"), persistence: bool = True, + event_listeners: "dict | None" = None, ) -> "dag.AgGrid": """Grille AG Grid server-side (infinite) pour la page Tableau. @@ -1195,6 +1196,7 @@ def ag_grid( id=grid_id, columnDefs=column_defs, defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True}, + eventListeners=event_listeners, rowModelType="infinite", dangerously_allow_code=True, # rend le HTML des cellules liens dashGridOptions={ diff --git a/src/pages/tableau.py b/src/pages/tableau.py index aae2f4a..3fccde0 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -65,10 +65,30 @@ register_page( order=1, ) +# Le bloc « URL directe » (share-url-box) reste affiché tant que la vue partagée +# n'a pas dérivé. On détecte la dérive par des écouteurs d'événements AG Grid +# côté client plutôt que par diff d'état : l'application programmatique d'une vue +# (source 'api'/'gridOptionsChanged') est ignorée, seules les actions utilisateur +# effacent `active-view` et masquent le bloc. Voir +# src/assets/dashAgGridFunctions.js. Cette approche évite la course +# d'ordonnancement d'un compteur d'échos et le bruit du columnState réémis. +# +# On écoute filtre et tri (signaux natifs fiables, source utilisateur distincte +# de 'api'). La visibilité des colonnes est pilotée par le sélecteur « Colonnes » +# maison (store tableau-hidden-columns → régénération des columnDefs → événement +# columnVisible de source 'gridOptionsChanged', identique pour l'utilisateur et +# l'application programmatique) : indistinguable via AG Grid, donc non écoutée. +_SHARE_DRIFT_LISTENERS = { + event: ["hideShareOnUserAction(params)"] + for event in ("filterChanged", "sortChanged") +} + DATATABLE = html.Div( className="marches_table", children=ag_grid( - "tableau_grid", grid_column_defs(get_default_hidden_columns("tableau")) + "tableau_grid", + grid_column_defs(get_default_hidden_columns("tableau")), + event_listeners=_SHARE_DRIFT_LISTENERS, ), ) @@ -131,7 +151,6 @@ layout = [ 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", @@ -386,8 +405,7 @@ layout = [ ), html.Div( id="share-url-box", - style={"display": "none"}, - className="d-flex align-items-center gap-2 my-2", + className="share-url-box d-none", children=[ dbc.Label( "URL directe vers cette vue :", @@ -636,17 +654,24 @@ def store_vue_resolution(search): 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.""" + """Mappe le dict de résolution vers les sorties de la grille + le store + `active-view`. Séparé du callback pour être testable sans contexte Dash. + + Sorties : (filterModel, columnState, hidden-columns, active-view, feedback). + Le masquage du bloc de partage n'est PAS géré par comparaison d'état ici : + c'est un écouteur d'événements AG Grid côté client (eventListeners → + `dashAgGridFunctions.hideShareOnUserAction`) qui efface `active-view` sur + action utilisateur (filtre/tri/colonne), en ignorant l'application + programmatique (source == 'api'). Voir src/assets/dashAgGridFunctions.js. + """ if resolution is None: - return (no_update,) * 6 + return (no_update,) * 5 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 ( @@ -654,7 +679,6 @@ def apply_vue_resolution(resolution): resolution["column_state"], resolution["hidden_columns"], {"token": resolution["token"], "url": resolution["url"]}, - 1, # suppress-next : neutralise l'écho de l'application (verrou one-shot) "", ) @@ -664,7 +688,6 @@ def apply_vue_resolution(resolution): 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, @@ -673,32 +696,23 @@ def apply_vue_resolution_cb(resolution): return apply_vue_resolution(resolution) +# Classe Bootstrap de visibilité du bloc de partage. On bascule la CLASSE +# (d-flex ↔ d-none, toutes deux `!important`) et non le style inline : `.d-flex` +# est `display: flex !important` et l'emporterait sur un `style={"display": +# "none"}` inline, rendant le bloc impossible à masquer par le style. +_SHARE_BOX_SHOWN = "share-url-box d-flex align-items-center gap-2 my-2" +_SHARE_BOX_HIDDEN = "share-url-box d-none" + + @callback( - Output("share-url-box", "style"), + Output("share-url-box", "className"), 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 + return _SHARE_BOX_SHOWN, active_view["url"] + return _SHARE_BOX_HIDDEN, "" @callback( @@ -715,7 +729,6 @@ def toggle_save_view_modal(_open): 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"), @@ -731,7 +744,6 @@ def save_view(_n, name, filter_model, column_state): 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, @@ -745,7 +757,6 @@ def save_view(_n, name, filter_model, column_state): 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 ) @@ -766,7 +777,6 @@ def populate_saved_views_menu(_pathname, _refresh): 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, @@ -774,10 +784,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, no_update, no_update + return 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, no_update, no_update + return 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) : @@ -797,7 +807,7 @@ 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, no_update, no_update + return 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 @@ -808,7 +818,7 @@ def apply_saved_view(n_clicks, ids): "token": row["token"], "url": saved_views_ui.build_view_url(row["name"], row["token"]), } - return filter_model, column_state, hidden_columns, active, 1 + return filter_model, column_state, hidden_columns, active @callback( diff --git a/tests/saved_views/test_apply_saved_view.py b/tests/saved_views/test_apply_saved_view.py index 0ebabaf..8f77e8c 100644 --- a/tests/saved_views/test_apply_saved_view.py +++ b/tests/saved_views/test_apply_saved_view.py @@ -48,13 +48,14 @@ def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_pat monkeypatch.setattr(tableau, "ctx", _Ctx) with patch.object(tableau, "current_user", _fake_user(uid)): - filter_model, column_state, hidden_columns = tableau.apply_saved_view( + filter_model, column_state, hidden_columns, active = 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 assert hidden_columns is dash.no_update + assert active is dash.no_update def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): @@ -75,8 +76,10 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): monkeypatch.setattr(tableau, "ctx", _Ctx) with patch.object(tableau, "current_user", _fake_user(uid)): - filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( - [1], [{"type": "saved-view-item", "index": view_id}] + filter_model, returned_column_state, hidden_columns, active = ( + tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) ) assert filter_model == { @@ -86,6 +89,8 @@ def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path): # tableau-hidden-columns doit être resynchronisé à partir du columnState # rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True. assert hidden_columns == ["acheteur_nom"] + # active-view alimente le bloc de partage (token + URL courte). + assert active["token"] and active["url"].endswith(f"_{active['token']}") def test_apply_saved_view_missing_ast_key_degrades_gracefully( @@ -111,8 +116,10 @@ def test_apply_saved_view_missing_ast_key_degrades_gracefully( monkeypatch.setattr(tableau, "ctx", _Ctx) with patch.object(tableau, "current_user", _fake_user(uid)): - filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view( - [1], [{"type": "saved-view-item", "index": view_id}] + filter_model, returned_column_state, hidden_columns, _active = ( + tableau.apply_saved_view( + [1], [{"type": "saved-view-item", "index": view_id}] + ) ) assert filter_model == {} diff --git a/tests/saved_views/test_tableau_share.py b/tests/saved_views/test_tableau_share.py index 7f42795..d6777d5 100644 --- a/tests/saved_views/test_tableau_share.py +++ b/tests/saved_views/test_tableau_share.py @@ -2,6 +2,7 @@ import json from unittest.mock import patch import dash +from selenium.webdriver.support.ui import WebDriverWait import src.app # noqa: F401 # instancie l'app → register_page() from src.auth import db as auth_db @@ -52,14 +53,11 @@ def test_apply_vue_resolution_found_shows_box(users_db_path): "url": "https://test.colibre.fr/tableau?vue=ma-vue_abc123", "error": None, } - fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution( - resolution - ) + fm, cs, hidden, active, 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 == "" @@ -73,13 +71,10 @@ def test_apply_vue_resolution_not_found_shows_alert(users_db_path): "url": None, "error": resolve.NOT_FOUND_MESSAGE, } - fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution( - resolution - ) + fm, cs, hidden, active, 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) @@ -100,31 +95,21 @@ class _Ctx: def test_render_share_box_visible_when_active(): - style, value = tableau.render_share_box( + class_name, value = tableau.render_share_box( {"token": "abc123", "url": "https://x/tableau?vue=a_abc123"} ) - assert style == {} + assert "d-flex" in class_name + assert "d-none" not in class_name 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"} + class_name, value = tableau.render_share_box(None) + assert "d-none" in class_name 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): +def test_apply_saved_view_sets_active(monkeypatch, users_db_path): monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr") saved_views_db.init_schema() uid = _make_user() @@ -136,12 +121,11 @@ def test_apply_saved_view_sets_active_and_suppress(monkeypatch, users_db_path): out = tableau.apply_saved_view( [1], [{"type": "saved-view-item", "index": view_id}] ) - # (filter_model, column_state, hidden, active-view, suppress-next) + # (filter_model, column_state, hidden, active-view) 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): @@ -151,6 +135,57 @@ def test_save_view_shows_box(monkeypatch, users_db_path): 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) + # (is_open, feedback, refresh, active-view) 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 + + +def _type_in_first_filter(dash_duo): + """Saisit du texte dans le premier filtre flottant présent dans le DOM (AG + Grid virtualise horizontalement : 'objet' peut être hors DOM). Re-localise + l'élément à chaque appel : la grille se re-rend juste après l'application de + la vue (echo columnVisible) et remplace l'input.""" + selector = "#tableau_grid .ag-floating-filter-input input" + dash_duo.wait_for_element(selector, timeout=8) + el = dash_duo.find_elements(selector)[0] + dash_duo.driver.execute_script( + "arguments[0].scrollIntoView({block: 'center', inline: 'center'});", el + ) + el.click() + el.send_keys("z") + + +def test_open_shared_view_applies_and_shows_box(dash_duo, users_db_path): + """?vue= applique la vue et affiche le bloc de partage ; une action + utilisateur (saisie d'un filtre) le masque via l'écouteur d'événements AG + Grid (source utilisateur ≠ 'api'), l'écho de l'application étant ignoré.""" + saved_views_db.init_schema() + uid = _make_user() + token = _seed(uid, "Ma vue") + + from src.app import app + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=6) + dash_duo.wait_for_page(dash_duo.server_url + f"/tableau?vue=ma-vue_{token}") + + # Le bloc de partage est visible et contient l'URL courte (jeton). La valeur + # de l'input est renseignée par le même callback que l'affichage : la lire + # non vide prouve que l'écho de l'application n'a PAS masqué le bloc. + dash_duo.wait_for_style_to_equal("#share-url-box", "display", "flex", timeout=10) + dash_duo.wait_for_element("#share-url-input", timeout=6) + WebDriverWait(dash_duo.driver, 10).until( + lambda _d: token + in (dash_duo.find_element("#share-url-input").get_attribute("value") or "") + ) + + # Une action utilisateur (filtre) masque la box. On re-tente la saisie tant + # que le bloc n'est pas masqué : la fenêtre de re-render post-application + # peut invalider l'input entre le find et le send_keys. + def _box_hidden(_d): + _type_in_first_filter(dash_duo) + display = dash_duo.find_element("#share-url-box").value_of_css_property( + "display" + ) + return display == "none" + + WebDriverWait(dash_duo.driver, 15).until(_box_hidden)