この記事はElixirアドベントカレンダー2024のシリーズ2、10日目の記事です
本記事では、スポットを分類するためのフォルダのCRUD画面とユーザーとのリレーションを組んでいきます
今回の作業ブランチを作成します
git checkout -b feature/folder_crud
CRUD画面の作成
CRUD画面を作成するコマンドは以下になります
mix phx.gen.live [コンテキスト名] [スキーマ名] [テーブル名] [カラム名:データ型]...
次のコマンドを実行します
user has_many foldersの関連にしたいので、外部キー制約をつけるために
mix phx.gen.live Folders Folder folders name:string user_id:references:users
実行すると以下のログがでて色々作成されているのがわかります
最後の方にルーティングの追加とマイグレーションの実行が指示されているのでこちらを行っていきます
* creating lib/trarecord_web/live/folder_live/show.ex
* creating lib/trarecord_web/live/folder_live/index.ex
* creating lib/trarecord_web/live/folder_live/form_component.ex
* creating lib/trarecord_web/live/folder_live/index.html.heex
* creating lib/trarecord_web/live/folder_live/show.html.heex
* creating test/trarecord_web/live/folder_live_test.exs
* creating lib/trarecord/folders/folder.ex
* creating priv/repo/migrations/20241214150303_create_folders.exs
* creating lib/trarecord/folders.ex
* injecting lib/trarecord/folders.ex
* creating test/trarecord/folders_test.exs
* injecting test/trarecord/folders_test.exs
* creating test/support/fixtures/folders_fixtures.ex
* injecting test/support/fixtures/folders_fixtures.ex
Add the live routes to your browser scope in lib/trarecord_web/router.ex:
live "/folders", FolderLive.Index, :index
live "/folders/new", FolderLive.Index, :new
live "/folders/:id/edit", FolderLive.Index, :edit
live "/folders/:id", FolderLive.Show, :show
live "/folders/:id/show/edit", FolderLive.Show, :edit
Remember to update your repository by running migrations:
$ mix ecto.migrate
scope "/", TrarecordWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{TrarecordWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
live "/onboarding", OnboardingLive.Index, :index
+ live "/folders", FolderLive.Index, :index
+ live "/folders/new", FolderLive.Index, :new
+ live "/folders/:id/edit", FolderLive.Index, :edit
+
+ live "/folders/:id", FolderLive.Show, :show
+ live "/folders/:id/show/edit", FolderLive.Show, :edit
end
end
mix ecto.migrate
リレーションの設定
Ectoでデータを取得した際に user.foldersでアクセスできるようにhas_many
をUserに定義します
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime
+ has_many :folders, Trarecord.Folders.Folder
timestamps(type: :utc_datetime)
end
Ectoでデータを取得した際にfolder.userでアクセスできるようにと, user_idを外部キーとして使えるようにbelong_toをFolderに定義します
schema "folders" do
field :name, :string
- field :user_id, :id
+ belongs_to :user, Trarecord.Accounts.User
timestamps(type: :utc_datetime)
end
保存・更新時にフォームパラメータを構造体に値としていれられるようにするために、castのリストに:user_idを追加します
ついでにuser_idをバリデーションの必須項目として設定します
def changeset(folder, attrs) do
folder
- |> cast(attrs, [:name])
- |> validate_required([:name])
+ |> cast(attrs, [:name, :user_id])
+ |> validate_required([:name, :user_id])
end
保存時にuser_idを渡す
フォルダを保存する際にuser_idが必要なのですが
フォームはlive_componentで、index.exはlive_viewでそれぞれsocket.assingsは独立しているためindex(live_view)からフォームコンポーネント(live_component)にuser_idを渡す必要があります
<.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>
渡された値はsocket.assignsに入っているので
パラメータに新たにuser_idを追加してフォルダの作成をするようにしています
_paramsのキーは文字列なのと、マップのキーは文字列とアトムの片方しか使えないので気をつけましょう
defp save_folder(socket, :new, folder_params) do
+ folder_params = Map.put(folder_params, "user_id", socket.assigns.user_id)
case Folders.create_folder(folder_params) do
{:ok, folder} ->
notify_parent({:saved, folder})
{:noreply,
socket
|> put_flash(:info, "Folder created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
認証時のリダイレクト先を変更
ナビゲーションがまだないのと、フォルダ一覧をトップページにしたいので変更します
defp redirect_to(conn, %User{}) do
- redirect(conn, to: ~p"/users/settings")
+ redirect(conn, to: ~p"/folders")
end
フォルダ一覧を自身のものだけに変更
このままだと他人のフォルダまで一覧されてしまうので、ユーザーIDで絞り込みます
def list_folders do
Repo.all(Folder)
end
+ def list_folders(user_id) do
+ Folder
+ |> where([f], f.user_id == ^user_id)
+ |> Repo.all()
+ end
@impl true
def mount(_params, _session, socket) do
+ user = socket.assigns.current_user
- {:ok, stream(socket, :folders, Folders.list_folders())}
+ {:ok, stream(socket, :folders, Folders.list_folders(user.id))}
end
動作確認
ログイン済みのアカウントでフォルダを作ったあと、別のユーザーを作成して、他のユーザーのフォルダが表示されないのを確認できました
テスト修正
ジェネレーターで生成されたテストに以下の修正を入れていきます
- folder_fixtureを folder factroyに置き換え
- ログイン後のリダイレクトさきをフォルダ一覧に変更
ファクトリの追加
最初にFolderFactoryを作成します
build(:user)
とすることで保存前の構造体を渡して、insert(:folder)
時にユーザーを作成したうえでフォルダを作成してくれます
defmodule Trarecord.FolderFactory do
@moduledoc """
Folder Factory for ExMachina
"""
defmacro __using__(_opts) do
alias Trarecord.Folders.Folder
quote do
def folder_factory do
%Folder{
name: "new folder",
user: build(:user)
}
end
end
end
end
FolderFactoryを作成したのでFactoryで読み込みます
defmodule Trarecord.Factory do
use ExMachina.Ecto, repo: Trarecord.Repo
use Trarecord.UserFactory
+ use Trarecord.FolderFactory
end
folders_test
folder_fixture()をinsert(:folder)に置き換えます
defmodule Trarecord.FoldersTest do
use Trarecord.DataCase
alias Trarecord.Folders
describe "folders" do
alias Trarecord.Folders.Folder
- import Trarecord.FoldersFixtures
@invalid_attrs %{name: nil}
test "list_folders/0 returns all folders" do
- folder = folder_fixture()
+ folder = insert(:folder)
assert Folders.list_folders() == [folder]
end
test "get_folder!/1 returns the folder with given id" do
- folder = folder_fixture()
+ folder = insert(:folder)
assert Folders.get_folder!(folder.id) == folder
end
test "create_folder/1 with valid data creates a folder" do
+ user = insert(:user)
- valid_attrs = %{name: "some name"}
+ valid_attrs = %{name: "some name", user_id: user.id}
assert {:ok, %Folder{} = folder} = Folders.create_folder(valid_attrs)
assert folder.name == "some name"
end
test "create_folder/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Folders.create_folder(@invalid_attrs)
end
test "update_folder/2 with valid data updates the folder" do
- folder = folder_fixture()
+ folder = insert(:folder)
update_attrs = %{name: "some updated name"}
assert {:ok, %Folder{} = folder} = Folders.update_folder(folder, update_attrs)
assert folder.name == "some updated name"
end
test "update_folder/2 with invalid data returns error changeset" do
- folder = folder_fixture()
+ folder = insert(:folder)
assert {:error, %Ecto.Changeset{}} = Folders.update_folder(folder, @invalid_attrs)
assert folder == Folders.get_folder!(folder.id)
end
test "delete_folder/1 deletes the folder" do
- folder = folder_fixture()
+ folder = insert(:folder)
assert {:ok, %Folder{}} = Folders.delete_folder(folder)
assert_raise Ecto.NoResultsError, fn -> Folders.get_folder!(folder.id) end
end
test "change_folder/1 returns a folder changeset" do
- folder = folder_fixture()
+ folder = insert(:folder)
assert %Ecto.Changeset{} = Folders.change_folder(folder)
end
end
end
これでテストを実行すると以下のエラーになるので、値のアサーションではなく、idでのパターンマッチを行うか get_folderやlist_folder時にpreload(:user)でユーザーの情報も一緒に取得することで解消できます
Assertion with == failed
code: assert folder == Folders.get_folder!(folder.id)
left: %Trarecord.Folders.Folder{
__meta__: #Ecto.Schema.Metadata<:loaded, "folders">,
id: 7,
inserted_at: ~U[2024-12-16 04:30:15Z],
name: "new folder",
updated_at: ~U[2024-12-16 04:30:15Z],
user: #Trarecord.Accounts.User<__meta__: #Ecto.Schema.Metadata<:loaded, "users">, id: 3664, email: "user6@example.com", confirmed_at: nil, folders: #Ecto.Association.NotLoaded<association :folders is not loaded>, inserted_at: ~U[2024-12-16 04:30:15Z], updated_at: ~U[2024-12-16 04:30:15Z], ...>,
user_id: 3664
}
right: %Trarecord.Folders.Folder{
__meta__: #Ecto.Schema.Metadata<:loaded, "folders">,
id: 7,
inserted_at: ~U[2024-12-16 04:30:15Z],
name: "new folder",
updated_at: ~U[2024-12-16 04:30:15Z],
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
user_id: 3664
}
stacktrace:
test/trarecord/folders_test.exs:46: (test)
今回はidや更新日時でのパターンマッチのアサーションに変更します
パターンマッチのアサーションは左辺が変数ではなく固定された値である必要があるので
^
(ピン演算子)でidを固定します
ピン演算子を付ける場合はプリミティブな値である必要があるので、フォルダの取得時にパターンマッチでidだけの変数を作ります
test "list_folders/0 returns all folders" do
- folder = insert(:folder)
+ %{id: id} = insert(:folder)
- assert Folders.list_folders() == [folder]
+ assert [%{id: ^id}] = Folders.list_folders()
end
test "get_folder!/1 returns the folder with given id" do
- folder = insert(:folder)
+ %{id: id} = insert(:folder)
- assert Folders.get_folder!(folder.id) == folder
+ assert %{id: ^id} = Folders.get_folder!(id)
end
更新のテストは更新されてないかのチェックなのでupdated_atでパターンマッチを行っています
test "update_folder/2 with invalid data returns error changeset" do
- folder = insert(:folder)
+ %{updated_at: update} = folder = insert(:folder)
assert {:error, %Ecto.Changeset{}} = Folders.update_folder(folder, @invalid_attrs)
+ assert %{updated_at: ^update} = Folders.get_folder!(folder.id)
end
folder_live_test
live_viewテストはログインしている必要があるので、 setup :register_and_log_in_userでログインした状態でテストが開始されます
またcreate_folderでフォルダを作成した状態でテストを実行する場合は以下のようにパイプ演算子で繋がれて実行されるので、引数からuserをパターンマッチで取得してログインしたユーザーでフォルダを作成しています
conn
|> register_and_login_user
|> create_folder
deffmodule TrarecordWeb.FolderLiveTest do
use TrarecordWeb.ConnCase
import Phoenix.LiveViewTest
- import Trarecord.FoldersFixtures
@create_attrs %{name: "some name"}
@update_attrs %{name: "some updated name"}
@invalid_attrs %{name: nil}
+ setup :register_and_log_in_user
- defp create_folder(_) do
+ defp create_folder(%{user: user}) do
- folder = fixture_folder()
+ folder = insert(:folder, user: user)
%{folder: folder}
end
...
end
user_session_controller_test
ログイン後にリダイレクトされる場所を/foldersに変更したので修正します
test "logs the user in", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log_in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 302)
- assert response =~ "/users/settings"
+ assert response =~ "/folders"
end
user_registration_live_test
ログイン後にリダイレクトされる場所を/foldersに変更したので修正します
test "creates account and logs the user in", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
email = unique_user_email()
form = form(lv, "#registration_form", user: params_for(:user_form_data, email: email))
render_submit(form)
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) == ~p"/onboarding"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 302)
- assert response =~ "/users/settings"
+ assert response =~ "/folders"
end
CIも無事パスしたのでマージして完了です
最後に
今回はCRUD画面とリレーションの作成を行いました
関連付けのカラムをたしてもリレーションの設定はやってはくれませんが、変更箇所はそこまで多くないのでなんとかなるでしょう
次はデザインの修正とナビゲーションコンポーネントを実装していきます
本記事は以上になりますありがとうございました