ElixirDesktopで作るスマホアプリ Part 8 フォルダ一覧のカードレイアウト化、streamの解説

# はじめに

この記事はElixirアドベントカレンダー2024のシリーズ２、11日目の記事です

https://qiita.com/advent-calendar/2024/elixir

本記事では以下のことを行います
- ログイン・新規登録画面のデザイン修正
- ナビゲーションコンポーネントの実装

今回の作業ブランチを作成します

```
git checkout -b feature/card_layout
```

# Streamについて

カードレイアウト化をする前に
phx.gen.liveで生成された一覧画面は以下のようになっていて
繰り返し表示をする`for`がなく、`@stream.folders`とstreamの配下に`folders`があります

```heex
  <.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はどこから来たかというと

```elixir
  @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等で頑張って更新をしていました

詳しくは以下のサイトに解説あります

https://fly.io/phoenix-files/phoenix-dev-blog-streams/

実際にどんな感じで使うのかCoreComponentのtableコンポーネントを見てみましょう

```heex:lib/trarecord_web/components/core_components.ex:L486
<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の第２引数で、forで回されているrowがletに入っています

その値を展開パターンマッチで`:let={{id, folder}}`としているのでforで回されるのは
folder構造体のリストではなく、 {id,folder構造体}のリストになるので注意が必要です

```heex
  <.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による一覧を行う場合は以下のように出来ます

```heex
<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追加

といったことをしています

```elixir:lib/trarecord_web/live/folder_live/form_component.ex:L51
  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}) `が実行されます

```elixir:lib/trarecord_web/live/folder_live/form_component.ex:L83
  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
```

https://hexdocs.pm/elixir/1.18.0/Kernel.html#send/2

sendは self()で同一のプロセスIDを渡して第２引数にモジュール名と更新された構造体を渡します

なので実行される値はこんな感じです

`send(index.exのプロセスID, { FormModalComponent, {:saved, %Folder{}} })`

sendをindex.exのhandle_infoコールバックで待ち受けているのでパターンマッチが成功して

stream_insertが実行されて、置き換え(:edit)か末尾に追加(:save)されます

```elixir:lib/trarecord_web/live/folder_live/index.ex:L37
  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:
```elixir
song = get_song!(id)

socket
|> stream_delete(:songs, song)
|> stream_insert(:songs, song, at: -1)
```

# Streamから削除

stream_deleteに渡すだけでいい感じに消してくれます

```elixir:lib/trarecord_web/live/folder_live/index.ex:L42
  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の使い方がわかったのでフォルダ一覧をテーブルからカードレイアウトに変えていきます

コンポーネントは以下をベースにします

https://daisyui.com/components/card/#card-with-no-image


テーブルを削除して追加します

注意点としてはid属性を`<div id={id} class="card bg-base-200 m-4 shadow-xl">`のように付けていないと更新処理、削除処理が失敗するので忘れないようにしましょう

iOSだとconfirmモーダルを別途実装する必要があり面倒なのでPhoenix側で実装します

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heex:L9
- <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>
```
こんな感じになります

![スクリーンショット 2024-12-23 1.27.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/c18bd238-75b0-c693-bff7-b358e7e3e140.png)

通常だと新規登録後は一番上に追加されるので、末尾に追加されるように変更します

```diff_elixir:lib/trarecord_web/live/folder_live/index.ex:L43
  @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に削除モーダル表示するリンクを追加

```diff_elixir:lib/trarecord_web/router.ex:L67
  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`での関数パターンマッチを追加
タイトルと削除する対象をアサインします

```diff_elixir:lib/trarecord_web/live/folder_live/index.ex:L30
  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にはモーダルに表示する内容をいれて
タイトル、キャンセルボタン、削除ボタンを表示
キャンセルはフォルダ一覧に戻る、削除ボタンで削除イベントを発火させます

```elixir:lib/trarecord_web/live/folder_live/index.html.heex:L43
<.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に戻す処理と削除成功フラッシュを表示するように変更します

```diff_elixir:lib/trarecord_web/live/folder_live/index.ex:L48
  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
```

# 動作確認

追加、更新、削除ができるのが確認できました

![1bf4ca27f636802c57b643d0e8ae87f3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/66ad532b-052e-0587-ef3e-7e2a2bb3a72e.gif)

# テスト修正

domの構造が変わったので、変更後で編集リンクを見つけられるように修正します

```diff_elixir:test/trarecord_web/live/folder_live_test.exs
    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&#39;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ダイアログからモーダルに変更したので、そちらに合わせます

```diff_elixir:test/trarecord_web/live/folder_live_test.exs:73
    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
```

![スクリーンショット 2024-12-24 10.57.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/007dc3e1-bb65-a78b-cd34-4dde59f410ce.png)


# 最後に
本記事では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