【Rails】JavaScriptでフォームを追加する方法

## はじめに
以前、単一ページのフォームで複数のテーブルにデータを保存する方法を書きましたが、その続きとして**JavaScriptを利用してフォームを追加・削除できるようにする方法**を書いていきます。
以前の記事 →　https://qiita.com/koki_73/items/bc4ca80ab43e84d9704f

完成イメージは以下のような感じです。
今回はフォームを最大で５つまで作成できるようにしていきます。
![de071ce4d6374ca709d2d1c9f7e9703d.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605568/871165b9-6fd1-9fd7-64e0-520dc235b796.gif)
![9b3cdc56affd1d78577359374cc13989.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605568/17a4c217-3bc0-acfd-d66b-1a6c31a7baeb.gif)
なお、railsのバージョンは5.2.3で、ビューファイルはhamlとJqueryを使います。
## 概要
追加・削除の概要を簡単に説明しておきます。

* フォームに識別用の番号を振る
* 追加するフォームに渡す番号を配列で用意する
* 追加のときは配列の数字を使って新しい識別番号のフォーム作成し、削除のときは配列に数字を追加する。

では追加、削除のやり方をそれぞれ説明します。

## フォームの追加
まずはどんな流れでフォームを追加すればよいか確認してみます。

1. 追加するフォームのまとまりにクラスとインデックス番号を設定する（HTMLファイル）
2. 追加ボタンをクリックしたときにイベントを発火させ、新しいインデックス番号を使って新しいフォームを追加する。（JSファイル）

追加は特に難しいことはありません。
ではコードをみていきましょう！

### HTMLファイル

```haml:viewファイル
= form_with(model: @post, local: true) do |f|
  .post-area
    .post-area__title
      投稿内容
    .post-area__form
    = f.text_field :content
 
  -# ↓のクラスの子要素としてフォームを追加します
  .tag-area
    = f.fields_for :tags do |tag|
      -# ↓このクラスにindex番号を振り、フォームを識別できるようにします
      .js-file-group{ data: {index: "#{tag.index}"} }
        .tag-area__title
          タグ
        .tag-area__form
          = tag.text_field :content
          %span.delete-form-btn
            削除する
          -# ↓は編集フォーム用です（データが存在する場合は削除用の非表示チェックボックスを作るため）
          - if @post.persisted?
            = tag.check_box :_destroy, data:{ index: tag.index }, class: "hidden-destroy"
  -# ↓フォーム追加のイベント発火用です
  .add-form-btn
    追加する
  = f.submit "投稿"
```

### JSファイル

```js:JSファイル
$(function(){
  function buildField(index) {  // 追加するフォームのｈｔｍｌを用意
    const html = `<div class="js-file-group" data-index="${index}">
                    <div class="tag-area__title">
                      タグ
                    </div>
                    <div class="tag-area__form">
                      <input type="text" name="post[tags_attributes][${index}][content]" id="post_tags_attributes_${index}_content">
                      <span class="delete-form-btn">
                        削除する
                      </span>
                    </div>
                  </div>`;
    return html;
  }

  let fileIndex = [1, 2, 3, 4] // 追加するフォームのインデックス番号を用意
  var lastIndex = $(".js-file-group:last").data("index"); // 編集フォーム用（すでにデータがある分のインデックス番号が何か取得しておく）
  fileIndex.splice(0, lastIndex); // 編集フォーム用（データがある分のインデックスをfileIndexから除いておく）
  let fileCount = $(".hidden-destroy").length; // 編集フォーム用（データがある分のフォームの数を取得する）
  let displayCount = $(".js-file-group").length // 見えているフォームの数を取得する
  $(".hidden-destroy").hide(); // 編集フォーム用（削除用のチェックボックスを非表示にしておく）
  if (fileIndex.length == 0) $(".add-form-btn").css("display","none"); // 編集フォーム用（フォームが５つある場合は追加ボタンを非表示にしておく）
  
  $(".add-form-btn").on("click", function() { // 追加ボタンクリックでイベント発火
    $(".tag-area").append(buildField(fileIndex[0])); // fileIndexの一番小さい数字をインデックス番号に使ってフォームを作成
    fileIndex.shift(); // fileIndexの一番小さい数字を取り除く
    if (fileIndex.length == 0) $(".add-form-btn").css("display","none"); // フォームが５つになったら追加ボタンを非表示にする
    displayCount += 1; // 見えているフォームの数をカウントアップしておく
  })
})
```
編集用にいくつか変数を用意してありますが、とりあえず無視してOKです。（後ほど説明します）

