はじめに
この記事は Elixirアドベントカレンダーのシリーズ4の19日目の記事です
今回はphx.gen.liveでCRUD画面とヘッダーとボトムタブのナビゲーションコンポーネントを作っていきます
DaisyUIのインストール
デフォルトでCSSフレームワークがTailwindなのでTailwindベースのUIライブラリのdaisyuiを使っていきます
installガイドにそってtailwind.config.jsのpluginに追加して完了です
cd assets
npm i daisyui
cd ..
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の配下に追加します
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リレーションを設定します
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が入るようにします
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を実装します
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を渡せるようにします
以下は変更した関数だけ表示しています
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で参照できるようにします
<.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を入れるとエラーになります
キーの型は統一するようにしましょう
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が見えないので修正します
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を追加して、
削除実行後リダイレクト処理とフラッシュメッセージを出すようにします
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を追加します
<.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を追加します
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
に変更します
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を削除
まず元のヘッダーを消します
- <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オプションで注釈等をつけることができます
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
ベースはこちらになります
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も追加します
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 に追加
随所に追加していきます
+ <.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" />
...
- <.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" />
...
ついでにログアウトボタンもつけます
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
動作確認
タブもナビゲーションもちゃんと動いてますね
最後に
CRUDの作成とリレーション設定とナビゲーションコンポーネントを作成してスマホっぽいUIができました
次はGoogleMapの表示を行いたいと思います
本記事は以上になりますありがとうございました