Dynamic Callback and Dynamic Inputs

Hi everyone !

We have a problem with a callback called after other callbacks have modified the interface.
Here is an example of the interface:

Here I can create as many conditions as I want, by clicking on the “plus button”. Each condition has its own ID generated with the n_clicks of the “plus button” and also with the “Add new label”.
For example, each element in the first row has an ID something like “card-1-1”, the second “card-1-2”, etc.

Then, depending on the Dropdown “Question ID” value, the Input type/possible values of “Condition” and “Values” change. In the example above, when I click on “Save”, the following conditions are generated, which is correct:

01_step_result

The problem occurs when I change the value of the first “Question ID” dropdown.
Here I changed QCountry to S1, so “Condition” and “Values” were changed by the callback.

The result gives me a wrong condition (the two first rows come from the old condition):

02_step_result

Something seems to change the order of my inputs.
Do you know why I have this behaviour?

I may show you the code if needed.

The code would help a lot if you don’t mind sharing it here.

Okay, here is the code, hold on to your hats ! (it’s a bit complicated :sweat_smile:)

Template Part

This how the “card” is created (it removed some useless lines):

def create_card(n, question_ids):
    """
    n = n_clicks from the callback or 1 if it's the first card created by default
    question_ids = a list of strings referencing to IDs
    """
    
    # The content ID
    content_id = {"type": "rc-label-card", "index": "rc-{}".format(n)}
    # The label ID
    label_id = {"type": "rc-value-label", "index": "rc-{}".format(n)}
    label_input = dbc.Input(id=label_id)

    # An hidden index needed for managing stuffs
    index_id = {"type": "rc-index-label", "index": "rc-{}".format(n)}
    index_input = dbc.Input(id=index_id, type="hidden")
    # The delete btn ID
    btn_id = {"type": "rc-delete-label", "index": "rc-{}".format(n)}
    btn = dbc.Button(html.I(className="bi bi-x-lg"), id=btn_id, color="danger", size="sm", className="float-end")

    # Add condition btn
    add_condition_id = {"type": "rc-add-condition", "index": "rc-{}".format(n)}
    add_condition_btn = dbc.Button(
        [html.I(className="bi bi-plus-lg text-primary")],
        id=add_condition_id,
    )

    # Then a lot of template stuff with dbc...
    # And I build the first blank condition when the card is created
    # With the following function call
    condition = create_condition(n, 1, question_ids)
    card_body = [] # I dropped those lines, not usefull 
    return dbc.Card(card_body, className="mt-2")

And how create_condition works:

def create_condition(n_card, n_condition, question_ids):
    """
    n_card: n_clicks from create_card function, used for ID
    n_condition: n_clicks from callback when the + btn is clicked to add a condition
    question_ids = a list of strings referencing to IDs
    """
    # For each input, the ID format is rc-n_card-n_condition
    questions_id = {"type": "rc-question", "index": "rc-{}-{}".format(n_card, n_condition)}
    dd_question = dcc.Dropdown(options=[{"label": q, "value": q} for q in question_ids], id=questions_id, multi=False, clearable=True)

    operators = ["==", "!=", ">=", ">", "<=", "<", "in", "not in", "between"]
    operator_id = {"type": "rc-operator", "index": "rc-{}-{}".format(n_card, n_condition)}
    # The code for create_operators_input is shown below 
    dd_operator = create_operators_input(n_card, n_condition, "single")

    values_id = {"type": "rc-values", "index": "rc-{}-{}".format(n_card, n_condition)}
    # The code for create_values_input is shown below 
    dd_values = create_values_input(n_card, n_condition)

    expand_id = {"type": "rc-expand", "index": "rc-{}-{}".format(n_card, n_condition)}
    check_expand = dbc.Checklist(
        options=[
            {"label": "", "value": 1},
        ],
        value=[1],
        id=expand_id,
    )

    index_id = {"type": "rc-condition-index", "index": "rc-{}-{}".format(n_card, n_condition)}
    input_index = dbc.Input(type="hidden", id=index_id)

    update_input_id = {"type": "rc-update-input", "index": "rc-{}-{}".format(n_card, n_condition)}
    update_operators_id = {"type": "rc-update-operators", "index": "rc-{}-{}".format(n_card, n_condition)}

    return dbc.Row([
        # Template
    ])

