From 43fd2df2c039cba0e91678ba960458da91b15fbe Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Wed, 3 Dec 2025 16:31:05 +0100 Subject: [PATCH 1/2] 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) From 43104f611e6f3f9fca9ad5e77d655d91798d9aff Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Thu, 18 Dec 2025 03:40:32 +0100 Subject: [PATCH 2/2] =?UTF-8?q?R=C3=A9paration=20des=20callbacks,=20mode?= =?UTF-8?q?=20d'emploi=20#58?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/tableau.py | 25 ++++++++++++++++--------- src/utils.py | 1 + 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index f7a17af..85563c3 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -40,6 +40,7 @@ datatable = html.Div( filter_action="custom", sort_action="custom", hidden_columns=get_default_hidden_columns(None), + columns=[{"id": col, "name": col} for col in df.columns], ), ) @@ -49,10 +50,11 @@ layout = [ html.Details( children=[ html.Summary( - html.H3("Mode d'emploi", style={"text-decoration": "underline"}), + html.H3("Mode d'emploi", style={"textDecoration": "underline"}), ), dcc.Markdown( - """ + dangerously_allow_html=True, + children=""" ##### Définition des colonnes Pour voir la définition d'une colonne, passez votre souris sur son en-tête. @@ -62,10 +64,10 @@ layout = [ Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. - Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent - le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". + le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé. - Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres. - Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024", - `2024` pour "en 2024", `> 2022` pour "à partir de 2022" + `2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé. Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page. @@ -73,6 +75,10 @@ layout = [ Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri. + ##### Partager une vue + + Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône drawing. En la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. + ##### Télécharger le résultat Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**. @@ -82,7 +88,7 @@ layout = [ Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée (informations, marchés attribués/remportés, etc.) - """ + """, ), ], id="instructions", @@ -142,7 +148,6 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp) # 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 ) @@ -180,11 +185,13 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None): Output("table", "filter_query"), Output("table", "sort_by"), Output("table", "hidden_columns"), + Output("url", "search", allow_duplicate=True), Input("url", "search"), + prevent_initial_call=True, ) def restore_view_from_url(search): if not search: - return no_update, no_update, no_update + return no_update, no_update, no_update, no_update params = urllib.parse.parse_qs(search.lstrip("?")) print("params", params) @@ -208,7 +215,7 @@ def restore_view_from_url(search): except json.JSONDecodeError: pass - return filter_query, sort_by, hidden_columns + return filter_query, sort_by, hidden_columns, "" @callback( @@ -263,7 +270,7 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): def show_confirmation(n_clicks): if n_clicks: return html.Span( - "URL copiée", + "Adresse de la vue copiée", style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"}, ) return no_update diff --git a/src/utils.py b/src/utils.py index 9307c92..e7d47ad 100644 --- a/src/utils.py +++ b/src/utils.py @@ -27,6 +27,7 @@ def split_filter_part(filter_part): ["i<", "<"], ["i>", ">"], ["icontains", "contains"], + # [" ", "contains"] ] print("filter part", filter_part) for operator_group in operators: