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.

  1. Create a new helper module that contains the sorting logic
  2. Extent list_customers/1 function so it can take a second params argument
  3. 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.

`elixir # lib/tutorialweb/live/datatable.ex defmodule TutorialWeb.Live.DataTable do import Phoenix.LiveView.Helpers

def sort(%{"sortfield" => field, "sortdirection" => direction}) when direction in ~w(asc desc) do {String.toatom(direction), String.toexisting_atom(field)} end

def sort(_other) do {:asc, :id} end end `

NOTE that I use String.toexistingatom(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 <%= tablelink(@params, "Name", :name) %>

So, add these functions in the module:

`elixir # lib/tutorialweb/live/datatable.ex def table_link(params, text, field) do direction = params["sort_direction"]

opts = if params["sortfield"] == tostring(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 "sortfield" => opts[:sortfield] || params["sort_field"] || nil, "sortdirection" => opts[:sortdirection] || 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.

`elixir # 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:

`elixir defmodule TutorialWeb.CustomerLive.Index do use TutorialWeb, :live_view import TutorialWeb.Live.DataTable

def mount(params, session, socket) do {:ok, socket} end

def handleparams(params, url, socket) do { :noreply, socket |> assign(:params, params) |> assign(:customers, list_customers(params)) |> applyaction(socket.assigns.liveaction, params) } end

defp list_customers(params) do Customers.list_customers(params) end end


NOTE That I used livepatch under the hood in the DataTable module. That means that it wont be a full page-refresh but it will directly call the handleparams/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>

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"},

And run the command

mix deps.get

To get started, I need to change the Repo.all/1 to Scrivener.paginate/2 in the listcustomers/1. And I also set a default pagesize here.

`elixir # 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:

  entries: [
      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.

`elixir # lib/tutorialweb/live/paginationcomponent.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(paginationassigns(assigns[:paginationdata])) } end

def render(assigns) do ~H"""

<%= if @total_pages > 1 do %>
<%= prevlink(@params, @pagenumber) %> <%= for num <- startpage(@pagenumber)..endpage(@pagenumber, @total_pages) do %> <%= livepatch num, to: "?#{querystring(@params, page: num)}", class: "btn btn-link #{if @pagenumber == num, do: "btn-active", else: ""}" %> <% end %> <%= nextlink(@params, @pagenumber, @total_pages) %>
<% end %>
""" end

defp pagination_assigns(%Scrivener.Page{} = pagination) do [ pagenumber: pagination.pagenumber, pagesize: pagination.pagesize, totalentries: pagination.totalentries, totalpages: pagination.totalpages, ] end

def prevlink(conn, currentpage) do if current_page != 1 do livepatch "Prev", to: "?" <> querystring(conn, page: currentpage - 1), class: "btn btn-link" else live_patch "Prev", to: "#", class: "btn btn-link btn-disabled" end end

def nextlink(conn, currentpage, num_pages) do if currentpage != numpages do livepatch "Next", to: "?" <> querystring(conn, page: currentpage + 1), class: "btn btn-link" else live_patch "Next", to: "#", class: "btn btn-link btn-disabled" end end

def startpage(currentpage) when current_page - @distance <= 0, do: 1 def startpage(currentpage), do: current_page - @distance

def endpage(currentpage, 0), do: current_page def endpage(currentpage, total) when current_page <= @distance and @distance * 2 <= total do @distance * 2 end def endpage(currentpage, total) when current_page + @distance >= total do total end def endpage(currentpage, total), do: currentpage + @distance - 1 end `

NOTE I use livepatch here as well for the same reason that I did above. This prevents an entire page reload and just hits the handleparams.


<%= livecomponent TutorialWeb.Live.PaginationComponent, params: @params, paginationdata: @customers %> `


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.

Related Tutorials

Published 02 Sep - 2020

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

Published 25 Nov - 2022
Updated 29 Nov - 2022
Phoenix 1.7

Sortable lists with Phoenix LiveView and SortableJS

A very common user interface pattern on the web is to have sortable elements. In this tutorial I will go through how to accomplish sortable lists wi..