環境
Rails: 5.2.3
実装条件
・ユーザー新規登録画面を実装したい
=>ここではusersとbooksテーブルで実装
・ウィザードフォーム形式で実装する
(入力画面を複数画面用意し、画面遷移させる。)
(データベースへの登録は最後に1回のみ行う。)
=> sessionでデータ保持する方法を使用
・入力フォームで入力した値は複数のデータベースに保存する。
=> fields_forを使用
・gem 'devise'
は使用しない
=> 理由は下記記事の補足参照
[Rails]新規登録画面のウィザードフォーム実装(sessionでデータ保持する)
=> パスワードはgem 'bcrypt'
で暗号化する
パスワードを暗号化できるようにする
▼gem 'bcrypt' をインストール
gem 'bcrypt'
上記を追記してbundle install
。
▼usersテーブルに必要なカラムを作成
t.string :password_digest
ここに暗号化したパスワードが保存される。
▼モデルに記述する
has_secure_password
この記述によりパスワードが暗号化されてpassword_digest
カラムに保存される。
ウィザードフォーム形式で実装する
▼遷移するページ数分、ビューファイルを用意する
ここでは下記2ファイルを用意
step1.html.erb
step2.html.erb
▼ルーティングを設定する
resources :users do
collection do
get 'step1'
get 'step2'
end
end
表示するページの分、ルーティングを記述する。
▼ビューファイルを記述
<%= 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だとなぜか遷移しないので注意
<%= f.password_field :password %>
<%= f.password_field :password_confirmation %>
この部分、パスワードのカラムは「password_digest
カラム」1つしか作成していないのに入力フォームが2つある。
has_secure_password
の機能により、ビューに上記の記述をしても、
ちゃんとパスワード2つが検証され暗号化されて「password_digest
カラム」に保存される。
<%= form_for @user, url: users_path do |f| %>
name
<%= f.text_field :name %>
<%= f.submit 'Continue' %>
<% end %>
url: users_path
でcreateアクションのルーティングを指定することにより、
submitした際にcreateアクションが動く。
▼コントローラーを記述
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
のフォームを追加
<%= 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 %>
▼ストロングパラメーターに記述を追加
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カラムに保存される。
▼コントローラーに記述を追加
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を記述したビューを呼び出すアクションに」記述する、というところ。
▼コントローラーに記述を追加
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ページ目の入力が終わって次のページに遷移する際、バリデーションがかかっていないので、入力に不備があってもそのまま次のページに遷移できてしまう。
そのため、入力時、またはページ遷移時にバリデーションをかけ、入力に不備がある場合は元のページに戻りエラーメッセージを表示する必要がある。
ここでは、ページ遷移する際にバリデーションをかける方法を書く。
▼バリデーションをかけるためのメソッドを作成
# 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で安直に生成しようとしたら痛い目をみた話)