Rails Jquery でマルチステップフォームの実装

# Rails Jqueryを使ってマルチステップフォームの実装

オリジナルアプリを作る過程でマルチステップフォーム（複数登録画面）を作ってみました。
細かい部分はまだ終わってないですがこういう流れなのかという感じで参考にしてみてください。

##　環境
- Mac
- Rails 5.2.3

## 今回のポイント
 - reformというgemを使って、登録画面ごとにvalidationを行うこと
 - Jqueryを使って隠しているビュー部分を出していきながら登録を進めていく方法

### reformの使い方の詳細は省くので以下の記事や公式ドキュメントを参考にしてみてください。
- https://qiita.com/akichim21/items/afe961ea752f894b389b
- https://qiita.com/subaru-shoji/items/e548f10a871938bedd06
- http://trailblazer.to/gems/reform/index.html


まずはビューの部分

```html:new.html.haml
.registration-container
  .registration-header
    %h3
      registration
  .registration-view-part
    = form_for @user, html: {class: 'registration-form clearfix'} do |f|
      .first-step-registration
        .field
          .field-label
            = f.label '名前(名)'
          .field-input#first_name_input
            = f.text_field :first_name, id: 'first_name'
        .field
          .field-label
            = f.label '名前(性)'
          .field-input#family_name_input
            = f.text_field :family_name, id: 'family_name'
        .field
          .field-label
            = f.label 'ニックネーム'
          .field-input#nickname_input
            = f.text_field :nickname, id: 'nickname'
        .field
          .field-label
            = f.label '電話番号'
          .field-input#phone_number_input
            = f.password_field :phone_number, id: 'phone_number'
        .btn#first-step-btn
          next
      .second-step-registration
        .field
          .field-label
            = f.label '性別'
          .field-input
            = f.select :sex, { '男の子': 1, '女の子': 2 }
        .field
          .field-label
            = f.label 'パスワード'
          .field-input#password_input
            = f.password_field :password, id: 'password'
        .field
          .field-label
            = f.label 'パスワード（確認）'
          .field-input#password_confirmation_input
            = f.password_field :password_confirmation, id: 'password_confirmation'
        .btn#second-step-btn
          next
      .third-step-registration
        .field
          .field-label
            = f.label '学年'
          .field-input#grade
            = f.select :grade, {'大学1年': 1, '大学2年': 2,'大学3年': 3,'大学4年': 4,'修士1年': 5,'修士2年': 6}
        .field
          .field-label
            = f.label '大学'
          .field-input#university_input
            = f.text_field :university, id: 'university'
        .field
          .field-label
            = f.label 'Eメールアドレス'
          .field-input#email_input
            = f.email_field :email, id: 'email'
        = f.submit 'create account', class: 'registration-submit-btn'
```

```css:registrations.css
header {
  width: 100%;
  height: 100px;
  line-height: 100px;
  background: skyblue;
  h1 {
    color: #f8f8f8;
    font-size: 50px;
    text-align: center;
  }
}
.registration-container {
 width: 500px;
 height: 500px;
 margin: 30px auto;
  .registration-header {
    width: 200px;
    margin: 0 auto;
    height: 30px;
    line-height: 30px;
    text-align: center;
    h3 {
      font-size: 30px;
    }
  }
  .registration-view-part {
    width: 480px;
    height: 550px;
    margin: 30px auto;
    overflow: hidden;
    border: 1px solid black;
    .registration-form {
      width: 2000px;
      height: 440px;
      .clearfix::after {
        content: "";
        display: block;
        clear: both;
      }
      .first-step-registration {
        border-box: box-sizing;
        width: 400px;
        height: 500px;
        padding: 40px;
        float: left;
        .field {
          width: 400px;
          height: 70px;
          margin: 0 30px 30px 30px;
          .field-label {
            margin-bottom: 10px;
          }
          .field-input {
            width: 80%;
            input {
              width: 90%;
              height: 30px;
              padding: 5px 10px;
              border-radius: 5px;
              border: 1px solid black;            
            }
            select {
              width: 100px;
              height: 40px;
            }
          }
          p {
            color: red;
          }
        }
        .btn {
          width: 100px;
          height: 40px;
          margin: 50px auto 30px auto;
          display: block;
          border-radius: 3px;
          font-size: 20px;
          border: 1px solid black;
          text-align: center;
          line-height: 40px;
          cursor: pointer;
        }
      }
      .second-step-registration,
      .third-step-registration {
        border-box: box-sizing;
        width: 400px;
        height: 400px;
        padding: 40px;
        float: left;
        .field {
          width: 400px;
          height: 100px;
          margin: 0 30px 30px 30px;
          .field-label {
            margin-bottom: 10px;
          }
          .field-input {
            width: 80%;
            input {
              width: 90%;
              height: 30px;
              padding: 5px 10px;
              border-radius: 5px;
              border: 1px solid black;            
            }
            select {
              width: 100px;
              height: 40px;
            }
          }
          p {
            color: red;
          }
        }
        .btn {
          width: 100px;
          height: 40px;
          margin: 0 auto;
          display: block;
          border-radius: 3px;
          font-size: 20px;
          border: 1px solid black;
          text-align: center;
          line-height: 40px;
          cursor: pointer;
        }
        .registration-submit-btn {
          margin: 0 auto;
          display: block;
          border-radius: 3px;
          font-size: 20px;
          border: 1px solid black;
          text-align: center;
          line-height: 40px;
          padding: 5px;
        }
      }
    }
  }
}
```

