LoginSignup
18
14

More than 3 years have passed since last update.

Rails 幹事向けアプリの作り方 複数レコードの同時登録編

Last updated at Posted at 2020-11-24

最初に

この記事は、幹事向けのアプリを作成方法を記載します。
どんな構造を取るかアプリと言うと、2段階の複数登録を行うものです。

①複数のテーブルと複数レコードを同時登録(イベントと複数の日付の登録)

②①で登録した複数レコード(日付など)の中間テーブルレコードの一括登録(出欠状態など)

開発時に、②複数のレコードを登録する方法について
参考となる情報がなく苦労しました。
そこで、作り方を公開して、同じ悩みを持った方の
助けに慣れればと思い記載致します。

※UI部分については今回紹介していません。Gitにて確認お願いします。

目次

  1. 概要
  2. テーブルデータについて
  3. イベント(親)と複数の日程(子)、お店(子)の登録機能
  4. 参加者(親)と参加状況(子)の登録機能
  5. 参加者の参加状況の編集機能
  6. 参加状況の削除機能

1.アプリの概要

イベントを開催して、参加者の状況を管理。最終的にはイベントの日時・場所を決定するアプリです
ユーザーは2種類あり、「イベントの主催者」と「参加者」を想定しています。
Git:https://github.com/tsuyatsuya-april/ikang
HP:http://kyomo-ikang.com/events/1

機能

主催者
・イベントの概要・候補日・候補店を登録・編集・削除する機能
Image from Gyazo

参加者
・参加者の名前と参加状況の登録・編集・削除する機能
Image from Gyazo

2.テーブルデータについて

Userテーブル...主催者の名前・email・passwordを登録
Eventテーブル...イベントの名称と概要を登録
Scheduleテーブル...イベントの候補日を登録
Shopテーブル...イベントの開催場所候補を登録
Joinテーブル...参加者の名前を登録
DateAnswerテーブル...JoinとScheduleを親として、参加日程の状況を登録
ShopAnswerテーブル...JoinとShopを親として、開催場所の評価を登録

作成するアプリでは、主に2つのフォームで下記の関係での登録を行う
Event(親)、Schedule(子)、Shop(子)のイベント登録フォーム
Join(親)、DateAnswer(子)、ShopAnswer(子)の参加者登録フォーム

Image from Gyazo

3.イベント(親)と複数の日程(子)、お店(子)の登録機能

イベントの登録機能を下記の小項目に沿って説明を行う。
尚、ユーザーの登録は、メジャーなので割愛させていただきます。
また、コードの一部抜粋した形で説明させていただきます。

小項目

  1. 注目コード記載(Model,Controller,HTML,JS)
  2. fields_forメソッド
  3. name属性の修正

1.注目コード記載

model/event.rb
class Event < ApplicationRecord
  belongs_to :user
  has_many :shops, inverse_of: :event, dependent: :destroy
  accepts_nested_attributes_for :shops, allow_destroy: true
end

model/shop.rb
class Shop < ApplicationRecord
  belongs_to :event, inverse_of: :shops
  validates_presence_of :event
end
events_controller.rb
  def new
    @event = Event.new
    1.times { @event.shops.build } 
  end 

  def create
    @event = Event.new(event_params)
    @event.user_id = current_user.id
    if @event.save
      redirect_to event_path(@event.id)
    else
      render "new"
    end
  end

  private
  def event_params
    params.require(:event).permit(:name, :description, schedules_attributes: [:savedate, :savetime], shops_attributes: [:shop_name, :shop_url, :map_url, :comment])
  end