And the two function to create dynamic inputs based on types I have in my dataframe:

def create_values_input(card, cond, input_type="dropdown", options=(), min_value=-1000, max_value=1000, step=1, operator=None):

    values_id = {"type": "rc-values", "index": "rc-{}-{}".format(card, cond)}
    dd_values = dcc.Dropdown(options=options, id=values_id, multi=True, clearable=False)

    if input_type == "number":
        dd_values = dbc.Input(type=input_type, id=values_id, min=min_value, max=max_value, step=step)

    if input_type == "number" and operator in ["between", "not in", "in"]:
        dd_values = dbc.Input(type="text", id=values_id)

    return dd_values


def create_operators_input(card, cond, condition_type, actual_value="=="):
    operators = ["==", "!=", ">=", ">", "<=", "<", "in", "not in", "between"]
    operator_id = {"type": "rc-operator", "index": "rc-{}-{}".format(card, cond)}
    if condition_type in ["single", "multi", "grid"]:
        if actual_value in [">=", ">", "<=", "<"]:
            actual_value = "in"
        operators = ["in", "not in"]

    return dcc.Dropdown(options=[{"label": o, "value": o} for o in operators], value=actual_value, id=operator_id, multi=False, clearable=False)

Callback Part

This is the callback that creates/deletes a card (label), works great:

@app.callback(
    Output("rc-cards-content", "children"),
    Input("rc-add-label", "n_clicks"),
    Input({"type": "rc-delete-label", "index": ALL}, "n_clicks"),
    State("rc-study", "value"),
    State("rc-cards-content", "children"),
    prevent_initial_call=True
)
def add_label(n, d, study, children):
    action = dash.callback_context.triggered[0]["prop_id"].split(".")[0]
    if "rc-delete-label" in action:
        delete_card = json.loads(action)["index"]
        children = [
            card for card in children
            if "'index': '" + str(delete_card) + "'" not in str(card)
        ]
        return children
   
    # Functions and parameters
    # Not usefull to understand 
    s, w = study.split("-")
    meta = fetch_metadata(s, w)
    not_in = ["x", "y"]
    meta = meta.query("state == 1 & ctype not in @not_in")
    question_ids = meta["question_id"].unique()

    # Here is called create_card
    children.append(create_card(n, question_ids))

    return children

This one create a condition inside a card, works fine (I don’t know how to delete it, too many dynamic callback for my brain):

@app.callback(
    Output({"type": "rc-label-card", "index": MATCH}, "children"),
    Input({"type": "rc-add-condition", "index": MATCH}, "n_clicks"),
    State("rc-study", "value"),
    State({"type": "rc-label-card", "index": MATCH}, "children"),
    prevent_initial_call=True
)
def add_condition(n, study, children):
    action = dash.callback_context.triggered[0]["prop_id"].split(".")[0]
    action_index = json.loads(action)["index"]

    if "add" in action_type:
        s, w = study.split("-")
        meta = fetch_metadata(s, w)
        not_in = ["x", "y"]
        meta = meta.query("state == 1 & ctype not in @not_in")
        question_ids = meta["question_id"].unique()
        c = action_index.split("-")[1]
        children.append(create_condition(c, n, question_ids))

    return children

And I guess, this is where the issue happens:

