LoginSignup
5
2

More than 3 years have passed since last update.

PhoenixによるWeb開発 - LiveViewを使う -

Last updated at Posted at 2020-09-09

はじめに

久々にPhoenixのWeb開発を直近行ったので、その報告がてら執筆したいと思います。今回は主にLiveView周りの話です。

開発した機能

現在、開発中のシステムの全体絵はこちら→Webアプリケーションの設計案

そして、本記事の主に設計部分は下記の日報作成部分になります。

関係性.png

実際に日報に書く項目は"仕事"という対象に紐づく"作業項目"になります。そして、更にその作業項目で具体的に何をしたのかを記す"作業内容"を初めて日報という報告書に書くというプロセスとなっております。この作業内容は1つの日報に複数紐付いてもいいようにしたいので、実際に日報を書くフォームは動的に作業内容を書く項目を増やしたり減らしたりすることができるようにします。
それを実装するために、今回はLiveViewを採用する事にしました。

中間層を1対多のアソシエーションにする

まずは、プロジェクト/スポンサーに紐づく作業項目のモデルをhas_manyで紐付けたいと思います。

mix phx.gen.html Propositions Proposition propositions name:string description:text
lib/we_reports/groups/group.ex
defmodule WeReports.Groups.Group do
  (中略)
  schema "groups" do
    field :description, :string
    field :name, :string
    field :type_name, :string
    many_to_many :users, WeReports.UserManager.User, join_through: "groups_users", on_replace: :delete, on_delete: :delete_all
    has_many :propositions, WeReports.Propositions.Proposition, on_delete: :delete_all
    timestamps()
  end
end
lib/we_reports/propositions/proposition.ex
defmodule WeReports.Propositions.Proposition do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset
  schema "propositions" do
    field :description, :string
    field :name, :string
    belongs_to :group, WeReports.Groups.Group
    timestamps()
  end
  @doc false
  def changeset(proposition, attrs) do
    proposition
    |> cast(attrs, [:name, :description, :group_id])
    |> validate_required([:name, :description, :group_id])
  end
end

newアクションについては以下のように変更しました。

lib/we_reports_web/controllers/session_controller.ex
  def new(conn, %{"group_id" => group_id}) do
    group = Groups.get_group!(group_id)
    changeset =
      Ecto.build_assoc(group, :propositions)
      |> WeReports.Propositions.Proposition.changeset(%{})
    render(conn, "new.html", changeset: changeset, group: group)
  rescue
    Ecto.NoResultsError ->
      put_status(conn, :not_found)
      |> put_layout(false)
      |> put_view(WeReportsWeb.ErrorView)
      |> render("404.html", %{})
  end

デフォルトで作成されるcontrollerの記述にはchangesetというメソッドでモデルを用意します。
changesetはgen.htmlでリソースを生成した際に用意されるEcto.Changesetでモデルを生成するための雛形メソッドです。
Ecto.Changesetはモデル構造体を定義する際にvalidationやfilteringをかけたりすることが出来るメソッドが提供されています。
デフォルトではこのchangesetメソッドを利用してモデル構造体を生成しますが、今回外部キーとしてgroup_idを必ず入れています。そのため、デフォルトのchangesetではcastの後にvalidation_requriedメソッドが走ってしまうため、group_idだけ用意したモデルの生成を行うことができません。
そこでnewアクション側でbuild_assocでモデルを生成することとしました。

なお、デフォルトで用意されるchangesetメソッドはあくまでも雛形であるため、これを使ってモデル構造体を生成しようとするのがそもそも良くないという意見もあります(validationをかけるメソッドと生成メソッドと別々に切り分けるとか)。
一先ずは、build_assocでモデルを生成する案を取りたいと思います。

エラーページのカスタマイズ

因みに、上記のURLで書かれているコードはdeprectedなコードなため、公式のサイトを参考に今時な書き方に変更しました。

lib/we_reports_web/controllers/proposition_controller.ex
  def index(conn, %{"group_id" => group_id}) do
    try do
      group = Propositions.list_propositions(group_id)
      render(conn, "index.html", group: group)
    rescue
      Ecto.NoResultsError ->
        put_status(conn, :not_found)
        |> put_layout(false)
        |> put_view(WeReportsWeb.ErrorView)
        |> render("404.html", %{})
    end
  end

