Skip to content

Latest commit

 

History

History
870 lines (687 loc) · 22.8 KB

drag-and-drop.md

File metadata and controls

870 lines (687 loc) · 22.8 KB

Drag and drop

A drag and drop implementation using Alpine.js and Phoenix LiveView to sort a list of items.

The drag and drop actions are visible in real time across any browsers connected to the Phoenix LiveView app.

0. Technical Info

The following versions were used:

  • Phoenix: 1.6.15
  • LiveView: 0.17.12
  • Alpine.js: 3.x.x

Note: If your versions are different from these and anything is not working, please open an issue

1. Initialisation

Start by creating a new Phoenix application:

mix phx.new app --no-dashboard --no-gettext --no-mailer

Install the dependencies when asked:

Fetch and install dependencies? [Yn] y

Then follow the last instructions to make sure the Phoenix application is running correctly:

cd app
mix ecto.create
mix phx.server

Open the your web browser to the the following URL: localhost:4000/ You should be able to see:

Phoenix App

We can now update the generated html in lib/app_web/templates/layout/root.html.heex file:

  • Add Alpine.js CDN script tag, see Alpine.js documentation <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  • Remove the header tag containing the Phoenix logo:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="csrf-token" content={csrf_token_value()} />
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <%= live_title_tag(assigns[:page_title] || "App", suffix: " · Phoenix Framework") %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")} />
    <script
      defer
      phx-track-static
      type="text/javascript"
      src={Routes.static_path(@conn, "/assets/app.js")}
    >
    </script>
  </head>
  <body>
    <%= @inner_content %>
  </body>
</html>

You can now run mix deps.get to make sure all dependencies are installed followed by mix phx.server

2. Create items

We can use the mix phx.gen.live command to let Phoenix create the LiveView files:

