アンケートのRailsアプリSTI版

今回は前々回前回をまとめたアンケートアプリを作ります。
Railsの単一テーブル継承 (STI)を使って単一回答と複数回答をまとめました。

環境
Ruby 3.3.0
Rails 7.1.3.2

モデルを生成します。

rails new qrc_sti && cd $_
bin/rails g model User name
bin/rails g model question content type
bin/rails g model question_radio --parent=Question
bin/rails g model Radio content question:belongs_to
bin/rails g model question_check --parent=Question
bin/rails g model Check content question:belongs_to
bin/rails g model answer type user:belongs_to question:belongs_to radio:belongs_to
bin/rails g model answer_radio --parent=Answer
bin/rails g model answer_check --parent=Answer
bin/rails g model choice answer:belongs_to check:belongs_to

Answerを派生したAnswerCheckはラジオボタンを持ちません。
ラジオボタンへの外部キーはnull値を許可します。

# db/migrate/..._create_answers.rb
t.belongs_to :radio, null: true, foreign_key: true
# app/models/answer.rb
  belongs_to :radio, optional: true

AnswerRadioはラジオボタンを必須にします。

# app/models/answer_radio.rb
  validates :radio, presence: true

AnswerCheckは複数の選択を持ちます。
AnswerCheckはChoice経由で複数のCheckを持ちます。

# app/models/answer_check.rb
class AnswerCheck < Answer
  has_many :choices, foreign_key: "answer_id", inverse_of: :answer
  has_many :checks, through: :choices
end
bin/rails db:migrate

初期データを生成します。
今回はログインユーザーの管理、管理者による質問の編集機能は省きます。

# db/seeds.rb
User.find_or_create_by(name: "ユーザー1")
User.find_or_create_by(name: "ユーザー2")
qr1 = QuestionRadio.find_or_create_by(content: "好きな映画のジャンルは?")
["アクション", "コメディー", "ドラマ", "ホラー"].each do |content|
  Radio.find_or_create_by(content: content, question: qr1)
end
qr2 = QuestionRadio.find_or_create_by(content: "好きな果物は?")
["いちご", "もも", "みかん", "りんご", "ぶどう"].each do |content|
  Radio.find_or_create_by(content: content, question: qr2)
end
qc1 = QuestionCheck.find_or_create_by(content: "好きな映画のジャンルは?")
["アクション", "コメディー", "ドラマ", "ホラー"].each do |content|
  Check.find_or_create_by(content: content, question: qc1)
end
qc2 = QuestionCheck.find_or_create_by(content: "好きな果物は?")
["いちご", "もも", "みかん", "りんご", "ぶどう"].each do |content|
  Check.find_or_create_by(content: content, question: qc2)
end
bin/rails db:seed

コントローラーを生成します。

bin/rails g controller questions index show --skip-routes --skip-helper 
bin/rails g controller answer_radios --skip-routes --skip-helper 
bin/rails g controller answer_checks --skip-routes --skip-helper 
# config/routes.rb
Rails.application.routes.draw do
  resources :questions, only: %i[ index show ]
  resources :answer_radios, only: %i[ create update ]
  resources :answer_checks, only: %i[ create update ]

  get "up" => "rails/health#show", as: :rails_health_check

  root "questions#index"
end

一覧ページを開きます。

bin/rails s
open http://localhost:3000/questions
# app/controllers/questions_controller.rb
class QuestionsController < ApplicationController
  def index
    @questions = Question.all
  end

  def show
    current_user = User.second # TODO: ログインユーザーを取得する。
    question = Question.find(params[:id])
    @answer = question.answer_class.find_or_initialize_by(user: current_user, question: question)
  end
end
# app/views/questions/index.html.erb
<% @questions.each do |question| %>
  <div>
    <%= link_to question.content, question.becomes(Question) %>
  </div>
<% end %>

最初のアンケートページを開きます。

open http://localhost:3000/questions/1
# app/models/question_radio.rb
class QuestionRadio < Question
  has_many :radios, foreign_key: "question_id"

  def answer_class 
    AnswerRadio
  end
end
# app/models/question_check.rb
class QuestionCheck < Question
  has_many :checks, foreign_key: "question_id"

  def answer_class 
    AnswerCheck
  end
end

ラジオボタンを表示します。

# app/controllers/answer_radios_controller.rb
class AnswerRadiosController < ApplicationController
  def create
    @answer = AnswerRadio.new(answer_radio_params)
    if @answer.save
      redirect_to @answer.question.becomes(Question), notice: "Answer was successfully created."
    else
      render "questions/show", status: :unprocessable_entity
    end
  end

  def update
    @answer = AnswerRadio.find(params[:id])
    if @answer.update(answer_radio_params)
      redirect_to @answer.question.becomes(Question), notice: "Answer was successfully updated."
    else
      render "questions/show", status: :unprocessable_entity
    end
  end

  private
    def answer_radio_params
      params.require(:answer_radio).permit(:user_id, :question_id, :radio_id)
    end
end
# app/views/questions/show.html.erb
<p style="color: green"><%= notice %></p>

<%= @answer.question.content %>

<%= form_with(model: @answer) do |form| %>
  <% if @answer.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(@answer.errors.count, "error") %> prohibited this answer from being saved:</h2>

      <ul>
        <% @answer.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <%= form.hidden_field :user_id, value: @answer.user_id %>
  <%= form.hidden_field :question_id, value: @answer.question_id %>

  <%= render "questions/#{@answer.class.name.underscore}", form: form, answer: @answer %>

  <div>
    <%= form.submit %>
  </div>
<% end %>

<br>

<%= link_to "一覧", questions_url %>

部分テンプレートファイルを作ります。

touch app/views/questions/_answer_radio.html.erb
# app/views/questions/_answer_radio.html.erb
<%# locals: (form:, answer:) -%>
<div>
  <%= form.collection_radio_buttons :radio_id, answer.question.radios, :id, :content %>
</div>


チェックボックスを表示します。

open http://localhost:3000/questions/3
# app/controllers/answer_checks_controller.rb
class AnswerChecksController < ApplicationController
  def create
    @answer = AnswerCheck.new(answer_check_params)
    if @answer.save
      redirect_to @answer.question.becomes(Question), notice: "Answer was successfully created."
    else
      render "questions/show", status: :unprocessable_entity
    end
  end

  def update
    @answer = AnswerCheck.find(params[:id])
    if @answer.update(answer_check_params)
      redirect_to @answer.question.becomes(Question), notice: "Answer was successfully updated."
    else
      render "questions/show", status: :unprocessable_entity
    end
  end

  private
    def answer_check_params
      params.require(:answer_check).permit(:user_id, :question_id, check_ids: [])
    end
end

部分テンプレートファイルを作ります。

touch app/views/questions/_answer_check.html.erb
# app/views/questions/_answer_check.html.erb
<%# locals: (form:, answer:) -%>
<div>
  <%= form.collection_check_boxes :check_ids, answer.question.checks, :id, :content %>
</div>

前回と同様にレイアウトや文言など見栄えを洗練する必要がありますが、機能としては完成しました。