events/new.html
    <%= form_for @event,id:"event-new-form", local: true do |f| %>
      <%= render "share/error_messages", model: f.object %>
      <% 中略%>
          <div id="shop-add-btn">
            <%= link_to "お店追加", "#", class:"btn-flat-border" %>
          </div>
        </div>
        <%= f.fields_for :shops do |shop_fields| %>
          <div id="new-shop-top">
            <div id="new-shop">
              <div id="shop-name-box">
                <div id="shop-name"><p>店名(必須)</p></div>
                <%= shop_fields.text_field :shop_name, class:"shop-name-input"%>
              </div>
              <% 中略 %>
              <div id="shop-delete-box">
                <%= link_to "お店削除", "#", class:"btn-flat-border-red", id:"shop-delete" %>
              </div>
            </div>
          </div>
        <% end %>
      </div>
      <div class="submit-box">
        <%= f.submit "イベントの登録",class:"btn-flat-border-submit", id:"new-submit" %>
      </div>
    <% end %>
main.js
//お店の追加
   function newShopAdd(){
        nameNumberShopAdjust();
        const shopParent = document.getElementById("new-shop-top");
        const addShopBtn = document.getElementById("shop-add-btn");
        let currentShopLength = document.querySelectorAll("#new-shop").length;
        let nextNum = currentShopLength;
        let shopHtml = `
          <div id="new-shop">
            <div id="shop-name-box">
              <div id="shop-name"><p>店名(必須)</p></div>
              <input class="shop-name-input" type="text" name="event[shops_attributes][${nextNum}][shop_name]" id="event_shops_attributes_${nextNum}_shop_name">
            </div>
            ~<中略>~`;
        addShopBtn.onclick = function(){
          shopParent.insertAdjacentHTML("beforeend", shopHtml);
          shopDelete();
          newShopAdd();
        };

      }
   //お店の削除
      function shopDelete(){
        let shopParent = document.querySelectorAll("#new-shop");
        let shopDeleteBtn = document.querySelectorAll("#shop-delete");
        let shopParentLength = shopParent.length;
        for (let i=0; i < shopParentLength; i++){
          shopDeleteBtn[i].onclick = function(){
            let conformShopLength = document.querySelectorAll("#new-shop").length;
            if(conformShopLength != 1){  
              return shopParent[i].remove();
            }
          };
        };
      }
   //お店のname属性の値に入る数値の調整
   function nameNumberShopAdjust(){
        let saveShop = document.querySelectorAll(".shop-name-input");
        let saveShopLength = saveShop.length;
        for(let j=0; j<saveShopLength; j++){
          saveShop[j].removeAttribute("name");
          saveShop[j].setAttribute("name",`event[shops_attributes][${j}][shop_name]`);
        }
      }
    } 

2.fields_forメソッド

このメソッドは、公式ドキュメントにて下記のような説明をしている。
モデルを固定してフォームを生成
form_for内で異なるモデルを編集できるようになる。

つまり、今回でいうとEvent(親)とは別のShop(子)モデルの編集ができるようになるというものである。
ただし、fields_forを使う為にはいくつか準備が必要である。

アソシエーションの設定

model/parent.rb

親と子のモデルの双方向の関連付けができるようにする
has_many :子モデル(複数形), invers_of: :(親モデル), dependent: :destroy

子モデルが同時に登録できるようにする
accepts_nested_attributes_for :子モデル(複数形), allow_destroy: true(空白のフォームがあれば登録できないようにする)
model/child.rb
  親と双方向の関連付けしてバリデーション設定などができるようにする。(例 shops.eventなど)
  belongs_to :親モデル, inverse_of: :子モデル

コントローラの設定

controller/test.rb
def new
 @event = Event.new
 関連した子の要素を生成する時はbuild(newのエイリアス)を使用する
 1times { @event.shops.build }
 ちなみにtimesの数値の分だけ子の要素の登録フォームができる
end

通常に保存する場合と同じ
def create
  @event = 親モデル.new(event_params)
    if @event.save
      redirect_to 指定のパス
    else
      render アクション名
    end
end

private
def event_params
    params.require(:親モデル).permit(:親カラム1, :親カラム2, 
                  子モデル名(複数系)_attributes: [:子カラム1])
    .merge(user.id: :current_user.id)
end

ビューの設定