mix phx.gen.live Tasks Item items text:string index:integer`

This will create the:

.heex Template files and LiveView controllers will also be created.

Update lib/app_web/router.ex to add the new endpoints:

  scope "/", AppWeb do
    pipe_through :browser
    live "/", ItemLive.Index, :index
    live "/items/new", ItemLive.Index, :new
    live "/items/:id/edit", ItemLive.Index, :edit

    live "/items/:id", ItemLive.Show, :show
    live "/items/:id/show/edit", ItemLive.Show, :edit
  end

in the lib/app_web/live/item_live/index.html.heex file, remove the edit and delete links as we won't use them:

<h1>Listing Items</h1>

<%= if @live_action in [:new, :edit] do %>
  <.modal return_to={Routes.item_index_path(@socket, :index)}>
    <.live_component
      module={AppWeb.ItemLive.FormComponent}
      id={@item.id || :new}
      title={@page_title}
      action={@live_action}
      item={@item}
      return_to={Routes.item_index_path(@socket, :index)}
    />
  </.modal>
<% end %>

<table>
  <thead>
    <tr>
      <th>Text</th>
      <th>Index</th>
    </tr>
  </thead>
  <tbody id="items">
    <%= for item <- @items do %>
      <tr id={"item-#{item.id}"}>
        <td><%= item.text %></td>
        <td><%= item.index %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<span><%= live_patch "New Item", to: Routes.item_index_path(@socket, :new) %></span>

Note: the <.modal> component is created by phx.gen.live. It is not a UI/UX best-practice and should not be used in a real App.

Then in lib/app_web/live/item_live/form_component.html.heex remove the label, number_input and error_tag linked to the index as we want our server to set this value when the item is created:

<div>
  <h2><%= @title %></h2>

  <.form
    let={f}
    for={@changeset}
    id="item-form"
    phx-target={@myself}
    phx-change="validate"
    phx-submit="save">
  
    <%= label f, :text %>
    <%= text_input f, :text %>
    <%= error_tag f, :text %>
  
    <div>
      <%= submit "Save", phx_disable_with: "Saving..." %>
    </div>
  </.form>
</div>

Then we need to update our Item schema to be able to save a new item. We want to remove the :index value from the validate_required function in the changeset. Update lib/app/tasks/item.ex:

def changeset(item, attrs) do
  item
  |> cast(attrs, [:text, :index])
  |> validate_required([:text]) # index is removed
end

Let's update the create_item function in lib/app/tasks.ex to make sure Phoenix set the index value. The item's index is equal to the number of existing items + 1:

def create_item(attrs \\ %{}) do
  items = list_items()
  index = length(items) + 1

  %Item{}
  |> Item.changeset(Map.put(attrs, "index", index))
  |> Repo.insert()
end

Finally we want to update the list_items function in the same file to get the items order by their indexes:

def list_items do
  Repo.all(from i in Item, order_by: i.index)
end

Running the application, you should see a UI similar to:

create-items list-items

3. Make it Real Time

PubSub is used to send and listen to messages. Any clients connected to a topic can listen for new messages on this topic.

In this section we are using PubSub to notify clients when new items are created.

The first step is to connect the client when the LiveView page is requested. Add helper functions in lib/app/tasks.ex to manages the PubSub feature. The first function is subscribe:

# Make sure to add the alias
alias Phoenix.PubSub

# subscribe to the `liveview_items` topic
def subscribe() do
  PubSub.subscribe(App.PubSub, "liveview_items")
end

Then in lib/app_web/live/item_live/index.ex, update the mount function to:

def mount(_params, _session, socket) do
  if connected?(socket), do: Tasks.subscribe()
  {:ok, assign(socket, :items, list_items())}
end

We are checking if the socket is properly connected to the client before calling the new subscribe function.

We are going to write now the notify function which uses the PubSub.broadcast function to dispatch messages to clients

In lib/app/tasks.ex:

def notify({:ok, item}, event) do
  PubSub.broadcast(App.PubSub, "liveview_items", {event, item})
  {:ok, item}
end

def notify({:error, reason}, _event), do: {:error, reason}

Then call this function inside the create_item function:

def create_item(attrs \\ %{}) do
  items = list_items()
  index = length(items) + 1

  %Item{}
  |> Item.changeset(Map.put(attrs, "index", index))
  |> Repo.insert()
  |> notify(:item_created)
end

The notify function will send the :item_created message to all clients.

Finally we need to listen to this new messages and update our liveview. In lib/app_web/live/item_live/index.ex, add:

@impl true
def handle_info({:item_created, _item}, socket) do
  items = list_items()
  {:noreply, assign(socket, items: items)}
end

When the client receive the :item_created we are getting the list of items from the database and assigning the list to the socket. This will update the liveview template with the new created item.

4. Drag and Drop

Now that we can create items, we can finally start to implement our drag and drop feature.

To be able to use Alpine.js with Phoenix LiveView we need to update asset/js/app.js:

let liveSocket = new LiveSocket("/live", Socket, {
  dom: {
    onBeforeElUpdated(from, to) {
      if (from._x_dataStack) {
        window.Alpine.clone(from, to)
      }
    }
  },
    params: {_csrf_token: csrfToken}
})

This is to make sure Alpine.js keeps track of the DOM changes created by LiveView.

See the Phoenix LiveView JavaScript interoperability documentation:

Alpine.js docs

Add the following content at the end of the assets/css/app.css file:

.cursor-grab{
  cursor: grab;
}

.cursor-grabbing{
  cursor: grabbing; 
}

.bg-yellow-300{
  background-color: rgb(253 224 71);
}

These CSS classes will be used to make our items more visible when moved.

We are going to define an Alpine.js component using the x-data directive:

Everything in Alpine starts with the x-data directive. x-data defines a chunk of HTML as an Alpine component and provides the reactive data for that component to reference.

in lib/app_web/live/item_live/index.html.heex:

<tbody id="items">
  <%= for item <- @items do %>
    <tr id={"item-#{item.id}"} x-data="{}" draggable="true">
      <td><%= item.text %></td>
      <td><%= item.index %></td>
    </tr>
  <% end %>
</tbody>

We have also added the draggable html attribute to the tr tags.

To add an event listener to your html tag Alpine.js provides the x-on attribute. Listen for the dragstart and dragend events:

<tbody id="items">
  <%= for item <- @items do %>
    <tr
      id={"item-#{item.id}"}
      draggable="true"
      x-data="{selected: false}"
      x-on:dragstart="selected = true"
      x-on:dragend="selected = false"
      x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'"
    >
      <td><%= item.text %></td>
      <td><%= item.index %></td>
    </tr>
  <% end %>
</tbody>

When the dragstart event is triggered (i.e. an item is moved) we update the newly selected value define in x-data to true. When the dragend event is triggered we set selected to false.

Finally we are using x-bind:class to add a CSS class depending on the value of selected. In this case we have customized the display of the cursor.

To make the moved item a bit more obvious, we also change the background color.

In this step we also make sure that all connected clients can see the new background color of the moved item!

Update the tr tag with the following:

<tr
  id={"item-#{item.id}"}
  x-data="{selected: false}"
  draggable="true"
  x-on:dragstart="selected = true; $dispatch('highlight', {id: $el.id})"
  x-on:dragend="selected = false; $dispatch('remove-highlight', {id: $el.id})"
  x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'"
>

The dispatch Alpine.js function sends a new custom JS event. We use hooks to listen for this event and then notify LiveView.

In assets/js/app.js, add above the liveSocket variable:

let Hooks = {};
Hooks.Items = {
  mounted() {
    const hook = this

    this.el.addEventListener("highlight", e => {
      hook.pushEventTo("#items", "highlight", {id: e.detail.id})
    })
    
    this.el.addEventListener("remove-highlight", e => {
      hook.pushEventTo("#items", "remove-highlight", {id: e.detail.id})
    })
  }
}

Then add the Hooks JS object to the socket:

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: Hooks, //Add hooks
  dom: {
    onBeforeElUpdated(from, to) {
      if (from._x_dataStack) {
        window.Alpine.clone(from, to)
      }
    }
  },
    params: {_csrf_token: csrfToken}
})

The last step for the hooks to initialized is to add phx-hook attribute in our lib/app_web/live/item_live/index.html.heex:

<tbody id="items" phx-hook="Items">

Note that the value of phx-hook must be the same as Hooks.Items = ... defined in app.js, i.e. Items.

We now have the hooks listening to the highlight and remove-highlight events, and we use the pushEventTo function to send a message to the LiveView server.

Let's add the following code to handle the new messages in lib/app_web/live/item_live/index.ex. Note that Elixir requires the handle_event function definitions to be grouped.

@impl true
def handle_event("highlight", %{"id" => id}, socket) do
  Tasks.drag_item(id)
  {:noreply, socket}
end

@impl true
def handle_event("remove-highlight", %{"id" => id}, socket) do
  Tasks.drop_item(id)
  {:noreply, socket}
end

The Tasks functions drag_item and drop_item are using PubSub to send a message to all clients to let them know which item is being moved:

In lib/app/tasks.ex:

def drag_item(item_id) do
  PubSub.broadcast(App.PubSub, "liveview_items", {:drag_item, item_id})
end

def drop_item(item_id) do
  PubSub.broadcast(App.PubSub, "liveview_items", {:drop_item, item_id})
end

Then back in lib/app_web/live/item_live/index.ex we handle these events with:

@impl true
def handle_info({:drag_item, item_id}, socket) do
  {:noreply, push_event(socket, "highlight", %{id: item_id})} 
end

@impl true
def handle_info({:drop_item, item_id}, socket) do
  {:noreply, push_event(socket, "remove-highlight", %{id: item_id})} 
end

The LiveView will send the highlight and remove-highlight to the client. The final step is to handle these Phoenix events with Phoenix.LiveView.JS to add and remove the background color.

In assets/js/app.js add (for example above liveSocket.connect()) the event listeners:

window.addEventListener("phx:highlight", (e) => {
  document.querySelectorAll("[data-highlight]").forEach(el => {
    if(el.id == e.detail.id) {
        liveSocket.execJS(el, el.getAttribute("data-highlight"))
    }
  })
})

window.addEventListener("phx:remove-highlight", (e) => {
  document.querySelectorAll("[data-highlight]").forEach(el => {
    if(el.id == e.detail.id) {
        liveSocket.execJS(el, el.getAttribute("data-remove-highlight"))
    }
  })
})

For each item we are checking if the id match the id linked to the drag/drop event, then execute the Phoenix.LiveView.JS function that we now have to define back to our lib/app_web/live/item_live/index.html.heex file.

<tr
  id={"item-#{item.id}"}
  x-data="{selected: false}"
  draggable="true"
  x-on:dragstart="selected = true; $dispatch('highlight', {id: $el.id})"
  x-on:dragend="selected = false; $dispatch('remove-highlight', {id: $el.id})"
  x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'"
  data-highlight={JS.add_class("bg-yellow-300")}
  data-remove-highlight={JS.remove_class("bg-yellow-300")}
>

To the call to add_class and remove_class, you need to add alias Phoenix.LiveView.JS at the top of the file lib/app_web/live/item_live/index.ex This alias will make sure the two functions are accessible in the LiveView template.

Again there are a few steps to make sure the highlight for the selected item is properly displayed. However all the clients should now be able to see the drag/drop action!

So far we have added the code to be able to drag an item, however we haven't yet implemented the code to sort the items.

We want to switch the positions of the items when the selected item is hovering over another item. We are going to use the dragover event for this:

<tbody id="items" phx-hook="Items" x-data="{selectedItem: null}">
  <%= for item <- @items do %>
    <tr
      id={"item-#{item.id}"}
      x-data="{selected: false}"
      draggable="true"
      class="item"
      x-on:dragstart="selected = true; $dispatch('highlight', {id: $el.id}); selectedItem = $el"
      x-on:dragend="selected = false; $dispatch('remove-highlight', {id: $el.id}); selectedItem = null"
      x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'"
      x-on:dragover.throttle="$dispatch('dragoverItem', {selectedItemId: selectedItem.id, currentItemId: $el.id})"
      data-highlight={JS.add_class("bg-yellow-300")}
      data-remove-highlight={JS.remove_class("bg-yellow-300")}
    >

We have added x-data="{selectedItem: null} to the tbody html tag. This value represents which element is currently being moved.

We have also added the class="item". This will be used later on in app.js to get the list of items using querySelectorAll.

Then we have x-on:dragover.throttle="$dispatch('dragoverItem', {selectedItemId: selectedItem.id, currentItemId: $el.id})"

The throttle Alpine.js modifier will only send the event dragoverItem once every 250ms max. Similar to how we manage the highlights events, we need to update the app.js file and add to the Hooks:

this.el.addEventListener("dragoverItem", e => {
  const currentItemId = e.detail.currentItemId
  const selectedItemId = e.detail.selectedItemId
  if( currentItemId != selectedItemId) {
    hook.pushEventTo("#items", "dragoverItem", {currentItemId: currentItemId, selectedItemId: selectedItemId})
  }
})

We only want to push the dragoverItem event to the server if the item is over an item which is different than itself.

On the server side we now add

  • in lib/app_web/live/item_live/index.ex:
@impl true
def handle_event(
      "dragoverItem",
      %{"currentItemId" => current_item_id, "selectedItemId" => selected_item_id},
      socket
    ) do
  Tasks.dragover_item(current_item_id, selected_item_id)
  {:noreply, socket}
end

and

@impl true
def handle_info({:dragover_item, {current_item_id, selected_item_id}}, socket) do
  {:noreply,
   push_event(socket, "dragover-item", %{
     current_item_id: current_item_id,
     selected_item_id: selected_item_id
   })}
end

Where Tasks.dragover_item\2 is defined as:

def dragover_item(current_item_id, selected_item_id) do
  PubSub.broadcast(App.PubSub, "liveview_items", {:dragover_item, {current_item_id,selected_item_id }})
end

Finally we in app.js:

window.addEventListener("phx:dragover-item", (e) => {
  const selectedItem = document.querySelector(`#${e.detail.selected_item_id}`)
  const currentItem = document.querySelector(`#${e.detail.current_item_id}`)

  const items = document.querySelector('#items')
  const listItems = [...document.querySelectorAll('.item')]

  if(listItems.indexOf(selectedItem) < listItems.indexOf(currentItem)){
    items.insertBefore(selectedItem, currentItem.nextSibling)
  }
  
  if(listItems.indexOf(selectedItem) > listItems.indexOf(currentItem)){
    items.insertBefore(selectedItem, currentItem)
  }
})

