15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2022

Day 18

Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編

Last updated at Posted at 2022-12-19

はじめに

本記事は Qiita AdventCalendar2022 Elixir vol4 18日目の記事です

セットアップ編でPhoenixプロジェクトを新たに作成し、それをスマホアプリ化してトップページを表示できるところまでやりました
今回はphx.gen.liveでCRUD画面とヘッダーとボトムタブのナビゲーションコンポーネントを作っていきます

ElixirDesktopでスマホアプリ作成シリーズ

  1. Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編
  2. Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編
  3. Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編
  4. Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編

DaisyUIのインストール

デフォルトでCSSフレームワークがTailwindなのでTailwindベースのUIライブラリのdaisyuiを使っていきます

installガイドにそってtailwind.config.jsのpluginに追加して完了です

cd assets
npm i daisyui
phoenix/assets/tailwind.config.js

const plugin = require("tailwindcss/plugin")

module.exports = {
  ...
  plugins: [
    require("@tailwindcss/forms"),
+    require("daisyui"),
    plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
    plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
    plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
    plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
  ]
}

CRUD

CRUD画面を作っていきます
なんのアプリか特に決めてなかったのですが、書籍管理にでもしましょうか
ユーザー > 棚 > 本 な感じで本とファイルアップロードまでだとでかくなるので棚を登録するところまでをやっていきましょう
GPS Loggerにしようと思います!
ユーザー > ルート > 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:refrences:users

routerの認証が必要なsessionの配下に追加します

phoenix/lib/spotties_web/router.ex
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router
  ...
  scope "/", SpottiesWeb do
    pipe_through([:browser, :require_authenticated_user])

    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.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リレーションを設定します

phoenix/lib/spotties/accounts/user.ex
defmodule Spotties.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, Spotties.Loggers.Route)

    timestamps()
  end
  ...
end

belongs_to リレーションと
castでchangesetの保存時にuser_idが入るようにします

phoenix/lib/spotties/loggers/route.ex
defmodule Spotties.Loggers.Route do
  use Ecto.Schema
  import Ecto.Changeset

  schema "routes" do
    field(:name, :string)
+    belongs_to(:user, Spotties.Accounts.User)

    timestamps()
  end

  @doc false
  def changeset(route, attrs) do
    shelf
-    |> 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を実装します

phoenix/lib/spotties/routes.ex
defmodule Spotties.Loggers do
  ...
  def list_routes(user_id) do
    from(
      route in Route,
      where: route.user_id == ^user_id
    )
    |> Repo.all()
  end
  ...
end

index.exでユーザーに関連付けられたrouteを取得する

list_routesにuser_idを渡せるようにします

以下は変更した関数だけ表示しています

phoenix/lib/spotties_web/live/route_live/index.ex
defmodule SpottiesWeb.RouteLive.Index do
  use SpottiesWeb, :live_view

  alias Spotties.Loggers
  alias Spotties.Loggers.Route

  @impl true
  def mount(_params, _session, socket) do
+    user = socket.assigns.current_user
+    {:ok, assign(socket, :routes, list_routes(user.id))}
-    {:ok, assign(socket, :routes, list_routes())}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
+    user = socket.assigns.current_user
    shelf = Loggers.get_route!(id)
    {:ok, _} = Loggers.delete_route(route)

-    {:noreply, assign(socket, :routes, list_routes())}
+    {:noreply, assign(socket, :routes, list_routes(user.id))}
  end

-  defp list_routes() do
-    Loggers.list_routes()
-  end
+  defp list_routes(user_id) do
+    Loggers.list_routes(user_id)
+  end
end

formデータにuser_idを追加する

index.exで初期値に入れて、formのhidden_inputにuser_idを追加してもいいのですが
新規作成のときだけ元のパラメータにuser_idを追加するほうが個人的に好みなので、この方法でやっていきます

RouteLiveのFormComponentにassignsを渡していきます
assignsにcurrent_userがあるのでコンポーネントでuser_idで参照できるようにします

phoenix/lib/spotties_web/live/route_live/index.html.heex
<.modal
  :if={@live_action in [:new, :edit]}
  id="shelf-modal"
  show
  on_cancel={JS.navigate(~p"/routes")}
