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)