Plotly Dash:删除元素后自动触发下载

Plotly Dash: Download getting triggered automatically after removing element

如您所见,如果我按下下载按钮,就会出现下载弹出窗口。下载后,如果我尝试删除其他卡,下载弹窗又来了。为什么会这样?

这是一个最小的可重现示例

重现步骤

  1. 按下载按钮,下载或取消任何你喜欢的
  2. 现在尝试按删除按钮删除其他卡片

观察: 下载弹窗应该又来了

import json

import dash
import dash_bootstrap_components as dbc
from dash import dcc
from dash.dependencies import Input, Output, ALL, State

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

app.layout = dbc.Container([
    dbc.Row([
        dcc.Download(id="download-1"),
        'card 1',
        dbc.Col(dbc.Button('download', id='download-btn-1')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-1'}))
    ], className='bg-warning m-2 p-2', id='card-1'),
    dbc.Row([
        dcc.Download(id="download-2"),
        'card 2',
        dbc.Col(dbc.Button('download', id='download-btn-2')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-2'}))
    ], className='bg-warning m-2 p-2', id='card-2'),
    dbc.Row([
        dcc.Download(id="download-3"),
        'card 3',
        dbc.Col(dbc.Button('download', id='download-btn-3')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-3'}))
    ], className='bg-warning m-2 p-2', id='card-3'),
], id='container-body')


@app.callback(
    Output('download-1', 'data'),
    Input('download-btn-1', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    return dict(content="data 1", filename="hello.txt")


@app.callback(
    Output('download-2', 'data'),
    Input('download-btn-2', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    return dict(content="data 2", filename="hello.txt")


@app.callback(
    Output('download-3', 'data'),
    Input('download-btn-3', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    return dict(content="data 3", filename="hello.txt")


@app.callback(
    Output('container-body', 'children'),
    Input({'type': 'delete', 'id': ALL}, 'n_clicks'),
    State('container-body', 'children'),
    prevent_initial_call=True
)
def delete_children(n_clicks, children):
    card_id_to_be_deleted = json.loads(dash.callback_context.triggered[0]['prop_id'].split('.')[0])['id']
    index_to_be_deleted = None
    for index, c in enumerate(children):
        if c['props']['id'] == card_id_to_be_deleted:
            index_to_be_deleted = index
            break
    children.pop(index_to_be_deleted)
    return children


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

(感谢@Epsi95 提供可重现的代码!对帮助调试非常有帮助!Esp plotly-dash 问题)

我能够通过稍微改变方法来实现它:

import dash
import dash_bootstrap_components as dbc

from dash import dcc
from dash import html
from dash.dependencies import Input, Output, State


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

app.layout = dbc.Container(
    [
        html.Div(
            [
                dbc.Row(
                    [
                        dcc.Download(id="download-1"),
                        "card 1",
                        dbc.Col(dbc.Button("download", id="download-btn-1")),
                        dbc.Col(
                            dbc.Button(
                                "delete",
                                id={"type": "delete", "id": "card-1"},
                            )
                        ),
                    ],
                    className="bg-warning m-2 p-2",
                    id="card-1",
                )
            ],
            id="container-body-1",
        ),
        html.Div(
            [
                dbc.Row(
                    [
                        dcc.Download(id="download-2"),
                        "card 2",
                        dbc.Col(dbc.Button("download", id="download-btn-2")),
                        dbc.Col(
                            dbc.Button(
                                "delete",
                                id={"type": "delete", "id": "card-2"},
                            )
                        ),
                    ],
                    className="bg-warning m-2 p-2",
                    id="card-2",
                )
            ],
            id="container-body-2",
        ),
        html.Div(
            [
                dbc.Row(
                    [
                        dcc.Download(id="download-3"),
                        "card 3",
                        dbc.Col(dbc.Button("download", id="download-btn-3")),
                        dbc.Col(
                            dbc.Button(
                                "delete",
                                id={"type": "delete", "id": "card-3"},
                            )
                        ),
                    ],
                    className="bg-warning m-2 p-2",
                    id="card-3",
                )
            ],
            id="container-body-3",
        ),
    ],
    id="container-body",
)


for n in range(1, 4):

    @app.callback(
        Output(f"download-{n}", "data"),
        Input(f"download-btn-{n}", "n_clicks"),
        State(f"container-body-{n}", "id"),
        prevent_initial_call=True,
    )
    def download(n_clicks, id):
        # for more complex variable content,
        # could insert here routine for looking up
        # data from dict where key == id, etc.
        print(n, n_clicks, id)
        return dict(content=f"data {id}", filename="hello.txt")

    @app.callback(
        Output(f"container-body-{n}", "children"),
        Input({"type": "delete", "id": f"card-{n}"}, "n_clicks"),
        prevent_initial_call=True,
    )
    def delete_children(n_clicks):
        return None


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

我没有为任何可能的删除返回单个输出,而是使用 for range 循环以 python 方式创建 n-many delete 输出回调*,这也消除了对您所做的所有工作都是为了弄清楚如何为已选择的删除组件的特定删除 id 获取正确的 ID。我假设是 ALL(我以前没见过)的使用触发了删除组件的 n_clicks 触发的下载回调...虽然不是 100%。

(*因此,这也需要创建三个新的不同输出“容器”[我只是使用了 dash.html.Div,正如您在布局中看到的那样]。但根本不会改变外观。 )

我还将您的下载回调压缩为同一 for 循环下的一个回调,依靠 State 输入参数获取触发该组件的特定 id 信息回调,从而允许可变回调算法功能。 (在这种情况下,只是简单地在下载的文件中打印 variable/dynamic id - 但是,正如您可以想象的那样,您可以使用字典等来查找要下载的更复杂的内容 [或执行 w/e /其他] 使用动态 State 提供的 id 作为查找正确匹配内容的必要变量键。

例如,请注意调试模式下 运行 应用程序的 stdout 输出(通过 for 循环下载回调函数中的打印语句):

$ python app.py 
Dash is running on http://127.0.0.1:8050/

 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
3 1 container-body-3
3 1 container-body-1
3 1 container-body-2

我下载了第三个,删除了那个卡,然后下载了第一个,删除了那个卡,最后第二个也是如此。在所有情况下,变量 n 都是 3,因此,我们必须利用 State 输入参数来获取动态 ID 信息。

另一种解决方案是将 clientside_callback 与客户端回调上下文一起使用。

import json

import dash
import dash_bootstrap_components as dbc
from dash import dcc
from dash.dependencies import Input, Output, ALL, State

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

app.layout = dbc.Container([
    dbc.Row([
        dcc.Download(id="download-1"),
        'card 1',
        dbc.Col(dbc.Button('download', id='download-btn-1')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-1'}))
    ], className='bg-warning m-2 p-2', id='card-1'),
    dbc.Row([
        dcc.Download(id="download-2"),
        'card 2',
        dbc.Col(dbc.Button('download', id='download-btn-2')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-2'}))
    ], className='bg-warning m-2 p-2', id='card-2'),
    dbc.Row([
        dcc.Download(id="download-3"),
        'card 3',
        dbc.Col(dbc.Button('download', id='download-btn-3')),
        dbc.Col(dbc.Button('delete', id={'type': 'delete', 'id': 'card-3'}))
    ], className='bg-warning m-2 p-2', id='card-3'),
], id='container-body')


@app.callback(
    Output('download-1', 'data'),
    Input('download-btn-1', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    if n_clicks is not None:
        return dict(content="data 1", filename="hello1.txt")


@app.callback(
    Output('download-2', 'data'),
    Input('download-btn-2', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    if n_clicks is not None:
        return dict(content="data 2", filename="hello2.txt")


@app.callback(
    Output('download-3', 'data'),
    Input('download-btn-3', 'n_clicks'),
    prevent_initial_call=True
)
def download_1(n_clicks):
    print(n_clicks)
    if n_clicks is not None:
        return dict(content="data 3", filename="hello3.txt")


app.clientside_callback(
    """
    function(n_clicks, children) {
        const triggered = dash_clientside.callback_context.triggered.map(t => t.prop_id);
        const card_id_to_remove = JSON.parse(triggered[0].split('.')[0])['type'];
        let child_index_to_remove = null;
        for(let i=0; i<children.length; i++){
            if (children[i]['props']['id'] === card_id_to_remove){
                child_index_to_remove = i;
                break;
            }
        }
        children.splice(child_index_to_remove, 1);
        return children;
    }
    """,
    Output('container-body', 'children'),
    Input({'type': 'delete', 'id': ALL}, 'n_clicks'),
    State('container-body', 'children'),
    prevent_initial_call=True
)

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

除了其他答案外,还提供更多背景信息。

我认为发生的事情是您的 delete_children 回调导致 Download 组件重新 并因此更新。

下载组件使用 componentDidUpdate React lifecycle method. Inside componentDidMount it checks if data is not null or if data equals the previous value of data. If this is not true the save dialog is triggered (source).

单击第一个下载按钮后,第一个下载组件的 data 属性 的值不为空,并且与其之前的状态(空)不同。因此,当 delete_children 回调被触发时,下载组件的 componentDidUpdate 被调用,下载组件检测到 data 已更改并且不为空,因此它会触发保存对话框。

所以并不是在您的 delete_children 回调之后您的下载回调被触发(您可以通过在每个下载回调中记录一些内容来检查这没有发生 )。这是下载组件的更新,其中一些组件检测到它们应该根据 data 属性.

的值触发保存

您还会注意到,如果您按下载按钮 1,然后按下载按钮 2,然后再按删除按钮 3,将依次弹出两个保存对话框。 data 的值已为两个下载组件更改,下载组件的 componentDidMount 方法被调用,因此出现两个保存对话框。