feat(tableau): vues sauvegardées en JSON AST, retrait URL riche (#41)
Les vues stockent désormais {filterModel, columnState} en JSON dans
saved_views.query au lieu d'une query string DSL. Le rappel d'une vue
applique filterModel + columnState à la grille AG Grid via un nouveau
callback apply_saved_view, déclenché par pattern-matching sur les items
du menu "Mes vues" (désormais cliquables au lieu de liens href).
Retire restore_view_from_url, sync_url_and_reset_button,
show_confirmation, le bouton "Partager la vue" et le clientside
clean_filters de tableau.py - la migration AG Grid retire le mécanisme
d'URL riche sans rétrocompatibilité.
Le lien "Ouvrir" de compte/vues.py pointe maintenant vers /tableau nu
(limitation connue documentée, le rappel cross-page reste à faire).
This commit is contained in:
+29
-134
@@ -1,17 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import (
|
from dash import (
|
||||||
ClientsideFunction,
|
ALL,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
State,
|
State,
|
||||||
callback,
|
callback,
|
||||||
clientside_callback,
|
clientside_callback,
|
||||||
|
ctx,
|
||||||
dcc,
|
dcc,
|
||||||
html,
|
html,
|
||||||
no_update,
|
no_update,
|
||||||
@@ -24,14 +23,12 @@ 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 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
|
||||||
from src.utils.grid import fetch_grid_page, grid_column_defs
|
from src.utils.grid import fetch_grid_page, grid_column_defs
|
||||||
from src.utils.seo import META_CONTENT
|
from src.utils.seo import META_CONTENT
|
||||||
from src.utils.table import (
|
from src.utils.table import (
|
||||||
COLUMNS,
|
COLUMNS,
|
||||||
build_view_query,
|
|
||||||
get_default_hidden_columns,
|
get_default_hidden_columns,
|
||||||
invert_columns,
|
|
||||||
write_styled_excel,
|
write_styled_excel,
|
||||||
)
|
)
|
||||||
from src.utils.tracking import track_search
|
from src.utils.tracking import track_search
|
||||||
@@ -124,7 +121,6 @@ def _help_button_legend():
|
|||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
dcc.Location(id="tableau_url", refresh=False),
|
dcc.Location(id="tableau_url", refresh=False),
|
||||||
dcc.Store(id="filter-cleanup-trigger-tableau"),
|
|
||||||
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
||||||
dcc.Store(id="tableau-table"),
|
dcc.Store(id="tableau-table"),
|
||||||
dcc.Store(id="tableau-total"),
|
dcc.Store(id="tableau-total"),
|
||||||
@@ -364,8 +360,6 @@ layout = [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
html.Div(id="copy-container"),
|
|
||||||
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
|
||||||
dbc.Button(
|
dbc.Button(
|
||||||
"Télécharger (Excel)",
|
"Télécharger (Excel)",
|
||||||
id="btn-download-data",
|
id="btn-download-data",
|
||||||
@@ -488,123 +482,6 @@ def download_data(n_clicks, filter_model, column_state):
|
|||||||
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
|
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("tableau_datatable", "filter_query"),
|
|
||||||
Output("tableau_datatable", "sort_by"),
|
|
||||||
Output("tableau-hidden-columns", "data"),
|
|
||||||
Output("tableau_url", "search"),
|
|
||||||
Output("filter-cleanup-trigger-tableau", "data"),
|
|
||||||
Input("tableau_url", "search"),
|
|
||||||
State("tableau_datatable", "filter_query"),
|
|
||||||
State("tableau_datatable", "sort_by"),
|
|
||||||
)
|
|
||||||
def restore_view_from_url(search, stored_filters, stored_sort):
|
|
||||||
if not search and not stored_filters:
|
|
||||||
return no_update, no_update, no_update, no_update, no_update
|
|
||||||
|
|
||||||
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
|
|
||||||
logger.debug("params " + json.dumps(params, indent=2))
|
|
||||||
|
|
||||||
filter_query = no_update
|
|
||||||
sort_by = no_update
|
|
||||||
hidden_columns = no_update
|
|
||||||
trigger_cleanup = no_update
|
|
||||||
|
|
||||||
if "filtres" in params:
|
|
||||||
filter_query = params["filtres"][0]
|
|
||||||
trigger_cleanup = str(uuid.uuid4())
|
|
||||||
elif stored_filters:
|
|
||||||
filter_query = stored_filters
|
|
||||||
trigger_cleanup = str(uuid.uuid4())
|
|
||||||
|
|
||||||
if "tris" in params:
|
|
||||||
try:
|
|
||||||
sort_by = json.loads(params["tris"][0])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
elif stored_sort:
|
|
||||||
sort_by = stored_sort
|
|
||||||
|
|
||||||
if "colonnes" in params:
|
|
||||||
table_columns = params["colonnes"][0].split(",")
|
|
||||||
verified_columns = [
|
|
||||||
column for column in table_columns if column in schema.names()
|
|
||||||
]
|
|
||||||
hidden_columns = invert_columns(verified_columns)
|
|
||||||
|
|
||||||
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
|
|
||||||
|
|
||||||
|
|
||||||
# Pour nettoyer les icontains et i< des filtres
|
|
||||||
# voir aussi src/assets/dash_clientside.js
|
|
||||||
clientside_callback(
|
|
||||||
ClientsideFunction(
|
|
||||||
namespace="clientside",
|
|
||||||
function_name="clean_filters",
|
|
||||||
),
|
|
||||||
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
|
||||||
Input("filter-cleanup-trigger-tableau", "data"),
|
|
||||||
prevent_initial_call=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("share-url", "value"),
|
|
||||||
Output("copy-container", "children"),
|
|
||||||
Input("tableau_datatable", "filter_query"),
|
|
||||||
Input("tableau_datatable", "sort_by"),
|
|
||||||
Input("tableau_datatable", "hidden_columns"),
|
|
||||||
State("tableau_url", "href"),
|
|
||||||
prevent_initial_call=True,
|
|
||||||
)
|
|
||||||
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
|
||||||
if not href:
|
|
||||||
return no_update, no_update
|
|
||||||
|
|
||||||
# Extract base URL (remove existing query params)
|
|
||||||
base_url = href.split("?")[0]
|
|
||||||
|
|
||||||
query_string = build_view_query(filter_query, sort_by, hidden_columns)
|
|
||||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
|
||||||
|
|
||||||
copy_button = dcc.Clipboard(
|
|
||||||
id="btn-copy-url",
|
|
||||||
target_id="share-url",
|
|
||||||
title="Copier l'URL de cette vue",
|
|
||||||
style={
|
|
||||||
"display": "inline-block",
|
|
||||||
"fontSize": 20,
|
|
||||||
"verticalAlign": "top",
|
|
||||||
"cursor": "pointer",
|
|
||||||
},
|
|
||||||
className="fa fa-link",
|
|
||||||
children=[
|
|
||||||
dbc.Button(
|
|
||||||
"Partager la vue",
|
|
||||||
color="secondary",
|
|
||||||
size="sm",
|
|
||||||
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return full_url, copy_button
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
|
||||||
Output("copy-container", "children", allow_duplicate=True),
|
|
||||||
Input("btn-copy-url", "n_clicks", allow_optional=True),
|
|
||||||
prevent_initial_call=True,
|
|
||||||
)
|
|
||||||
def show_confirmation(n_clicks):
|
|
||||||
if n_clicks:
|
|
||||||
return html.Span(
|
|
||||||
"Adresse de la vue copiée",
|
|
||||||
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
|
|
||||||
)
|
|
||||||
return no_update
|
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("tableau_help", "is_open"),
|
Output("tableau_help", "is_open"),
|
||||||
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
|
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
|
||||||
@@ -666,13 +543,12 @@ def toggle_tableau_columns(click_open, click_close, is_open):
|
|||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("tableau_datatable", "filter_query", allow_duplicate=True),
|
Output("tableau_grid", "filterModel", allow_duplicate=True),
|
||||||
Output("tableau_datatable", "sort_by", allow_duplicate=True),
|
|
||||||
Input("btn-tableau-reset", "n_clicks"),
|
Input("btn-tableau-reset", "n_clicks"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def reset_view(n_clicks):
|
def reset_view(n_clicks):
|
||||||
return "", []
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
@@ -698,17 +574,18 @@ def toggle_save_view_modal(_open):
|
|||||||
Output("saved-views-refresh", "data"),
|
Output("saved-views-refresh", "data"),
|
||||||
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_datatable", "filter_query"),
|
State("tableau_grid", "filterModel"),
|
||||||
State("tableau_datatable", "sort_by"),
|
State("tableau_grid", "columnState"),
|
||||||
State("tableau_datatable", "hidden_columns"),
|
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def save_view(_n, name, filter_query, sort_by, hidden_columns):
|
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
|
||||||
query = build_view_query(filter_query, sort_by, hidden_columns)
|
query = json.dumps(
|
||||||
|
{"filterModel": filter_model or {}, "columnState": column_state or []}
|
||||||
|
)
|
||||||
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
|
saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
@@ -729,6 +606,24 @@ def populate_saved_views_menu(_pathname, _refresh):
|
|||||||
return saved_views_ui.saved_views_items(views)
|
return saved_views_ui.saved_views_items(views)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_grid", "filterModel"),
|
||||||
|
Output("tableau_grid", "columnState"),
|
||||||
|
Input({"type": "saved-view-item", "index": ALL}, "n_clicks"),
|
||||||
|
State({"type": "saved-view-item", "index": ALL}, "id"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def apply_saved_view(n_clicks, ids):
|
||||||
|
triggered = ctx.triggered_id
|
||||||
|
if not triggered or not any(n_clicks):
|
||||||
|
return no_update, no_update
|
||||||
|
row = saved_views_db.get(triggered["index"], current_user.id)
|
||||||
|
if not row:
|
||||||
|
return no_update, no_update
|
||||||
|
view = json.loads(row["query"])
|
||||||
|
return view.get("filterModel") or {}, view.get("columnState") or []
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("overwrite-view-select", "options"),
|
Output("overwrite-view-select", "options"),
|
||||||
Input("save-view-modal", "is_open"),
|
Input("save-view-modal", "is_open"),
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ def prepare_view_to_save(
|
|||||||
|
|
||||||
def saved_views_items(views) -> list:
|
def saved_views_items(views) -> list:
|
||||||
return [
|
return [
|
||||||
dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}")
|
dbc.DropdownMenuItem(
|
||||||
|
view["name"],
|
||||||
|
id={"type": "saved-view-item", "index": view["id"]},
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
for view in views
|
for view in views
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -35,8 +39,11 @@ def _view_row(view) -> html.Div:
|
|||||||
children=[
|
children=[
|
||||||
html.Span(view["name"], className="flex-grow-1"),
|
html.Span(view["name"], className="flex-grow-1"),
|
||||||
dbc.Button(
|
dbc.Button(
|
||||||
|
# Limitation connue : la vue n'est pas appliquée automatiquement
|
||||||
|
# en arrivant sur /tableau depuis cette page. Le mécanisme de
|
||||||
|
# rappel multi-page reste à faire (voir tableau.py `apply_saved_view`).
|
||||||
"Ouvrir",
|
"Ouvrir",
|
||||||
href=f"/tableau?{view['query']}",
|
href="/tableau",
|
||||||
color="link",
|
color="link",
|
||||||
size="sm",
|
size="sm",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,13 +38,14 @@ def test_prepare_accepts_valid():
|
|||||||
assert err is None
|
assert err is None
|
||||||
|
|
||||||
|
|
||||||
def test_saved_views_items_build_links():
|
def test_saved_views_items_build_clickable_entries():
|
||||||
items = ui.saved_views_items(
|
items = ui.saved_views_items(
|
||||||
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
|
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
|
||||||
)
|
)
|
||||||
assert len(items) == 2
|
assert len(items) == 2
|
||||||
assert items[0].href == "/tableau?filtres=a"
|
assert items[0].id == {"type": "saved-view-item", "index": 1}
|
||||||
assert items[0].children == "Vue A"
|
assert items[0].children == "Vue A"
|
||||||
|
assert items[1].id == {"type": "saved-view-item", "index": 2}
|
||||||
|
|
||||||
|
|
||||||
def test_views_table_empty_state():
|
def test_views_table_empty_state():
|
||||||
|
|||||||
Reference in New Issue
Block a user