import json import os from datetime import datetime from urllib.parse import parse_qs import dash_bootstrap_components as dbc from dash import ( ALL, Input, Output, State, callback, clientside_callback, ctx, dcc, html, no_update, register_page, ) from flask_login import current_user from src.db import schema from src.figures import ag_grid, make_column_picker 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 resolve as saved_views_resolve from src.saved_views import ui as saved_views_ui from src.utils import get_data_update_timestamp, logger from src.utils.grid import apply_persisted_layout, fetch_grid_page, grid_column_defs from src.utils.query_ast import ( ast_from_dict, ast_to_dict, ast_to_filtermodel, filtermodel_to_ast, ) from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, format_number, get_default_hidden_columns, write_styled_excel, ) from src.utils.tracking import track_search update_date_timestamp = get_data_update_timestamp( os.getenv("DATA_FILE_PARQUET_PATH", ""), os.getenv("DUCKDB_PATH", "./decp.duckdb"), ) if update_date_timestamp is not None: update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y") update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat() else: update_date = "date inconnue" update_date_iso = "" NAME = "Tableau" register_page( __name__, path="/tableau", title="Tableau des marchés publics | colibre", name=NAME, description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.", image_url=META_CONTENT["image_url"], order=1, ) # Le bloc « URL directe » (share-url-box) reste affiché tant que la vue partagée # n'a pas dérivé. On détecte la dérive par des écouteurs d'événements AG Grid # côté client plutôt que par diff d'état : l'application programmatique d'une vue # (source 'api'/'gridOptionsChanged') est ignorée, seules les actions utilisateur # effacent `active-view` et masquent le bloc. Voir # src/assets/dashAgGridFunctions.js. Cette approche évite la course # d'ordonnancement d'un compteur d'échos et le bruit du columnState réémis. # # On écoute filtre et tri (signaux natifs fiables, source utilisateur distincte # de 'api'). La visibilité des colonnes est pilotée par le sélecteur « Colonnes » # maison (store tableau-hidden-columns → régénération des columnDefs → événement # columnVisible de source 'gridOptionsChanged', identique pour l'utilisateur et # l'application programmatique) : indistinguable via AG Grid, donc non écoutée. _SHARE_DRIFT_LISTENERS = { event: ["hideShareOnUserAction(params)"] for event in ("filterChanged", "sortChanged") } DATATABLE = html.Div( className="marches_table", children=ag_grid( "tableau_grid", grid_column_defs(get_default_hidden_columns("tableau")), event_listeners=_SHARE_DRIFT_LISTENERS, ), ) def _help_button_legend(): """Légende en tête du mode d'emploi : chaque bouton de la barre d'outils, reproduit à l'identique (mais inerte), en face de sa fonction.""" rows = [ ( dbc.Button("Colonnes", color="secondary", size="sm"), "Choisir les colonnes affichées.", ), ( dbc.Button("Sauvegarder la vue", color="secondary", size="sm"), "Enregistrer les filtres, tris et colonnes actuels sous un nom (abonnés).", ), ( dbc.Button("Mes vues ▾", color="secondary", size="sm"), "Rouvrir une vue que vous avez enregistrée (abonnés).", ), ( dbc.Button("Télécharger (Excel)", color="secondary", size="sm"), "Télécharger les données filtrées et triées au format Excel.", ), ( dbc.Button("Réinitialiser", color="danger", outline=True, size="sm"), "Supprimer tous les filtres et tris.", ), ( dbc.Button("⍰ Mode d'emploi", color="secondary", outline=True, size="sm"), "Ouvrir cette aide.", ), ] return html.Div( className="help-legend", children=[ html.P("Les boutons de la barre d'outils", className="fw-bold mb-2"), html.Table( className="help-legend-table", children=html.Tbody( [ html.Tr( [ html.Td(btn, className="help-legend-btn"), html.Td(desc, className="help-legend-desc"), ] ) for btn, desc in rows ] ), ), ], ) layout = [ dcc.Location(id="tableau_url", refresh=False), dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-table"), dcc.Store(id="tableau-total"), dcc.Store(id="tableau-total-unique"), dcc.Store(id="active-view"), dcc.Store(id="vue-resolution"), html.Script( type="application/ld+json", id="dataset_jsonld", children=[ json.dumps( { "@context": "https://schema.org/", "@type": "Dataset", "name": "Données essentielles des marchés publics français (DECP)", "description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.", "url": "https://colibre.fr", "sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325", "keywords": [ "marchés publics", "commande publique", "decp", "public procurement", ], "license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence", "isAccessibleForFree": True, "creator": { "@type": "Organization", "url": "https://colmo.tech", "name": "Colmo", "sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350", "contactPoint": { "@type": "ContactPoint", "contactType": "Support et contact commercial", "email": "colin@colmo.tech", }, }, "includedInDataCatalog": { "@type": "DataCatalog", "name": "data.gouv.fr", }, "distribution": [ { "@type": "DataDownload", "encodingFormat": "CSV", "contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52", }, { "@type": "DataDownload", "encodingFormat": "Parquet", "contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432", }, ], **( {"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}"} if update_date_iso else {} ), "spatialCoverage": { "@type": "Place", "address": {"countryCode": "FR"}, }, }, indent=2, ) ], ), dcc.Markdown( f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin. Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", style={"maxWidth": "1000px"}, ), html.Div( [], id="header", ), html.Div(id="vue-resolve-feedback"), dcc.Loading( overlay_style={"visibility": "visible", "filter": "blur(2px)"}, id="loading-home", type="default", children=[ html.Div( [ # Modal du mode d'emploi dbc.Button( "⍰ Mode d'emploi", id="tableau_help_open", color="secondary", outline=True, size="sm", ), dbc.Modal( [ dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")), dbc.ModalBody( [ _help_button_legend(), html.Hr(), dcc.Markdown( dangerously_allow_html=True, children=f""" ##### Définition des colonnes Pour voir la définition d'une colonne et ses valeurs possibles, passez votre souris sur son en-tête. ##### Vos réglages sont persistents Les filtres, les tris , le choix de colonnes, le placement et la largeur des colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée. ##### Filtrer les colonnes Chaque colonne a son propre filtre : saisissez une valeur dans le champ situé juste sous son en-tête, ou cliquez sur l'icône entonnoir dans l'en-tête pour ouvrir le filtre complet. - Champs textuels : contient (par défaut), égal à, ne contient pas, commence par, se termine par... - Champs numériques (durée en mois, montant, nombre d'offres...) : égal à, supérieur à, inférieur à, entre (plage)... - Champs date : égal à, avant, après, entre (plage)... Dans le filtre complet (icône entonnoir), vous pouvez combiner deux conditions sur la même colonne avec **ET** ou **OU**. Vous pouvez filtrer plusieurs colonnes à la fois ; les filtres de colonnes différentes se cumulent toujours (ET). ##### Trier les données Cliquez sur l'en-tête d'une colonne pour la trier. Chaque clic change le tri dans cet ordre : 1. tri croissant 2. tri décroissant 3. pas de tri Pour trier sur plusieurs colonnes à la fois, maintenez la touche `Maj` (Shift) enfoncée en cliquant sur les en-têtes suivants : la première colonne triée a la priorité, la suivante ne départage qu'au sein des groupes de valeurs identiques de la précédente, et ainsi de suite. ##### Défilement Le tableau charge les lignes au fur et à mesure que vous faites défiler la page, plutôt que par pages numérotées. Les en-têtes de colonnes (et leurs filtres) restent toujours visibles en haut du tableau pendant le défilement. ##### Afficher plus de colonnes Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter ! Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes** et cochez les colonnes à afficher. ##### Vues sauvegardées (abonnés) Une vue est un ensemble de filtres, de tris et de colonnes affichées que vous avez appliqués. Si vous êtes abonné, le bouton **Sauvegarder la vue** vous permet d'enregistrer la configuration actuelle sous un nom, et le menu **Mes vues** de la rappeler d'un clic plus tard. ##### 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 (Excel)**. ##### Liens 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.) """, ), ], ), dbc.ModalFooter( dbc.Button( "Fermer", id="tableau_help_close", className="ms-auto", n_clicks=0, ) ), ], id="tableau_help", is_open=False, fullscreen="md-down", scrollable=True, size="lg", ), # Bouton modal des colonnes affichées dbc.Button( "Colonnes", id="tableau_columns_open", color="secondary", size="sm", className="column_list", title="Choisir les colonnes à afficher et masquer", ), html.Div( id="saved-views-bar", style={"display": "none"}, className="d-inline-flex align-items-center gap-2", children=[ dbc.Button( "Sauvegarder la vue", id="btn-save-view", color="secondary", size="sm", title="Enregistrer les filtres, tris et colonnes actuels sous un nom", ), dbc.DropdownMenu( id="saved-views-menu", label="Mes vues", color="secondary", size="sm", children=[], className="d-inline-block", ), ], ), dcc.Store(id="saved-views-refresh"), dbc.Modal( id="save-view-modal", is_open=False, children=[ dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")), dbc.ModalBody( [ dbc.Label("Nom de la vue"), dcc.Input( id="save-view-name", type="text", className="form-control", ), html.Hr(className="my-3"), dbc.Label("Ou remplacer une vue existante"), dbc.Select( id="overwrite-view-select", options=[], placeholder="Sélectionner une vue…", ), html.Div(id="save-view-feedback", className="mt-2"), ] ), dbc.ModalFooter( dbc.Button( "Enregistrer", id="btn-save-view-confirm", color="primary", ) ), ], ), dbc.Button( "Télécharger (Excel)", id="btn-download-data", color="secondary", size="sm", disabled=True, ), dcc.Download(id="download-data"), dcc.Store(id="filtered_data", storage_type="memory"), dbc.Button( "Réinitialiser", id="btn-tableau-reset", color="danger", outline=True, size="sm", title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.", ), ], className="table-toolbar", ), html.Div( id="share-url-box", className="share-url-box d-none", children=[ dbc.Label( "URL directe vers cette vue :", html_for="share-url-input", className="mb-0", ), dcc.Input( id="share-url-input", type="text", readOnly=True, className="form-control form-control-sm", style={"maxWidth": "420px"}, ), dcc.Clipboard( target_id="share-url-input", title="Copier le lien vers cette vue", style={"cursor": "pointer", "fontSize": "1.1rem"}, ), ], ), html.Div( className="table-meta", children=[ html.Span(id="nb_rows"), html.Span(" · Données mises à jour le " + str(update_date)), html.Span(id="download-hint"), ], ), dbc.Modal( [ dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")), dbc.ModalBody( id="tableau_columns_body", children=make_column_picker("tableau"), ), dbc.ModalFooter( dbc.Button( "Fermer", id="tableau_columns_close", className="ms-auto", n_clicks=0, ) ), ], id="tableau_columns", is_open=False, fullscreen="md-down", scrollable=True, size="xl", ), DATATABLE, ], ), ] @callback( Output("tableau_grid", "getRowsResponse"), Output("tableau-total", "data"), Output("tableau-total-unique", "data"), Input("tableau_grid", "getRowsRequest"), prevent_initial_call=True, ) def get_rows_tableau(request): if request is None: return no_update, no_update, no_update filter_model = request.get("filterModel") or None sort_model = request.get("sortModel") or None # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de # défilement infini, avec le même filterModel tant que le filtre ne change # pas. Ne compter qu'une recherche par changement de filtre/tri (bloc 0), # pas une par bloc chargé au défilement. if filter_model and request.get("startRow", 0) == 0: track_search(json.dumps(filter_model), "tableau") rows, total, total_unique = fetch_grid_page( filter_model, sort_model, request.get("startRow", 0), request.get("endRow", 100), ) return {"rowData": rows, "rowCount": total}, total, total_unique @callback( Output("nb_rows", "children"), Output("btn-download-data", "disabled"), Output("download-hint", "children"), Input("tableau-total", "data"), Input("tableau-total-unique", "data"), ) def update_meta(total, total_unique): total = total or 0 total_unique = total_unique or 0 too_many = total > 65000 hint = ( " · Filtrez sous 65 000 lignes pour activer le téléchargement" if too_many else "" ) nb_rows = ( f"{format_number(total_unique) or 0} marchés " f"({format_number(total) or 0} lignes)" ) return nb_rows, too_many, hint @callback( Output("download-data", "data"), Input("btn-download-data", "n_clicks"), State("tableau_grid", "filterModel"), State("tableau_grid", "columnState"), prevent_initial_call=True, ) def download_data(n_clicks, filter_model, column_state): from src.utils.grid import export_dataframe sort_model = [ {"colId": c["colId"], "sort": c["sort"]} for c in (column_state or []) if c.get("sort") ] hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")] if filter_model: track_search(json.dumps(filter_model), "tab download") df = export_dataframe(filter_model, sort_model, hidden_columns) def to_bytes(buffer): write_styled_excel(df, buffer) date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") @callback( Output("tableau_help", "is_open"), [Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")], [State("tableau_help", "is_open")], ) def toggle_tableau_help(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open @callback( Output("tableau-hidden-columns", "data", allow_duplicate=True), Input("tableau_column_list", "selected_rows"), prevent_initial_call=True, ) def update_hidden_columns_from_checkboxes(selected_columns): # selected_columns == [] est un choix explicite (tout décoché), à ne pas # confondre avec « pas encore de préférence » : cf. apply_hidden_columns # et update_checkboxes_from_hidden_columns, qui eux distinguent ce cas de # None. Sans ce traitement uniforme, décocher/cocher toutes les colonnes # se faisait écraser par update_checkboxes_from_hidden_columns au tour # suivant (`hidden_cols or get_default_hidden_columns(...)`). selected = [COLUMNS[i] for i in (selected_columns or [])] hidden_columns = [col for col in COLUMNS if col not in selected] return hidden_columns @callback( Output("tableau_grid", "columnDefs"), Input("tableau-hidden-columns", "data"), State("tableau_grid", "columnState"), ) def apply_hidden_columns(hidden_columns, column_state): if hidden_columns is None: hidden_columns = get_default_hidden_columns("tableau") defs = grid_column_defs(hidden_columns) return apply_persisted_layout(defs, column_state) @callback( Output("tableau_column_list", "selected_rows"), Input("tableau-hidden-columns", "data"), State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie ) def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): # None = pas encore de préférence enregistrée (première visite) ; [] # est un choix explicite (« ne rien masquer »/tout afficher) et doit # être respecté tel quel, cf. update_hidden_columns_from_checkboxes. if hidden_cols is None: hidden_cols = get_default_hidden_columns("tableau") # Show all columns that are NOT hidden visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols] return visible_cols @callback( Output("tableau_columns", "is_open"), Input("tableau_columns_open", "n_clicks"), Input("tableau_columns_close", "n_clicks"), State("tableau_columns", "is_open"), ) def toggle_tableau_columns(click_open, click_close, is_open): if click_open or click_close: return not is_open return is_open @callback( Output("tableau_grid", "filterModel", allow_duplicate=True), Output("tableau_grid", "columnState", allow_duplicate=True), Input("btn-tableau-reset", "n_clicks"), State("tableau_grid", "columnState"), prevent_initial_call=True, ) def reset_view(n_clicks, column_state): # On ne touche qu'au tri (sort/sortIndex) : on réécrit le columnState tel # quel pour préserver la largeur, l'épinglage et l'ordre des colonnes # choisis par l'utilisateur (auparavant resetColumnState les effaçait # aussi, cf. #47). cleared_sort = [ {**col, "sort": None, "sortIndex": None} for col in (column_state or []) ] return {}, cleared_sort @callback( Output("saved-views-bar", "style"), Input("tableau_url", "pathname"), ) def toggle_saved_views_bar(_pathname): return saved_views_ui.bar_style(current_user_has_subscription()) def resolve_vue_from_url(search: str) -> dict | None: """Extrait ?vue=... de la query string et le résout. Renvoie None s'il n'y a pas de paramètre `vue` (chargement normal du tableau).""" params = parse_qs((search or "").lstrip("?")) values = params.get("vue") if not values: return None return saved_views_resolve.resolve_vue_param(values[0], schema) @callback( Output("vue-resolution", "data"), Input("tableau_url", "search"), ) def store_vue_resolution(search): resolution = resolve_vue_from_url(search) return resolution if resolution is not None else no_update def apply_vue_resolution(resolution): """Mappe le dict de résolution vers les sorties de la grille + le store `active-view`. Séparé du callback pour être testable sans contexte Dash. Sorties : (filterModel, columnState, hidden-columns, active-view, feedback). Le masquage du bloc de partage n'est PAS géré par comparaison d'état ici : c'est un écouteur d'événements AG Grid côté client (eventListeners → `dashAgGridFunctions.hideShareOnUserAction`) qui efface `active-view` sur action utilisateur (filtre/tri/colonne), en ignorant l'application programmatique (source == 'api'). Voir src/assets/dashAgGridFunctions.js. """ if resolution is None: return (no_update,) * 5 if not resolution["found"]: return ( no_update, no_update, no_update, None, # active-view : masque le bloc de partage html.Div(resolution["error"], className="alert alert-warning py-2"), ) return ( resolution["filter_model"], resolution["column_state"], resolution["hidden_columns"], {"token": resolution["token"], "url": resolution["url"]}, "", ) @callback( Output("tableau_grid", "filterModel", allow_duplicate=True), Output("tableau_grid", "columnState", allow_duplicate=True), Output("tableau-hidden-columns", "data", allow_duplicate=True), Output("active-view", "data", allow_duplicate=True), Output("vue-resolve-feedback", "children"), Input("vue-resolution", "data"), prevent_initial_call=True, ) def apply_vue_resolution_cb(resolution): return apply_vue_resolution(resolution) # Classe Bootstrap de visibilité du bloc de partage. On bascule la CLASSE # (d-flex ↔ d-none, toutes deux `!important`) et non le style inline : `.d-flex` # est `display: flex !important` et l'emporterait sur un `style={"display": # "none"}` inline, rendant le bloc impossible à masquer par le style. _SHARE_BOX_SHOWN = "share-url-box d-flex align-items-center gap-2 my-2" _SHARE_BOX_HIDDEN = "share-url-box d-none" @callback( Output("share-url-box", "className"), Output("share-url-input", "value"), Input("active-view", "data"), ) def render_share_box(active_view): if active_view and active_view.get("url"): return _SHARE_BOX_SHOWN, active_view["url"] return _SHARE_BOX_HIDDEN, "" @callback( Output("save-view-modal", "is_open"), Input("btn-save-view", "n_clicks"), prevent_initial_call=True, ) def toggle_save_view_modal(_open): return True @callback( Output("save-view-modal", "is_open", allow_duplicate=True), Output("save-view-feedback", "children"), Output("saved-views-refresh", "data"), Output("active-view", "data", allow_duplicate=True), Input("btn-save-view-confirm", "n_clicks"), State("save-view-name", "value"), State("tableau_grid", "filterModel"), State("tableau_grid", "columnState"), prevent_initial_call=True, ) def save_view(_n, name, filter_model, column_state): has_sub = current_user_has_subscription() clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) if error: return ( True, html.Span(error, style={"color": "red"}), no_update, no_update, ) # On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut # d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState, # indépendant de l'UI". ast = filtermodel_to_ast(filter_model, schema) query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []}) token = saved_views_db.upsert(current_user.id, "tableau", clean_name, query) active = {"token": token, "url": saved_views_ui.build_view_url(clean_name, token)} return ( False, html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), clean_name, active, ) @callback( Output("saved-views-menu", "children"), Input("tableau_url", "pathname"), Input("saved-views-refresh", "data"), ) def populate_saved_views_menu(_pathname, _refresh): if not current_user_has_subscription(): return [] views = saved_views_db.list_views(current_user.id, "tableau") return saved_views_ui.saved_views_items(views) @callback( Output("tableau_grid", "filterModel"), Output("tableau_grid", "columnState"), Output("tableau-hidden-columns", "data", allow_duplicate=True), Output("active-view", "data", allow_duplicate=True), 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, no_update, no_update row = saved_views_db.get(triggered["index"], current_user.id) if not row: return no_update, no_update, no_update, no_update try: view = json.loads(row["query"]) # L'AST canonique est stocké (pas le filterModel brut d'AG Grid) : # cf. save_view. `ast_from_dict(None)` -> None et # `ast_to_filtermodel(None, schema)` -> {} si la vue est d'un ancien # format (sans clé "ast") : dégradation propre, la vue se rappelle # sans filtre plutôt que de planter. ast = ast_from_dict(view.get("ast")) filter_model = ast_to_filtermodel(ast, schema) column_state = view.get("columnState") or [] except (json.JSONDecodeError, TypeError, AttributeError): # Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"] # est encore une query string (ex. "filtres=a&tris=b"), pas du JSON. On # échoue proprement plutôt que de planter le callback ; pas de # migration automatique de l'ancien format. logger.warning( "Vue sauvegardée au format pré-migration, impossible de l'appliquer : " f"id={row['id']!r} name={row['name']!r}" ) return no_update, no_update, no_update, no_update # tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes # (update_checkboxes_from_hidden_columns) et la régénération des # columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait # désynchronisé du columnState rappelé (revue finale #41). Même extraction # que download_data. hidden_columns = [c["colId"] for c in column_state if c.get("hide")] active = { "token": row["token"], "url": saved_views_ui.build_view_url(row["name"], row["token"]), } return filter_model, column_state, hidden_columns, active @callback( Output("overwrite-view-select", "options"), Input("save-view-modal", "is_open"), ) def populate_overwrite_select(is_open): if not is_open or not current_user_has_subscription(): return [] views = saved_views_db.list_views(current_user.id, "tableau") return [{"label": v["name"], "value": v["name"]} for v in views] clientside_callback( "function(val) { return val != null ? val : window.dash_clientside.no_update; }", Output("save-view-name", "value"), Input("overwrite-view-select", "value"), prevent_initial_call=True, )