We compare the selected item position in the list with the "over" item and use insertBefore JS function to add our item at the correct DOM place.

You should now be able to see on different clients the selected item moved into the list during the drag and drop. However we haven't updated the indexes of the items yet.

We want to send a new event when the dragend event is emitted:

<tr
  id={"item-#{item.id}"}
  data-id={item.id}
  class="item"
  x-data="{selected: false}"
  draggable="true"
  x-on:dragstart="selected = true; $dispatch('highlight', {id: $el.id}); selectedItem = $el"
  x-on:dragend="selected = false; $dispatch('remove-highlight', {id: $el.id}); selectedItem = null; $dispatch('update-indexes')"
  x-bind:class="selected ? 'cursor-grabbing' : 'cursor-grab'"
  x-on:dragover.throttle="$dispatch('dragoverItem', {selectedItemId: selectedItem.id, currentItemId: $el.id})"
  data-highlight={JS.add_class("bg-yellow-300")}
  data-remove-highlight={JS.remove_class("bg-yellow-300")}
>

We have added the data-id attribute to store the item.id and created the $dispatch('update-indexes') event.

In app.js we listen to the event in the Hook:

this.el.addEventListener("update-indexes", e => {
    const ids = [...document.querySelectorAll(".item")].map( i => i.dataset.id)
    hook.pushEventTo("#items", "updateIndexes", {ids: ids})
})

