Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

jjudge's avatar

Recommendations for drag-and-drop reordering

I am assuming a drag-and-drop reordering will be the neatest solution here, but if there is a better way, then I'll be happy to consider that.

So, a competition is set up with four levels in a big hierarchy - the competition->groups->judges->sessions Once all these levels are set up, some arbitrary reordering within each level needs to be done. So the sessions for a judge need to be arranged into an arbitrary order. The groups within a show need to be ordered etc.

I have a page that displays the entire show as nested lists, four levels deep. It would be great of the administrator could use drag-and-drop on that page to reorder the elements of that competition. A major requirements would be that the structure cannot be rearranged - only elements within a list can be moved around (those elements could be other lists or session leaf nodes). So in this example, I could swap Sess 1 and Sess 2 around, but I could not move Sess 1 to Judge 2. Though I could swap Judge 1 and Judge 2 around within Group 1.

  • My competition
    • Competition Group 1
      • Judge 1
        • Sess 1
        • Sess 2
      • Judge 2
        • Sess 3
        • Sess 4
    • Competition Group 2 etc.

What front-end tools are recommended for this? I'm not using jQuery UI for anything else on the site at this time, and have always found jQuery UI too monolithic (I may just be using it wrong). There are plenty of vue.js examples on github, but most seem a little unstable - many demos simply don't work properly, so I am guessing it is a fairly new thing to vue.js. Any pointers as to what I should be looking at? Any examples of a multi-level list reordering using these rules? The competition structure could be quite big, but collapsing levels in the nested lists would make it easy enough to use by focusing on one level at a time.

Thanks.

0 likes
25 replies
jjudge's avatar

If I were using vue.js, would the approach be to put the entire show structure into a JS structure first, then bind that to the display through some kind of templates (like knockout.js, which is probably more suited to single-page applications)? Or could I continue to just output the HTML nested lists then tell vue.js to parse it into its own structures then take over from there? I'm mainly back-end, so still getting my head around the whole paradigms these different JS frameworks use.

zachleigh's avatar

If using vue, you may have to track the reordering yourself. I did this in a vue project using dragula and ended having to do that.

jjudge's avatar

Okay, I was hoping vue would handle the binding so all the JS would need to do was wait for a "list reordered" event then send the relevant array or object to the server. Or is that what you meant, defining the binding myself using vue html element attributes rather than using a plugin to set that up?

zachleigh's avatar

In my situation, the list wouldnt automatically reorder after the drag/drop. I had to set drag and drop events using dragula that reordered the list after each drop event. It took me ages to figure out, but wasnt that complicated once I got it. These are the events. Dragula is the js library I used. colorScheme is the list of draggable items I had stored on the parent.

         dragula.on('drag', function (element, source) {
            var index = [].indexOf.call(element.parentNode.children, element);

            from = index;
        });

        dragula.on('drop', function (element, target, source, sibling) {
            var index = [].indexOf.call(element.parentNode.children, element);

            self.colorScheme.splice(index, 0, self.colorScheme.splice(from, 1)[0]);
        });
jjudge's avatar

Ah, right. Thanks for the explanation.

jjudge's avatar

Also, just wondering what the usual approach is for the data sent back to the server. In theory it just needs to be the ID of the item that has been moved and its new position index. Everything else can be worked out on the server. But maybe a complete array of the full list with ALL positions is sent back to the server instead, to make sure they are fully synchronised (I can imagine another user may be adding or removing items from their own account at the same time as you are rearranging them in your browser, so there probably needs to be some kind of two-way syncing to make sure all users are seeing the same thing).

1 like
zachleigh's avatar

That sounds much more complicated than my project. I have a save feature which sends the entire array to the server. If you want to sync the array with other users you're going to have to look into web sockets, I think. Jeffery has a series about that here on the site somewhere.

jjudge's avatar

Here is my final solution. Hopefully it will be helpful to others (and to me when I inevitably Google search for it in the future).

