2

enter image description here

As you can see from this if I press the download button the download pop-up comes. After downloading, if I try to delete other cards, the download popup comes again. Why is it happening?

Here is a minimal reproducible example

STEPS to reproduce

  1. Press the download button and download or cancel whatever you like
  2. Now try to delete some other card by pressing the delete button

Observation: The download pop up should come again

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)

Flavia Giammarino
  • 7,987
  • 11
  • 30
  • 40
Epsi95
  • 8,832
  • 1
  • 16
  • 34

3 Answers3

2

To provide some more context in addition to the other answers.

I think what's happening is that your delete_children callback causes the Download components to remount and therefore update.

The download component uses the 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).

After clicking the first download button the value of the data property of the first download component is not null and is different from its previous state (null). So when the delete_children callback is triggered, which causes the download components' componentDidUpdate to be called, the download component detects that data has changed and is not null so it triggers the save dialog.

So it's not that after your delete_children callback your download callbacks are triggered (You can check that this is not happening by logging something in each download callback). It's that the download components' update and some of these components detect they should trigger a save based on their value of the data property.

You will also notice that if you press download button 1, then download button 2 and then delete button 3, two save dialogs will popup after eachother. The value of data has changed for two download components and the download components' componentDidMount method is called, so two save dialogs show up.

5eb
  • 14,798
  • 5
  • 21
  • 65
  • Thank you for describing the working in details. Ya I observed the last one described, do you have any new light apart from the existing solutions described by @John Collins or the dash community solution? – Epsi95 Nov 17 '21 at 07:07
1

(Thanks @Epsi95 for the reproducible code! Makes it super helpful to help debug! Esp for plotly-dash questions)

I was able to get this to work by slightly altering the approach:

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)

Rather than returning a single output for any possible delete, I used a for range loop to pythonically create n-many delete output callbacks*, and that also removes the need for all that work you were doing with figuring out how to get the correct id for the particular delete id of whichever delete component had been selected. I assume it was the use of ALL (which I haven't seen before) which was triggering the download callbacks to be triggered by the delete components' n_clicks...although not 100% on that.

(*Thus, that also necessitated creating three new distinct output "containers" [I just used dash.html.Div's, as you can see in the layout]. But does not at all alter the appearance.)

I also collapsed your download callbacks into just one callback under the same for-loop, relying on the State input parameter to obtain the specific id information for whichever component it was that triggered the callback, thus allowing for variable callback algorithmic functionality. (In this case just simply printing the variable/dynamic id in the downloaded file - but, as you can imagine, you could have a dictionary etc. which looks up more complex content to download [or do w/e / anything else with] using the dynamic State-provided id as the necessary variable key for looking up correctly matched content.

E.g., notice the stdout output from running the app in debug mode (via the print statement in the for-looped downloads callback function):

$ 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

which was a scenario where I downloaded the third, deleted that card, then download the first, deleted that card, and finally likewise for the second. In all cases variable n is 3, so, hence we must leverage the State input parameter for obtaining the dynamic id info.

John Collins
  • 2,067
  • 9
  • 17
  • 1
    Thanks for providing the solution. I asked a similar question in-dash community forum and we came out with a solution with clientside callbacks. – Epsi95 Nov 16 '21 at 04:10
  • https://community.plotly.com/t/download-getting-triggered-automatically-after-removing-element/58439 – Epsi95 Nov 16 '21 at 04:31
0

Another solution could be to use clientside_callback along with client-side callback context.

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)
Epsi95
  • 8,832
  • 1
  • 16
  • 34