ElixirDesktopで作るスマホアプリ Part 7 ナビゲーションコンポーネントの作成

# はじめに

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

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

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

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

```
git checkout -b feature/navigation_component
```

# ログイン・新規登録画面のデザイン修正

不要な機能（Keep me logged in)等が残っているので修正していきます

ログイン状態の場合は以下のコマンドでDBをリセットするかトークンファイルを削除します

```
mix ecto.reset
```

```
rm ~/.config/trarecord/token
```

## 新規登録画面

render関数内を以下のように書き換えます

```heex:lib/trarecord_web/live/user_registration_live.ex:L7
  def render(assigns) do
    ~H"""
    <div id="register" class="mx-auto pt-12 h-screen w-[80vw]">
      <.header class="text-center">
        Sign Up
      </.header>

      <.simple_form
        for={@form}
        id="registration_form"
        phx-submit="save"
        phx-change="validate"
        phx-trigger-action={@trigger_submit}
        action={~p"/users/log_in?_action=registered"}
        method="post"
      >
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />
        <:actions>
          <.link id="signin" navigate={~p"/users/log_in"} class="font-semibold text-sm underline">
            Sign in here
          </.link>
        </:actions>

        <:actions>
          <.button
            phx-disable-with={gettext("Creating account...")}
            class="w-full h-12 disabled:bg-gray-400 disabled:border-gray-400"
          >
            Sign Up
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
```

変更後は以下のようになります

![スクリーンショット 2024-12-19 10.10.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/3b5801f4-7a27-1236-c8ba-d8d0d2d9588d.png)

## ログイン画面

ログイン画面も変更していきます、テストのときにクリックする対象を楽に絞り込むためにリンクにID属性を付けています

```heex:lib/trarecord_web/live/user_login_live.ex
  def render(assigns) do
    ~H"""
    <div id="login" class="m-auto pt-12 h-screen w-[80vw]">
      <.header class="text-center">
        Sign In
      </.header>

      <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />

        <:actions>
          <ul>
            <li class="mb-2">
              <.link
                id="forgort-password"
                href={~p"/users/reset_password"}
                class="text-sm font-semibold underline"
              >
                Forgot your password?
              </.link>
            </li>
            <li>
              <.link
                id="signup"
                navigate={~p"/users/register"}
                class="font-semibold text-sm underline"
              >
                Sign up here
              </.link>
            </li>
          </ul>
        </:actions>
        <:actions>
          <.button phx-disable-with="Signing in..." class="w-full">
            Sign In
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
```

変更後は以下のようになります

![スクリーンショット 2024-12-19 10.26.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/2f4ff497-1cf6-2824-399d-22df7d628202.png)

# ナビゲーションコンポーネントの作成

ナビゲーションコンポーネントとは、ボトムタブやサイドメニューなどアプリの各リンクを表示するためのコンポーネントです

今回は以下の2つを作成します

ヘッダーナビゲーション
センターにページタイトル、両サイドに戻るなどのアクションボタンを配置
![スクリーンショット 2024-12-19 14.13.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/2fd4d5c2-9b82-157f-7c49-33b376fd83d8.png)

ボトムタブナビゲーション 
メインとなる機能へのリンク
![スクリーンショット 2024-12-19 14.13.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/bc285859-c723-cd74-f750-0e7ab673e5d7.png)

## Phoenix LiveViewにおけるコンポーネントについて

LiveViewでは表示だけを行うステートレスのPhoenix.Componentと状態やイベントを定義するPhoenix.LiveComponentの２つのコンポーネントがあります

最初にPhoenix.Componentについて、`phx.gen.live`によって生成された画面で使用されている、`CoreComponent`を題材に解説します

`CoreComponent`は`Phoenix`にビルドインされた`Tailwind`と`LiveView Phoniex Component`で実装されたデフォルトコンポーネント群は以下のようなコンポーネントがあります。

 * `modal`：CRUD作成時にも使用されるモーダル
 * `flash`：保存の成功、エラー発生の際右上に表示されるFlash
 * `flash_group`：上記をまとめたもの
 * `simple_form`：`input`部分と保存、キャンセル等のボタンのレイアウトを調整したフォーム
 * `button`：各丸ボタン
 * `input`：各種各丸`input`、正規化やエラーメッセージ表示も対応
 * `label`：デザイン調整されたラベル
 * `error`：`input`等で使うエラーメッセージ
 * `header`：タイトル、サブタイトル、アクション含むデザインヘッダー
 * `table`：`LiveStream`対応したデザイン調整されたテーブル
 * `list`：構造体や`Map`の情報を一覧する
 * `back`：戻るボタン
 * `icon`：`Heroicon`を表示するコンポーネント

