e0828a7e13
- Bouton de copie explicite (icône copy.svg + « Copier le lien » + retour « ✓ Copié ») sur /tableau et /compte/vues, via dcc.Clipboard (children / copied_children), à la place de l'icône seule. - Le lien direct s'affiche en texte (span#share-url-text) plutôt qu'un <input> : évite la troncature d'affichage du champ Bootstrap form-control en flex (zone de texte bloquée ~218px), montre tout le lien avec retour à la ligne, et prend la largeur du lien. Stylé comme un champ (bordure, coins arrondis, texte 90%). - Instructions /compte/vues simplifiées ; E2E adapté (#share-url-text).
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
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
|
|
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, 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 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, feedback = tableau.apply_vue_resolution(resolution)
|
|
assert fm is dash.no_update
|
|
assert cs is dash.no_update
|
|
assert active is None
|
|
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)
|
|
|
|
|
|
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():
|
|
class_name, value = tableau.render_share_box(
|
|
{"token": "abc123", "url": "https://x/tableau?vue=a_abc123"}
|
|
)
|
|
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():
|
|
class_name, value = tableau.render_share_box(None)
|
|
assert "d-none" in class_name
|
|
assert value == ""
|
|
|
|
|
|
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()
|
|
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)
|
|
assert out[3] == {
|
|
"token": token,
|
|
"url": f"https://test.colibre.fr/tableau?vue=ma-vue_{token}",
|
|
}
|
|
|
|
|
|
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)
|
|
assert out[3]["url"].startswith("https://test.colibre.fr/tableau?vue=nouvelle_")
|
|
|
|
|
|
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 affiche l'URL courte (jeton). Le texte est
|
|
# renseigné par le même callback que l'affichage : le 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-text", timeout=6)
|
|
WebDriverWait(dash_duo.driver, 10).until(
|
|
lambda _d: token in (dash_duo.find_element("#share-url-text").text or "")
|
|
)
|
|
|
|
# Le bouton de copie porte un libellé explicite (UX : pas d'icône seule).
|
|
assert "Copier le lien" in dash_duo.find_element("#share-url-box").text
|
|
|
|
# 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)
|