panel icon indicating copy to clipboard operation
panel copied to clipboard

Card callback not called when collapsed by default

Open singharpit94 opened this issue 3 months ago • 5 comments

I am using panel 1.7.5, bokeh 3.7.3 and ipywidgets-bokeh 1.6.0 and notice a weird issue with cards collapsed by default when using with ipywidgets.

When I have the card collapsed by default, the widgets are not updated correctly see gif 1 but when I expand the card explicitly after the application load, widgets are updated as expected see gif 2.

GIF1

Image

GIF2

Image

Related code for reproducing the issue

import pandas as pd
import panel as pn
import param
import ipywidgets as widgets

# Configuration
TITLE = "Simple Panel Dashboard"

pn.extension()

# Sample data
data = pd.DataFrame({
    "Name": ["Alice", "Bob", "Cathy"],
    "City": ["NY", "LA", "NY"]
})

# Column names
NAME_COL = "Name"
CITY_COL = "City"
class TestDash(param.Parameterized):

    city = param.ObjectSelector()

    def __init__(self, df):
        super().__init__()
        self.df = df
        
        # Initialize cards as None - will be created in build_widgets
        self.card_1 = None
        self.card_2 = None
        
        # Status indicator for card collapsed state
        self.card_status_indicator = None

        self.build_widgets()


    def build_widgets(self):
        """Builds all panel widgets."""
        cities = ["All"] + list(self.df[CITY_COL].unique())
        self.filter_options_dict = {CITY_COL: cities}

        self.city_filter = pn.widgets.Select.from_param(
            self.param.city, name="City", options=cities, value="All",
            sizing_mode="stretch_width"
        )

        self.selectors_dict = {CITY_COL: self.city_filter}
        self.selectors_dependents_dict = {CITY_COL: []}
        
    #_________________________________________________________________________#
    #SECTION - Data Filtering
    def filter_data(self, filter_on_dict):

        df = self.df.copy()
        for col, widget in filter_on_dict.items():
            if widget.value != "All":
                # only filter if widget is not set to "everything"
                df = df[df[col] == widget.value]

        return df


    def filter_display_data(self) -> pd.DataFrame:
        # Filter using all selectors in the dashboard
        df = self.filter_data(self.selectors_dict)

        # Save the result as an instance variable for use elsewhere
        self.filtered_df = df.reset_index(drop=True).copy()

        return df

    #!SECTION - END Data Filtering
    # Object Builders

    def contain_objects(self):

        self.build_widgets_content()
        
        # Create cards if they don't exist yet
        if self.card_1 is None:
            self.card_1 = pn.Card(
                self.widget_card_1,
                styles={"background": "WhiteSmoke"},
                width=400,
                title="Card 1 - Selector Values",
                collapsed=True
            )
            # Watch the collapsed property of the first card
            self.card_1.param.watch(self._on_card_collapsed, ['collapsed'])
        else:
            # Update the content of existing card
            self.card_1.objects = [self.widget_card_1]
            
        if self.card_2 is None:
            self.card_2 = pn.Card(
                self.widget_card_2,
                styles={"background": "LightBlue"},
                width=400,
                title="Card 2 - Filtered Data",
                collapsed=False
            )
        else:
            # Update the content of existing card
            self.card_2.objects = [self.widget_card_2]
            
        # Create status indicator if it doesn't exist
        if self.card_status_indicator is None:
            self.card_status_indicator = pn.pane.HTML(
                self._get_card_status_html(),
                styles={"background": "#f0f0f0", "padding": "10px", "border-radius": "5px"}
            )

        return [
            # Row with city filter
            pn.Row(
                pn.pane.HTML("<h3>Filter:</h3>"),
                self.city_filter,
                sizing_mode="stretch_width"
            ),
            # Status indicator showing card state
            pn.Row(
                pn.pane.HTML("<h4>Card Status:</h4>"),
                self.card_status_indicator,
                sizing_mode="stretch_width"
            ),
            # Row for cards
            pn.Row(
                self.card_1,
                self.card_2,
                sizing_mode="stretch_width"
            )
        ]

    def build_widgets_content(self):
        """Build ipywidgets content for the cards."""
        
        # Card 1: Display selector values
        selector_values = {}
        for key, widget in self.selectors_dict.items():
            selector_values[key] = widget.value
        
        display_text = "<h4>Current Filter Settings:</h4><ul>"
        for key, value in selector_values.items():
            display_text += f"<li><strong>{key}:</strong> {value}</li>"
        display_text += "</ul>"
        
        self.widget_card_1 = widgets.HTML(value=display_text)
        
        data_text = ''
        
        if hasattr(self, 'filtered_df') and not self.filtered_df.empty:

            data_text += self.filtered_df.to_html(index=False, classes='table table-striped')
        
        self.widget_card_2 = widgets.HTML(value=data_text)

    def _get_card_status_html(self):
        """Generate HTML for the card status indicator."""
        if self.card_1 is None:
            status = "Card not initialized"
            color = "#999"
            icon = "⚪"
        elif self.card_1.collapsed:
            status = "Card 1 is COLLAPSED 📦"
            color = "#ff6b6b"
            icon = "🔒"
        else:
            status = "Card 1 is EXPANDED 📂"
            color = "#51cf66"
            icon = "🔓"
            
        return f"""
        <div style="display: flex; align-items: center; gap: 10px;">
            <span style="font-size: 20px;">{icon}</span>
            <span style="color: {color}; font-weight: bold; font-size: 16px;">{status}</span>
        </div>
        """
    
    def _on_card_collapsed(self, event):
        """Callback function that gets triggered when card collapsed state changes."""
        print(f"Card collapsed state changed! New value: {event.new}")
        
        # Update the status indicator
        if self.card_status_indicator is not None:
            self.card_status_indicator.object = self._get_card_status_html()
        
        # You can add any other logic here based on the collapsed state
        if event.new:  # Card is now collapsed
            print("Card 1 was collapsed - you can trigger any action here!")
            # Example: Change card 2 background when card 1 is collapsed
            if self.card_2 is not None:
                self.card_2.styles = {"background": "#ffcccc"}
        else:  # Card is now expanded
            print("Card 1 was expanded - you can trigger any action here!")
            # Example: Restore card 2 background when card 1 is expanded
            if self.card_2 is not None:
                self.card_2.styles = {"background": "LightBlue"}

    # Callbacks
    @param.depends(
        "city",
        watch = True
    )
    def update_dashboard(self, *events):

        if not self.dashboard.objects and not events:
            #NOTE - This needs to stay, and stops the flickering of objects when
            #       widgets were cleared.
            return

        self.dashboard.loading=True

        # Update Widgets before filtering data
        self.filter_display_data()

        self.dashboard.clear()

        self.dashboard.extend(self.contain_objects())

        self._updating=False
        self.dashboard.loading=False


    #SECTION - Building Containers
    #ANCHOR - Main Container
    def __panel__(self,):

        if hasattr(self,'dashboard'):
            return self.dashboard

        self.dashboard = pn.Column(
            pn.pane.HTML(f"<h1>{TITLE}</h1>"),
            sizing_mode="stretch_width"
        )

        self.filter_display_data()

        objects = self.contain_objects()
        self.dashboard.extend(objects)

        return self.dashboard

