5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ElixirAdvent Calendar 2024

Day 10

ElixirDesktopで作るスマホアプリ Part 6 CRUD画面とリレーションの作成

Posted at

この記事は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
lib/trarecord_web/router.ex:L64
  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に定義します

lib/trarecord/accounts/user.ex:L5
  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に定義します

lib/trarecord/folders/folder.ex:L5
  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をバリデーションの必須項目として設定します

lib/trarecord/folders/folder.ex:L13
  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を渡す必要があります

lib/trarecord_web/live/folder_live/index.html.heex:L32
<.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のキーは文字列なのと、マップのキーは文字列とアトムの片方しか使えないので気をつけましょう

lib/trarecord_web/live/folder_live/form_component.ex:L66
  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

認証時のリダイレクト先を変更

ナビゲーションがまだないのと、フォルダ一覧をトップページにしたいので変更します

lib/trarecord_web/controllers/page_controller.ex:L9
  defp redirect_to(conn, %User{}) do
-   redirect(conn, to: ~p"/users/settings")
+   redirect(conn, to: ~p"/folders")
  end

フォルダ一覧を自身のものだけに変更

このままだと他人のフォルダまで一覧されてしまうので、ユーザーIDで絞り込みます

lib/trarecord/folders.ex:L20
  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
lib/trarecord_web/live/folder_live/index.ex:L7
  @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

動作確認

ログイン済みのアカウントでフォルダを作ったあと、別のユーザーを作成して、他のユーザーのフォルダが表示されないのを確認できました

996ffcd4ecb97140f2f9d731b8fa8968.gif

テスト修正

ジェネレーターで生成されたテストに以下の修正を入れていきます

  • folder_fixtureを folder factroyに置き換え
  • ログイン後のリダイレクトさきをフォルダ一覧に変更

ファクトリの追加

最初にFolderFactoryを作成します
build(:user)とすることで保存前の構造体を渡して、insert(:folder)時にユーザーを作成したうえでフォルダを作成してくれます

test/support/factories/folder_factory.ex
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で読み込みます

test/support/factory.ex
defmodule Trarecord.Factory do
  use ExMachina.Ecto, repo: Trarecord.Repo

  use Trarecord.UserFactory
+ use Trarecord.FolderFactory
end

folders_test

folder_fixture()をinsert(:folder)に置き換えます

test/trarecord/folders_test.exs
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/trarecord/folders_test.exs:L11
    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/trarecord/folders_test.exs:L43
    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
test/trarecord_web/live/folder_live_test.exs
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/trarecord_web/controllers/user_session_controller_test.exs:L9
    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/trarecord_web/live/user_registration_live_test.exs
    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

スクリーンショット 2024-12-16 14.45.59.png

CIも無事パスしたのでマージして完了です

最後に

今回はCRUD画面とリレーションの作成を行いました

関連付けのカラムをたしてもリレーションの設定はやってはくれませんが、変更箇所はそこまで多くないのでなんとかなるでしょう

次はデザインの修正とナビゲーションコンポーネントを実装していきます

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

5
0
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
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?