Add/remove forms to list with dynamic/generated callbacks

Hi community!

As part of an app, I m trying to implement a kind of a dynamic list of forms. The idea is that the user can add/remove forms by pressing buttons and submit the entire list of forms for batch processing. To avoid issues with undefined callbacks, the number of forms can be limited to a certain value, e.g., max 3. Use cases could be, e.g., uploading samples for analysis, starting a number of processes, etc.

In my implementation, pressing a button appends a form to the layout with index = len(form_list). A form appended to an empty list gets index 0, etc. Each form could contain multiple different input components, and has a ‘Remove’ button that (using pattern matching) triggers a modal asking the user to confirm removing the respective form:

Adding a form seems to work. However, removing a form causes issues. If I add 3 forms, delete the second one, and add another one, the dropdown menus and the outputs of form 1 and form 2 (0-index counting) are confused. This, obviously, is caused by my indexing scheme for the forms, but I could not come up with another solution. I tried re-indexing but it does not work. It feels like I am close to a solution but I am not even sure if I overcomplicated things or whether this is even possible. Has anyone else worked on a feature like this before? I’d appreciate any help or pointers to other relevant community discussions! Thanks!

Please see my code below:

import dash
import dash_bootstrap_components as dbc

from dash import ALL, dcc, html, Input, Output, State

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

max_forms = 3  # maximal number of forms


def form(index):
    """Returns a form with id 'index'."""
    return dbc.Card(
	dbc.Form([
	    html.Div([
	        dbc.InputGroup([
	            dbc.InputGroupText("Text input"),
	            dbc.Input(type="text", id=f"text-input-{index}", placeholder=f"form ({index})"),
	        ], className="mb-2"),
	        dbc.InputGroup([
	            dbc.DropdownMenu([dbc.DropdownMenuItem(item, id={"id_type": f"dropdown-{index}", "index": i}) for i, item in enumerate(["A", "B", "C"])], label="Dropdown"),
	            dbc.Input(id=f"dropdown-text-{index}", placeholder="-", persistence=True)
	        ])
	    ], className="mb-2"),
	    html.Div([
	        # button 'remove'
	        dbc.Button("Remove", id={"type": f"remove-button", "index": index}, color="danger", className="me-2", n_clicks=0)
	    ], className="d-grid gap-2 d-md-flex justify-content-md-end"),
	    html.Div(id=f"output-form-{index}")
	]),
	id=f"form-{index}", body=True, color="primary", inverse=True, className="mb-3"
    )


for id_type in ["Dropdown"]:  # generate pattern matching callbacks for dropdown menu items
    for index in range(0, 3):
	@app.callback(
	    Output(f"dropdown-text-{index}", "value"),
	    Input({"id_type": f"dropdown-{index}", "index": ALL}, "n_clicks"),
	    prevent_initial_call=True
	 )
	def dropdown_callback(n_clicks):
	    ctx = dash.callback_context
	    if not ctx:
	        return "-"
	    else:
	        button_id = ctx.triggered[0]["prop_id"].split(".")[0]
	        if button_id != ".":
	            return button_id

app.layout = html.Div([
    dbc.Row([
	dbc.Col(width=2),   # spacer left
	dbc.Col(html.Div([  # content
	    dbc.Modal([     # modal to confirm removing a sample
	        dcc.Store(id="session"),
	        dbc.ModalHeader([], id="modal-title", close_button=True),
	        dbc.ModalFooter([
	            dbc.Button("Cancel", id="cancel-remove", n_clicks=0),
	            dbc.Button("Remove", id="confirm-remove", color="danger", n_clicks=0)
	        ])
	    ], id="modal-backdrop", is_open=False),
	    html.H3("Dynamic Form lists with Dash"),
	    html.Div([
	        # button 'Add form'
	        dbc.Button("Add form", id="add-form-button", n_clicks=0, outline=False, color="primary", className="me-2"),
	    ], className="mb-3"),
	    html.Div(id="alerts", className="mb-3"),    # container for alerts
	    html.Div(id="form-container", children=[])  # container for list of forms
	])),
	dbc.Col(width=2)    # spacer right
    ])
])

@app.callback(
    Output("form-container", "children"),
    Output("alerts", "children"),
    Output("modal-backdrop", "is_open"),
    Output("session", "data"),
    Output("modal-title", "children"),
    Input("add-form-button", "n_clicks"),
    Input({"type": "remove-button", "index": ALL}, "n_clicks"),
    Input("cancel-remove", "n_clicks"),
    Input("confirm-remove", "n_clicks"),
    State("form-container", "children"),
    State("modal-backdrop", "is_open"),
    State("session", "data"),
    prevent_initial_call=True)
def add_form(add_form_click, remove_button_click, cancel_remove_click, confirm_remove_click, children, modal_is_open, session_data):
    """Multi-purpose callback for adding and removing forms."""
    triggered = [t["prop_id"] for t in dash.callback_context.triggered]
    alert = ""
    if triggered[0] == "add-form-button.n_clicks":  # add form
	new_form = form(len(children))
	if len(children) < max_forms:
	    children.append(new_form)
	else:
	    alert = dbc.Alert(f"The maximum number of forms is {max_forms}!", color="primary", id="alert-fade", duration=4000)
	return children, alert, modal_is_open, None, None
    elif "remove-button" in triggered[0]:  # open modal and reserve form for removal in dcc.Store(id='session')
	sample_id = eval(triggered[0].split(".")[0])
	return children, alert, not modal_is_open, sample_id, dbc.ModalTitle(f"Remove form {sample_id['index']}?")
    elif "cancel-remove" in triggered[0]:  # cancel removal: return original list of forms
	return children, alert, not modal_is_open, None, None
    elif "confirm-remove" in triggered[0]:  # confirm removal: remove form and return new list of forms
	to_remove_index = None
	c = 0
	for child in children:
	    if child["props"]["id"].split("-")[1] == str(session_data["index"]):
	        to_remove_index = c
	        break
	    c += 1
	del children[to_remove_index]

	# re-indexing does not work:
	# c = 0
	# for child in children:
	#     child["props"]["id"] = f"form-{c}"
	#     c += 1
	return children, alert, not modal_is_open, None, None


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