スクリーンショット 2020-03-29 22.31.29.png

最下層の作業内容のリソースを日報作成時に同時に作れるようにしたい

LiveViewについて

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html

LiveViewとは、リアルタイムにサーバレンダリングを提供できるPhoenixの機能の一つです。
Phoenixのような、サーバサイドとフロントエンドが密結合なフレームワークの中で、リアルタイム通信とレンダリングのPushが一つの言語で実装可能なこの機能は、正に自分が開発したいアプリケーションに適している事でしょう。
とは言え、PhoenixもLiveViewも中々、参考文献を探すのが難しい部分がありますので一先ずは下記のチュートリアルを参考にLiveViewに対応したフォームを作ることにしました。

lib/we_reports/daily_reports/daily_report.ex
defmodule WeReports.DailyReports.DailyReport do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset

  schema "daily_reports" do
    field :reporting_date, :date
    field :memo, :string
    field :summary, :string
    has_many :articles, WeReports.Articles.Article, on_delete: :delete_all
    belongs_to :user, WeReports.UserManager.User
    timestamps()
  end

  @doc false
  def changeset(daily_report, attrs) do
    daily_report
    |> cast(attrs, [:reporting_date, :memo, :summary, :user_id])
    |> cast_assoc(:articles)
    |> validate_required([:user_id])
  end
end
lib/we_reports/articles/article.ex
defmodule WeReports.Articles.Article do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :body, :string
    field :work_time, :string
    field :delete, :boolean, virtual: true
    field :tmp_id, :string, virtual: true
    belongs_to :daily_report, WeReports.DailyReports.DailyReport
    timestamps()
  end

  @doc false
  def changeset(article, attrs) do
    article
    |> Map.put(:tmp_id, (article.tmp_id || attrs["tmp_id"]))
    |> cast(attrs, [:title, :body, :work_time, :delete])
    |> maybe_mark_for_deletion
  end

  defp maybe_mark_for_deletion(%{data: %{id: nil}} = changeset), do: changeset
  defp maybe_mark_for_deletion(changeset) do
    if get_change(changeset, :delete) do
      %{changeset | action: :delete}
    else
      changeset
    end
  end
end

モデルの定義はこれでOKです。特徴として、動的フォームでArticle(作業内容モデル)を削除するために、tmp_id,deleteというvirtual(DBには保存しない仮のフィールド)を定義しています。これにより、フロントエンドでは、見た目上フォームが削除されたように見せかけるようにしています。

特定のユーザに紐づく日報を作成する

話を簡単にするためにPOSTリクエスト(新規作成アクションのみ)だけのコードを添付します。

lib/we_reports_web/controllers/daily_reports_controller.ex
defmodule WeReportsWeb.DailyReportController do
  use WeReportsWeb, :controller

  alias WeReports.DailyReports
  alias WeReports.DailyReports.DailyReport
  alias WeReports.UserManager.Guardian

  def index(conn, _params) do
    user = Guardian.current_user(conn)
    daily_reports = DailyReports.list_daily_reports
    render(conn, "index.html", daily_reports: daily_reports, user: user)
  end

  def new(conn, _params) do
    changeset = DailyReports.change_daily_report(%DailyReport{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"daily_report" => daily_report_params}) do
    case DailyReports.create_daily_report(daily_report_params) do
      {:ok, daily_report} ->
        conn
        |> put_flash(:success, "日報の作成に成功しました。")
        |> redirect(to: Routes.daily_report_path(conn, :show, daily_report))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end
lib/we_reports_web/templates/daily_reports/new.html.eex
<div class="container">
  <h1>新規日報作成</h1>
    <%= live_render @conn, WeReportsWeb.DailyReportFormLive,
      session: %{
        "action" => Routes.daily_report_path(@conn, :create),
        "csrf_token" => Plug.CSRFProtection.get_csrf_token(),
        "current_user_id" => WeReports.UserManager.Guardian.current_user(@conn).id
      }
    %>
  <span><%= link "日報一覧に戻る", class: "btn btn-outline-secondary", to: Routes.daily_report_path(@conn, :index) %></span>
