ActiveStorageを使った複数画像アップロードアプリを作る
はじめに
Active Storageを使って複数の画像を扱う方法についてまとめます。 Active Storageは、Rails 5.2で追加されたファイルアップロードの機能です。必要なテーブルも自動で生成してくれるので、モデルに関連つけを記述しクラウドストレージへの設定するだけで画像の添付やクラウドストレージへのアップロードを簡単に実装できます。 複数の画像を扱う場合も、モデルへの関連つけを記述するだけで機能を実装できます。ただし、実際に実装しているとプレビューや更新で詰まったところも多く、詳しい記事があまりなかったので記録のため、まとめておきます。
アプリ概要
- Itemに複数画像を添付
- 画像の削除、変更が可能
- 画像保存先はひとまずローカルで
環境
- Ruby 2.5.1
- Rails 5.2.2
- ImageMagick 7.0.8
準備
プロジェクト作成
$ rails new activestorage_sample
mini_magick追加
# Gemfile gem 'mini_magick'
Active Storage インストール
$ rails active_storage:install $ rails db:migrate
実装
scaffoldでitemを作成します。
$ rails g scaffold item name:string description:text $ rails db:migrate
Itemモデルに画像(images)を関連付けします。
# app/models/item.rb class Item < ApplicationRecord has_many_attached :images end
コントローラを編集し、ストロングパラメータにimagesを追加します。
# app/controllers/items_controller.rb def item_params params.require(:item).permit(:name, :description, images: []) end
フォームにimagesのfile_fieldを追加します。
# app/views/items/_form.html.erb <div class="field"> <%= form.label :images %> <%= form.file_field :images ,multiple: true%> </div>
showビューに画像の表示を追加します。
# app/views/items/show.html.erb <p> <strong>images:</strong> <% @item.images.each do |image|%> <%= image_tag image.variant(resize: "150x150") %> <% end %> </p>
ここまでで、一応複数画像の添付ができるようになりました。 ただ、これではプレビューがないので添付した画像を確認できません。また、Saveに失敗したときや編集時に画像が消えてしまいます。
画像プレビューを実装
jQuery追加
# Gemfile gem 'jquery-rails'
# app/assets/javascripts/application.js
//= require jquery
ファイルが選択されてときにプレビューを追加するjsを追加。
$(document).on('turbolinks:load', function() { $('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function(){return previewImage(file)}); }); }) var previewImage = function(imageFile){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ $('.images_field').append(img); img.src = e.target.result; def.resolve(img); }; reader.readAsDataURL(imageFile); return def.promise(); } })
編集時やSave失敗の時の画像表示を追加
# app/views/items/_form.html.erb <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <%= image_tag image.variant(resize: "150x150") %> <% end %> <% end %> </div>
バリデーションエラーでSave失敗した時や編集時に画像を表示することができます。 しかし、file inputの中身は消えてしまうため、このままではフォームから画像を送ることができず、画像を保存することができません。 ちなみに、file inputタグはセキュリティ上、初期値を入れておくことができないので、file inputタグ以外を使う以外の方法が必要になります。
今回は、画像ファイルを先にアップロードしておく方法を使いました。 画像ファイルは、ファイル選択時にアップロードし、アップロードした画像と紐付けのためのimage_idをプレビュー表示の際にhidden fieldに保管します。 フォームのサブミットの時に、このimage_idを送ることでモデルと画像の紐付けを行います。
画像アップロード機能
Active Storageのダイレクトアップロード機能を使いたかったのですが、やり方がよくわからなかったので使わずに実装しています。
item_controllerにupload_imageアクションを追加 さらに、Active Storageのblobを生成するcreate_blobメソッドとformから送られたidからblobを取得するメソッドを追加する。 ストロングパラメータのimagesをblobを渡すように変更。 なお、updateアクションでは一旦すべてのimageの紐付けを解除して更新時に再度紐付けすることで画像を更新しています。
# app/controllers/items_controller.rb def update @item.images.detach #一旦、すべてのimageの紐つけを解除 if @item.update(item_params) redirect_to @item, notice: 'Item was successfully updated.' else render :edit end end def upload_image @image_blob = create_blob(params[:image]) respond_to do |format| format.json { @image_blob.id } end end private def item_params params.require(:item).permit(:name, :description).merge(images: uploaded_images) end def uploaded_images params[:item][:images].map{|id| ActiveStorage::Blob.find(id)} if params[:item][:images] end def create_blob(uploading_file) ActiveStorage::Blob.create_after_upload! \ io: uploading_file.open, filename: uploading_file.original_filename, content_type: uploading_file.content_type end
# app/view/upload_image.json.jbuilder json.image_id @image_blob.id if @image_blob
formにhidden fieldなどを追加。ついでに画像削除や編集のボタンを追加しておく。
# app/views/items/_form.html.erb <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> <%= form.hidden_field :images , name: "item[images][]", value: "#{image.blob.id}", style: "display: none;", class: "item-images-input" %> <%= link_to "Edit", "", class: "btn-edit" %> <%= file_field "edit-image","" , class: "edit-image-file-input file-input", style: "display: none;"%> <%= link_to "Delete", "", class: "btn-delete" %> </div> <% end %> <% end %> </div>
jsを変更
$('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function() { return Uploader.upload(file)}) .then(function(data){ return previewImage(file, data.image_id); }); }); $('#item_images').val(''); }); var previewImage = function(imageFile, image_id){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ var image_box = $('<div>',{class: 'image-box'}); image_box.append(img); image_box.append($('<p>').html(imageFile.name)); image_box.append($('<input>').attr({ name: "item[images][]", value: image_id, style: "display: none;", type: "hidden", class: "item-images-input"})); image_box.append('<a href="" class= "btn-edit">Edit</a>'); image_box.append($('<input>').attr({ name: "edit-image[]", style: "display: none;", type: "file", class: "edit-image-file-input file-input"})); image_box.append('<a href="" class="btn-delete">Delete</a>'); $('.images-field').append(image_box); img.src = e.target.result; def.resolve(); }; reader.readAsDataURL(imageFile); return def.promise(); } var Uploader = { upload: function(imageFile){ var def =$.Deferred(); var formData = new FormData(); formData.append('image', imageFile); $.ajax({ type: "POST", url: '/items/upload_image', data: formData, dataType: 'json', processData: false, contentType: false, success: def.resolve }) return def.promise(); } }
実装結果
バリデーションエラー時
画像は表示しています。
Item編集時
画像の削除、変更
削除は、削除リンクをクリックしたときに親要素ごと消すことで実装します。hidden要素のimage idがなくなるため、formを送信した時に画像の紐付けが削除されます。 なお、画像ファイル自体は削除されずストレージに残りますが、Active Storageでは以下のコードでモデルに紐ついていないファイルをまとめて消すことができるので今回はこの方法にしました。まじめに削除する必要がある場合は、記述を追加する必要があります。
ActiveStorage::Blob.unattached.find_each(&:purge)
編集リンクをクリックすると、file選択が表示され選択すると画像を差し替える操作をjsに追加します。
$('.images-field').on('click','.btn-edit', function(e){ e.preventDefault(); $(this).parent().find('.edit-image-file-input').trigger("click"); }); $('.images-field').on('change','.edit-image-file-input', function(e){ var file = e.target.files[0]; var image_box = $(this).parent(); Uploader.upload(file).done(function(data){ replaceImage(file, data.image_id, image_box); }); }); $('.images-field').on('click','.btn-delete', function(e){ e.preventDefault(); $(this).parent().remove(); }); var replaceImage = function(imageFile, image_id, element){ var reader = new FileReader(); var img = element.find('img'); var input = element.find('.item-images-input'); var filename = element.find('p'); reader.onload = function(e){ input.attr({value: image_id}); filename.html(imageFile.name); img.attr("src", e.target.result); }; reader.readAsDataURL(imageFile); }
完成コード
主なコード
class Item < ApplicationRecord has_many_attached :images validates :name, :description ,presence: true end
# app/controllers/items_controller.rb class ItemsController < ApplicationController before_action :set_item, only: [:show, :edit, :update, :destroy] def index @items = Item.all end def show end def new @item = Item.new end def edit end def create @item = Item.new(item_params) if @item.save redirect_to @item, notice: 'Item was successfully created.' else render :new end end def update @item.images.detach #一旦、すべてのimageの紐つけを解除 if @item.update(item_params) redirect_to @item, notice: 'Item was successfully updated.' else render :edit end end def destroy @item.destroy redirect_to items_url, notice: 'Item was successfully destroyed.' end def upload_image @image_blob = create_blob(params[:image]) respond_to do |format| format.json { @image_blob } end end private def set_item @item = Item.with_attached_images.find(params[:id]) end def item_params params.require(:item).permit(:name, :description).merge(images: uploaded_images) end def uploaded_images params[:item][:images].map{|id| ActiveStorage::Blob.find(id)} if params[:item][:images] end def create_blob(uploading_file) ActiveStorage::Blob.create_after_upload! \ io: uploading_file.open, filename: uploading_file.original_filename, content_type: uploading_file.content_type end end
# app/views/items/_form.html.erb <%= form_with(model: item, local: true) do |form| %> <% if item.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(item.errors.count, "error") %> prohibited this item from being saved:</h2> <ul> <% item.errors.full_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= form.label :name %> <%= form.text_field :name %> </div> <div class="field"> <%= form.label :description %> <%= form.text_area :description %> </div> <div class="images-field clearfix"> <div class="field"> <%= form.label :images %> <%= form.file_field :images, multiple: true, class: "file-input" %> </div> <% if item.images.attached? %> <% item.images.each do |image| %> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> <%= form.hidden_field :images , name: "item[images][]", value: "#{image.blob.id}", style: "display: none;", class: "item-images-input" %> <%= link_to "Edit", "", class: "btn-edit" %> <%= file_field "edit-image","" , class: "edit-image-file-input file-input", style: "display: none;"%> <%= link_to "Delete", "", class: "btn-delete" %> </div> <% end %> <% end %> </div> <div class="actions"> <%= form.submit "Save"%> </div> <% end %>
# app/views/items/show.html.erb <p id="notice"><%= notice %></p> <p> <strong>Name:</strong> <%= @item.name %> </p> <p> <strong>Description:</strong> <%= @item.description %> </p> <p> <strong>images:</strong> </p> <div class= "clearfix"> <% if @item.images.attached? %> <% @item.images.each do |image|%> <div class="image-box"> <%= image_tag image.variant(resize: "150x150") %> <p> <%= image.filename %> </p> </div> <% end %> <% end %> </div> <div> <%= link_to 'Edit', edit_item_path(@item) %> | <%= link_to 'Back', items_path %> </div>
$(document).on('turbolinks:load', function() { $('#item_images').on('change',function(e){ var files = e.target.files; var d = (new $.Deferred()).resolve(); $.each(files,function(i,file){ d = d.then(function() { return Uploader.upload(file)}) .then(function(data){ return previewImage(file, data.image_id); }); }); $('#item_images').val(''); }); $('.images-field').on('click','.btn-edit', function(e){ e.preventDefault(); $(this).parent().find('.edit-image-file-input').trigger("click"); }); $('.images-field').on('change','.edit-image-file-input', function(e){ var file = e.target.files[0]; var image_box = $(this).parent(); Uploader.upload(file).done(function(data){ replaceImage(file, data.image_id, image_box); }); }); $('.images-field').on('click','.btn-delete', function(e){ e.preventDefault(); $(this).parent().remove(); }); var replaceImage = function(imageFile, image_id, element){ var reader = new FileReader(); var img = element.find('img'); var input = element.find('.item-images-input'); var filename = element.find('p'); reader.onload = function(e){ input.attr({value: image_id}); filename.html(imageFile.name); img.attr("src", e.target.result); }; reader.readAsDataURL(imageFile); } var previewImage = function(imageFile, image_id){ var reader = new FileReader(); var img = new Image(); var def =$.Deferred(); reader.onload = function(e){ var image_box = $('<div>',{class: 'image-box'}); image_box.append(img); image_box.append($('<p>').html(imageFile.name)); image_box.append($('<input>').attr({ name: "item[images][]", value: image_id, style: "display: none;", type: "hidden", class: "item-images-input"})); image_box.append('<a href="" class= "btn-edit">Edit</a>'); image_box.append($('<input>').attr({ name: "edit-image[]", style: "display: none;", type: "file", class: "edit-image-file-input file-input"})); image_box.append('<a href="" class="btn-delete">Delete</a>'); $('.images-field').append(image_box); img.src = e.target.result; def.resolve(); }; reader.readAsDataURL(imageFile); return def.promise(); } var Uploader = { upload: function(imageFile){ var def =$.Deferred(); var formData = new FormData(); formData.append('image', imageFile); $.ajax({ type: "POST", url: '/items/upload_image', data: formData, dataType: 'json', processData: false, contentType: false, success: def.resolve }) return def.promise(); } } })
まとめ
- ひとまずActiveStorageで複数画像をアップロードするアプリを作成しました。
- バリデーションやストレージの設定などなどについてはまたいつか書きたいと思います。