LoginSignup
34
38

More than 3 years have passed since last update.

cocoonを使って、子要素のデータを一気に作成する(&いろいろカスタマイズする)

Posted at

やったこと

1対多の関係にあるテーブルの子テーブルのデータを、親テーブルのフォームから一気に作成できる、cocoonという便利なgemがあります。基本的な作り方は既に色々な記事が上がっていますが、

▼この辺りの記事が便利です
fields_forを使った子モデルへの複数レコード保存【cocoonが便利】

この度リッチなフォームを作ることになり、かなりカスタマイズしたので、その記録をまとめておきます。

なお、実行環境は以下の通りです。

  • rails 5.2.4.2
  • webpacker使用

作ったもの

作ったフォームのイメージはこんな感じです。以下、サンプルの画像です。
▼基本の画面
Image from Gyazo

▼ボタンを押すとフィールドが増える
Image from Gyazo

▼事前に設定した人数以上にはフィールドは増えない(disabledボタンになる)
Image from Gyazo

▼ゴミ箱アイコンを押すと、確認画面が出て、OKならフィールドが消える
Image from Gyazo

▼同じ人に2つの記録は作成できない(アラートメッセージが出る。DB側でも別途制御している)
Image from Gyazo

必要なものだけを入れたつもりが、いつの間にか4つも機能がついていました。。。。

RailsにjQueryを入れる

cocoonはjQueryに依存していますが、Rails6や非sprockets環境のRailsでは、デフォルトではjQueryが入っていません。そのため、RailsにjQueryを導入します。

▼こちらの記事を参考にしました
Rails6でjQueryの導入方法

$ yarn add jquery
config/webpack/environment.js

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
javascript/packs/application.js
require("@rails/ujs").start()
require('jquery') // 追記

gem cocoonを入れる

通常通りRailsにGemをインストールした後、下記のドキュメントを参考にRails6用の設定を読み込ませました。

▼公式のノート
Rails 6/Webpacker

一部の機能は、こちらのRails6用のパッケージをインストールしないと動かなかったです。

Gemfile
gem 'cocoon'
$ yarn add @nathanvda/cocoon
app/javascripts/packs/application.js
require("jquery")
require("@nathanvda/cocoon") // 追記

基本の書き方

以下、実際に書いたコードをもとに、基本的な書き方を紹介します。

model

今回のモデルはeventlogとします。それぞれのモデルの書き方は、以下の通りです。

models

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]

controller
def project_params
  params.require(:event).permit(:name, :description, log_attributes: [:user_id, :task1, :task2, :_destroy])
end

:_destroyを記載しているのがポイントで、これで子モデルの削除及び編集の動作が利用可能になります。

View

ビューファイルの書き方は以下の通りです。まずは、外側のフォームから。なお、装飾のためのクラスや実装に直接関係のないタグは省いています。

また、ところどころに.js-****といったクラス名、ID名が出てきますが、それは「JSで使用しているクラス名・ID名だよ」ということを明確にするためのものです。後半にJSのコードも載せているので、ここではあえて記載しています。

view
= 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'で呼び出しているのは、ボタンを押して追加するパーシャルです。

_log_field.html.haml
.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のオブジェクトを区別するため、前者に$をつけています。

それでは、最後まで読んでくださりありがとうございました^^
年末年始、また記事を書き溜めていきたいと思います。

34
38
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
34
38