Ordering children with drag & drop, how to trigger "dash component has changed" from JS

I would like to be able to reorder the children of a div.

I have seen a previous example (Drag and drop cards - #2 by RenaudLN) that allow to drag&drop elements thanks to the Dragula js library.

However, while on the UI, the order of elements are changed, on the children property of the div, the children are not reordered.

I have adapted the code to adapt the children order within the javascript.

What I am still missing is the ability from the JS code to trigger the event “the children of this element has been changed, trigger the callbacks that depends on it”.

To make it clearer, here is an example:
app.py

import dash
import dash_html_components as html
from dash.dependencies import Input, Output, ClientsideFunction, State

app = dash.Dash(
    __name__,
    external_scripts=["https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.min.js"],
)

app.layout = html.Div(
    id="main",
    children=[
        html.Button(id="btn", children="Refresh display for order of children"),
        html.Label(id="order"),
        html.Div(
            id="drag_container",
            className="container",
            children=[html.Button(id=f"child-{i}", children=f"child-{i}") for i in range(5)],
        ),
    ],
)

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output("drag_container", "data-drag"),
    [Input("drag_container", "id")],
    [State("drag_container", "children")],
)


@app.callback(
    Output("order", "children"),
    [
        Input(component_id="btn", component_property="n_clicks"),
        Input(component_id="drag_container", component_property="children"),
    ],
)
def watch_children(nclicks, children):
    """Display on screen the order of children"""
    return ", ".join([comp["props"]["id"] for comp in children])


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

asset/script.js

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function (id, children) {
        setTimeout(function () {
            var drake = dragula({});
            var el = document.getElementById(id)
            drake.containers.push(el);
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });
                // How can I trigger an update on the children property
                // ???
            })
        }, 1)
        return window.dash_clientside.no_update
    }
}

assets/dargula.css

.gu-mirror {
    position: fixed !important;
    margin: 0 !important;
    z-index: 9999 !important;
    opacity: 0.8;
    -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)";
    filter: alpha(opacity=80);
  }
  .gu-hide {
    display: none !important;
  }
  .gu-unselectable {
    -webkit-user-select: none !important;
    -moz-user-select: none !important;
    -ms-user-select: none !important;
    user-select: none !important;
  }
  .gu-transit {
    opacity: 0.2;
    -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)";
    filter: alpha(opacity=20);
  }
1 Like

Hi,

I am trying to implement your solution, and it works fine when the children of the drag-container are set from the app-start.

However, I would like to update the children and then use the dragable feature.

This however, seem to be problematic, the new order is not saved and the dragging appears sloppy. (Not always responsive.)

edit: By new order I mean that the serverside order of the children is not linked to the displayed order AFTER the callback. Before callback with new drag-children it is linked.

Do you have any idea how to fix this.

thanks in advance
Henrik

These tweaks works for me.

This works with dynamic children in the drag-container and does trigger an update of children upon reordering:

App.py:

"""
App for testing drag and drop

"""


import dash
import dash_html_components as html
from dash.dependencies import Input, Output, ClientsideFunction, State


SERVER_PORT = 12327

app = dash.Dash(
    __name__,
    #external_scripts=["https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.min.js"],
)

app.layout = html.Div(
    id="main",
    children=[
        html.Button(id="btn", children="Refresh display for order of children"),
        html.Label(id="order"),
        html.Div(id="container"),
        html.Button(id="btn_children", children="new children")
    ],
)

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output("drag_container", "data-drag"),
    Input("drag_container", "children"),
    State("drag_container", "id")

)

@app.callback(
    Output('container', 'children'),
    Input('btn_children', 'n_clicks'),
    prevent_initial_call=True
)
def test(n_clicks):
    print("running")

    if (n_clicks % 2) == 0:
        range_ = range(5)
    else:
        range_ = range(5,10)

    a = html.Div(
        id="drag_container",
        className="container",
        children=[html.Button(id=f"child-{i}", children=f"child-{i}") for i in range_],
    ),
    return(a)





