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

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

https://zenn.dev/koyo/articles/c00527aef96c9f

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

# 完成画面

![edit_20220418022928.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/f63a15c4-0d12-1f91-cf43-53637da899cb.gif)

# 準備

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

https://qiita.com/koyo-miyamura/items/a609de2e9fadaf198243

あとは 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/3355089f-ce8d-19ae-13cb-8a8dddea1757.png)

## seedデータの作成
```elixir: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/af0b3fe0-9fd4-630f-fe3e-1dbe1790a807.png)

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

### 導入方法
https://github.com/drewolson/scrivener_ecto

まず足します
```elixir:mix.exs
{:scrivener_ecto, "~> 2.0"}
```

すかさず `mix deps.get`
```
mix deps.get
```

デフォルトのページサイズを10件に指定しつつ、Scrivenerをアプリケーションに組み込みます。
```elixir:lib/paginate_sample/repo.ex
defmodule PaginateSample.Repo do
  ...
    
  use Scrivener, page_size: 10
end
```

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

```elixir
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(*) のクエリと合わせて２クエリ出てそうです。
`:entries` でデータを返しつつ、各種データも返してくれています。

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

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html

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

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

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

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

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

ビューにコンポーネントを設置します。
```diff_eex: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 を設定することをお忘れなく！
```elixir: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` を使うと良いかと思います）

```elixir: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201242/f63a15c4-0d12-1f91-cf43-53637da899cb.gif)

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

https://github.com/koyo-miyamura/paginate_sample_ex