Tutorial
Add bulk actions in Phoenix LiveView
In this tutorial, I have a list of breweries. One common feature for a table of records is to perform some sort of bulk actions. As an example in this tutorial, I want to be able to toggle all records with a checkbox in the table header and perform some sort of bulk action on the selected ones. Also, I want to be able to do it in a reusable way.
This tutorial is updated to account for Phoenix 1.7 and the new LiveView Streams feature. The difference with streams, is that they are not kept in the LiveView state, but is more a fire and forget. Since I already have a LiveView page setup that displays a list of breweries.
STEP 1 - Add toggle_ids to LiveView
The first thing I need to do is to setup a way to store the records that I want to toggle. The toggle records will be a list of record id:s so it make sense to start of with mounting the live view with an empty list of ids. So, I will assign the toggle_ids
as an empty list to the socket. When I later perform a bulk action, iI will pass in these ids and only perform it on them.
# lib/boilerplate_web/live/brewery_live/index.ex
def mount(_params, _session, socket) do
{
:ok,
socket
|> stream(:breweries, Breweries.list_breweries())
|> assign(:toggle_ids, [])
}
end
STEP 2 - Modify the table component
I want to modify the table component in the CoreComponents
module. I want to instruct that a column should have a toggle_all
attribute.
# lib/boilerplate_web/components/core_components.ex
slot :col, required: true do
attr :label, :string
attr :toggle_all, :boolean # Add
end
In the table head, filter the cols on if they have the slot attribute :toggle_all
# lib/boilerplate_web/components/core_components.ex
<thead class="...">
<tr>
<th :for={col <- @col} :if={col[:toggle_all]} class="...">
<.input
id={col[:toggle_all_id] || "toggle-all"}
type="checkbox"
name="toggle-all"
phx-click={JS.dispatch("toggle-all")}
phx-update="ignore"
phx-hook="ToggleAll"
/>
</th>
<th :for={col <- @col} :if={!col[:toggle_all]} class="..."><%= col[:label] %></th>
<th class="..."><span class="sr-only"><%= gettext("Actions") %></span></th>
</tr>
</thead>
STEP 3 - Add JavaScript Hook for Toggle All
I am using a LiveView javascript hook to capture the clicks and pass them to the LiveView process. The reason to use javascript is because when using LiveView streams, the LiveView process does not know which breweries are on the page currently. So the javascript will just iterate over the relevant DOM nodes and collect the ids and send to the LiveView.
// assets/js/hooks/toggle_all.js
Hooks.ToggleAll = {
mounted() {
const table = this.el.closest('table')
this.el.addEventListener("toggle-all", event => {
const checkboxes = table.querySelectorAll('input[phx-value-toggle-id]')
let ids = []
if (event.target.checked) {
checkboxes.forEach(el => {
el.checked = true
ids.push(el.getAttribute('phx-value-toggle-id'))
})
this.pushEvent("toggle-all", {ids: ids})
} else {
checkboxes.forEach(el => el.checked = false)
this.pushEvent("toggle-all", {ids: []})
}
})
this.handleEvent("reset-toggle", _event => this.el.checked = false)
}
}
NOTE: When it iterates, it also uses javascript to toggle the checkboxes so the user interface is in sync.
This now results in a list of ids
being sent, and the only thing I need to do in the LiveView is to assigns the ids to the toggle_ids
attribute.
# lib/boilerplate_web/live/brewery_live/index.ex
def handle_event("toggle-all", %{"ids" => ids}, socket) do
{:noreply, assign(socket, :toggle_ids, ids)}
end
STEP 4 - Add a checkbox in each table row
Next step is to open the LiveView template. I want to add a a new column that contains a checkbox for every row. Note that the checkbox is not wrapped in a form, but uses the the phx-click
event to send the event to the LiveView. For this to work, I need to pass in the record id in the phx-value-toggle-id
.
<!-- lib/boilerplate_web/live/brewery_live/index.html.heex -->
<:col :let={{id, brewery}} toggle_all={true}>
<.input
type="checkbox"
id={"toggle_#{id}"}
name="toggle"
phx-click="toggle"
phx-value-toggle-id={brewery.id}
/>
</:col>
NOTE: I use the toggle_all={true}
attribute on the :col
-slot that I setup above. It will tell the column that it should have a toggle-all checkbox above in the table head.
Back in the LiveView, I need to handle the click event on the checkbox. Here, I am looking the the list of toggle_ids. If the id already exists in the list, I will remove it, otherwise I will add it to the list.
# lib/boilerplate_web/live/brewery_live/index.ex
def handle_event("toggle", %{"toggle-id" => id}, socket) do
toggle_ids = socket.assigns.toggle_ids
toggle_ids =
if (id in toggle_ids) do
Enum.reject(toggle_ids, & &1 == id)
else
[id|toggle_ids]
end
{:noreply, assign(socket, :toggle_ids, toggle_ids)}
end
Step 5 - Update and delete toggled record
Just toggling records on and off is not super useful. I want to be able to bulk update an attribute and also bulk delete records. The scenario is that I want to bulk update phone numbers all at once so I need to add a form for that.
In the template, I add the form below the table:
<div class="mt-4 pt-4 flex items-center space-x-2 border-t border-gray-200 dark:border-gray-700">
<.button type="button" class="mt-2" phx-click="bulk-delete">Delete checked</.button>
<div class="text-sm font-bold text-gray-500 dark:text-gray-400 px-2 mt-2">OR</div>
<.form for={to_form(%{}, as: "brewery")} :let={f} phx-submit="bulk-update" class="flex items-center space-x-2">
<.input field={f[:phone_number]} placeholder="Phone" label=""/>
<.button type="submit" class="mt-2">Update checked</.button>
</.form>
</div>
NOTE: that I also add the delete button that I will add code for below.
In the bulk-trigger event, I must first check if the price form is valid. If its valid, I want to apply the changes on all toggles customers.
# lib/boilerplate_web/live/brewery_live/index.ex
def handle_event("bulk-update", %{"brewery" => brewery_params}, socket) do
toggle_ids = socket.assigns.toggle_ids
breweries_to_be_updated =
Breweries.list_breweries()
|> Enum.filter(& &1.id in toggle_ids)
socket =
breweries_to_be_updated
|> Enum.reduce(socket, fn brewery, memo ->
case Breweries.update_brewery(brewery, brewery_params) do
{:ok, brewery} -> stream_insert(memo, :breweries, brewery)
_ -> memo
end
end)
{
:noreply,
socket
|> assign(:toggle_ids, [])
|> push_event("reset-toggle", %{})
}
end
This should now work to bulk update the phone numbers. But what about bulk delete? I already added the button with phx-click="bulk-delete"
. Now its just a matter of handling this in the backend:
# lib/boilerplate_web/live/brewery_live/index.ex
def handle_event("bulk-delete", %{}, socket) do
toggle_ids = socket.assigns.toggle_ids
breweries_to_be_deleted =
Breweries.list_breweries()
|> Enum.filter(& &1.id in toggle_ids)
socket =
breweries_to_be_deleted
|> Enum.reduce(socket, fn brewery, acc ->
Breweries.delete_brewery(brewery)
stream_delete(acc, :breweries, brewery)
end)
{
:noreply,
socket
|> assign(:toggle_ids, [])
|> push_event("reset-toggle", %{})
}
end
NOTE that I stream delete the customers that should be deleted.
Now, it should I be able to bulk delete all the checked records.
In this tutorial, I have a list of breweries. One common feature for a table of records is to perform some sort of bulk actions. As an example in this tutorial, I want to be able to toggle all records with a checkbox in the table header and perform some sort of bulk action on the selected ones. Also, I want to be able to do it in a reusable way.