views/test.html
<%= form_for @event,id:url, local: true do |f| %>
  <%= f.text_field :親カラム名 %>
 ここで子モデルの登録ができるように設定を行う
  <%= f.fields_for :子モデル do |sf| %>
     <%= sf.text_field :子モデルカラム名 %> 
  <% end %>
  <%= f.submit "イベントの登録" %>
<% end %>

上記のビュー・モデル・コントローラーの設定を行えば、
フォームの送信ボタンを押した後にデータが下記のようなパラメータで送られます。

paramater.rb
通常の場合
親モデル名 = { :first => 1, :second => 2}

fields_forを使用した場合
親モデル名 = {:first => 1, :second => 2, 
子モデル名 => {:first => 1, :second => 2 }}

見にくいですが具体的な例が下記画像です。
Image from Gyazo

親モデルの中に子モデルがネストされてデータが受け渡され保存されるということになります。
これで複数モデルの複数レコードの保存の下地が整いました。

3. name属性の修正

2では子モデルの複数のレコードが保存できる設定の説明を行ってきた。
ただし、javascriptを用いてお店と日程フォームの増減が行えるように設定をしています。
この時に保存が上手く行かなくなることがあったので、その原因と解消方法について記載します。

問題
javascriptでフォームの追加を実行した後に全てのフォームが保存されない時があった。

前提条件
fields_forを使った時のフォームのname属性がhtml上で下記のように変換されて表示される

test.html
fields_for内のinputタグの中にあるname属性の名前
event[schedules_attributes][0][savedate]
event[schedules_attributes][1][savedate]
event[schedules_attributes][3][savedate]

公式化
親モデル名[子モデル名(複数系)_attributes][要素番号][子モデルの対象カラム名]

仮説検証
Image from Gyazo

解消方法
javascriptを使ってデータの送信前にname属性の要素番号が被らないように調整する。

main.js

   //(送信ボタンを押した時に要素番号を調整するメソッドを実行)
   function nameNumberShopAdjust(){
     //お店のフォームのセレクタを取得
        let saveShop = document.querySelectorAll(".shop-name-input");
     //要素の数がいくらあるかを取得
        let saveShopLength = saveShop.length;
     //要素数分だけループを実行
        for(let j=0; j<saveShopLength; j++){
      //フォームの既存で設定されたname属性を削除する
          saveShop[j].removeAttribute("name");
      //フォームのname属性について、現在のループ回数を要素番号として付け直す。
          saveShop[j].setAttribute("name",`event[shops_attributes][${j}][shop_name]`);
        }
      }
    } 

ここの部分に関しては、ドキュメントに記載されておらず挙動を読んで
仮説検証をたてたものですが上記方法で解決致しました。

4.参加者(親)と参加状況(子)の登録機能

大項目3で指定した日程とお店について、参加状況を登録する方法について記載する。
join(親)、shop(親)、shop_answer(子)の中間テーブル、
join(親)、schedule(親)、date_answer(子)の中間テーブル
上記2つの登録を主に行う。
尚、解説はお店の方のみさせていただく

小項目

  1. 注目コード記載(Model,Controller,HTM, routes)
  2. viewのname属性の調整
  3. 中間テーブルの親_idの格納
  4. event/showページで子のjoinコントローラにパラメータを渡す方法
  5. joinコントローラーのupdateアクション

1. 注目コード記載(Model,Controller,HTM, routes)

model/join.rb
  has_many :shop_answers, dependent: :destroy
  accepts_nested_attributes_for :shop_answers, allow_destroy: true
model/shop.rb
  has_many :shop_answesr, dependent: :destroy
model/shop_answer.rb
  belongs_to :join
  validates_presence_of :join
  belongs_to :shop
events_controller.rb
  def show
    if params[:join_id]
      set_join
    else
      @join = Join.new
      1.times { @join.shop_answers.build }
    end
  end
joins_controller.rb
  def create
    @join = Join.new(join_params)
    if @join.save
      redirect_to event_path(params[:event_id])
    else
      render "events/show"
    end
  end
  private
  def join_params
    params.require(:join).permit(:nickname, :email, 
      shop_answers_attributes: [:shop_id, :status, :vote])
      .merge(event_id: params[:event_id])
  end
