はじめに
この記事はElixirアドベントカレンダー2024のシリーズ2、11日目の記事です
本記事では以下のことを行います
- ログイン・新規登録画面のデザイン修正
- ナビゲーションコンポーネントの実装
今回の作業ブランチを作成します
git checkout -b feature/card_layout
Streamについて
カードレイアウト化をする前に
phx.gen.liveで生成された一覧画面は以下のようになっていて
繰り返し表示をするfor
がなく、@stream.folders
とstreamの配下にfolders
があります
<.table
id="folders"
rows={@streams.folders}
row_click={fn {_id, folder} -> JS.navigate(~p"/folders/#{folder}") end}
>
<:col :let={{_id, folder}} label="Name"><%= folder.name %></:col>
<:action :let={{_id, folder}}>
<div class="sr-only">
<.link navigate={~p"/folders/#{folder}"}>Show</.link>
</div>
<.link patch={~p"/folders/#{folder}/edit"}>Edit</.link>
</:action>
<:action :let={{id, folder}}>
<.link
phx-click={JS.push("delete", value: %{id: folder.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
このstreamはどこから来たかというと
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_user
{:ok, stream(socket, :folders, Folders.list_folders(user.id))}
end
通常値をsocketに追加する場合はassign/3
関数を使いますが、ここではstream/3
関数を使っています
これを使うことで、DOMと連動したコレクションの一覧、追加、削除を簡単に行うことが出来ます
これを使う前は更新したら毎回コレクションの一覧を取得したり、JS hooks等で頑張って更新をしていました
詳しくは以下のサイトに解説あります
実際にどんな感じで使うのかCoreComponentのtableコンポーネントを見てみましょう
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
....
</tr>
</tbody>
phx-updateでrow
にアサインされた値がLiveSteam構造体だった場合、"stream"が設定されています
:forに{row <- @rows}
で回すようにされていて stream.folders
をそのままforに渡せば良さそうです
次に呼び出し側を見てみます
各slotの:col
と:action
はrender_slot/2の第2引数で、forで回されているrowがletに入っています
その値を展開パターンマッチで:let={{id, folder}}
としているのでforで回されるのは
folder構造体のリストではなく、 {id,folder構造体}のリストになるので注意が必要です
<.table
id="folders"
rows={@streams.folders}
row_click={fn {_id, folder} -> JS.navigate(~p"/folders/#{folder}") end}
>
<:col :let={{_id, folder}} label="Name"><%= folder.name %></:col>
<:action :let={{_id, folder}}>
<div class="sr-only">
<.link navigate={~p"/folders/#{folder}"}>Show</.link>
</div>
<.link patch={~p"/folders/#{folder}/edit"}>Edit</.link>
</:action>
<:action :let={{id, folder}}>
<.link
phx-click={JS.push("delete", value: %{id: folder.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
なのでstreamを使用したforによる一覧を行う場合は以下のように出来ます
<div id="folders" phx-update="stream">
<%= for {id, folder} <- @stream.folders do %>
...
<% end %>
</div>
Stream更新の仕組み
一覧表示時に付いて解説しましたが、次は更新処理を解説します
phx.gen.liveで生成された流れ的には
- formから save or edit
- formのnotify_parentを実行
- 呼び出し元で待ち受けている handle_infoがnotify_parentを受けて実行
- handle_info内のstream_insertで更新or追加
といったことをしています
defp save_folder(socket, :edit, folder_params) do
case Folders.update_folder(socket.assigns.folder, folder_params) do
{:ok, folder} ->
notify_parent({:saved, folder})
{:noreply,
socket
|> put_flash(:info, "Folder updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
更新・保存が完了してnotify_parent({:saved, folder})
が実行されます
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
sendは self()で同一のプロセスIDを渡して第2引数にモジュール名と更新された構造体を渡します
なので実行される値はこんな感じです
send(index.exのプロセスID, { FormModalComponent, {:saved, %Folder{}} })
sendをindex.exのhandle_infoコールバックで待ち受けているのでパターンマッチが成功して
stream_insertが実行されて、置き換え(:edit)か末尾に追加(:save)されます
def handle_info({TrarecordWeb.FolderLive.FormComponent, {:saved, folder}}, socket) do
{:noreply, stream_insert(socket, :folders, folder)}
end
更新じに置き換えではなくて末尾に追加したい場合は、一度stream_deleteしてから再度stream_insertを行ってください
To both update an existing item and move it to another position, issue a stream_delete, followed by a stream_insert. For example:
song = get_song!(id)
socket
|> stream_delete(:songs, song)
|> stream_insert(:songs, song, at: -1)
Streamから削除
stream_deleteに渡すだけでいい感じに消してくれます
def handle_event("delete", %{"id" => id}, socket) do
folder = Folders.get_folder!(id)
{:ok, _} = Folders.delete_folder(folder)
{:noreply, stream_delete(socket, :folders, folder)}
end
カードレイアウト化
streamの使い方がわかったのでフォルダ一覧をテーブルからカードレイアウトに変えていきます
コンポーネントは以下をベースにします
テーブルを削除して追加します
注意点としてはid属性を<div id={id} class="card bg-base-200 m-4 shadow-xl">
のように付けていないと更新処理、削除処理が失敗するので忘れないようにしましょう
iOSだとconfirmモーダルを別途実装する必要があり面倒なのでPhoenix側で実装します
- <div class="mt-12">
- <.table
- id="folders"
- rows={@streams.folders}
- row_click={fn {_id, folder} -> JS.navigate(~p"/folders/#{folder}") end}
- >
- ...
- </.table>
- </div>
+ <div id="folders" class="mt-16" phx-update="stream">
+ <%= for {id, folder} <- @streams.folders do %>
+ <div id={id} class="card bg-base-200 m-4 shadow-xl">
+ <.link navigate={~p"/folders/#{folder}"}>
+ <div class="card-body p-4">
+ <h2 class="card-title"><%= folder.name %></h2>
+ <div class="card-actions justify-end">
+ <.link patch={~p"/folders/#{folder}/edit"}>Edit</.link>
+ <.link patch={~p"/folders/#{folder}/delete"}>Delete</.link>
+ </div>
+ </div>
+ </.link>
+ </div>
+ <% end %>
+ </div>
こんな感じになります
通常だと新規登録後は一番上に追加されるので、末尾に追加されるように変更します
@impl true
def handle_info({TrarecordWeb.FolderLive.FormComponent, {:saved, folder}}, socket) do
- {:noreply, stream_insert(socket, :folders, folder)}
+ {:noreply, stream_insert(socket, :folders, folder, at: -1)}
end
twitterみたいに読み込み時下が古い、上が新しいになり随時上に足していく場合はliser_foldersのorderをdescにするかEnum.reverseで反転させましょう
delete btn -> delede modal -> delete
という流れで削除するため /folders/:id/delete
にアクセスした際に削除確認モーダルを出すようにします
routeに削除モーダル表示するリンクを追加
scope "/", TrarecordWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{TrarecordWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
live "/onboarding", OnboardingLive.Index, :index
live "/folders", FolderLive.Index, :index
live "/folders/new", FolderLive.Index, :new
live "/folders/:id/edit", FolderLive.Index, :edit
+ live "/folders/:id/delete", FolderLive.Index, :delete
live "/folders/:id", FolderLive.Show, :show
live "/folders/:id/show/edit", FolderLive.Show, :edit
end
end
apply actinで:delete
での関数パターンマッチを追加
タイトルと削除する対象をアサインします
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Folders")
|> assign(:folder, nil)
end
+ defp apply_action(socket, :delete, %{"id" => id}) do
+ socket
+ |> assign(:page_title, "Delete Folder")
+ |> assign(:folder, Folders.get_folder!(id))
+ end
表示するモーダルを末尾に追加します
表示フラグに使用している@live_action
はrouterで設定している末尾のアトムが入っています
live "/folders/:id/delete", FolderLive.Index, :delete
モーダル外のタップ、xボタンのタップで on_cancelが発火してフォルダ一覧に帰ります
slotにはモーダルに表示する内容をいれて
タイトル、キャンセルボタン、削除ボタンを表示
キャンセルはフォルダ一覧に戻る、削除ボタンで削除イベントを発火させます
<.modal
:if={@live_action in [:delete]}
id="folder-delete-modal"
show
on_cancel={JS.navigate(~p"/folders")}
>
<p class="m-4">Are you sure?</p>
<div class="flex justify-between mx-4 gap-x-4">
<button class="btn w-28" phx-click={JS.navigate(~p"/folders")}>
Cancel
</button>
<button
class="btn btn-error text-white w-28"
phx-click={JS.push("delete", value: %{id: @folder.id})}
>
Delete
</button>
</div>
</.modal>
URLが変わっているので、もとのフォルダ一覧のURLに戻す処理と削除成功フラッシュを表示するように変更します
def handle_event("delete", %{"id" => id}, socket) do
folder = Folders.get_folder!(id)
{:ok, _} = Folders.delete_folder(folder)
- {:noreply, stream_delete(socket, :folders, folder)}
+ socket
+ |> put_flash(:info, "Folder deleted successfully")
+ |> push_patch(to: ~p"/folders")
+ |> stream_delete(:folders, folder)
+ |> then(&{:noreply, &1})
end
動作確認
追加、更新、削除ができるのが確認できました
テスト修正
domの構造が変わったので、変更後で編集リンクを見つけられるように修正します
test "updates folder in listing", %{conn: conn, folder: folder} do
{:ok, index_live, _html} = live(conn, ~p"/folders")
- assert index_live |> element("#folders-#{folder.id} a", "Edit") |> render_click() =~
+ assert index_live
+ |> element("#folders-#{folder.id} .card-actions a", "Edit")
+ |> render_click() =~
"Edit Folder"
assert_patch(index_live, ~p"/folders/#{folder}/edit")
assert index_live
|> form("#folder-form", folder: @invalid_attrs)
|> render_change() =~ "can't be blank"
assert index_live
|> form("#folder-form", folder: @update_attrs)
|> render_submit()
assert_patch(index_live, ~p"/folders")
html = render(index_live)
assert html =~ "Folder updated successfully"
assert html =~ "some updated name"
end
confirmダイアログからモーダルに変更したので、そちらに合わせます
test "deletes folder in listing", %{conn: conn, folder: folder} do
{:ok, index_live, _html} = live(conn, ~p"/folders")
- assert index_live |> element("#folders-#{folder.id} a", "Delete") |> render_click()
+ assert index_live
+ |> element("#folders-#{folder.id} .card-actions a", "Delete")
+ |> render_click() =~
+ "Are you sure?"
+
+ assert_patch(index_live, ~p"/folders/#{folder.id}/delete")
+
+ assert index_live
+ |> element("#folder-delete-modal button", "Delete")
+ |> render_click()
refute has_element?(index_live, "#folders-#{folder.id}")
end
end
最後に
本記事ではDOM更新とサーバーのメモリ効率を改善するStreamの解説と
Streamを使用したカードレイアウトを実装しました
次はgettextで多言語化対応を行っていきます
本記事は以上になりますありがとうございました
参考ページ
https://fly.io/phoenix-files/phoenix-dev-blog-streams/
https://blog.emattsan.org/entry/2023/02/27/200000
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream_insert/4