@app.callback(
    Output("order", "children"),
    [
        Input(component_id="btn", component_property="n_clicks"),
        Input(component_id="drag_container", component_property="children"),
    ],
    prevent_initial_call = True
)
def watch_children(nclicks, children):
    """Display on screen the order of children"""
    return ", ".join([comp["props"]["id"] for comp in children])


if __name__ == '__main__':
	app.run_server(host="0.0.0.0", port=SERVER_PORT ,debug=True, dev_tools_hot_reload=True)

Script.js

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function (children, id) {
        setTimeout(function () {

            var drake = dragula({});
            var el = document.getElementById(id)
            var order = document.getElementById("order")
            drake.containers.push(el);
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });
                // Trigger an update on the children property
                order.innerHTML = order_ids
            })
        }, 1)
        return window.dash_clientside.no_update
    }
}

2 Likes

It appears that there is some desyncing problems with the above code.

You can observe this by printing the return in the callback for the order component.

sdementen,

Not sure if I understand you correctly, but if you want to fire the callback on element drop, you could always add a click event on your button so that the user is not having to deliberately click to see the new order. So your script.js would look like:

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function (id, children) {
        setTimeout(function () {
            var drake = dragula({});
            var el = document.getElementById(id)
            var btn = document.getElementById('btn')
            drake.containers.push(el);
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                btn.click();
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });
                // How can I trigger an update on the children property
                // ???
            })
        }, 1)
        return window.dash_clientside.no_update
    }
}

Your post has been super helpful, but I’m terrible at javascript and can’t for the life of me figure out how to alter it to output only the children in a particular element. So for example, if I have child elements in a wanted div and unwanted div, how would I output only the children in the wanted div AND in the correct order? Thanks!

Hello Guys,
does someone of you know how i can grab the order in my Python Code?

I want to work with these new order in my next step in python. The “.innerHTML” update only the browser not the ‘children’ element themselfes.

So if i try to grab the ‘children’ of the ‘order’ Label via a Button i get a “None”. Any ideas?

In dragging mode mouse wheel scrolling is not available. Is It possible to enable it and/or autoscroll while dragging?

Thanks for you efforts!
However there seems to be a bug (maybe due to some changes?)
When clicking on a dragable container like child-0 before moving it, the order does not get updates anymore when clicking the refresh button. Before that, it works just fine.

The easiest solution would be to never fiddle with the dom order in the first place and to output order_ids to dash.
I’ve tried this like so:

    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    [Output("output-div", "children"),
    Output("drag_container", "data-drag")],
    [Input("drag_container", "id")],
    [State("drag_container", "children")],
window.dash_clientside.clientside = {
    make_draggable: function (id, children) {
        setTimeout(function () {
            var drake = dragula({});
            var el = document.getElementById(id)
            drake.containers.push(el);
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                return order_ids;
                });
            })
        }, 1)
        return window.dash_clientside.no_update
    }
}

but as soon as the dragula drake is assigned a drop event, it does not seem to want so speak to dash anymore…

Hi Luggie,
I had some wierd desynching issues as well so I ended up dropping the drag-n-drop approach.

It does puzzle me that there isn’t any general solution to this somewhat generic problem.
Hope you found a different solution.

Syncing UI changes via drag&drop with the server is a challenge. Mostly because:

  • Dash lacks access to the DOM
  • If a component does not react to html/DOM events than there is no way to talk back to the Dash server (e.g., double clicks)

I found a solution using EventListener, which allows to sends custom events in javascript and listen to them in Dash. Essentially, one needs to wrap the drag_container inside the EventListener and trigger a custom “dropcomplete” event when the drop has been completed.

@sdementen : I adapted your original posting to make the changes clearer.

App.py

Key line here is - EventListener(html.div(id=“drag_container”, …)) and in the callback Input(“el”, “n_events”), State(“el”, “event”)

from dash_extensions.enrich import DashProxy, html, Input, Output, ClientsideFunction, State
from dash_extensions import EventListener


app = DashProxy(
    __name__,
    external_scripts=["https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.min.js"],
)

event = {"event": "dropcomplete", "props": ["detail.name"]}

