LoginSignup
12
8

More than 1 year has passed since last update.

Phoenix LiveView でページネーションコンポーネントを作る

Last updated at Posted at 2022-04-18

@koyo-miyamura です!
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務に基づく知見が色々溜まってきたので公開したいと思います。

今回は「ページネーション」コンポーネントをLiveViewで作る方法を紹介したいと思います。
LiveView でコンポーネントを作る方法や、ページネーションを実現するライブラリを学んでいきましょう。

完成画面

edit_20220418022928.gif

準備

ローカルもしくはDockerで Elixir + Phoenix の環境構築をしましょう。
以前Dockerで環境構築する方法を紹介したのでこちらを使っても:ok:です

あとは Phoenix のプロジェクトを作っていきます。

プロジェクトの作成

mix phx.new paginate_sample
mix phx.gen.live Users User users name:string
mix ecto.migrate

下記に従ってルーティングを追加します

Add the live routes to your browser scope in lib/paginate_sample_web/router.ex:

    live "/users", UserLive.Index, :index
    live "/users/new", UserLive.Index, :new
    live "/users/:id/edit", UserLive.Index, :edit

    live "/users/:id", UserLive.Show, :show
    live "/users/:id/show/edit", UserLive.Show, :edit

http://localhost:4000/users にアクセスするとできています。

image.png

seedデータの作成

priv/repo/seeds.exs
alias PaginateSample.Users

1..21
|> Enum.each(fn i ->
  Users.create_user(%{name: "user_#{i}"})
end)

mix run priv/repo/seeds.exs を実行してseedデータを作成します

できました!
image.png

ページネーション

ページネーションを実現するライブラリ Scrivenner

ページネーションをするにはクエリで OFFSET LIMIT を使用する必要があります。
自前で実装してもいいのですが、こういう時に便利な Scrivenner というライブラリがあるので紹介します。
これを使えば、簡単にページネーション対応のクエリができ、さらに総ページ数などのコンポーネントが知りたい情報も返してくれるようになります。

導入方法

まず足します

mix.exs
{:scrivener_ecto, "~> 2.0"}

すかさず mix deps.get

mix deps.get

デフォルトのページサイズを10件に指定しつつ、Scrivenerをアプリケーションに組み込みます。

lib/paginate_sample/repo.ex
defmodule PaginateSample.Repo do
  ...
    
  use Scrivener, page_size: 10
end

ここで iex -S mix で試してみましょう。

iex> PaginateSample.Users.User |> PaginateSample.Repo.paginate(page_size: 2)
[debug] QUERY OK source="users" db=1.1ms idle=1126.3ms
SELECT count('*') FROM "users" AS u0 []
[debug] QUERY OK source="users" db=0.9ms idle=1127.6ms
SELECT u0."id", u0."name", u0."inserted_at", u0."updated_at" FROM "users" AS u0 LIMIT $1 OFFSET $2 [2, 0]
%Scrivener.Page{
  entries: [
    %PaginateSample.Users.User{
      __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
      id: 1,
      inserted_at: ~N[2022-04-17 13:56:02],
      name: "user_1",
      updated_at: ~N[2022-04-17 13:56:02]
    },
    %PaginateSample.Users.User{
      __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
      id: 2,
      inserted_at: ~N[2022-04-17 13:56:03],
      name: "user_2",
      updated_at: ~N[2022-04-17 13:56:03]
    }
  ],
  page_number: 1,
  page_size: 2,
  total_entries: 21,
  total_pages: 11
}

クエリが LIMIT OFFSET になっており、ページネーションできていますね!(なお実務上は ORDER BY を自前で入れて一意性を担保する必要があります)
COUNT(*) のクエリと合わせて2クエリ出てそうです。
:entries でデータを返しつつ、各種データも返してくれています。

ページネーションコンポーネントの作成

ページネーションコンポーネントを作成します。
LiveView でのコンポーネントの構成方法は2種類あり状態を持たない Phoenix.Component と状態を持てる Phoenix.LiveComponent があります。
(厳密にいうと色々違うのですが詳しくはドキュメント参照)

今回は Phoenix.Component で作ります。なお、状態は持たなくても phx-click イベントなどは発生させることができるので、意外に Phoenix.Component でも困らないです。
公式ドキュメントにもあるように、できるだけシンプルに作った方がいいと思うのでこれでいきます。

components ディレクトリを掘って、以下のように作ります。

  • ページサイズが変更したら update_page_size
  • ページが変更したら update_page

イベントを発行するようにして、親コンポーネントが実装することを要求します。

