Determine what DataTable row was selected

What would be the simplest / safest way to determine in a callback which row (or row ID) a user just selected in a DataTable with multi-select?
There may already be rows selected, and I only want to know the last one that was clicked.

I guess I could use a dcc.Store to save the last state of selection, then use it as a State in the callback, to then compare with the currently selected rows and work out the difference between the 2, but that seems unnecessarily complex…

Following the discovery of what (to me) looks like a bug (discrepancy between the actual selection of rows, and the value of derived_viewport_selected_row_ids - which looks like something I also reported a while back, I found one simple technique, which basically compares the selected_row_ids, derived_viewport_row_ids and (incorrect) derived_viewport_selected_row_ids to find the difference, and thereby determining the last ticked or unticked row in the table (without the need for a dcc.Store.

Example below.

I’m not sure however whether I can rely on this, given that this looks like a bug, and may get fixed one day.

from dash import Dash, dash_table, dcc, html
from dash.dependencies import Input, Output, State
import pandas as pd

df = pd.read_csv('https://raw.githubusercontent.com/plotly/datasets/master/gapminder2007.csv')
df['id'] = df['country']

app = Dash(__name__)

app.layout = html.Div([
    html.P(id='placeholder'),

    dash_table.DataTable(
        id='datatable-interactivity',
        columns=[
            {"name": i, "id": i, "deletable": True, "selectable": True} for i in df.columns
        ],
        data=df.to_dict('records'),
        editable=True,
        filter_action="native",
        sort_action="native",
        sort_mode="multi",
        column_selectable="single",
        row_selectable="multi",
        row_deletable=True,
        selected_columns=[],
        selected_rows=[],
        page_action="native",
        page_current= 0,
        page_size= 10,
    ),
    html.Div(id='datatable-interactivity-container')
])

@app.callback(
    Output('datatable-interactivity', 'style_data_conditional'),
    Input('datatable-interactivity', 'selected_columns')
)
def update_styles(selected_columns):
    return [{
        'if': { 'column_id': i },
        'background_color': '#D2F3FF'
    } for i in selected_columns]

@app.callback(
    Output('datatable-interactivity-container', "children"),
    Input('datatable-interactivity', "derived_virtual_data"),
    Input('datatable-interactivity', "derived_virtual_selected_rows"))
def update_graphs(rows, derived_virtual_selected_rows):
    # When the table is first rendered, `derived_virtual_data` and
    # `derived_virtual_selected_rows` will be `None`. This is due to an
    # idiosyncrasy in Dash (unsupplied properties are always None and Dash
    # calls the dependent callbacks when the component is first rendered).
    # So, if `rows` is `None`, then the component was just rendered
    # and its value will be the same as the component's dataframe.
    # Instead of setting `None` in here, you could also set
    # `derived_virtual_data=df.to_rows('dict')` when you initialize
    # the component.
    if derived_virtual_selected_rows is None:
        derived_virtual_selected_rows = []

    dff = df if rows is None else pd.DataFrame(rows)

    colors = ['#7FDBFF' if i in derived_virtual_selected_rows else '#0074D9'
              for i in range(len(dff))]

    return [
        dcc.Graph(
            id=column,
            figure={
                "data": [
                    {
                        "x": dff["country"],
                        "y": dff[column],
                        "type": "bar",
                        "marker": {"color": colors},
                    }
                ],
                "layout": {
                    "xaxis": {"automargin": True},
                    "yaxis": {
                        "automargin": True,
                        "title": {"text": column}
                    },
                    "height": 250,
                    "margin": {"t": 10, "l": 10, "r": 10},
                },
            },
        )
        # check if column exists - user may have deleted it
        # If `column.deletable=False`, then you don't
        # need to do this check.
        for column in ["pop", "lifeExp", "gdpPercap"] if column in dff
    ]

@app.callback(
    [Output("placeholder", "children")],
    [Input("datatable-interactivity", "selected_row_ids")],
    [
        State("datatable-interactivity", "derived_viewport_row_ids"),
        State("datatable-interactivity", "derived_viewport_selected_row_ids"),
        State("datatable-interactivity", "derived_virtual_row_ids"),
        State("datatable-interactivity", "derived_virtual_selected_row_ids"),
        State("datatable-interactivity", "data")
    ])
def see_row_ids(selected_row_ids, derived_viewport_row_ids, derived_viewport_selected_row_ids, derived_virtual_row_ids,
                derived_virtual_selected_row_ids, data):
    out = []
    if selected_row_ids:
        out.append(html.P("selected_row_ids ({}): {}\n".format(len(selected_row_ids), selected_row_ids)))
    if derived_viewport_row_ids:
        out.append(html.P("derived_viewport_row_ids ({}): {}".format(len(derived_viewport_row_ids), derived_viewport_row_ids)))
    if not derived_viewport_selected_row_ids:
        derived_viewport_selected_row_ids = []
    out.append(html.P("derived_viewport_selected_row_ids ({}): {}".format(len(derived_viewport_selected_row_ids), derived_viewport_selected_row_ids)))
    if derived_virtual_row_ids:
        out.append(html.P("derived_virtual_row_ids ({}): {}".format(len(derived_virtual_row_ids), derived_virtual_row_ids)))
    if not derived_virtual_selected_row_ids:
        derived_virtual_selected_row_ids = []
    out.append(html.P("derived_virtual_selected_row_ids ({}): {}".format(len(derived_virtual_selected_row_ids), derived_virtual_selected_row_ids)))

    all_selected = selected_row_ids
    if not all_selected:
        all_selected = []
    if not derived_virtual_row_ids:
        derived_virtual_row_ids = []
    selected_in_view = [x for x in derived_virtual_row_ids if x in all_selected]
    previously_selected_in_view = derived_viewport_selected_row_ids
    last_selection = [x for x in selected_in_view if x not in previously_selected_in_view]
    last_deselection = [x for x in previously_selected_in_view if x not in selected_in_view]
    if last_selection:
        out.append(html.P(f"last selected {last_selection}"))
    if last_deselection:
        out.append(html.P(f"last deselected {last_deselection}"))

    # difference = [x for x in ]

    return [out]


if __name__ == '__main__':
    app.run_server(debug=True)

I am haven’t used the data DataTable component much, so I can’t say if there is an easy way to get the row index. A different approach, generic with respect to the underlying component , could be to use the EventListener component to capture the click event. Here is a small example,

import pandas as pd
from dash import Dash, html, Input, Output, dash_table as dt
from dash.exceptions import PreventUpdate
from dash_extensions import EventListener

# Create a small data table.
df = pd.read_csv("https://git.io/Juf1t")
df["id"] = df.index
table = dt.DataTable(
    id="tbl",
    data=df.to_dict("records"),
    columns=[{"name": i, "id": i} for i in df.columns],
)
# Event(s) to listen to, i.e. click, and the prop(s) to record, i.e. row index.
row_index = "srcElement.attributes.data-dash-row.value"
events = [{"event": "click", "props": [row_index]}]
# Create small example app.
app = Dash()
app.layout = html.Div(
    [
        EventListener(id="el", events=events, children=table),
        html.Div(id="event"),
    ]
)

@app.callback(Output("event", "children"), Input("el", "event"), Input("el", "n_events"))
def click_event(event, n_events):
    if not event:
        raise PreventUpdate
    return f"Row index is {event[row_index]}, number of clicks in {n_events}"

if __name__ == "__main__":
    app.run_server()

It does require a few lines of code more than targeting a native Dash property, but it seems less complex as compared to your current solution.

1 Like