Drag and Drop Sorting With Livewire

March 7th, 2024

If you have a list of items you need to be able to drag, drop, then set a new order in the database — good news, it's pretty straightforward to do with Livewire and Alpine.

We're going to use Sortable.js, but build our own Alpine plugin that registers a directive. We'll then apply this directive to a list of items, listen for when the list was sorted, and then feed the new order back to our Livewire component.

If you'd prefer to watch, we have a screencast covering building this, and loads more Livewire content!

First up, a simple list of items

We won't dive into the migrations just yet, but here's a simple Livewire component that lists a bunch of links on a user's profile that can be sorted.

class Links extends Component
{
    public function render()
    {
        return view('livewire.links', [
            'links' => auth()->user()->links()->get()
        ]);
    }
}

And here's the view.

<div>
    <div class="space-y-3">
        @foreach ($links as $link)
            <div class="bg-slate-200 text-gray-700 py-3 px-6 rounded-xl font-medium">
                {{ $link->url }}
            </div>
        @endforeach
    </div>
</div>

Build the Alpine plugin

First up, we'll install Sortable.js. This drives all of the sorting functionality.

npm i sortablejs

Next, we'll create a sortable.js plugin, stored in our resources/js directory.

import Sortable from 'sortablejs'

export default function (Alpine) {
    Alpine.directive('sortable', (el) => {
        el.sortable = Sortable.create(el, {
            dataIdAttr: 'x-sortable-item',
            onSort() {
                el.dispatchEvent(
                    new CustomEvent('sorted', {
                        detail: el.sortable.toArray().map(id => parseInt(id))
                    })
                )
            }
        })
    })
}

There's quite a bit going on here, so let's dive into the specifics before we use our shiny new plugin!

  1. Alpine.directive simply registers a directive that we'll be able to use on our list container (like x-sortable). We'll get to that soon.
  2. We create a Sortable.js instance
  3. dataIdAttr refers to the unique ID for each item in our list. We'll set this as the unique ID of each item when we implement it.
  4. We use onSort (a Sortable.js event) to dispatch a custom event called sorted when the list is... well, sorted.
  5. The custom event detail contains a list of IDs (set by dataIdAttr, remember), and we parse each one so it's an integer, not a string.

Ok, hopefully that's clear. Let's register the plugin in app.js

import sortable from './sortable'
import { Livewire, Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm';

Alpine.plugin(sortable)

Livewire.start()

Since Alpine is bundled with Livewire already, we're manually pulling in Livewire and Alpine here so we can register our plugin. You'll also need to add the @livewireScriptConfig directive to the end of your base layout file if you haven't already.

        <!-- Other base layout stuff -->
        @livewireScriptConfig
    </body>
</html>

Applying the directive

This is where the magic happens. Once you've applied the directive to your list of items, you should end up with the ability to drag and drop every item in the list! Here's what it looks like.

<div>
    <div
        x-data
        x-sortable
        x-on:sorted="console.log($event.detail)"
        class="space-y-3"
    >
        @foreach ($links as $link)
            <div
                class="bg-slate-200 text-gray-700 py-3 px-6 rounded-xl font-medium"
                x-sortable-item="{{ $link->id }}"
            >
                {{ $link->url }}
            </div>
        @endforeach
    </div>
</div>

You'll notice we're listening for the custom sorted event and logging the list of IDs to the console right now. Eventually we'll be able to feed this list of IDs back to livewire so we can update the database with the new order.

But first, we need a way to order our models, and easily set a new order.

Sortable models with spatie/eloquent-sortable

Using this package makes it incredibly easy to order and adjust orders with just a few lines of code. You'll find all of the installation instructions in the repository, but I'll add them here for clarity.

First up, you'll need an order column in the database table for the model you're sorting. I'd already added this, but here's what the migration would look like.

Schema::create('links', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('url');
    $table->unsignedInteger('order')->nullable();
    $table->timestamps();
});

Next, install the package and publish the config file (we'll need to adjust this).

composer require spatie/eloquent-sortable
php artisan vendor:publish --tag=eloquent-sortable-config

Update the eloquent-sortable.php config file to change the default column name for sorting.

return [
    /*
     * Which column will be used as the order column.
     */
    'order_column_name' => 'order',

    //...
]

Now implement the Sortable interface and apply the SortableTrait trait to your model.

use Spatie\EloquentSortable\Sortable;
use Spatie\EloquentSortable\SortableTrait;

class Link extends Model implements Sortable
{
    use HasFactory;
    use SortableTrait;

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

And we're done! Go back to your Livewire component and update the builder to output the data in the correct order using the ordered scope from this package.

class Links extends Component
{
    public function render()
    {
        return view('livewire.links', [
            'links' => auth()->user()->links()->ordered()->get()
        ]);
    }
}

Setting the new order

We now need to let our Livewire component know that the list has been sorted, and provide the IDs we got back from Sortable.js.

Start by implementing a method to handle this in your Livewire component. We'll discuss what's happening here in a bit.

class Links extends Component
{
    public function updateOrder(array $order)
    {
        Link::setNewOrder($order, 1, 'id', function ($query) {
            $query->whereBelongsTo(auth()->user());
        });
    }

    //...
}

Now update your Livewire view to call the updateOrder method when sorting is finished.

x-on:sorted="$wire.updateOrder($event.detail)"

And that's it. Once you sort the list, the database will be udpated with the new order!

Let's quickly talk about the way we're setting the new order, since this is really important.

Link::setNewOrder($order, 1, 'id', function ($query) {
    $query->whereBelongsTo(auth()->user());
});

By default setNewOrder will just assume you're updating all of your records. Since we're using Livewire, any list of IDs could be passed through to the updateOrder method (because it's public). This means that other people could update other people's records. Not great.

The closure we apply scopes the records that are being updated to the current user using the query builder passed through. So, even if someone passes in a list of IDs that don't belong to them, they'll be ignored.

We're sorted

Great, we've created a reusable drag and drop Alpine directive that can now be used on any list of data in our Livewire components!

I'd recommend checking out the Sortable.js documentation to see what other options you can provide to customise the drag and drop functionality.

Author
Alex Garrett-Smith

Comments

No coments, yet. Be the first to leave a comment.