Tutorial

Building a datatable in Phoenix LiveView

liveviewdatatablesortingpagination

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.

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

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Learn More

Related Tutorials

Published 28 Jan - 2020 - Updated 01 May - 2020

Pagination with Phoenix LiveView

liveviewpaginationphoenix
Let say you have a long table that you want to paginate with Phoenix LiveView. In this tutorial, I have an existing table with 100 entries that I wi..

Published 02 Sep - 2020

Table sorting with Ecto and LiveView

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