Building a Questionnaire

Episode #294 by David Kimura

Summary

Using StimulusJS and nested forms, we create the first parts of a questionnaire. Dynamic surveys can be difficult to architect and maintain. In this episode, we take a simple approach to creating questionnaires.
form rails stimulusjs hotwire 24:08

Resources

Download Source Code

Summary

# Terminal
bundle add hotwire-rails
rails hotwire:install
rails g scaffold questionnaire name
rails g model question questionnaire:belongs_to name question_type:integer required:boolean
rails g model answer question:belongs_to name

# models/questionnaire.rb
class Questionnaire < ApplicationRecord
  has_many :questions, dependent: :destroy
  accepts_nested_attributes_for :questions, allow_destroy: true
end

# models/question.rb
class Question < ApplicationRecord
  belongs_to :questionnaire
  has_many :answers, dependent: :destroy
  accepts_nested_attributes_for :answers, allow_destroy: true

  enum question_type: { single_choice: 0, multiple_choice: 1, long_answer: 2 }

  def self.question_type_select
    question_types.keys.map { |k| [k.titleize, k] }
  end
end

# models/answer.rb
class Answer < ApplicationRecord
  belongs_to :question
end

# app/assets/javascripts/controllers/nested_form_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["add_item", "template"]

  add_association(event) {
    event.preventDefault()
    var content = this.templateTarget.innerHTML.replace(/TEMPLATE_RECORD/g, new Date().getTime())
    this.add_itemTarget.insertAdjacentHTML('beforebegin', content)
  }

  remove_association(event) {
    event.preventDefault()
    let item = event.target.closest(".nested-fields")
    item.querySelector("input[name*='_destroy']").value = 1
    item.style.display = 'none'
  }
}

# views/questionnaires/_form.html.erb
<%= form_with(model: questionnaire) do |form| %>
  <div class="field">
    <%= form.label :name %>
    <%= form.text_field :name %>
  </div>

  <div data-controller="nested-form">
    <template data-nested-form-target='template'>
      <%= form.fields_for :questions, Question.new, child_index: 'TEMPLATE_RECORD' do |question| %>
        <%= render 'question_fields', form: question %>
      <% end %>
    </template>

    <%= form.fields_for :questions do |question| %>
      <%= render 'question_fields', form: question %>
    <% end %>

    <div data-nested-form-target="add_item">
      <%= link_to "Add Question", "#", data: { action: "nested-form#add_association" } %>
    </div>
  </div>


  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

# views/questionnaires/_question_fields.html.erb
<div class='nested-fields box' data-controller='dynamic-select'>
  <div class='form-group'>
    <%= form.select :question_type,
        options_for_select(Question.question_type_select, selected: form.object.question_type),
        {},
        'data-dynamic-select-target': 'select',
        'data-action': 'dynamic-select#selected' %>
  </div>

  <div class='form-group'>
    <%= form.hidden_field :_destroy %>
    <%= form.text_field :name, placeholder: 'Question', class: 'form-control' %>
    <small>
      <%= link_to "Remove", "#", data: { action: "click->nested-form#remove_association" } %>
    </small>
  </div>

  <div data-controller="nested-form" data-dynamic-select-target='choice'>
    <template data-nested-form-target='template'>
      <%= form.fields_for :answers, Answer.new, child_index: 'TEMPLATE_RECORD' do |answer| %>
        <%= render 'answer_fields', form: answer %>
      <% end %>
    </template>

    <%= form.fields_for :answers do |answer| %>
      <%= render 'answer_fields', form: answer %>
    <% end %>

    <div data-nested-form-target="add_item">
      <%= link_to "Add Answer", "#", data: { action: "nested-form#add_association" } %>
    </div>
  </div>

  <div data-controller="nested-form" data-dynamic-select-target='long'>
  </div>
</div>

# questionnaire_controller.rb
  def questionnaire_params
    params.require(:questionnaire).permit(
      :name,
      questions_attributes: [
        :_destroy,
        :id,
        :question_type,
        :name,
        answers_attributes: [:_destroy, :id, :name]
      ]
    )
  end

# views/questionnaires/_answer_fields.html.erb
<div class='nested-fields'>
  <div class='form-group'>
    <%= form.hidden_field :_destroy %>
    <%= form.text_field :name, placeholder: 'Answer', class: 'form-control' %>
    <small>
      <%= link_to "Remove Answer", "#", data: { action: "click->nested-form#remove_association" } %>
    </small>
  </div>
</div>

# app/assets/javascripts/controllers/dynamic_select_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["select", "choice", "long"]

  connect() {
    this.selected()
  }

  selected() {
    this.hideFields()
    switch (this.selectTarget.value) {
      case 'single_choice':
        this.choiceTarget.classList.remove('hidden')
        break;
      case 'multiple_choice':
        this.choiceTarget.classList.remove('hidden')
        break;
      case 'long_answer':
        this.longTarget.classList.remove('hidden')
        break;
    }
  }

  hideFields() {
    this.choiceTarget.classList.add('hidden')
    this.longTarget.classList.add('hidden')
  }
}

# views/questionnaires/show.html.erb
<h1><%= @questionnaire.name %></h1>

<% @questionnaire.questions.each do |question| %>
  <h2><%= question.name %></h2>

  <% case question.question_type %>
  <% when 'single_choice' %>
    <% question.answers.each do |answer| %>
      <p>
        <%= radio_button_tag question.id, answer.id %>
        <%= answer.name %>
      </p>
    <% end %>
  <% when 'multiple_choice' %>
    <% question.answers.each do |answer| %>
      <p>
        <%= check_box_tag question.id, answer.id %>
        <%= answer.name %>
      </p>
    <% end %>
  <% when 'long_answer' %>
    <%= text_area_tag question.id %>
  <% end %>
<% end %>