>
  <.live_component
    module={SpottiesWeb.RouteLive.FormComponent}
    id={@route.id || :new}
    title={@page_title}
    action={@live_action}
    route={@route}
+    user_id={@current_user.id}
    navigate={~p"/rotues"}
  />
</.modal>

route_paramsにMap.putするときの注意点なのですが
route_paramsのキーは全てStringなので
Map.put(params, :user_id, 1)とStringのキーの中にAtomを入れるとエラーになります
キーの型は統一するようにしましょう

phoenix/lib/spotties_web/live/route_live/form_component.ex
defmodule SpottiesWeb.RouteLive.FormComponent do
  use SpottiesWeb, :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が見えないので修正します

phoenix/lib/spotties_web/components/core_components.ex:L443
defmodule SpottiesWeb.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もモーダルにします

core_component.exにconfirm modalの例があったのでそれを参考にします

phoenix/lib/spotties_web/components/core_components.ex:L17-36
  @doc """
  Renders a modal.

  ## Examples

      <.modal id="confirm-modal">
        Are you sure?
        <:confirm>OK</:confirm>
        <:cancel>Cancel</:cancel>
      </.modal>

  JS commands may be passed to the `:on_cancel` and `on_confirm` attributes
  for the caller to react to each button press, for example:

      <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}>
        Are you sure you?
        <:confirm>OK</:confirm>
        <:cancel>Cancel</:cancel>
      </.modal>
  """

最初はindex.ex
handle_paramsのapply_actionの最後にdeleteを追加して、
削除実行後リダイレクト処理とフラッシュメッセージを出すようにします

phoenix/lib/spotties_web/live/route_live/index.ex
defmodule SpottiesWeb.RouteLive.Index do
  use SpottiesWeb, :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, assign(socket, :routes, list_routes(user.id))}
+   socket
+   |> assign(:routes, list_routes(user.id))
+   |> put_flash(:info, "Route deleted successfully")
+   |> push_navigate(to: ~p"/routes")
+   |> then(&{:noreply, &1})
  end
  ...
end

index.html.heexの末尾に :deleteの時に表示されるconfirm modalを追加します
on_cancelにcancelボタンを押した時の挙動(モーダルを閉じる)
on_confirmにOKボタン(delete)を押した時の挙動(deleteイベントを発火)
をそれぞれ渡しています

phoenix/lib/spotties_web/live/route_live/index.html.heex
<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>

<.table id="routes" rows={@routes} row_click={&JS.navigate(~p"/routes/#{&1}")}>
  <:col :let={route} label="Name"><%= route.name %></:col>
  <:action :let={route}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
  </:action>
  <:action :let={route}>