app.layout = html.Div(
    id="main",
    children=[
        html.Button(id="btn", children="Refresh display for order of children"),
        html.Label(id="order"),
        EventListener(
            html.Div(
            id="drag_container",
            className="container",
            children=[html.Button(id=f"child-{i}", children=f"child-{i}") for i in range(5)],
            ),
            events=[event], logging=True, id="el")
    ],
)

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output("drag_container", "data-drag"),
    [Input("drag_container", "id")],
    [State("drag_container", "children")],
)


@app.callback(
    Output("order", "children"),
    [
        Input("el", "n_events"), State("el", "event"),
        State(component_id="drag_container", component_property="children"),
    ],
)
def watch_children(nevents, event_data, children):
    """Display on screen the order of children"""
    return ", ".join([comp["props"]["id"] for comp in children])


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

assets/script.js

Key line here is - new customEvent(‘dropcomplete’, …), target.dispatchEvent(drop_complete)

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function (id, children) {
        setTimeout(function () {
            var drake = dragula({});
            var el = document.getElementById(id)
            drake.containers.push(el);
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });

                const drop_complete = new CustomEvent('dropcomplete', {
                    bubbles: true,
                    detail: {
                      name: "Additional event infos"
                    }
                  });
                target.dispatchEvent(drop_complete)
                // How can I trigger an update on the children property
                // ???
            })
        }, 1)
        return window.dash_clientside.no_update
    }
}
2 Likes

Hi @chris8, thanks for the super helpful solution. The solution seems to work fine if the children of drag_container are predefined in the app layout, as is in your case. However, in my case the children are dynamic in the sense that clicking different buttons will render different children. For example, if button1 is clicked, the children should be a list of fruits and if button2 is clicked, the children should be a list of vegetables. So initially, drag_container has no children. After the a button is clicked, the children of drag_container are rendered, which can then be dragged and dropped. However, in my case, the div element order remains empty after drag and drop leading me to believe that drag_container is not returning anything after drag and drop, i.e. it’s children remains equal to None.

Would appreciate any help!

Hi @ray26,
it’s actually not that complicated. I’ll give you the high level outline:

a) use “dynamic IDs” for your buttons, divs, anything you want to track, e.g.,
def create_new_div_dynamically():
return html.Div(id={“type”: “draggable_item”, “index”: “dynamic_created1”)

b) use pattern matching for those dynamic IDs in the clientside_callback, this ensures that all Divs/Buttons of type “draggable_item” are actually used for drag & drop

app.clientside_callback(
ClientsideFunction(namespace="clientside", function_name="make_draggable"),
Output("drag_container0", "data-drag"),
Input({'type': 'draggable_item', 'index': ALL}, 'id')

)

c) adapt the javascript code to reflect the new JSON id structure {“type”: “draggable_item”, “index”: “some_index”}

window.dash_clientside.clientside = {
    make_draggable: function(id1,id2) {
        setTimeout(function() {
            var draggers = []
            console.log("Updating draggers")
            id1.forEach(element => {
                el = document.getElementById(JSON.stringify({"index": element["index"], "type": element["type"]}))
                console.log(el)
                draggers.push(el)
            });
            window.dash_clientside.drake = dragula(draggers)

Hope this outline helps. A more complete example would require more time.

1 Like

Hi @chris8, thanks a lot for your help!

My app layout has an element html.Label(id="order") like your example in this post: Ordering children with drag & drop, how to trigger "dash component has changed" from JS - #10 by chris8. I am trying to update that with the new order of dynamically created draggable items after drag and drop, but html.Label(id="order") remains blank.

I have an EventListener for the dynamically created html.div element containing draggable dbc.Cards:

event = {"event": "dropcomplete", "props": ["detail.name"]}

 html.Div([
        EventListener(
            html.Div(children=[html.Div([dbc.Card(dbc.CardBody(item_name), className="mb-3 draggable-cards")]) for item_name in some_dynamic_list],
                     id={'type': 'draggable-item', 'index': some_dynamic_list_name}),
            events=[event], logging=True, id={'type': 'draggable-item', 'index': some_dynamic_list_name}
        ) # This EventListener is returned by a callback function after a button is pressed for a specific dynamic list
    ],
    id='drag_container0') # drag_container0 is always present in app.Layout. The content of drag_container0, i.e. the EventListener, is rendered by a callback function

This is what I have for the clientside callback:

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output("drag_container0", "data-drag"),
    Input({'type': 'draggable-item', 'index': ALL}, 'id'),
    State({'type': 'draggable-item', 'index': ALL}, 'children'),
)

JavaScript code:

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function(id1, children) {
        setTimeout(function() {
            var draggers = []
            id1.forEach(element => {
                el = document.getElementById(JSON.stringify({"index": element["index"], "type": element["type"]}))
                window.console.log(el)
                draggers.push(el)
            });
            dragula(draggers)

            var drake = dragula({});
            drake.on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });

                const drop_complete = new CustomEvent('dropcomplete', {
                    bubbles: true,
                    detail: {
                      name: "Additional event infos"
                    }
                  });
                target.dispatchEvent(drop_complete)
            })

        }, 1)
        return window.dash_clientside.no_update
    }
}

