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<i>field" => field, "sort</i>direction" => direction}) when direction in ~w(asc desc) do
{String.to<i>atom(direction), String.to</i>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 tablelink/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:
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 (listcustomers/0) in the mount-callback and instead move it to handleparams and call it with the params. So the relevant code now looks like this:
NOTE That I used
And now, if I test it in the browser, it should work.
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:
And run the command
To get started, I need to change the
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:
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
There are some helper methods as well in the code below that helps me figure out what links to show, in regards to
NOTE I use
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.
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..
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 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>
Pagination with Scrivener
# mix.exs
defp deps do
[
{:scrivener_ecto, "~> 2.0"},
]
end
mix deps.get
Repo.all/1
to Scrivener.paginate/2
in the list_customers/1
. And I also set a default pagesize 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
%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
}
@distance
variable to set the number of page-links that will be visible at the same time.
@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<i>assigns(assigns[:pagination</i>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<i>link(@params, @page</i>number) %>
<%= for num <- start<i>page(@page</i>number)..end<i>page(@page</i>number, @total_pages) do %>
<%= live<i>patch num, to: "?#{querystring(@params, page: num)}", class: "btn btn-link #{if @page</i>number == num, do: "btn-active", else: ""}" %>
<% end %>
<%= next<i>link(@params, @page</i>number, @total_pages) %>
</div>
<% end %>
</div>
"""
end defp pagination_assigns(%Scrivener.Page{} = pagination) do
[
page<i>number: pagination.page</i>number,
page<i>size: pagination.page</i>size,
total<i>entries: pagination.total</i>entries,
total<i>pages: pagination.total</i>pages,
]
end def prev<i>link(conn, current</i>page) do
if current_page != 1 do
live<i>patch "Prev", to: "?" <> querystring(conn, page: current</i>page - 1), class: "btn btn-link"
else
live_patch "Prev", to: "#", class: "btn btn-link btn-disabled"
end
end def next<i>link(conn, current</i>page, num_pages) do
if current<i>page != num</i>pages do
live<i>patch "Next", to: "?" <> querystring(conn, page: current</i>page + 1), class: "btn btn-link"
else
live_patch "Next", to: "#", class: "btn btn-link btn-disabled"
end
end def start<i>page(current</i>page) when current_page - @distance <= 0, do: 1
def start<i>page(current</i>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
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
Related Tutorials
Table sorting with Ecto and LiveView