Tutorial

Add bulk actions in Phoenix LiveView

liveviewtablebulk

In this tutorial, I have a list of products. 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 action on the selected ones.

STEP 1 - Add toggle all feature

The toggle records will be a list of product 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.

# lib/shop_test_web/live/product_live/index.ex
socket
|> assign(:toggle_ids, [])

In the view, I want to add the toggle-all checkbox in the table header and one checkbox for every row. The idea is that I should be able to either toggle a single product id or toggle the entire list. So, I will start with adding the main checkbox in the header.

I am adding a phx-click="toggle-all" on the checkbox to be able to send that event to the LiveView.

# lib/shop_test_web/live/product_live/index.html.leex

<!-- In the <thead> -->
<th>
	<input type="checkbox" name="toggle-all" id="toggle-all" phx-click="toggle-all" phx-update="ignore" />
</th>

NOTE that I have a phx-update="ignore" so the checkbox keeps state after the view is updated.

Then for every product row, I insert a new td with checkbox. I am also adding a little helper here to figure out if the checkbox should be checked or not. If they are in the list of @toggle_ids, they should be checked.

# lib/shop_test_web/live/product_live/index.html.leex
<% checked = if (product.id in @toggle_ids), do: "checked", else: "" %>

<tr id="product-<%= product.id %>">
  <td>
    <input type="checkbox" name="toggle"
      phx-click="toggle" phx-value-toggle-id="<%= product.id %>" <%= checked %> />
  </td>
  <!-- The other table cells here -->
</tr>

Since the checkboxes are there, and I have added the phx-click to trigger events, I need to add those event handlers in the LiveView.

The code I need for adding the functionality of toggling and keeping track of the toggled ids looks like:

# lib/shop_test_web/live/product_live/index.ex

def handle_event("toggle-all", %{"value" => "on"}, socket) do
  product_ids = socket.assigns.products |> Enum.map(& &1.id)
  {:noreply, assign(socket, :toggle_ids, product_ids)}
end

def handle_event("toggle-all", %{}, socket) do
  {:noreply, assign(socket, :toggle_ids, [])}
end

def handle_event("toggle", %{"toggle-id" => id}, socket) do
  id = String.to_integer(id)
  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 2 - Update and delete toggled record

Just toggling records on and off is not super useful. I want to be able to bulk update prices and also bulk delete records. The scenario is that I want to bulk update prices all at once so I need to add a form for that.

In the LiveView I need to add a changeset. Its an anonymous changeset and I add it directly in the LiveView. I only want to be able to do bulk updates if the form is valid to I need to make sure that the price is present.

# lib/shop_test_web/live/product_live/index.ex

def mount(_params, _session, socket) do
  # other code
  |> assign(:price_changeset, price_changeset())
end

# other code

defp price_changeset(attrs \\ %{}) do
  cast(
    {%{}, %{price: :decimal}},
    attrs,
    [:price]
  )
  |> validate_required([:price])
end

In the view, I add the form in a table footer:

<tfoot>
  <tr>
    <td colspan="5">
      <div class="flex items-center">
        <div class="flex-initial">
          <button type="button" class="btn btn-link btn-sm" phx-click="delete">Delete checked</button>
        </div>
        <div class="ml-4">
          <%= f = form_for @price_changeset, "#", phx_change: "bulk-action", as: "product" %>
            <%= text_input f, :price, class: "tag-input tag-input-sm", phx_debounce: 500, placeholder: "Price" %>
          </form>
        </div>
        <div class="ml-4">
          <button class="btn btn-sm btn-light" phx-click="bulk-trigger">Update checked</button>
        </div>
      </div>
    </td>
  </tr>
</tfoot>

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

As soon as a update the input field in the price field, I also do the live validation. In this tutorial, I dont do a validation error though.

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

# lib/shop_test_web/live/product_live/index.ex

def handle_event("bulk-action", %{"product" => params}, socket) do
  {:noreply,  assign(socket, :price_changeset, price_changeset(params))}
end

def handle_event("bulk-trigger", _, socket) do
  changeset = socket.assigns.price_changeset
  toggle_ids = socket.assigns.toggle_ids

  products =
    if changeset.valid? do
      socket.assigns.products
      |> Enum.map(fn product ->
        # Check if the product should update
        if product.id in toggle_ids do
          {:ok, product} = Products.update_product(product, changeset.changes)
          product
        else
          product
        end
      end)
    else
      socket.assigns.products
    end

  {
    :noreply,
    socket
    |> assign(:price_changeset, price_changeset())
    |> assign(:products, products)
    |> assign(:toggle_ids, [])
  }
end

@impl true
def handle_info(_, socket), do: {:noreply, socket}

This should now work to bulk update the prices. But what about bulk delete? I already added the button with phx-click="delete". Now its just a matter of handling this in the backend:

# lib/shop_test_web/live/product_live/index.ex

def handle_event("delete", %{}, socket) do
  toggle_ids = socket.assigns.toggle_ids
  products = Enum.reject(socket.assigns.products, & &1.id in toggle_ids)
  products_to_be_deleted = Enum.filter(socket.assigns.products, & &1.id in toggle_ids)

  Task.async(fn ->
    products_to_be_deleted
    |> Enum.each(& Products.delete_product/1)
  end)

  {
    :noreply,
    socket
    |> assign(:toggle_ids, [])
    |> assign(:products, products)
  }
end

NOTE that I opted for deleting the products async to speed up the responsiveness of the page a little.

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


Related Tutorials

Published 02 May - 2022

LiveView and page specific javascript

In most applications you have some page specific javascript that is only used in one or just a few pages. The solution for this is to either setup..

Published 25 Nov - 2022
Updated 29 Nov - 2022
Phoenix 1.7

Sortable lists with Phoenix LiveView and SortableJS

A very common user interface pattern on the web is to have sortable elements. In this tutorial I will go through how to accomplish sortable lists wi..