Sync multiple dropdowns but still be able to filter based on previous selection

Hi All

I’m working on a Dash app made up of 1 row and 2 columns (simplified version)

Column one plots address on a scatterMapbox and show the addresses plotted in a table below it based on user selections

Column two has 7 dropdown filters e.g. street, address and other items etc.

I’ve managed to sync the dropdowns. Where it gets a bit funny is if I chose one dropdown and want to use another to filter by what’s already chosen… it that doesn’t work or work the way I think I should.

I have thought about using store but wasn’t sure that would work and also thought about maybe chaining responses for the dropdown but that sounds messier than what I have currently and shying away from that as I think I need to refactor my current code as Im using similar code in a few spots!

Any help is appreciated! I’m also new to Dash and an amateur at Python :slight_smile: I have a private github if that’s useful!

Hello @Javadabbadoo,

Welcome to the community!

For my filtering that builds upon each other, I actually use a table or grid, then pull the data from the table to filter the DF down. This allows for building filters fairly straightforward because each time you add a new level, you just filter down even more.

This can even allow for some very complex filtering.

For example, you can begin to use OR clauses inside different layers of the filters. I typically do this by “grouping” the filters together.

Hi Jinny,

Thanks heaps for the reply! I’m not sure I completely follow tho. Do you have any example or a code snippet I could look at?

I’m looking for functionality like this:

Drop-down 1: street name
Drop-down 2 : addresses

So that if I click drop-down 1 street it automatically shows all the available addresses on the street and vice versa!

Thanks again

Is there any way that you can provide an MRE for this?

Give some basic info and how you are wanting it to work.

2 Likes

Hi Bryan,

Thanks for the help so far! I think your first answer is sort of what I’m after but I’m a bit new so will take me a while to muddle through so I’ve attempted an MRE below.

I’d like to have a series of dropdowns synced together such that if I select a feature in one dropdown it updates the other dropdowns and their options based on the selection which in turn filters the table results.

So with the example below if I choose the continent Africa, the country dropdown only allows countries from Africa and so on for the year! Likewise, it works in reverse or any order you start the selection from.

After rereading your post I had a look at this and it has the functionality for what I’m after in the table filters but wasn’t sure how to make the dropdown work as the filter query etc. Does the same docs on the filtering page work for AG Grid?

On a separate note is there a way to fix that weird space formatting to get the dropdown and table in one row apart from dropping columns?

Thanks again:)

import pandas as pd
import plotly.express as px
from dash import Dash, html, dash_table, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

df = px.data.gapminder()

#dropdown options
continent_options = df['continent'].unique()
country_options = df['country'].unique()
year_options = df['year'].unique()


# Initilialise app & style
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

#Layout
app.layout = dbc.Container(
    dbc.Row([
        dbc.Col([
            dcc.Markdown('##### Sync Dropdowns')
        ]),
        html.Hr(),
        
        dbc.Col([dash_table.DataTable(data=df.to_dict('records'),
                                      id='Table',
                                      page_size=10,
                                     )
                ],
                width=8
               ),
        dbc.Col([dcc.Dropdown(id='continent_dropdown',
                              options=continent_options,
                              clearable=True,
                              multi=True),
                html.Hr(),
                dcc.Dropdown(id='country_dropdown',
                              options=country_options,
                              clearable=True,
                              multi=True),
                html.Hr(), 
                dcc.Dropdown(id='year_dropdown',
                              options=year_options,
                              clearable=True,
                              multi=True),
                html.Hr()],
                width=4)
    ]),
    fluid=True
)

# Sync Street, Sequence, Address, Status dropdown Filter
@callback(
    Output(component_id='continent_dropdown', component_property='value'),
    Output(component_id='country_dropdown', component_property='value'),
    Output(component_id='year_dropdown', component_property='value'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='country_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value')
)
def sync_dropdowns(chosen_continent, chosen_country, chosen_year):
    input_id = ctx.triggered[0]["prop_id"].split(".")[0]
    if input_id == "continent_dropdown":
        selected_df = df.query("continent == @chosen_continent")
        chosen_country = selected_df['country'].unique().tolist()
        chosen_year = selected_df['year'].unique().tolist()
    elif input_id == "country_dropdown":
        selected_df = df.query("country == @chosen_country")
        chosen_continent = selected_df['continent'].unique().tolist()
        chosen_year = selected_df['year'].unique().tolist()
    elif input_id == "year_dropdown":
        selected_df = df.query("year == @chosen_year")
        chosen_country = selected_df['country'].unique().tolist()
        chosen_continent = selected_df['continent'].unique().tolist()
    return chosen_continent, chosen_country, chosen_year


