7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirAdvent Calendar 2024

Day 12

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

Last updated at Posted at 2024-12-22

はじめに

この記事は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コンポーネントを見てみましょう

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の第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追加

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

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}) が実行されます

lib/trarecord_web/live/folder_live/form_component.ex:L83
  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)されます

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:

song = get_song!(id)

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

Streamから削除

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

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の使い方がわかったのでフォルダ一覧をテーブルからカードレイアウトに変えていきます

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

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

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

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

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

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

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に削除モーダル表示するリンクを追加

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での関数パターンマッチを追加
タイトルと削除する対象をアサインします

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にはモーダルに表示する内容をいれて
タイトル、キャンセルボタン、削除ボタンを表示
キャンセルはフォルダ一覧に戻る、削除ボタンで削除イベントを発火させます

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に戻す処理と削除成功フラッシュを表示するように変更します

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

テスト修正

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

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ダイアログからモーダルに変更したので、そちらに合わせます

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

最後に

本記事では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

7
1
0

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
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?