My JavaScript skills are not the best, so I feel like the JavaScript code needs some tweaking. Would appreciate any help!

Hi @chris8, I have updated my code since it had some bugs. The children of html.Label(id="order") is not blank anymore. It is getting the children of drag_container0 but the order does not change after drag and drop. The order of the children is always the original order.

Here’s my drag_container0 with the updated EvenListener:

event = {"event": "dropcomplete", "props": ["detail.name"]}

 html.Div([
        EventListener(
            html.Div(children=[html.Div([dbc.Card(dbc.CardBody(item_name), className="mb-3 draggable-cards")]) for item_name in some_dynamic_list],
                     id={'type': 'draggable-item', 'index': some_dynamic_list_name}),
            events=[event], logging=True, id={'type': 'event-el', 'index': some_dynamic_list_name}
        ) # This EventListener is returned by a callback function after a button is pressed for a specific dynamic list
    ],
    id='drag_container0') # drag_container0 is always present in app.Layout. The content of drag_container0, i.e. the EventListener, is rendered by a callback function

html.Label(id="order"):

html.Label(children=html.Div(id={'type': 'order-el', 'index': some_dynamic_list_name}, id="order")

Callback functions:

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output("drag_container0", "data-drag"),
    Input({'type': 'draggable-item', 'index': ALL}, 'id'),
    State({'type': 'draggable-item', 'index': ALL}, 'children'),
)
@app.callback(
    Output({'type': 'order-el', 'index': MATCH}, 'children'),
    [
        Input({'type': 'event-el', 'index': MATCH}, 'n_events'), State({'type': 'event-el', 'index': MATCH}, 'event'),
        State({'type': 'draggable-item', 'index': MATCH}, 'children'),
    ],
)
def watch_children(nevents, event_data, children):
    """Display on screen the order of children"""
    return children

My JavaScript code:

if (!window.dash_clientside) {
    window.dash_clientside = {};
}
window.dash_clientside.clientside = {
    make_draggable: function(id1, children) {
        setTimeout(function() {
            var draggers = []
            id1.forEach(element => {
                el = document.getElementById(JSON.stringify({"index": element["index"], "type": element["type"]}))
                window.console.log(el)
                draggers.push(el)
            });
            dragula(draggers).on("drop", function (_el, target, source, sibling) {
                // a component has been dragged & dropped
                // get the order of the ids from the DOM
                var order_ids = Array.from(target.children).map(function (child) {
                    return child.id;
                });
                // in place sorting of the children to match the new order
                children.sort(function (child1, child2) {
                    return order_ids.indexOf(child1.props.id) - order_ids.indexOf(child2.props.id)
                });

                const drop_complete = new CustomEvent('dropcomplete', {
                    bubbles: true,
                    detail: {
                      name: "Additional event infos"
                    }
                  });
                target.dispatchEvent(drop_complete)
            })

        }, 1)
        return window.dash_clientside.no_update
    }
}

I believe my JavaScript code may need to be modified, but I do not know how to modify it. I would greatly appreciate any help!

Without understanding this end-to-end my first ideas would be:

  • Remove classname draggable-cards - not sure if this is really necessary and complicating things
  • Use the dynamic id also on the html.Div elements that hold the cards. Currently you have only 2 static Divs. The html.Divs generated by the dynamic_list are not transmitted to draggula, if they are not using the dynamic_id.

