Tutorial
Building a datatable in Phoenix LiveView
This post was updated 27 Mar
To display a static table on a webpage that contains a lot of data is a pretty bad user experience. There are popular JavaScript libraries that implement 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
- Extend list_customers/1 function so it can take a second params argument
- Modify the CustomerLive Index to pass along the params
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 tuple can then be passed in as a second argument in order_by/3 when doing the query.
# lib/my_app_web/live/data_table.ex
defmodule MyAppWeb.Live.DataTable do
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 a link for sorting.
So, add these functions in the module:
Step 2 - Implement the sorting
To make sorting work, I need to add list_customers/1 that takes the params and use the sort/1 function to get the sort direction and sort field.
# lib/my_app/customers.ex
import MyAppWeb.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 for rendering the table. I need to move the initial loading of customers (list_customers/0) from the mount callback and instead move it to handle_params and call it with the params. So the relevant code now looks like this:
NOTE That I used patch links under the hood in the DataTable module. That means that it won’t 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/my_app_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/my_app/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 of 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: [
%MyApp.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 help me figure out what links to show, in regards to @distance and how to behave on the first and last page.
# lib/my_app_web/live/pagination_component.ex
defmodule MyAppWeb.Live.PaginationComponent do
use MyAppWeb, :live_component
import MyAppWeb.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 %>
<.link patch={"?#{querystring(@params, page: num)}"} class={"btn btn-link #{if @page_number == num, do: "btn-active", else: ""}"}>
{num}
</.link>
<% 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
assigns = %{querystring: querystring(conn, page: current_page - 1)}
~H"""
<.link patch={"?" <> @querystring} class="btn btn-link">Prev</.link>
"""
else
assigns = %{}
~H"""
<.link patch="#" class="btn btn-link btn-disabled">Prev</.link>
"""
end
end
def next_link(conn, current_page, num_pages) do
if current_page != num_pages do
assigns = %{querystring: querystring(conn, page: current_page + 1)}
~H"""
<.link patch={"?" <> @querystring} class="btn btn-link">Next</.link>
"""
else
assigns = %{}
~H"""
<.link patch="#" class="btn btn-link btn-disabled">Next</.link>
"""
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 <.link 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/my_app_web/live/customer_live/index.html.heex -->
<!-- BELOW THE TABLE -->
<.live_component module={MyAppWeb.Live.PaginationComponent} id="pagination" 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.