Suggested by Gemini Pro, but race condition: filters apply before columns are created #58

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.
This commit is contained in:
Colin Maudry
2025-12-03 16:31:05 +01:00
parent 9ae6f29391
commit 43fd2df2c0
2 changed files with 123 additions and 1 deletions
+104 -1
View File
@@ -1,8 +1,10 @@
import json
import os import os
import urllib.parse
from datetime import datetime from datetime import datetime
import polars as pl 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.figures import DataTable
from src.utils import ( from src.utils import (
@@ -42,6 +44,7 @@ datatable = html.Div(
) )
layout = [ layout = [
dcc.Location(id="url", refresh=False),
html.Div( html.Div(
html.Details( html.Details(
children=[ children=[
@@ -100,6 +103,8 @@ layout = [
html.Div( html.Div(
[ [
html.P("lignes", id="nb_rows"), html.P("lignes", id="nb_rows"),
html.Div(id="copy-container"),
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
html.Button( html.Button(
"Téléchargement désactivé au-delà de 65 000 lignes", "Téléchargement désactivé au-delà de 65 000 lignes",
id="btn-download-data", id="btn-download-data",
@@ -133,6 +138,11 @@ layout = [
State("table", "data_timestamp"), State("table", "data_timestamp"),
) )
def update_table(page_current, page_size, filter_query, sort_by, 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( return prepare_table_data(
None, data_timestamp, filter_query, page_current, page_size, sort_by 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") date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") 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
+19
View File
@@ -543,6 +543,7 @@ def prepare_table_data(
:param page_current: :param page_current:
:param page_size: :param page_size:
:param sort_by: :param sort_by:
:param search_params:
:return: :return:
""" """
@@ -555,6 +556,24 @@ def prepare_table_data(
else: else:
lff: pl.LazyFrame = df.lazy() # start from the original data 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 # Application des filtres
if filter_query: if filter_query:
lff = filter_table_data(lff, filter_query) lff = filter_table_data(lff, filter_query)