やったこと
1対多の関係にあるテーブルの子テーブルのデータを、親テーブルのフォームから一気に作成できる、cocoon
という便利なgemがあります。基本的な作り方は既に色々な記事が上がっていますが、
▼この辺りの記事が便利です
fields_forを使った子モデルへの複数レコード保存【cocoonが便利】
この度リッチなフォームを作ることになり、かなりカスタマイズしたので、その記録をまとめておきます。
なお、実行環境は以下の通りです。
rails 5.2.4.2
-
webpacker
使用
作ったもの
作ったフォームのイメージはこんな感じです。以下、サンプルの画像です。
▼基本の画面
▼事前に設定した人数以上にはフィールドは増えない(disabledボタンになる)
▼ゴミ箱アイコンを押すと、確認画面が出て、OKならフィールドが消える
▼同じ人に2つの記録は作成できない(アラートメッセージが出る。DB側でも別途制御している)
必要なものだけを入れたつもりが、いつの間にか4つも機能がついていました。。。。
RailsにjQueryを入れる
cocoon
はjQueryに依存していますが、Rails6や非sprockets環境のRailsでは、デフォルトではjQueryが入っていません。そのため、RailsにjQueryを導入します。
▼こちらの記事を参考にしました
Rails6でjQueryの導入方法
$ yarn add jquery
const { environment } = require('@rails/webpacker')
// 追記
const webpack = require('webpack')
environment.plugins.prepend('Provide',
new webpack.ProvidePlugin({
$: 'jquery/src/jquery',
jQuery: 'jquery/src/jquery'
})
)
// ここまで
module.exports = environment
require("@rails/ujs").start()
require('jquery') // 追記
gem cocoonを入れる
通常通りRailsにGemをインストールした後、下記のドキュメントを参考にRails6用の設定を読み込ませました。
▼公式のノート
Rails 6/Webpacker
一部の機能は、こちらのRails6用のパッケージをインストールしないと動かなかったです。
gem 'cocoon'
$ yarn add @nathanvda/cocoon
require("jquery")
require("@nathanvda/cocoon") // 追記
基本の書き方
以下、実際に書いたコードをもとに、基本的な書き方を紹介します。
model
今回のモデルはevent
とlog
とします。それぞれのモデルの書き方は、以下の通りです。
class Event < ActiveRecord::Base
has_many :logs, inverse_of: :event, dependent: :destroy
accepts_nested_attributes_for :logs, allow_destroy: true
validates_associated :logs
end
class Log < ActiveRecord::Base
belongs_to :event
end
allow_destroy: true
をつけることで、子モデルの削除が可能になります。また、今回は子モデルのバリデーションを(Rails側で)効かせたかったので、validates_associated
で子モデルを指定しました。
inverse_of
でわざわざ反対のアソシエーションを追記している理由は、公式のこちらの記述を参考に、厳密さを上げるために付けています。
▼公式の記述を主にGoogle翻訳したもの
ネストされたアイテムを保存する場合、理論的には親はまだ検証時に保存されていないため、railsはリレーション間のリンクを知るための支援が必要です。 方法は2つあります。ひとつは
belongs_to:
をoptional: false
で使うこと。しかし最もきれいな方法はinverse_of:
をhas_many
の方に使うことです。
controller(strong paramater)
コントローラーのstrong paramater
では、*_attributes
の形で、子モデルのパラメーターを受け取れるようにします。
accepts_nested_attributes_for
を使った、子テーブルのデータを一気に作成するフォームの作成は詳しくはこちらの記事をご覧ください。
fields_forで子テーブルのデータを一気に作成する(テストも書いてます)[Rails][Rspec]
def project_params
params.require(:event).permit(:name, :description, log_attributes: [:user_id, :task1, :task2, :_destroy])
end
:_destroy
を記載しているのがポイントで、これで子モデルの削除及び編集の動作が利用可能になります。
View
ビューファイルの書き方は以下の通りです。まずは、外側のフォームから。なお、装飾のためのクラスや実装に直接関係のないタグは省いています。
また、ところどころに.js-****
といったクラス名、ID名が出てきますが、それは「JSで使用しているクラス名・ID名だよ」ということを明確にするためのものです。後半にJSのコードも載せているので、ここではあえて記載しています。
= form_with model: @event, local: true do |f|
#logs
= f.fields_for :logs, local: true, id: 'js-log-field' do |log|
= render 'log_fields', f: log
.links{data: {count: staffs.count}}
= link_to_add_association '対応者の情報を追加', f, :logs, { class: 'js-add-log-field-btn' }
link_to_add_association
の部分が、要素を追加するためのボタンを設置するところ。ボタンの直前にある.links
というクラスは必須のようです。render 'log_fields'
で呼び出しているのは、ボタンを押して追加するパーシャルです。
.nested-fields
.field
= f.collection_select :staff_id, @staffs, :id, :name, {prompt: '選択してください' }, {class: 'form-control js-select-form'}
.field
= f.check_box :task1
.field
= f.check_box :task2
.field
= link_to_remove_association "#{icon 'fas', 'trash-alt'}".html_safe , f, class: 'js-log-remove-btn'
チェックボックスを2つも入れてしまっているのでわかりにくいですが、先の.links
下に設置したボタンを押すと、.nested-fields
以下が増えていきます。.field
は公式のこのあたりの記述を見るに、ブロック要素一つ一つに必要なようです。
アレンジ:削除ボタンをアイコンにする
削除ボタンは最低限、
= link_to_remove_association "削除", f
だけあれば実装可能ですが、今回は"削除"のボタンをゴミ箱のアイコンに変えたかったので、少し冗長な記述になっています。(作成した環境ではgem fontawesome-sass
を利用しているため、そのアイコンの記述方法を利用しつつ、テキストではなくhtmlとして読み込ませています)
= link_to_remove_association "#{icon 'fas', 'trash-alt'}".html_safe , f, class: 'js-log-remove-btn'
カスタマイズ用JSのコード
上記までの記述で、フィールドを増やす・フィールドを減らすといった基本的な動作は可能ですが、今回はカスタマイズ用に、以下のコードを追加しました。まずは一気に書いたものを紹介します。
$(document).ready(function(){
var $addFieldBtn = $('.js-add-field-btn'),
counter = 1,
$cocoonField = $('#activity_logs'),
$headerStaffName = $('#js-staff-name'),
$staffAmount = $addFieldBtn.parent().data('count');
// cocoonのコールバック ------------ 解説します(1)
$cocoonField
.on('cocoon:after-insert', function() {
counter++;
checkCount(counter);
})
.on('cocoon:before-remove', function(event) {
// 削除ボタンを押すと、でアラートメッセージで確認が入る
var confirmation = confirm('記録を削除します。よろしいですか?');
if( !confirmation ){
event.preventDefault();
}
})
.on("cocoon:after-remove", function() {
// スタッフ数のカウントを減らす
counter--;
checkCount(counter);
// 新しいnameListを定義する
var nameList = [],
$nestedFields = $cocoonField.find('.nested-fields'),
selectForms = [];
// 編集時には、削除したフォームがdisplay: noneで画面内に残るので
// $nestedFieldから計算用の配列を作成する 解説します(2)
selectForms = filterVisibleField($nestedFields, selectForms)
var listToCheck = getNameList(selectForms, nameList);
// エラー関連のCSSを削除する
if (checkSameValue(listToCheck)) {
removeErrorClasses();
}
});
// 一定の人数以上にフィールドを生成しない処理 --------------------------------
function checkCount(count){
if (count >= $staffAmount) {
// 要素はdisabledのpropで動作しなくなるが、見た目はdisabledのclassを付けないと変化しない
$addFieldBtn.prop('disabled', true);
$addFieldBtn.addClass('disabled');
} else if (count < $staffAmount) {
$addFieldBtn.prop('disabled', false);
$addFieldBtn.removeClass('disabled');
}
}
// 見えないフィールドをnameList作成から除外するメソッド
function filterVisibleField($nestedFields, selectForms) {
$nestedFields.each(function(i, field) {
if ($(field).css('display') !== 'none') {
var $selectForm = $($(this).find('select'))
selectForms.push($selectForm)
}
})
return selectForms
}
// 名前が重複していることを通知する処理 ------------------------
$(document).on('change', '.js-select-form', function(event){
var blankList = [],
alertMessage = document.createElement('span'),
alertContent = document.createTextNode('スタッフ名重複'),
$nestedFields = $cocoonField.find('.nested-fields'),
selectForms = [];
// エラーメッセージを作成する
alertMessage.appendChild(alertContent);
alertMessage.setAttribute('class', 'c-error-message');
selectForms = filterVisibleField($nestedFields, selectForms)
var listToCheck = getNameList(selectForms, blankList);
if (checkSameValue(listToCheck)) {
removeErrorClasses();
} else {
if (!$('.c-error-message').length) {
$headerStaffName.append(alertMessage);
}
}
});
// エラー関連のcss削除
function removeErrorClasses() {
$('.c-error-message').remove();
}
// セレクトフォームの選択中の項目の配列を作る
function getNameList(selectForms, nameList) {
selectForms.filter(form => {
var name = $(form).children("option:selected").text()
if (name !== '選択してください') {
nameList.push(name);
}
})
return nameList
}
// 作った配列の中に、重複した名前がないか判定
function checkSameValue(array){
var uniqArray = new Set(array); // 解説します(3)
if (array.length === 1) {
return true;
} else if (uniqArray.size === array.length) {
return true;
} else {
return false;
}
}
});
上記のコードは動作はしますが、ここではCocoonの使用に関するところと、自分的に大発見だったところだけ、主に解説しようと思います。
解説(1):cocoon のコールバックを設定する
cocoonでは以下の4つのタイミングでコールバックを設定できます。
$(document).ready(function() {
$('#someId') // ここでのIDはフィールドの挿入開始位置に記載したID
.on('cocoon:before-insert', function() {
// フィールド挿入前の処理
})
.on('cocoon:after-insert', function() {
// フィールド挿入前の処理
})
.on("cocoon:before-remove", function() {
// フィールド削除前の処理
})
.on("cocoon:after-remove", function() {
// フィールド削除後の処理
});
それぞれのコールバックは以下のような引数をとることができます。
function(e, insertedItem, originalEvent)
-
e
...おなじみイベントオブジェクト -
insertedItem
...削除/追加
される要素のパラメーター -
originalEvent
... 挿入または削除をトリガーしたイベント
3つ目の使いどころがよくわかりませんが、、、。詳細は公式ドキュメントのこちらの部分にも詳しいです。
解説(2): 編集画面では、フィールドを削除ボタンを押すとdisplay: none;
で要素が消える
ここが今回一番のハマりどころだったのですが、
新規登録画面では、フィールド削除ボタン(ゴミ箱のアイコン)を謳歌すると、要素そのものが削除されるのですが、編集画面では、display: none;
で見えなくなるだけです。
フォームの「送信」ボタンを押した時点で修正が確定になることと、今回は「画面内にあるスタッフ名のセレクトボックスの数」を基準に、「重複した名前があったらエラー通知を出す」処理をしていたので、気づくまではなかなかエラーが直せませんでした...。
解説(3):おまけ
なお、作成していて、こちらの面白いメソッドに出会ったので紹介します。
▼ソースはこちら
JavaScriptで配列の重複チェックを超簡単&スマートにやる方法
/** 配列内で値が重複してないか調べる **/
function existsSameValue(a){
var s = new Set(a);
return s.size != a.length;
}
Set(引数)
は引数から重複のない配列を作成するメソッドで(MDNの記述はこちら)、
- 重複のない配列 と
- 重複があるかもしれない元の配列
の長さを比べることで、重複があるかどうかチェックできるそうです。へー。
その他、学んだこと
色々な要素が盛り沢山で、実装に時間がかかってしまったのですが、今回発見した1番大きなことは、jQueryオブジェクト用のメソッドはJSのオブジェクトには使えないということでしょうか。。。
私がWEB業界に入ったときにはすでにjQueryはかなり下火で、ちゃんと習う機会もなく、今回見様見真似で書いていました。
その結果、「このオブジェクトで使えたメソッドが、あのオブジェクトには使えない」ということが大量に発生していました。
途中でようやく気がついてなんとか実装できましたが。。。ちなみに、上記のコードではjQueryオブジェクトとJSのオブジェクトを区別するため、前者に$
をつけています。
それでは、最後まで読んでくださりありがとうございました^^
年末年始、また記事を書き溜めていきたいと思います。