0
3

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 3 years have passed since last update.

「1つの投稿に対し複数の画像を紐付ける機能の実装」

Last updated at Posted at 2020-08-15

目的

某スクールの複数投稿の実装の仕方がとても苦労したので、メモとして残して置こうと思います。
これが、何処かの誰かの役に立てれば嬉しいです(笑)

完成形

これが完成形です
ファイル名
動画も貼れたので掲載します。
Shimokita.php

使用しているrailsのver

Rails 6.0.3.2

ステップ

①. モデルを作成していきます。
②. マイグレーションファイルの作成をしましょう。
③. imagesモデルで画像をアップロードできるようにしていきます。
④. 画像を保存させる場所をuploaderを作成します。
⑤. モデルに追記します。
⑥. ネストした関連先のテーブルをまとめて保存出来るように記載します。
⑦. fields_forを以下のように使用して出品画面のHTMLを記載します。
⑧. SCSSを記載します。
⑨. コントローラーの記載をします。
⑩.javascripts(jQuery)でフォームを生成します。



準備から一つずつ初めて行きましょう!

① モデルを作成していきます。ターミナルで以下のコマンドを実行しましょう。

% rails g model item
% rails g model image

item(商品)モデルと、image(画像)モデル作成します!




② マイグレーションファイルの作成をしましょう。
モデルを作成したら自動で一緒にマイグレーションファイルも作成されているので、
それの編集をしていきましょう!

XXXXXXXXXX_create_items.rb
t.string :name
t.integer :price

※もし他にitemテーブルに必要カラムが有れば各自作成をお願いします

XXXXXXXXX_create_images.rb
t.string :item_image
t.references :item, foreign_key: true

imagesテーブルには外部キー制約をつけたitemカラムが必要です!
imagesは、1対多の関係の多の方なので外部キー制約がつけれますね。
※その他、null: false等の必要な制約は必要なら入れて下さい




③ imagesモデルで画像をアップロードできるようにしていきます。
Gemfileに以下を追記して bundle installコマンドを打ちましょう。
※その後「rails s」を忘れがちなので忘れないように!

gem 'carrierwave'
gem 'mini_magick'

carrierwaveは、画像のアップロード機能を作るgemですね。
mini_magickは、画像を高速でサムネイル化(リサイズ&Crop)するgemになります。




④ 次に画像を保存させる場所をuploaderを作成します、以下のようにターミナルで以下のコマンドを実行しましょう。
※必ず上記のgemを入れてから作成しましょう!エラーが出ますので!

% rails g uploader image(アップローダー名)

すると、image_uploader.rbが出来ますので、以下のようにファイルを編集します。
image_uploader.rb
include CarrierWave::MiniMagick  // この記述を探し、コメントアウトを外す

process resize_to_fit: [100, 100]  // この記述は追記


*⑤ モデルに以下を追記します。*
item.rb
has_many :images

アップローダーへのマウントする記述を追加
image.rb
mount_uploader :item_image, ImageUploader
belongs_to :item


*⑥ ネストした関連先のテーブルをまとめて保存出来るように記載します。* モデル(親モデル)とそれに所属しているモデル(子モデル)を同時に保存・更新するためにfields_forというヘルパーメソッドおよびaccepts_nested_attributes_forというメソッドを利用します。

fields_forについて詳しく知りたい方はこちら
accepts_nested_attributes_forについて詳しく知りたい方はこちら

まずは、accepts_nested_attributes_forをitemデーブルに追記します。

item.rb
has_many :images
accepts_nested_attributes_for :images   // この記述は追記