```js:registrations.js
$(function() {
  function check_first_name(first_name) {
    var result = first_name.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#first_name_input').after('<p>名前を入力してください</p>')
    }
  }

  function check_family_name(family_name) {
    var result = family_name.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#family_name_input').after('<p>名字を入力してください</p>')
    }
  }

  function check_nickname(nickname) {
    var result = nickname.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#nickname_input').after('<p>ニックネームを入力してください</p>')
    }
  }

  function check_phone_number(phone_number) {
    var result = phone_number.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#phone_number_input').after('<p>電話番号を入力してください</p>')
    }
  }

  function check_password(password) {
    var result = password.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#password_input').after('<p>パスワードを入力してください</p>');
    }
  }

  function check_password_confirmation(password_confirmation) {
    var result = password_confirmation.match(/.+/);
    if(result != null) {
      return true;
    } else {
      $('#password_confirmation_input').after('<p>パスワード（確認）を入力してください</p>')
    }
  }

  function check_university(university) {
    var result = university.match(/.+/);
    if(result != null) {
      return true;
    } else {
      $('#university_input').after('<p>大学名を入力してください</p>');
      return false;
    }
  }

  function alert_first_step_error(message) {
    if(message["nickname"] && message["phone_number"]) {
      $('#nickname_input').after("<p>" + message["nickname"] + "</p>")
      $('#phone_number_input').after("<p>" + message["phone_number"] + "</p>")      
    } else if(message["nickname"]) {
      $('#nickname').after("<p>" + message["nickname"] + "</p>")
    } else if(message["phobe_number"]) {
      $('#phobe_number_input').after("<p>" + message["phobe_number"] + "</p>")
    }
  }

  function alert_second_step_error(message) {      
    if(message["password_confirmation"]) {
      $('#password_confirmation').after("<p>" + message["password_confirmation"] + "</p>")
    }
  }

  function alert_third_step_error(message) {
    if(message["email"]) {
      $('#email_input').after("<p>" + message["email"] + "</p>")
    }
  }

  $('#first-step-btn').on('click', function() {
    var first_name = $('#first_name').val();
    var family_name = $('#family_name').val();
    var nickname = $('#nickname').val();
    var phone_number = $('#phone_number').val();
    var btn = $(this)
    $('.first-step-registration > .field > p').remove();

    var first_name_result = check_first_name(first_name)
    var family_name_result = check_family_name(family_name)
    var nickname_result = check_nickname(nickname)
    var phone_number_result = check_phone_number(phone_number)
    if(first_name_result == true && family_name_result  == true && nickname_result == true && phone_number_result == true ) {
      $.ajax({
        type: 'POST',
        url: '/first_step',
        data: { registrations: { first_name: first_name, family_name: family_name, nickname: nickname, phone_number: phone_number} },
        dataType: 'json'
      })
      .done(function(message) {
        if(message.length != 0) {
          $('#nickname_input > p').remove();
          $('#phone_number_input > p').remove();
          alert_first_step_error(message)
        } else {
          $('.registration-form').css({
            'transform': 'translate(-480px)',
            'transition-duration': '1s'
          })
        }
      })
    }
  })
  $('#second-step-btn').on('click', function() {
    var sex = $('#user_sex').val()
    var password = $('#password').val()
    var password_confirmation = $('#password_confirmation').val()
    var btn = $(this)
    $('.first-step-registration > .field > p').remove();

    var password_result = check_password(password)
    var password_confirmation_result = check_password_confirmation(password_confirmation)
    if(password_result  == true && password_confirmation_result == true) {
      $.ajax({
        type: 'POST',
        url: '/second_step',
        data: { registrations: { sex: sex, password: password, password_confirmation: password_confirmation } },
        dataType: 'json'
      })
      .done(function(message) {
        if(message.length != 0) {
          alert_second_step_error(message)
        } else {
          $('.registration-form').css({
            'transform': 'translate(-960px)',
            'transition-duration': '1s'            
          })
        }
      })
    }
  })
  $('#new_user').on('submit', function(e) {
    e.preventDefault();
    var formData = new FormData(this)
    var url = $(this).attr('action');
    var university = $('#university').val();
    var university_result = check_university(university)
    if(university_result == false) {
      return;
    }
    $.ajax({
      type: 'POST',
      url: url,
      data: formData,
      dataType: 'json',
      processData: false,
      contentType: false
    })
    .done(function(message) {
      if(message.length != 0) {
        console.log('good');
        alert_third_step_error(message)
        $('.registration-submit-btn').prop('disabled', false);
      } else {
        location.href = '/users';
      }
    })
  })
});
```
cssで最初の登録画面以外は隠してあります。次の登録画面へ行くボタンを各画面に配置してクリックしたらajaxでコントローラーにリクエストを送ってvalidationを行ってエラーがなければ次の登録画面に移動するという手順です。

