How I added pagination using live_patch for my LiveView site using Dissolver

Jun 21, 2020

I recently hijacked a semi-popular phoenix library called Kerosene. I did this because of a few reasons. Primarily because I had a few small features I wanted to add but I didn’t expect there to be much of a response seeing as there has been no development in over 2 years. The other reasons will become more clear as we go on.

So I forked Kerosene and decided I wanted to rebrand it as Dissolverhttps://github.com/MorphicPro/dissolver If you want to follow along I would recommend installing and configuring dissolved first.

Dissolver solved a few problems I had in Kerosene, specifically returning an Ecto query instead of abstracting away etco, that way I could do complex things like paginating on an Ecto association. I call this a lazy paginator. Which I will show a little later. I also wanted a way to simply add my own HTML theme. This is how I added LiveView support via live_patch/3 without doing a whole lot.

Dissolver is an Offset style paginator and thus it suffers the shortcomings of an Offset style paginator. That said it’s simple to work with and understand. Just realize that at scale this may not be the best solution for your large volume of fast-moving records. For that, I would recommend a cursor style paginator. Ok, with the formalities out of the way this is how I added pagination for this very site. (Note: see how pagination works in snaps and also note how it works for tags too)

One of the cool things that Dissolver inherited from Kerosene is theming. You start by picking one of our CSS themes like Bootstrap 4 or Foundation (see the full list here). Soon, Dissolver will take it a step farther by allowing you to quickly copy a base theme and customize it locally for your app via a simple generator. I find more times then not you will need to customize this theme and the idea is to keep that barrier low. At the moment we can use our themes, though we have to do it by hand. A good starting point would be to read over the theme relating to the one you are using and copy it over to your module in your app and configure your app to point to that module.

You should note that none of Dissolver’s themes at the moment inherently support LiveView out of the box, but dissolver does not get in your way to provide a theme that does. In this example, I will use my own theme to make use of Tailwind CSS and LiveView’s live_patch/3

I’m pointing my configuration of Dissolver to use the theme below. https://github.com/MorphicPro/morphic.pro/blob/master/config/config.exs#L31

# config/config.exs
config :dissolver,
  theme: MorphicProWeb.Dissolver.HTML.Tailwind

# lib/morphic_pro_web/dissolver/html/tailwind.ex

defmodule MorphicProWeb.Dissolver.HTML.Tailwind do
  @behaviour Dissolver.HTML.Theme
  use Phoenix.HTML
  import Phoenix.LiveView.Helpers

  @moduledoc """
  This is a theme to support Tailwind CSS.
  https://tailwindcss.com/
  """

  @impl Dissolver.HTML.Theme
  def generate_links(page_list, additional_class) do
    content_tag :div, class: build_html_class(additional_class), role: "pagination" do
      for {label, _page, url, is_current} <- page_list do
        live_patch("#{label}",
          to: url,
          class:
            hide_for_mobile(is_current, label) <>
              "text-sm px-3 py-2 mx-1 rounded-lg hover:bg-gray-700 hover:text-gray-200 " <>
              if_active_class(is_current)
        )
      end
    end
  end

  defp build_html_class(additional_class) do
    String.trim("text-center pagination #{additional_class}")
  end

  defp if_active_class(true), do: "bg-gray-300"
  defp if_active_class(_), do: ""

  defp hide_for_mobile(false, page) when is_number(page), do: "hidden md:inline "
  defp hide_for_mobile(_, _), do: ""
end

This theme is based on Dissolver’s TailwindCss theme https://github.com/MorphicPro/dissolver/blob/v0.9.4/lib/dissolver/html/tailwind.ex

I just took this theme and then made the adjustments needed to utilize live_patch vs link as the original theme had done initially.

LiveView

Now that we have our theme defined and configured let’s look at our LiveView for snaps on the index for that LiveView. You can see below how I use the generated handle_params/3 call back to build out the paginator. You can see how it delegates to one of the two apply_action functions. This is to route requests for snaps of a tag vs the normal index for snaps.