</div>
lib/we_reports_web/templates/daily_reports/form.html.eex
<%= form_for @changeset, @action, [phx_change: :validate, class: "block", csrf_token: @csrf_token], fn f -> %>
  <%= hidden_input f, :user_id %>
  <div class="form-group">
    <%= label(f, :reporting_date, "作業実施日") %>
    <div class="input-group date" id="reporting_date" data-target-input="nearest">
      <%= text_input f, :reporting_date, class: "form-control datetimepicker-input" %>
      <div class="input-group-append" data-target="#reporting_date" data-toggle="datetimepicker">
        <div class="input-group-text"><i class="fa fa-calendar"></i></div>
      </div>
    </div>
    <div class="form-group">
      <%= label(f, :memo, "メモ") %>
      <%= textarea f, :memo, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(f, :memo, "まとめ・感想") %>
      <%= textarea f, :summary, class: "form-control" %>
    </div>
  </div>
  <%= inputs_for f, :articles, fn v -> %>
    <div class="form-group">
      <%= label(v, :title, "案件タイトル") %>
      <%= select v, :title, Enum.flat_map(@user.groups, fn(g) -> Enum.map(g.propositions, fn(p) -> {"#{g.name} / #{p.name}", "#{g.name} / #{p.name}"} end) end), class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(v, :body, "作業内容") %>
      <%= textarea v, :body, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(v, :work_time, "作業時間") %>
      <%= time_input v, :work_time, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label v, :delete, "削除" %><br>
      <%= if is_nil(v.data.tmp_id) do %>
        <%= checkbox v, :delete %>
      <% else %>
        <%= hidden_input v, :temp_id %>
        <a href="#" phx-click="remove-article" phx-value-remove="<%= v.data.tmp_id %>">&times</a>
      <% end %>
    </div>

  <% end %>
  <div class="form-group">
    <a href="#" phx-click="add_article" class="btn btn-success">新規案件の追加</a>
  </div>
  <%= submit "提出", class: "btn btn-primary" %>
<% end %>
lib/we_reports_web/live/daily_reports/daily_report_form_live.ex
defmodule WeReportsWeb.DailyReportFormLive do
  @moduledoc false
  use Phoenix.LiveView
  alias WeReports.Repo
  alias WeReports.{Articles.Article, DailyReports, DailyReports.DailyReport, UserManager}

  @impl true
  def mount(params, session, socket) do
    case UserManager.get_user_groups(session["current_user_id"]) do
      user ->
        daily_report = get_daily_report(session, user)
        changeset =
          DailyReports.change_daily_report(daily_report)
          |> Ecto.Changeset.put_assoc(:articles, daily_report.articles)
        assigns = [
          action: session["action"],
          csrf_token: session["csrf_token"],
          changeset: changeset,
          daily_report: daily_report,
          user: user
        ]
        {:ok, assign(socket, assigns)}
    end
  end

  def render(assigns) do
    WeReportsWeb.DailyReportView.render("form.html", assigns)
  end

  @impl true
  def handle_event("add_article", _params, socket) do
    existing_articles = Map.get(socket.assigns.changeset.changes, :articles, socket.assigns.daily_report.articles)
    articles =
      existing_articles
      |> Enum.concat([
        DailyReports.change_article(%Article{tmp_id: get_tmp_id()})
      ])
    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(:articles, articles)
    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("remove-article", %{"remove" => remove_id}, socket) do
    articles =
      socket.assigns.changeset.changes.articles
      |> Enum.reject(fn %{data: article} ->
        article.tmp_id == remove_id
      end)
    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(:articles, articles)
    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("validate", %{"daily_report" => params}, socket) do
    changeset =
    socket.assigns.daily_report
    |> DailyReport.changeset(params)
    |> Map.put(:action, :insert)
    {:noreply, assign(socket, changeset: changeset)}
  end

  defp get_daily_report(%{"id" => id} = _daily_report_params, user), do: DailyReports.get_daily_report!(id)
  defp get_daily_report(_daily_report_params, user), do: %DailyReport{user_id: user.id, articles: []}
  defp get_tmp_id, do: :crypto.strong_rand_bytes(5) |> Base.url_encode64 |> binary_part(0, 5)