# Update Table
@callback(
    Output(component_id='Table', component_property='data'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='country_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value')
)

def update_table(chosen_continent, chosen_country, chosen_year):
    input_id = ctx.triggered[0]["prop_id"].split(".")[0]

    if input_id == 'continent_dropdown':
        selected_df = df.query("continent == @chosen_continent")
    elif input_id == 'country_dropdown':
        selected_df = df.query("country == @chosen_country")
    elif input_id == 'year_dropdown':
        selected_df = df.query("year == @chosen_year")
    
    return selected_df.to_dict('records')

# Run the app
if __name__ == '__main__':
    app.run(debug=True)

Hello @Javadabbadoo,

Thanks for the example of what you want to do.

For clarification, you want the selections to work in a narrowing fashion? For example, currently, if you select a year, it automatically populates the other values, is this what you want? Or do you want to change the year and it leaves the others alone as long the values already exist in there?

If the later, then I’d recommend interacting directly with the filterModel of an AG Grid and use it to populate the available options or such.

I think you could use chained callback for this purpose. Something as below:

import pandas as pd
import plotly.express as px
from dash import Dash, html, dash_table, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

df = px.data.gapminder()

#dropdown options
continent_options = df['continent'].unique().tolist()
country_options = df['country'].unique().tolist()
year_options = df['year'].unique().tolist()


# Initilialise app & style
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

#Layout
app.layout = dbc.Container(
    dbc.Row([
        dbc.Col([
            dcc.Markdown('##### Sync Dropdowns')
        ]),
        html.Hr(),
        
        dbc.Col([dash_table.DataTable(data=df.to_dict('records'),
                                      id='Table',
                                      page_size=10,
                                     )
                ],
                width=8
               ),
        dbc.Col([dcc.Dropdown(id='continent_dropdown',
                              options=continent_options,
                              clearable=True,
                              multi=True, 
                              value=[]),
                html.Hr(),
                dcc.Dropdown(id='country_dropdown',
                             options=country_options,
                             clearable=True,
                             multi=True, 
                             value=[]),
                html.Hr(), 
                dcc.Dropdown(id='year_dropdown',
                             options=year_options,
                             clearable=True,
                             multi=True, value=[]),
                html.Hr()],
                width=4)
    ]),
    fluid=True
)

# Sync Street, Sequence, Address, Status dropdown Filter
@callback(
    Output(component_id='continent_dropdown', component_property='options'),
    Input(component_id='country_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value')
)
def sync_dropdowns(chosen_country, chosen_year):
    dff = df.copy()
    if chosen_country:
        dff = dff[dff['country'].isin(chosen_country)]
    if chosen_year:
        dff = dff[dff['year'].isin(chosen_year)] 
    return [{'label':x,'value':x} for x in dff['continent'].unique()]

