feat(tableau): verrou de derive + affichage du bloc de partage (#112)
This commit is contained in:
+51
-6
@@ -673,6 +673,34 @@ def apply_vue_resolution_cb(resolution):
|
|||||||
return apply_vue_resolution(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(
|
@callback(
|
||||||
Output("save-view-modal", "is_open"),
|
Output("save-view-modal", "is_open"),
|
||||||
Input("btn-save-view", "n_clicks"),
|
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-modal", "is_open", allow_duplicate=True),
|
||||||
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("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"),
|
||||||
@@ -696,17 +726,26 @@ def save_view(_n, name, filter_model, column_state):
|
|||||||
has_sub = current_user_has_subscription()
|
has_sub = current_user_has_subscription()
|
||||||
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
|
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
|
||||||
if error:
|
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
|
# 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,
|
||||||
# indépendant de l'UI".
|
# indépendant de l'UI".
|
||||||
ast = filtermodel_to_ast(filter_model, schema)
|
ast = filtermodel_to_ast(filter_model, schema)
|
||||||
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []})
|
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 (
|
return (
|
||||||
False,
|
False,
|
||||||
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,
|
||||||
|
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", "filterModel"),
|
||||||
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("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,
|
||||||
@@ -733,10 +774,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
|
return no_update, 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
|
return no_update, 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) :
|
||||||
@@ -756,14 +797,18 @@ 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
|
return no_update, 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
|
||||||
# désynchronisé du columnState rappelé (revue finale #41). Même extraction
|
# désynchronisé du columnState rappelé (revue finale #41). Même extraction
|
||||||
# que download_data.
|
# que download_data.
|
||||||
hidden_columns = [c["colId"] for c in column_state if c.get("hide")]
|
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(
|
@callback(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import dash
|
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):
|
def test_apply_vue_resolution_none_is_noop(users_db_path):
|
||||||
out = tableau.apply_vue_resolution(None)
|
out = tableau.apply_vue_resolution(None)
|
||||||
assert all(v is dash.no_update for v in out)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user