@app.callback(
    Output({"type": "rc-update-input", "index": MATCH}, "children"),
    Output({"type": "rc-update-operators", "index": MATCH}, "children"),
    [
        Input({"type": "rc-question", "index": MATCH}, "value"),
        Input({"type": "rc-operator", "index": MATCH}, "value"),
    ],
    State("rc-study", "value"),
    prevent_initial_call=True
)
def update_value_input(question_id, operator, study):
    s, w = study.split("-")

    action = dash.callback_context.triggered[0]["prop_id"].split(".")[0]
    _, card, cond = json.loads(action)["index"].split("-")

    meta = fetch_metadata(s, w)
    not_in = ["x", "y"]
    meta = meta.query("question_id == @question_id & ctype not in @not_in")
    ctypes = meta["ctype"].unique()

    if "single" in ctype or "multi" in ctypes:

        opts = [
            {"label": l, "value": v}
            for v, l in zip(meta["answer_label"], meta["answer_label"].apply(lambda x: x[: 50]+"..." if len(x) > 50 else x))
        ]
        new_input = create_values_input(card, cond, options=opts)
        new_operators = create_operators_input(card, cond, confirmit_type, operator)
        return new_input, new_operators

    if "grid" in confirmit_type:

        meta_cleaned = meta.groupby(["answer_code", "answer_label"]).size().reset_index()
        opts = [
            {"label": l, "value": v}
            for v, l in zip(meta_cleaned["answer_label"], meta_cleaned["answer_label"].apply(lambda x: x[: 50]+"..." if len(x) > 50 else x))
        ]
        new_input = create_values_input(card, cond, options=opts)
        new_operators = create_operators_input(card, cond, confirmit_type, operator)
        return new_input, new_operators

    if "numeric" in confirmit_type or "numericlist" in confirmit_type:
        new_input = create_values_input(card, cond, input_type="number", operator=operator)
        new_operators = create_operators_input(card, cond, confirmit_type, operator)
        return new_input, new_operators

    return dash.no_update, dash.no_update

That last callback updates the ‘condition’ and ‘values’ inputs. It works almost great.
If I change the value in the first Question ID dropdown (yes, user may be wrong… :roll_eyes:), it’s replacing ‘condition’ and ‘values’ depending on the Question ID attributes.
But, it seems to change the order of my inputs, like it’s the last updated that comes first. That’s not the behaviour I want since it breaks all my conditions (cf. first post).

I hope I am clear enough.
Thanks a lot for you help.

I don’t see an obvious problem, but without running it locally I can’t really debug it.

What I noticed is that the values in the second screenshot of your GUI seem to be in the right order, only once you print them it shows the wrong values. It might be helpful to print dash.callback_context.triggered_prop_ids to see the index values of the input components that were triggered to determine which output it will feed back to.

Some more information about this: Determining Which Callback Input Changed | Dash for Python Documentation | Plotly

Since we are already talking about this, you can also change the complicated

action = dash.callback_context.triggered[0]["prop_id"].split(".")[0]

to

action = ctx.triggered_id

Thanks for your answer.
Well I don’t think it’s that, it’s just one button that triggers the callback.
The callback in question, I forgot to show before, aggregates the inputs and displays what is on the terminal.

I also don’t think it comes from the ALL matching. I have some code like that in other parts of my app, no problem as long as I don’t change the DOM like I do here.
But I really need that features for readability.

@app.callback(
    Output("rc-save-feedback", "children"),
    Input("rc-save-full", "n_clicks"),
    [
        State("rc-name", "value"),
        State("rc-description", "value"),
        State({"type": "rc-value-label", "index": ALL}, "value"),
        State({"type": "rc-value-label", "index": ALL}, "id"),
        State({"type": "rc-question", "index": ALL}, "value"),
        State({"type": "rc-question", "index": ALL}, "id"),
        State({"type": "rc-operator", "index": ALL}, "value"),
        State({"type": "rc-operator", "index": ALL}, "id"),
        State({"type": "rc-values", "index": ALL}, "value"),
        State({"type": "rc-values", "index": ALL}, "id"),
        State({"type": "rc-expand", "index": ALL}, "value"),
        State({"type": "rc-expand", "index": ALL}, "id"),
        State("rc-study", "value"),
    ],
    prevent_initial_call=True
)
def rc_save_cb(n, nf_name, nf_description, labels, ids_label, question_ids, ids_questions, operators, ids_operators, values, ids_values, expands, ids_expand, study):

    errors = []

    # Some check if empty...

    if errors:
        return get_feedback(errors, "warning", size="sm")

    # This function works great if no DOM update
    conditions = generate_new_categories(labels, ids_label, question_ids, ids_questions,
                            operators, ids_operators, values, ids_values, expands, ids_expand)

    # The print in the terminal
    print(conditions)

    return get_feedback("super", "success", size="sm")

Hope you see what’s wrong. Maybe !