```ruby:registrations.controller
class RegistrationsController < ApplicationController

  def first_step
    @form = Registrations::FirstStepUsecase.new(first_step_params).execute
    if @form.errors.empty?
      render json: []
    else
      render 'first_step_errors', formats: 'json', handlders: 'jbuilder'
    end
  end

  def second_step
    @form = Registrations::SecondStepUsecase.new(second_step_params).execute
    if @form.errors.empty?
      render json: []
    else
      render 'second_step_errors', formats: 'json', handlers: 'jbuilder'
    end
  end

  private

  def first_step_params
    params.require(:registrations).permit(:first_name, :family_name, :nickname, :phone_number)
  end

  def second_step_params
    params.require(:registrations).permit(:sex, :password, :password_confirmation)
  end

end
```
ここでは自分でUsecaseクラスを作ってvalidation等の処理はそっちに任せています。（実際にvalidationを行うのはreformです）

```first_step_usecase.rb
module Registrations
  class FirstStepUsecase
    attr_reader :param, :form
    def initialize(param)
      @param = param
      @form = Registrations::FirstStepForm.new(User.new)
    end

    def execute
      return form unless form.validate(param)
      form
    end
  end
end
```
usecaseクラスはこんな感じです。登録画面の数だけusecaseクラスを作ります。

次はreformクラスの作成です。

```ruby:first_step_form.rb
require "reform/form/validation/unique_validator"
module Registrations
  class FirstStepForm < Reform::Form

    property :first_name
    property :family_name
    property :nickname
    property :phone_number

    validates :first_name, presence: true
    validates :family_name, presence: true
    validates :nickname, presence: true, unique: true
    validates :phone_number, presence: true, unique: true

  end
end
```
簡単に説明しておくとここにvalitionを行いたいモデルのpropertyと
どんなvalidation行いたいかを書いておくことでvalidationすることができます。
先ほどのusecaseクラスに戻ります。

```first_step_usecase.rb
module Registrations
  class FirstStepUsecase
    attr_reader :param, :form
    def initialize(param)
      @param = param
      @form = Registrations::FirstStepForm.new(User.new)
    end

    def executeif @form.errors.messages[:nickname].present?
  json.nickname "このニックネームは使用されています。"
end

if @form.errors.messages[:phone_number].present?
  json.phone_number "この電話番号は使用されてます"
end
      return form unless form.validate(param)
      form
    end
  end
end
```
executeメソッド内のform.validate(param)の部分でバリデーションを行ってエラーがあればfalseを返しコントローラー内で@form.errorsが利用できます。エラーがなければ普通にformを返します。
あとはエラー文があればjbuilderなどでエラー文をjsonで返して、JS側でエラー文をビューに表示する流れになります。

