8
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?

More than 1 year has passed since last update.

はじめに

この記事は Elixirアドベントカレンダーのシリーズ4の19日目の記事です

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

DaisyUIのインストール

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

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

cd assets
npm i daisyui
cd ..
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の配下に追加します

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リレーションを設定します

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が入るようにします

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を渡せるようにします

以下は変更した関数だけ表示しています

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で参照できるようにします

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を入れるとエラーになります
キーの型は統一するようにしましょう

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が見えないので修正します

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を追加して、
削除実行後リダイレクト処理とフラッシュメッセージを出すようにします

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を追加します

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を追加します

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に変更します

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を削除

まず元のヘッダーを消します

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

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

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

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

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

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も追加します

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 に追加

随所に追加していきます

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" />
...
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" />
...

ついでにログアウトボタンもつけます

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

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

最後に

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

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

8
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
8
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?