events/show.html
<%= form_with model: @join, url:event_joins_path(@event.id), id:"join-form",  local: true do |f| %>
   <div id="join-box">
     <div id="join-name-label">
       <label>ユーザー名</label>
     </div>
     <div class="join-users">
      <%= f.text_field :nickname, id:"join-user",placeholder:"ニックネームを入力ください"%>
     </div>

     <div id="shop-answer">
       <h1 id="shop-answer-title">店舗選択</h1>
         <div class="circle-text">説明: お店を◯×△で評価,一番良いと思う店に一番ボタンで投票下さい</div>
           <table id="shop-answer-table">
             <tbody> 
               <% @event.shops.each do |es| %>
                  <%= f.fields_for :shop_answers do |shop_fields| %>
                    <tr id="join-shops" class="bottom-line">
                      <th class="shop-label">
                        <div>
                          <%= link_to es.shop_name, es.shop_url, target: :_blank %>
                        </div>
                      </th>
                      <td class="shop-vote-balance">
                        <%= shop_fields.hidden_field :shop_id,class:"shop-id", value: es.id %>
                        <%= shop_fields.hidden_field :status, id:"shops-status" %>
                        <%= shop_fields.hidden_field :vote, id:"shops-vote" %>
                        <div class="change-status">
                          <h1 class="btn btn--orange btn--circle btn--circle-a btn--shadow " id="shop-yes"></h1>
                          <h1 class="btn btn--orange btn--circle btn--circle-a btn--shadow choice" id="shop-delta"></h1>
                          <h1 class="btn btn--orange btn--circle btn--circle-a btn--shadow cross-vote" id="shop-no">×</h1>
                         </div>
                         <div id="shop-change-vote">
                           <h1 class="btn btn--orange btn--circle btn--circle-a btn--shadow" id="shop-vote">一番</h1>
                         </div>
                       </td>
                    </tr>
                 <% end %>
              <% end %>
            </tbody>
         </table>
      </div>
       <div id="join-submit-box">
        <%= f.submit "回答",class:"btn-flat-border", id:"join-submit-inputbox", :onclick => "return check_name()" %>
       </div>
  <% end %>
routes.rb
Rails.application.routes.draw do
  root to: "events#index"
  resources :events do
    resources :schedules
    resources :shops
    resources :joins
    resources :comments
    resources :date_decisions
    resources :shop_decisions
  end
end

2. viewのname属性の調整

大項目3で登録した複数データに対して、複数の中間テーブルの保存を行うには下記のコードの構造をとる。

events/show.html
<%= form_with model: @join, url:event_joins_path(@event.id), id:"join-form",  local: true do |f| %>
  親モデルのnicknameカラムの保存フォーム
  <%= f.text_field :nickname, id:"join-user",placeholder:"ニックネームを入力ください"%>

  イベントページに紐づいているお店のレコードを全て出力する。
  <% @event.shops.each do |es| %>

  fields_forで複数レコード登録できるようにする。
    <%= f.fields_for :shop_answers do |shop_fields| %>

    中間テーブルの親IDを登録するフォーム。事前にid番号をvalueに格納する
      <%= shop_fields.hidden_field :shop_id, value: es.id %>

    javascriptを使って、数字1(=◯),2(=△),3(=×)が格納されるようにしている
      <%= shop_fields.hidden_field :status, id:"shops-status" %>

    javascriptを使って投票数0,1を格納する。
      <%= shop_fields.hidden_field :vote, id:"shops-vote" %>

    onclickでユーザーのニックネームが格納されていない場合にフォームを送信できないようにする
      <%= f.submit "回答",:onclick => "return check_name()" %>
    <% end %>
  <% end %>
<% end %>               

Image from Gyazo
解決方法
大項目3と同様な形でjavascriptを用いて、
出力されたお店の分、name属性の要素番号を付け直す
これで出力されたお店のレコード分だけ、中間テーブルのレコードが保存できるようになった。

3. 中間テーブルの親_idの格納

