Tutorial

Add bulk actions in Phoenix LiveView

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 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.

Related Tutorials

Published 31 May - 2023
Phoenix 1.7

CSV Import file upload with preview in LiveView

In this tutorial, I will go through how to upload and import a CSV file with Phoenix LiveView and, show how easy it is to preview the imports before..

Published 03 Apr

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..