I'm using RubaXa/Sortable on the front end. It just works straight out the box, with virtually no effort. It handles a nested list nicely - it keeps each list separate by default, so the structure cannot be accidentally changed.

When an item is moved (dragged and dropped) I send two pieces of information to the server:

  • The identity of the item being moved. This is in the URL.
  • Its new position in the list, zero-based. This is a PUT parameter.

I don't send arrays of keys around - it is not up to the front end IMO to manage all that stuff. I send the minimum and the backend can validate that data is correct, not going to mess stuff up, and can work out what actually needs to be adjusted in the database.

My front-end event handler is this:

Sortable.create(el, {
            ...
            onUpdate: function (evt) {
                var url = evt.item.getAttribute('url');
                if (url) {
                    $.ajax({
                        method: "PUT",
                        url: url,
                        data: {position: evt.newIndex}
                    });
                }
            }
});

That's it. Each list-item provides its own URL and the D-n-D library provides the new position evt.newIndex. el is a list of elements passed to it via jQuery:

jQuery("all-the-lists-that-I-want-to-be-reorderable")
    .each(function(i, el) {
        Sortable.create(el, {...});
    });

There is probably a more efficient way to do this (binding one handler to all the unordered lists rather than a new instance of Sortable to each unordered list - I'll work on that, but it's not a priority as there are not often going to be more than a dozen or two of these on the page).

The back-end is simple enough:

    public function position(Show $show, ShowGroup $showgroup_moved)
    {
        // Get the showgroups in this show, and move the selected showgroup
        // to the specified position (with a zero-based index).
        $position = (int)Input::get('position', 0);

        // Get the show groups for the show and build up a list of current positions.
        // We don't care what the current position numbers are; we just want them in the
        // right order. We can fall back to the ID where there is no position set.
        $showgroups = $show
            ->showgroups()
            ->orderBy('position')
            ->orderBy('id')
            ->select(['id', 'position'])
            ->get();

        // Get all the IDs, in order.
        $order = $showgroups->lists('id')->toArray();

        // We have an array of the current order.
        // Now pick out the item we want to move and split it into the new position.
        if ($position < count($order) && ($key = array_search($showgroup_moved->id, $order)) !== false) {
            // Remove the element ID from the current position.
            unset($order[$key]);

            // Splice the moved ID into the new position.
            // This will reset the keys as numeric and contiguous.
            array_splice($order, $position, 0, $showgroup_moved->id);

            // Flip the order so IDs are keys, for easy checking of position for each ID.
            $order_flipped = array_flip($order);

            // Now update the positions where necessary.
            foreach($showgroups as $showgroup) {
                if ($showgroup->position != $order_flipped[$showgroup->id]) {
                    $showgroup->position = $order_flipped[$showgroup->id];
                    $showgroup->save();
                }
            }
        }
    }

The $show and $showgroup_moved are provided by the model-bound route. This method reorders the show groups within a show. The show group model has a position attribute which is just an integer, defaulting to zero when the model is created.

This method could also benefit from being split into two methods - one to set up the query, and the other to run the query and do the reordering in a generic way. That way I can reuse most of this code for the show groups, show judges and judge classes.

It might look complicated, but it's not really - just get the models in the list in their current order, remove the one that is being moved, put it back into the new position, reset any position values that have now changed (i.e. resequence the position attributes), and Robert's your mother's brother.

This should work whether your item IDs are numeric or UUIDs (except for the orderBy('id') which you might want to change to the creation timestamp). Mine are numeric because they are only exposed to the administrator. You wouldn't want non-admin or public users seeing those numeric IDs, but that's another subject...

2 likes
zachleigh's avatar

Nice. Glad you found a solution that works. I may have to have a second look at my implementation to see if I can improve it with your ideas.

jjudge's avatar

Hopefully the reasoning behind it makes sense. I did spend much of the weekend trying to get the dragging to work, before I realised last night I was using html.sortable from Bower, rather than Sortable. All those dragging bugs of the former library just melted away - it really is a beautiful library. Doh.

