@koyo-miyamura です!
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務に基づく知見が色々溜まってきたので公開したいと思います。
今回は「ページネーション」コンポーネントをLiveViewで作る方法を紹介したいと思います。
LiveView でコンポーネントを作る方法や、ページネーションを実現するライブラリを学んでいきましょう。
完成画面
準備
ローカルもしくはDockerで Elixir + Phoenix の環境構築をしましょう。
以前Dockerで環境構築する方法を紹介したのでこちらを使ってもです
あとは 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
にアクセスするとできています。
seedデータの作成
alias PaginateSample.Users
1..21
|> Enum.each(fn i ->
Users.create_user(%{name: "user_#{i}"})
end)
mix run priv/repo/seeds.exs
を実行してseedデータを作成します
ページネーション
ページネーションを実現するライブラリ Scrivenner
ページネーションをするにはクエリで OFFSET LIMIT を使用する必要があります。
自前で実装してもいいのですが、こういう時に便利な Scrivenner というライブラリがあるので紹介します。
これを使えば、簡単にページネーション対応のクエリができ、さらに総ページ数などのコンポーネントが知りたい情報も返してくれるようになります。
導入方法
まず足します
{:scrivener_ecto, "~> 2.0"}
すかさず mix deps.get
mix deps.get
デフォルトのページサイズを10件に指定しつつ、Scrivenerをアプリケーションに組み込みます。
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
イベントを発行するようにして、親コンポーネントが実装することを要求します。
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
ビューにコンポーネントを設置します。
<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 を設定することをお忘れなく!
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
を使うと良いかと思います)
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
まとめ
Phoenix LiveView を用いてページネーションコンポーネントを作成してみました。
テーブルコンポーネントや検索フォームコンポーネントなど、他にも様々なコンポーネントがあるので、機会があればまた執筆できればと思います。
Github にコードを置いておいたのでぜひ触ってみてください