ElixirDesktop APIレス開発 CRUDとナビゲーション

# はじめに
この記事は Elixirアドベントカレンダーのシリーズ４の19日目の記事です

今回はphx.gen.liveでCRUD画面とヘッダーとボトムタブのナビゲーションコンポーネントを作っていきます


# DaisyUIのインストール
デフォルトでCSSフレームワークがTailwindなのでTailwindベースのUIライブラリのdaisyuiを使っていきます

https://daisyui.com/

installガイドにそってtailwind.config.jsのpluginに追加して完了です

```
cd assets
npm i daisyui
cd ..
```

```diff_javascript:phoenix/assets/tailwind.config.js

const plugin = require("tailwindcss/plugin")

module.exports = {
  ...
  plugins: [
    require("@tailwindcss/forms"),
+   require("daisyui"),
    ...
  ]
}
```

# CRUD
CRUD画面を作っていきます
ユーザー >　ルート > 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:references:users
```
routerの認証が必要なsessionの配下に追加します

```diff_elixir:lib/spotter_web/router.ex
defmodule SpotterWeb.Router do
  use SpotterWeb, :router
  ...
  scope "/", SpotterWeb do
    pipe_through([:browser, :require_authenticated_user])

    live_session :require_authenticated_user,
      on_mount: [{SpotterWeb.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リレーションを設定します
```diff_elixir:lib/spotter/accounts/user.ex
defmodule Spotter.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, Spotter.Loggers.Route)

    timestamps(type: :utc_datetime)
  end
  ...
end
```

belongs_to リレーションと
castでchangesetの保存時にuser_idが入るようにします

```diff_elixir:lib/spotter/loggers/route.ex
defmodule Spotter.Loggers.Route do
  use Ecto.Schema
  import Ecto.Changeset

  schema "routes" do
    field(:name, :string)
-   field :user_id, :id
+   belongs_to(:user, Spotter.Accounts.User)

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(route, attrs) do
    route
-   |> 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を実装します

```lib/spotties/routes.ex
defmodule Spotter.Loggers do
  ...
  def list_routes(user_id) do
    Route
    |> where([r], r.user_id == ^user_id)
    |> Repo.all()
  end
  ...
end
```

## index.exでユーザーに関連付けられたrouteを取得する
list_routesにuser_idを渡せるようにします

以下は変更した関数だけ表示しています
```diff_elixir:lib/spotter_web/live/route_live/index.ex
defmodule SpotterWeb.RouteLive.Index do
  use SpotterWeb, :live_view

  alias Spotter.Loggers
  alias Spotter.Loggers.Route

  @impl true
  def mount(_params, _session, socket) do
+   user = socket.assigns.current_user
+   {:ok, stream(socket, :routes, Loggers.list_routes(user.id))}
-   {:ok, stream(socket, :routes, Loggers.list_routes())}
  end
  ...
end
```

## formデータにuser_idを追加する
index.exで初期値に入れて、formのhidden_inputにuser_idを追加してもいいのですが
新規作成のときだけ元のパラメータにuser_idを追加するほうが個人的に好みなので、この方法でやっていきます

RouteLiveのFormComponentにassignsを渡していきます
assignsにcurrent_userがあるのでコンポーネントでuser_idで参照できるようにします
```diff_elixir:lib/spotter_web/live/route_live/index.html.heex
<.modal :if={@live_action in [:new, :edit]} id="route-modal" show on_cancel={JS.patch(~p"/routes")}>
  <.live_component
    module={SpotterWeb.RouteLive.FormComponent}
    id={@route.id || :new}
    title={@page_title}
    action={@live_action}
    route={@route}
+   user_id={@current_user.id}
    patch={~p"/routes"}
  />
</.modal>
```

route_paramsにMap.putするときの注意点なのですが
route_paramsのキーは全てStringなので
`Map.put(params, :user_id, 1)`とStringのキーの中にAtomを入れるとエラーになります
キーの型は統一するようにしましょう

```diff_elixir:lib/spotter_web/live/route_live/form_component.ex
defmodule SpotterWeb.RouteLive.FormComponent do
  use SpotterWeb, :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が見えないので修正します

```diff_elixir:lib/spotties_web/components/core_components.ex:L477
defmodule SpotterWeb.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もモーダルにします

最初はindex.ex
handle_paramsのapply_actionの最後にdeleteを追加して、
削除実行後リダイレクト処理とフラッシュメッセージを出すようにします

```diff_elixir:lib/spotter_web/live/route_live/index.ex
defmodule SpotterWeb.RouteLive.Index do
  use SpotterWeb, :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, stream_delete(socket,:routes, route)}
+   socket
+   |> stream_delete(:routes, route)
+   |> put_flash(:info, "Route deleted successfully")
+   |> push_navigate(to: ~p"/routes")
+   |> then(&{:noreply, &1})
  end
  ...
end
```

tableのアクションを data-confirm linkから /deleteのリンクに変更し、
index.html.heexの末尾に :deleteの時に表示されるconfirm modalを追加します

```diff_elixir:lib/spotter_web/live/route_live/index.html.heex
<.table
  id="routes"
  rows={@streams.routes}
  row_click={fn {_id, route} -> JS.navigate(~p"/routes/#{route}") end}
>
  <:col :let={{_id, route}} label="Name"><%= route.name %></:col>
  <:action :let={{_id, route}}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
- </:action>
- <:action :let={{id, route}}>
-   <.link
-     phx-click={JS.push("delete", value: %{id: route.id}) |> hide("##{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")}>
+   <p class="mb-4 text-lg">Are you sure?</p>
+   <.button phx-click={JS.push("delete", value: %{id: @route.id})}>Delete</.button>
+ </.modal>
 ```

最後にroutingにdeleteを追加します

```diff_elixir:lib/spotter_web/router.ex
defmodule SpotterWeb.Router do
  use SpotterWeb, :router
  ...
  scope "/", SpotterWeb do
    pipe_through([:browser, :require_authenticated_user])

    live_session :require_authenticated_user,
      on_mount: [{SpotterWeb.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/delete", RouteLive.Index, :delete)

      live("/routes/:id", RouteLive.Show, :show)
      live("/routes/:id/show/edit", RouteLive.Show, :edit)
    end
  end
  ...
end
```
これでconfirm modalができました

## トップページの変更
ログイン後のトップページを `/routes`に変更します

```diff_elixir:phoenix/lib/spotties_web/controllers/page_controller.ex
defmodule SpotterWeb.PageController do
  use SpotterWeb, :controller
  alias Spotter.Accounts.User
  ...
  def redirect_to(conn, %User{}) do
-   redirect(conn, to: ~p"/users/settings")
+   redirect(conn, to: ~p"/routes")
  end
end
```

# 共通コンポーネントの作成
phx.gen.authで作られたグローバルメニューを消して、ログイン後はsettingに移動できないので、以下のナビゲーション共通コンポーネントを作ります

- gheader
- bottom_tab

# default headerを削除
まず元のヘッダーを消します

```diff_elixir:lib/spotter_web/components/layouts/app.html.heex
- <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">
  <div class="mx-auto max-w-2xl">
    ...
    <%= @inner_content %>
  </div>
</main>
```


## gheader
ベースはこちらになります

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

attrはタイトル
slot backは戻るボタンの戻る先をつけた`<.link>`を渡す
slot actionsはEditやAddのリンクを指定した`<.link>`を渡す
attrとslotはdocオプションで注釈等をつけることができます

```elixir:lib/spotter_web/components/navigation.ex
defmodule SpotterWeb.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-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
```

## bottom_tab
ベースはこちらになります

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


```lib/spotter_web/components/navigation.ex
defmodule SpotterWeb.Components.Navigation do
  use Phoenix.Component

  ...
  attr(:title, :string, default: "")

  def bottom_tab(assigns) do
    ~H"""
    <div class="btm-nav">
      <%= for {title, icon ,path} <- links() do %>
        <a href={path} class={if @title == 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
    [
      {"Route", "hero-book-open-solid", "/routes"},
      {"Setting", "hero-cog-6-tooth-solid", "/users/settings"}
    ]
  end
end
```

## PJ全体で読み込む
CoreComponentですが`lib/spotter_web`に追加することでPJ全体で使えるようにしてあるので、共通コンポーネントであるNavigationも追加します
```diff_elixir:lib/spotter_web.ex:L83-98
defmodule SpotterWeb do
  ...
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
      import SpotterWeb.CoreComponents
+     import SpotterWeb.Components.Navigation
      import SpotterWeb.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 に追加
随所に追加していきます

```diff_elixir:lib/spotter_web/live/route_live/index.html.heex
+ <.gheader title="Routes">
+   <:actions>
+     <.link patch={~p"/routes/new"}>
+         <.icon name="hero-plus-solid" class="h-8 w-8 mr-4" />
+     </.link>
+   </:actions>
+ </.gheader>

- <.header>
- Listing Routes
-  <:actions>
-    <.link patch={~p"/routes/new"}>
-      <.button>New Route</.button>
-    </.link>
-  </:actions>
- </.header>

<.table
  id="routes"
  rows={@streams.routes}
  row_click={fn {_id, route} -> JS.navigate(~p"/routes/#{route}") end}
>
  <:col :let={{_id, route}} label="Name"><%= route.name %></:col>
  <:action :let={{_id, route}}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
    <.link patch={~p"/routes/#{route}/delete"}>Delete</.link>    
  </:action>
</.table>

+ <.bottom_tab title="Route" />
...
```

```diff_elixir:lib/spotter_web/live/route_live/show.html.heex
- <.header>
-   Route <%= @route.id %>
-   <:subtitle>This is a route record from your database.</:subtitle>
-   <:actions>
-     <.link patch={~p"/routes/#{@route}/show/edit"} phx-click={JS.push_focus()}>
-       <.button>Edit route</.button>
-      </.link>
-   </:actions>
- </.header>
+ <.gheader title={@route.name}>
+  <:back>
+    <.link navigate={~p"/routes"}>
+      <.icon name="hero-chevron-left-solid" class="h-6 w-6"/>
+    </.link>
+  </:back>
+  <:actions>
+    <.link patch={~p"/routes/#{@route}/show/edit"} phx-click={JS.push_focus()}>
+      <.icon name="hero-pencil-square-solid" class="h-6 w-6 mr-4" />
+    </.link>
+  </:actions>
+ </.gheader>


<.list>
  <:item title="Name"><%= @route.name %></:item>
</.list>

- <.back navigate={~p"/routes"}>Back to routes</.back>
+ <.bottom_tab title="Route" />
...
```
ついでにログアウトボタンもつけます
```diff_elixir:phoenix/lib/spotties_web/live/user_settings_live.ex
defmodule SpottiesWeb.UserSettingsLive do
  use SpottiesWeb, :live_view

  alias Spotties.Accounts

  def render(assigns) do
    ~H"""
+   <.gheader title="Setting" />
    <div>
      ... 
    </div>
+   <.button class="mt-4"><.link href={~p"/users/log_out"} method="delete">Log out</.link></.button>
+   <.bottom_tab title="Setting" />
    """
  end
  ...
end
```

# 動作確認

![81b98d1ec3d80aa0579f03a8716f82f6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/444e885c-e10a-97b9-2ac0-a3300a58bada.gif)


タブもナビゲーションもちゃんと動いてますね

# 最後に
CRUDの作成とリレーション設定とナビゲーションコンポーネントを作成してスマホっぽいUIができました
次はGoogleMapの表示を行いたいと思います

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