Phoenix LiveView server-side rendering is very fast.
However, there are situations where client-side implementation results in a better user experience.
In this tutorial, we'll add SortableJS to a Phoenix LiveView project to implement a drag and drop interaction. I'll show you how you can make client-side drag and drop event data available to a server-side Phoenix LiveView using hooks.
Learn better with video? In this video below, you can learn how to build this project and hear Kelsey discuss the purpose behind each step.
You can also grab the source code for this demo below if you want to follow along.
Create a Phoenix LiveView project by running the following command in your terminal:
-- CODE line-numbers language-bash --
<!--
$ mix phx.new draggable_walkthru --live
-->
You may need to update `phoenix_live_view` and `phoenix_live_dashboard` in `mix.exs`:
-- CODE line-numbers language-bash --
<!--
{:phoenix_live_view, "~> 0.13.0"},
{:phoenix_live_dashboard, "~> 0.2.0"},
-->
Remove boilerplate content from `header` tag in `root.html.leex` and replace it with an `h1` tag:
-- CODE line-numbers language-html --
<!--
<!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"/>
<%= csrf_meta_tag() %>
<%= live_title_tag assigns[:page_title] || "DraggableWalkthru", suffix: " · Phoenix Framework" %>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
</head>
<body>
<header>
<div class="container">
<h1>Draggable</h1>
</div>
</header>
<%= @inner_content %>
</body>
</html>
-->
Delete everything from `page_live.html.leex` but do not delete the file.
When you run `mix phx.server` you should see the following:
Don't forget to run `mix ecto.create` before you run `mix phx.server`.
If you're following along and don't need an Ecto database, you can add `--no-ecto` when creating your project (`mix phx.new draggable_walkthru --live --no-ecto`).
If you add the `--no-ecto` flag, you won't need to run `mix ecto.create`.
In this example, I'm using Tailwind CSS for quick utility-based styling. Because this is a simple project, I'm opting to use the CDN for Tailwind.
Add the CDN link tag to `root.html.leex` before the link tag for `app.css`:
-- CODE line-numbers language-html --
<!--
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
-->
We'll use SortableJS to implement drag-and-drop. Add SortableJS to your project by running the following command in your terminal:
-- CODE line-numbers language-bash --
<!--
npm i sortablejs --prefix assets
-->
In `page_live.ex`, remove both `handle_event` functions and the `search` function. Since we removed the default content from `page_live.html.leex`, we don't need these anymore.
Remove the call to assign from the `mount` function.
Your `page_live.ex` file should look like this:
-- CODE line-numbers language-elixir --
<!--
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end
-->
Within mount we'll add some mock data. In a real app this data would come from a datasource.
-- CODE line-numbers language-elixir --
<!--
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
{:ok, socket}
end
-->
Next we'll add three assigns to socket:
`pool` - draggable items that haven't been assigned to a drop zone
`drop_zone_a` - our first drop zone
`drop_zone_b` - our second drop zone
In mount, all the elements in `draggables` will be allocated to `pool` and our drop zones will be initialized with empty lists:
-- CODE line-numbers language-elixir --
<!--
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
-->
Next we'll create a LiveComponent for our drop zones. Within `lib/draggable_walkthru_web/live` create a file named `drop_zone_component.ex`.
Add a `mount` function and a `render` function. Mount will return `{:ok, socket}`.
Within render, we'll add Live Eex (the multiline string preceded by `~L`) to define how we want our component rendered.
-- CODE line-numbers language-elixir --
<!--
defmodule DraggableWalkthruWeb.PageLive.DropZoneComponent do
use Phoenix.LiveComponent
@impl true
def mount(socket) do
{:ok, socket}
end
@impl true
def render(assigns) do
~L"""
<div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">
<%= @title %>
<%= for %{text: text, id: id} <- @draggables do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>
<% end %>
</div>
"""
end
end
-->
We've used Tailwind CSS classes to style the elements in our component render. To learn more about these utility classes review the Tailwind CSS documentation!
In our drop zone LiveComponent we'll pass through four assigns:
`draggables` - the list of items currently in the drop zone. The list is iterated over and a div is rendered for each item.
`drop_zone_id` - a value for the root div's `id` attribute (we'll need this for drag-and-drop later)
`title` - text to display at the top of the drop zone
`color` - the background color for the drop zone and the items within it
Note we add `draggable="true"` to our item div to indicate the element can be dragged. Learn more about the HTML global attribute `draggable` within the MDN Web Docs.
Now that we have a drop zone component, we can use it within `page_live.html.leex`:
-- CODE line-numbers language-elixir --
<!--
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
-->
Now our app will have two empty drop zones with their own titles and colors.
Learn more about Phoenix LiveComponents in the Phoenix LiveView documentation!
We'll add our pool directly inside `page_live.html.leex` above our drop zones. We'll iterate over the `pool` assign and render a draggable div for each item in the `pool` list.
-- CODE line-numbers language-elixir --
<!--
<div class="dropzone grid gap-3" id="pool">
<%= for %{text: text, id: id} <- @pool do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>
<% end %>
</div>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
-->
Now our app looks like this. We can drag the list items but we can't move them out of the pool and we can't rearrange them.
Now that we have our markup in LiveEEx, we can add the client-side javascript to implement drag and drop functionality. We're adding this on the client-side because the user experience is smoother than implementing it server-side within our `page_live.ex`.
First, create a file within `assets/js` named `dragHook.js`. We'll import `sortablejs` at the top and add an object that is our default export:
-- CODE line-numbers language-jsx --
<!--
import Sortable from 'sortablejs';
export default {};
-->
Client hooks can implement a number of lifecycle callbacks. In this hook, we only need to implement `mounted`. This callback is called when the element we add this hook to has been mounted to the DOM and the server-side LiveView has also mounted.
-- CODE line-numbers language-jsx --
<!--
import Sortable from 'sortablejs';
export default {
mounted() {
/* implementation will go here */
}
};
-->
Read more about JavaScript interoperability in the Phoenix LiveView documentation.
At the beginning of our `mounted` lifecycle callback function we'll define three variables:
`dragged` - the item currently being dragged
`hook` - a reference to `this` that we'll use later
`selector` - our element's id as a string prepended with "#"
-- CODE line-numbers language-jsx --
<!--
export default {
mounted() {
let dragged; // this will change so we use `let`
const hook = this;
const selector = '#' + this.el.id;
}
}
-->
Right away you'll see `this` has an `el` member. The callback lifecycle functions have several attributes in scope including `el`, a reference to the DOM element the hook has been added to.
Before we move on, let's add the hook to our DOM element so we can test to see if the `el` and it's ID are being passed through correctly.
First, we'll import our hook within `app.js` and add it to our LiveSocket. We'll create a `Hooks` object and add it to our SocketOptions within `new LiveSocket()`.
-- CODE line-numbers language-jsx --
<!--
// assets/js/app.js
// other imports omitted for clarity
import Drag from './dragHook';
const Hooks = { Drag: Drag }; // define an object to contain our hooks, our first key is `Drag`
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute('content');
let liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks, // add hooks to the SocketOptions object
});
-->
Next, within `page_live.html.leex`, we'll surround our markup with a `div` that has a `phx-hook` attribute and an `id` attribute. The value for `phx-hook` should match the key we used in our `Hooks` object and when using `phx-hook` we have to define a unique DOM id as well.
-- CODE line-numbers language-html --
<!--
<div phx-hook="Drag" id="drag">
<div class="dropzone grid gap-3" id="pool">
<%= for %{text: text, id: id} <- @pool do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-blue-700 text-white"><%= text %></div>
<% end %>
</div>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_a,
drop_zone_id: "drop_zone_a",
title: "Drop Zone A",
color: "orange"
%>
<%= live_component @socket, DraggableWalkthruWeb.PageLive.DropZoneComponent,
draggables: @drop_zone_b,
drop_zone_id: "drop_zone_b",
title: "Drop Zone B",
color: "green"
%>
</div>
-->
Now if we add a `console.log` after we define `selector` in `mounted`, we should see our message logged in the console:
-- CODE line-numbers language-jsx --
<!--
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
console.log('The selector is:', selector);
}
-->
Great! We have a hook and it's linking our LiveEEx markup with our JavaScript. Next, we'll start adding the drag and drop functionality.
Within `dragHook.js` we want to make the "pool", "drop zone A", and "drop zone B" sortable and we want to be able to drag items from one to any of the others.
Within mounted, we'll start by getting references to all dropzone elements using `document.querySelectorAll` and the "dropzone" class as the selector:
-- CODE line-numbers language-jsx --
<!--
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
// make sure you prepend the class's name with a period "." to indicate it's a class
document.querySelectorAll('.dropzone').forEach((dropzone) => {
/* implementation to make this sortable will go here */
});
},
};
-->
In our markup, we've already added the class "dropzone" to our pool and drop zones "A" and "B"!
We'll iterate over the elements returned by `querySelectorAll` using `forEach`.
With each dropzone element, we'll instantiate a `Sortable` by calling `new Sortable` and passing in the element `dropzone` as the first parameter and an object of Sortable options as our second parameter.
-- CODE line-numbers language-jsx --
<!--
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
document.querySelectorAll('.dropzone').forEach((dropzone) => {
new Sortable(dropzone, {
animation: 0,
delay: 50,
delayOnTouchOnly: true,
group: 'shared',
draggable: '.draggable',
ghostClass: 'sortable-ghost'
});
});
},
};
-->
A detailed review of all the available options is outside the scope of this article, but we'll briefly review what options we've utilized here:
`animation` - the number of milliseconds to animate
`delay` - the number of milliseconds to delay
`delayOnTouchOnly` - we only want to delay if the interaction is via touch (not mouse or keyboard) so mobile users can scroll without grabbing draggable items
`group` - this is a string identifier that is shared by our pool and drop zones "A" and "B". It tells sortable that draggable items can be dragged from one to the others since they share the same string identifier
`draggable` - the selector Sortable will use to identify which child elements are draggable
`ghostClass` - the class that should be used to style the drop placeholder
Since the `draggable` class is used as a selector only, we don't need to add CSS.
We've added the `ghostClass` of `.sortable-ghost` to the bottom of `assets/css/app.scss`:
-- CODE line-numbers language-scss --
<!--
.sortable-ghost {
opacity: 0.75; // make the drop placeholder slightly transparent
}
-->
Now that we've added our `Sortable` instantiation, we can drag the items from the pool into dropzones "A" and "B"!
The drag and drop user experience is working!
Unfortunately though, our Elixir app doesn't know when an element has been moved nor where it has been moved to. Let's add that next!
When we drop a draggable element into a drop zone we want to make that information available to our PageLive module (`page_live.ex`).
In a real app, we might want to make a database update or publish the information using PubSub when an item is dropped into a drop zone.
Before we try to pass that information from our JavaScript implementation, let's add an event handler to the PageLive module:
-- CODE line-numbers language-elixir --
<!--
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
@impl true
def handle_event("dropped", params, socket) do
# implementation will go here
{:noreply, socket}
end
end
-->
Our `handle_event` callback function accepts as it's params:
We'll push the event from the Drag hook. We'll add another option, `onEnd`, to the Sortable options:
-- CODE line-numbers language-jsx --
<!--
import Sortable from 'sortablejs';
export default {
mounted() {
let dragged;
const hook = this;
const selector = '#' + this.el.id;
document.querySelectorAll('.dropzone').forEach((dropzone) => {
new Sortable(dropzone, {
animation: 0,
delay: 50,
delayOnTouchOnly: true,
group: 'shared',
draggable: '.draggable',
ghostClass: 'sortable-ghost',
onEnd: function (evt) {
/* implementation goes here */
},
});
});
},
};
-->
Within `onEnd`, we'll call `hook.pushEventTo`.
We'll pass `selector` in as the first parameter.
For our second parameter we want make sure to pass the same string we used as the event parameter in `handle_event` so we'll pass in "dropped".
For our third parameter, we'll pass in a payload object of parameters for use by `handle_event`.
-- CODE line-numbers language-jsx --
<!--
onEnd: function (evt) {
hook.pushEventTo(selector, 'dropped', {
draggedId: evt.item.id, // id of the dragged item
dropzoneId: evt.to.id, // id of the drop zone where the drop occured
draggableIndex: evt.newDraggableIndex, // index where the item was dropped (relative to other items in the drop zone)
});
},
-->
In our example, we use `pushEventTo`. This function pushes the event to the LiveView or LiveComponent specified by the selector. Hooks also have `pushEvent` if the event should be pushed to the LiveView server instead.
The parameters from the payload object are available within the second parameter of the `handle_event` callback function in `page_live.ex`. We can destructure the second parameter of the function definition to access the payload object members:
-- CODE line-numbers language-elixir --
<!--
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, socket) do
# implementation will go here
{:noreply, socket}
end
-->
Now that we have the necessary data being passed from the client to the server, we can use it to update the `pool`, `drop_zone_a`, and `drop_zone_b` lists in the socket assigns.
First, we'll identify which drop zone the drop occurred in. We'll get the correct atom for the event parameter `dropzoneId`. We search our drop zone atoms for one that matches the `dropzoneId`. We want to prevent the user from creating additional atoms and from corrupting our assigns.
-- CODE line-numbers language-elixir --
<!--
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: %{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}} = socket) do
drop_zone_atom =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.find(fn zone_atom -> to_string(zone_atom) == drop_zone_id end)
if drop_zone_atom === nil do
throw "invalid drop_zone_id"
end
{:noreply, socket}
end
-->
Next, we'll get the dragged item using the event parameter `draggedId`. We'll add a private function called `find_dragged` that accepts the `socket`'s assigns as its first parameter and the `draggedId` as its second parameter.
The function will combine the `pool`, `drop_zone_a`, and `drop_zone_b` lists and use `Enum.find` to return the draggable with an id equal to `draggedId`. In `handle_event` we'll destructure `assigns` from the third parameter, `socket`.
-- CODE line-numbers language-elixir --
<!--
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
{:noreply, socket}
end
defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do
pool ++ drop_zone_a ++ drop_zone_b
|> Enum.find(nil, fn draggable ->
draggable.id == dragged_id
end)
end
-->
Finally, we'll use `drop_zone_atom`, `dragged`, and the event parameter `draggableIndex` to update the `pool`, `drop_zone_a`, and `drop_zone_b` lists in `socket`'s assigns.
We'll reduce over the atoms for the lists using `Enum.reduce`.
We'll pass in the socket as the initial value for the accumulator and in the callback function, we'll destructure assigns from the current accumulator.
-- CODE line-numbers language-elixir --
<!--
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
# implementation goes here
end)
{:noreply, socket}
end
-->
Within the reduce callback function we'll update the list for the current atom. We'll write a private function `update_list` with two function signature: one for use when the list being updated is the list being dropped into and a second function for when the list being updated isn't the list being dropped into.
Within `update_list` we'll remove the dragged item by calling a private function `remove_dragged`. Within the function when the list being updated is the list being dropped into, we'll add the dragged item back into the list using `List.insert_at` and the `draggable_index` parameter from the event.
-- CODE line-numbers language-elixir --
<!--
defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
|> List.insert_at(draggable_index, dragged)
end
defp update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
end
defp remove_dragged(list, dragged_id) do
list
|> Enum.filter(fn draggable ->
draggable.id != dragged_id
end)
end
-->
Once we've called `update_list`, we'll call assign, passing in the `accumulator` socket, the zone atom, and the updated list. We'll assign the return from `Enum.reduce` to socket and return `{:noreply, socket}`.
-- CODE line-numbers language-elixir --
<!--
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket = # assign to socket
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
updated_list =
assigns
|> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)
accumulator
|> assign(zone_atom, updated_list)
end)
{:noreply, socket}
end
-->
The PageLive module (`page_live.ex`) will look like this when we're finished:
-- CODE line-numbers language-elixir --
<!--
defmodule DraggableWalkthruWeb.PageLive do
use DraggableWalkthruWeb, :live_view
@impl true
def mount(_params, _session, socket) do
# this is hardcoded but would come from a datasource
draggables = [
%{id: "drag-me-0", text: "Drag Me 0"},
%{id: "drag-me-1", text: "Drag Me 1"},
%{id: "drag-me-2", text: "Drag Me 2"},
%{id: "drag-me-3", text: "Drag Me 3"},
]
socket =
socket
|> assign(:pool, draggables)
|> assign(:drop_zone_a, [])
|> assign(:drop_zone_b, [])
{:ok, socket}
end
def handle_event("dropped", %{"draggedId" => dragged_id, "dropzoneId" => drop_zone_id,"draggableIndex" => draggable_index}, %{assigns: assigns} = socket) do
drop_zone_atom = drop_zone_id |> get_drop_zone_atom
dragged = find_dragged(assigns, dragged_id)
socket =
[:pool, :drop_zone_a, :drop_zone_b]
|> Enum.reduce(socket, fn zone_atom, %{assigns: assigns} = accumulator ->
updated_list =
assigns
|> update_list(zone_atom, dragged, drop_zone_atom, draggable_index)
accumulator
|> assign(zone_atom, updated_list)
end)
{:noreply, socket}
end
defp get_drop_zone_atom(drop_zone_id) do
case drop_zone_id in ["pool", "drop_zone_a", "drop_zone_b"] do
true ->
drop_zone_id |> String.to_existing_atom()
false ->
throw "invalid drop_zone_id"
end
end
defp find_dragged(%{pool: pool, drop_zone_a: drop_zone_a, drop_zone_b: drop_zone_b}, dragged_id) do
pool ++ drop_zone_a ++ drop_zone_b
|> Enum.find(nil, fn draggable ->
draggable.id == dragged_id
end)
end
def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom == drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
|> List.insert_at(draggable_index, dragged)
end
def update_list(assigns, list_atom, dragged, drop_zone_atom, draggable_index) when list_atom != drop_zone_atom do
assigns[list_atom]
|> remove_dragged(dragged.id)
end
def remove_dragged(list, dragged_id) do
list
|> Enum.filter(fn draggable ->
draggable.id != dragged_id
end)
end
end
-->
Now we can drag items into the various drop zones and we know the data is reaching the back end because the colors of the dropped items change!
The colors change because in our DropZoneComponent (`drop_zone_component.ex`) we use the color assign passed in as the background color of the rendered items.
When the assigns in the PageLive module (`page_live.ex`) are updated, those updates flow into our DropZoneComponent and the list items are rendered with the color assign.
-- CODE line-numbers language-elixir --
<!--
@impl true
def render(assigns) do
~L"""
<div class="dropzone grid gap-3 p-6 border-solid border-2 border-<%= @color %>-300 rounded-md my-6" id="<%= @drop_zone_id %>">
<%= @title %>
<%= for %{text: text, id: id} <- @draggables do %>
<div draggable="true" id="<%= id %>" class="draggable p-4 bg-<%= @color %>-700 text-white"><%= text %></div>
<% end %>
</div>
"""
end
-->
To learn more, check out the resources listed below. 👇🏻
You can grab the source code for this demo here:
Source code for Trello Clone by Pedro
JavaScript for Trello Clone by Pedro
Check out Birdseye, a tool to help you manage tasks from multiple sources in one simple view.
We're always looking for talented and open-minded people to help us bring new ideas to life. See what it's like to work at Headway and the opportunities we currently have available.
Learn more about careers at Headway
Download our free guide to begin implementing feedback loops in your organization.
By filling out this form, you agree to receive marketing emails from Headway.
In this free video series, learn how the best startup growth teams overcome common challenges and make impact.
In this free video series, learn how the best startup growth teams overcome common challenges and make impact.
In this free video series, learn proven tactics that will impact real business growth.
By filling out this form, you agree to receive marketing emails from Headway.
Dive deeper into the MoSCoW process to be more effective with your team.
By filling out this form, you agree to receive marketing emails from Headway.
In this free video series, learn the common mistakes we see and give yourself a greater chance for success.
By filling out this form, you agree to receive marketing emails from Headway.
Everything you need for a killer DIY audit on your product.
See all the ways we can help you grow through design, development, marketing, and more.
Level up your skills and develop a startup mindset.
Stay up to date with the latest content from the Headway team.