*⑦ 次は、fields_forを以下のように使用して出品画面のHTMLを記載します。*
new.html.haml
   .contents__body__input-list
      %p.theme 出品情報
      = form_with(model: @item, local: true, class: "contents__body__input-list__form") do |f|
        .input-list__images
          %p.input-list__images--notice 最大5枚までアップロードできます
          #image-box
            #image-box-1
              .item-num-0#image-box__container
                %i.fas.fa-camera#image-box-icon
            #image-box-2
              = f.fields_for :images do |i|
                .input-field__contents{"data-index" => "#{i.index}"}
                  = i.file_field :item_image, type: 'file'
        .product-name
          %p.letter--1 商品名
          %p.letter--2 ※必須
          = f.text_field :name, placeholder: "商品名(必須 40文字まで)", class: "form--text"
        .introduction
          %p.introduction--letter1 商品説明
          %p.introduction--letter2 ※必須
          = f.text_area :introduction, placeholder: "商品の説明(必須 1,000文字以内)(色、素材、重さ、定価、注意点など)", class: "introduction__form-area"
        .form-separator
        .form-post
          = f.submit "確認する", class: "form-post--send"

今回は、1つのitemモデルに対して複数のimageモデルが関連付くため、f.fields_for :imagesと複数形にしています。




⑧ SCSSは、こちら!
#image-box-1以下のSCSSは複数投稿した際の画像のものです。

items.scss
.contents__body__input-list{
  width: 50%;
  height: 90%;
  background-color: #F5F5F5;
  margin-top: 30px;
  .theme{
    font-size: 25px;
    font-weight: bold;
  }
  .contents__body__input-list__form{
    height: 100%;
    .input-list__images{
      height: 380px;
      .input-list__images--notice{
        font-size: 17px;
        margin-top: 50px;
      }
    }
    .product-name{
      height: 100px;
      display: flex;
      .letter--1{
        font-size: 17px;
        font-weight: bold;
        margin-top: 5px;
      }
      .letter--2{
        font-size: 14px;
        color: red;
        margin-left: 10px;
        margin-top: 8px;
      }
      .form--text{
        width: 60%;
        height: 40px;
        margin-left: 18%;
        padding-left: 8px;
        font-size: 15px;
      }
    }
    .introduction{
      height: 180px;
      display: flex;
      &--letter1{
        font-size: 16px;
        font-weight: bold;
        margin-top: 5px;
      }
      &--letter2{
        font-size: 14px;
        color: red;
        margin-left: 10px;
        margin-top: 8px;
      }
      &__form-area{
        width: 60%;
        height: 140px;
        margin-left: 17%;
        padding: 8px 8px;
        font-size: 15px;
      }
    }
    .form-separator{
      border-bottom: 1px solid #ddd;
    }
    .form-post{
      height: 180px;
      width: 100%;
      margin-top: 50px;
      &--send{
        width: 50%;
        margin-left: 23%;
        height: 45px;
        background-color: #FFFFFF;
        border: none;
        border-radius: 5px 5px 5px 5px / 5px 5px 5px 5px;
        border: 1px solid #c5c5c5; 
      }
    }
  }
}


#image-box-1 {
  display: flex;
  height: 230px;
  width: 100%;
  text-align: center;
  i{
    padding-top: 50px;
    cursor: pointer;
  }
  .item-num-0#image-box__container  {
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 90%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;
  }
  .item-num-1{
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 100%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;
  }
  .item-num-2{
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 100%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;
  }
  .item-num-3{
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 100%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;
  }
  .item-num-4{
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 100%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;

  }
  .item-num-5{
    background-color: rgb(245, 245, 245);
    height: 100%;
    width: 100%;
    border-width: 1px;
    border-style: dashed;
    border-color: rgb(204, 204, 204);
    border-image: initial;
    text-align: center;
  }  
}

.fas.fa-camera{
  font-size: 25px;
  margin-top: 20px;
}
//レビュー表示のCSS
.item-image{
  height: 186px;
  width: 160px;
  border: 1px solid #eee;
  margin-right: 10px;
  .item-image__content{
    padding-top: 30px;
  }
  .item-image__operetion{
    height: 40px;
    .item-image__operetion--delete{
      height: 40px;
      color: #00b0ff;
      cursor: pointer;
      padding-top: 5px;
      text-align: center;
      border: 1px solid #eee	;
      background-color: #EEEEEE;
    }
  }
}


