How to use button on_click to remove itself?

In my project I’m generating a webpage with different types of widgets: Select, RangeSlider, MultiSelect etc to apply filters on a given dataset.
To make the page flexible I want these widgets “removable” by users, so I determine to add a Button (labeled as “X”) beside each of the widgets, when user clicks the button it removes the widget as well as the button itself.
Is it possible to achieve this by on_click callback?
Thanks in advance for any advices.

You only need to remove the button object from whatever layout it is in, typically if it is a row or column you would remove it from their children list.

Thanks for the quick response, changing layout children list is also what I intended, however it doesn’t work as expected. I’m attaching a demo code here.

import os
from typing import Tuple
import pandas as pd
import numpy as np
from bokeh.models.widgets import MultiSelect, RangeSlider, Select, Button, Div, Paragraph, \
    DateRangeSlider, DataTable, TableColumn
from bokeh.layouts import column, widgetbox, Spacer, row
from bokeh.models import ColumnDataSource, Column
from functools import partial
from bokeh.io import curdoc


def widget_height(rows: int, row_height: int, height_min: int, height_max: int) -> int:
    """
    Sets height of a selection widget

    :param rows: how many options to be listed
    :param row_height: height for a single option
    :param height_min: lower cap of widget height
    :param height_max: upper cap of widget height
    :return: int value of widget height
    """
    if rows * row_height < height_min:
        height = height_min
    elif rows * row_height > height_max:
        height = height_max
    else:
        height = rows * row_height
    return height


def column_type(input_df: pd.DataFrame) -> Tuple[list, list, list]:
    """
    Segregate df columns by dtype

    Generates 3 lists of column names by column dtype
    :param input_df: input dataframe
    :return: list of string column, list of number column, list of datetime column
    """
    columns = input_df.columns
    string_cols = [x for x in columns if (('object' in str(input_df[x].dtype)) or
                                          ('category' in str(input_df[x].dtype)) or
                                          ('bool' in str(input_df[x].dtype)))]
    number_cols = [x for x in columns if(('int' in str(input_df[x].dtype)) or
                                         ('float' in str(input_df[x].dtype)))]
    datetime_cols = [x for x in columns if 'datetime' in str(input_df[x].dtype)]
    return string_cols, number_cols, datetime_cols


def replace_na(input_df: pd.DataFrame) -> pd.DataFrame:
    """Replaces empty cells in a dataframe to meaningful values

    For string columns, fill empty as 'N/A'
    For number columns, fill empty or infinite as 0
    :param input_df: dataframe with (potentially) empty cells
    :return: dataframe with empty cells filled up
    """
    string_cols, number_cols, datetime_cols = column_type(input_df)

    for col in string_cols:
        input_df[col].fillna('N/A', inplace=True)
    for col in number_cols:
        input_df[col].replace(np.nan, 0, inplace=True)
        input_df[col].replace(np.inf, 0, inplace=True)
    for col in datetime_cols:
        input_df[col].replace(np.nan, 0, inplace=True)
    return input_df


def generate_wid_table(df: pd.DataFrame, width: int = 800,
                       height: int = 400, fit: bool = False) -> DataTable:
    """
    Converts a DataFrame into a bokeh data_table widget

    :param df: input dataframe
    :param width: table width
    :param height: table height
    :param fit: boolean flag of fit_column
    :return: bokeh dataTable widget
    """
    source_cols = []
    for i in df.columns.values:
        col = TableColumn(field=i, title=i)
        source_cols.append(col)
    wid_table = DataTable(source=ColumnDataSource(df), columns=source_cols, margin=[2, 2, 10, 10],
                          fit_columns=fit, width=width, height=height)
    return wid_table