中間テーブルであるshop_answerテーブルに親IDを紐付ける方法を記載する。
・1点目のjoin_idはfields_forメソッドを使用しているので親子関係となり自動的にIDが割り振られる。
・2点目のshop_idは下記の通りeachメソッドを用いて、各ID番号をshop_idのフォームに格納している。

events/show.html
<% @event.shops.each do |es| %>
  <%= f.fields_for :shop_answers do |shop_fields| %>

    先ほども記述したがes.idでshop(親)のID番号をshop_answer_idに渡している。
    <%= shop_fields.hidden_field :shop_id, value: es.id %>
  <% end %>
<% end %>               

以上で中間テーブルの保存ができる状態になった

4. events/showページで子のjoinコントローラにパラメータを渡す方法

初学者にとってevents/showページで登録を行う場合、
events_controllerにパラメータをpostするという印象が強い。
しかし今回はjoins_controllerにパラメータをpostして登録を行うので
その場合のurl指定方法を記載する。

まず下記コマンドを実行しURLの確認を行う

test.rb
 rails routes

次に確認したURLの内joinの登録であるURLをform_withメソッドの中に適応させる

events/show.html
 <%= form_with model: @join, url:event_joins_path(@event.id), id:"join-form",  local: true do |f| %>

以上を行うことで、events/showページからjoins_controllerにデータを受け渡すことができるようになる。

5.参加者の参加状況の編集機能

events/show.htmlにて参加者毎の編集フォームを表示する方法について記載を行う。
複数の情報を出力する上で

小項目

  1. 同一ページ内で参加者IDの情報をコントローラに受け渡す方法
  2. fields_forを用いた場合の編集フォームのviewを表示について。
  3. objectメソッド、親データの店名やURLを引き出す方法

1. 同一ページ内で参加者IDの情報をコントローラに受け渡す方法

まず、events/show.html上に参加者の編集フォームを出力するには、
コントローラに各参加者のjoin_idの数値を送り、データを抽出する必要がある。

その為に、link_toメソッドを用いて、join_idをパラメータとしてコントローラに引き渡す手法を用いた。

events/show.html

<% if @event.joins %>
 登録されている参加者全てを出力する
  <% @event.joins.each do |event_join| %>
  ループ中の参加者のID、join.idをjoin_idというキーに格納してコントローラに渡している
    <%= link_to event_join.nickname, event_path(@event.id,join_id: event_join.id) %>
  <% end %>
<% end %>

コントローラではjoin_idというパラメータの有無によって
編集フォームの出力か登録ページの出力かの分岐を行っている。

events_controller.rb
def show
  @joins = Join.all
  if params[:join_id]
    set_join
  else
    @join = Join.new
    1.times { @join.shop_answers.build }
  end
end

private
def set_join   
    @join = Join.find(params[:join_id])
end

編集フォームの場合は、決まった値をフォームに格納した状態で表示される。
登録フォームの場合は、新規にデータを作成できる状態で表示される
これでビューに表示できる条件が整った。

2. fields_forを用いた場合の編集フォームのviewを表示について

登録時には、shop.each doとfields_forを用いたが編集時には、fields_forのみを用いれば、選択したJoinレコードに紐づくshop_answerレコードが全て出力される。

events/show.html
<% unless params[:join_id] %>
新規登録の時の処理
<% else %>
編集時の処理
  <%= form_with model: @join, url:event_join_path(@event.id, @join.id), id:"join-edit-form" , local: true do |f| %>
   ユーザー名の編集
    <%= f.text_field :nickname,id:"join-edit-user", placeholder:"ニックネームを入力ください"%>
   fields_forを用いて、選択したJoinレコードに紐づく全てのshop_answerレコードを出力
    <%= f.fields_for :shop_answers, @join.shop_answers do |shop_edit_fields| %>
   編集ページでは新たに、joinのidカラムを格納するフォームを用意する。それ以外は登録と同じ。
      <%= shop_edit_fields.hidden_field :id,class:"shop-answers-id" %>
      <%= shop_edit_fields.hidden_field :shop_id,class:"shop-edit-id" %>   
      <%= shop_edit_fields.hidden_field :status, id:"shops-edit-status" %>
      <%= shop_edit_fields.hidden_field :vote, id:"shops-edit-vote" %>  
    <% end % >
    <%= f.submit "更新",class:"btn-flat-border", id:"join-edit-submit-inputbox", :onclick => "return check_name()" %>
  <% end %>