jjudge's avatar

Refactored the back end so it is split between the controller method and the actual reordering of the model. I can share this method now between multiple items that need ordering:

// ShowGroupController.php

    /**
     * Put a showgroup into a specified position.
     * Used when ordering the show groups using drag-and-drop.
     * Will be called via AJAX as a PUT.
     */
    public function position(Show $show, ShowGroup $showgroup)
    {
        // Get the showgroups in this show, and move the selected showgroup
        // to the specified position (with a zero-based index).
        $position = (int)Input::get('position', 0);

        return $this->movePosition($show->showgroups(), $showgroup->id, $position);
    }

    /**
     * Move an element to a new position given the query for the group.
     * The model needs a "position" integer attribute.
     * @param $query Eloquent query e.g. $show->showGroups()
     * @param $element_id integer|string The ID of the element to move.
     * @param $position integer New zero-based position for the element in the query list.
     */
    protected function movePosition($query, $element_id, $position)
    {
        $collection = $query
            ->orderBy('position')
            ->orderBy('created_at')
            ->select(['id', 'position'])
            ->get();

        // Get all the IDs, in collection.
        $order = $collection->lists('id')->toArray();

        // We have an array of the current order.
        // Now pick out the item we want to move and split it into the new position.
        if ($position < count($order) && ($key = array_search($element_id, $order)) !== false) {
            // Remove the element ID from the current position.
            unset($order[$key]);

            // Splice the moved ID into the new position.
            // This will reset the keys as numeric and contiguous.
            array_splice($order, $position, 0, $element_id);

            // Flip the order so IDs are keys, for easy checking of position for each ID.
            $order_flipped = array_flip($order);

            // Now update the positions where necessary.
            foreach($collection as $item) {
                if ($item->position != $order_flipped[$item->id]) {
                    $item->position = $order_flipped[$item->id];
                    $item->save();
                }
            }
        }
    }
1 like
mniblett's avatar

So, what did you end up with here? I have almost the exact same structure, a hierarchy of containers (represented in HTML as lists within lists and in my database as many-one relationships).

I see that there is a vue wrapper for the RubaXa/sortable library available: https://github.com/sagalbot/vue-sortable

I am just learning Laravel and have't done any javascript code yet, this whole drag and drop/reorder is a little daunting. Usually, I am ok once I get started. I ran npm install vue-sortable and now have the libraries in my node_modules folder, but the sample code posted by @consil is sort of beyond me. But, I did want to thank you for taking the time to post this.

mniblett's avatar

@consil I have a couple questions if you have the time to answer:

The url included with each element is the route that you use to update the list? And, each item is different in that it includes the item's ID? If it's not too much trouble, could you show a snippet of what the final HTML looks like and the PHP you used to create it?

In my implementation you can move items (at any level) to a different group. In other words, I could move a session to a different judge. So I have to deal with that as well. But I figure I can just use the new position value to grab the previous element at that position, and copy it's "judge_id" to the moved element before I reorder the list.

Also, I was planning on using one of the sortable trait packages from packagist on the models I need to make sortable.

jjudge's avatar

@mniblett just in the last few days, the client has asked us if they could move the sessions between judges. Oh well :-)

The general approach I took, is that each draggable <li> element is given a URL attribute. When it is dragged, that URL is used to tell the server that this element has moved (using PUT). The URL contains enough for the server to identify which element in the list you are moving, and it also identifies that it is a movement taking place.

So for example, a judge in a show may have a "moving URL":

<li url="http://example.com/show/123/judge/456/position">Judge Dredd</li>

So that is the URL we PUT to in order to tell the server this node has been dragged. What we then need is additional data to tell the server where we have dragged it to. That is sent as GET parameters. The draggable front-end library I used has draggable events to tap in. In my case I was not worried about changes to parents, but simply the new position in the current branch the judge is in - so I send position=N in the PUT request. The server then works out what needs to be changed in the database to implement that (changing siblings in the nested set left/right values - but that really depends on how your data is managed).

