はじめに
この記事はElixirアドベントカレンダー2024のシリーズ2、11日目の記事です
本記事では以下のことを行います
- ログイン・新規登録画面のデザイン修正
- ナビゲーションコンポーネントの実装
今回の作業ブランチを作成します
git checkout -b feature/navigation_component
ログイン・新規登録画面のデザイン修正
不要な機能(Keep me logged in)等が残っているので修正していきます
ログイン状態の場合は以下のコマンドでDBをリセットするかトークンファイルを削除します
mix ecto.reset
rm ~/.config/trarecord/token
新規登録画面
render関数内を以下のように書き換えます
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
変更後は以下のようになります
ログイン画面
ログイン画面も変更していきます、テストのときにクリックする対象を楽に絞り込むためにリンクにID属性を付けています
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
変更後は以下のようになります
ナビゲーションコンポーネントの作成
ナビゲーションコンポーネントとは、ボトムタブやサイドメニューなどアプリの各リンクを表示するためのコンポーネントです
今回は以下の2つを作成します
ヘッダーナビゲーション
センターにページタイトル、両サイドに戻るなどのアクションボタンを配置
Phoenix LiveViewにおけるコンポーネントについて
LiveViewでは表示だけを行うステートレスのPhoenix.Componentと状態やイベントを定義するPhoenix.LiveComponentの2つのコンポーネントがあります
最初に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
コンポーネントの実装を見てみましょう。
@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で生成したモーダルで入力フォームを表示する際に使われています
<.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操作とナビゲーションを行う処理を定義
初期化されたときに1回実行されるmountもあります、詳しくは以下を見てみてください
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コンポーネントはこちらを使います
navigation.exをcomponents配下に作り、そこに各コンポーネントを実装していきます
値としてタイトル、左側のスロットは戻るボタン、右側のスロットはアクションボタンを付けれるようになります。
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追加すると全体で読み込んでもらえます
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になっているので上方向のマージンをコンテンツに対して行います
- <.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>
こんな感じになります
ボトムタブナビゲーション
ボトムタブナビゲーションを作成していきます
UIコンポーネントはこちらを使います
タブのリストをlinks関数に書き出し{タブ名,アイコン,URL}のタプルのリストをforでレンダリングしていきます
また素の Phoenix.Componentだとhtml_helpersが展開されずCoreComponentが使えないので読み込みます
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
フォルダ一覧とユーザー設定画面に組み込む
レイアウト的にテーブルの下に配置します
<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" />
設定画面にも追加します
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
こんな感じになります
これでナビゲーションコンポーネントが完成しました
テストの修正
ログイン・新規登録ページの文言を変更していくつかテストがコケるようになったので修正していきます
folder_live_test
新規作成のボタンが変わったので直します
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'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
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
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
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
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
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ぱすできたのでマージして完了です
最後に
本記事ではユーザーログインと、新規登録画面のデザイン修正とナビゲーションコンポーネントの作成を行いました
次はフォルダ一覧をカードレイアウト化していきます
本記事は以上になりますありがとうございました
参考サイト
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