feat(tableau): resolution ?vue= + bloc URL de partage (#112)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import (
|
from dash import (
|
||||||
@@ -22,6 +23,7 @@ from src.db import schema
|
|||||||
from src.figures import ag_grid, make_column_picker
|
from src.figures import ag_grid, make_column_picker
|
||||||
from src.pages._compte_shell import current_user_has_subscription
|
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 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.saved_views import ui as saved_views_ui
|
||||||
from src.utils import get_data_update_timestamp, logger
|
from src.utils import get_data_update_timestamp, logger
|
||||||
from src.utils.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs
|
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-table"),
|
||||||
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="suppress-next", data=0),
|
||||||
|
dcc.Store(id="vue-resolution"),
|
||||||
html.Script(
|
html.Script(
|
||||||
type="application/ld+json",
|
type="application/ld+json",
|
||||||
id="dataset_jsonld",
|
id="dataset_jsonld",
|
||||||
@@ -197,6 +202,7 @@ layout = [
|
|||||||
[],
|
[],
|
||||||
id="header",
|
id="header",
|
||||||
),
|
),
|
||||||
|
html.Div(id="vue-resolve-feedback"),
|
||||||
dcc.Loading(
|
dcc.Loading(
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
id="loading-home",
|
id="loading-home",
|
||||||
@@ -378,6 +384,30 @@ layout = [
|
|||||||
],
|
],
|
||||||
className="table-toolbar",
|
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(
|
html.Div(
|
||||||
className="table-meta",
|
className="table-meta",
|
||||||
children=[
|
children=[
|
||||||
@@ -586,6 +616,63 @@ def toggle_saved_views_bar(_pathname):
|
|||||||
return saved_views_ui.bar_style(current_user_has_subscription())
|
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(
|
@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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user