@callback(
    Output(component_id='country_dropdown', component_property='options'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value')
)
def sync_dropdowns(chosen_continent, chosen_year):
    dff = df.copy()
    if chosen_continent:
        dff = dff[dff['continent'].isin(chosen_continent)]
    if chosen_year:
        dff = dff[dff['year'].isin(chosen_year)] 
    return [{'label':x,'value':x} for x in dff['country'].unique()]

@callback(
    Output(component_id='year_dropdown', component_property='options'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='country_dropdown', component_property='value')
)
def sync_dropdowns(chosen_continent,chosen_country):
    dff = df.copy()
    if chosen_continent:
        dff = dff[dff['continent'].isin(chosen_continent)]
    if chosen_country:
        dff = dff[dff['country'].isin(chosen_country)]
    return [{'label':x,'value':x} for x in dff['year'].unique()]


@callback(
    Output(component_id='Table', component_property='data'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='country_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value')
)

def update_table(chosen_continent, chosen_country, chosen_year):
    if not chosen_continent:
        chosen_continent = continent_options
    if not chosen_country:
        chosen_country = country_options
    if not chosen_year:
        chosen_year = year_options

    ddf = df.query('continent == @chosen_continent and '
                   'country == @chosen_country and '
                   'year == @chosen_year',
                   engine='python')
    
    return ddf.to_dict('records')


# Run the app
if __name__ == '__main__':
    app.run(debug=False)
1 Like

Hi Jinnyzor,

Thanks again. I’m after the former where the dropdowns work in a narrowing fashing. The code snippet from hoatran works exactly how I’d expect it to work however, I’ve got 7/8 dropdown filters so the amount of code becomes a bit much (not including graphs etc).

I’ve tried it with table filters in the code below but the table only updates once all the dropdowns are selected which makes the filtering a bit silly (I was hoping to do a callback on the filtered table data to update the dropdowns).

import pandas as pd
import plotly.express as px
from dash import Dash, html, dash_table, dcc, callback, Output, Input
import dash_bootstrap_components as dbc

df = px.data.gapminder()
df.drop(columns='iso_num', inplace=True)


#dropdown options
continent_options = df['continent'].unique()
country_options = df['country'].unique()
year_options = df['year'].unique()


# Initilialise app & style
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

def table_type(df_column):

    if isinstance(df_column.dtype, pd.DatetimeTZDtype):
        return 'datetime',
    elif (isinstance(df_column.dtype, pd.StringDtype) or
            isinstance(df_column.dtype, pd.BooleanDtype) or
            isinstance(df_column.dtype, pd.CategoricalDtype) or
            isinstance(df_column.dtype, pd.PeriodDtype)):
        return 'text'
    elif (isinstance(df_column.dtype, pd.SparseDtype) or
            isinstance(df_column.dtype, pd.IntervalDtype) or
            isinstance(df_column.dtype, pd.Int8Dtype) or
            isinstance(df_column.dtype, pd.Int16Dtype) or
            isinstance(df_column.dtype, pd.Int32Dtype) or
            isinstance(df_column.dtype, pd.Int64Dtype)):
        return 'numeric'
    else:
        return 'any'


#Layout
app.layout = dbc.Container([
    dbc.Row([
        dbc.Col([
            dcc.Markdown('##### Sync Dropdowns')
        ]),
        html.Hr(),
    ]),
    dbc.Row([
        dbc.Col([dash_table.DataTable(data=df.to_dict('records'),
                                      columns=[
                                          {'name': i, 'id': i, 'type': table_type(df[i])} for i in df.columns
                                      ],
                                      id='Table',
                                      page_size=10,
                                      fill_width=False,
                                      style_table={
                                          'overflowX': 'auto',
                                          'width':'100%',
                                          'margin':'auto'},
                                      filter_action='native',
                                      filter_query=''
                                     )
                ],
                width='9'
               ),
        dbc.Col([dcc.Dropdown(id='continent_dropdown',
                              options=continent_options,
                              clearable=True,
                              multi=True),
                html.Hr(),
                dcc.Dropdown(id='country_dropdown',
                              options=country_options,
                              clearable=True,
                              multi=True),
                html.Hr(), 
                dcc.Dropdown(id='year_dropdown',
                              options=year_options,
                              clearable=True,
                              multi=True),
                html.Hr()],
                width='3')
    ])
],
    fluid=True,)


# Sync table & Dropdown
@callback(
    Output(component_id='Table', component_property='filter_query'),
    Input(component_id='continent_dropdown', component_property='value'),
    Input(component_id='country_dropdown', component_property='value'),
    Input(component_id='year_dropdown', component_property='value'),
    

)

def update_table(chosen_continents, chosen_country, chosen_year):
    if all([chosen_continents, chosen_country, chosen_year]) == False:
        return '{continent} contains "Asia"'
    
    else:
        continent_query = " or ".join([f"{{continent}} contains '{i}'" for i in chosen_continents])
        continent_query = f"({continent_query})"
        print(continent_query)
        country_query = " or ".join([f"{{country}} contains '{i}'" for i in chosen_country])
        country_query = f"({country_query})"
        print(country_query)
        year_query = " or ".join([f"{{year}} contains '{i}'" for i in chosen_year])
        year_query = f"({year_query})"
        print(year_query)
        combined_query= " and ".join([string for string in [continent_query, country_query, year_query]])
        print(combined_query)

    return combined_query

# Run the app
if __name__ == '__main__':
    app.run(debug=True)

Hi Hoatran,

Thanks heaps for this. It works pretty much as I’d like it on the mock data. I scaled it up to the real dataset and included 5 more dropdowns and it kept giving a valueerror: (‘lengths must match to compare’, (1427,), (34,)) and filters a bit odd but will keep having a look at it. Thanks again!

1 Like

Here, check out this version, this uses AG Grid and its filterModel, notice how we had set the maxNumConditions on the column based upon the possible list of options:

import pandas as pd
import plotly.express as px
from dash import Dash, html, dash_table, dcc, callback, Output, Input, ALL, State, no_update, Patch
import dash_bootstrap_components as dbc
import dash_ag_grid as dag

df = px.data.gapminder()
df.drop(columns='iso_num', inplace=True)

# Initilialise app & style
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

# Layout
app.layout = dbc.Container([
    dbc.Row([
        dbc.Col([
            dcc.Markdown('##### Sync Dropdowns')
        ]),
        html.Hr(),
    ]),
    dbc.Row([
        dbc.Col([dag.AgGrid(rowData=df.to_dict('records'),
                                      columnDefs=[
                                          {'field': i,
                                           'filterParams': {'readOnly': True,
                                                            'maxNumConditions': len(df[i].unique())}}
                                          for i in df.columns
                                      ],
                            defaultColDef={'filter': True},
                                             id='grid',
                                      )
                 ],
                width='9'
                ),
        dbc.Col([html.Div([html.Div(i),
                           dcc.Dropdown(id={'index': i, 'type': 'filter'},
                                        options=[{'label': x, 'value': x} for x in df[i].unique().tolist()], multi=True)]
                          ) for i in df.columns],
                width='3')
    ])
],
    fluid=True)

@callback(
    Output({'index': ALL, 'type': 'filter'}, 'value'),
    Input('grid', 'virtualRowData'),
    State({'index': ALL, 'type': 'filter'}, 'id'),
    State({'index': ALL, 'type': 'filter'}, 'value'),
)
def updateDropdowns(d, ids, vals):
    if d:
        df = pd.DataFrame(d)
        values = []
        for i in range(len(ids)):
            refList = df[ids[i]['index']].unique().tolist()
            if vals[i]:
                values.append([x for x in vals[i] if x in refList])
            else:
                values.append([])
        return values
    return no_update

@callback(
    Output('grid', 'filterModel'),
    Input({'index': ALL, 'type': 'filter'}, 'value'),
    State({'index': ALL, 'type': 'filter'}, 'id'),
)
def updateFilters(vals, ids):
    filterModel = {}
    for i in range(len(vals)):
        if vals[i]:
            filterModel[ids[i]['index']] = {}
            filterModel[ids[i]['index']]['type'] = 'equals'
            filterModel[ids[i]['index']]['filterType'] = 'text'
            count = 1
            if len(vals[i]) > 1:
                filterModel[ids[i]['index']]['operator'] = 'OR'
                conditions = []
            for val in vals[i]:
                if len(vals[i]) > 1:
                    conditions.append({'filterType': 'text', 'type': 'equals', 'filter': val})
                    count += 1
                else:
                    filterModel[ids[i]['index']]['filter'] = val
            if len(vals[i]) > 1:
                filterModel[ids[i]['index']]['conditions'] = conditions
    return filterModel


# Run the app
if __name__ == '__main__':
    app.run(debug=True)

This utilizes pattern-matching to keep the callbacks simpler.

3 Likes

Hey Jinnyzor,

Thanks heaps for the help:) and sorry it took a while to reply.

