1

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

flamenco
  • 2,702
  • 5
  • 30
  • 46
  • If you really need a widget common to all tabs, did you consider putting the widget outside of the Tabs object? – Seb May 24 '18 at 12:53
  • @Seb Do you have an example how it can be done? It might be even better as information can be shared between multiple tabs and it will minimize the amount of code to write. Yeah, if you have a simple example, I would be happy to try it out. Cheers! – flamenco May 24 '18 at 16:10
  • I do not have a simple example ready, hard to tell without knowing the content of the tabs. It would just be something with a layout like row(widgetbox(input,multiselect),tabs); then it is a question of adapting the input/multiselect callback to update the contents of the different tabs. – Seb May 24 '18 at 17:05

2 Answers2

2

You can't use the same widget model to create multiple views. You can create new widgets in every tabs and link the value:

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


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

class Filter:
    def __init__(self):
        self.multiselect = MultiSelect(title = 'multiselect',
                                  name = 'multiselect',
                                  value = [],
                                  options = list(df["fruits"]))
        self.multiselect.on_change('value', self.selection_changed)

        self.input_box = TextInput(title = 'input',
                               name ='input',
                               value='Enter you choice')
        self.input_box.on_change('value', self.input_box_updated)

        self.widget = widgetbox(self.input_box, self.multiselect)

    def input_box_updated(self, attr, old, new):
        print(attr, old, new)
        col_data = list(df[df['fruits'].str.contains(new)]['fruits'])
        self.multiselect.update(options = sorted(list(col_data)))

    def selection_changed(self, attr, old, new):
        print(new)

filter1 = Filter()
filter2 = Filter()

def link_property(property_name, *widgets):
    wb = widgetbox(*widgets)

    wb.tags = [property_name, 0]
    def callback(widgets=wb):
        if widgets.tags[1] != 0:
            return
        widgets.tags[1] = 1
        for widget in widgets.children:
            widget[widgets.tags[0]] = cb_obj.value
        widgets.tags[1] = 0

    jscallback = CustomJS.from_py_func(callback)

    for widget in widgets:
        widget.js_on_change(property_name, jscallback)

link_property("value", filter1.input_box, filter2.input_box) 
link_property("value", filter1.multiselect, filter2.multiselect)        
p1 = Panel(child = filter1.widget, title = "Panel1")
p2 = Panel(child = filter2.widget, title = "Panel2")

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

It seems that there is a bug in MultiSelect that doesn't deselect previous items.

HYRY
  • 94,853
  • 25
  • 187
  • 187
  • Filtering is not working. The only change that I made to your code was to replace `show(layout(tabs))` with `curdoc().add_root(layout(tabs))` and run it with `bokeh serve test_so.py --port 5006`. Type _o_ in any of the `TextInput` widgets and it should display _orange_ in both `MultiSelect` widgets. But filtering functionality is lost. – flamenco May 24 '18 at 03:48
  • Also, the filters are not the _same_ widget as they work fain when they are displayed on the same page, side-by-side. The only problem that I have is when trying to embed them in two different tabs. – flamenco May 24 '18 at 03:56
  • @flamenco, I modified the code, it should work in server mode. I think it happened to work with the same id on the same page, There are not any documents that says Bokeh support multiple view for one model. – HYRY May 24 '18 at 04:11
  • @flamenco, just replace `show(app)` to `app(curdoc())`. – HYRY May 24 '18 at 04:30
  • Filtering still not working as expected. I would be surprised if it works for you. Try it... – flamenco May 24 '18 at 04:38
  • @flamenco, There was a bug in the code, after fixed it, I tried it with `bokeh serve`, it worked. – HYRY May 24 '18 at 04:48
2

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.

Seb
  • 1,765
  • 9
  • 23
  • Cool solution! I like the way you use `partial` to pass in more parameters. Can you explain how `sync_attr()` works? – flamenco May 25 '18 at 00:04
  • @flamenco added some explanations – Seb May 25 '18 at 12:59
  • Taking the idea further, do you know how to create a custom widget that will, in the end, 'feel' like native Bokeh widget? If you want, we can treat this topic under a different post. – flamenco May 27 '18 at 17:49
  • @flamenco I think making a real custom widget will require to write Typescript, I don't have experience doing that – Seb May 28 '18 at 12:19