Tutorial
Add bulk actions in Phoenix LiveView
This post was updated 27 Mar
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.