### attr と slot

`CoreComponent`の元になっている`Phoenix.Component`は`attr`と`slot`という値を定義できます。

`attr`はパラメーターとして渡せる値を定義でき、`slot`は`component`のタグで挟んだ内容をどの場所で展開するかを定義できます。

実際に`back`コンポーネントの実装を見てみましょう。

```elixir:lib/trarecord_web/components/core_components.ex:L558
@doc """
  Renders a back navigation link.

  ## Examples

      <.back navigate={~p"/posts"}>Back to posts</.back>
  """
  attr :navigate, :any, required: true
  slot :inner_block, required: true

  def back(assigns) do
    ~H"""
    <div class="mt-16">
      <.link
        navigate#{@navigate}
        class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
      >
        <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
        <%= render_slot(@inner_block) %>
      </.link>
    </div>
    """
  end
```

`Examples`に使い方が載っています。`attr`の`navigate`に`/posts`を指定し、`slot`に `Back to Posts`が指定されています。

`attr`の`navigate`は`link`タグの`navigate`にパスとして展開されています。また`slot`の中身を展開するときは`render_slot`を使用します。

 `<.back>...<./back>`で挟んだ真ん中の部分が`render_slot`で展開されます

### liveComponent

 LiveComponentは今回使いませんが軽く解説します

update,handle_eventなど各種コールバックが定義できるステートフルなコンポーネントを作成できます

 使用する際は`<.live_component />`内でモジュールを指定し、ID属性が必須になります。

phx.gen.liveで生成したモーダルで入力フォームを表示する際に使われています

 ```heex:lib/trarecord_web/live/folder_live/index.html.heex:L32
 <.modal
  :if={@live_action in [:new, :edit]}
  id="folder-modal"
  show
  on_cancel={JS.patch(~p"/folders")}
>
  <.live_component
    module={TrarecordWeb.FolderLive.FormComponent}
    id={@folder.id || :new}
    title={@page_title}
    action={@live_action}
    folder={@folder}
    user_id={@current_user.id}
    patch={~p"/folders"}
  />
</.modal>
 ```

LiveComponentを作る場合は以下のように作ります
- renderで実際に表示する内容を定義
- updateで渡された値をもとに行う処理を定義
- handle_eventでイベントが発火した際のハンドリングを定義
- イベント内で実行されてDB操作とナビゲーションを行う処理を定義

初期化されたときに１回実行されるmountもあります、詳しくは以下を見てみてください

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#callbacks

 ```lib/trarecord_web/live/folder_live/form_component.ex
 defmodule TrarecordWeb.FolderLive.FormComponent do
  use TrarecordWeb, :live_component

  alias Trarecord.Folders

  @impl true
  def render(assigns) do
  ...
  end

  @impl true
  def update(%{folder: folder} = assigns, socket) do
  ...
  end

  @impl true
  def handle_event("validate", %{"folder" => folder_params}, socket) do
  ...
  end

  def handle_event("save", %{"folder" => folder_params}, socket) do
  ...
  end

  defp save_folder(socket, :edit, folder_params) do
  ...
  end

  defp save_folder(socket, :new, folder_params) do
  ...
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end
 ```

 ## ヘッダーナビゲーションの作成

 では実際にヘッダーナビゲーションを作成していきます

UIコンポーネントはこちらを使います

https://daisyui.com/components/navbar/#navbar-with-dropdown-center-logo-and-icon

navigation.exをcomponents配下に作り、そこに各コンポーネントを実装していきます