# Create and serve the app
def create_app():
    """Create the simple Panel application."""
    dashboard = TestDash(data)
    return dashboard.__panel__()

# Run the app
if __name__.startswith("bokeh"):
    create_app().servable()
elif __name__ == "__main__":
    # For local development
    app = create_app()
    app.show(port=5007
)

Can you please suggest a fix here or fix in upstream if needed?

singharpit94 avatar Sep 16 '25 08:09 singharpit94

This also seems to recreate it. By the looks of it, the document first becomes idle when opening the card, blocking everything else.

import panel as pn
import ipywidgets

pn.extension()

current_city = "All"

def create_widget_content():
    value=f"<strong>Current City:</strong> {current_city}"
    return ipywidgets.HTML(value=value)

def update_card(event):
    global current_city
    current_city = event.new
    card.objects = [create_widget_content()]

city_select = pn.widgets.Select(
    name="City Filter",
    value="All",
    options=["All", "NY", "LA"]
)

city_select.param.watch(update_card, 'value')

card = pn.Card(create_widget_content(), title="card", collapsed=True)

pn.Column(city_select, card).servable()

hoxbro avatar Sep 17 '25 08:09 hoxbro

Even simpler reproducer:

import panel as pn
import ipywidgets

pn.extension()

current_city = "All"

def create_widget_content():
    value=f"<strong>Current City:</strong> {current_city}"
    return ipywidgets.HTML(value=value)

def update_card(event):
    card.objects = [event.new]

city_select = pn.widgets.Select(
    name="City Filter",
    value="All",
    options=["All", "NY", "LA"]
)

city_select.param.watch(update_card, 'value')

card = pn.Card(create_widget_content(), title="card", collapsed=True)

pn.Column(city_select, card).servable()

philippjfr avatar Sep 26 '25 13:09 philippjfr

Hi there, gave this a check and seems to me that the main reason this happens is since the card contents doesn't get rendered if the card is in a collapsed state when created due to the following line:

https://github.com/holoviz/panel/blob/8eb0b14f18eb239007a07a21a15c9471c409b460/panel/models/card.ts#L109-L111

My guess is that when the card is created with collapsed=True, since is not rendered and is not being marked as non-visible (child_view.mode.visible = false) unless you manually uncollapse the content, it will not get an initial render so you can end up with erros related to trying to update the unrendered card child element:

Uncaught Error: reference p1055 isn't known

Following that, seems like if around

https://github.com/holoviz/panel/blob/8eb0b14f18eb239007a07a21a15c9471c409b460/panel/models/card.ts#L109-L117

Things get change to something like:

    for (const child_view of this.child_views.slice(1)) {
      child_view.render()
      child_view.r_after_render()
      this.shadow_el.appendChild(child_view.el)
      child_view.model.visible = !this.model.collapsed
    }

The card contents is able to be properly updated:

  • Using the latest code snippet to reproduce the issue:

Image

  • Using the original OP code example to reproduce the issue:

Image

If the fix suggested above makes sense let me know to open a PR with it!

Edit: Here a branch with the proposed change in case someone wants to give the possible fix a check - https://github.com/dalthviz/panel/tree/issue-8198 - https://github.com/holoviz/panel/compare/main...dalthviz:issue-8198

dalthviz avatar Oct 23 '25 16:10 dalthviz

@dalthviz Thanks for looking into it, if you could please open your branch as a PR and we can iterate from there.

philippjfr avatar Oct 29 '25 15:10 philippjfr

Done @philippjfr ! Created PR #8274

dalthviz avatar Oct 29 '25 17:10 dalthviz