Parsleyを使ったバリデーションフォームテクニック

# はじめに
- 今回は[Parsley](http://parsleyjs.org/)という、フロントエンド側のフォームバリデーションライブラリを快適に使う方法をつらつらと書いていきます。
- ベースはRailsでやっていますが、知識を応用すればどのWAF内でも使えるような気がします。

## 利用するライブラリ
- [Parsley](http://parsleyjs.org/)
- [ActiveHash（セレクトボックス作りに重宝）](https://github.com/zilkey/active_hash)
- [Cocoon（nested_attributesのフォーム作りに）](https://github.com/nathanvda/cocoon)
- 今回セレクトボックスを使う時にActiveHashと、`nested_attributes`のフォームを作るのに`Cocoon`を使用するので、**この二つの知識をもっておかないとわかりにくいかもしれません。**
- **凄まじく使えるGemなので、この機会にぜひ使って見てください！**
- あとBootStrap4のクラスが多々登場しますので、そちらの方もお願いいたします。

# 準備
## インストール
- Railsの`vendor/assets/parsley`ディレクトリに、ダウンロードしてきたParsleyライブラリを配置します。

```
vendor/assets/parsley/
├── parsley-ja.js（ja.jsから改名する）
├── parsley.css
├── parsley.min.js
└── parsley.min.js.map
```

```sass
// app/assets/stylesheets/application.scss
@import "parsley";
@import "custom/*";
```

```js
// app/assets/javascripts/application.js
//= require parsley.min
//= require parsley-ja
//= require_tree ./custom
```

- Gemの方もインストールします。

```rb
gem 'active_hash'
gem 'cocoon'
```

## Parsley大元の設定
- `app/assets/javascripts/custom`配下に設定する。
- 今後記述しないが、**`parsley`クラスのついた`formタグ`に対してparsley処理が走るように設定するため、parsleyを使う場合は必ずクラス付与を忘れない**こと。

```coffeescript
# app/assets/javascripts/custom/parsley.coffee

$ ->
  $('form.parsley').parsley({
    # レンダーされるエラーのul,liタグの指定
    errorsWrapper: '<ul class="parsley-error-list"></ul>',
    errorTemplate: '<li class="parsley-error-item"></li>',
    # parsleyを適用するフォームの種類の指定（デフォルトだとhiddenが使えないので指定する）
    excluded: 'input[type=button],' +
              'input[type=submit],' +
              'input[type=reset],' +
              '[disabled]',
    inputs: 'input, textarea, select, :hidden',
    # 入力に変更があった場合にバリデーションを走らせる
    trigger: 'change'
  })
```

## ActiveHashによる区分値の設定
- 今回はセレクトボックスを`ActiveHash`というライブラリを使って作成する。
- 準備の仕方は簡単で、Gemをインストールした状態で以下の二つのファイルを配置すれば、あとは簡単に区分値を取得できる。

```rb
# app/models/item.rb
class Item < ActiveYaml::Base
  include ActiveHash::Enum

  set_root_path 'config/master'
  set_filename name.underscore
end
```

```yml
# config/master/item.yml
- id: 1
  name: アイテム１
- id: 2
  name: アイテム2
- id: 3
  name: アイテム3
```

```sh
# railsコンソール
> Item.all
=> [#<Item:0x0000563949ca9878 @attributes={:id=>1, :name=>"アイテム１"}>,
 #<Item:0x0000563949ca94b8 @attributes={:id=>2, :name=>"アイテム２"}>,
 #<Item:0x0000563949ca9170 @attributes={:id=>3, :name=>"アイテム３"}>]
```

# 基本形
## 入力必須
- `required: true`属性を指定するだけ。

```slim
/ テキストフィールド
= f.text_field :title, class: 'form-control', required: true

/ セレクトボックス
= f.collection_select :item_id, Item.all, :id, :name, { include_blank: '未選択' }, class: 'form-control', required: true
```

## 完全一致
- password確認フォームに同じ文字列が入っているかの確認とかに使う。

```slim
= f.password_field :password, id: 'password', class: 'form-control', required: true
/ 上のフォームと完全一致じゃないとエラーになる
= f.password_field :password_confirmation, class: 'form-control', required: true, data: { 'parsley-equalto': '#password' }
```

## パターン指定
- こちらもパスワードのフォーマットの指定に使う。
- パスワードのパターンを指示する正規表現は**[RailsConfig](https://github.com/railsconfig/config)とかを使って、アプリ全体で共有できるようにしておくといい**。
- エラーメッセージもオリジナルのを指定できる。

```yml
# config/settings.yml
format:
  password: /^(?=.*?[a-zA-Z])(?=.*?\d)[0-9a-zA-Z]{8,128}$/
```

```slim
= f.password_field :password, placeholder: '※英数含む8文字以上128文字以下', class: 'form-control', required: true, pattern: Settings.format.password, data: { 'parsley-error-message': "パスワードは8文字以上の英数にしてください" }
```

- ちなみに[こちらのサイト](https://tuono034s.com/web-entry/2602/)によれば、**ひらがな・カタカナによるバリデーションもできる**らしいです。（ウェブマスターさんありがとうございます。）

# 発展系
## 選択するか、チェックボックスを入れるか
- よく見るかはわからないが、必須入力の選択肢を特定条件下で向こうにするしくみ。
- チェックボックスの`chengeトリガー`で、フォームの`disabled属性`を変化させ、**disabledになった時はバリデーションをリセットする**という手順を踏む。

![](https://dl.dropboxusercontent.com/s/vz0crgwqj4gvpf0/parsley_demo1.gif?dl=0)

```slim
.form-group
  = f.label 'アイテム'
  = f.collection_select :item_id, Item.all, :id, :name, { include_blank: '未選択' }, class: 'form-control main-input', required: true
  .form-check.mt-3
    label.form-check-label
      = f.check_box :item_id_blank, class: 'form-check-input later-checkbox' 
      | 後で決める
= f.submit 'Submit', class: 'btn btn-success w-100'
```

```js
// 読み込むJSファイル
$(function() {
  var onChangeLaterCheck = function(checkbox) {
    $mainInput = $(checkbox).closest('.form-group').find('.main-input')
    if ($(checkbox).is(':checked')) {
      $mainInput.prop('disabled', true);
      $mainInput.val('');
      // formをdisabledにするので、エラー文が出ていた場合には削除する。
      $mainInput.parsley().reset();
    } else {
      $mainInput.prop('disabled', false);
    }
  };

  $('.later-checkbox').on('change', function() {
    onChangeLaterCheck(this);
  });

  $('.later-checkbox').on('change', function() {
    onChangeLaterCheck(this);
  });
});
```
## 個数判定（一つ以上入力してください的な）
- `nested_attributes`などの一対多の子要素を作成するフォームで、最低〇〇個以上入力して欲しい時に使える。

![](https://dl.dropboxusercontent.com/s/pqxp07w8r9tl22j/parsley_demo2.gif?dl=0)

```slim
/ app/views/sample/index.html.slim

/ フォームタグは一例なので適宜書き換える
= form_with(model: User.new, url: sample_path, class: 'parsley', local: true, method: :get) do |f|
  .form-group
    / cocoonライブラリの子要素追加ボタン。敢えて表示せずに裏から叩く
    = link_to_add_association '', f, :items, class: 'd-none', partial: 'item_fields', data: { association_insertion_node: "#itemList", association_inser
tion_method: "append" }                                            
    #itemList.nested-element-list
      / 要素数をhiddenフィールドで保持して、min属性でいくつ以上必要か指定する。                        
      = hidden_field_tag :element_count, 0, id: 'itemElementCount', class: 'element-count', min: 1, data: { 'parsley-min-message': 'アイテムを一つ以上選択してください' }
      = f.fields_for :items do |item|
        = render 'item_fields', f: item
    .nested-element-entry                              
      .form-group.row                                                                     
        .col-md-10                                                
          = f.collection_select :item_id, Item.all, :id, :name, { include_blank: '未選択' }, class: 'form-control entry-input'
  = f.submit 'Submit', class: 'btn btn-success w-100'
```

```slim
/ app/views/sample/_item_fields.html.slim

.nested-fields
  = f.hidden_field :id
  .form-group.row
    .col-md-10
      = f.collection_select :item_id, Item.all, :id, :name, { include_blank: '未選択' }, class: 'form-control nested-main-input', required: true
    .col-md-2
      = link_to_remove_association '削除', f, class: 'btn btn-danger'
```

```js
// 読み込むJSファイル
$(function() {
  // フォーム入力時に要素を一つ追加する（選択式の場合のみ）
  $('.nested-element-entry').on('change', function() {
    if ($(this).find('select').val()) {
      $(this).closest('.form-group').find('a.add_fields').click();
    }
  });

  // 要素数カウント
  elementCountValidation = function($targetList, validate = true) {
    if (!$targetList.find('.element-count').length) { return }

    // visible属性がないと正確な数が取れない
    var count = $targetList.find('.nested-fields:visible').length;
    $targetList.find('.element-count').val(count);
    if (validate) {
      $targetList.find('.element-count').parsley().validate();
    }
  }

  // 要素追加時には、フォームの内容を反映して、フォームを空にする
  $('.nested-element-list').on('cocoon:after-insert', function(e, added) {
    $targetList = $(e.currentTarget);

    // これを指定しないと、新しく追加された子要素のselectでは、parsleyのchangeトリガーが起動しなくなってしまう。
    $('.parsley').parsley().refresh();

    // Entryフォームの値を追加した要素に移動する
    $entryInput = $targetList.siblings('.nested-element-entry').find('.entry-input');
    $(added).find('.nested-main-input').val($entryInput.val());
    $entryInput.val('');

    // 要素数バリデーション
    elementCountValidation($targetList);
  }).on('cocoon:after-remove', function(e) {
    $targetList = $(e.currentTarget);

    // 要素数バリデーション
    elementCountValidation($targetList);
  });

  // 要素数だけ数えてバリデーションしない
  $('.nested-element-list').each(function() {
    elementCountValidation($(this), false);
  });
});
```

## 重複判定（同じ要素がすでに存在しています）
- `nested_attributes`のフォームとかで、同じタイトルが入力できないようにする。
- 一個前に出てきた個数カウント入力欄に対して`parsley-equalto`を指定することで、`「要素数」 = 「ユニークな要素数」`が成り立たない場合にエラーを出すようにする。
- 先ほどの個数判定と並列で使うことも可能。

![](https://dl.dropboxusercontent.com/s/2jxmmhgatitj9tj/parsley_demo3.gif?dl=0)

```slim
/ 要素数を保持
= hidden_field_tag :element_count, 0, id: 'itemElementCount', class: 'element-count'
/ こちらのhiddenを追加
= hidden_field_tag :unique_count, 0, class: 'unique-element-count', data: { 'parsley-equalto': '#itemElementCount', 'parsley-equalto-message': '重複している項目があります' }
```

```js
$(function() {
  // フォーム入力時に要素を一つ追加する（選択式の場合のみ）
  $('.nested-element-entry').on('change', function() {
    if ($(this).find('select').val()) {    
      $(this).closest('.form-group').find('a.add_fields').click();
    }
  });

  // 要素数カウント
  elementCount = function($targetList) {
    if (!$targetList.find('.element-count').length) { return }

    var count = $targetList.find('.nested-fields:visible').length;
    $targetList.find('.element-count').val(count);
  }

  // ユニークバリデーション
  uniqueElementValidation = function($element) {
    if (!$targetList.find('.unique-element-count').length) { return }

    var values = $element.find('.nested-fields:visible').map(function() {
      return $(this).find('.nested-main-input').val();
    }).toArray();
    var unique_values = values.filter((val, idx, arr) => {
      return arr.indexOf(val) === idx;
    });
    $element.find('.unique-element-count').val(unique_values.length);
    $element.find('.unique-element-count').parsley().validate();
  }

  // 要素に変更があった場合にユニークバリデーションを走らせる
  $('.nested-element-list').on('change', '.nested-fields:visible .nested-main-input', function() {
    $targetList = $(this).closest('.nested-element-list');
    uniqueElementValidation($targetList);
  });

  // 要素追加時には、フォームの内容を反映して、フォームを空にする
  $('.nested-element-list').on('cocoon:after-insert', function(e, added) {
    $targetList = $(e.currentTarget);

    // これを指定しないと、新しく追加された子要素のselectでは、parsleyのchangeトリガーが起動しなくなってしまう。
    $('.parsley').parsley().refresh();

    // フォームの内容を最後の要素に移動する
    $entryInput = $targetList.siblings('.nested-element-entry').find('.entry-input');
    $(added).find('.nested-main-input').val($entryInput.val());
    $entryInput.val('');

    // 要素数カウント
    elementCount($targetList);
    // ユニーク要素カウント  
    uniqueElementValidation($targetList);
  }).on('cocoon:after-remove', function(e) {
    $targetList = $(e.currentTarget);

    // 要素数カウント
    elementCount($targetList);
    // ユニーク要素カウント 
    uniqueElementValidation($targetList);
  });

  // 一番最初に要素のカウントとユニークカウントを実行する。
  $('.nested-element-list').each(function() {
    elementCount($(this));
    uniqueElementValidation($(this));
  });
});
```

## ウィジェットフォーム

# おわりに
- 自分でゼロから実装するよりも遥かに簡単にフロントバリデーションができてしまうのはとても嬉しいですね！