[Python] roblema ao capturar estado de layout modificado com Streamlit Elements para salvar no...

Discussão em 'Python' iniciado por Stack, Agosto 7, 2025.

  1. Stack

    Stack Membro Participativo

    Estou a desenvolver um dashboard financeiro com Streamlit e a biblioteca streamlit-elements. O objetivo é permitir que os utilizadores façam login, personalizem a posição (x, y) e o tamanho (w, h) dos cards num grid (arrastar e redimensionar) e salvem essas preferências num banco de dados Supabase. Ao fazer login novamente, o layout personalizado deve ser restaurado.

    O salvamento dos filtros (outra coluna JSONB no Supabase) está a funcionar perfeitamente. O problema está exclusivamente em capturar e salvar o layout modificado.

    Stack Tecnológica:

    Linguagem: Python

    Framework Web: Streamlit

    Biblioteca de Componentes: streamlit-elements (usando dashboard.Grid)

    Banco de Dados: Supabase (PostgreSQL)

    Biblioteca de BD: supabase-py

    O Problema Detalhado:

    A aplicação renderiza um dashboard.Grid. O utilizador pode mover e redimensionar os cards. Ao clicar no botão "Salvar Preferências", o layout atualizado deveria ser enviado para a coluna saved_layouts (do tipo jsonb) na minha tabela profiles do Supabase.

    O que acontece é que, independentemente das alterações feitas na interface, a aplicação salva sempre o layout original (padrão), e não o layout modificado. A depuração mostrou que, no momento do clique no botão, a variável de estado do Python que deveria conter o novo layout ainda está com os valores antigos.

    O Que Já Foi Tentado Sem Sucesso:

    Callback onChange: A tentativa inicial foi usar o parâmetro onChange do dashboard.Grid para atualizar uma variável de estado (st.session_state.working_layout). A depuração mostrou que existe um problema de timing ("race condition"): o clique no botão é processado antes que a atualização do onChange chegue ao servidor.

    Função sync(): Tentámos usar a função sync() no início do bloco do botão "Salvar" para forçar a sincronização. Mesmo assim, a variável de estado não foi atualizada com as novas coordenadas.

    Verificação Completa de Permissões (RLS): As políticas de segurança (RLS) do Supabase foram completamente verificadas e estão corretas. Um script de teste que escreve um JSON simples na mesma coluna saved_layouts funciona perfeitamente, provando que não há problemas de conexão ou permissão. O problema é específico dos dados vindos do dashboard.Grid.

    Evidência Crucial do Problema:

    Num teste de depuração, adicionei código para imprimir o conteúdo da variável de layout no momento exato em que o botão "Salvar" é clicado. Mesmo após mover os cards, o resultado da depuração foi:

    DEBUG: Coordenadas Capturadas ATENÇÃO: As coordenadas capturadas são idênticas às do padrão. Isto prova que o problema está em capturar os dados da interface e trazê-los para o estado da sessão do Python antes de os enviar para o Supabase.

    Referência de Funcionalidade Desejada (em React):

    Este sandbox de React-Grid-Layout (a biblioteca por baixo do streamlit-elements) mostra exatamente o comportamento que preciso: o onLayoutChange captura e exibe as novas coordenadas em tempo real. O desafio é replicar esta captura de dados no ambiente Streamlit/Python. Link: React Grid Layout Showcase

    Pedido:

    Com base no código completo do meu Dash.py abaixo, por favor, forneça uma solução que consiga capturar corretamente as coordenadas (x, y, w, h) dos cards após serem movidos/redimensionados e que salve este novo layout no Supabase ao clicar no botão "Salvar Preferências". A solução deve ser uma abordagem diferente das que já foram tentadas e que falharam.

    Código Completo (Dash.py):

    Python

    Dash.py


    import streamlit as st import streamlit.components.v1 as components import datetime import time from supabase import create_client, Client from gotrue.errors import AuthApiError from streamlit_autorefresh import st_autorefresh from streamlit_elements import elements, dashboard, mui, html import copy

    Suponha que estes módulos existam e funcionem corretamente


    from cds_card import render_cds_table_card, CDS_TERMS from commodities_card import render_commodities_table_card, COMMODITY_NAMES from juros_card import render_juros_table_card, JUROS_COUNTRIES, JUROS_TERMS from jswap_card import render_jswap_table_card, JSWAP_COUNTRIES, JSWAP_TERMS from pre_mercado_card import render_pre_mercado_table_card, PRE_MERCADO_NAMES from tesouro_card import render_tesouro_table_card, TESOURO_TIPOS, TESOURO_PERIODOS, TESOURO_ANOS from pares_moeda_card import render_pares_moeda_table_card, PARES_MOEDA_NAMES from bolsas_card import render_bolsas_table_card, BOLSAS_NAMES

    --- Constantes Globais ---


    CDS_COUNTRIES = ['BR', 'US', 'DE', 'CH', 'JP', 'CN']

    --- Configurações da Página ---


    st.set_page_config(page_title="Dashboard Financeiro", layout="wide", initial_sidebar_state="collapsed")

    --- CSS ---


    st.markdown(""" body, .stApp { font-family: "Helvetica", Arial, sans-serif !important; } .react-grid-item { background-color: #32323c !important; border-radius: 10px; } .MuiPaper-root { background-color: transparent !important; color: white !important; } [data-testid="stSidebar"] [data-testid="stMultiSelect"] label { font-size: 0.9rem !important; } table { width: 100%; border-collapse: collapse; font-family: "Helvetica", Arial, sans-serif; } th, td { padding: 4px 8px; border-bottom: 1px solid #4f5b66; vertical-align: middle; } """, unsafe_allow_html=True)

    --- Funções da Aplicação ---


    @st.cache_resource def init_supabase_client(): """Cria e cacheia a conexão com o Supabase para ser usada em toda a sessão.""" try: return create_client(st.secrets["supabase_url"], st.secrets["supabase_key"]) except Exception as e: st.error(f"Falha na conexão com Supabase: {e}") return None

    def initialize_session(): """Inicializa as variáveis da sessão se elas não existirem.""" if 'auth_user' not in st.session_state: st.session_state.auth_user = None if 'active_desktop' not in st.session_state: st.session_state.active_desktop = "Desktop 1" if 'user_filters' not in st.session_state: st.session_state.user_filters = {} if 'filters_loaded' not in st.session_state: st.session_state.filters_loaded = False if 'saved_layouts' not in st.session_state: st.session_state.saved_layouts = {} if 'working_layout' not in st.session_state: st.session_state.working_layout = {}

    def handle_layout_change(new_layout): """Callback que deveria atualizar o layout de trabalho.""" desktop_key = st.session_state.active_desktop sanitized_layout = [{'i': item['i'], 'x': item['x'], 'y': item['y'], 'w': item['w'], 'h': item['h']} for item in new_layout] st.session_state.working_layout[desktop_key] = sanitized_layout

    def show_login_screen(supabase: Client): """Mostra o formulário de login e lida com a autenticação.""" st.title("Dashboard Login") with st.form("login_form"): email = st.text_input("Email") password = st.text_input("Senha", type="password") if st.form_submit_button("Entrar"): try: st.session_state.auth_user = supabase.auth.sign_in_with_password({"email": email, "password": password}) st.session_state.filters_loaded = False st.rerun() except Exception as e: st.error(f"Falha no login: {e}") st.session_state.auth_user = None

    def show_dashboard(supabase: Client): """Mostra o dashboard completo para um usuário autenticado.""" if not st.session_state.auth_user or not hasattr(st.session_state.auth_user, 'user'): st.session_state.clear(); st.rerun(); return

    user_info = st.session_state.auth_user.user

    if hasattr(st.session_state.auth_user.session, 'expires_at') and st.session_state.auth_user.session.expires_at < time.time():
    try:
    st.session_state.auth_user = supabase.auth.refresh_session(st.session_state.auth_user.session.refresh_token)
    except Exception:
    st.error("Sua sessão expirou."); st.session_state.clear(); st.rerun(); return

    DEFAULT_LAYOUT = [
    {'i': 'cds_card', 'x': 0, 'y': 0, 'w': 24, 'h': 32}, {'i': 'commodities_card', 'x': 24, 'y': 0, 'w': 24, 'h': 32},
    {'i': 'juros_card', 'x': 48, 'y': 0, 'w': 24, 'h': 32}, {'i': 'jswap_card', 'x': 72, 'y': 0, 'w': 24, 'h': 32},
    {'i': 'pre_mercado_card', 'x': 0, 'y': 32, 'w': 24, 'h': 32}, {'i': 'tesouro_card', 'x': 24, 'y': 32, 'w': 24, 'h': 32},
    {'i': 'pares_moeda_card', 'x': 48, 'y': 32, 'w': 24, 'h': 32}, {'i': 'bolsas_card', 'x': 72, 'y': 32, 'w': 24, 'h': 32},
    ]

    if not st.session_state.filters_loaded:
    try:
    profile_response = supabase.table("profiles").select("saved_filters, saved_layouts").eq("id", user_info.id).maybe_single().execute()
    st.session_state.user_filters = profile_response.data.get('saved_filters', {}) if profile_response.data else {}
    saved_layouts_from_db = profile_response.data.get('saved_layouts') if profile_response.data else None
    if saved_layouts_from_db: st.session_state.saved_layouts = saved_layouts_from_db
    else: st.session_state.saved_layouts = {"Desktop 1": DEFAULT_LAYOUT}
    st.session_state.working_layout = copy.deepcopy(st.session_state.saved_layouts)
    st.session_state.filters_loaded = True
    except Exception as e:
    st.warning(f"Não foi possível carregar suas preferências: {e}")
    st.session_state.user_filters = {}; st.session_state.saved_layouts = {"Desktop 1": DEFAULT_LAYOUT}
    st.session_state.working_layout = copy.deepcopy(st.session_state.saved_layouts)
    st.session_state.filters_loaded = True

    st_autorefresh(interval=30 * 1000, key="data_refresher")

    with st.sidebar:
    st.markdown(f"### Bem-vindo(a)"); st.write(user_info.email); st.markdown("---")
    if st.button("Desktop 1"): st.session_state.active_desktop = "Desktop 1"; st.rerun()
    if st.button("Desktop 2"): st.session_state.active_desktop = "Desktop 2"; st.rerun()
    st.markdown("---")

    with st.expander("Filtros CDS", expanded=False):
    saved_cds_countries = st.session_state.user_filters.get('cds_country', []); selected_countries_cds = st.multiselect("País", options=CDS_COUNTRIES, default=saved_cds_countries, key="cds_country")
    saved_cds_terms = st.session_state.user_filters.get('cds_term', []); selected_terms_cds = st.multiselect("Prazo", options=CDS_TERMS, default=saved_cds_terms, key="cds_term")
    # ... (outros expanders de filtro aqui, sem alterações) ...

    st.markdown("---")

    if st.button("Salvar Preferências"):
    try:
    supabase.table("profiles").update({ "saved_layouts": st.session_state.working_layout }).eq("id", user_info.id).execute()
    # ... (resto da lógica de salvar) ...
    st.success("Preferências salvas com sucesso!")
    except Exception as e:
    st.error(f"Erro ao salvar preferências: {e}")

    if st.button("Sair"): st.session_state.clear(); st.rerun()

    # --- Header (código omitido para brevidade) ---
    # ...

    with elements(f"dashboard_elements_{st.session_state.active_desktop}"):
    desktop_key = st.session_state.active_desktop
    if desktop_key not in st.session_state.working_layout:
    st.session_state.working_layout[desktop_key] = copy.deepcopy(st.session_state.saved_layouts.get(desktop_key, DEFAULT_LAYOUT))

    layout_to_display = st.session_state.working_layout[desktop_key]

    if st.session_state.active_desktop == "Desktop 1":
    with dashboard.Grid(
    layout_to_display,
    draggableHandle=".draggable-handle",
    rowHeight=10,
    cols={"lg": 96, "md": 96, "sm": 48, "xs": 24, "xxs": 12},
    onChange=handle_layout_change
    ):
    with mui.Paper(key="cds_card", sx={"display":"flex","flexDirection":"column","padding":0}):
    with mui.Box(className="draggable-handle",sx={"cursor":"move","padding":"10px","borderBottom":"1px solid #4f5b66"}): mui.Typography("CDS",variant="h6")
    with mui.Box(sx={"flex":1,"overflow":"auto","padding":"10px"}): html.div(dangerouslySetInnerHTML={"__html": render_cds_table_card(supabase, selected_countries_cds, selected_terms_cds)})
    # ... (Todos os outros 7 cards aqui, sem alterações) ...
    else:
    # ... (Lógica para o Desktop 2) ...
    pass


    def main(): """Função principal que controla o fluxo da aplicação.""" initialize_session()

    supabase = init_supabase_client()
    if not supabase:
    return

    if not st.session_state.auth_user:
    show_login_screen(supabase)
    else:
    show_dashboard(supabase)


    if name == "main": main()

    Continue reading...

Compartilhe esta Página