lib/paginate_sample_web/live/components/paginator.ex
defmodule PaginateSample.Components.Paginator do
  @moduledoc """
  Paginator.

  # Example
  <PaginateSample.Components.Paginator.render total_entries={@total_entries} page_size={@page_size} page={@page} total_pages={@total_pages} />

  # Events
  Emit events is here and parent components must implement handle_event.
    - update_page_size
    - update_page
  """
  use Phoenix.Component

  def render(assigns) do
    ~H"""
    <div>
      <div>
        <p>表示件数:</p>
        <div>
          <form phx-change="update_page_size">
            <select name="page_size">
              <option value="5" selected={@page_size == 5}>5</option>
              <option value="10" selected={@page_size == 10}>10</option>
              <option value="15" selected={@page_size == 15}>15</option>
              <option value="20" selected={@page_size == 20}>20</option>
              <option value="25" selected={@page_size == 25}>25</option>
            </select>
          </form>
        </div>
        <p><%= @total_entries %>件中 <%= @page_size * (@page - 1) + 1 %>~<%= @page_size * @page %>件を表示</p>
      </div>
      <div>
        <button phx-click="update_page" phx-value-page="1" disabled={@page == 1}>先頭へ</button>
        <button phx-click="update_page" phx-value-page={@page - 1} disabled={@page == 1}>前へ</button>
        <button phx-click="update_page" phx-value-page={@page + 1} disabled={@page == @total_pages}>次へ</button>
        <button phx-click="update_page" phx-value-page={@total_pages} disabled={@page == @total_pages}>最後尾へ</button>
      </div>
    </div>
    """
  end
end

ビューにコンポーネントを設置します。

lib/paginate_sample_web/live/user_live/index.html.heex
<table>
...
</table>
+ <PaginateSample.Components.Paginator.render total_entries={@users.total_entries} page_size={@users.page_size} page={@users.page_number} total_pages={@users.total_pages} />

Context を修正します。order_by を設定することをお忘れなく!

lib/paginate_sample/users.ex
  def list_users() do
    build_list_query()
    |> Repo.paginate()
  end

  def list_users(page, page_size) do
    build_list_query()
    |> Repo.paginate(page: page, page_size: page_size)
  end

  defp build_list_query() do
    from(u in User,
      order_by: [desc: u.id]
    )
  end

LiveView側の記述を修正します。ページが変更されたときに、ページサイズがリセットされないよう、すでにセット済みの値を考慮してリダイレクトします。
push_redirect を用いることで、スクロールがトップに戻ります。
(逆にスクロールをそのままにしたい場合は push_patch を使うと良いかと思います)

lib/paginate_sample_web/live/user_live/index.ex
defmodule PaginateSampleWeb.UserLive.Index do
  use PaginateSampleWeb, :live_view

  alias PaginateSample.Users
  alias PaginateSample.Users.User

  @default_page 1
  @default_page_size 10

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

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

  defp apply_action(socket, :edit, %{"id" => id}) do
    socket
    |> assign(:page_title, "Edit User")
    |> assign(:user, Users.get_user!(id))
  end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New User")
    |> assign(:user, %User{})
  end

  defp apply_action(socket, :index, params) do
    socket
    |> assign(:page_title, "Listing Users")
    |> assign(:user, nil)
    |> assign(:users, list_users(params))
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    user = Users.get_user!(id)
    {:ok, _} = Users.delete_user(user)

    {:noreply, assign(socket, :users, list_users())}
  end

  @impl true
  def handle_event("update_page", %{"page" => page}, socket) do
    params =
      socket.assigns
      |> Map.get(:users)
      |> Map.take([:page_number, :page_size])
      |> Map.merge(%{page_number: page})
      |> Keyword.new()

    {:noreply,
     push_redirect(socket,
       to: Routes.user_index_path(socket, :index, params)
     )}
  end

  @impl true
  def handle_event("update_page_size", %{"page_size" => page_size}, socket) do
    params =
      socket.assigns
      |> Map.get(:users)
      |> Map.take([:page_number, :page_size])
      |> Map.merge(%{page_size: page_size})
      |> Keyword.new()

    {:noreply,
     push_redirect(socket,
       to: Routes.user_index_path(socket, :index, params)
     )}
  end

  defp list_users() do
    Users.list_users()
  end

  defp list_users(%{"page_number" => page, "page_size" => page_size}) do
    Users.list_users(page, page_size)
  end

  defp list_users(%{"page_number" => page}) do
    Users.list_users(page, @default_page_size)
  end

  defp list_users(%{"page_size" => page_size}) do
    Users.list_users(@default_page, page_size)
  end

  defp list_users(%{}) do
    Users.list_users()
  end
end

完成!リアルタイムに動作するオシャレなUIができましたね!
これでスタイルを整えれば実務でも十分使えるコードとなりますb

edit_20220418022928.gif

まとめ

Phoenix LiveView を用いてページネーションコンポーネントを作成してみました。
テーブルコンポーネントや検索フォームコンポーネントなど、他にも様々なコンポーネントがあるので、機会があればまた執筆できればと思います。
Github にコードを置いておいたのでぜひ触ってみてください:thumbsup:

12
8
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
8