【Elixir】Phoenix/LiveView でフォロー機能を実装する手順
はじめに
今回は Phoenix の LiveView でユーザーをフォローする機能を作成します。
ソースコードの全体は僕の Github リポジトリにあります。
要件
- トップページ以外はログイン必須にする
- 指定ユーザーをフォロー、フォロー解除できる
- フォロー数・フォロワー数を更新できる。
- フォローリスト・フォロワーリストを表示できる
準備
予め Phoenix と PostgreSQL をセットアップしておきます。
devcontainer を使うと環境構築が楽です。
プロジェクトの作成
以下の一連のコマンドで認証機能の生成まで完了します。
mix phx.new app --install
cd app
mix setup
mix phx.gen.auth Accounts User users --live
mix deps.get
mix ecto.reset
シードデータの作成
毎回ユーザーを登録するのは大変なのでシードファイルに何人かユーザーを追加しておきます。
mix ecto.reset
を実行すると最後にシードファイルが読み込まれます。
{:ok, _} =
App.Accounts.register_user(%{
email: "test@example.com",
password: "passpassword"
})
{:ok, _} =
App.Accounts.register_user(%{
email: "dev@example.com",
password: "passpassword"
})
プロフィールページの作成
まずはプロフィールページを作成します。
/profiles/123
のように user_id を指定してアクセスできるようにします。
defmodule AppWeb.ProfileLive do
use AppWeb, :live_view
alias App.Accounts
@impl true
def render(assigns) do
~H"""
<.header>
<h1><%= @user.email %></h1>
</.header>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok, assign(socket, user: Accounts.get_user!(id))}
end
end
続いてルーティングを編集します。
ログイン必須の scope にルートを追加します。
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do
# 追加
live "/profiles/:id", ProfileLive
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
end
これでhttp://localhost:4000/profiles/123
のような URL でプロフィールページへアクセスできるようになりました。
フォロー・フォロー解除機能の作成
ユーザー間のフォロー関係はリレーションシップとして扱います。
分かりやすいようにフォローする側を source、フォローされる側を target と呼んでいます。
リレーションシップの作成
以下のコマンドでリレーションシップを作成します。
コンテキスト、スキーマ、マイグレーションファイル、テストファイルが生成されます。
mix phx.gen.context Relationship UserRelationship user_relationship source_user_id:references:users target_user_id:references:users
マイグレーションファイルを編集します。
source_user_id と target_user_id はセットで一意にしたいので複合ユニーク制約にしています。
def change do
create table(:user_relationship) do
add :source_user_id, references(:users, on_delete: :delete_all), null: false
add :target_user_id, references(:users, on_delete: :delete_all), null: false
timestamps(type: :utc_datetime)
end
create index(:user_relationship, [:source_user_id])
create index(:user_relationship, [:target_user_id])
create unique_index(:user_relationship, [:source_user_id, :target_user_id])
e
スキーマファイルを編集します。
belongs_to で各 User と関連付けしています。
defmodule App.Relationship.UserRelationship do
use Ecto.Schema
import Ecto.Changeset
schema "user_relationship" do
belongs_to :source_user, App.Accounts.User
belongs_to :target_user, App.Accounts.User
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user_relationship, attrs) do
user_relationship
|> cast(attrs, [:source_user_id, :target_user_id])
|> validate_required([:source_user_id, :target_user_id])
end
end
フォロー・フォロー解除処理の作成
Relationship コンテキストを編集します。
重複チェックのために get_user_relationship 関数も作成します。データが見つからない場合に raise するものと nil を返すものの 2 パターンあると便利です。
defmodule App.Relationship do
@moduledoc """
The Relationship context.
"""
import Ecto.Query, warn: false
alias App.Repo
alias App.Relationship.UserRelationship
@doc """
条件にマッチするリレーションシップを1つ取得します。見つからない場合はnilを返します。
"""
def get_user_relationship(source_user_id, target_user_id),
do: Repo.one(get_relationship_query(source_user_id, target_user_id))
@doc """
条件にマッチするリレーションシップを1つ取得します。見つからない場合は`Ecto.NoResultsError`をraiseします。
"""
def get_user_relationship!(source_user_id, target_user_id),
do: Repo.one!(get_relationship_query(source_user_id, target_user_id))
defp get_relationship_query(source_user_id, target_user_id) do
from(
r in UserRelationship,
where: r.source_user_id == ^source_user_id and r.target_user_id == ^target_user_id
)
end
@doc """
リレーションシップを作成します。
"""
def create_user_relationship(attrs \\ %{}) do
source_user_id = Map.get(attrs, :source_user_id) || Map.get(attrs, "source_user_id")
target_user_id = Map.get(attrs, :target_user_id) || Map.get(attrs, "target_user_id")
%UserRelationship{}
|> registration_change_user_relationship(source_user_id, target_user_id, attrs)
|> Repo.insert()
end
@doc """
リレーションシップを削除します。
"""
def delete_user_relationship(%UserRelationship{} = user_relationship) do
Repo.delete(user_relationship)
end
@doc false
def registration_change_user_relationship(
%UserRelationship{} = user_relationship,
source_user_id,
target_user_id,
attrs \\ %{}
) do
user_relationship
|> UserRelationship.changeset(attrs)
|> Ecto.Changeset.put_assoc(:source_user, App.Accounts.get_user!(source_user_id))
|> Ecto.Changeset.put_assoc(:target_user, App.Accounts.get_user!(target_user_id))
end
end
テストを追加します。
defmodule App.RelationshipTest do
use App.DataCase
alias App.Relationship
describe "user_relationship" do
alias App.Relationship.UserRelationship
import App.RelationshipFixtures
import App.AccountsFixtures
test "get_user_relationship!/2 returns the user_relationship with given id" do
u1 = user_fixture()
u2 = user_fixture()
rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
result = Relationship.get_user_relationship!(u1.id, u2.id)
assert result.id == rel.id
assert result.source_user_id == u1.id
assert result.target_user_id == u2.id
end
test "create_user_relationship/1 with valid data creates a user_relationship" do
u1 = user_fixture()
u2 = user_fixture()
valid_attrs = %{
source_user_id: u1.id,
target_user_id: u2.id
}
assert {:ok, %UserRelationship{}} =
Relationship.create_user_relationship(valid_attrs)
end
test "delete_user_relationship/1 deletes the user_relationship" do
u1 = user_fixture()
u2 = user_fixture()
rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert {:ok, %UserRelationship{}} = Relationship.delete_user_relationship(rel)
assert_raise Ecto.NoResultsError, fn ->
Relationship.get_user_relationship!(u1.id, u2.id)
end
end
test "registration_change_user_relationship/3 returns a user_relationship changeset" do
u1 = user_fixture()
u2 = user_fixture()
rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert %Ecto.Changeset{} =
Relationship.registration_change_user_relationship(rel, u1.id, u2.id)
end
end
end
プロフィールページにフォロー・フォロー解除ボタンを表示
プロフィールページにアクセスした時、ログインユーザーとプロフィールユーザーとのリレーションシップをチェックして、フォローボタン・フォロー解除ボタンを切り替えます。
フォロー・フォロー解除ボタンを押下するとコンテキスト経由で処理され、結果に応じて表示が切り替わります。
ProfileLive を編集します。
assign の show_follow はフォローボタンの表示状態、is_following はフォロー済み状態を表します。
defmodule AppWeb.ProfileLive do
use AppWeb, :live_view
alias App.Accounts
alias App.Relationship
@impl true
def render(assigns) do
~H"""
<.header>
<h1><%= @target.email %></h1>
<:actions :if={@show_follow}>
<.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
フォローする
</.button>
<.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
フォロー解除
</.button>
</:actions>
</.header>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
source = socket.assigns.current_user
target = Accounts.get_user!(id)
rel = Relationship.get_user_relationship(source.id, target.id)
{:ok,
socket
|> assign(target: target)
|> assign(show_follow: source.id != target.id)
|> assign(is_following: !is_nil(rel))}
end
@impl true
def handle_event("follow", %{"id" => target_id}, socket) do
attrs = %{
source_user_id: socket.assigns.current_user.id,
target_user_id: target_id
}
case Relationship.create_user_relationship(attrs) do
{:ok, _} ->
{:noreply,
socket
|> assign(is_following: true)
|> put_flash(:info, "フォローしました")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "フォローの途中でエラーが発生しました")}
end
end
@impl true
def handle_event("unfollow", %{"id" => target_id}, socket) do
rel = Relationship.get_user_relationship!(socket.assigns.current_user.id, target_id)
case Relationship.delete_user_relationship(rel) do
{:ok, _} ->
{:noreply,
socket
|> assign(is_following: false)
|> put_flash(:info, "フォローを解除しました")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "フォロー解除の途中でエラーが発生しました")}
end
end
end
テストを編集します。
操作後の表示が正しいか検証します。
defmodule AppWeb.ProfileLiveTest do
use AppWeb.ConnCase, async: true
setup :register_and_log_in_user
import Phoenix.LiveViewTest
import App.AccountsFixtures
import App.RelationshipFixtures
describe "index" do
test "未フォローの場合、フォローできること", %{conn: conn} do
target = user_fixture()
{:ok, lv, _html} = live(conn, ~p"/profiles/#{target}")
html =
lv
|> element("button", "フォローする")
|> render_click()
assert html =~ "フォロー解除"
assert html =~ "フォローしました"
end
test "フォロー済みの場合、フォロー解除できること", %{conn: conn, user: user} do
target = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
{:ok, lv, _html} = live(conn, ~p"/profiles/#{target}")
html =
lv
|> element("button", "フォロー解除")
|> render_click()
assert html =~ "フォローする"
assert html =~ "フォローを解除しました"
end
end
end
フォロー数・フォロワー数の作成
何人をフォローしていて何人からフォローされているかが分かるようにフォロー数・フォロワー数を追加します。
分かりやすいようにフォロー数は following_count、フォロワー数は followers_count と呼んでいます。
User にフィールドを追加
マイグレーションファイルを編集します。
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
# 追加
add :following_count, :integer, default: 0, null: false
add :followers_count, :integer, default: 0, null: false
timestamps(type: :utc_datetime)
end
スキーマファイルを編集します。
更新用に following_count と followers_count の 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
# 追加
field :following_count, :integer, default: 0
field :followers_count, :integer, default: 0
timestamps(type: :utc_datetime)
end
...
def following_count_changeset(user, attrs) do
user
|> cast(attrs, [:following_count])
|> validate_required([:following_count])
|> validate_number(:following_count, greater_than_or_equal_to: 0)
end
def followers_count_changeset(user, attrs) do
user
|> cast(attrs, [:followers_count])
|> validate_required([:followers_count])
|> validate_number(:followers_count, greater_than_or_equal_to: 0)
end
フォロー数・フォロワー数の更新処理を追加
フォロー時は自分のフォロー数を+1
、相手のフォロワー数を+1
します。フォロー解除時はその逆でそれぞれ-1
します。
集計関数の COUNT でカウントし、following_count と followers_count カラムに反映します。
Relationship コンテキストに関数を追加します。
@doc """
フォロー数を更新します。
"""
def update_following_count(id) do
query = from(r in UserRelationship, where: r.source_user_id == ^id)
count = Repo.aggregate(query, :count, :id)
user = Accounts.get_user!(id)
{:ok, _} =
user
|> User.following_count_changeset(%{following_count: count})
|> Repo.update()
end
@doc """
フォロワー数を更新します。
"""
def update_followers_count(id) do
query = from(r in UserRelationship, where: r.target_user_id == ^id)
count = Repo.aggregate(query, :count, :id)
user = Accounts.get_user!(id)
{:ok, _} =
user
|> User.followers_count_changeset(%{followers_count: count})
|> Repo.update()
end
テストを追加します。
test "update_following_count/1 フォロー数が更新されること" do
u1 = user_fixture()
u2 = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert u1.following_count == 0
assert {:ok, _} = Relationship.update_following_count(u1.id)
assert App.Accounts.get_user!(u1.id).following_count == 1
end
test "update_followers_count/1 フォロワー数が更新されること" do
u1 = user_fixture()
u2 = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert u2.followers_count == 0
assert {:ok, _} = Relationship.update_followers_count(u2.id)
assert App.Accounts.get_user!(u2.id).followers_count == 1
end
プロフィールページにフォロー数・フォロワー数を表示
プロフィールページでフォローするとそのプロフィールユーザーのフォロワー数が変化するため followers_count を assign にします。
リレーションシップの作成・削除後にカウント更新を行います。
ProfileLive を編集します。
with を使ってリレーションシップの作成・削除後にカウント更新を行います。
defmodule AppWeb.ProfileLive do
use AppWeb, :live_view
alias App.Accounts
alias App.Accounts.User
alias App.Relationship
@impl true
def render(assigns) do
~H"""
<.header>
<h1><%= @target.email %></h1>
<:actions :if={@show_follow}>
<.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
フォローする
</.button>
<.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
フォロー解除
</.button>
</:actions>
</.header>
<div class="flex gap-4 py-2">
<div class="p-2"><%= @target.following_count %> フォロー</div>
<div class="p-2"><%= @followers_count %> フォロワー</div>
</div>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
source = socket.assigns.current_user
target = Accounts.get_user!(id)
rel = Relationship.get_user_relationship(source.id, target.id)
{:ok,
socket
|> assign(target: target)
|> assign(show_follow: source.id != target.id)
|> assign(is_following: !is_nil(rel))
|> assign(followers_count: target.followers_count)}
end
@impl true
def handle_event("follow", %{"id" => target_id}, socket) do
attrs = %{
source_user_id: socket.assigns.current_user.id,
target_user_id: target_id
}
with {:ok, _} <- Relationship.create_user_relationship(attrs),
{:ok, _} <- Relationship.update_following_count(attrs.source_user_id),
{:ok, %User{followers_count: followers_count}} <-
Relationship.update_followers_count(attrs.target_user_id) do
{:noreply,
socket
|> assign(is_following: true)
|> assign(followers_count: followers_count)
|> put_flash(:info, "フォローしました")}
else
_ -> {:noreply, put_flash(socket, :error, "フォローの途中でエラーが発生しました")}
end
end
@impl true
def handle_event("unfollow", %{"id" => target_id}, socket) do
rel = Relationship.get_user_relationship!(socket.assigns.current_user.id, target_id)
with {:ok, _} <- Relationship.delete_user_relationship(rel),
{:ok, _} <- Relationship.update_following_count(rel.source_user_id),
{:ok, %User{followers_count: followers_count}} <-
Relationship.update_followers_count(rel.target_user_id) do
{:noreply,
socket
|> assign(is_following: false)
|> assign(followers_count: followers_count)
|> put_flash(:info, "フォローを解除しました")}
else
_ -> {:noreply, put_flash(socket, :error, "フォロー解除の途中でエラーが発生しました")}
end
end
end
テストを編集します。
操作後の表示が正しいことを検証します。
defmodule AppWeb.ProfileLiveTest do
use AppWeb.ConnCase, async: true
setup :register_and_log_in_user
import Phoenix.LiveViewTest
import App.AccountsFixtures
import App.RelationshipFixtures
describe "index" do
test "フォローができること", %{conn: conn, user: user} do
target = user_fixture()
# 相手のプロフィールページへ移動し、フォローボタンをクリックする
{:ok, lv, html} = live(conn, ~p"/profiles/#{target}")
assert html =~ "0 フォロワー"
html =
lv
|> element("button", "フォローする")
|> render_click()
assert html =~ "フォロー解除"
assert html =~ "フォローしました"
assert html =~ "1 フォロワー"
# 自分のプロフィールページへ移動する
{:ok, _lv, html} = live(conn, ~p"/profiles/#{user}")
assert html =~ "1 フォロー"
end
test "フォロー解除ができること", %{conn: conn, user: user} do
target = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
assert {:ok, _} = App.Relationship.update_following_count(user.id)
assert {:ok, _} = App.Relationship.update_followers_count(target.id)
# 相手のプロフィールページへ移動し、フォロー解除ボタンをクリックする
{:ok, lv, html} = live(conn, ~p"/profiles/#{target}")
assert html =~ "1 フォロワー"
html =
lv
|> element("button", "フォロー解除")
|> render_click()
assert html =~ "フォローする"
assert html =~ "フォローを解除しました"
assert html =~ "0 フォロワー"
# 自分のプロフィールページへ移動する
{:ok, _lv, html} = live(conn, ~p"/profiles/#{user}")
assert html =~ "0 フォロー"
end
end
end
フォローリスト・フォロワーリストの作成
誰をフォローしていて誰からフォローされているかが分かるようにユーザーをリスト表示します。
フォローリスト・フォロワーリストの取得はリレーションシップを通して行います。
リストの取得処理を作成
Relationship コンテキストに関数を追加します。
ページング用にオプションで limit と offset を受け取ってその位置のリストを返せるようにしています。
# 1ページあたりの表示件数
@count_per_page 10
@doc """
フォローリストを取得します。
## Options
- limit
- offset
"""
def list_following(id, opts \\ []) do
limit = Keyword.get(opts, :limit, @count_per_page)
offset = Keyword.get(opts, :offset, 0)
query =
from(
r in UserRelationship,
where: r.source_user_id == ^id,
join: u in assoc(r, :target_user),
select: u,
order_by: [desc: r.inserted_at],
limit: ^limit,
offset: ^offset
)
Repo.all(query)
end
@doc """
フォロワーリストを取得します。
## Options
- limit
- offset
"""
def list_followers(id, opts \\ []) do
limit = Keyword.get(opts, :limit, @count_per_page)
offset = Keyword.get(opts, :offset, 0)
query =
from(
r in UserRelationship,
where: r.target_user_id == ^id,
join: u in assoc(r, :source_user),
select: u,
order_by: [desc: r.inserted_at],
limit: ^limit,
offset: ^offset
)
Repo.all(query)
end
テストを追加します。
test "list_following/1 フォローリストが取得できること" do
u1 = user_fixture()
u2 = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert Relationship.list_following(u1.id) == [u2]
end
test "list_followers/1 フォロワーリストが取得できること" do
u1 = user_fixture()
u2 = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})
assert Relationship.list_followers(u2.id) == [u1]
end
リレーションシップページにリストを表示
フォローリスト・フォロワーリストは共通のページで表示し、アクションで切り替えます。
現在のページをパラメータから受け取って limit と offset に変換することでページングもできますがここでは省略しています。
RelationshipLive を作成します。
アクションはsocket.assigns.live_action
で取得できます。
aria-current
はアクセシビリティ属性です。見た目には影響しませんが現在選択しているページをスクリーンリーダーに伝えてくれます。
defmodule AppWeb.RelationshipLive do
use AppWeb, :live_view
alias App.Accounts
alias App.Relationship
@count_per_page 10
@impl true
def render(assigns) do
~H"""
<.header>
<span><%= @target.email %>さんの<%= @page_title %></span>
<:actions>
<.link
patch={~p"/profiles/#{@target.id}/following"}
aria-current={@live_action == :following and "page"}
>
<.button>フォロー中</.button>
</.link>
<.link
patch={~p"/profiles/#{@target.id}/followers"}
aria-current={@live_action == :followers and "page"}
>
<.button>フォロワー</.button>
</.link>
</:actions>
</.header>
<div id="users" phx-update="stream">
<article :for={{dom_id, user} <- @streams.users} id={dom_id}>
<.link navigate={~p"/profiles/#{user}"}>
<h3><%= user.email %></h3>
</.link>
</article>
</div>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(target: Accounts.get_user!(id))
|> stream(:users, [], limit: @count_per_page)}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :following, %{"id" => id}) do
socket
|> assign(page_title: "フォロー中リスト")
|> stream(:users, Relationship.list_following(id), limit: @count_per_page, reset: true)
end
defp apply_action(socket, :followers, %{"id" => id}) do
socket
|> assign(page_title: "フォロワーリスト")
|> stream(:users, Relationship.list_followers(id), limit: @count_per_page, reset: true)
end
end
ルーティングを編集してフォローリストとフォロワーリストのルートを追加します。
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do
live "/profiles/:id", ProfileLive
# 追加
live "/profiles/:id/following", RelationshipLive, :following
live "/profiles/:id/followers", RelationshipLive, :followers
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
テストを追加します。
defmodule AppWeb.RelationshipLiveTest do
use AppWeb.ConnCase, async: true
setup :register_and_log_in_user
import Phoenix.LiveViewTest
import App.AccountsFixtures
import App.RelationshipFixtures
describe "index" do
test "フォローリストが表示できること", %{conn: conn, user: user} do
target = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
{:ok, _lv, html} = live(conn, ~p"/profiles/#{user}/following")
assert html =~ "#{user.email}さんのフォロー中リスト"
assert html =~ target.email
end
test "フォロワーリストが表示できること", %{conn: conn, user: user} do
target = user_fixture()
_rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
{:ok, _lv, html} = live(conn, ~p"/profiles/#{target}/followers")
assert html =~ "#{target.email}さんのフォロワーリスト"
assert html =~ user.email
end
end
end
最後にプロフィールページをリレーションシップページとリンクします。
リレーションシップページは/profiles/123/following
または/profiles/123/followers
のようなパスでアクセスできます。
@impl true
def render(assigns) do
~H"""
<.header>
<h1><%= @target.email %></h1>
<:actions :if={@show_follow}>
<.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
フォローする
</.button>
<.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
フォロー解除
</.button>
</:actions>
</.header>
<div class="flex gap-4 py-2">
<div class="p-2">
<.link navigate={~p"/profiles/#{@target}/following"}>
<%= @target.following_count %> フォロー
</.link>
</div>
<div class="p-2">
<.link navigate={~p"/profiles/#{@target}/followers"}>
<%= @followers_count %> フォロワー
</.link>
</div>
</div>
"""
end