class BokehFilters:
    """
    Takes a given dataframe and generates bokeh page of widget components, then applies the
    customized filters on dataset to generate a new subset.

    Variables that programmers can set: widget height, widget width, debug switch
    """
    def __init__(self, df: pd.DataFrame, widget_width: int = 250, row_height: int = 30,
                 height_min: int = 80, height_max: int = 120, debug_flag: bool = True):
        """
        Initializes with given dataframe, outputs widgets on bokeh page layout

        :param df: input pandas dataframe
        :param widget_width: global var or widget width in final output page
        :param row_height: height of a single option in MultiSelect widget
        :param height_min: lower cap of MultiSelect widget height
        :param height_max: upper cap of MultiSelect widget height
        :param debug_flag: switch to turn on/off debug messages
        """
        self.DEBUG = debug_flag
        self.raw_data = replace_na(df)
        self.filtered_data = pd.DataFrame()
        self.columns = self.raw_data.columns.to_list()
        self.str_cols, self.num_cols, self.dt_cols = column_type(self.raw_data)
        self.filter_name_list = []
        self.filter_widget_list = []
        self.x_btn_list = []
        self.header = Div(text='<b>Data Filter</b>',
                          style={'font-size': '120%', 'color': 'darkgreen'})
        self.select_column = Select(title='Choose Column:', value=self.columns[0],
                                    options=self.columns)
        self.btn_add = Button(label='Add filter on selected column', button_type='success')
        self.btn_add.on_click(self.add_filter_widget)
        self.btn_remove = Button(label='Remove filter on selected column', button_type='primary')
        self.btn_remove.on_click(self.remove_filter_widget)
        self.para_debug = Paragraph(text='', visible=self.DEBUG)
        self.placeholder1 = Paragraph(text='')
        self.placeholder2 = Paragraph(text='')
        self.btn_apply_filter = Button(label='Apply Filters', button_type='success')
        self.btn_apply_filter.on_click(self.apply_filters)
        self.btn_clear_filter = Button(label='Clear All Filters', button_type='primary')
        self.btn_clear_filter.on_click(self.clear_filters)
        self.para_dataset_desc = Paragraph(text='')
        self.data_table = generate_wid_table(self.filtered_data)
        self.space_h = Spacer(height=3, background='gray')
        self.widget_width = widget_width
        self.row_height = row_height
        self.widget_height_min = height_min
        self.widget_height_max = height_max
        self.page_layout = column(self.header,
                                  self.select_column,
                                  self.btn_add,
                                  self.btn_remove,
                                  self.space_h,
                                  self.para_debug,
                                  row(self.placeholder1, self.placeholder2),
                                  self.btn_apply_filter,
                                  self.btn_clear_filter,
                                  self.para_dataset_desc,
                                  self.data_table,
                                  width=250
                                  # sizing_mode='fixed'
                                  )

    def generate_category_filter(self, col_name: str) -> MultiSelect:
        """
        Generates a MultiSelect (dropdown menu) widget for string type column

        A "SelectAll" options is added to option list and enabled by default, so users don't have
        to shift-scroll
        :param col_name: dataframe column name to make filter on
        :return: one MultiSelect (dropdown menu) widget
        """
        option_vals = list(self.raw_data[col_name].unique())
        option_vals.sort()
        options_count = len(option_vals)
        select_widget = MultiSelect(title=col_name,
                                    value=['SelectAll'],
                                    options=['SelectAll'] + option_vals,
                                    name=col_name,
                                    height=widget_height(options_count,
                                                         self.row_height,
                                                         self.widget_height_min,
                                                         self.widget_height_max),
                                    width=self.widget_width)
        return select_widget

    def generate_range_filter(self, col_name: str) -> RangeSlider:
        """
        Generates a RangeSlider widget for number type column

        :param col_name: dataframe column name to make filter on
        :return: one RangeSlider widget
        """
        range_min = self.raw_data[col_name].min()
        range_max = self.raw_data[col_name].max()
        range_widget = RangeSlider(title=col_name,
                                   name=col_name,
                                   start=range_min,
                                   end=range_max,
                                   value=(range_min, range_max), )
        return range_widget

    def generate_daterange_filter(self, col_name: str) -> DateRangeSlider:
        """
        Generates a DateRangeSlider widget for datetime type column

        :param col_name: dataframe column name to make filter on
        :return: one DateRangeSlider widget, the returned value of this slider is a tuple of int,
        need to use value_as_datetime property instead
        """
        date_min = self.raw_data[col_name].min()
        date_max = self.raw_data[col_name].max()
        daterange_slider = DateRangeSlider(title="Date Range:",
                                           name=col_name,
                                           start=date_min,
                                           end=date_max,
                                           value=(date_min, date_max),
                                           step=1)
        return daterange_slider

    def generate_x_btn(self, col_name: str) -> Button:
        x_btn = Button(label='Remove {}'.format(col_name), button_type='danger')
        x_btn.name = col_name
        x_btn.on_click(partial(self.remove_x_btn, name=x_btn.name))
        self.x_btn_list.append(x_btn)
        return x_btn

    def remove_x_btn(self, name: str):
        for filter_widget in self.filter_widget_list:
            if filter_widget.name == name:
                self.filter_widget_list.remove(filter_widget)
        for x_btn in self.x_btn_list:
            if x_btn.name == name:
                self.x_btn_list.remove(x_btn)
        self.filter_name_list.remove(name)
        self.page_layout.children[6].children[0] = Column(children=self.filter_widget_list)
        self.page_layout.children[6].children[1] = Column(children=self.x_btn_list)
        self.para_debug.text = str(self.filter_name_list)

    def add_filter_widget(self):
        """
        Updates widget lists and page upon "button_add" click:

        Adds the widget into filter_widget_list (by respctive dtype);
        Adds the column name into filter_name_list
        Finally refreshes page layout
        """
        col_name = self.select_column.value
        if col_name not in self.filter_name_list:
            self.filter_name_list.append(col_name)

            if col_name in self.num_cols:
                filter_widget = self.generate_range_filter(col_name)
            elif col_name in self.dt_cols:
                filter_widget = self.generate_daterange_filter(col_name)
            else:
                filter_widget = self.generate_category_filter(col_name)

            self.filter_widget_list.append(filter_widget)
            x_btn = self.generate_x_btn(col_name)
            self.x_btn_list.append(x_btn)

            self.page_layout.children[6].children[0] = Column(children=self.filter_widget_list)
            self.page_layout.children[6].children[1] = Column(children=self.x_btn_list)

            self.para_debug.text = 'Active Filter(s):' + str(self.filter_name_list)

    def remove_filter_widget(self):
        """
        Updates widget lists and page upon "button_remove" click:

        Removes the widget from filter_widget_list (by respctive dtype);
        Removes the column name from filter_name_list
        Finally refreshes page layout
        """
        col_name = self.select_column.value
        for filter_widget in self.filter_widget_list:
            if filter_widget.name == col_name:
                self.filter_widget_list.remove(filter_widget)
                self.filter_name_list.remove(col_name)
            else:
                continue
        self.page_layout.children[6] = Column(children=self.filter_widget_list)
        self.para_debug.text = str(self.filter_name_list)

    def apply_filters(self):
        """
        Updates widget lists and page upon "button_apply" click:

        Makes a subset of dataframe by applying user selected filters on raw data
        Also updates the description text to brief data shape
        """
        df = self.raw_data.copy()
        for filter_widget in self.filter_widget_list:
            if filter_widget.name in self.num_cols:
                df = df[(df[filter_widget.name] >= filter_widget.value[0]) &
                        (df[filter_widget.name] <= filter_widget.value[1])]
            elif filter_widget.name in self.dt_cols:
                df = df[(df[filter_widget.name] >= filter_widget.value_as_datetime[0]) &
                        (df[filter_widget.name] <= filter_widget.value_as_datetime[1])]
            else:
                if filter_widget.value != ['SelectAll']:
                    df = df[df[filter_widget.name].isin(filter_widget.value)]
        self.filtered_data = df.copy()
        self.para_dataset_desc.text = 'Selected {} rows, Total {} rows'.format(
            self.filtered_data.shape[0], self.raw_data.shape[0])
        self.page_layout.children[-1] = generate_wid_table(self.filtered_data)

    def clear_filters(self):
        """
        Updates widget lists and page upon "button_clear" click:

        Clears filter_widget_lists (by respctive dtype);
        Clears filter_name_list
        Reset filtered_dataset to empty
        Finally refreshes page layout
        """
        self.filter_widget_list = []
        self.filter_name_list = []
        self.filtered_data = pd.DataFrame()
        self.page_layout.children[6] = Column(children=self.filter_widget_list)
        self.para_debug.text = str(self.filter_name_list)
        self.para_dataset_desc.text = 'Selected {} rows, Total {} rows'.format(
            self.filtered_data.shape[0], self.raw_data.shape[0])


