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

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

#####完成イメージ
![e76d454dd2a3321f60786f9d0954754e.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1140333/d6d321c2-2f65-f3a3-a7fd-23b5089749f4.gif)


**※なお今回は前回の記事の発展的な内容になっております。**
Active Storageの導入方法について[こちら](https://qiita.com/mitaninjin/items/c3d66ca0e806c13930a5)

https://qiita.com/mitaninjin/items/c3d66ca0e806c13930a5

プレビュー機能については[こちら](https://qiita.com/mitaninjin/items/8df2d55a4872ec984e06)

https://qiita.com/mitaninjin/items/8df2d55a4872ec984e06

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


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

####アソシエーションの修正
レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するので**[has_many_attached](https://railsguides.jp/active_storage_overview.html#has-many-attached)**メソッドに修正します。[Railsガイド](https://railsguides.jp/active_storage_overview.html#has-many-attached)

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

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

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

```erb: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 %>
```

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

```ruby: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
```

####バリデーションの設定
今回、投稿できる画像を３枚までにしたいので、独自のバリデーションメソッドを作成していきます。[Railsガイド](https://railsguides.jp/active_record_validations.html#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)

```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1140333/f65da319-c31b-16d5-f7d6-cef0b1b5d30d.gif)

まずは、[前回](https://qiita.com/mitaninjin/items/8df2d55a4872ec984e06)作成したpreview.jsを確認してみましょう。

```javascript: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を編集していきます。

```html:/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>
```

[前回](https://qiita.com/mitaninjin/items/8df2d55a4872ec984e06)まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。

そして、classにimage-elementを設定し**[querySelectorAll](https://developer.mozilla.org/ja/docs/Web/API/Document/querySelectorAll)**メソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。

```javascript: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](https://developer.mozilla.org/ja/docs/Web/API/Document/createElement)メソッドでinput要素を生成し、[setAttribute](https://developer.mozilla.org/ja/docs/Web/API/Element/setAttribute)で属性を追加していきます。
そして、inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。

```javascript: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度目に発火するイベントとほとんど同じ記述になります。

```javascript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1140333/b2eb6fda-3a20-ff76-b218-3edb3ab23ff1.gif)
モデルにバリデーションをかけているので投稿は保存されません。
![61635f5521f4d56c6380ed39a77dea34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1140333/8e3405f5-9cf8-5961-0fc9-e160c75684a8.png)

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

```javascript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1140333/6dc7287e-4307-3c08-e216-21c500cebb79.gif)
以下、完成形のコードです。

```javascript: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);
    });
  });
}
```

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

https://qiita.com/mitaninjin/items/50c1454c7a3908b86e32