Dynamic Callbacks with dcc.Store and Modal btns

Hi, I’m struggling with the pattern matching callbacks. I’m trying to save input from a textArea and a ‘save’ modal’s input field to local storage, have a callback that generates a ‘load’ modal table with rows as [title, text, loadBtn, deleteBtn] from the saved data, and allows for the dynamically generated deleteBtns to also modify the stored data. Another callback will take the load modal’s LoadBtn as input, Output(‘text_input’, ‘value’) with the state of the row’s corresponding text value.

I’ve put together a simple application to show where I’m at. I have the actual application working except for the deleteBtn and loadBtn part which is when I realized I probably need to use pattern matching callbacks for the dynamically generated buttons since there is no way to get the IDs of those without a callback. I’ve done similar in plain javascript and html using global vars.

Currently, the callback inserts the entered title:text to storage (as well as an initial ‘null’:None) but doesn’t insert any new entries after the first ‘save’ click. I thought setting the text_storage output and state to index:0 would work but it doesn’t :/. Any advice is greatly appreciated.

I’ve read How to save and access multiple data frames from dcc.Store in plotly dash - #3 by UdayGuntupalli but am still a tad lost.

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

#server 
app = dash.Dash(__name__, suppress_callback_exceptions=False, external_stylesheets = [dbc.themes.BOOTSTRAP, dbc.icons.FONT_AWESOME])
server = app.server


body = dbc.Container(
    [
        dbc.Row(
                dbc.Textarea(id="text_input", className='mb-3 border-primary', size="md", value='Testing storing text with dynamic modals'),
                align="center",
            ),
        dbc.Row(                       
            html.Button(html.I(className="far fa-save"),
                className=("btn bg-transparent "),
                id='btn_save',
                n_clicks=0,
            ),
        ),
        dbc.Row(                       
            html.Button(html.I(className="far far fa-folder-open"),
                className=("btn bg-transparent "),
                id='btn_load',
                n_clicks=0,
            ),
        )
    ]
)

save_modal = html.Div(
    [
        dbc.Modal(
            [
                dbc.ModalHeader(dbc.ModalTitle("Save Text")),
                dbc.ModalBody([
                            dbc.Input(id="text_title"),
                ]),
                dbc.ModalFooter(
                    dbc.Button(
                        [
                            html.I(className="far fa-folder-open")," Save",
                            dcc.Store(id={"type":"text_storage", "index": 'n_clicks'}, storage_type='local')
                        ],
                        className="ms-auto btn-success mbp-3", 
                        n_clicks= 0,
                        id={"type": "dynamic_save_btn","index": 'n_clicks'}
                    )
                ),
            ],
            id='save_modal',
            is_open=False
        ),
    ]
)

load_modal = html.Div(
    [
        dbc.Modal(
            [
                dbc.ModalHeader(dbc.ModalTitle("Load Text")),
                dbc.ModalBody(id="load_modal_table"),
            ],
            id='load_modal',
            is_open=False
        ),
    ]
)

app.layout = html.Div([body, save_modal, load_modal])


#open/close save modal
@app.callback(
    Output("save_modal", "is_open"),
    Input("btn_save", "n_clicks"),
    State("save_modal", "is_open"),
)
def toggle_modal(n1, is_open):
    if n1:
        return not is_open
    return is_open

#open/close load modal
@app.callback(
    Output("load_modal", "is_open"),
    Input("btn_load", "n_clicks"),
    State("load_modal", "is_open"),
)
def toggle_modal(n1, is_open):
    if n1:
        return not is_open
    return is_open



#save text to browser storage
@app.callback(
    Output({"type": "text_storage", "index": ALL}, "data"),
    [Input({"type": "dynamic_save_btn","index": ALL}, "n_clicks")], #Input({"type": "delete", 'index': MATCH}, 'value')],
    [
        State("text_title", "value"),
        State("text_input", "value"),
        State({"type": "text_storage", "index": ALL}, "data")
    ]

)
def save_pattern_data(clicks, title, text, stored_text):
    print(clicks)
    print(f'Retrieved data: \n {stored_text}')
    if stored_text is None:
        stored_text={}
    data = stored_text
    data[title]=text
    print(f'Saved data: {data}')
    return data
if __name__ == '__main__':
    app.run_server(debug=True)

Hi,

I believe the problem with your callback is related to the fact that Inputs and Outputs generated via the pattern-matching callback ALL are lists and not “single elements”, even if there is a single component matching the pattern.

What I mean by that is that stored_text is from the get-go [None] instead of None, so your conditional is always True and you might have all types of type errors (no pun intended) because of that (like data[title] is indexing a list with a string and so on)…

A quick solution would be to replace stored_text to stored_text[0] in your callback body and return [data] instead of simply data (you should return a list len 1 to match the number of Store components in your layout).

Now, that said, I don’t see the need for a pattern-matching callback here, unless you have multiple “identical” modals in your actual implementation (here’s there is only one). Note also that id={"type": "dynamic_save_btn","index": 'n_clicks'} is a bit weird in the sense that the index is fixed and not dynamically updated, but it might be just that you are simplifying your application to a MWE (which is appreciated).

Does this helps? Hope it does!

@jlfsjunior That helps a lot! Thank you. I can’t believe I missed that aspect… Kinda a Homer Simpson ‘Doh!’ moment there. Data saves and generates the modal as it does in my the actual application (pre-dynamic callbacks). Below is another MWE port with the callback to generate the dynamic ‘Load’ modal options. Do you have any suggestions for a good approach to set the callbacks to 1: not create circular dependencies, and 2: remove the key:value pair from ‘text_storage’ when the associated row’s delete btn is clicked?

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