I’ve tried updating the available dropdown options based on the dropdown selection. It appears to work but it doesn’t allow me to select more than one item. E.g. if I chose continent = Oceania I can’t then pick countries Australia & New Zealand… I can only choose one. Is there a way to fix this?

Also, what would be the best way to have this in a multipage app? I’ve tried this a couple of ways (changing the index name, filter name ) and it either throws a duplicate wildcard error or in some cases does nothing and throws no error:/ << Fixed this:) by changing the type but not the id:) and removed a spelling error :blush:

Thanks again:)

import pandas as pd
import plotly.express as px
from dash import Dash, html, dash_table, dcc, callback, Output, Input, ALL, State, no_update, Patch
import dash_bootstrap_components as dbc
import dash_ag_grid as dag

df = px.data.gapminder()
df.drop(columns='iso_num', inplace=True)

# Initilialise app & style
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

# Layout
app.layout = dbc.Container([
    dbc.Row([
        dbc.Col([
            dcc.Markdown('##### Sync Dropdowns')
        ]),
        html.Hr(),
    ]),
    dbc.Row([
        dbc.Col([dag.AgGrid(rowData=df.to_dict('records'),
                                      columnDefs=[
                                          {'field': i,
                                           'filterParams': {'readOnly': True,
                                                            'maxNumConditions': len(df[i].unique())}}
                                          for i in df.columns
                                      ],
                            defaultColDef={'filter': True},
                                             id='grid',
                                      )
                 ],
                width='9'
                ),
        dbc.Col([html.Div([html.Div(i),
                           dcc.Dropdown(id={'index': i, 'type': 'filter'},
                                        options=[{'label': x, 'value': x} for x in df[i].unique().tolist()], multi=True)]
                          ) for i in df.columns],
                width='3')
    ])
],
    fluid=True)

