plotly / dash

Data Apps & Dashboards for Python. No JavaScript Required.
https://plotly.com/dash
MIT License
21.61k stars 2.08k forks source link

Deletion of last row containing delete-button which is target of dbc.Tooltip leads to invalid children output #2311

Open timbrnbrr opened 2 years ago

timbrnbrr commented 2 years ago

Describe your context

dash_bootstrap_components==1.2.1
dash==2.7.0

Describe the bug

I have implemented a simple editor, which enables you to add and delete rows using a pattern-matching-callback. Each row contains a delete-button, which is target of a dbc.Tooltip.

image

When deleting the very last row, an error occurs when returning adjusted children for div selection-area:

An object was provided as `children` instead of a component, string, or number (or list of those). Check the children property that looks something like:
{
  "props": {
    "children": [
      {
        "props": {
          "children": [
            null,
            {
              "props": {
                "is_open": false
              }
            }
          ]
        }
      }
    ]
  }
}

Full runnable code for investigation of the bug:

import dash
from dash import Dash, dcc, html
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
from dash.dependencies import Input, Output, State, ALL
import json

app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

def make_input(i):
    return dbc.Row([dbc.Col(
        dcc.Input(id={"index": i, "type": "selection-input"},
                  type='text',
                  value='',
                  placeholder='e.g. c1-c0',
                  className='w-100'), lg=9)])

def make_input_row(i):
    elements = (dbc.Col([html.Div(dbc.Button(children=['Delete'], id={"index": i, "type": 'remove-row-button'}), id=f"remove-row-button-tooltip-wrapper-{i}"),
                         dbc.Tooltip(
                             'Delete Row',
                             target=f"remove-row-button-tooltip-wrapper-{i}",
                             placement='left')
                         ], lg=1),)

    elements = elements + (dbc.Col(make_input(i), lg=4),)

    return elements

app.layout = html.Div([html.Div(id='selection-area', children=[
    dbc.Row(children=[
        *make_input_row(0)
    ], id={"index": 0, "type": "selection-area-row"}, className="mt-2"),
    dbc.Row(children=[
        *make_input_row(1)
    ], id={"index": 1, "type": "selection-area-row"}, className="mt-2"),
    dbc.Row(children=[
        *make_input_row(2)
    ], id={"index": 2, "type": "selection-area-row"}, className="mt-2"),
    dbc.Row(children=[
        *make_input_row(3)
    ], id={"index": 3, "type": "selection-area-row"}, className="mt-2")
]), dbc.Row(children=[
    dbc.Col(lg=1),
    dbc.Col(dbc.Button('Add', id='add-row-button', outline=True, color='primary', style={'width': '100%'}), lg=3)
], className="mt-2"), ])

def edit_selection_area(remove_click, selection_elements):
    trigger_id = dash.callback_context.triggered[0]['prop_id'].split('.')[0]

    if trigger_id == 'add-row-button' and len(selection_elements) < 10:
        i = max([channel_element['props']['id']['index'] for channel_element in selection_elements]) + 1 if selection_elements else 0

        selection_elements.append(dbc.Row(children=[
            *make_input_row(i)
        ], id={"index": i, "type": "selection-area-row"}, className="mt-2"))

    elif 'remove-row-button' in trigger_id and len(selection_elements) > 1 and any(remove_click):
        i = json.loads(trigger_id)['index']
        selection_elements = [selection_element for selection_element in selection_elements if selection_element['props']['id']['index'] != i]

    return selection_elements

@app.callback(
    Output('selection-area', 'children'),
    [Input('add-row-button', 'n_clicks'),
     Input({'type': 'remove-row-button', 'index': ALL}, 'n_clicks')],
    [State('selection-area', 'children')]
)
def edit_selection_area_callback(add_click, remove_click, selection_elements):
    trigger_id = dash.callback_context.triggered[0]['prop_id']

    if "remove-row-button" in trigger_id and not any(remove_click):
        print("TRIGGERED A SECOND TIME WHEN CLICKING THE LAST ELEMENT:")
        print("trigger_id: ", trigger_id)
        print("remove_click: ", remove_click)
        print(f"input selection elements: {len(selection_elements)} ", selection_elements)
        raise PreventUpdate

    print("trigger_id: ", trigger_id)
    print("remove_click: ", remove_click)
    print(f"input PreventUpdate elements: {len(selection_elements)} ", selection_elements)
    children = edit_selection_area(remove_click, selection_elements)
    print(f"output PreventUpdate elements: {len(children)} ", children)
    print()

    return children

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

Expected behavior

Deletion of the last row containing a delete-button which is target of a dbc.Tooltip should behave the same way as deletion of rows from the middle or top.

FlxPo commented 1 year ago

Hi @timbrnbrr, did you find a solution ? I have the exact same type of bug, in a different context : redirect to a different page after a button click, after triggering a database insert statement. The bug does not always occur, so I'm not sure what's happening.

The time.sleep() trick seems to work in my case too.