We are creating a list of the items' id that we push to the LiveView server with the event updateIndexes

In lib/app_web/live/item_live/index.ex we add a new handle_event

@impl true
def handle_event("updateIndexes", %{"ids" => ids}, socket) do
  Tasks.update_items_index(ids)
  {:noreply, socket}
end

And in tasks.ex:

def update_items_index(ids) do
  ids
  |> Enum.with_index(fn id, index ->
    item = get_item!(id)
    update_item(item, %{index: index + 1})
  end)

  PubSub.broadcast(App.PubSub, "liveview_items", :indexes_updated)
end

For each id a new index is created using Enum.with_index and the item is updated. (This might not be the best implementation for updating a list of items, so if you think there is a better way to do this don't hesitate to open an issue, thanks!)

Finally similar to the way we tell clients a new item has been created, we broadcast a new message, indexes_updated:

@impl true
def handle_info(:indexes_updated, socket) do
  items = list_items()
  {:noreply, assign(socket, items: items)}
end

We fetch the list of items from the database and let LiveView update the UI automatically.

You should now have a complete drag-and-drop feature shared with multiple clients!

Connect a few browsers to the app URL: localhost:4000/

You should see something similar to the following when you drag-and-drop items:

drag-and-drop-demo

Thanks for reading and again don't hesitate to open issues for questions, enhancement, bug fixes...