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

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

▼この辺りの記事が便利です
[fields_forを使った子モデルへの複数レコード保存【cocoonが便利】](https://www.y-hakopro.com/entry/2019/08/03/234126)


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

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

- `rails 5.2.4.2`
- `webpacker`使用

### 作ったもの
作ったフォームのイメージはこんな感じです。以下、サンプルの画像です。
▼基本の画面
[![Image from Gyazo](https://i.gyazo.com/5d3d1af2edba4ce77a5ec58242b39b6a.png)](https://gyazo.com/5d3d1af2edba4ce77a5ec58242b39b6a)

▼ボタンを押すとフィールドが増える
[![Image from Gyazo](https://i.gyazo.com/8bce9b4b7e74ed646f6074faaa922867.png)](https://gyazo.com/8bce9b4b7e74ed646f6074faaa922867)

▼事前に設定した人数以上にはフィールドは増えない（disabledボタンになる）
[![Image from Gyazo](https://i.gyazo.com/d64eb31e922d33ad9941795968392f5d.png)](https://gyazo.com/d64eb31e922d33ad9941795968392f5d)

▼ゴミ箱アイコンを押すと、確認画面が出て、OKならフィールドが消える
[![Image from Gyazo](https://i.gyazo.com/9561304818dc5dfb4c69ddebcac181ac.png)](https://gyazo.com/9561304818dc5dfb4c69ddebcac181ac)

▼同じ人に２つの記録は作成できない(アラートメッセージが出る。DB側でも別途制御している)
[![Image from Gyazo](https://i.gyazo.com/b52f1f57752959c59227c23e5bbd3a70.png)](https://gyazo.com/b52f1f57752959c59227c23e5bbd3a70)

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

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

▼こちらの記事を参考にしました
[Rails6でjQueryの導入方法](https://qiita.com/masahisa/items/eaacb0c3b82f4a11fc13)

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

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

▼公式のノート
[Rails 6/Webpacker](https://github.com/nathanvda/cocoon#rails-6webpacker)

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

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

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

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

```ruby: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`でわざわざ反対のアソシエーションを追記している理由は、[公式のこちらの記述](https://github.com/nathanvda/cocoon#basic-usage)を参考に、厳密さを上げるために付けています。

▼公式の記述を主に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]](https://qiita.com/tanutanu/items/c82c1ed2f5fa78a58366)

```ruby: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のコードも載せているので、ここではあえて記載しています。

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

```ruby:_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`は[公式のこのあたりの記述](https://github.com/nathanvda/cocoon#standard-rails-forms)を見るに、ブロック要素一つ一つに必要なようです。

### アレンジ：削除ボタンをアイコンにする

削除ボタンは最低限、

```ruby
= link_to_remove_association "削除", f
```
だけあれば実装可能ですが、今回は"削除"のボタンをゴミ箱のアイコンに変えたかったので、少し冗長な記述になっています。（作成した環境では`gem fontawesome-sass`を利用しているため、そのアイコンの記述方法を利用しつつ、テキストではなくhtmlとして読み込ませています）

```ruby
= link_to_remove_association "#{icon 'fas', 'trash-alt'}".html_safe , f, class: 'js-log-remove-btn'
```

## カスタマイズ用JSのコード
上記までの記述で、フィールドを増やす・フィールドを減らすといった基本的な動作は可能ですが、今回はカスタマイズ用に、以下のコードを追加しました。まずは一気に書いたものを紹介します。

```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では以下の４つのタイミングでコールバックを設定できます。

```js
$(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() {
        // フィールド削除後の処理
      });
```

それぞれのコールバックは以下のような引数をとることができます。

```js
function(e, insertedItem, originalEvent)
```

- `e`...おなじみイベントオブジェクト
- `insertedItem`... `削除/追加`される要素のパラメーター 
- `originalEvent` ... 挿入または削除をトリガーしたイベント

３つ目の使いどころがよくわかりませんが、、、。詳細は[公式ドキュメントのこちらの部分](https://github.com/nathanvda/cocoon#callbacks-upon-insert-and-remove-of-items)にも詳しいです。

### 解説(2): 編集画面では、フィールドを削除ボタンを押すと`display: none;`で要素が消える
ここが今回一番のハマりどころだったのですが、

新規登録画面では、フィールド削除ボタン（ゴミ箱のアイコン）を謳歌すると、要素そのものが削除されるのですが、編集画面では、`display: none;`で見えなくなるだけです。

フォームの「送信」ボタンを押した時点で修正が確定になることと、今回は「画面内にあるスタッフ名のセレクトボックスの数」を基準に、「重複した名前があったらエラー通知を出す」処理をしていたので、気づくまではなかなかエラーが直せませんでした...。

### 解説(3)：おまけ

なお、作成していて、こちらの面白いメソッドに出会ったので紹介します。

▼ソースはこちら
[JavaScriptで配列の重複チェックを超簡単＆スマートにやる方法
](https://pisuke-code.com/js-check-duplicated-array-values/)

```js
/** 配列内で値が重複してないか調べる **/
function existsSameValue(a){
  var s = new Set(a);
  return s.size != a.length;
}

```

`Set(引数)`は引数から重複のない配列を作成するメソッドで（[MDNの記述はこちら](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Set))、

- 重複のない配列 と
- 重複があるかもしれない元の配列

の長さを比べることで、重複があるかどうかチェックできるそうです。へー。

## その他、学んだこと
色々な要素が盛り沢山で、実装に時間がかかってしまったのですが、今回発見した１番大きなことは、**jQueryオブジェクト用のメソッドはJSのオブジェクトには使えない**ということでしょうか。。。

私がWEB業界に入ったときにはすでにjQueryはかなり下火で、ちゃんと習う機会もなく、今回見様見真似で書いていました。

その結果、「このオブジェクトで使えたメソッドが、あのオブジェクトには使えない」ということが大量に発生していました。

途中でようやく気がついてなんとか実装できましたが。。。ちなみに、上記のコードではjQueryオブジェクトとJSのオブジェクトを区別するため、前者に`$`をつけています。


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