はじめに
久々にPhoenixのWeb開発を直近行ったので、その報告がてら執筆したいと思います。今回は主にLiveView周りの話です。
開発した機能
現在、開発中のシステムの全体絵はこちら→Webアプリケーションの設計案
そして、本記事の主に設計部分は下記の日報作成部分になります。
実際に日報に書く項目は"仕事"という対象に紐づく"作業項目"になります。そして、更にその作業項目で具体的に何をしたのかを記す"作業内容"を初めて日報という報告書に書くというプロセスとなっております。この作業内容は1つの日報に複数紐付いてもいいようにしたいので、実際に日報を書くフォームは動的に作業内容を書く項目を増やしたり減らしたりすることができる
ようにします。
それを実装するために、今回はLiveViewを採用する事にしました。
中間層を1対多のアソシエーションにする
まずは、プロジェクト/スポンサーに紐づく作業項目のモデルをhas_manyで紐付けたいと思います。
mix phx.gen.html Propositions Proposition propositions name:string description:text
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
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アクションについては以下のように変更しました。
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なコードなため、公式のサイトを参考に今時な書き方に変更しました。
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
最下層の作業内容のリソースを日報作成時に同時に作れるようにしたい
LiveViewについて
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
LiveViewとは、リアルタイムにサーバレンダリングを提供できるPhoenixの機能の一つです。
Phoenixのような、サーバサイドとフロントエンドが密結合なフレームワークの中で、リアルタイム通信とレンダリングのPushが一つの言語で実装可能なこの機能は、正に自分が開発したいアプリケーションに適している事でしょう。
とは言え、PhoenixもLiveViewも中々、参考文献を探すのが難しい部分がありますので一先ずは下記のチュートリアルを参考にLiveViewに対応したフォームを作ることにしました。
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
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リクエスト(新規作成アクションのみ)だけのコードを添付します。
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
<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>
<%= 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 %>">×</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 %>
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パラメータ弄られたらどうしようもないので辞めました)。
動作画面
動作画面です。
無事に作成できていますね。
終わりに
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
defp deps do
[
{:phoenix, "~> 1.5.0"},
{:phoenix_pubsub, "~> 2.0"},
{:plug_cowboy, "~> 2.1"},
...
]
PubSub 2.0系による変更
config :we_reports, WeReportsWeb.Endpoint,
...
pubsub_server: WeReportsWeb.PubSub
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になったコードを置き換え
using do
quote do
# Import conveniences for testing with connections
- use Phoenix.ConnTest
+ import Plug.Conn
+ import Phoenix.ConnTest
参考文献
- https://elixirschool.com/ja/lessons/ecto/associations/#1%E5%AF%BE%E5%A4%9A%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E
- https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3
- https://elixirforum.com/t/how-to-render-an-errorview-without-fallbackcontroller/14260
- https://hexdocs.pm/phoenix/Phoenix.Controller.html#render/4
- https://hexdocs.pm/ecto/Ecto.Changeset.html
- https://hexdocs.pm/phoenix_live_view
- https://gist.github.com/chrismccord/e53e79ef8b34adf5d8122a47db44d22f
- https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3
- https://github.com/andreaseriksson/tutorials