<% end %>

同じ躓きをする方もいると思うので私が失敗した時の場合も下記の画像にて記載します。
Image from Gyazo

3. objectメソッド、親テーブルの店名やURLを引き出す方法

小項目2では、フォームが正しく表示されたが実は1つ問題がある。
それは、fields_forでは、
「中間テーブルの子モデルに対し、親モデルのデータが引き出せない」ことである。
私のアプリでは店名やURLが表示されず、どのデータに紐づいているか判断出来なくなる。

これに対して、shops.each do を用いて、店名を引き出せば良いと考えたこともあった。
しかし、結果は小項目2の最後の画像の通り、フォームが正しく表記されない。

そこで、objectメソッドを用いることにした。
このメソッドを変数の後につけることで、変数に格納しているデータを取得できる。

test.HTML
  shop_answer = { id => 1, join_id => 1, shop_id => 1, status => 1}
  shop = { id => 1, shop_name => "吉野家", shop_url => "yoshinoya" }

  <%= f.fields_for :shop_answers, @join.shop_answers do |shop_edit_fields| %>
   snum = shop_id = 1
    <% snum = shop_edit_fields.object.shop_id%>
      sn = Shop.find(1)
     <% sn = Shop.find(snum) %>
     これでfields_for内でshop_answerの親モデルのお店情報が出力できるようになった
     sn.shop_name = 吉野家
   sn.shop_url = yoshinoya

objectメソッド導入前と導入後のイメージ
Image from Gyazo

5. joinコントローラーのupdateアクション

fields_forを用いた時は、paramsの表記方法が異なる。
登録時には子モデルのID番号が必要なかったが
更新時には子モデルのID番号が必要となる

また、:_destroyをつけることで親モデルに紐づく子モデルのデータを削除することができる。
例えば、編集時に子モデルのフォームを空白に変更して更新するとデータが削除されるなど。

joins_controller.rb
  def update
    if @join.update(join_update_params)
      redirect_to event_path(params[:event_id])
    else
      render "events/show"
    end
  end

  private
  通常時
  def join_params
    params.require(:join).permit(:nickname, :email, 
      date_answers_attributes: [:schedule_id, :status], 
      shop_answers_attributes: [:shop_id, :status, :vote])
      .merge(event_id: params[:event_id])
  end
 
 更新時
  def join_update_params
    params.require(:join).permit(:nickname, :email, 
      date_answers_attributes: [:id,:schedule_id, :status, :_destroy], 
      shop_answers_attributes: [:id,:shop_id, :status, :vote, :_destroy])
  end

6. 参加状況の削除機能

アソシエーションの設定で親レコードの削除に伴い紐づく子のレコードが削除されるようにする必要がある。
allow_destroy: trueは
親要素が削除された時、関連付けている情報もまとめて削除するためのオプションです。

model/join.rb
  has_many :date_answers, dependent: :destroy
  accepts_nested_attributes_for :date_answers, allow_destroy: true
model/shop.rb
  has_many :shop_answer, dependent: :destroy
model/shop_answer.rb
  belongs_to :join
  validates_presence_of :join
  belongs_to :shop

以上のアソシエーションをつけることで通常時と変わりなく、削除を実行することができる。
今回はビューとコントローラーの記述を割愛させていただきます。

最後に

ここまで読んで下さってありがとうございます。
はじめて個人で作成したアプリなのでアラが目立つかとは存じますが
ご容赦下さいますようお願いします。

また、何かご質問があればコメントお願いします。
お答えできる限りはお答え致します。

参考

18
14
2

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
18
14