値としてタイトル、左側のスロットは戻るボタン、右側のスロットはアクションボタンを付けれるようになります。

 ```lib/trarecord_web/components/navigation.ex
 defmodule TrarecordWeb.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 header_nav(assigns) do
    ~H"""
    <div class="fixed navbar bg-orange-300 text-white w-full z-10 top-0 left-0">
      <div class="navbar-start">
        <span :if={@back != []} class="normal-case text-xl">
          <%= render_slot(@back) %>
        </span>
      </div>
      <div class="navbar-center">
        <span class="normal-case text-4xl">
          <%= @title %>
        </span>
      </div>
      <div class="navbar-end">
        <span :if={@actions != []} class="normal-case text-xl">
          <%= render_slot(@actions) %>
        </span>
      </div>
    </div>
    """
  end
end
```

### プロジェクト全体で読み込む

プロジェクト全体を通してコンポーネントモジュールを読み込みたい場合はxx_web.exのhtml_helpersマクロにimport追加すると全体で読み込んでもらえます

```diff_elixir:lib/trarecord_web.ex:L82
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
      import TrarecordWeb.CoreComponents
+     import TrarecordWeb.Navigation
      use Gettext, backend: TrarecordWeb.Gettext

      # Shortcut for generating JS commands
      alias Phoenix.LiveView.JS

      # Routes generation with the ~p sigil
      unquote(verified_routes())
    end
  end

```

### フォルダ一覧に組み込む

実際に組み込んでいきます

元のヘッダーと差し替えて、新規作成ボタンは文字ではなく[+]マークにします
またヘッダーはスクロールしてもずれないようにfixedになっているので上方向のマージンをコンテンツに対して行います

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heex
- <.header>
-  Listing Folders
-  <:actions>
-    <.link id="add" patch={~p"/folders/new"}>
-      <.button>New Folder</.button>
-    </.link>
-  </:actions>
- </.header>
+ <.header_nav title="Folder">
+  <:actions>
+    <.link patch={~p"/folders/new"}>
+      <.icon name="hero-plus-solid" class="mr-4 h-8 w-8" />
+    </.link>
+  </:actions>
+ </.header_nav>

+ <div class="mt-12">
<.table
  id="folders"
  rows={@streams.folders}
  row_click={fn {_id, folder} -> JS.navigate(~p"/folders/#{folder}") end}
>
  ...
</.table>
+ </div>
```

こんな感じになります

![8de0d960cb93e55b6354635aab3e8dbb.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/c5245a6d-eeac-f576-daab-91a17297ce16.gif)


## ボトムタブナビゲーション

ボトムタブナビゲーションを作成していきます

UIコンポーネントはこちらを使います

https://daisyui.com/components/bottom-navigation/#with-title

タブのリストをlinks関数に書き出し{タブ名,アイコン,URL}のタプルのリストをforでレンダリングしていきます

また素の Phoenix.Componentだとhtml_helpersが展開されずCoreComponentが使えないので読み込みます

```elixir:lib/trarecord_web/components/navigation.ex
defmodule TrarecordWeb.Navigation do
  use Phoenix.Component

  import TrarecordWeb.CoreComponents

  def header_nav(assings)
  ...
  end
  
  attr(:current, :string, default: "", doc: "curruent open tab name")

  def bottom_tab(assigns) do
    ~H"""
    <div class="btm-nav">
      <%= for {title, icon ,path} <- links() do %>
        <a href={path} class={if @current == title, do: "active", else: ""}>
          <button>
            <.icon name={icon} class="w-5 h-5" />
            <p class="btm-nav-label"><%= title %></p>
          </button>
        </a>
      <% end %>
    </div>
    """
  end

  defp links() do
    [
      {"Folder", "hero-book-open-solid", "/folders"},
      {"Setting", "hero-cog-6-tooth-solid", "/users/settings"}
    ]
  end
```

### フォルダ一覧とユーザー設定画面に組み込む

レイアウト的にテーブルの下に配置します

```diff_elixir:lib/trarecord_web/live/folder_live/index.html.heexL9
<div class="mt-12">
  <.table
    id="folders"
    rows={@streams.folders}
    row_click={fn {_id, folder} -> JS.navigate(~p"/folders/#{folder}") end}
  >
    ...
    </:action>
  </.table>
</div>

