LoginSignup
1
0

More than 3 years have passed since last update.

【発展】Active Storageで複数の画像を投稿しよう! (プレビュー機能も実装)

Last updated at Posted at 2021-03-06

Active Storage利用して複数の画像を投稿できる機能とJavaScriptを用いてプレビューできる機能を実装します。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。

完成イメージ

e76d454dd2a3321f60786f9d0954754e.gif

※なお今回は前回の記事の発展的な内容になっております。
Active Storageの導入方法についてこちら

プレビュー機能についてはこちら

今回は上記の機能が実装されている前提で話を進めていきます。

Active Storage関連ファイルの修正

まずはActive Storage周りのファイルから修正していきます。

アソシエーションの修正

レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するのでhas_many_attachedメソッドに修正します。Railsガイド

また、imageを複数形のimagesに変更します。

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images #ここを修正
end

投稿フォームの修正

file_fieldのimageをimagesに修正します。
また、name属性を追加し送信する際に必要な画像の配列を設定します。

app/views/recipes/new.html.erb
<%= form_with model: @recipe, local: true do |f| %>

#中略
    <div class="form-group">
      <label class="text-secondary">画像</label><br>
      <%= f.file_field :images, name: 'recipe[images][]' %> #ここを修正
    </div>

#以下略

<% end %>

コントローラーの修正

画像の配列を受け取れるようにストロングパラメーターを修正します。

app/controllers/recipes_controller.rb
class RecipesController < ApplicationController

#中略

  private
  def recipe_params
    params.require(:recipe).permit(:title, :text, :category_id, :time_required_id, images: []) #images: []に修正
  end
end

バリデーションの設定

今回、投稿できる画像を3枚までにしたいので、独自のバリデーションメソッドを作成していきます。Railsガイド

app/models/recipe.rb
class Recipe < ApplicationRecord
  has_many_attached :images 

#ここから追加
  validate :image_length #カスタムメソッドなので"validate"

  private

  def image_length
    if images.length >= 4
      errors.add(:images, "は3枚以内にしてください")
    end
  end
#ここまで追加
end

以上でActive Storage関連の修正は完了です。

preview.jsの修正

今回は以下のGIFのようにひとつ画像を選択すると新しく画像選択フォームが出現する仕様にします。
90c396247ee860b014d53f48d1c5f087.gif

まずは、前回作成したpreview.jsを確認してみましょう。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const createImageHTML = (blob) => {
      const imageElement = document.getElementById('new-image');
      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
    };

    document.getElementById('recipe_image').addEventListener('change', (e) => {
      const imageContent = document.querySelector('img');
      if (imageContent){
        imageContent.remove();
      }

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

こちらを修正していきます。

今回は、以下のようなHTMLを生成することを前提にpreview.jsを編集していきます。

/recipes/new
<div id="new-image">
<!-- ここから -->
  <div class="image-element">
    <img class="new-img" src="xxxxxxx...">
    <input id="recipe_image_n" class="recipe-images" name="recipe[images][]" type="file">
  </div>
<!-- ここまでを複製していくイメージ -->
</div>

前回まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。

そして、classにimage-elementを設定しquerySelectorAllメソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {

    const ImageList = document.getElementById('new-image'); //追記

    const createImageHTML = (blob) => {

      const imageElement = document.createElement('div'); //new-imageからdivに変更

//ここから
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length
//ここまで追記

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      imageElement.appendChild(blobImage);
      ImageList.appendChild(imageElement); //追記
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => { //recipe-imagesに修正

//ここから
     //const imageContent = document.querySelector('img');
      //if (imageContent){
        //imageContent.remove();
      //}
//ここまで削除

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次に新しく画像選択フォームを作成する記述をしていきます。

createElementメソッドでinput要素を生成し、setAttributeで属性を追加していきます。
そして、inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。

app/javascript/packs/preview.js
//中略
document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

//ここから
      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');
//ここまで追記

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML); //追記
      ImageList.appendChild(imageElement);

//以下略

これで、画像を選択すると新しく画像選択フォームが出現するようになりました。
2枚目以降にもイベント発火するよう処理を記述していきます。
1度目に発火するイベントとほとんど同じ記述になります。

app/javascript/packs/preview.js
//中略

    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      imageElement.appendChild(inputHTML);
      ImageList.appendChild(imageElement);

//ここから
      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//ここまで追記
    };

//以下略

ここまでで複数の画像投稿に対応したプレビュー機能の実装が完了しました。

しかし、これでは1枚画像を選択するたびに新しく画像選択フォームが出現してしまうので何枚でも画像選択が可能になってしまいます。
9e0000ca5ed8bee348ff2752f8962bef.gif
モデルにバリデーションをかけているので投稿は保存されません。
61635f5521f4d56c6380ed39a77dea34.png

条件分岐で制限をかけよう

input要素の数える定数imageElementNumを使い、3枚選択すると新しく画像選択フォームが出現しないように条件分岐していきましょう。

app/javascript/packs/preview.js
//中略

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {            //追記
        imageElement.appendChild(inputHTML);
      }                                     //追記
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
//以下略

以上で完成です。
32e67abef3b9fedea1327e930a9556a2.gif
以下、完成形のコードです。

app/javascript/packs/preview.js
if (document.URL.match(/new/)){
  document.addEventListener('DOMContentLoaded', () => {
    const ImageList = document.getElementById('new-image');
    const createImageHTML = (blob) => {
      const imageElement = document.createElement('div');
      imageElement.setAttribute('class', "image-element")
      let imageElementNum = document.querySelectorAll('.image-element').length

      const blobImage = document.createElement('img');
      blobImage.setAttribute('class', 'new-img')
      blobImage.setAttribute('src', blob);

      const inputHTML = document.createElement('input');
      inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`);
      inputHTML.setAttribute('class', 'recipe-images');
      inputHTML.setAttribute('name', 'recipe[images][]');
      inputHTML.setAttribute('type', 'file');

      imageElement.appendChild(blobImage);
      if (imageElementNum < 2) {
        imageElement.appendChild(inputHTML);
      } 
      ImageList.appendChild(imageElement);

      inputHTML.addEventListener('change', (e) => {
        const file = e.target.files[0];
        const blob = window.URL.createObjectURL(file);
        createImageHTML(blob);
      });
    };

    document.getElementById('recipe-images').addEventListener('change', (e) => {

      const file = e.target.files[0];
      const blob = window.URL.createObjectURL(file);
      createImageHTML(blob);
    });
  });
}

次回は投稿された複数の画像をスライド形式で表示する実装を行っていきます。

1
0
0

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
1
0