You (and maybe me next week) will probably also need to send the parent ID with the drag-and-drop action. So adding another custom data attribute to the draggable elements will be needed - the ID of that node on the server, because the front end will need to know the parent of the node you have dragged the element onto, so it can tell the server.

I'm sure there are frameworks and libraries to kind of do all this stuff without having to worry about the details, but I'm not into big JS frameworks and so am putting together much smaller building blocks, so have to understand a little more about what is happening at a lower level. It means I don't have a simple sample I can paste to say "just do this", because it depends on your whole application structure.

jjudge's avatar

The url included with each element is the route that you use to update the list?

Essentially yes:

<ul class="classes list-unstyled list-group in">
    <li class="class" url="http://example.com/admin/shows/14/groupjudges/43/position/newposition">
        <a title="A show class" href="http://example.com/admin/shows/14/groupjudges/336" title="Edit judge">Judge Dredd</a>
    </li>
    <li class="class" url="http://example.com/admin/shows/14/groupjudges/44/position/newposition">
    <li class="class" url="http://example.com/admin/shows/14/groupjudges/45/position/newposition">
    <li class="class" url="http://example.com/admin/shows/14/groupjudges/46/position/newposition">
</ul>

So when that third element gets dragged into the second position (wrt it siblings) I send this request to the server:

PUT http://example.com/admin/shows/14/groupjudges/43/position/newposition?position=2
// (or 1 - can't remember if it is zero-based)

There are more levels than that, but it gives you an idea. Each of these would need additional data to identify just their ID, so when dragging to a new parent, you can identify that parent to notify the server.

This is all generated as plain HTML using loops in blade templates. I'm sure the tree could be generated on the client side through pure data bound to the HTML using vue.js. But that's a whole new ball-game for another day :-)

mniblett's avatar

Thanks!

I'm using the same draggable library you are (rubaxa/sortable) and I have pretty much everything set in my blade file to render the hierarchical output. I create the sortable objects within the blade loops which allows me to set the same group for each level of the hierarchy. This works fine for my innermost elements, but for some reason I can't get the top most elements to be draggable. haven't figured out what is wrong yet but here's how I'm doing that part:

<script src="http://rubaxa.github.io/Sortable/Sortable.js"></script>

<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2 ">
            <ul class="list-group" id= <?= '"story' . $story->id .'"'; ?>>
                @foreach($story->sections as $section)
                    <li class="list-group-item">  
                        <span class="glyphicon glyphicon-sort pull-left" aria-hidden="true"></span>   
                        <span class="section-title">{{ $section->name }}</span>
                               
                        <ul class="list-group" id= <?= '"section' . $section->id .'"'; ?>>
                        @foreach($section->chapters as $chapter)
                            <li class="list-group-item"> 
                                <span class="glyphicon glyphicon-sort pull-left" aria-hidden="true"></span>                         
                                <span class="chapter-title">{{ $chapter->name }}</span>                                     
                                                               
                                <ul class="list-group" id= <?= '"chapter' . $chapter->id .'"'; ?> min-height: 200px;>
                                @foreach($chapter->scenes as $scene)
                                    <li class= "list-group-item">
                                        <span class="glyphicon glyphicon-sort" aria-hidden="true"></span>
                                        <span class="scene-title">{{ $scene->name }}</span>                            
                                    </li>
                                @endforeach
                                </ul>
                                
                                <script type="text/javascript">
                                    Sortable.create(<?= 'chapter' . $chapter->id; ?>, {
                                        group:'scenes',
                                        handle: ".glyphicon glyphicon-sort"
                                    });    
                                </script>   
                            </li>
                        @endforeach
                        </ul>
                        <script type="text/javascript">
                            Sortable.create(<?= 'section' . $section->id; ?>, {
                                group:'chapters',
                                handle: ".glyphicon glyphicon-sort"                            
                                }
                            });    
                        </script>
                    </li>
                @endforeach
            </ul>
             <script type="text/javascript">
                Sortable.create(<?= 'story' . $story->id; ?>, {
                    group:'sections',
                    handle: ".glyphicon glyphicon-sort"                            
                    }
                });    
            </script>
        </div>  <!-- class="col-md-8 col-md-offset-2 " -->
    </div>  <!-- class="row" -->