```ruby:first_step_errors.json.jbuilder
if @form.errors.messages[:nickname].present?
  json.nickname "このニックネームは使用されています。"
end

if @form.errors.messages[:phone_number].present?
  json.phone_number "この電話番号は使用されてます"
end
```

```js:registrations.js

  function check_first_name(first_name) {
    var result = first_name.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#first_name_input').after('<p>名前を入力してください</p>')
    }
  }

  function check_family_name(family_name) {
    var result = family_name.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#family_name_input').after('<p>名字を入力してください</p>')
    }
  }

  function check_phone_number(phone_number) {
    var result = phone_number.match(/.+/)
    if(result != null) {
      return true;
    } else {
      $('#phone_number_input').after('<p>電話番号を入力してください</p>')
    }
  }

  function alert_first_step_error(message) {
    if(message["nickname"] && message["phone_number"]) {
      $('#nickname_input').after("<p>" + message["nickname"] + "</p>")
      $('#phone_number_input').after("<p>" + message["phone_number"] + "</p>")      
    } else if(message["nickname"]) {
      $('#nickname').after("<p>" + message["nickname"] + "</p>")
    } else if(message["phobe_number"]) {
      $('#phobe_number_input').after("<p>" + message["phobe_number"] + "</p>")
    }
  }

$('#first-step-btn').on('click', function() {
    var first_name = $('#first_name').val();
    var family_name = $('#family_name').val();
    var nickname = $('#nickname').val();
    var phone_number = $('#phone_number').val();
    var btn = $(this)
    $('.first-step-registration > .field > p').remove();

    var first_name_result = check_first_name(first_name)
    var family_name_result = check_family_name(family_name)
    var nickname_result = check_nickname(nickname)
    var phone_number_result = check_phone_number(phone_number)
    if(first_name_result == true && family_name_result  == true && nickname_result == true && phone_number_result == true ) {
      $.ajax({
        type: 'POST',
        url: '/first_step',
        data: { registrations: { first_name: first_name, family_name: family_name, nickname: nickname, phone_number: phone_number} },
        dataType: 'json'
      })
      .done(function(message) {
        if(message.length != 0) {
          $('#nickname_input > p').remove();
          $('#phone_number_input > p').remove();
          alert_first_step_error(message)
        } else {
          $('.registration-form').css({
            'transform': 'translate(-480px)',
            'transition-duration': '1s'
          })
        }
      })
    }
  })
```
他の画面も同じ流れなので説明は省きますが最後の画面でユーザーを作る際にajaxでコントローラーにリクエストを送ることになるのですが, redirect_toでページ遷移ができないことに注意してください。

```ruby:users_controller.rb
class UsersController < ApplicationController
  
  def index
  end

  def new
    @user = User.new
  end

  def create
    @form = Registrations::ThirdStepUsecase.new(third_step_params).execute
    if @form.errors.empty?
      @user = User.create(user_params)
      binding.pry
      if @user.save
        log_in @user
        render json: []
      else
        render 'new'
      end
    else
      render 'registrations/third_step_errors', formats: 'json', handlers: 'jbuilder'
    end
  end

  private

  def third_step_params
    params.require(:user).permit(:grade, :university, :email)
  end

  def user_params
    params.require(:user).permit(:first_name, :family_name, :nickname, 
      :password, :password_confirmation, :grade, :university, :phone_number,
      :email, :sex
    )
  end
end
```

例えばcreateメソッド内にredirect_to を記述してもページ遷移できません。
なのでJS内でページ遷移ができるようにしてあげてください。

```js:registration.js
$('#new_user').on('submit', function(e) {
    e.preventDefault();
    var formData = new FormData(this)
    var url = $(this).attr('action');
    var university = $('#university').val();
    var university_result = check_university(university)
    if(university_result == false) {
      return;
    }
    $.ajax({
      type: 'POST',
      url: url,
      data: formData,
      dataType: 'json',
      processData: false,
      contentType: false
    })
    .done(function(message) {
      if(message.length != 0) {
        alert_third_step_error(message)
        $('.registration-submit-btn').prop('disabled', false);
      } else {
        location.href = '/users';
      }
    })
  })
});

```
location.href = '/users';　の部分ですね。

以上 RailsとJqueryを使ったマルチステップフォームの実装をざっくりと説明してみました。
わからないところ、もっとこうした方がいいところなどがあれば教えてください。
よろしくお願いします！