@callback(
    Output({'index': ALL, 'type': 'filter'}, 'value'),
    Output({'index': ALL, 'type': 'filter'}, 'options'),
    Input('grid', 'virtualRowData'),
    State({'index': ALL, 'type': 'filter'}, 'id'),
    State({'index': ALL, 'type': 'filter'}, 'value'),
)
def updateDropdowns(grid_data, ids, vals):
    # State ids don't change they just are used to help filter
    print('_____New Selections____')
    if grid_data:
        # Converts grid row data dict to df
        df = pd.DataFrame(grid_data)
        values = []
        available_options = []

        # loop through list of ids. Range is used to get an index position e.g. 0, 1, etc
        for i in range(len(ids)):
            # create a list of available dropdown options "values" for each dropdown using the 'ranged index'
            refList = df[ids[i]['index']].dropna().unique().tolist()
            #print(f'reflist: {refList}')

            # Update available options list for one selection only
            available_options.append(refList)

            # create a list of values chosen in the dropdown and append to the values list
            if vals[i]:
                # add a list to create a list of list for each dropdown and it values chosen
                values.append([x for x in vals[i] if x in refList])

            # Return empty list if dropdown wasn't used
            else:
                values.append([])
        print(f'These are the available options: {available_options}')

        return values, available_options
    # if no dropdown chosen do nothing
    return no_update

@callback(
    Output('grid', 'filterModel'),
    Input({'index': ALL, 'type': 'filter'}, 'value'),
    State({'index': ALL, 'type': 'filter'}, 'id'),
)
def updateFilters(vals, ids):
    filterModel = {}
    for i in range(len(vals)):
        if vals[i]:
            filterModel[ids[i]['index']] = {}
            filterModel[ids[i]['index']]['type'] = 'equals'
            filterModel[ids[i]['index']]['filterType'] = 'text'
            count = 1
            if len(vals[i]) > 1:
                filterModel[ids[i]['index']]['operator'] = 'OR'
                conditions = []
            for val in vals[i]:
                if len(vals[i]) > 1:
                    conditions.append({'filterType': 'text', 'type': 'equals', 'filter': val})
                    count += 1
                else:
                    filterModel[ids[i]['index']]['filter'] = val
            if len(vals[i]) > 1:
                filterModel[ids[i]['index']]['conditions'] = conditions
    return filterModel


# Run the app
if __name__ == '__main__':
    app.run(debug=True)