はじめに
ずっとコードを書いていたが、それだけではだいじなことを忘れそうな気がしたので、ここに記録しておこう。今、個人開発で日報アプリをつくろうとしている。ユーザーは日々の記録を書ける。各作業のジャンル、作業名、何時間したかさいごにその日のコメントをつける。
ログインした後、reportを登録する。reportひとつに対してreport_itemを複数登録できる。以下のようにしたい。
例:
ユーザー名:satou
登録日: 2021/10/21
report_item 1
ジャンル: Ruby
やったこと: メソッド復習
作業時間: 1時間
report_item 2
ジャンル: 英語
やったこと: TOEIC対策
作業時間: 2時間
report_item 3
ジャンル: 筋トレ
やったこと: ランニング
作業時間: 0.5時間
全体のコメント:
よくできた。
これを実現するにはどうすればいいのだろう。ひとつずつやっていくことにした。
モデルを考える
まずはモデルとその関係性を考えないといけない。必要なモデルはUser,Report,Report_itemそしてGenreだ。
User
-メールアドレス
-パスワード
1対多: reports/genres
Report
-コメント
-登録日
1対多: report_items
Genre
-ジャンル名
1対多:report_items
Report_item
-内容
-何時間したか
ユーザーは複数とReportと複数のGenreを持ち、Reportは複数のReport_itemを持つ。Genreも複数のReport_itemを持つ。個々のバリデーションは考えながら追加しよう。
モデルを作る
そうやってモデルを作っていく。はじめにやるのがUserモデルだ。これにはログイン機能が必要だが、私はdeviseというgemを使ってそれを実装する。このdeviseの兼ね合いがあり、あとから追加すると面倒臭かった記憶がある。
ここを参考にしながらgemを導入してモデルをつくった。rails g model
ではなく rails g devise
をつかう。
$ rails g devise:install
$ rails g devise User
あとは普通につくる。モデルの関連付けは以下のようになる。
class User < ApplicationRecord
has_many :reports, dependent: :destroy
has_many :genres, dependent: :destroy
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
class Report < ApplicationRecord
belongs_to :user
has_many :report_items, dependent: :destroy
accepts_nested_attributes_for :report_items
end
class ReportItem < ApplicationRecord
belongs_to :report
belongs_to :genre
end
class Genre < ApplicationRecord
has_many :report_items,dependent: :restrict_with_exception
belongs_to :user
end
accepts_nested_attributes_forがだいじ
report.rb にサラッと記述されている accepts_nested_attributes_for
だが、これがかなり重要だ。
最初は単一のオブジェクトを編集していただけのシンプルなフォームも、やがて成長し複雑になります。たとえば、Personを1人作成するのであれば、そのうち同じフォームで複数の住所レコード(自宅、職場など)を登録したくなるでしょう。後でPersonを編集するときに、必要に応じて住所の追加・削除・変更が行えるようにする必要もあります。
親のモデルに accepts_nested_attributes_for
を記述することで関連しているモデルも編集することができるようになる。
ルーティングを整える
ルーティングはわりと簡単だ。resourcesを使うだけ。
Rails.application.routes.draw do
devise_for :users
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
root "reports#index"
resources :reports
end
コントローラーとをビュー作る
これがちょっと厳しかった。今回はreportのindex/new/createアクションだけ紹介する。
rails g controller reports
でコントローラーとviewファイルが生成されるはずだ。
indexとnew アクション
indexアクションとnewアクションを書く。
class ReportsController < ApplicationController
before_action :authenticate_user!
def index
@reports = Report.where(user_id: current_user.id)
end
def new
@report = Report.new
3.times { @report.report_items.build }
@select_genre = Genre.where(user_id: current_user)
end
def create
end
end
indexアクションはそこまで問題ではない。Reportを自分が登録したものだけ引っ張ってきている。
before_action :authenticate_user!
はdeviseによって使用できるメソッドであり、ログインしていない状態ではログインページへリダイレクトされる。
newアクションではreportを新しく作り、その子要素を3つ作っている。これは新規投稿画面でreport_itemを一気に3つまで登録したいためだ。
index/newのビュー
indexのビューは以下のようになる。
<h1>日報一覧</h1>
<% @reports.each do |report| %>
<p>
日報ID:<%= report.id %>
</p>
<p>日付:<%= report.reported_on %></p>
<% report.report_items.each do |item| %>
<p>
ID:<%= item.id %>
ジャンルID:<%= item.genre_id %>
やったこと:<%= item.content %>
時間:<%= item.work_hours %>
</p>
<% end %>
コメント: <%= report.content %>
</p>
<hr>
<% end %>
いたって普通だ。コントローラーの@reportsを呼び出し、繰り返しで中の要素を書き出している。ジャンルIDを普通の名前表示にしたりとかは、また別の話。
newのビューは以下のようになった。
<h1>日報登録</h1>
<%= form_with model: @report, local: true do |f| %>
<p>登録日:<%= f.date_field :reported_on , value:Date.today%></p>
<%= f.fields_for :report_items do |item| %>
<p>ジャンル:<%= item.collection_select :genre_id, @select_genre, :id, :name, :include_blank => true %></p>
<p>やったこと:<%= item.text_field :content %></p>
<p>作業時間:<%= item.text_field :work_hours %></p>
<% end %>
<p>コメント:<%= f.text_field :content %></p>
<%= f.submit "登録" %>
<% end %>
ここが少し大変だった。form_withとfields_forの使い方を調べるのが難しかった。Railsのバージョンが新しくなってから、form_forとform_tagではなくform_withを利用することが推奨され始めた。
form_with
のあとに登録するモデルを書く。繰り返しの構文のように |f|
を指定してやる。f.date_field
やf.text_field
を設定することでいろんなスタイルのフォームを作成できる。
あとはfileds_for
を使うとreportsとは関係のないモデルへの登録も可能となる。ここで|item|
をつかって繰り返しをすると、さきほどつくった3つの子要素のフォームが表示される。
このあたりは公式のgithubのコメント欄にいろいろ書いてある。rails ドキュメントより記載が豊富だ。
createアクション
これが本当に面倒くさかった。なにをどう登録するかがわからなかったのだ。まずはbinding.pryを挟んで、newアクション後のparamsになにが渡っているのか確認した。
class ReportsController < ApplicationController
# 略
def create
binding.pry
end
end
以下のようなものが入っていた。
pry(#<ReportsController>)> params
=> <ActionController::Parameters {"authenticity_token"=>"qyEey4ZqOElVMESUggFdPNOjsgsmDDK7yLZDtwuxv9yxVsI8kPkzspTbkVhyYyeppK1qR/mhG7ed1sjDR95wDA==", "report"=><ActionController::Parameters {"reported_on"=>"2021-10-17", "report_items_attributes"=>{"0"=>{"genre_id"=>"1", "content"=>"a", "work_hours"=>"3"}, "1"=>{"genre_id"=>"", "content"=>"", "work_hours"=>""}, "2"=>{"genre_id"=>"", "content"=>"", "work_hours"=>""}}, "content"=>"test"} permitted: false>, "commit"=>"登録", "controller"=>"reports", "action"=>"create"} permitted: false>
なんとなくわかった。
このまま利用するとセキュリティ的にあぶないとどこかに書いてあった。
railsにはマスアサイメントといって複数の項目を一括で更新/登録ができる機能が備わっている。しかし、これは渡されるハッシュを読み取っているだけなので、一般ユーザが管理者権限がないと更新できない項目もいぢれてしまう!という可能性もでてくるらしい。
ストロングパラメータを使って、設計者側から利用できるパラメータを指定すべき、らしい。
ネストされているので少し工夫が必要だ。
params.require(:report).permit(:content, :reported_on, report_items_attributes: [:content, :genre_id, :work_hours, :id])
上記のようにした。report_items_attributes
には:id
も必要だった。これをストロングパラメータとしてコントローラーに追記する。
class ReportsController < ApplicationController
# 略
def create
binding.pry
end
# 略
private
def report_params
params.require(:report).permit(:content, :reported_on, report_items_attributes: [:content, :genre_id, :work_hours, :id])
end
end
あとは3つの子要素のうち、1つだけ/2つだけ登録したいというときの処理を考える必要がある。ストロングパラメータにしたreport_paramsにはなにがはいっているのか。
report_params
=> <ActionController::Parameters {
"content"=>"eee", "reported_on"=>"2021-10-17",
"report_items_attributes"=>
<ActionController::Parameters {
"0"=><ActionController::Parameters {"content"=>"a", "genre_id"=>"2", "work_hours"=>"1"} permitted: true>,
"1"=><ActionController::Parameters {"content"=>"", "genre_id"=>"", "work_hours"=>""} permitted: true>,
"2"=><ActionController::Parameters {"content"=>"", "genre_id"=>"", "work_hours"=>""} permitted: true>
}
permitted: true>
}
permitted: true>
なるほど。いろいろ入っていた。"1","2"のなかみがcontent=""になっているので、これをうまいこと除去してやればよさそうだ。
そうして以下のようになった。
class ReportsController < ApplicationController
# 略
def create
para = report_params[:report_items_attributes]
first_key = para.keys.first
first_value = para.values.first
para.reject! { |_key, value| value[:content] == "" }
para[first_key] = first_value unless para.key?("content")
formatted_para = report_params
formatted_para[:report_items_attributes] = para
report = Report.new(formatted_para)
report.user_id = current_user.id
report.save!
end
# 略
end
中央のpara.rejecet!でcontentが空白になっているものは除去するようにした。3つの子要素すべのcontentが空白だとバリデーションではじくようになっている。
# できた
やっとひとつの機能ができた。なんだかうれしい気分になった。でも次に行った実装はもっと面倒くさかった。多対多の要素の登録である。
それはまた別のお話。
↓やっていく github
https://github.com/kyokucho1989/simple-record