feat(tableau): masquage du bloc de partage piloté par événements AG Grid (#112)
Remplace le verrou de dérive à compteur (suppress-next) par une détection événementielle : eventListeners AG Grid (filterChanged/sortChanged) → dashAgGridFunctions.hideShareOnUserAction, qui efface active-view sur action utilisateur (source != 'api') et ignore l'écho de l'application programmatique. Le compteur suppress-next souffrait d'une course d'ordonnancement Dash (l'écho lisait la valeur pré-batch) et le diff de columnState était impraticable (état réémis bruité). La visibilité du bloc bascule via la classe (d-flex/d-none) et non le style inline, car .d-flex est display:flex !important. Task 7 : test E2E de bout en bout (application + masquage sur action).
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1184,6 +1184,7 @@ def ag_grid(
|
|||||||
column_defs: list[dict],
|
column_defs: list[dict],
|
||||||
persisted_props=("filterModel", "columnState"),
|
persisted_props=("filterModel", "columnState"),
|
||||||
persistence: bool = True,
|
persistence: bool = True,
|
||||||
|
event_listeners: "dict | None" = None,
|
||||||
) -> "dag.AgGrid":
|
) -> "dag.AgGrid":
|
||||||
"""Grille AG Grid server-side (infinite) pour la page Tableau.
|
"""Grille AG Grid server-side (infinite) pour la page Tableau.
|
||||||
|
|
||||||
@@ -1195,6 +1196,7 @@ def ag_grid(
|
|||||||
id=grid_id,
|
id=grid_id,
|
||||||
columnDefs=column_defs,
|
columnDefs=column_defs,
|
||||||
defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True},
|
defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True},
|
||||||
|
eventListeners=event_listeners,
|
||||||
rowModelType="infinite",
|
rowModelType="infinite",
|
||||||
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
|
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
|
||||||
dashGridOptions={
|
dashGridOptions={
|
||||||
|
|||||||
+48
-38
@@ -65,10 +65,30 @@ register_page(
|
|||||||
order=1,
|
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(
|
DATATABLE = html.Div(
|
||||||
className="marches_table",
|
className="marches_table",
|
||||||
children=ag_grid(
|
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"),
|
||||||
dcc.Store(id="tableau-total-unique"),
|
dcc.Store(id="tableau-total-unique"),
|
||||||
dcc.Store(id="active-view"),
|
dcc.Store(id="active-view"),
|
||||||
dcc.Store(id="suppress-next", data=0),
|
|
||||||
dcc.Store(id="vue-resolution"),
|
dcc.Store(id="vue-resolution"),
|
||||||
html.Script(
|
html.Script(
|
||||||
type="application/ld+json",
|
type="application/ld+json",
|
||||||
@@ -386,8 +405,7 @@ layout = [
|
|||||||
),
|
),
|
||||||
html.Div(
|
html.Div(
|
||||||
id="share-url-box",
|
id="share-url-box",
|
||||||
style={"display": "none"},
|
className="share-url-box d-none",
|
||||||
className="d-flex align-items-center gap-2 my-2",
|
|
||||||
children=[
|
children=[
|
||||||
dbc.Label(
|
dbc.Label(
|
||||||
"URL directe vers cette vue :",
|
"URL directe vers cette vue :",
|
||||||
@@ -636,17 +654,24 @@ def store_vue_resolution(search):
|
|||||||
|
|
||||||
|
|
||||||
def apply_vue_resolution(resolution):
|
def apply_vue_resolution(resolution):
|
||||||
"""Mappe le dict de résolution vers les sorties de la grille + stores. Séparé
|
"""Mappe le dict de résolution vers les sorties de la grille + le store
|
||||||
du callback pour être testable sans contexte Dash."""
|
`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:
|
if resolution is None:
|
||||||
return (no_update,) * 6
|
return (no_update,) * 5
|
||||||
if not resolution["found"]:
|
if not resolution["found"]:
|
||||||
return (
|
return (
|
||||||
no_update,
|
no_update,
|
||||||
no_update,
|
no_update,
|
||||||
no_update,
|
no_update,
|
||||||
None, # active-view : masque le bloc de partage
|
None, # active-view : masque le bloc de partage
|
||||||
0,
|
|
||||||
html.Div(resolution["error"], className="alert alert-warning py-2"),
|
html.Div(resolution["error"], className="alert alert-warning py-2"),
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
@@ -654,7 +679,6 @@ def apply_vue_resolution(resolution):
|
|||||||
resolution["column_state"],
|
resolution["column_state"],
|
||||||
resolution["hidden_columns"],
|
resolution["hidden_columns"],
|
||||||
{"token": resolution["token"], "url": resolution["url"]},
|
{"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_grid", "columnState", allow_duplicate=True),
|
||||||
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
||||||
Output("active-view", "data", allow_duplicate=True),
|
Output("active-view", "data", allow_duplicate=True),
|
||||||
Output("suppress-next", "data", allow_duplicate=True),
|
|
||||||
Output("vue-resolve-feedback", "children"),
|
Output("vue-resolve-feedback", "children"),
|
||||||
Input("vue-resolution", "data"),
|
Input("vue-resolution", "data"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
@@ -673,32 +696,23 @@ def apply_vue_resolution_cb(resolution):
|
|||||||
return apply_vue_resolution(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(
|
@callback(
|
||||||
Output("share-url-box", "style"),
|
Output("share-url-box", "className"),
|
||||||
Output("share-url-input", "value"),
|
Output("share-url-input", "value"),
|
||||||
Input("active-view", "data"),
|
Input("active-view", "data"),
|
||||||
)
|
)
|
||||||
def render_share_box(active_view):
|
def render_share_box(active_view):
|
||||||
if active_view and active_view.get("url"):
|
if active_view and active_view.get("url"):
|
||||||
return {}, active_view["url"]
|
return _SHARE_BOX_SHOWN, active_view["url"]
|
||||||
return {"display": "none"}, ""
|
return _SHARE_BOX_HIDDEN, ""
|
||||||
|
|
||||||
|
|
||||||
@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(
|
@callback(
|
||||||
@@ -715,7 +729,6 @@ def toggle_save_view_modal(_open):
|
|||||||
Output("save-view-feedback", "children"),
|
Output("save-view-feedback", "children"),
|
||||||
Output("saved-views-refresh", "data"),
|
Output("saved-views-refresh", "data"),
|
||||||
Output("active-view", "data", allow_duplicate=True),
|
Output("active-view", "data", allow_duplicate=True),
|
||||||
Output("suppress-next", "data", allow_duplicate=True),
|
|
||||||
Input("btn-save-view-confirm", "n_clicks"),
|
Input("btn-save-view-confirm", "n_clicks"),
|
||||||
State("save-view-name", "value"),
|
State("save-view-name", "value"),
|
||||||
State("tableau_grid", "filterModel"),
|
State("tableau_grid", "filterModel"),
|
||||||
@@ -731,7 +744,6 @@ def save_view(_n, name, filter_model, column_state):
|
|||||||
html.Span(error, style={"color": "red"}),
|
html.Span(error, style={"color": "red"}),
|
||||||
no_update,
|
no_update,
|
||||||
no_update,
|
no_update,
|
||||||
no_update,
|
|
||||||
)
|
)
|
||||||
# On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut
|
# 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,
|
# 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"}),
|
html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}),
|
||||||
clean_name,
|
clean_name,
|
||||||
active,
|
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_grid", "columnState"),
|
||||||
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
||||||
Output("active-view", "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"),
|
Input({"type": "saved-view-item", "index": ALL}, "n_clicks"),
|
||||||
State({"type": "saved-view-item", "index": ALL}, "id"),
|
State({"type": "saved-view-item", "index": ALL}, "id"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
@@ -774,10 +784,10 @@ def populate_saved_views_menu(_pathname, _refresh):
|
|||||||
def apply_saved_view(n_clicks, ids):
|
def apply_saved_view(n_clicks, ids):
|
||||||
triggered = ctx.triggered_id
|
triggered = ctx.triggered_id
|
||||||
if not triggered or not any(n_clicks):
|
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)
|
row = saved_views_db.get(triggered["index"], current_user.id)
|
||||||
if not row:
|
if not row:
|
||||||
return no_update, no_update, no_update, no_update, no_update
|
return no_update, no_update, no_update, no_update
|
||||||
try:
|
try:
|
||||||
view = json.loads(row["query"])
|
view = json.loads(row["query"])
|
||||||
# L'AST canonique est stocké (pas le filterModel brut d'AG Grid) :
|
# 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 : "
|
"Vue sauvegardée au format pré-migration, impossible de l'appliquer : "
|
||||||
f"id={row['id']!r} name={row['name']!r}"
|
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
|
# tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes
|
||||||
# (update_checkboxes_from_hidden_columns) et la régénération des
|
# (update_checkboxes_from_hidden_columns) et la régénération des
|
||||||
# columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait
|
# columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait
|
||||||
@@ -808,7 +818,7 @@ def apply_saved_view(n_clicks, ids):
|
|||||||
"token": row["token"],
|
"token": row["token"],
|
||||||
"url": saved_views_ui.build_view_url(row["name"], 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(
|
@callback(
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_pat
|
|||||||
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
||||||
|
|
||||||
with patch.object(tableau, "current_user", _fake_user(uid)):
|
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}]
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert filter_model is dash.no_update
|
assert filter_model is dash.no_update
|
||||||
assert column_state is dash.no_update
|
assert column_state is dash.no_update
|
||||||
assert hidden_columns 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):
|
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)
|
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
||||||
|
|
||||||
with patch.object(tableau, "current_user", _fake_user(uid)):
|
with patch.object(tableau, "current_user", _fake_user(uid)):
|
||||||
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
|
filter_model, returned_column_state, hidden_columns, active = (
|
||||||
[1], [{"type": "saved-view-item", "index": view_id}]
|
tableau.apply_saved_view(
|
||||||
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert filter_model == {
|
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
|
# tableau-hidden-columns doit être resynchronisé à partir du columnState
|
||||||
# rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True.
|
# rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True.
|
||||||
assert hidden_columns == ["acheteur_nom"]
|
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(
|
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)
|
monkeypatch.setattr(tableau, "ctx", _Ctx)
|
||||||
|
|
||||||
with patch.object(tableau, "current_user", _fake_user(uid)):
|
with patch.object(tableau, "current_user", _fake_user(uid)):
|
||||||
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
|
filter_model, returned_column_state, hidden_columns, _active = (
|
||||||
[1], [{"type": "saved-view-item", "index": view_id}]
|
tableau.apply_saved_view(
|
||||||
|
[1], [{"type": "saved-view-item", "index": view_id}]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert filter_model == {}
|
assert filter_model == {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import dash
|
import dash
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
|
||||||
import src.app # noqa: F401 # instancie l'app → register_page()
|
import src.app # noqa: F401 # instancie l'app → register_page()
|
||||||
from src.auth import db as auth_db
|
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",
|
"url": "https://test.colibre.fr/tableau?vue=ma-vue_abc123",
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution(
|
fm, cs, hidden, active, feedback = tableau.apply_vue_resolution(resolution)
|
||||||
resolution
|
|
||||||
)
|
|
||||||
assert fm == resolution["filter_model"]
|
assert fm == resolution["filter_model"]
|
||||||
assert cs == resolution["column_state"]
|
assert cs == resolution["column_state"]
|
||||||
assert hidden == ["montant"]
|
assert hidden == ["montant"]
|
||||||
assert active == {"token": "abc123", "url": resolution["url"]}
|
assert active == {"token": "abc123", "url": resolution["url"]}
|
||||||
assert suppress == 1
|
|
||||||
assert feedback == ""
|
assert feedback == ""
|
||||||
|
|
||||||
|
|
||||||
@@ -73,13 +71,10 @@ def test_apply_vue_resolution_not_found_shows_alert(users_db_path):
|
|||||||
"url": None,
|
"url": None,
|
||||||
"error": resolve.NOT_FOUND_MESSAGE,
|
"error": resolve.NOT_FOUND_MESSAGE,
|
||||||
}
|
}
|
||||||
fm, cs, hidden, active, suppress, feedback = tableau.apply_vue_resolution(
|
fm, cs, hidden, active, feedback = tableau.apply_vue_resolution(resolution)
|
||||||
resolution
|
|
||||||
)
|
|
||||||
assert fm is dash.no_update
|
assert fm is dash.no_update
|
||||||
assert cs is dash.no_update
|
assert cs is dash.no_update
|
||||||
assert active is None
|
assert active is None
|
||||||
assert suppress == 0
|
|
||||||
assert resolve.NOT_FOUND_MESSAGE in str(feedback)
|
assert resolve.NOT_FOUND_MESSAGE in str(feedback)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,31 +95,21 @@ class _Ctx:
|
|||||||
|
|
||||||
|
|
||||||
def test_render_share_box_visible_when_active():
|
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"}
|
{"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"
|
assert value == "https://x/tableau?vue=a_abc123"
|
||||||
|
|
||||||
|
|
||||||
def test_render_share_box_hidden_when_none():
|
def test_render_share_box_hidden_when_none():
|
||||||
style, value = tableau.render_share_box(None)
|
class_name, value = tableau.render_share_box(None)
|
||||||
assert style == {"display": "none"}
|
assert "d-none" in class_name
|
||||||
assert value == ""
|
assert value == ""
|
||||||
|
|
||||||
|
|
||||||
def test_hide_lock_consumes_echo_then_hides():
|
def test_apply_saved_view_sets_active(monkeypatch, users_db_path):
|
||||||
# 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")
|
monkeypatch.setattr(tableau.saved_views_ui, "DOMAIN_NAME", "test.colibre.fr")
|
||||||
saved_views_db.init_schema()
|
saved_views_db.init_schema()
|
||||||
uid = _make_user()
|
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(
|
out = tableau.apply_saved_view(
|
||||||
[1], [{"type": "saved-view-item", "index": view_id}]
|
[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] == {
|
assert out[3] == {
|
||||||
"token": token,
|
"token": token,
|
||||||
"url": f"https://test.colibre.fr/tableau?vue=ma-vue_{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):
|
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)
|
monkeypatch.setattr(tableau, "current_user_has_subscription", lambda: True)
|
||||||
with patch.object(tableau, "current_user", _fake_user(uid)):
|
with patch.object(tableau, "current_user", _fake_user(uid)):
|
||||||
out = tableau.save_view(1, "Nouvelle", {}, [])
|
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[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=<token> 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user