script_path = os.path.dirname(__file__)
sample_csv = os.path.join(script_path, '..', 'data', 'sample.csv')
sample_df = pd.read_csv(sample_csv, low_memory=False)
filter_object = BokehFilters(sample_df)
curdoc().add_root(filter_object.page_layout)

@seer711 please pare all that down to a Minimal Reproducible Example (with emphasis on minimal) that illustrates just how you are attempting to update the layout, with everything unrelated stripped out. I would expect that to take 10-20 lines of code, at most.

This stand-alone code is working after I take out the other stuffs.
I’ll try to figure out how to make it work in a Class.
Thanks for the advice.

from bokeh.models.widgets import Select, Button, Paragraph
from bokeh.layouts import column, row
from bokeh.models import Column
from functools import partial
from bokeh.io import curdoc

widget_list = []
select_column = Select(title='Choose Column:', value='A', options=['A', 'B', 'C'])

def generate_widget():
    name = select_column.value
    widget = Paragraph(text='Widget {}'.format(name), name=name)
    x_btn = Button(label='X', button_type='danger', name=name)
    x_btn.on_click(partial(remove_widget, name=name))
    widget_list.append(row(widget, x_btn))
    page_layout.children[2] = Column(children=widget_list)

def remove_widget(name):
    for combo in widget_list:
        if combo.children[0].name == name:
            widget_list.remove(combo)
    page_layout.children[2] = Column(children=widget_list)

btn_add = Button(label='Add filter on selected column', button_type='success')
btn_add.on_click(generate_widget)

page_layout = column(select_column, btn_add, Column(children=widget_list))
curdoc().add_root(page_layout)
1 Like