Tutorial
Building a datatable in Phoenix LiveView
To display a static table on webpage that contains a lot of data is a pretty bad user experience. There are popular javascript libraries that implements sorting and pagination but in this tutorial, i will implement these datatable features with Phoenix LiveView.
The initial table looks like this. A table with customers that have several columns.
Sorting by column names
The first goal in the tutorial is to sort by columns by the column name. Also, I want to implement code that is reusable across different liveviews.
- Create a new helper module that contains the sorting logic
- Extent list_customers/1 function so it can take a second params argument
- Modify the CustomerLive Index to pass along the params to
The params that I will be working with here will have the shape of:
%{"sort_direction" => "asc", "sort_field" => "name"}
Step 1 - DataTable Module
In the data table module, I first want to add the sort/1 functions. I need to examine both field and direction and return a tuple with sort order and field. That tuble can then be passed in as a second argument in order_by/3 when doing the query.
# lib/tutorial_web/live/data_table.ex
defmodule TutorialWeb.Live.DataTable do
import Phoenix.LiveView.Helpers
def sort(%{"sort_field" => field, "sort_direction" => direction}) when direction in ~w(asc desc) do
{String.to_atom(direction), String.to_existing_atom(field)}
end
def sort(_other) do
{:asc, :id}
end
end
NOTE that I use String.to_existing_atom(field)
since I want to avoid that we dynamically create atoms based on user input.
Next step in the data table module is to add the table_link/3. That means that in the table-markup, inside the th-tag, I can add <%= table_link(@params, "Name", :name) %>
So, add these functions in the module:
# lib/tutorial_web/live/data_table.ex
def table_link(params, text, field) do
direction = params["sort_direction"]
opts =
if params["sort_field"] == to_string(field) do
[
sort_field: field,
sort_direction: reverse(direction)
]
else
[
sort_field: field,
sort_direction: "desc"
]
end
live_patch text, to: "?" <> querystring(params, opts), class: "link flex items-center"
end
defp querystring(params, opts \\ %{}) do
params = params |> Plug.Conn.Query.encode() |> URI.decode_query()
opts = %{
"page" => opts[:page], # For the pagination
"sort_field" => opts[:sort_field] || params["sort_field"] || nil,
"sort_direction" => opts[:sort_direction] || params["sort_direction"] || nil
}
params
|> Map.merge(opts)
|> Enum.filter(fn {_, v} -> v != nil end)
|> Enum.into(%{})
|> URI.encode_query()
end
defp reverse("desc"), do: "asc"
defp reverse(_), do: "desc"
Step 2 - Implement the sorting
To make sorting work, I need to add list_customers/1 that take the params and use the sort/1 function to get the sort sirection and sort field.
# lib/tutorial/customers.ex
import TutorialWeb.Live.DataTable, only: [sort: 1]
def list_customers(params) do
from(
c in Customer,
order_by: ^sort(params)
) |> Repo.all()
end
Step 3 - Update the live view
Last step here is to update the live view that is responsible to render the table. I need move the initial loading of customers (list_customers/0) in the mount-callback and instead move it to handle_params and call it with the params. So the relevant code now looks like this:
defmodule TutorialWeb.CustomerLive.Index do
use TutorialWeb, :live_view
import TutorialWeb.Live.DataTable
def mount(_params, _session, socket) do
{:ok, socket}
end
def handle_params(params, _url, socket) do
{
:noreply,
socket
|> assign(:params, params)
|> assign(:customers, list_customers(params))
|> apply_action(socket.assigns.live_action, params)
}
end
defp list_customers(params) do
Customers.list_customers(params)
end
end
NOTE That I used live_patch
under the hood in the DataTable module. That means that it wont be a full page-refresh but it will directly call the handle_params/3
in the LiveView. That means that it has a much smaller impact and the UI feels much more responsive.
Now I just need to implement the table_link inside the <th>
tags.
<!-- lib/tutorial_web/live/customer_live/index.html.heex -->
<th><%= table_link(@params, "Name", :name) %></th>
<th><%= table_link(@params, "Address", :address) %></th>
<th><%= table_link(@params, "Zip", :zip) %></th>
<th><%= table_link(@params, "City", :city) %> </th>
<th><%= table_link(@params, "Phone", :phone) %></th>
<th><%= table_link(@params, "Longitude", :longitude) %></th>
<th><%= table_link(@params, "Latitude", :latitude) %></th>
And now, if I test it in the browser, it should work.
Pagination with Scrivener
The pagination library that I will use for this tutorial is Scrivener and more specifically ScrivenerEcto. I need to add it to the list of dependencies:
# mix.exs
defp deps do
[
{:scrivener_ecto, "~> 2.0"},
]
end
And run the command
mix deps.get
To get started, I need to change the Repo.all/1
to Scrivener.paginate/2
in the list_customers/1
. And I also set a default page_size here.
# lib/tutorial/customers.ex
@pagination [page_size: 10]
def list_customers(params) do
from(
c in Customer,
order_by: ^sort(params)
)
|> Scrivener.paginate(Scrivener.Config.new(Repo, @pagination, params))
end
Now, instead if a list, the function returns a Scrivener.Page struct with the entries and some info needed for pagination. The response will look something like this:
%Scrivener.Page{
entries: [
%Tutorial.Customers.Customer{
address: "Adele Spring 47095",
city: "White Plains",
latitude: 41.033985,
longitude: -73.762909,
name: "O'Keefe-Kshlerin",
phone: "6479788794",
zip: "09483"
},
],
page_number: 1,
page_size: 10,
total_entries: 101,
total_pages: 11
}
Note that the page still works if I refresh it. The Scrivener.Page module has implemented a behaviour that makes it possible to loop over the entries just like it was a list, which is pretty neat.
Next, I need to create the pagination component. The component will loop through the pages from 1 to in this case 11, since it is 11 pages. I could use the @distance
variable to set the number of page-links that will be visible at the same time.
There are some helper methods as well in the code below that helps me figure out what links to show, in regards to @distance
and how to behave on the first and last page.
# lib/tutorial_web/live/pagination_component.ex
defmodule TutorialWeb.Live.PaginationComponent do
use TutorialWeb, :live_component
import TutorialWeb.Live.DataTable
@distance 5
def update(assigns, socket) do
{
:ok,
socket
|> assign(assigns)
|> assign(pagination_assigns(assigns[:pagination_data]))
}
end
def render(assigns) do
~H"""
<div id={assigns[:id] || "pagination"} class="flex justify-center my-2">
<%= if @total_pages > 1 do %>
<div class="btn-group">
<%= prev_link(@params, @page_number) %>
<%= for num <- start_page(@page_number)..end_page(@page_number, @total_pages) do %>
<%= live_patch num, to: "?#{querystring(@params, page: num)}", class: "btn btn-link #{if @page_number == num, do: "btn-active", else: ""}" %>
<% end %>
<%= next_link(@params, @page_number, @total_pages) %>
</div>
<% end %>
</div>
"""
end
defp pagination_assigns(%Scrivener.Page{} = pagination) do
[
page_number: pagination.page_number,
page_size: pagination.page_size,
total_entries: pagination.total_entries,
total_pages: pagination.total_pages,
]
end
def prev_link(conn, current_page) do
if current_page != 1 do
live_patch "Prev", to: "?" <> querystring(conn, page: current_page - 1), class: "btn btn-link"
else
live_patch "Prev", to: "#", class: "btn btn-link btn-disabled"
end
end
def next_link(conn, current_page, num_pages) do
if current_page != num_pages do
live_patch "Next", to: "?" <> querystring(conn, page: current_page + 1), class: "btn btn-link"
else
live_patch "Next", to: "#", class: "btn btn-link btn-disabled"
end
end
def start_page(current_page) when current_page - @distance <= 0, do: 1
def start_page(current_page), do: current_page - @distance
def end_page(current_page, 0), do: current_page
def end_page(current_page, total)
when current_page <= @distance and @distance * 2 <= total do
@distance * 2
end
def end_page(current_page, total) when current_page + @distance >= total do
total
end
def end_page(current_page, _total), do: current_page + @distance - 1
end
NOTE I use live_patch
here as well for the same reason that I did above. This prevents an entire page reload and just hits the handle_params
.
<!-- lib/tutorial_web/live/customer_live/index.html.heex -->
<!-- BELOW THE TABLE -->
<%= live_component TutorialWeb.Live.PaginationComponent, params: @params, pagination_data: @customers %>
Result
Now that I test this in the browser the pagination should work as expected.
Note that the pagination preserves any sorting that I already have applied.
Tag Cloud
Related Tutorials

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