end

要は最初のnewアクション時のレンダリングはnew.html.eexで行い、その中でlive_renderというメソッドを使い、LiveView用のHTMLテンプレートをレンダリングさせています。リアルタイムに変更が加わった場合はこのlive_renderの部分で提供されたHTMLテンプレートに変更が加えられます。そしてそこにcurrent_user_idの変数をもたせることにより、どのユーザに対しての日報を作るのかという事をサーバサイドが認識できるようにしています。

最初は、このlive_renderをlive_redirectにしており、新規作成のリンクを押すと、そこで表現されるフォームは全てLiveView対応にすることを検討していました。

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_redirect/2

しかし、この実装方法には一つ問題があり、LiveViewによるinitializeを行うために、必ずmountメソッドが実行されるのですが、実はこのmountメソッドinitializeの時とLiveViewによるSocketとのコネクション確立時の二回実行されるという仕様の模様です。

そうなると、live_redirectでユーザIDのようなリソースを持たせると一回目のmountメソッド実行にはユーザ検索ができるのですが、二回目のmountメソッド実行時には、live_redirectで渡したユーザリソースが分からなくなり、コネクション確立できずに失敗してしまう問題がありました。

仕方なしにコントローラ側で一部のリソースの取得を固定させ、live_render時にユーザリソースを渡すようにしました。これであれば、二回目のmountメソッド時にもユーザ検索ができるようにしています(他にもGETパラメータでuser_id持たせる方法とかしていましたが、流石にこれはGETパラメータ弄られたらどうしようもないので辞めました)。

動作画面

動作画面です。

日報作成.gif

無事に作成できていますね。

終わりに

LiveViewに関しては、ブランクもあり実装(検討時間含む)には、かなり難航していましたが、とりあえず仕様とできることをちゃんと見極めれば、これほどリアルタイム通信の実装が楽にできるものはないんじゃないかなとか思っていたりします。

そして、ここまでの実装で一先ず最低限の事はできるようになったので、リポジトリ公開しておきます。

https://github.com/himrock922/we_reports

今回のアプリケーションもサンプルとして外部公開する予定なので、それまでお楽しみに。
計画としてはGCPに構築したjaistingのコンテナホスティングでwe_reportsのコンテナを建てようと思います。

jaistingについてはこちら→https://note.com/himrock922/n/n0a1b7d8fc59f

それでは。

おまけ

Phoenixのアップグレード(1.4から1.5にアップグレード)

まず最初に、新しいphxのバージョンで新しいPhoenixアプリケーションのプロジェクトが生成できるようにarchive.installコマンドでPhoenix1.5系のアーカイブファイルをインストールします。

$ mix archive.uninstall phx_new
$ mix archive.install hex phx_new 1.5.1
mix.exs
  defp deps do
    [
      {:phoenix, "~> 1.5.0"},
      {:phoenix_pubsub, "~> 2.0"},
      {:plug_cowboy, "~> 2.1"},
      ...
    ]

PubSub 2.0系による変更

config/config.exs
config :we_reports, WeReportsWeb.Endpoint,
  ...
  pubsub_server: WeReportsWeb.PubSub
lib/we_reports/application.ex
  def start(_type, _args) do
    # List all child processes to be supervised
    children = [
      # Start the PubSub system
      {Phoenix.PubSub, name: WeReports.PubSub},
      ...

レイアウト関係でdeprecatedになったコードの置き換え

<%= render(@view_module, @view_template, assigns) %>

この書き方がdeprecatedになったため、以下のように置き換えます。

<%= @inner_content %>

テスト関係でdeprecatedになったコードを置き換え

test/scupport/conn_case.ex
  using do
    quote do
      # Import conveniences for testing with connections
-      use Phoenix.ConnTest
+      import Plug.Conn
+      import Phoenix.ConnTest

参考文献

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