JSファイルの冒頭の部分でhtmlのコードを準備していますが、これはform_with, fields_forを使ったときに作成されるコードをそのまま使っています。Chromeの検証ツールを使うと見れますので見てみましょう！
<img width="701" alt="a6ab053bf000c5fc0f17909cf8c75bd0.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605568/6ff32075-ce11-ccf0-cbe2-6265bde96df8.png">
こんな感じで表示されているはずです。
追加するのはjs-file-group以下なので、その部分をコピーして、js-file-groupのインデックス番号とフォームの番号の部分は引数が入るような関数を用意すればOKです。

## フォームの削除
次に削除の流れを確認してみます。

1. 削除ボタンをクリックしてイベントを発火させ、クリックした箇所のインデックス番号を取得
2. 取得したインデックス番号に応じて、①フォームの削除、②チェックボックスのチェック＆フォームの非表示をする
3. フォームが１つしかないときに削除ボタンを押してもフォームが消えないようにする

削除は追加に比べるとやや面倒ですが、順番に処理していけば大丈夫なので冷静に見ていきましょう。

### HTMLファイル
HTMLファイルは特に変更はありません。
### JSファイル

```js:JSファイル
$(function(){
// (省略)
  $(".tag-area").on("click", ".delete-form-btn", function() { // 削除ボタンクリックでイベント発火
    $(".add-form-btn").css("display","block"); // どの道フォームは一つ消えるので、追加ボタンを必ず表示させるようにしておく
    const targetIndex = $(this).parent().parent().data("index") // クリックした箇所のインデックス番号を取得
    const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`); // 編集用（クリックした箇所のチェックボックスを取得）
    var lastIndex = $(".js-file-group:last").data("index"); // フォームの最後に使われているインデックス番号を取得
    displayCount -= 1; // 表示されているフォーム数を一つカウントダウン
    if (targetIndex < fileCount) { // 編集用（チェックボックスがある場合の処理）
      $(this).parent().parent().css("display","none") // フォームを非表示にする
      hiddenCheck.prop("checked", true); // チェックボックスにチェックを入れる
    } else { // チェックボックスがない場合の処理
      $(this).parent().parent().remove(); // フォームを削除する
    }
    // ↓はfileIndex（フォーム追加ように用意してある数字の配列）の処理
    if (fileIndex.length >= 1) { // 数字が一つでも残っている場合
      fileIndex.push(fileIndex[fileIndex.length - 1] + 1); // 配列の一番右側にある数字に１を足した数字を追加
    } else {
      fileIndex.push(lastIndex + 1); // フォームの最後の数字に1を足した数字を追加
    }
    // ↓はフォームがなくならないための処理
    if (displayCount == 0) { // 見えてるフォームの数が0になったとき
      $(".tag-area").append(buildField(fileIndex[0] - 1)); // fileIndexの一番左側にある数字から１引いた数字でフォームを作成
      fileIndex.shift(); // fileIndexの一番小さい数字を取り除く
      displayCount += 1; // 見えているフォームの数をカウントアップしておく
    } 
  })
})
```
削除の場合は、フォームの処理とインデックス番号の処理が条件によって違うので注意が必要です。また、残り一つのフォームは削除されないようにしておきたいです。
順番に説明していきます。
### フォームの処理
#### 単純にフォームを削除する場合
この場合、クリックした部分の親要素（今回の例ではjs-file-group）ごと削除するだけです。
#### 編集時にデータが入っているフォームを削除する場合
この場合、フォームを削除してそのまま送信ボタンを押してもデータは更新されません。
フォームを削除するのではなく、削除用のチェックボックスにチェックをつけてフォーム自体は非表示にする必要があります。

### インデックス番号の処理
フォームを削除したらその分インデックス番号を増やしておく必要があります。（削除 → 追加を繰り返すとインデックス番号がなくなってしまうため）
今回インデックス番号の配列の中には数字が4つまで入るようにしておきたいです。また、すでにフォームに使われている数字と重複しないようにする必要があります。
#### インデックス番号が一つ以上残っているとき
これはフォームが１〜４つある状態のどれかです。番号が被らないようにしたいので、配列の一番右にある数字に１を足した数字を追加すれば良いです。（例えばfileIndexが[2,3,4]の場合は5を追加する）

#### インデックス番号がないとき
これはフォームが５つある状態です。配列が空なのでどの数字を使えば良いか考える必要があります。フォームにそれぞれ番号が振ってあるはずなので、最後のフォームに使われている数字に１を足した数字を追加すればOKです。

### フォームがなくならないための処理
フォームが全て消えてしまうと良くないので、その時の処理です。消えないようにするというよりは、全て消えたらフォームを追加するという処理になります。
見えているフォームの数を数えておいて、それが0になったら追加の処理と同様の処理をするだけです。
見えているフォームが１つの時は削除ボタンを非表示にするというのもアリかと思います。

#### 注意
削除のイベントはjsで追加した削除ボタンも使用するので、書き方には注意が必要です。
`$(追加ボタンのクラス).on("click", function(){処理})`ではうまくいかないので、`$("追加ボタンの親クラス").on("click", "追加ボタンのクラス", function(){処理})`としてあげましょう。追加ボタンの親クラスはjsで追加されない親クラスを書いておけば良いですが、何も考えず"document"でもOKです。

## 編集時の考え方
編集時は削除ボタンを押してもフォームが消えないようにしたいため、条件分岐を少し工夫する必要があります。上の削除の解説を見るとわかるかもしれませんが、少し解説してみます。

### クリックしたフォームにチェックボックスがあるかどうかの判定
ページを読み込んだときにチェックボックスの数を数えておきます。（fileCount）
編集ページでは、(fileCount - 1)までのインデックス番号でフォームが作成されているため、fileCountとクリックした箇所のインデックス番号（targetIndex）を比較することで、削除するか非表示にするかの判別をします。

### フォームがなくならないための判定
今回の例でいくとjs-file-groupの数を数えて判定しても良さそうですが、編集時はフォームを非表示にするだけで消えないフォームもあるので、この方法だとうまくいきません。
なので、ページ読み込み時のフォームの数を起点として、追加イベントでは１つカウントアップし、削除イベントでは１つカウントダウンすることで、見えているフォームの数を追うことができます。

### 最終的なJSファイルのコード
最後にまとめて見たほうがわかりやすいと思うので載せておきます。

```js:JSファイル
$(function(){
  function buildField(index) {
    const html = `<div class="js-file-group" data-index="${index}">
                    <div class="tag-area__title">
                      タグ
                    </div>
                    <div class="tag-area__form">
                      <input type="text" name="post[tags_attributes][${index}][content]" id="post_tags_attributes_${index}_content">
                      <span class="delete-form-btn">
                        削除する
                      </span>
                    </div>
                  </div>`;
    return html;
  }

  let fileIndex = [1, 2, 3, 4]
  var lastIndex = $(".js-file-group:last").data("index");
  fileIndex.splice(0, lastIndex);
  let fileCount = $(".hidden-destroy").length;
  let displayCount = $(".js-file-group").length
  $(".hidden-destroy").hide();
  if (fileIndex.length == 0) $(".add-form-btn").css("display","none");
  
  $(".add-form-btn").on("click", function() {
    $(".tag-area").append(buildField(fileIndex[0]));
    fileIndex.shift();
    if (fileIndex.length == 0) $(".add-form-btn").css("display","none");
    displayCount += 1;
  })
  
  $(".tag-area").on("click", ".delete-form-btn", function() {
    $(".add-form-btn").css("display","block");
    const targetIndex = $(this).parent().parent().data("index")
    const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
    var lastIndex = $(".js-file-group:last").data("index");
    displayCount -= 1;
    if (targetIndex < fileCount) {
      $(this).parent().parent().css("display","none")
      hiddenCheck.prop("checked", true);
    } else {
      $(this).parent().parent().remove();
    }
    if (fileIndex.length >= 1) {
      fileIndex.push(fileIndex[fileIndex.length - 1] + 1);
    } else {
      fileIndex.push(lastIndex + 1);
    }
    if (displayCount == 0) {
      $(".tag-area").append(buildField(fileIndex[0] - 1));
      fileIndex.shift();
      displayCount += 1;
    } 
  })
})
```

## 最後に
もう少しシンプルな方法もありそうですが、とりあえずこれでうまく動作するので初めて実装する方なんかは参考にしてみてください。
もっと上手いやり方や間違っていることなどありましたらコメントいただけると幸いです。

最後までありがとうございました。