Tutorial
Table sorting with Ecto and LiveView
A very common or even mandatory feature in e-commerce stores is the ability to sort a list of products by attributes. This is easy enough and a good fit for Phoenix LiveView. In this tutorial, I will build on an existing page that displays a list of products and implement sorting on product name and prices.
STEP 1 - Setup dynamic sorting in Ecto
The Ecto documentation has a section on filtering and sorting on dynamic attributes. I want to change how I query all products from:
# lib/shop_test/products.ex
def list_products do
Repo.all(Product)
end
To make it possible to add the params from the controller or in this case the LiveView.
# lib/shop_test/products.ex
def list_products(params \\ %{}) do
from(
p in Product,
order_by: ^filter_order_by(params["order_by"])
)
|> Repo.all()
end
defp filter_order_by("name_desc"), do: [desc: :name]
defp filter_order_by("name_asc"), do: [asc: :name]
defp filter_order_by("price_desc"), do: [desc: :price]
defp filter_order_by("price_asc"), do: [asc: :price]
defp filter_order_by(_), do: []
NOTE that I allow-list the possible combinations so I don't end up in a situation where a user can attempt to get the system behave in an unwanted way.
STEP 2 - Add the form to the LiveView
I want to have a simple select field where a user can specify the sorting. For this example is name or price in ascending or descending order. Even if it's strictly not needed, I have started to use adhoc schemaless changesets that I specify directly in the LiveView.
I also want to setup so that I pass in the params listproducts
that I changed above. So, I will add the changeset and add the new code to handleparams
callback function.
# lib/shop_test_web/live/product_live/index.ex
defmodule ShopTestWeb.ProductLive.Index do
use ShopTestWeb, :live_view
import Ecto.Changeset
alias ShopTest.Products
@impl true
def handle_params(params, _url, socket) do
{
:noreply,
socket
|> assign(:order_and_filter_changeset, order_and_filter_changeset(params))
|> assign(:products, Products.list_products(params))
}
end
# OTHER CODE ...
defp order_and_filter_changeset(attrs \\ %{}) do
cast(
{%{}, %{order_by: :string}},
attrs,
[:order_by]
)
end
end
Then I can add the form to the template:
<%= f = form_for @order_and_filter_changeset, "#", phx_change: "order_and_filter", as: "order_and_filter" %>
<% options = ["Order by": "", "Name": "name_asc", "Name (Desc)": "name_desc",
"Price": "price_asc", "Price (Desc)": "price_desc"] %>
<%= select f, :order_by, options, class: "tag-select tag-select-sm" %>
</form>
If you are wondering why I actually use a changeset here is because since I don't trust params coming from the big scary Internet, I can validate before I actually use the input.
To tie this up, I need to handle the on change event in the LiveView. So basically I do this by:
- cast the params in the changeset
- If it's valid, use LiveView's pushpatch to the same route, but with the added params. This will invoke handleparams again and I will do the new database query.
- If it's not valid, do nothing.
# lib/shop_test_web/live/product_live/index.ex
defmodule ShopTestWeb.ProductLive.Index do
# OTHER CODE ..
@impl true
def handle_event("order_and_filter", %{"order_and_filter" => order_and_filter_params}, socket) do
order_and_filter_params
|> order_and_filter_changeset()
|> case do
%{valid?: true} = changeset ->
{
:noreply,
socket
|> push_patch(to: Routes.product_index_path(socket, :index, apply_changes(changeset)))
}
_ ->
{:noreply, socket}
end
end
end
Note I am calling the changeset orderandfilterchangeset
because it will fairly trivial to allow other params like maxprice and min_price and have a second form that will have range sliders. But I will leave that for another day.