Next stage would be proper debugging. This is quite easy actually. You can use console.log() to print the current objects used in javascript, e.g.,

console.log(target)
console.log(source, _el)
console.log(id1) 
...

Just open the browser and press F12 (opening up the developer tools) and you can see what windows and targets, sources are actually used when you drag and drop.

Hi chris8,

I don’t have much experience with Dash yet but I find the example above very interesting. I am trying it out right now. When I import EventListener and compile it, I get an Error message:

from dash_extensions import EventListener
ImportError: cannot import name ‘EventListener’ from ‘dash_extensions’.

I have installed (with pip) Dash Extensions version 0.1.10 and Dash version 2.8.1.

Do you know why I can’t import EventListener?
Thanks.

I got @chris8’s solution working with dynamically created items by passing the new order in the custom event like this:

const drop_complete = new CustomEvent('dropcomplete', {
                    bubbles: true,
                    detail: {
                        name: "Additional event infos",
                        children: order_ids
                     }
                  });

and then creating a callback that reorders the children according to event["detail.children"]

Here is a working example:

import dash
from dash import MATCH, Patch, html
from dash_extensions import EventListener
from dash.dependencies import Input, Output, State, ClientsideFunction


app = dash.Dash(
    __name__,
    external_scripts=["https://cdnjs.cloudflare.com/ajax/libs/dragula/3.7.2/dragula.min.js"],
    prevent_initial_callbacks=True,
)

app.layout = html.Div(id="main", children=[
    EventListener(
        html.Div(id={"type": "drag_container", "idx": 0}, className="container", children=[]),
        events=[
            {
                "event": "dropcomplete",
                "props": ["detail.name", "detail.children"],
            }
        ],
        logging=True,
        id={"type": "el_drag_container", "idx": 0},
    ),
    html.Button(id={"type": "add_btn", "idx": 0}, children="Add"),
    html.Div(id={"type": "test_div", "idx": 0})
])

app.clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="make_draggable"),
    Output({"type": "drag_container", "idx": MATCH}, "data-drag"),
    [
        Input({"type": "drag_container", "idx": MATCH}, "id"), 
        Input({"type": "add_btn", "idx": MATCH}, "n_clicks")
    ],
)

@app.callback(
    Output({"type": "drag_container", "idx": MATCH}, "children"),
    Input({"type": "add_btn", "idx": MATCH}, "n_clicks"),
)
def add_element(n_clicks):
    patched_children = Patch()
    patched_children.append(
        html.Div(id={"type": "div", "idx": 0, "idx2": n_clicks}, children=f"Text {n_clicks}")
    )
    return patched_children

@app.callback(
    Output({"type": "test_div", "idx": MATCH}, "children"),
    Input({"type": "el_drag_container", "idx": MATCH}, "n_events"),
    State({"type": "el_drag_container", "idx": MATCH}, "event"),
)
def get_new_order(n_events, event):
    """Get new order of elements - can be used to synchronize children"""
    print(f"New order is: {event['detail.children']}")
    return str(event["detail.children"])


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

javascript:

window.dash_clientside = Object.assign({}, window.dash_clientside, {
    clientside: {
        make_draggable: function(id, n_clicks) {
            // convert id to string if dict
            if (!(typeof id === 'string' || id instanceof String)) {
                id = JSON.stringify(id, Object.keys(id).sort());
            };

            setTimeout(function() {
                var el = document.getElementById(id)
                var drake = dragula([el]);
                
                drake.on("drop", function (_el, target, source, sibling) {
                    // a component has been dragged & dropped
                    // get the order of the ids from the DOM
                    var order_ids = Array.from(target.children).map(function (child) {
                        return child.id;
                    });
                    
                    const drop_complete = new CustomEvent('dropcomplete', {
                        bubbles: true,
                        detail: {
                        name: "Additional event infos",
                        children: order_ids
                        }
                    });

                    target.dispatchEvent(drop_complete)

                })

            }, 1)
            return window.dash_clientside.no_update
        }
    }
});
3 Likes