LoginSignup
41
45

More than 3 years have passed since last update.

[Rails]ウィザードフォーム実装でfields_forを使って複数のテーブルに保存する

Last updated at Posted at 2019-06-05

環境

Rails: 5.2.3

実装条件

・ユーザー新規登録画面を実装したい
 =>ここではusersとbooksテーブルで実装
・ウィザードフォーム形式で実装する
 (入力画面を複数画面用意し、画面遷移させる。)
 (データベースへの登録は最後に1回のみ行う。)
 => sessionでデータ保持する方法を使用
・入力フォームで入力した値は複数のデータベースに保存する。
 => fields_forを使用
gem 'devise'は使用しない
 => 理由は下記記事の補足参照
  [Rails]新規登録画面のウィザードフォーム実装(sessionでデータ保持する)
 => パスワードはgem 'bcrypt'で暗号化する

パスワードを暗号化できるようにする

▼gem 'bcrypt' をインストール
Gemfile
gem 'bcrypt'

上記を追記してbundle install

▼usersテーブルに必要なカラムを作成
マイグレーションファイル
t.string :password_digest

ここに暗号化したパスワードが保存される。

▼モデルに記述する
models/user.rb
has_secure_password

この記述によりパスワードが暗号化されてpassword_digestカラムに保存される。

ウィザードフォーム形式で実装する

▼遷移するページ数分、ビューファイルを用意する

ここでは下記2ファイルを用意
step1.html.erb
step2.html.erb

▼ルーティングを設定する
config/routes.rb
resources :users do
    collection do
      get 'step1'
      get 'step2'
    end
  end

表示するページの分、ルーティングを記述する。

▼ビューファイルを記述
users/step1.html.erb
<%= form_for @user, url: step2_users_path, method: :get do |f| %>
  email
  <%= f.email_field :email %>
  password
  <%= f.password_field :password %>
  password_confirmation
  <%= f.password_field :password_confirmation %>

  <%= f.submit 'Continue' %>
<% end %>

url: step2_users_path, method: :get
で2ページ目のルーティングを指定することにより、
submitした際に2ページ目に遷移する。
 ※form_withだとなぜか遷移しないので注意

users/step1.html.erb
  <%= f.password_field :password %>
  <%= f.password_field :password_confirmation %>

この部分、パスワードのカラムは「password_digestカラム」1つしか作成していないのに入力フォームが2つある。
has_secure_passwordの機能により、ビューに上記の記述をしても、
ちゃんとパスワード2つが検証され暗号化されて「password_digestカラム」に保存される。

users/step2.html.erb
<%= form_for @user, url: users_path do |f| %>
  name
  <%= f.text_field :name %>
  <%= f.submit 'Continue' %>
<% end %>

url: users_path
でcreateアクションのルーティングを指定することにより、
submitした際にcreateアクションが動く。

▼コントローラーを記述
users_controller.rb
  def step1
    @user = User.new # 新規インスタンス作成
  end

  def step2
    # step1で入力した値をsessionに保存
    session[:email] = user_params[:email]
    session[:password] = user_params[:password]
    session[:password_confirmation] = user_params[:password_confirmation]
    @user = User.new # 新規インスタンス作成
  end

  def create
    @user = User.new(
      email: session[:email], # sessionに保存された値をインスタンスに渡す
      password: session[:password],
      password_confirmation: session[:password_confirmation],
      name: user_params[:name]  # step2で入力した値をインスタンスに渡す
    )
    if @user.save
      session[:user_id] = @user.id # ログイン状態維持のためuser_idをsessionに保存
      redirect_to new_user_path
    else
      render '/users/step1'
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :password_confirmation, :name)
  end

これでウィザードフォームについては実装完了。
遷移するページを増やしたければform_forで指定するルーティングを次ページのルーティングにするか、createアクションのルーティングにするか切り替えればOK。

formで入力したデータについてはparamsで取得したデータをsessionに保存し、
最後にcreateアクションでsessionに保存した値をインスタンスに渡す。
ポイントは入力したページの次のページのアクションでsessionに保存の記述をすること。

複数のデータベースに保存する実装(fields_forを使用)

▼親モデルと子モデルを用意する

子モデルには親モデルと関連付いたidカラムを作成しておくこと。

マイグレーションファイル
t.references :users, foreign_key: true
▼アソシエーションを設定する

ここでは仮に user (親モデル)、book (子モデル)が1対1(has_one)の関係とする。
それぞれにアソシエーションを記述。
 ※1対多(has_many)の場合は記述方法が少し違うので注意。

# models/user.rb
has_one :book
accepts_nested_attributes_for :book

# models/book.rb
belongs_to :user, optional: true
▼ビューファイルにfields_forのフォームを追加
step2.html.erb
<%= form_for @user, url: users_path do |f| %>
  <%= hidden_field_tag :current_step, 'step2' %>
  name
  <%= f.text_field :name %>
  <%= f.fields_for :book do |b| %> // :bookで保存先のモデルを指定
    title
    <%= b.text_field :title %> // fields_forのフォームなので、「b.」で始めている
  <% end %>
  <%= f.submit 'Continue' %>
<% end %>
▼ストロングパラメーターに記述を追加
users_controller.rb
private

  def user_params
    params.require(:user).permit(
      :email,
      :password,
      :password_confirmation,
      :name,
      book_attributes: [:id, :title]
    )
  end

