はじめに
本記事は、プロダクションでLiveViewのLiveVileInputを使用した際にハマったポイントや実装例の備忘録になります
内容としては以下を予定しています
- ベース <- 本記事
- リサイズ処理と独自サムネイル
- GCSアップロード
- テスト
Project作成 & phx.gen.live
最初にプロジェクトの作成と phx.gen.liveでベースとなるコードを作成します
mix phx.new live_upload
cd live_upload
mix ecto.create
mix phx.gen.live Blog Article articles title:string body:string eyecatch:string
mix ecto.migrate
defmodule LiveUploadWeb.Router do
use LiveUploadWeb, :router
scope "/", LiveUploadWeb do
pipe_through :browser
get "/", PageController, :index
live "/articles", ArticleLive.Index, :index
live "/articles/new", ArticleLive.Index, :new
live "/articles/:id/edit", ArticleLive.Index, :edit
live "/articles/:id", ArticleLive.Show, :show
live "/articles/:id/show/edit", ArticleLive.Show, :edit
end
end
topアクセス時のリダイレクト
ちょっとした小ネタとして、ログイン時とかに / にアクセスしたときにログイン後ページとかにリダイレクト処理を入れたいときは page_controller.exに書いておくとよさそうです
defmodule LiveUploadWeb.PageController do
use LiveUploadWeb, :controller
def index(conn, _params) do
redirect(conn, to: Routes.article_index_path(LiveUploadWeb.Endpoint, :index))
end
end
live_file_inputを使えるようにする
では live_file_inputをシステムに組み込んでいきましょう
大体前に書いた記事の通りに進めていきます
allow_upload/3を実行しassignsにアップロード関連のステートを追加します
defmodule LiveUploadWeb.ArticleLive.FormComponent do
use LiveUploadWeb, :live_component
alias LiveUpload.Blog
@impl true
def update(%{article: article} = assigns, socket) do
changeset = Blog.change_article(article)
{:ok,
socket
|> assign(assigns)
|> allow_upload(:eyecatch, accept: :any) #追加
|> assign(:changeset, changeset)}
end
...
end
実際にテンプレートで使用するときは
<%= live_file_input @uploads.eyecatch %>
とあるように、fはいれず先程指定した値のみ指定します
もとのformのeyecatchはhidden_inputにしておきます
<div>
<h2><%= @title %></h2>
<.form>
...
<%= label f, :eyecatch %>
<%= live_file_input @uploads.eyecatch %> <!-- 追加 --->
<%= hidden_input f, :eyecatch %> <!-- 変更 --->
<%= error_tag f, :eyecatch %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>
サムネイルを表示する
ファイルを選択すると eyecatch.entriesにファイルのデータが入るので、for文を使って表示します
live_img_preview
で画像をentry.client_name
でファイル名を表示しています
<div>
<h2><%= @title %></h2>
<.form>
...
<%= label f, :eyecatch %>
<%= live_file_input @uploads.eyecatch %>
<!-- 以下追加 -->
<%= for entry <- @uploads.eyecatch.entries do %>
<figure>
<%= live_img_preview entry %>
<figcaption><%= entry.client_name %></figcaption>
</figure>
<% end %>
<!-- 追加ここまで -->
<%= hidden_input f, :eyecatch %>
<%= error_tag f, :eyecatch %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>
保存する
アップロードとサムネイル表示はできたので実際にDBに保存しましょう
今回はローカル環境にアップロードします
最初に初期値 placeholderをeyecatchに指定します
defmodule LiveUpload.Blog.Article do
use Ecto.Schema
import Ecto.Changeset
schema "articles" do
field :body, :string
field :eyecatch, :string, default: "placeholder" # default値を追加
field :title, :string
timestamps()
end
...
end
formがsubmitされたときに発火する"save"イベントで、実際に保存する値にアップロードした画像のファイルパスを追加します
主に以下の処理を行います
- 未アップロード(entriesが空)
・ placefolderまたは空文字だった場合 Ecto.changesetのバリデーションで弾かれるようにnilで置き換える
・ editの場合はファイルパスが入っているので変更なく保存する
2 アップロードしたファイルがあった場合
・ LiveViewのconsume_uploaded_entriesでアップロードしたファイルのデータを取得する
・ ファイル名を生成する(UUID+extension)
・ ファイルをコピーし、最終的なファイルパスを返す
defmodule LiveUploadWeb.ArticleLive.FormComponent do
use LiveUploadWeb, :live_component
alias LiveUpload.Blog
...
def handle_event("save", %{"article" => article_params}, socket) do
article_params =
case Enum.empty?(socket.assigns.uploads.eyecatch.entries) do
true ->
validate_required(article_params)
false ->
{:ok, uploaded_file_path} = consume_uploaded(socket)
Map.put(article_params, "eyecatch", uploaded_file_path)
end
save_article(socket, socket.assigns.action, article_params)
end
def validate_required(params) do
case params["eyecatch"] in ["placeholder", ""] do
true -> Map.put(params, "eyecatch", nil)
false -> params
end
end
def consume_uploaded(socket) do
image =
consume_uploaded_entries(socket, :eyecatch, fn %{path: path}, entry ->
upload_local(path, entry)
end)
|> List.first()
{:ok, image}
end
def upload_local(path, entry) do
dest = Path.join([:code.priv_dir(:live_upload), "uploads", generate_filemae(entry)])
File.cp!(path, dest)
"/uploads/#{Path.basename(dest)}"
end
def generate_filemae(entry) do
extension = MIME.extensions(entry.client_type) |> List.first()
entry.uuid <> ".#{extension}"
end
...
end
entryのデータ
%Phoenix.LiveView.UploadEntry{
cancelled?: false,
client_last_modified: nil,
client_name: "DSC_0035.jpg",
client_size: 1693887,
client_type: "image/jpeg",
done?: true,
preflighted?: true,
progress: 100,
ref: "0",
upload_config: :eyecatch,
upload_ref: "phx-FvJI9I_nGsgH5gQG",
uuid: "382df2e4-276e-4ff0-843a-d83228afc3ff",
valid?: true
}
表示する
アップロードした画像を実際に表示させていきましょう
最初にアップロード先のフォルダを作ります
mkdir priv/uploads
gitに含めてほしくないので ignoreに追加します
# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/uploads/ # 追加
/uploadsでアクセスできるようにendpointを設定します
1つ目に含まないのは、live_updateが走ってしまうため、新しくendpointを作っています
defmodule LiveUploadWeb.Endpoint do
...
plug Plug.Static,
at: "/",
from: :live_upload,
gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt)
# 以下追加
plug Plug.Static,
at: "/",
from: {:live_upload, "priv"},
gzip: false,
only: ~w(uploads)
...
end
live_helpersに表示する関数や表示フラグの関数を実装しておけば
use LiveUploadWeb, :live_viewが記述してある箇所で使うことができます
defmodule LiveUploadWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
alias Phoenix.LiveView.JS
alias LiveUploadWeb.Router.Helpers, as: Routes #追加
...
def img_url(socket, path) do
Routes.static_path(socket, path)
end
def show_uploaded_image?(data, entries) do
data != "placeholder" && Enum.empty?(entries)
end
end
さっき実装したimg_urlで表示します
<h1>Listing Articles</h1>
<table>
<thead>
<tr>
<th>Title</th>
<th>Body</th>
<th>Eyecatch</th>
<th></th>
</tr>
</thead>
<tbody id="articles">
<%= for article <- @articles do %>
<tr id={"article-#{article.id}"}>
<!--- 変更 --->
<td><img src={img_url(@socket, article.eyecatch)} width="150" height="80"/></td>
<td><%= article.title %></td>
<td><%= article.body %></td>
<td>
<span><%= live_redirect "Show", to: Routes.article_show_path(@socket, :show, article) %></span>
<span><%= live_patch "Edit", to: Routes.article_index_path(@socket, :edit, article) %></span>
<span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: article.id, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= live_patch "New Article", to: Routes.article_index_path(@socket, :new) %></span>
ヘルパーに追加した表示フラグはedit時に使用します
差し替える画像がアップロードされたら非表示になるようしています
<div>
<h2><%= @title %></h2>
<.form>
...
<%= live_file_input @uploads.eyecatch %>
<%= for entry <- @uploads.eyecatch.entries do %>
<figure>
<%= live_img_preview entry %>
<figcaption><%= entry.client_name %></figcaption>
</figure>
<% end %>
<!--- 以下追加 --->
<%= if show_uploaded_image?(f.data.eyecatch, @uploads.eyecatch.entries) do %>
<figure>
<img src={img_url(@socket, f.data.eyecatch)}>
<figcaption><%= Path.basename(f.data.eyecatch) %></figcaption>
</figure>
<% end %>
<!--- 追加ここまで --->
<%= hidden_input f, :eyecatch %>
<%= error_tag f, :eyecatch %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>
fの中身
%Phoenix.HTML.Form{
action: "#",
data: %LiveUpload.Blog.Article{
__meta__: #Ecto.Schema.Metadata<:loaded, "articles">,
body: "test",
eyecatch: "/uploads/live_view_upload-1653463454-562604693383223-3",
id: 1,
inserted_at: ~N[2022-05-25 07:24:15],
title: "test",
updated_at: ~N[2022-05-25 07:24:15]
},
errors: [],
hidden: [id: 1],
id: "article-form",
impl: Phoenix.HTML.FormData.Ecto.Changeset,
index: nil,
name: "article",
options: [
method: "put",
id: "article-form",
"phx-change": "validate",
"phx-submit": "save",
"phx-target": %Phoenix.LiveComponent.CID{cid: 1}
],
params: %{},
source: #Ecto.Changeset<action: nil, changes: %{}, errors: [],
data: #LiveUpload.Blog.Article<>, valid?: true>
}
これで ファイルアップロード、保存・更新ができるようになりました
バリデーション
次にバリデーション周りを実装していきましょう
live_file_inputでは ファイル数、ファイル容量、accept属性のバリデーションがデフォルトで実装されています
今回はファイル容量とaccept属性を行います
ファイル容量を制限する
最初にファイル容量を制限してみましょう
defaultは8MBですので1MBに変更してみましょう
defmodule LiveUploadWeb.ArticleLive.FormComponent do
...
@impl true
def update(%{article: article} = assigns, socket) do
changeset = Blog.change_article(article)
{:ok,
socket
|> assign(assigns)
|> allow_upload(
:eyecatch,
accept: :any,
max_file_size: 1_000_000 #追加
)
|> assign(:changeset, changeset)}
end
...
end
アップロードを許可するファイルを制限する
次にacceptをanyから jpg,pngに変更します
defmodule LiveUploadWeb.ArticleLive.FormComponent do
...
@impl true
def update(%{article: article} = assigns, socket) do
changeset = Blog.change_article(article)
{:ok,
socket
|> assign(assigns)
|> allow_upload(
:eyecatch,
accept: ~w(.jpeg .jpg .png), #変更
max_file_size: 1_000_000
)
|> assign(:changeset, changeset)}
end
...
end
エラーメッセージを表示させる
エラーメッセージを表示する際にはドキュメントに記載されているupload_errors
でエラーを取得できるのでそちらをfor文で表示していきます
<div>
<h2><%= @title %></h2>
<.form>
...
<label>
<p class="button button-outline">ファイルを選択</p>
<span style="visibility:hidden;">
<%= live_file_input @uploads.eyecatch %>
</span>
</label>
<%= for entry <- @uploads.eyecatch.entries do %>
<%= if entry.valid? do %> <!--- エラーの場合は表示しないように変更 --->
<figure>
<%= live_img_preview entry %>
<figcaption><%= entry.client_name %></figcaption>
</figure>
<% end %>
<!--- 以下追加 --->
<%= for err <- upload_errors(@uploads.eyecatch, entry) do %>
<span class="invalid-feedback"><%= error_to_string(err) %></span>
<% end %>
<!--- 追加ここまで --->
<% end %>
<%= if show_uploaded_image?(f.data.eyecatch, @uploads.eyecatch.entries) do %>
<figure>
<img src={img_url(@socket, f.data.eyecatch)}>
<figcaption><%= Path.basename(f.data.eyecatch) %></figcaption>
</figure>
<% end %>
<%= hidden_input f, :eyecatch %>
<%= error_tag f, :eyecatch %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>
注意しないといけないのがエラーはatomで表示できないので、atomをエラーメッセージに変換する関数をerror_helpersに実装します
defmodule LiveUploadWeb.ErrorHelpers do
...
def error_to_string(:too_large), do: "Too large"
def error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
end
実際に1MBを超えるファイルをアップロードするとエラーメッセージが表示されました
これで完成!
ファイルアップロード失敗しても saveイベント発火させたい
とおもったらこのような要望が・・・
「ファイルアップロード失敗したらsubmitできないのはUI上良くないのでなんとかして(意訳)」
まぁそうですよね
どうやら uploads.hogehoge.errrosが空じゃないとsubmitできないようで
cancel_uploadで初期化する必要があるらしい
だがそうするとエラー内容まで消えてしまう
ということで以下のようにしました
- エラーの格納するステートを追加 - ①
- validateイベント時に以下を行う
- errorがなかったら①にnilをアサイン
- errorがあったらcancel_uploadを行いエラー内容を①にアサイン
defmodule LiveUploadWeb.ArticleLive.FormComponent do
use LiveUploadWeb, :live_component
alias LiveUpload.Blog
@impl true
def update(%{article: article} = assigns, socket) do
changeset = Blog.change_article(article)
{:ok,
socket
|> assign(assigns)
|> allow_upload(
:eyecatch,
accept: ~w(.jpeg .jpg .png),
max_file_size: 1_000_000
)
|> assign(:upload_error, nil) # エラーメッセージ格納用
|> assign(:changeset, changeset)}
end
@impl true
def handle_event("validate", %{"article" => article_params}, socket) do
changeset =
socket.assigns.article
|> Blog.change_article(article_params)
|> Map.put(:action, :validate)
# 以下追加
socket =
socket
|> assign_upload_error(socket.assigns.uploads)
|> assign(:changeset, changeset)
{:noreply, socket}
# 追加ここまで
end
...
# エラーのアサインと初期化処理実装
def assign_upload_error(socket, %{eyecatch: %{errors: errors}}) do
case Enum.empty?(errors) do
true ->
assign(socket, :upload_error, nil)
false ->
Enum.reduce(errors, socket, fn {ref, error}, acc ->
acc
|> cancel_upload(:eyecatch, ref)
|> assign(:upload_error, error)
end)
end
end
end
エラーメッセージを表示させるで追加した部分を消して、forの外側にエラーを表示する処理と追加します
おまけで
デフォルトのinput type="file"
が嫌だと言う場合はlabelタグで囲んでhiddenで非表示し、button以外のタグを置けば好きなデザインにできます
<div>
<h2><%= @title %></h2>
<.form>
...
<!--- 以下変更 --->
<label>
<p class="button button-outline">ファイルを選択</p>
<span style="visibility:hidden;"><%= live_file_input @uploads.eyecatch %></span>
</label>
<!--- 変更ここまで --->
<%= for entry <- @uploads.eyecatch.entries do %>
<%= if entry.valid? do %>
<figure>
<%= live_img_preview entry %>
<figcaption><%= entry.client_name %></figcaption>
</figure>
<% end %>
<% end %>
<!--- 以下追加 --->
<%= if !is_nil(@upload_error) do %>
<span class="invalid-feedback"><%= error_to_string(@upload_error) %></span>
<% end %>
<!--- 追加ここまで --->
<%= if show_uploaded_image?(f.data.eyecatch, @uploads.eyecatch.entries) do %>
<figure>
<img src={img_url(@socket, f.data.eyecatch)}>
<figcaption><%= Path.basename(f.data.eyecatch) %></figcaption>
</figure>
<% end %>
<%= hidden_input f, :eyecatch %>
<%= error_tag f, :eyecatch %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>
saveが発火したときにはファイルアップロードのエラーは消えてほしい
これもそうですね、
changesetのエラーとuploadのエラー両方が表示されてるのは良くないので、手を加えます
主に新規登録時に起こりうるので、save_article(socket, :new, article_params)
に実装します
save実行時に upload_errorがあったらnilに書き換えるようにすればokです
defmodule LiveUploadWeb.ArticleLive.FormComponent do
...
defp save_article(socket, :new, article_params) do
# 以下追加
socket =
case is_nil(socket.assigns.upload_error) do
true -> socket
false -> assign(socket, :upload_error, nil)
end
# 追加ここまで
case Blog.create_article(article_params) do
{:ok, _article} ->
{:noreply,
socket
|> put_flash(:info, "Article created successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end
最後に
いかがでしたでしょうか?
くっそめんどくさいですね!
実際にはこれにいくつか別の処理も入っている
+アップロードするカラムも複数なのでもっと面倒でした (┐「ε:)
carrier_waveは偉大だ・・・・
コード
https://github.com/thehaigo/live_upload
https://github.com/thehaigo/live_upload/tree/base
参考ページ
https://hexdocs.pm/phoenix_live_view/uploads.html
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#cancel_upload/3
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#consume_uploaded_entries/3
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_file_input/2
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_img_preview/2
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#upload_errors/2
https://qiita.com/the_haigo/items/6ad00175bb3d9c15b3ee