-    <.link phx-click={JS.push("delete", value: %{id: route.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")}
+   on_confirm={JS.push("delete", value: %{id: @route.id})}
+ >
+   <p class="m-8">Are you sure?</p>
+   <:confirm><span class="mx-8 text-neutral-200">Delete</span></:confirm>
+   <:cancel><span class="mx-8">Cancel</span></:cancel>  
+ </.modal>

deleteが黒なのは気に食わないので赤にします
該当のclassは96行目にあります

phoenix/lib/spotties_web/components/core_components.ex:L48-115
defmodule SpottiesWeb.CoreComponents do
  ...
  def modal(assigns) do
    ~H"""
    <div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden">
      <div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" />
      <div ...>
        <div class="flex min-h-full items-center justify-center">
          <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
            <.focus_wrap ...>
              <div class="absolute top-6 right-5">
                <button...>
                  <Heroicons.x_mark solid class="h-5 w-5 stroke-current" />
                </button>
              </div>
              <div id={"#{@id}-content"}>
                <header :if={@title != []}>...</header>
                <%= render_slot(@inner_block) %>
                <div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5">
                  <.button
                    :for={confirm <- @confirm}
                    id={"#{@id}-confirm"}
                    phx-click={@on_confirm}
                    phx-disable-with
-                    class="py-2 px-3"
+                    class="py-2 px-3 bg-red-400 hover:bg-red-600"
                  >
                    <%= render_slot(confirm) %>
                  </.button>
                  <.link
                    :for={cancel <- @cancel}
                    phx-click={hide_modal(@on_cancel, @id)}
                    class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
                  >
                    <%= render_slot(cancel) %>
                  </.link>
                </div>
              </div>
            </.focus_wrap>
          </div>
        </div>
      </div>
    </div>
    """
  end
  ...
end

最後にroutingにdeleteを追加します

phoenix/lib/spotties_web/router.ex
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router
  ...
  scope "/", SpottiesWeb do
    pipe_through([:browser, :require_authenticated_user])

    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.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", ShelfLive.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に変更します

phoenix/lib/spotties_web/controllers/page_controller.ex
defmodule SpottiesWeb.PageController do
  use SpottiesWeb, :controller
  alias Spotties.Accounts.User

  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end

  def index(conn, _params) do
    redirect_to(conn, conn.assigns.current_user)
  end

  def redirect_to(conn, %User{}) do
-    redirect(conn, to: ~p"/users/settings")
+    redirect(conn, to: ~p"/routes")
  end

  def redirect_to(conn, nil) do
    redirect(conn, to: ~p"/users/log_in")
  end
end

CRUDデモ

ここで一回動作確認をしてみましょうか

d9f305408308713da6bc2f40ca95ec05.gif

ちゃんとうごいますね

共通コンポーネントの作成

phx.gen.authで作られたグローバルメニューを消したので、ログイン後はsettingに移動できないので、以下のナビゲーション共通コンポーネントを作ります

  • gheader
  • bottom_tab

default headerを削除

まず元のヘッダーを消して、pyを20から24にします

phoenix/lib/spotties_web/components/layouts/app.html.heex
- <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">
+ <main class="px-4 py-24 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    ...
    <%= @inner_content %>
  </div>
</main>

CoreComponentの解説

core_component.exのheaderを参考にします

phoenix/lib/spotties_web/components/core_components.ex:L398-421
...
  @doc """
  Renders a header with title.
  """
  attr(:class, :string, default: nil)

  slot(:inner_block, required: true)
  slot(:subtitle)
  slot(:actions)

  def header(assigns) do
    ~H"""
    <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
      <div>
        <h1 class="text-lg font-semibold leading-8 text-zinc-800">
          <%= render_slot(@inner_block) %>
        </h1>
        <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
          <%= render_slot(@subtitle) %>
        </p>
      </div>
      <div class="flex-none"><%= render_slot(@actions) %></div>
    </header>
    """
  end
...

使用例

<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>

attrは以下のように指定したものが @classで展開される

<.header class={"bg-black"}>

slotはslot名のタグ(今回は:actions)で囲った内容がrender_solotで表示してくれるというイメージ

  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>

slotで指定していない要素は @inder_block に渡される
この場合は Listing Routesが該当する

<.header>
  Listing Routes
  <:actions>
    <.link patch={~p"/routes/new"}>
      <.button>New Route</.button>
    </.link>
  </:actions>
</.header>

表示フラグは:ifで制御できる slotが指定されていない場合は []が渡されるようだ

<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
  <%= render_slot(@subtitle) %>
</p>

これを踏まえてコンポーネントを作成してみよう

gheader

ベースはこちらになります

attrはタイトル
slot backは戻るボタンの戻る先をつけた<.link>を渡す
slot actionsはEditやAddのリンクを指定した<.link>を渡す
attrとslotはdocオプションで注釈等をつけることができます

phoenix/lib/spotties_web/components/navigation.ex
defmodule SpottiesWeb.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-primary text-primary-content w-full z-10 top-0 left-0">
      <div class="navbar-start">
        <button :if={@back != []} type="button" class="btn btn-ghost normal-case text-xl">
          <%= render_slot(@back) %>
        </button>
      </div>
      <div class="navbar-center">
        <a class="btn btn-ghost normal-case text-4xl">
          <%= @title %>
        </a>
      </div>
      <div class="navbar-end">
        <button :if={@actions != []} type="button" class="btn btn-ghost normal-case text-xl">
          <%= render_slot(@actions) %>
        </button>
      </div>
    </div>
    """
  end
  ...
end

bottom_tab

ベースはこちらになります

attr titleがlinksのタイトルと一致すると borderdクラスを付与するようにしています

phoenix/lib/spotties_web/components/navigation.ex
defmodule SpottiesWeb.Components.Navigation do
  use Phoenix.Component

  ...
  attr(:title, :string, default: "")

  def bottom_tab(assigns) do
    ~H"""
    <div class="btm-nav">
      <%= for {title, path} <- links() do %>
        <a href={path} class={if @title == title, do: "active", else: ""}>
          <button>
            <span class="btm-nav-label"><%= title %></span>
          </button>
        </a>
      <% end %>
    </div>
    """
  end

  defp links() do
    [
      {"Route", "/routes"},
      {"Setting", "/users/settings"}
    ]
  end

  def active?(false), do: ""
  def active?(true), do: "bordered"
end

PJ全体で読み込む

CoreComponentですが lib/xx_webに追加することでPJ全体で使えるようにしてあるので
共通コンポーネントであるNavigationも追加しましょう

phoenix/lib/spotties_web.ex:L83-98
defmodule SpottiesWeb do
  ...
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
      import SpottiesWeb.CoreComponents
+      import SpottiesWeb.Components.Navigation
      import SpottiesWeb.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 に追加

随所に追加していきます

phoenix/lib/spotties_web/live/route_live/index.html.heex
+ <.gheader title="Routes">
+  <:actions>
+    <.link patch={~p"/routes/new"} class="btn btn-ghost normal-case text-4xl">+</.link>
+  </:actions>
+ </.gheader>

<.header>
  Listing Routes
-  <:actions>
-    <.link patch={~p"/routes/new"}>
-      <.button>New Route</.button>
-    </.link>
-  </:actions>
</.header>

<.table id="routes" rows={@routes} row_click={&JS.navigate(~p"/routes/#{&1}")}>
  <:col :let={route} label="Name"><%= route.name %></:col>
  <:action :let={route}>
    <div class="sr-only">
      <.link navigate={~p"/routes/#{route}"}>Show</.link>
    </div>
    <.link patch={~p"/routes/#{route}/edit"}>Edit</.link>
  </:action>
  <:action :let={route}>
    <.link patch={~p"/routes/#{route}/delete"}>Delete</.link>
  </:action>
</.table>

+ <.bottom_tab title="Route" />

<.modal>
...
</.modal>
phoenix/lib/spotties_web/live/route_live/show.html.heex
+ <.gheader title="Route">
+  <:back>
+    <.link navigate={~p"/routes"}>Back</.link>
+  </:back>
+  <:actions>
+    <.link patch={~p"/routes/#{@routes}/show/edit"} phx-click={JS.push_focus()}>
+      Edit
+    </.link>
+  </:actions>
+ </.gheader>

<.header>
  Route <%= @route.id %>
  <:subtitle>This is a shelf record from your database.</:subtitle>
-  <:actions>
-    <.link patch={~p"/routes/#{@route}/show/edit"} phx-click={JS.push_focus()}>
-      <.button>Edit</.button>
-    </.link>
-  </:actions>
</.header>

<.list>
  <:item title="Name"><%= @route.name %></:item>
</.list>

- <.back navigate={~p"/routes"}>Back to Routes</.back>
+ <.bottom_tab title="route" />

<.modal>
...
</.modal>
phoenix/lib/spotties_web/live/user_settings_live.ex
defmodule SpottiesWeb.UserSettingsLive do
  use SpottiesWeb, :live_view

  alias Spotties.Accounts

  def render(assigns) do
    ~H"""
+    <.gheader title="Setting" />
    <.header>Change Email</.header>

    <.simple_form>
    ...
    </.simple_form>

    <.header>Change Password</.header>

    <.simple_form>
    ...
    </.simple_form>

    <.button class="mt-4"><.link href={~p"/users/log_out"} method="delete">Log out</.link></.button>
+    <.bottom_tab title="Setting" />
    """
  end
  ...
end

完成!

117046a83b1c47ac6bbb24a6080c0476.gif

タブもナビゲーションもちゃんと動いてますね

最後に

だいぶ長くなってしましましたが無事CRUDとナビゲーションコンポーネントを作成してスマホっぽいUIができました

これを元に誰かアプリを作って申請を通してみた記事とか書いてくれないかな(チラ

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

15
4
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
15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?