From 43fd2df2c039cba0e91678ba960458da91b15fbe Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 3 Dec 2025 16:31:05 +0100 Subject: [PATCH] Suggested by Gemini Pro, but race condition: filters apply before columns are created #58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt: - In the tableau page (src/pages/tableau.py), I want to allow users to copy a URL in their clipboard that enables opening the page with the same view: applied filters, sorting and column selection of the datatable. Typically to share the view with a colleague via email or chat. In terms of Dash callbacks, that would mean the following: - one callback syncs the filters, the sort parameters and the selection of columns with a read-only text input that stores the URL to copy. This callback also ensures the button to copy to the clipboard is visible, possibly replacing the confirmation message (see next point) - one callback reacts to clicks on a button to store the URLin the clipboard. This button in on the same row as "Télécharger au format Excel" button. When clicked, the URL is stored in the clipboard, and the button is replaced with a confrmation message (URL copiée) - one callback reads the URL, and applies the filters, sorting and column selection - The URL stores the view configuration (filters, sorting and columns) in URL parameters, similar to what is done in REST APIs. The values are the same as stored in the DataTable parameters, just URL encoded. The URL parameters are in French: filtres, tris, colonnes. --- src/pages/tableau.py | 105 ++++++++++++++++++++++++++++++++++++++++++- src/utils.py | 19 ++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index ba938b5..f7a17af 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -1,8 +1,10 @@ +import json import os +import urllib.parse from datetime import datetime import polars as pl -from dash import Input, Output, State, callback, dcc, html, register_page +from dash import Input, Output, State, callback, dcc, html, no_update, register_page from src.figures import DataTable from src.utils import ( @@ -42,6 +44,7 @@ datatable = html.Div( ) layout = [ + dcc.Location(id="url", refresh=False), html.Div( html.Details( children=[ @@ -100,6 +103,8 @@ layout = [ html.Div( [ html.P("lignes", id="nb_rows"), + html.Div(id="copy-container"), + dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), html.Button( "Téléchargement désactivé au-delà de 65 000 lignes", id="btn-download-data", @@ -133,6 +138,11 @@ layout = [ State("table", "data_timestamp"), ) def update_table(page_current, page_size, filter_query, sort_by, data_timestamp): + # if ctx.triggered_id != "url": + # search_params = None + # else: + # search_params = urllib.parse.parse_qs(search_params.lstrip("?")) + return prepare_table_data( None, data_timestamp, filter_query, page_current, page_size, sort_by ) @@ -164,3 +174,96 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") + + +@callback( + Output("table", "filter_query"), + Output("table", "sort_by"), + Output("table", "hidden_columns"), + Input("url", "search"), +) +def restore_view_from_url(search): + if not search: + return no_update, no_update, no_update + + params = urllib.parse.parse_qs(search.lstrip("?")) + print("params", params) + + filter_query = no_update + sort_by = no_update + hidden_columns = no_update + + if "filtres" in params: + filter_query = params["filtres"][0] + + if "tris" in params: + try: + sort_by = json.loads(params["tris"][0]) + except json.JSONDecodeError: + pass + + if "colonnes" in params: + try: + hidden_columns = json.loads(params["colonnes"][0]) + except json.JSONDecodeError: + pass + + return filter_query, sort_by, hidden_columns + + +@callback( + Output("share-url", "value"), + Output("copy-container", "children"), + Input("table", "filter_query"), + Input("table", "sort_by"), + Input("table", "hidden_columns"), + State("url", "href"), +) +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] + + params = {} + if filter_query: + params["filtres"] = filter_query + + if sort_by: + params["tris"] = json.dumps(sort_by) + + if hidden_columns: + params["colonnes"] = json.dumps(hidden_columns) + + query_string = urllib.parse.urlencode(params) + 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", + ) + + 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( + "URL copiée", + style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, + ) + return no_update diff --git a/src/utils.py b/src/utils.py index 81a7925..9307c92 100644 --- a/src/utils.py +++ b/src/utils.py @@ -543,6 +543,7 @@ def prepare_table_data( :param page_current: :param page_size: :param sort_by: + :param search_params: :return: """ @@ -555,6 +556,24 @@ def prepare_table_data( else: lff: pl.LazyFrame = df.lazy() # start from the original data + # if search_params: + # if "filtres" in search_params: + # filter_query = search_params["filtres"][0] + # + # if "tris" in search_params: + # try: + # sort_by = json.loads(search_params["tris"][0]) + # except json.JSONDecodeError: + # pass + # + # if "colonnes" in search_params: + # try: + # hidden_columns = json.loads(search_params["colonnes"][0]) + # print(hidden_columns) + # lff = lff.drop(hidden_columns) + # except json.JSONDecodeError: + # pass + # Application des filtres if filter_query: lff = filter_table_data(lff, filter_query)