#server 
app = dash.Dash(__name__, suppress_callback_exceptions=False, external_stylesheets = [dbc.themes.BOOTSTRAP, dbc.icons.FONT_AWESOME])
server = app.server


body = dbc.Container(
    [
        dcc.Store(id={"type":"text_storage", "index": 0}, storage_type='local'),
        dbc.Row(
                dbc.Textarea(id="text_input", className='mb-3 border-primary', size="md", value='Testing storing text with dynamic modals'),
                align="center",
            ),
        dbc.Row(                       
            html.Button(html.I(className="far fa-save"),
                className=("btn bg-transparent "),
                id='btn_save',
                n_clicks=0,
            ),
        ),
        dbc.Row(                       
            html.Button(html.I(className="far fa-folder-open"),
                className=("btn bg-transparent "),
                id='btn_load',
                n_clicks=0,
            ),
        )
    ]
)

save_modal = html.Div(
    [
        dbc.Modal(
            [
                dbc.ModalHeader(dbc.ModalTitle("Save Text")),
                dbc.ModalBody([
                            dbc.Input(id="text_title"),
                ]),
                dbc.ModalFooter(
                    dbc.Button(
                        [
                            html.I(className="far fa-save")," Save",
                        ],
                        className="ms-auto btn-success mbp-3", 
                        id={"type": "modal_save_btn","index": 'n_clicks'}
                    )
                ),
            ],
            id='save_modal',
            is_open=False
        ),
    ]
)

load_modal = html.Div(
    [
        dbc.Modal(
            [
                dbc.ModalHeader(dbc.ModalTitle("Load Text")),
                dbc.ModalBody(id={"type": "load_modal_table", "index": 0}),
            ],
            id='load_modal',
            is_open=False
        ),
    ]
)

app.layout = html.Div([body, save_modal, load_modal])


#open/close save modal
@app.callback(
    Output("save_modal", "is_open"),
    [Input("btn_save", "n_clicks"), Input({"type": "modal_save_btn","index": ALL}, "n_clicks")],
    State("save_modal", "is_open"),
)
def toggle_modal(n1, n2, is_open):
    if n1 or n2 != [None]:
        return not is_open
    return is_open

#open/close load modal
@app.callback(
    Output("load_modal", "is_open"),
    Input("btn_load", "n_clicks"),
    State("load_modal", "is_open"),
)
def toggle_modal(n1, is_open):
    if n1:
        return not is_open
    return is_open



#save text to browser storage
@app.callback(
    Output({"type": "text_storage", "index": ALL}, "data"),
    [Input({"type": "modal_save_btn","index": ALL}, "n_clicks")], #Input({"type": "delete_btn", 'index': MATCH}, 'value')],?
    [
        State("text_title", "value"),
        State("text_input", "value"),
        State({"type": "text_storage", "index": ALL}, "data")
    ]

)
def save_pattern_data(clicks, title, text, stored_text):
    if clicks == [None]:
        return dash.no_update
    print(clicks)
    print(f'Retrieved data: \n {stored_text}')
    if stored_text == [None]:
        stored_text[0]={}
    data = stored_text[0]
    data[title]=text
    print(f'Saved data: {data}')
    return [data]



#generate modal table upon load
@app.callback(
    Output({"type": "load_modal_table", "index": ALL},"children"),
    Input("btn_load", "n_clicks"),
    State({"type": "text_storage", "index": ALL}, "data"),
)
def update_modal_table(click, data):
    table_header = [html.Thead(
        html.Tr([
            html.Td(""),
            html.Td("Title"), 
            html.Td("Text"),
            html.Td(""), 
        ])
    )]
    if data is not [None]:
        table_rows = [html.Tr(
            [
                html.Td(dbc.Button(html.I(className="far fa-folder-open"), size='sm', id=f'{k}_load_btn'),style={"max-width":"3em"}),
                html.Td(f'{k}',style={"max-width":"12em", "overflow":"hidden", "whiteSpace": "nowrap"}), 
                html.Td(f'{v}', style={"max-width":"15em", "overflow":"hidden", "whiteSpace": "nowrap"}),
                html.Td( 
                    dbc.Button([html.I(className="far fa-trash-alt")],
                        className="ms-auto btn-danger", size='sm', n_clicks=0, id=f"{k}_delete_btn"
                    ),
                style = {"text-align":"right"}
                ),
            ], id=f'{k}_row'
        ) for (k,v) in data[0].items()]
    else: 
        table = dbc.Table(table_header)
        return [table]

    table_body=[html.Tbody(table_rows)]
    table = dbc.Table(table_header+table_body)
    return [table]




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

I don’t think you have many choices regarding the delete part of it. Since you are changing text_storage.data already in a callback, you will have to add the delete logic there.

You have this part commented out: #Input({"type": "delete_btn", 'index': MATCH}, 'value')]. You can’t use MATCH because you are not updating a matching component, in other words, you don’t have a “index”: MATCH in the Output. So you must use ALL and figure from the callback_context.triggered if “modal_save_btn” or “delete_btn” was clicked (and with which index). Apart from it, your delete_btn is actually the only place where the pattern matching callback is needed, so you should use something like:

dbc.Button([html.I(className="far fa-trash-alt")],
                        className="ms-auto btn-danger", size='sm', n_clicks=0, id={"type": "delete_btn", "id": k}
                    )

The rest is just removing an entry from a dictionary.

That makes sense. Appreciate the help!

I definitely misinterpreted some of the errors I had first received, which at the time lead me to believe that if input or output was dynamic they both needed to be. All makes sense now though. Again, thank you for the advice!