+ <.bottom_tab current="Folder" />
```

設定画面にも追加します

```diff_elixir
  def render(assigns) do
    ~H"""
-   <.header class="text-center">
-     Account Settings
-     <:subtitle>Manage your account email address and password settings</:subtitle>
-   </.header>
+   <.header_nav title="Setting" />


-    <div class="space-y-12 divide-y">
+    <div class="space-y-12 divide-y mt-12 p-4">
      <div>
        <.simple_form
          for={@email_form}
          id="email_form"
          phx-submit="update_email"
          phx-change="validate_email"
        >
        ...
        </.simple_form>
      </div>
    </div>

+   <.bottom_tab current="Setting" />
    """
  end
```

こんな感じになります

![137d75ec327cfa0f0a9524859062bd23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/400f712c-15f5-15e2-5cde-e27e748dc8dd.gif)


これでナビゲーションコンポーネントが完成しました

# テストの修正

ログイン・新規登録ページの文言を変更していくつかテストがコケるようになったので修正していきます

## folder_live_test

新規作成のボタンが変わったので直します

```diff_elixir:test/trarecord_web/live/folder_live_test.exs:L27
    test "saves new folder", %{conn: conn} do
      {:ok, index_live, _html} = live(conn, ~p"/folders")
-     assert index_live |> element("a", "New Folder") |> render_click() =~
+     assert index_live |> element("#add") |> render_click() =~
               "New Folder"

      assert_patch(index_live, ~p"/folders/new")

      assert index_live
             |> form("#folder-form", folder: @invalid_attrs)
             |> render_change() =~ "can&#39;t be blank"

      assert index_live
             |> form("#folder-form", folder: @create_attrs)
             |> render_submit()

      assert_patch(index_live, ~p"/folders")

      html = render(index_live)
      assert html =~ "Folder created successfully"
      assert html =~ "some name"
    end
```

## user_login_live_test

```diff_elixir:test/trarecord_web/live/user_login_live_test.exs:L7
   test "renders log in page", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/users/log_in")

-     assert html =~ "Log in"
+     assert html =~ "Sign In"
      assert html =~ "Sign up"
      assert html =~ "Forgot your password?"
    end
```

```diff_elixir:test/trarecord_web/live/user_login_live_test.exs:L60
   test "redirects to registration page when the Register button is clicked", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/log_in")

      {:ok, _login_live, login_html} =
        lv
-       |> element(~s|main a:fl-contains("Sign up")|)
+       |> element(~s|main a:fl-contains("Sign up here")|)
        |> render_click()
        |> follow_redirect(conn, ~p"/users/register")

-     assert login_html =~ "Register"
+     assert login_html =~ "Sign Up"
    end
```

## user_registration_live_test
```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs:L7
   test "renders registration page", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/users/register")

-     assert html =~ "Register"
-     assert html =~ "Log in"
+     assert html =~ "Sign Up"
+     assert html =~ "Sign in"
    end
```

```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs:L24
    test "renders errors for invalid data", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

      result =
        lv
        |> element("#registration_form")
        |> render_change(user: %{"email" => "with spaces", "password" => "too short"})

-     assert result =~ "Register"
+     assert result =~ "Sign Up"
      assert result =~ "must have the @ sign and no spaces"
      assert result =~ "should be at least 12 character"
    end
```
```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs:L71
  describe "registration navigation" do
    test "redirects to login page when the Log in button is clicked", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

      {:ok, _login_live, login_html} =
        lv
-       |> element(~s|main a:fl-contains("Log in")|)
+       |> element(~s|main a:fl-contains("Sign in")|)
        |> render_click()
        |> follow_redirect(conn, ~p"/users/log_in")

-     assert login_html =~ "Log in"
+     assert login_html =~ "Sign In"
    end
  end
```

CIぱすできたのでマージして完了です

![スクリーンショット 2024-12-19 16.53.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/a31f83c2-a4bb-a93f-4b88-0c0019928a56.png)


# 最後に

本記事ではユーザーログインと、新規登録画面のデザイン修正とナビゲーションコンポーネントの作成を行いました

次はフォルダ一覧をカードレイアウト化していきます

本記事は以上になりますありがとうございました

# 参考サイト
https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html
https://daisyui.com/components/navbar/#navbar-with-dropdown-center-logo-and-icon
https://daisyui.com/components/bottom-navigation/#with-title
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#callbacks