はじめに
本記事は Qiita AdventCalendar2022 Elixir vol4 18日目の記事です
セットアップ編でPhoenixプロジェクトを新たに作成し、それをスマホアプリ化してトップページを表示できるところまでやりました
今回はphx.gen.liveでCRUD画面とヘッダーとボトムタブのナビゲーションコンポーネントを作っていきます
ElixirDesktopでスマホアプリ作成シリーズ
- Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編
DaisyUIのインストール
デフォルトでCSSフレームワークがTailwindなのでTailwindベースのUIライブラリのdaisyuiを使っていきます
installガイドにそってtailwind.config.jsのpluginに追加して完了です
cd assets
npm i daisyui
const plugin = require("tailwindcss/plugin")
module.exports = {
  ...
  plugins: [
    require("@tailwindcss/forms"),
+    require("daisyui"),
    plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
  ]
}
CRUD
CRUD画面を作っていきます
なんのアプリか特に決めてなかったのですが、書籍管理にでもしましょうか
ユーザー > 棚 > 本 な感じで本とファイルアップロードまでだとでかくなるので棚を登録するところまでをやっていきましょう
GPS Loggerにしようと思います!
ユーザー > ルート > GPSログな感じで保存していきます、今回は最初のルートだけをやっていきます
phx.gen.live
phx.gen.liveでCRUD画面を作ります user has_many routeにしたいので user_idを外部キーにしてスキーマを作ります
mix phx.gen.live Loggers Route routes name:string user_id:refrences:users
routerの認証が必要なsessionの配下に追加します
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router
  ...
  scope "/", SpottiesWeb do
    pipe_through([:browser, :require_authenticated_user])
    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.UserAuth, :ensure_authenticated}] do
      live("/users/settings", UserSettingsLive, :edit)
      live("/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email)
+      live("/routes", RouteLive.Index, :index)
+      live("/routes/new", RouteLive.Index, :new)
+      live("/routes/:id/edit", RouteLive.Index, :edit)
+      live("/routes/:id", RouteLive.Show, :show)
+      live("/routes/:id/show/edit", RouteLive.Show, :edit)
    end
  end
  ...
end
終わったらDBに反映させます
mix ecto.migrate
リレーション周り修正
そのままだとユーザーに関連付けて保存できないので手を加えます
Userにhas_manyリレーションを設定します
defmodule Spotties.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  schema "users" do
    field(:email, :string)
    field(:password, :string, virtual: true, redact: true)
    field(:hashed_password, :string, redact: true)
    field(:confirmed_at, :naive_datetime)
+    has_many(:routes, Spotties.Loggers.Route)
    timestamps()
  end
  ...
end
belongs_to リレーションと
castでchangesetの保存時にuser_idが入るようにします
defmodule Spotties.Loggers.Route do
  use Ecto.Schema
  import Ecto.Changeset
  schema "routes" do
    field(:name, :string)
+    belongs_to(:user, Spotties.Accounts.User)
    timestamps()
  end
  @doc false
  def changeset(route, attrs) do
    shelf
-    |> cast(attrs, [:name])
-    |> validate_required([:name)
+    |> cast(attrs, [:name, :user_id])
+    |> validate_required([:name, :user_id])
  end
end
list_routes/1を実装
user_idでwhereするlist_routes/1を実装します
defmodule Spotties.Loggers do
  ...
  def list_routes(user_id) do
    from(
      route in Route,
      where: route.user_id == ^user_id
    )
    |> Repo.all()
  end
  ...
end
index.exでユーザーに関連付けられたrouteを取得する
list_routesにuser_idを渡せるようにします
以下は変更した関数だけ表示しています
defmodule SpottiesWeb.RouteLive.Index do
  use SpottiesWeb, :live_view
  alias Spotties.Loggers
  alias Spotties.Loggers.Route
  @impl true
  def mount(_params, _session, socket) do
+    user = socket.assigns.current_user
+    {:ok, assign(socket, :routes, list_routes(user.id))}
-    {:ok, assign(socket, :routes, list_routes())}
  end
  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
+    user = socket.assigns.current_user
    shelf = Loggers.get_route!(id)
    {:ok, _} = Loggers.delete_route(route)
-    {:noreply, assign(socket, :routes, list_routes())}
+    {:noreply, assign(socket, :routes, list_routes(user.id))}
  end
-  defp list_routes() do
-    Loggers.list_routes()
-  end
+  defp list_routes(user_id) do
+    Loggers.list_routes(user_id)
+  end
end
formデータにuser_idを追加する
index.exで初期値に入れて、formのhidden_inputにuser_idを追加してもいいのですが
新規作成のときだけ元のパラメータにuser_idを追加するほうが個人的に好みなので、この方法でやっていきます
RouteLiveのFormComponentにassignsを渡していきます
assignsにcurrent_userがあるのでコンポーネントでuser_idで参照できるようにします
<.modal
  :if={@live_action in [:new, :edit]}
  id="shelf-modal"
  show
  on_cancel={JS.navigate(~p"/routes")}
>
  <.live_component
    module={SpottiesWeb.RouteLive.FormComponent}
    id={@route.id || :new}
    title={@page_title}
    action={@live_action}
    route={@route}
+    user_id={@current_user.id}
    navigate={~p"/rotues"}
  />
</.modal>
route_paramsにMap.putするときの注意点なのですが
route_paramsのキーは全てStringなので
Map.put(params, :user_id, 1)とStringのキーの中にAtomを入れるとエラーになります
キーの型は統一するようにしましょう
defmodule SpottiesWeb.RouteLive.FormComponent do
  use SpottiesWeb, :live_component
  ...
  defp save_route(socket, :new, route_params) do
+    route_params = Map.put(route_params, "user_id", socket.assigns.user_id)
    case Loggers.create_route(route_params) do
      {:ok, _route} ->
        {:noreply,
         socket
         |> put_flash(:info, "Route created successfully")
         |> push_navigate(to: socket.assigns.navigate)}
      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
end
tableのwidth調整
tableコンポーネントですがデフォルトだとスマホサイズでedit,deleteが見えないので修正します
defmodule SpottiesWeb.CoreComponents do
  ...
  def table(assigns) do
    ~H"""
    <div id={@id} class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
-      <table class="mt-11 w-[40rem] sm:w-full">
+      <table class="mt-11 w-full">
      ...
      </table>
    </div>
    """
  end
  ...
end
削除モーダルの作成
スマホだとalertやconfirmダイアログ表示がネイティブ側に手を加える必要があって面倒なので、confirmもモーダルにします
core_component.exにconfirm modalの例があったのでそれを参考にします
  @doc """
  Renders a modal.
  ## Examples
      <.modal id="confirm-modal">
        Are you sure?
        <:confirm>OK</:confirm>
        <:cancel>Cancel</:cancel>
      </.modal>
  JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
  for the caller to react to each button press, for example:
      <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
        Are you sure you?
        <:confirm>OK</:confirm>
        <:cancel>Cancel</:cancel>
      </.modal>
  """
最初はindex.ex
handle_paramsのapply_actionの最後にdeleteを追加して、
削除実行後リダイレクト処理とフラッシュメッセージを出すようにします
defmodule SpottiesWeb.RouteLive.Index do
  use SpottiesWeb, :live_view
  ...
  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Routes")
    |> assign(:route, nil)
  end
+  defp apply_action(socket, :delete, %{"id" => id}) do
+    socket
+    |> assign(:page_title, "Delete Route")
+    |> assign(:route, Loggers.get_route!(id))
+  end
  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    user = socket.assigns.current_user
    route = Loggers.get_route!(id)
    {:ok, _} = Loggers.delete_route(route)
-   {:noreply, assign(socket, :routes, list_routes(user.id))}
+   socket
+   |> assign(:routes, list_routes(user.id))
+   |> put_flash(:info, "Route deleted successfully")
+   |> push_navigate(to: ~p"/routes")
+   |> then(&{:noreply, &1})
  end
  ...
end
index.html.heexの末尾に :deleteの時に表示されるconfirm modalを追加します
on_cancelにcancelボタンを押した時の挙動(モーダルを閉じる)
on_confirmにOKボタン(delete)を押した時の挙動(deleteイベントを発火)
をそれぞれ渡しています
<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>
<.table id="routes" rows={@routes} row_click={&JS.navigate(~p"/routes/#{&1}")}>
  <:col :let={route} label="Name"><%= route.name %></:col>
  <:action :let={route}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
  </:action>
  <:action :let={route}>
-    <.link phx-click={JS.push("delete", value: %{id: route.id})} data-confirm="Are you sure?">
-      Delete
-    </.link>
+    <.link patch={~p"/routes/#{route}/delete"}>Delete</.link>    
  </:action>
</.table>
<.modal>
...
</.modal>
+ <.modal
+   :if={@live_action in [:delete]}
+   id="route-delete-modal"
+   show
+   on_cancel={JS.navigate(~p"/routes")}
+   on_confirm={JS.push("delete", value: %{id: @route.id})}
+ >
+   <p class="m-8">Are you sure?</p>
+   <:confirm><span class="mx-8 text-neutral-200">Delete</span></:confirm>
+   <:cancel><span class="mx-8">Cancel</span></:cancel>  
+ </.modal>
deleteが黒なのは気に食わないので赤にします
該当のclassは96行目にあります
defmodule SpottiesWeb.CoreComponents do
  ...
  def modal(assigns) do
    ~H"""
    <div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden">
      <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
      <div ...>
        <div class="flex min-h-full items-center justify-center">
          <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
            <.focus_wrap ...>
              <div class="absolute top-6 right-5">
                <button...>
                  <Heroicons.x_mark solid class="h-5 w-5 stroke-current" />
                </button>
              </div>
              <div id={"#{@id}-content"}>
                <header :if={@title != []}>...</header>
                <%= render_slot(@inner_block) %>
                <div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
                  <.button
                    :for={confirm <- @confirm}
                    id={"#{@id}-confirm"}
                    phx-click={@on_confirm}
                    phx-disable-with
-                    class="py-2 px-3"
+                    class="py-2 px-3 bg-red-400 hover:bg-red-600"
                  >
                    <%= render_slot(confirm) %>
                  </.button>
                  <.link
                    :for={cancel <- @cancel}
                    phx-click={hide_modal(@on_cancel, @id)}
                    class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
                  >
                    <%= render_slot(cancel) %>
                  </.link>
                </div>
              </div>
            </.focus_wrap>
          </div>
        </div>
      </div>
    </div>
    """
  end
  ...
end
最後にroutingにdeleteを追加します
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router
  ...
  scope "/", SpottiesWeb do
    pipe_through([:browser, :require_authenticated_user])
    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.UserAuth, :ensure_authenticated}] do
      live("/users/settings", UserSettingsLive, :edit)
      live("/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email)
      live("/routes", RouteLive.Index, :index)
      live("/routes/new", RouteLive.Index, :new)
      live("/routes/:id/edit", ShelfLive.Index, :edit)
+      live("/routes/:id/delete", RouteLive.Index, :delete)
      live("/routes/:id", RouteLive.Show, :show)
      live("/routes/:id/show/edit", RouteLive.Show, :edit)
    end
  end
  ...
end
これでconfirm modalができました
トップページの変更
ログイン後のトップページを /routesに変更します
defmodule SpottiesWeb.PageController do
  use SpottiesWeb, :controller
  alias Spotties.Accounts.User
  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end
  def index(conn, _params) do
    redirect_to(conn, conn.assigns.current_user)
  end
  def redirect_to(conn, %User{}) do
-    redirect(conn, to: ~p"/users/settings")
+    redirect(conn, to: ~p"/routes")
  end
  def redirect_to(conn, nil) do
    redirect(conn, to: ~p"/users/log_in")
  end
end
CRUDデモ
ここで一回動作確認をしてみましょうか
ちゃんとうごいますね
共通コンポーネントの作成
phx.gen.authで作られたグローバルメニューを消したので、ログイン後はsettingに移動できないので、以下のナビゲーション共通コンポーネントを作ります
- gheader
- bottom_tab
default headerを削除
まず元のヘッダーを消して、pyを20から24にします
- <header class="px-4 sm:px-6 lg:px-8">
-  <div class="flex items-center justify-between border-b border-zinc-100 py-3">
-  ...
-  </div>
- </header>
- <main class="px-4 py-20 sm:px-6 lg:px-8">
+ <main class="px-4 py-24 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    ...
    <%= @inner_content %>
  </div>
</main>
CoreComponentの解説
core_component.exのheaderを参考にします
...
  @doc """
  Renders a header with title.
  """
  attr(:class, :string, default: nil)
  slot(:inner_block, required: true)
  slot(:subtitle)
  slot(:actions)
  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
      <div>
        <h1 class="text-lg font-semibold leading-8 text-zinc-800">
          <%= render_slot(@inner_block) %>
        </h1>
        <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
          <%= render_slot(@subtitle) %>
        </p>
      </div>
      <div class="flex-none"><%= render_slot(@actions) %></div>
    </header>
    """
  end
...
使用例
<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>
attrは以下のように指定したものが @classで展開される
<.header class={"bg-black"}>
slotはslot名のタグ(今回は:actions)で囲った内容がrender_solotで表示してくれるというイメージ
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
slotで指定していない要素は @inder_block に渡される
この場合は Listing Routesが該当する
<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>
表示フラグは:ifで制御できる slotが指定されていない場合は []が渡されるようだ
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
  <%= render_slot(@subtitle) %>
</p>
これを踏まえてコンポーネントを作成してみよう
gheader
ベースはこちらになります
attrはタイトル
slot backは戻るボタンの戻る先をつけた<.link>を渡す
slot actionsはEditやAddのリンクを指定した<.link>を渡す
attrとslotはdocオプションで注釈等をつけることができます
defmodule SpottiesWeb.Components.Navigation do
  use Phoenix.Component
  attr(:title, :string, default: "")
  slot(:back, doc: "add back navigation within .link component")
  slot(:actions, doc: "add action navigation such as add, edit and etc... within .link component")
  def gheader(assigns) do
    ~H"""
    <div class="fixed navbar bg-primary text-primary-content w-full z-10 top-0 left-0">
      <div class="navbar-start">
        <button :if={@back != []} type="button" class="btn btn-ghost normal-case text-xl">
          <%= render_slot(@back) %>
        </button>
      </div>
      <div class="navbar-center">
        <a class="btn btn-ghost normal-case text-4xl">
          <%= @title %>
        </a>
      </div>
      <div class="navbar-end">
        <button :if={@actions != []} type="button" class="btn btn-ghost normal-case text-xl">
          <%= render_slot(@actions) %>
        </button>
      </div>
    </div>
    """
  end
  ...
end
bottom_tab
ベースはこちらになります
attr titleがlinksのタイトルと一致すると borderdクラスを付与するようにしています
defmodule SpottiesWeb.Components.Navigation do
  use Phoenix.Component
  ...
  attr(:title, :string, default: "")
  def bottom_tab(assigns) do
    ~H"""
    <div class="btm-nav">
      <%= for {title, path} <- links() do %>
        <a href={path} class={if @title == title, do: "active", else: ""}>
          <button>
            <span class="btm-nav-label"><%= title %></span>
          </button>
        </a>
      <% end %>
    </div>
    """
  end
  defp links() do
    [
      {"Route", "/routes"},
      {"Setting", "/users/settings"}
    ]
  end
  def active?(false), do: ""
  def active?(true), do: "bordered"
end
PJ全体で読み込む
CoreComponentですが lib/xx_webに追加することでPJ全体で使えるようにしてあるので
共通コンポーネントであるNavigationも追加しましょう
defmodule SpottiesWeb do
  ...
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
      import SpottiesWeb.CoreComponents
+      import SpottiesWeb.Components.Navigation
      import SpottiesWeb.Gettext
      # Shortcut for generating JS commands
      alias Phoenix.LiveView.JS
      # Routes generation with the ~p sigil
      unquote(verified_routes())
    end
  end
  ...
end
routes, routes/:id, users/setting に追加
随所に追加していきます
+ <.gheader title="Routes">
+  <:actions>
+    <.link patch={~p"/routes/new"} class="btn btn-ghost normal-case text-4xl">+</.link>
+  </:actions>
+ </.gheader>
<.header>
  Listing Routes
-  <:actions>
-    <.link patch={~p"/routes/new"}>
-      <.button>New Route</.button>
-    </.link>
-  </:actions>
</.header>
<.table id="routes" rows={@routes} row_click={&JS.navigate(~p"/routes/#{&1}")}>
  <:col :let={route} label="Name"><%= route.name %></:col>
  <:action :let={route}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
  </:action>
  <:action :let={route}>
    <.link patch={~p"/routes/#{route}/delete"}>Delete</.link>
  </:action>
</.table>
+ <.bottom_tab title="Route" />
<.modal>
...
</.modal>
+ <.gheader title="Route">
+  <:back>
+    <.link navigate={~p"/routes"}>Back</.link>
+  </:back>
+  <:actions>
+    <.link patch={~p"/routes/#{@routes}/show/edit"} phx-click={JS.push_focus()}>
+      Edit
+    </.link>
+  </:actions>
+ </.gheader>
<.header>
  Route <%= @route.id %>
  <:subtitle>This is a shelf record from your database.</:subtitle>
-  <:actions>
-    <.link patch={~p"/routes/#{@route}/show/edit"} phx-click={JS.push_focus()}>
-      <.button>Edit</.button>
-    </.link>
-  </:actions>
</.header>
<.list>
  <:item title="Name"><%= @route.name %></:item>
</.list>
- <.back navigate={~p"/routes"}>Back to Routes</.back>
+ <.bottom_tab title="route" />
<.modal>
...
</.modal>
defmodule SpottiesWeb.UserSettingsLive do
  use SpottiesWeb, :live_view
  alias Spotties.Accounts
  def render(assigns) do
    ~H"""
+    <.gheader title="Setting" />
    <.header>Change Email</.header>
    <.simple_form>
    ...
    </.simple_form>
    <.header>Change Password</.header>
    <.simple_form>
    ...
    </.simple_form>
    <.button class="mt-4"><.link href={~p"/users/log_out"} method="delete">Log out</.link></.button>
+    <.bottom_tab title="Setting" />
    """
  end
  ...
end
完成!
タブもナビゲーションもちゃんと動いてますね
最後に
だいぶ長くなってしましましたが無事CRUDとナビゲーションコンポーネントを作成してスマホっぽいUIができました
これを元に誰かアプリを作って申請を通してみた記事とか書いてくれないかな(チラ
本記事は以上になりますありがとうございました