*⑨ 順番ちょっと間違いましたが、、、コントローラーの記載をしていきましょう。*
items_controller.rb
class ItemsController < ApplicationController
  def index
    @items = Item.includes(:images).order('created_at DESC')
  end

  def new
    @item = Item.new
    @item.images.new
  end

  def create
    @item = Item.new(item_params)
    if @item.save
      redirect_to root_path
    else
      render :new
    end
  end

  def item_params
    params.require(:item).permit(:name, :introduction, images_attributes: [:item_image])
  end
end

・indexアクションは記載してますが、今回はビューは省きますのでそこは各自で作成お願いします。データの表示させ方だけ下記に記載しておきます! ・order(created_at: :desc) → 投稿順が新しい物が上に来るようになる ・@item.images.new → これで、新しいinput[type=file]を作成してます。 ・createアクションに記載されているifの条件文は、もしデータが保存されたならば、root_pathにリダイレクトする、データが保存されなかった場合newのページに戻るように記載しています。 ・images_attributes: [:item_image] → fields_forを利用して作成されたフォームから来る値は、○○s_attributes: [:××]という形でparamsに入ります。○○は関連付く側のモデルの名前、××にはフォームに対応するカラムの名前が入ります。
.content
  - @items.each do |t|
    %tr
      %p= t.name
      %p= t.price
  - t.images.each do |image|
    = image_tag image.item_image.url



完成画像はこんな感じ!「ファイル選択」はあえて消してないです。 消したい方は下記のコードを入れて下さいね、只、消してしまうとカメラのアイコンとinput[type=file]の紐付けが出来ていないので画像が選択出来なくなってしまいます。 なので、それの実装をしましょう、只、今回のこの記事にはその実装の仕方は記載してませんので、ご了承ください。
#image-box-2{
  display: none;
}
ファイル名

**⑩ 最後は、画像を複数枚投稿できるようにjavascripts(jQuery)でフォームを生成します!** jQueryを利用するために以下を実施します。

初めに、assetsディレクトリ内に、javascriptsというディレクトリを作成しましょう。
下記のようになっていればオッケーです。
ファイル名
次に、app/assets/javascriptsの中に、application.jsを作成します。
ファイル名


1、Gemfileにgem 'jquery-rails'を記述し、 bundle installを行います。
 ※その後「rails s」を忘れがちなので忘れないように!

gem 'jquery-rails'

2、application.jsに以下を記載します。
application.js
//= require turbolinks
//= require jquery
//= require jquery_ujs
//= require_tree .

turbolinksは、JSの実装で使用しています。
turbolinksについて詳しく知りたい方はこちら


3、manifest.jsに以下を記載します。

manifest.js
//= link_tree ../images
//= link_directory ../javascripts .js
//= link_directory ../stylesheets .css

4、application.html.hamlのheadに以下を記載します。
application.html.haml
= javascript_include_tag 'application'
-# = javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'
    -# これは必要ないので削除する

これで、JavaScriptを読み込む記述が出来ました。
※ちなみに今回のJSの導入の書き方はrails5以前の書き方です(rails6以降でこの書き方をしても問題無く動きます)只、デプロイした時に問題が生じるかもしれないので、その場合はrails6以降の書き方で記載して下さいね。


では一応、正常にJavaScriptが読み込まれているか確認しましょう!
app/assets/javascripts/application.jsに以下のような記述をします。

application.js
console.log("a");

カッコの中身はなんでもオッケーです!
検証画面を開いて、コンソールに上記の a が表示されていれば問題なしです。




5、最後にJSの記載していきましょう!長いですが、各行解説を入れておきますので頑張って理解してみて下さい。
JSの名前はimage-post.jsにしています。
そして、JSを作る場所は、javascriptsというディレクトリの中に作って下さい。
application.jsと同じ場所ですね!