</div>  <!-- class="container" -->

So, I just need to figure out why my upper level isn't sorting and then create the store methods.

Q: not sure why my markdown isn't using highlighting, I put HTML after the three ticks.

jjudge's avatar

Completely aside, instead of this:

<ul class="list-group" id= <?= '"section' . $section->id .'"'; ?>>

You can do this:

<ul class="list-group" id="section{{ $section->id }}">

That will keep much of the raw PHP out of the blade view. Notice also how it helps with the colour highlighting :-)

jjudge's avatar

Rather than littering the tree with JS fragments, I use jQuery to scan the DOM for draggable lists and apply the Sortable events to it. This is at the bottom of my tree page:

@push('ready-scripts')
    jQuery("#show-map .groups, #show-map .judges, #show-map .classes").each(function(i, el){
        Sortable.create(el, {
            handle: '.drag-handle',
            animation: 150,
            forceFallback: false,
            onUpdate: function (evt) {
                var url = evt.item.getAttribute('url');
                if (url) {
                    $.ajax({
                        method: "PUT",
                        url: url,
                        data: {position: evt.newIndex}
                    });
                }
            }
        });
    });
@endpush

The selector (#show-map .groups, #show-map .judges, #show-map .classes) selects each <ul> that has sortable child elements. The onUpdate event reads the URL on a <li> that has been dragged and PUTs it to the server along with its new position.

This at the bottom of our outermost page template, just before the closing </body>, renders this @push() section in the right place, and makes sure the JS is executed when the DOM is ready:

        <script>
            jQuery(document).ready(function() {
                @stack('ready-scripts')
            });
        </script>

It keeps the JS in the view with the HTML, but renders it separately in the page. @push() and @stack() is a brilliant, yet often overlooked blade feature.

mniblett's avatar

Thanks, I went back and cleaned everything up to pull out that redundant php (including in my javascript). It's a lot cleaner now.

For moving between groups, I'm using the onAdd method (which fires when you move an element to a different group) to get the group ID.

evt.item.parentNode.getAttribute('id');

Had you not posted your code I would have never realized that evt.item returned a DOM element. Where did you find what kind of attributes/methods the evt object has? I tried going through the source code but couldn't figure it out.

jjudge's avatar

Using console.log(whatever) in your JS, and inspecting the console using Firebug or whatever equivalent your browser has, can give you a lot of information about what is happening. I tend not to look through the code too much - I find JS very difficult to get my head around being a mostly back-end developer, with it all being kind of mystery layers of dynamic context and scopes - but just inspecting what data it throws around is often all you need to know (i.e. what it does rather than how it does it).

mniblett's avatar

@consil OK, so here is where I am: I am using rutorika-sortable to implement my sortable trait. It works with groups so when I create a new object it automatically assigns the position to the last position within the current group. Each level is grouped based on the level above. So, in this hierarchy the positions are as follows:

Story Section 1 position 1 (of story) Chapter 1 position 1 (of sec 1) scene 1 position 1 (of chap 1) Chapter 2 position 2 (of sec 1) scene 1 position 1 (of chap 2) scene 2 position 2 (of chap 2) Section 2 position 2 (of story) Chapter 3 position 1 (of sec 2) scene 1 position 1 (of chap 3)

If I drag chap 3 scene 1 up under chapter 2 scene 2, I need to change it's group ID to Chapter 1, and it's position to 3.

So, when an item is dragged and re-positioned, I just need to know it's parent and it's new index within the parent, both of which I can get from the evt object that RubaXa/sortable emits. I'm able to do all that.

My problem is I don't know what to do next. I'm confused by the ajax call and the url you are sending it. It seems to me I would be trying to evoke a route, something like Route::put('/scenes/updatePosition/', 'SceneController@updatePosition');and pass it the parent ID and the new index. I'm thinking that's your url but not sure why you need the whole address including the http part and the domain instead of just sending it like a normal route is declared?

I know what to do after that (rutorika-sortable has putBefore() and putAfter() methods to update the positions). It's just the ajax call that is throwing me because I've never done that before.

jjudge's avatar

The AJAX URL just identifies the node (in the path of the URL) and its new position (in PUT parameters). Whether it is an absolute or relative URL will make no difference - it is just what the route() function spits out.

Alternatively you could have one path that all the nodes share, with the node IDs being passed in also as PUT parameters. Whatever is easiest for you really. The key things are:

  • It is a PUT action and not GET, so things don't get accidentally moved around by visiting URLs in the browser.
  • You are getting the data you need to implement the move in the database to a controller method. Whether that method has parameters mapped to the route path, or simply looks at POST parameters, makes no difference. You are just getting the new position data to the server so it can manipulate the database to reflect the drag-and-drop you did on the front end.

It's about keeping the front end and the database in sync. The sortable library lets you drag nodes around the tree structure, and that's great. Then each time a node moves, the server needs to be told what has just moved, and that is the AJAX call.

Of course, things can still get out of step, or out of order, because there is no guarantee the AJAX call will work. A node may have been deleted or moved by someone else, or an AJAX call may have failed, or was slow. A large, fancy JS framework may be able to handle what then happens reliably and consistently, automatically, but vue.js, sortable,js and jQuery with some simple AJAX is not going to cope with all eventualities. But just programme defensively (e.g. check all the parameters - is the node being moved in the same tree as the parent it is claimed it has been dragged to? And should the user have access to that tree? Is a chapter being dragged into another chapter, and is that allowed?), and if the user gets errors, they can just refresh the page and everything should be back in sync.

mr415's avatar

I've recently had to implement something similar - nested lists and Vue to keep track of the model. I went with Dragula but the workaround isn't optimal because I have to do the following:

  1. Replace the whole data in Vue to reflect the new positions.
  2. Manually remove DOM el that's being dragged around so Dragula doesn't fight with Vue over the DOM (duplicate elements)

Using sortable, is Vue aware of the changes in order? And if so, do you get any DOM conflicts when Vue wants to re-draw things?

Oh and similarly, I'm also using html attributes to figure out the parent, position, id's etc. Which are all bound to Vue data. The models are also polymorphic and nestable within themselves. I'm half tempted to fire a bunch of different vue events and update it that way.

BTW cheers for letting us know about stack/push. Didn't even know it could do that! And I was here using multiple @includes like a chump.

jjudge's avatar

Hi @mr415

When I first started this I had the assumption that Vue would have its own data model mapped to the structure and would be used to draw the initial tree. That proved very difficult to implement, and TBH did not offer any benefits. So instead I just drawn up the structure using standard nested loops in blade, giving elements appropriate IDs, classes and other attributes, then use some pretty standard jQuery to point sortable at the dragable elements in the tree. That proved pretty simple in the end.

If there were a draggable tree plugin to Vue, that sat on top of Vue, then it may be a completely different matter. So, tried that route, then threw it away and went for simple jQuery and sortable. Hope that helps.

Edit: just to summarise the sortable conceptual approach, when an element is dragged and dropped onto another place in the tree, there are only a few things you really need to know:

  • The ID of the element you have moved. i.e. the ID that identifies what the element represents in the database.
  • The ID of the parent it has been dragged to.
  • Its numeric position in relation to its immediate siblings.

With those three pieces of information sent to the server, the hierarchy can be restructured to match what has happened on the front end. Whether you need to track that hierarchy on the front end in a data structure there too, depends on your application. This ignores extra features here, such as the server responding to those three pieces of information to say, "nope, it can't go there - put it back", and then having to handle that on the front end.

Please or to participate in this conversation.