最初に
この記事は、幹事向けのアプリを作成方法を記載します。
どんな構造を取るかアプリと言うと、2段階の複数登録を行うものです。
①複数のテーブルと複数レコードを同時登録(イベントと複数の日付の登録)
②①で登録した複数レコード(日付など)の中間テーブルレコードの一括登録(出欠状態など)
開発時に、②複数のレコードを登録する方法について
参考となる情報がなく苦労しました。
そこで、作り方を公開して、同じ悩みを持った方の
助けに慣れればと思い記載致します。
※UI部分については今回紹介していません。Gitにて確認お願いします。
目次
- 概要
- テーブルデータについて
- イベント(親)と複数の日程(子)、お店(子)の登録機能
- 参加者(親)と参加状況(子)の登録機能
- 参加者の参加状況の編集機能
- 参加状況の削除機能
1.アプリの概要
イベントを開催して、参加者の状況を管理。最終的にはイベントの日時・場所を決定するアプリです
ユーザーは2種類あり、「イベントの主催者」と「参加者」を想定しています。
Git:https://github.com/tsuyatsuya-april/ikang
HP:http://kyomo-ikang.com/events/1
機能
主催者
・イベントの概要・候補日・候補店を登録・編集・削除する機能
2.テーブルデータについて
Userテーブル...主催者の名前・email・passwordを登録
Eventテーブル...イベントの名称と概要を登録
Scheduleテーブル...イベントの候補日を登録
Shopテーブル...イベントの開催場所候補を登録
Joinテーブル...参加者の名前を登録
DateAnswerテーブル...JoinとScheduleを親として、参加日程の状況を登録
ShopAnswerテーブル...JoinとShopを親として、開催場所の評価を登録
作成するアプリでは、主に2つのフォームで下記の関係での登録を行う
Event(親)、Schedule(子)、Shop(子)のイベント登録フォーム
Join(親)、DateAnswer(子)、ShopAnswer(子)の参加者登録フォーム
3.イベント(親)と複数の日程(子)、お店(子)の登録機能
イベントの登録機能を下記の小項目に沿って説明を行う。
尚、ユーザーの登録は、メジャーなので割愛させていただきます。
また、コードの一部抜粋した形で説明させていただきます。
小項目
- 注目コード記載(Model,Controller,HTML,JS)
- fields_forメソッド
- name属性の修正
1.注目コード記載
class Event < ApplicationRecord
belongs_to :user
has_many :shops, inverse_of: :event, dependent: :destroy
accepts_nested_attributes_for :shops, allow_destroy: true
end
class Shop < ApplicationRecord
belongs_to :event, inverse_of: :shops
validates_presence_of :event
end
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
<%= 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 %>
//お店の追加
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を使う為にはいくつか準備が必要である。
アソシエーションの設定
親と子のモデルの双方向の関連付けができるようにする
has_many :子モデル(複数形), invers_of: :(親モデル), dependent: :destroy
子モデルが同時に登録できるようにする
accepts_nested_attributes_for :子モデル(複数形), allow_destroy: true(空白のフォームがあれば登録できないようにする)
親と双方向の関連付けしてバリデーション設定などができるようにする。(例 shops.eventなど)
belongs_to :親モデル, inverse_of: :子モデル
コントローラの設定
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
ビューの設定
<%= form_for @event,id:url, local: true do |f| %>
<%= f.text_field :親カラム名 %>
ここで子モデルの登録ができるように設定を行う
<%= f.fields_for :子モデル do |sf| %>
<%= sf.text_field :子モデルカラム名 %>
<% end %>
<%= f.submit "イベントの登録" %>
<% end %>
上記のビュー・モデル・コントローラーの設定を行えば、
フォームの送信ボタンを押した後にデータが下記のようなパラメータで送られます。
通常の場合
親モデル名 = { :first => 1, :second => 2}
fields_forを使用した場合
親モデル名 = {:first => 1, :second => 2,
子モデル名 => {:first => 1, :second => 2 }}
__親モデルの中に子モデルがネストされてデータが受け渡され保存される__ということになります。
これで複数モデルの複数レコードの保存の下地が整いました。
3. name属性の修正
2では子モデルの複数のレコードが保存できる設定の説明を行ってきた。
ただし、javascriptを用いてお店と日程フォームの増減が行えるように設定をしています。
この時に保存が上手く行かなくなることがあったので、その原因と解消方法について記載します。
問題
javascriptでフォームの追加を実行した後に全てのフォームが保存されない時があった。
前提条件
fields_forを使った時のフォームのname属性がhtml上で下記のように変換されて表示される
fields_for内のinputタグの中にあるname属性の名前
event[schedules_attributes][0][savedate]
event[schedules_attributes][1][savedate]
event[schedules_attributes][3][savedate]
公式化
親モデル名[子モデル名(複数系)_attributes][要素番号][子モデルの対象カラム名]
解消方法
javascriptを使ってデータの送信前にname属性の要素番号が被らないように調整する。
//(送信ボタンを押した時に要素番号を調整するメソッドを実行)
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つの登録を主に行う。
尚、解説はお店の方のみさせていただく
小項目
- 注目コード記載(Model,Controller,HTM, routes)
- viewのname属性の調整
- 中間テーブルの親_idの格納
- event/showページで子のjoinコントローラにパラメータを渡す方法
- joinコントローラーのupdateアクション
1. 注目コード記載(Model,Controller,HTM, routes)
has_many :shop_answers, dependent: :destroy
accepts_nested_attributes_for :shop_answers, allow_destroy: true
has_many :shop_answesr, dependent: :destroy
belongs_to :join
validates_presence_of :join
belongs_to :shop
def show
if params[:join_id]
set_join
else
@join = Join.new
1.times { @join.shop_answers.build }
end
end
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
<%= 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 %>
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で登録した複数データに対して、複数の中間テーブルの保存を行うには下記のコードの構造をとる。
<%= 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 %>
解決方法
大項目3と同様な形でjavascriptを用いて、
出力されたお店の分、name属性の要素番号を付け直す
これで出力されたお店のレコード分だけ、中間テーブルのレコードが保存できるようになった。
3. 中間テーブルの親_idの格納
中間テーブルであるshop_answerテーブルに親IDを紐付ける方法を記載する。
・1点目のjoin_idはfields_forメソッドを使用しているので親子関係となり自動的にIDが割り振られる。
・2点目のshop_idは下記の通りeachメソッドを用いて、各ID番号をshop_idのフォームに格納している。
<% @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の確認を行う
rails routes
次に確認したURLの内joinの登録であるURLをform_withメソッドの中に適応させる
<%= 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にて参加者毎の編集フォームを表示する方法について記載を行う。
複数の情報を出力する上で
小項目
- 同一ページ内で参加者IDの情報をコントローラに受け渡す方法
- fields_forを用いた場合の編集フォームのviewを表示について。
- objectメソッド、親データの店名やURLを引き出す方法
1. 同一ページ内で参加者IDの情報をコントローラに受け渡す方法
まず、events/show.html上に参加者の編集フォームを出力するには、
コントローラに各参加者のjoin_idの数値を送り、データを抽出する必要がある。
その為に、link_toメソッドを用いて、join_idをパラメータとしてコントローラに引き渡す手法を用いた。
<% 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というパラメータの有無によって
編集フォームの出力か登録ページの出力かの分岐を行っている。
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レコードが全て出力される。
<% 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 %>
同じ躓きをする方もいると思うので私が失敗した時の場合も下記の画像にて記載します。
3. objectメソッド、親テーブルの店名やURLを引き出す方法
小項目2では、フォームが正しく表示されたが実は1つ問題がある。
それは、fields_forでは、
__「中間テーブルの子モデルに対し、親モデルのデータが引き出せない」__ことである。
私のアプリでは店名やURLが表示されず、どのデータに紐づいているか判断出来なくなる。
これに対して、shops.each do を用いて、店名を引き出せば良いと考えたこともあった。
しかし、結果は小項目2の最後の画像の通り、フォームが正しく表記されない。
そこで、objectメソッドを用いることにした。
このメソッドを変数の後につけることで、変数に格納しているデータを取得できる。
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
5. joinコントローラーのupdateアクション
fields_forを用いた時は、paramsの表記方法が異なる。
登録時には子モデルのID番号が必要なかったが
更新時には子モデルのID番号が必要となる
また、:_destroyをつけることで親モデルに紐づく子モデルのデータを削除することができる。
例えば、編集時に子モデルのフォームを空白に変更して更新するとデータが削除されるなど。
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は
親要素が削除された時、関連付けている情報もまとめて削除するためのオプションです。
has_many :date_answers, dependent: :destroy
accepts_nested_attributes_for :date_answers, allow_destroy: true
has_many :shop_answer, dependent: :destroy
belongs_to :join
validates_presence_of :join
belongs_to :shop
以上のアソシエーションをつけることで通常時と変わりなく、削除を実行することができる。
今回はビューとコントローラーの記述を割愛させていただきます。
最後に
ここまで読んで下さってありがとうございます。
はじめて個人で作成したアプリなのでアラが目立つかとは存じますが
ご容赦下さいますようお願いします。
また、何かご質問があればコメントお願いします。
お答えできる限りはお答え致します。
参考