image-post.js
$(document).on('turbolinks:load', function(){
  $(function(){
      //DataTransferオブジェクトで、データを格納する箱を作る
      var dataBox = new DataTransfer();
      //querySelectorでfile_fieldを取得し変数に入れている
      var file_field = document.querySelector('input[type=file]')
      
      //file(#image-box)が変化した時に発火するイベント
      $('#image-box').on("change",`input[type="file"]`,function(){
      //選択したファイル情報を取得し変数に格納 - 最後の[0]は最初のファイルという意味
      var files = $('input[type=file]').prop('files')[0];
      
      //$.each()メソッドで、配列やハッシュに対して繰り返し処理を行う
      $.each(this.files, function(i,file){
      
        //FileReaderのreadAsDataURLで指定したFileオブジェクトを読み込む
        var fileReader = new FileReader();
      
        //DataTransferオブジェクトに対して、fileを追加
        dataBox.items.add(file)

        // file_fieldのnameに動的なindexをつける為の配列
        let num = [1,2,3,4,5,6,7,8];
        let img = [0,1,2,3,4,5,6,7];
        // data()メソッドでindex番号を取得、lastを使って最後のinput[type="file"]を取得して変数に入れる
        lastIndex = $('.input-field__contents:last').data('index');
        anotherIndex = $('.input-field__contents:last').data('index');
        //splice()メソッドを使い配列から要素を削除・追加して組み替える
        num.splice(0, lastIndex);
        img.splice(0, anotherIndex);

        // 画像用のinputにそれぞれ異なる番号付与する記述
        const buildFileField = (index)=> {
          const html = `<div data-index="${index}" class="input-field__contents">
                          <input id:"img-file" type="file"
                          name="item[images_attributes][${index}][item_image]"
                          id="item_images_attributes_${index}_item_image"><br>
                        </div>`;
          return html;
        }
      
        // buildFileFieldの変数に配列の0番目の番号入れて、image-box-2に加える
        $('#image-box-2').append(buildFileField(num[0]));


        //fileReader.readAsDataURL(file)で画像の読み込み。
        fileReader.readAsDataURL(file);
      
        //読み込みが完了すると、srcにfileのURLを格納
        fileReader.onloadend = function() {
          //resultプロパティで、読み込み成功後に、中身のデータを取得する
          var src = fileReader.result

          // 画像のプレビュー作成
          var html = `<div class='item-image' data-image="${file.name}" data-index="${img[0]}">
                        <div class=' item-image__content'>
                          <div class='item-image__content--icon'>
                            <img src=${src} width="140" height="150" >
                          </div>
                        </div>
                        <div class='item-image__operetion'>
                          <div class='item-image__operetion--delete' >削除</div>
                        </div>
                      </div>`
          //image_box__container要素の前にhtmlを差し込む
          $('#image-box__container').before(html);
        };
      });
    });
  });
});

・補足
上記のコードに入っている下記の2行の変数は初めの実装では必要だったので書きましたが、
現在のコードはいらないです。只、、、なんとなく入れてます。実害はないので、必要なければ消してください。
var file_field = document.querySelector('input[type=file]')
var files = $('input[type=file]').prop('files')[0];

また上記のコードでは、data()とsplice()メソッドを使用し、番号を配列を使って、投稿される画像、追加されるinput[type=file]にカスタムデータとして付与しています。
カスタムデータを付与する理由は、
1、input[type=file]それぞれに、別々カスタムデータを付与しないと、例えば3枚の画像を投稿しても、3枚別々の画像としてDBに保存されません。一番最後の画像だけが保存されるだけになってしまいます。
2、削除する際に必要になる。削除する際に、同じ番号の画像とinput[type=file]が消せるようにJSを組む事が出来る。
3、カメラのアイコンとinput[type=file]を紐づける際に必要になる。どういう事と言いますと、最初に完成品の所に貼った動画のように、カメラのアイコンを押したら、input[type=file]が押されると言った感じです。

今回、上記の2と3を実装する為の、JSのコードと、コントローラーのコードの記述、hamlの記述はしてないので、実装出来ません、ごめんなさい!機会が有れば書きたいと思います。




まとめ

以上、長々と書きましたが、お読み頂きありがとうございます!このパートの実装は非常に苦労しました。ちょっと鬱になりかけました(笑)
類似した記事はたくさんありましたが、そのまま用いてもうまく動作しなかった為、後学者の助けになればと思い、私が今出来る範囲で丁寧に分かりやすく書いたと思います。
初めに書きましたが、これが、何処かの誰かの役に立てれば嬉しいです。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?