Search code examples
pythonpython-3.xbokehbokehjs

Bokeh - How to have the same widget (or duplicate a widget) in two different tabs?


I'm trying to create a widget filter (made up of TextInput and MultiSelect) that is replicated on two different Bokeh Tabs. The desired functionality is that filtering results should be preserved between tabs, regardless of which filter receives the text to filter off of.

The code below(it is working code) builds the Filter widget which is instantiated as filter1 and filter2. The callback is the update function which does the actual filtering and updates the MultiSelect part of the filter.

from bokeh.io import curdoc
from bokeh.layouts import column, widgetbox, row, layout, gridplot
from bokeh.models import Slider, Select, TextInput, MultiSelect
from bokeh.models.widgets import Panel, Tabs
import pandas as pd
from functools import partial


df = pd.DataFrame(["apples", "oranges", "grapes"], columns=["fruits"])


multiselect = None
input_box = None


def update(widget, attr, old, new):
    print("df['fruits']: {}".format(list(df['fruits'])))
    print("{} : {} changed: Old [ {} ] -> New [ {} ]".format(widget, attr, old, new))

    if widget == 'input':
        col_data = list(df[df['fruits'].str.contains(new)]['fruits'])
        print("col_date: {}".format(col_data))
        multiselect.update(options = sorted(list(col_data)))


def init():
    global multiselect
    multiselect = MultiSelect(title = 'multiselect',
                              name = 'multiselect',
                              value = [],
                              options = list(df["fruits"]))
    multiselect.on_change('value', partial(update,  multiselect.name))

    global input_box
    input_box = TextInput(title = 'input',
                           name ='input',
                           value='Enter you choice')
    input_box.on_change('value', partial(update, input_box.name))

class Filter:
    def __init__(self):
        self.multiselect = multiselect
        self.input_box = input_box
        self.widget = widgetbox(self.input_box, self.multiselect)

init()
filter1 = Filter().widget
filter2 = Filter().widget

curdoc().add_root(row(filter1, filter2))

The code above produces/assembles the widget as shown here:

enter image description here

Also, the functionality of the two mirrored filters is as desired; when text is entered in one of the boxes, the results are displayed on both filters.

Now, and here is where I need help, I want the same filters with the same functionality but I need them in two different tabs; one filter in one tab and the other filter in the other tab.

The code used to build the two tabs structure is:

p1 = Panel(child = filter1, title = "Panel1")

p2 = Panel(child = filter2, title = "Panel2")

tabs = Tabs(tabs=[ p1, p2 ])
curdoc().add_root(layout(tabs))

On the results side, the code preserves the desired functionality but filters are displayed on the same page. More than that, panels/tabs are not even being built.
Any idea what's missing? (If you want to play with the code it should work right off the bat if you have bokeh installed.)

enter image description here


Solution

  • I do not think your example should even build a document, both your textinputs and multiselect models have the same id, which may be why the display of tabs gets messed up.

    My solution is similar to HYRY's, but with a more general function to share attributes using two different things:

    model.properties_with_values()

    Can be used with any bokeh model and returns a dictionary of all the attribute:value pairs of the model. It's mostly useful in ipython to explore bokeh objects and debug

    Document.select({'type':model_type})

    Generator of all the widgets of the desired type in the document

    Then I just filter out the widgets that do not share the same tags as the input widget, which would avoid "syncing" other inputs/multiselect not generated with box_maker(). I use tags because different models cannot have the same name.

    When you change a TextInput value, it will change the associated Multiselect in the update function, but it will also change all the other TextInputs and trigger their update in the same way too. So each Input triggers update once and changes the options of their respective multiselect (and not multiplte times each because it's a "on_change" callback, if you give the same value for the new input it does not trigger).

    For the Multiselect the first trigger of update will do the job, but since it changed the values of the other Multiselect it still triggers as many times as there are Multiselect widgets.

    from bokeh.io import curdoc
    from bokeh.layouts import widgetbox
    from bokeh.models import TextInput, MultiSelect
    from bokeh.models.widgets import Panel, Tabs
    import pandas as pd
    from functools import partial
    
    
    df = pd.DataFrame(["apples", "oranges", "grapes"], columns=["fruits"])
    
    def sync_attr(widget):
        prop = widget.properties_with_values() # dictionary of attr:val pairs of the input widget
        for elem in curdoc().select({'type':type(widget)}): # loop over widgets of the same type
            if (elem!=widget) and (elem.tags==widget.tags): # filter out input widget and those with different tags
                for key in prop: # loop over attributes
                    setattr(elem,key,prop[key]) # copy input properties
    
    def update(attr,old,new,widget,other_widget):
        print("\ndf['fruits']: {}".format(list(df['fruits'])))
        print("{} : {} changed: Old [ {} ] -> New [ {} ]".format(str(widget),attr, old, new))
    
        if type(widget)==TextInput:
            col_data = list(df[df['fruits'].str.contains(new)]['fruits'])
            print("col_date: {}".format(col_data))
            other_widget.update(options = sorted(list(col_data)))
    
        sync_attr(widget)
    
    def box_maker():
        multiselect = multiselect = MultiSelect(title = 'multiselect',tags=['in_box'],value = [],options = list(df["fruits"]))
        input_box = TextInput(title = 'input',tags=['in_box'],value='Enter you choice')
    
        multiselect.on_change('value',partial(update,widget=multiselect,other_widget=input_box))
        input_box.on_change('value',partial(update,widget=input_box,other_widget=multiselect))
    
        return widgetbox(input_box, multiselect)
    
    box_list = [box_maker() for i in range(2)]
    
    tabs = [Panel(child=box,title="Panel{}".format(i)) for i,box in enumerate(box_list)]
    
    tabs = Tabs(tabs=tabs)
    curdoc().add_root(tabs)
    

    Note that the highlighting of the options in multiselect may not look consistent, but that just seems to be visual as the values/options of each of them are changing correctly.

    But unless you are particularly attached to the layout look when you put the widgets inside the panels, you could just put one input and multiselect outside, and write their callbacks to deal with what will be in the different panels.