子モデルのparamsは、以下の形で記述する。
モデル名_attributes: [:id, :カラム名]
:idを入れているのは、親モデルとの関連付けの「user_idカラム」に値が保存されるようにしている。

こう記述しておくことで、User.saveされた時に自動でbooksテーブルにも入力値が保存される。
また、対応する親モデルのidが自動でuser_idカラムに保存される。

▼コントローラーに記述を追加
users_controller.rb
  def step1
    @user = User.new
  end

  def step2
    session[:email] = user_params[:email]
    session[:password] = user_params[:password]
    session[:password_confirmation] = user_params[:password_confirmation]
    @user = User.new
    @user.build_book # bookモデルと関連付ける
  end

fields_forを記述したビューを呼び出すアクションに以下を記述する。
@user.build_モデル名
ポイントは、「fields_forを記述したビューを呼び出すアクションに」記述する、というところ。

▼コントローラーに記述を追加
users_controller.rb
def create
    @user = User.new(
      email: session[:email],
      password: session[:password],
      password_confirmation: session[:password_confirmation],
      name: user_params[:name]
    )
    @user.build_book(user_params[:book_attributes]) # 入力値を引数で渡す
    if @user.save
      session[:user_id] = @user.id
      redirect_to new_user_path
    else
      render '/users/step1'
    end
  end

これにより@user.save後に自動で、User.createとBook.createが走り、
親モデルと子モデル両方のテーブルに一度に保存される。
子モデルのテーブルには対応した親モデルのidもちゃんと保存される。

ここではuser (親モデル)、book (子モデル)が1対1(has_one)の関係の場合の実装を書いているが、1対多(has_many)の場合は記述方法が少し違うので注意。
特にところどころ出てくるモデル名が単数形か複数形か変わるので、書き間違いに注意。

ページ遷移時にバリデーションがかかるようにする

上記のコードのままだと、1ページ目の入力が終わって次のページに遷移する際、バリデーションがかかっていないので、入力に不備があってもそのまま次のページに遷移できてしまう。
そのため、入力時、またはページ遷移時にバリデーションをかけ、入力に不備がある場合は元のページに戻りエラーメッセージを表示する必要がある。

ここでは、ページ遷移する際にバリデーションをかける方法を書く。

▼バリデーションをかけるためのメソッドを作成
users_controller.rb
# 2step目のページが開く前にメソッドを動かす
before_action :save_to_session, only: :step2

  def step2
    @user = User.new
    @user.build_book
  end

  # sessionに保存しつつ、バリデーションをかけるメソッドを作成
  def save_to_session
    session[:email] = user_params[:email]
    session[:password] = user_params[:password]
    session[:password_confirmation] = user_params[:password_confirmation]
    # バリデーションをかけるため、仮でインスタンスに入力値を入れる
    @user = User.new(
      email: session[:email],
      password: session[:password],
      password_confirmation: session[:password_confirmation],
      name: user_params[:name]
    )
    # インスタンスにバリデーションをかけ、通らなければ1step目のページを再度表示する
    render '/users/step1' unless @user.valid?
  end

上記のようにしてみた。
sessionへの保存とバリデーションをかける用のメソッドを新規作成する。
バリデーションは基本はDBに保存する際にかかるため、session保存時にかけることはできない。
そこで、session保存とは別で仮でインスタンスを作成、そこに入力値を入れる。
インスタンスに対しては.valid?メソッドでバリデーションをかけることができるので、通らない場合は1step目のページを再度表示させる。

1step目のページを再表示させるためにrenderを使用。
redirect_toだと再度アクションを動かすため、ページが更新されてしまい、入力値が消えてしまうため。
ページを戻った際に入力値が消えてしまうとまた入力し直しになるのでユーザーに手間がかるのでできれば残したい。
renderだとアクションを動かさずに直接ページをレンダリングするので、更新がかからずに入力値が残る。

また、これを2step目のページが開く前に実行する必要があるため、before_actionにて2step目のアクションが動く前に実行されるようにする。

これにてページ遷移時にDB保存せずともバリデーションをかけることができる。
しかしエラーメッセージの実装方法によっては、JavaScriptでバリデーションをかける方法もあるかと思う。
あくまで一例ということで。

あとがき

今回この実装をするに当たって、検索して調べた内容だけでは実装できなかったので、実装方法をここにメモしておく。
苦戦したのは、「ウィザードフォーム実装」と、「fields_forを使った複数テーブルへの保存」、それぞれの機能については実装方法がいくらでも紹介されていたが、この2つを組み合わせた実装というのは調べても出てこなかった。
コントローラー周りの記述が変わってくるので、なかなか思ったように動かずにかなり苦戦した。
記述を少し変えては試しに動かしてbinding.pryでparamsの中身確認して・・・というのを幾度となく繰り返し、ようやく思った通りに動く実装ができた。
少しでも記述が違ったりするとちゃんとデータが取れなかったり保存されなかったりするので注意。

参考

【Rails】fields_for と accepts_nested_attributes_for
Rails ネストした関連先のテーブルもまとめて保存する (accepts_nested_attributes_for、fields_for)
fields_forにハマった
Railsで関連モデルを同時に更新したい(has_oneで関連させていたモデルをbuild_associationで安直に生成しようとしたら痛い目をみた話)

41
45
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
41
45