8
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.

LiveFileInput in Production ベース+validation

Posted at

はじめに

本記事は、プロダクションで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
lib/live_upload_web/router.ex
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に書いておくとよさそうです

lib/live_upload_web/controllers/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にアップロード関連のステートを追加します

lib/live_upload_web/live/article_live/form_component.ex
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にしておきます

lib/live_upload_web/live/article_live/form_component.html.heex
<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でファイル名を表示しています

lib/live_upload_web/live/article_live/form_component.html.heex
<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に指定します

lib/live_upload/blog/article.ex
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"イベントで、実際に保存する値にアップロードした画像のファイルパスを追加します
主に以下の処理を行います

  1. 未アップロード(entriesが空)
    ・ placefolderまたは空文字だった場合 Ecto.changesetのバリデーションで弾かれるようにnilで置き換える
    ・ editの場合はファイルパスが入っているので変更なく保存する
    2 アップロードしたファイルがあった場合
    ・ LiveViewのconsume_uploaded_entriesでアップロードしたファイルのデータを取得する
    ・ ファイル名を生成する(UUID+extension)
    ・ ファイルをコピーし、最終的なファイルパスを返す
lib/live_upload_web/live/article_live/form_component.ex
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に追加します

.gitignore
# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/uploads/ # 追加

/uploadsでアクセスできるようにendpointを設定します
1つ目に含まないのは、live_updateが走ってしまうため、新しくendpointを作っています

lib/live_upload_web/endpoint.ex
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が記述してある箇所で使うことができます

lib/live_upload_web/live/live_helpers.ex
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で表示します

lib/live_upload_web/live/article_live/index.html.heex
<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>

スクリーンショット 2022-05-25 16.53.25.png
無事表示できました!

ヘルパーに追加した表示フラグはedit時に使用します
差し替える画像がアップロードされたら非表示になるようしています

lib/live_upload_web/live/article_live/form_component.html.heex
<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>
}

f09961e277dedfdfa163d1d83bcfbbf4.gif

これで ファイルアップロード、保存・更新ができるようになりました

バリデーション

次にバリデーション周りを実装していきましょう
live_file_inputでは ファイル数、ファイル容量、accept属性のバリデーションがデフォルトで実装されています

今回はファイル容量とaccept属性を行います

ファイル容量を制限する

最初にファイル容量を制限してみましょう
defaultは8MBですので1MBに変更してみましょう

lib/live_upload_web/live/article_live/form_component.ex
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に変更します

lib/live_upload_web/live/article_live/form_component.ex
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文で表示していきます

lib/live_upload_web/live/article_live/form_component.html.heex
<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に実装します

lib/live_upload_web/views/error_helpers.ex
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

スクリーンショット 2022-05-26 0.41.06.png
実際に1MBを超えるファイルをアップロードするとエラーメッセージが表示されました

これで完成!

ファイルアップロード失敗しても saveイベント発火させたい

とおもったらこのような要望が・・・
「ファイルアップロード失敗したらsubmitできないのはUI上良くないのでなんとかして(意訳)」
まぁそうですよね

どうやら uploads.hogehoge.errrosが空じゃないとsubmitできないようで
cancel_uploadで初期化する必要があるらしい
だがそうするとエラー内容まで消えてしまう

ということで以下のようにしました

  • エラーの格納するステートを追加 - ①
  • validateイベント時に以下を行う
    • errorがなかったら①にnilをアサイン
    • errorがあったらcancel_uploadを行いエラー内容を①にアサイン
lib/live_upload_web/live/article_live/form_component.ex
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以外のタグを置けば好きなデザインにできます

lib/live_upload_web/live/article_live/form_component.html.heex
<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です

lib/live_upload_web/live/article_live/form_component.ex
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

最終的にこんな感じになりました
5002364b86b84f253d5892163887c715.gif

最後に

いかがでしたでしょうか?
くっそめんどくさいですね!
実際にはこれにいくつか別の処理も入っている
+アップロードするカラムも複数なのでもっと面倒でした (┐「ε:)
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

8
4
1

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
8
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?