# lib/morphic_pro_web/live/snap_live/index.ex

defmodule MorphicProWeb.SnapLive.Index do
  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(%{assigns: %{current_user: current_user}} = socket, :index, params) do
    {snaps, dissolver} = Blog.list_snaps(params, current_user)

    socket
    |> assign(page_title: "Listing Snaps")
    |> assign(dissolver: dissolver)
    |> assign(scope: nil)
    |> assign(snaps: snaps)
  end

  defp apply_action(%{assigns: %{current_user: current_user}} = socket, :tag, %{"tag" => tag} = params) do
    {tag, dissolver} = Blog.get_snap_for_tag!(tag, current_user, params)

    socket
    |> assign(page_title: "Listing Snaps for Tag")
    |> assign(dissolver: dissolver)
    |> assign(scope: tag.name)
    |> assign(snaps: tag.snaps)
  end

end

Following along we get our paginator from {snaps, dissolver} = Blog.list_snaps(params, current_user) and {tag, dissolver} = Blog.get_snap_for_tag!(tag, current_user, params) then assign them to the socket for use in the template.

  def list_snaps(params, user) do
    Snap
    |> from(preload: [:tags])
    |> Bodyguard.scope(user)
    |> Repo.order_by_published_at()
    |> Dissolver.paginate(params)
  end

  def get_snap_for_tag!(tag_name, user, params \\ %{}) do
    snaps =
      from(s in Snap)
      |> Bodyguard.scope(user)

    [total_count] =
      from(st in "snap_tags",
        join: s in ^snaps,
        on: s.id == st.snap_id,
        join: t in "tags",
        on: t.id == st.tag_id,
        where: t.name == ^tag_name,
        select: count()
      )
      |> Repo.all()

    {snaps_query, paginator} =
      from(s in Snap, order_by: [desc: :inserted_at], preload: [:tags])
      |> Bodyguard.scope(user)
      |> Dissolver.paginate(params, total_count: total_count, lazy: true)

    tag =
      from(t in Tag, where: t.name == ^tag_name, preload: [snaps: ^snaps_query])
      |> Repo.one!()

    {tag, paginator}
  end

get_snap_for_tag! is a great example of where Dissolver shines for you. This is our example of a lazy paginator. Instead of having Dissolver abstract away ecto internally, we have it return an Ecto query which I can use as a subquery on the association. This allows me to paginate the snaps for a given tag with ease.

From there the only thing remaining is to render the links.

# lib/morphic_pro_web/live/snap_live/index.html.leex
<div class="container px-4 py-10 mx-auto" onClick="window.scrollTo(0, 0);">
  <%= HTML.paginate @dissolver, snap_paginate_helper(@socket, @live_action, @scope) %>
</div>

At this point, I realized I brushed over a few things. Mainly snap_paginate_helper/3

  # lib/morphic_pro_web/live/live_helpers.ex

  def snap_paginate_helper(socket, action, nil) do
    &(Routes.snap_index_path(socket, action, &1))
  end

  def snap_paginate_helper(socket, action, scope) do
    &(Routes.snap_index_path(socket, action, scope, &1))
  end

I’m actively refactoring Dissolver.HTML.paginate to allow for a different interface that allows you to pass in the route helper as a lambda as seen in the two snap_paginate_helper functions. The current difference between the two helpers is just the extra scope which is used for tag queries since they are a subquery and I need to pass in a little extra context, IE the tag. Also, the socket is only used to generate the URL and I’m working to omit the socket as a requirement to that function since I decided I should just pass the route helper as a function rather than a value.

And finally our routes them selfs.

# lib/morphic_pro_web/router.ex

live "/snaps/tags/:tag", SnapLive.Index, :tag
live "/snaps", SnapLive.Index, :index

While I’m currently using Dissolver in Morphic.Pro, you should know to beware of bugs in production. There are a few things I’m still in need of addressing such as the test coverage that I destroyed in my first refactor of Kerosene. That said I’m happy to accept any help along the way.

And that’s all there is, hope you enjoyed following along.