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:
+104
-1
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user