Tutorial

Add bulk actions in Phoenix LiveView

This post was updated 27 Mar

Phoenix 1.7 table stream liveview bulk

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, 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.8 and LiveView Streams. The difference with streams is that they are not kept in the LiveView state, but are more fire and forget. 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 ids so it makes sense to start off 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, I will pass in these ids and only perform it on them.

# lib/my_app_web/live/brewery_live/index.ex
def mount(_params, _session, socket) do
{:ok,
socket
|> stream(:breweries, Breweries.list_breweries())
|> assign(:toggle_ids, [])
|> assign(:bulk_form, to_form(%{}, as: "brewery"))}
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/my_app_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/my_app_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, collect the ids, and send them 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 assign the ids to the toggle_ids attribute.

# lib/my_app_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 new column that contains a checkbox for every row. Note that the checkbox is not wrapped in a form, but uses 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/my_app_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 at 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/my_app_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 records

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={@bulk_form} id="bulk-update-form" phx-submit="bulk-update" class="flex items-center space-x-2">
<.input field={@bulk_form[:phone_number]} placeholder="Phone" label=""/>
<.button type="submit" class="mt-2">Update checked</.button>
</.form>
</div>

NOTE: I also add the delete button that I will add code for below.

In the bulk-update event, I want to apply the changes on all toggled breweries.

# lib/my_app_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 =
Enum.reduce(breweries_to_be_updated, 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 it’s just a matter of handling this in the backend:

# lib/my_app_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 =
Enum.reduce(breweries_to_be_deleted, 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 breweries that should be deleted.

Now, I should be able to bulk delete all the checked records.

Related Tutorials

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..

Published 03 Apr - 2024

Set session values from LiveView

Working with session data can significantly improve the feel of web applications, making interactions feel more connected and dynamic. However, Phoe..