ActiveStorageを使った複数画像アップロードアプリを作る

はじめに

 Active Storageを使って複数の画像を扱う方法についてまとめます。 Active Storageは、Rails 5.2で追加されたファイルアップロードの機能です。必要なテーブルも自動で生成してくれるので、モデルに関連つけを記述しクラウドストレージへの設定するだけで画像の添付やクラウドストレージへのアップロードを簡単に実装できます。  複数の画像を扱う場合も、モデルへの関連つけを記述するだけで機能を実装できます。ただし、実際に実装しているとプレビューや更新で詰まったところも多く、詳しい記事があまりなかったので記録のため、まとめておきます。

アプリ概要

  • Itemに複数画像を添付
  • 画像の削除、変更が可能
  • 画像保存先はひとまずローカルで

f:id:kykt35:20190116173159p:plain

環境

準備

プロジェクト作成

$ 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();
    }
  }

実装結果

f:id:kykt35:20190116120654g:plain

バリデーションエラー時

画像は表示しています。 f:id:kykt35:20190116173727p:plain

Item編集時

f:id:kykt35:20190116174305p:plain

画像の削除、変更

削除は、削除リンクをクリックしたときに親要素ごと消すことで実装します。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で複数画像をアップロードするアプリを作成しました。
  • バリデーションやストレージの設定などなどについてはまたいつか書きたいと思います。

参考