Tutorial

View on Github

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.

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Learn More

Related Tutorials

Published 13 May

How to create a custom select with Alpine JS and Phoenix LiveView

formsalpinejsliveview

In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoeni..

Published 04 May

How to combine Phoenix LiveView with Alpine.js

alpinejsliveviewphoenixtailwind

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..