Strange behaviour when deleting dynamically generated forms

Hi all,
I’ve created a form to enter student data. In this example, it loads a dataframe of student data, creates a tab for each house, and then dynamically creates forms (in accordions) for each student in each house. There’s also an add student button to add more students.
The problem I have is that there is a delete button for each student, and it’s not working well for me.
The first problem is that it generally deletes the wrong item. The second problem is that this callback is somehow triggered indirectly by the ‘add student’ button, which is quite counterproductive.

Any help in detangling this code would be appreciated. I’m sure there’s a simple way to do this, but I can’t seem to find it myself.

from dash import Dash, dcc, html, dash_table, Input, Output, State, callback, Patch, MATCH, ALL, callback_context, no_update
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
from import_XML import import_TC2PC, get_file_list, extract_dates, get_times, filename_from_datetime
import pandas as pd

def generate_form(row_string:str, row_num:int, tab_id:str, row=None) -> dbc.AccordionItem:
    if row is None: #then create empty series
        row=pd.Series({
            'house':'',
            'name':'Add new student',
            'points':'',
        })

    row_id:str = row_string + str(row_num)

    accordion_item = (dbc.AccordionItem(children=[
        html.Div(children=[
                dbc.Label(children='House'),
                dbc.Input(value=row['house'],
                            id={'type':'settings',
                                'name':'house',
                                'tab_id':tab_id,
                                'row':row_id},
                            debounce=True
                            )]),
        html.Div(children=[
                dbc.Label(children='Name'),
                dbc.Input(value=row['name'],
                            id={'type':'settings',
                                'name':'name',
                                'tab_id':tab_id,
                                'row':row_id},
                            debounce=True
                            )]),
        html.Div(children=[
                dbc.Label(children='Points'),
                dbc.Input(value=row['points'],
                            id={'type':'settings',
                                'name':'points',
                                'tab_id':tab_id,
                                'row':row_id},
                            debounce=True
                            )]),
        html.Div(children=
            dbc.Button("Delete", id={'type':'del-btn',
                                        'tab_id':tab_id,
                                        'row':row_id},
                                    color="danger",
                                    className="me-1")
        )
    ],
    title=row['name'],
    id={'type':'accordion_item',
        'tab_id':tab_id,
        'row':row_id},
    item_id=row_id))
    return accordion_item

def form_from_dataframe(data) -> list:

    content: list = []
    
    # Iterate through the row
    # and generate all the forms for that particular tab
    for index, row in data.iterrows():
        content.append(generate_form(row_string=row['house'],
                                     row_num=index,
                                     tab_id=row['house'],
                                     row=row))

    return content


# Data (normally from a CSV)
student_data = pd.DataFrame({
    'name':['Harry', 'Ron', 'Draco', 'Cho'],
    'house':['Griffindor', 'Griffindor', 'Slytherin', 'Ravenclaw'],
    'points':[100, 70, -30, 50]
    })

# Get a list of all the houses, for tabs
houses: list[str] = student_data['house'].unique()

# Generate components
tabs:list = [dbc.Tab(label=house_tab,
                id={'type':'house-tabs',
                    'tab_id':house_tab},
                tab_id=house_tab,
                children=
                    dbc.Accordion(
                        form_from_dataframe(
                            student_data[student_data['house']==house_tab]),
                        id={'type':'accordion',
                            'tab_id':house_tab},
                        start_collapsed=True,
                        always_open=True
                        )
                )
        for house_tab in houses]

# initialise app
app = Dash(external_stylesheets=[dbc.themes.YETI])

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~``
# Layout app
app.layout = html.Div([
    html.H1("Edit Student data"),
    dbc.Button("Add new student", id="add-btn", color="primary", className="me-1"),
    dbc.Button("Save", id="save-btn", color="primary", className="me-1"),
    html.Br(),html.Br(),
    html.Div(children=[
        dbc.Tabs(id='house-tabs',
                children=tabs
                )
    ])
])

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~``
# add interactivity to app

# Add form for a new student to the active tab when clicked
@callback(
    Output({'type':'accordion','tab_id':ALL}, 'children', allow_duplicate=True),
    Input('add-btn', 'n_clicks'),
    State('house-tabs','active_tab'),
    prevent_initial_call=True)
def add_new_point(n_clicks:int, tab_value:str):
    # Create a partial property update that adds a new form
    add_form = Patch()
    add_form.append(
        generate_form(
            row_string='n_clicks',
            row_num=n_clicks,
            tab_id=tab_value))

    # When doing an ALL output, return a list with an output for each
    # item that meets the criteria
    # (i.e. return a list corresponding to all accordions)
    output_list:list[str] = [dict['id']['tab_id'] for dict in callback_context.outputs_list]

    # Where the value in output_list is the active tab, return add_form
    # for all other tabs, don't update the interface
    output:list = [add_form if item==tab_value else no_update for item in output_list]

    return output

"""
# Delete button deletes the accordion item
@callback(
    Output({'type':'accordion', 'tab_id':MATCH}, 'children'),
    Input({'type':'del-btn','row':ALL, 'tab_id':MATCH}, 'n_clicks'),
    State({'type':'accordion', 'tab_id':MATCH}, 'children'),
    prevent_initial_call=True)
def delete_accordion(n_clicks, children):
    # A list of all the IDs of all the accordionitems in that tab
    child_ids:list = [children[item]['props']['id'] for item in range(len(children))]

    # The ID of the accordionitem that should be deleted
    row_id = callback_context.triggered_id['row']
    tab_id = callback_context.triggered_id['tab_id']
    delete_id = {'type':'accordion_item',
                'tab_id':tab_id,
                'row':row_id}

    # Find the position of the accordionitem in the accordion children list
    delete_index = child_ids.index(delete_id)

    # Remove that item and return the new list
    output = children.pop(delete_index)
    
    return output
"""

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~``
# Run app
if __name__ == '__main__':
    app.run(debug=True)

1 Like

That’s always the case if adding new components to the layout via callbacks.