Browse Source

Refactor the form parsing and navigation logic into OOP domain objects (#106)

* OOP form

* Add ADR

* PR commits
pull/107/head
Daniel Baark 3 years ago committed by GitHub
parent
commit
ba51e66a56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 47
      app/controllers/case_logs_controller.rb
  2. 6
      app/controllers/soft_validations_controller.rb
  3. 61
      app/helpers/check_answers_helper.rb
  4. 8
      app/helpers/conditional_questions_helper.rb
  5. 10
      app/helpers/question_attribute_helper.rb
  6. 38
      app/helpers/tasklist_helper.rb
  7. 4
      app/models/case_log.rb
  8. 160
      app/models/form.rb
  9. 30
      app/models/form/page.rb
  10. 80
      app/models/form/question.rb
  11. 10
      app/models/form/section.rb
  12. 65
      app/models/form/subsection.rb
  13. 2
      app/validations/household_validations.rb
  14. 13
      app/views/case_logs/_tasklist.html.erb
  15. 2
      app/views/case_logs/edit.html.erb
  16. 6
      app/views/form/_check_answers_table.html.erb
  17. 10
      app/views/form/_checkbox_question.html.erb
  18. 6
      app/views/form/_date_question.html.erb
  19. 10
      app/views/form/_numeric_question.html.erb
  20. 12
      app/views/form/_radio_question.html.erb
  21. 11
      app/views/form/_select_question.html.erb
  22. 6
      app/views/form/_text_question.html.erb
  23. 6
      app/views/form/_validation_override_question.html.erb
  24. 8
      app/views/form/check_answers.html.erb
  25. 16
      app/views/form/page.html.erb
  26. 10
      config/routes.rb
  27. 10
      docs/adr/adr-011-form-oop-refactor.md
  28. 39
      spec/controllers/case_logs_controller_spec.rb
  29. 2
      spec/features/case_log_spec.rb
  30. 30
      spec/fixtures/forms/test_form.json
  31. 185
      spec/helpers/check_answers_helper_spec.rb
  32. 12
      spec/helpers/conditional_questions_helper_spec.rb
  33. 22
      spec/helpers/question_attribute_helper_spec.rb
  34. 30
      spec/helpers/tasklist_helper_spec.rb
  35. 66
      spec/models/form/page_spec.rb
  36. 140
      spec/models/form/question_spec.rb
  37. 21
      spec/models/form/section_spec.rb
  38. 72
      spec/models/form/subsection_spec.rb
  39. 2
      spec/models/form_handler_spec.rb
  40. 64
      spec/models/form_spec.rb

47
app/controllers/case_logs_controller.rb

@ -57,14 +57,14 @@ class CaseLogsController < ApplicationController
def submit_form def submit_form
form = FormHandler.instance.get_form("2021_2022") form = FormHandler.instance.get_form("2021_2022")
@case_log = CaseLog.find(params[:id]) @case_log = CaseLog.find(params[:id])
@case_log.page = params[:case_log][:page] @case_log.page_id = params[:case_log][:page]
responses_for_page = responses_for_page(@case_log.page) page = form.get_page(@case_log.page_id)
responses_for_page = responses_for_page(page)
if @case_log.update(responses_for_page) && @case_log.has_no_unresolved_soft_errors? if @case_log.update(responses_for_page) && @case_log.has_no_unresolved_soft_errors?
redirect_path = form.next_page_redirect_path(@case_log.page, @case_log) redirect_path = form.next_page_redirect_path(page, @case_log)
redirect_to(send(redirect_path, @case_log)) redirect_to(send(redirect_path, @case_log))
else else
page_info = form.all_pages[@case_log.page] render "form/page", locals: { form: form, page: page }, status: :unprocessable_entity
render "form/page", locals: { form: form, page_key: @case_log.page, page_info: page_info }, status: :unprocessable_entity
end end
end end
@ -84,15 +84,15 @@ class CaseLogsController < ApplicationController
form = FormHandler.instance.get_form("2021_2022") form = FormHandler.instance.get_form("2021_2022")
@case_log = CaseLog.find(params[:case_log_id]) @case_log = CaseLog.find(params[:case_log_id])
current_url = request.env["PATH_INFO"] current_url = request.env["PATH_INFO"]
subsection = current_url.split("/")[-2] subsection = form.get_subsection(current_url.split("/")[-2])
render "form/check_answers", locals: { subsection: subsection, form: form } render "form/check_answers", locals: { subsection: subsection, form: form }
end end
form = FormHandler.instance.get_form("2021_2022") form = FormHandler.instance.get_form("2021_2022")
form.all_pages.map do |page_key, page_info| form.pages.map do |page|
define_method(page_key) do |_errors = {}| define_method(page.id) do |_errors = {}|
@case_log = CaseLog.find(params[:case_log_id]) @case_log = CaseLog.find(params[:case_log_id])
render "form/page", locals: { form: form, page_key: page_key, page_info: page_info } render "form/page", locals: { form: form, page: page }
end end
end end
@ -101,29 +101,28 @@ private
API_ACTIONS = %w[create show update destroy].freeze API_ACTIONS = %w[create show update destroy].freeze
def responses_for_page(page) def responses_for_page(page)
form = FormHandler.instance.get_form("2021_2022") page.expected_responses.each_with_object({}) do |question, result|
form.expected_responses_for_page(page).each_with_object({}) do |(question_key, question_info), result| question_params = params["case_log"][question.id]
question_params = params["case_log"][question_key] if question.type == "date"
if question_info["type"] == "date" day = params["case_log"]["#{question.id}(3i)"]
day = params["case_log"]["#{question_key}(3i)"] month = params["case_log"]["#{question.id}(2i)"]
month = params["case_log"]["#{question_key}(2i)"] year = params["case_log"]["#{question.id}(1i)"]
year = params["case_log"]["#{question_key}(1i)"]
next unless [day, month, year].any?(&:present?) next unless [day, month, year].any?(&:present?)
result[question_key] = if day.to_i.between?(1, 31) && month.to_i.between?(1, 12) && year.to_i.between?(2000, 2200) result[question.id] = if day.to_i.between?(1, 31) && month.to_i.between?(1, 12) && year.to_i.between?(2000, 2200)
Date.new(year.to_i, month.to_i, day.to_i) Date.new(year.to_i, month.to_i, day.to_i)
else else
Date.new(0, 1, 1) Date.new(0, 1, 1)
end end
end end
next unless question_params next unless question_params
if %w[checkbox validation_override].include?(question_info["type"]) if %w[checkbox validation_override].include?(question.type)
question_info["answer_options"].keys.reject { |x| x.match(/divider/) }.each do |option| question.answer_options.keys.reject { |x| x.match(/divider/) }.each do |option|
result[option] = question_params.include?(option) ? 1 : 0 result[option] = question_params.include?(option) ? 1 : 0
end end
else else
result[question_key] = question_params result[question.id] = question_params
end end
result result
end end

6
app/controllers/soft_validations_controller.rb

@ -1,9 +1,9 @@
class SoftValidationsController < ApplicationController class SoftValidationsController < ApplicationController
def show def show
@case_log = CaseLog.find(params[:case_log_id]) @case_log = CaseLog.find(params[:case_log_id])
page_key = request.env["PATH_INFO"].split("/")[-2] page_id = request.env["PATH_INFO"].split("/")[-2]
form = FormHandler.instance.get_form("2021_2022") form = FormHandler.instance.get_form("2021_2022")
page = form.all_pages[page_key] page = form.get_page(page_id)
if page_requires_soft_validation_override?(page) if page_requires_soft_validation_override?(page)
errors = @case_log.soft_errors.values.first errors = @case_log.soft_errors.values.first
render json: { show: true, label: errors.message, hint: errors.hint_text } render json: { show: true, label: errors.message, hint: errors.hint_text }
@ -15,6 +15,6 @@ class SoftValidationsController < ApplicationController
private private
def page_requires_soft_validation_override?(page) def page_requires_soft_validation_override?(page)
@case_log.soft_errors.present? && @case_log.soft_errors.keys.first == page["soft_validations"]&.keys&.first @case_log.soft_errors.present? && @case_log.soft_errors.keys.first == page.soft_validations&.first&.id
end end
end end

61
app/helpers/check_answers_helper.rb

@ -1,57 +1,20 @@
module CheckAnswersHelper module CheckAnswersHelper
def total_answered_questions(subsection, case_log, form) def display_answered_questions_summary(subsection, case_log)
total_questions(subsection, case_log, form).keys.count do |question_key| total = subsection.applicable_questions_count(case_log)
case_log[question_key].present? answered = subsection.answered_questions_count(case_log)
end if total == answered
end '<p class="govuk-body govuk-!-margin-bottom-7">You answered all the questions</p>'.html_safe
else
def total_number_of_questions(subsection, case_log, form) "<p class=\"govuk-body govuk-!-margin-bottom-7\">You answered #{answered} of #{total} questions</p>
total_questions(subsection, case_log, form).count #{create_next_missing_question_link(subsection, case_log)}".html_safe
end
def total_questions(subsection, case_log, form)
questions = form.questions_for_subsection(subsection)
form.filter_conditional_questions(questions, case_log)
end
def get_next_page_name(form, page_name, case_log)
page = form.all_pages[page_name]
if page.key?("conditional_route_to")
page["conditional_route_to"].each do |conditional_page_name, condition|
unless condition.any? { |field, value| case_log[field].blank? || !value.include?(case_log[field]) }
return conditional_page_name
end
end
end end
form.next_page(page_name)
end end
def create_update_answer_link(question_title, question_info, case_log, form) private
page = form.page_for_question(question_title)
link_name = if question_info["type"] == "checkbox"
question_info["answer_options"].keys.any? { |key| case_log[key] == "Yes" } ? "Change" : "Answer"
else
case_log[question_title].blank? ? "Answer" : "Change"
end
link_to(link_name, "/case_logs/#{case_log.id}/#{page}", class: "govuk-link").html_safe
end
def create_next_missing_question_link(case_log_id, subsection, case_log, form) def create_next_missing_question_link(subsection, case_log)
pages_to_fill_in = [] pages_to_fill_in = subsection.unanswered_questions(case_log).map(&:page)
form.pages_for_subsection(subsection).each do |page_title, page_info| url = "/case_logs/#{case_log.id}/#{pages_to_fill_in.first.id}"
page_info["questions"].any? { |question| case_log[question].blank? }
pages_to_fill_in << page_title
end
url = "/case_logs/#{case_log_id}/#{pages_to_fill_in.first}"
link_to("Answer the missing questions", url, class: "govuk-link").html_safe link_to("Answer the missing questions", url, class: "govuk-link").html_safe
end end
def display_answered_questions_summary(subsection, case_log, form)
if total_answered_questions(subsection, case_log, form) == total_number_of_questions(subsection, case_log, form)
'<p class="govuk-body govuk-!-margin-bottom-7">You answered all the questions</p>'.html_safe
else
"<p class=\"govuk-body govuk-!-margin-bottom-7\">You answered #{total_answered_questions(subsection, case_log, form)} of #{total_number_of_questions(subsection, case_log, form)} questions</p>
#{create_next_missing_question_link(case_log['id'], subsection, case_log, form)}".html_safe
end
end
end end

8
app/helpers/conditional_questions_helper.rb

@ -1,11 +1,9 @@
module ConditionalQuestionsHelper module ConditionalQuestionsHelper
def conditional_questions_for_page(page) def conditional_questions_for_page(page)
page["questions"].values.map { |question| page.questions.map(&:conditional_for).compact.map(&:keys).flatten
question["conditional_for"]
}.compact.map(&:keys).flatten
end end
def display_question_key_div(page_info, question_key) def display_question_key_div(page, question)
"style='display:none;'".html_safe if conditional_questions_for_page(page_info).include?(question_key) "style='display:none;'".html_safe if conditional_questions_for_page(page).include?(question.id)
end end
end end

10
app/helpers/question_attribute_helper.rb

@ -10,23 +10,23 @@ module QuestionAttributeHelper
private private
def numeric_question_html_attributes(question) def numeric_question_html_attributes(question)
return {} if question["fields-to-add"].blank? || question["result-field"].blank? return {} if question.fields_to_add.blank? || question.result_field.blank?
{ {
"data-controller": "numeric-question", "data-controller": "numeric-question",
"data-action": "numeric-question#calculateFields", "data-action": "numeric-question#calculateFields",
"data-target": "case-log-#{question['result-field'].to_s.dasherize}-field", "data-target": "case-log-#{question.result_field.to_s.dasherize}-field",
"data-calculated": question["fields-to-add"].to_json, "data-calculated": question.fields_to_add.to_json,
} }
end end
def conditional_html_attributes(question) def conditional_html_attributes(question)
return {} if question["conditional_for"].blank? return {} if question.conditional_for.blank?
{ {
"data-controller": "conditional-question", "data-controller": "conditional-question",
"data-action": "conditional-question#displayConditional", "data-action": "conditional-question#displayConditional",
"data-info": question["conditional_for"].to_json, "data-info": question.conditional_for.to_json,
} }
end end
end end

38
app/helpers/tasklist_helper.rb

@ -14,40 +14,30 @@ module TasklistHelper
}.freeze }.freeze
def get_next_incomplete_section(form, case_log) def get_next_incomplete_section(form, case_log)
subsections = form.all_subsections.keys form.subsections.find { |subsection| subsection.is_incomplete?(case_log) }
subsections.find { |subsection| is_incomplete?(subsection, case_log, form) }
end end
def get_subsections_count(form, case_log, status = :all) def get_subsections_count(form, case_log, status = :all)
subsections = form.all_subsections.keys return form.subsections.count if status == :all
return subsections.count if status == :all
subsections.count { |subsection| form.subsection_status(subsection, case_log) == status } form.subsections.count { |subsection| subsection.status(case_log) == status }
end end
def get_first_page_or_check_answers(subsection, case_log, form) def first_page_or_check_answers(subsection, case_log)
path = if is_started?(subsection, case_log, form) path = if subsection.is_started?(case_log)
"case_log_#{subsection}_check_answers_path" "case_log_#{subsection.id}_check_answers_path"
else else
"case_log_#{form.first_page_for_subsection(subsection)}_path" "case_log_#{subsection.pages.first.id}_path"
end end
send(path, case_log) send(path, case_log)
end end
def subsection_link(subsection_key, subsection_value, status, form, case_log) def subsection_link(subsection, case_log)
next_page_path = status != :cannot_start_yet ? get_first_page_or_check_answers(subsection_key, case_log, form) : "#" next_page_path = if subsection.status(case_log) != :cannot_start_yet
link_to(subsection_value["label"], next_page_path, class: "task-name govuk-link") first_page_or_check_answers(subsection, case_log)
end else
"#"
private end
link_to(subsection.label, next_page_path, class: "task-name govuk-link")
def is_incomplete?(subsection, case_log, form)
status = form.subsection_status(subsection, case_log)
%i[not_started in_progress].include?(status)
end
def is_started?(subsection, case_log, form)
status = form.subsection_status(subsection, case_log)
%i[in_progress completed].include?(status)
end end
end end

4
app/models/case_log.rb

@ -11,7 +11,7 @@ class CaseLogValidator < ActiveModel::Validator
# If we've come from the form UI we only want to validate the specific fields # If we've come from the form UI we only want to validate the specific fields
# that have just been submitted. If we're submitting a log via API or Bulk Upload # that have just been submitted. If we're submitting a log via API or Bulk Upload
# we want to validate all data fields. # we want to validate all data fields.
page_to_validate = record.page page_to_validate = record.page_id
if page_to_validate if page_to_validate
public_send("validate_#{page_to_validate}", record) if respond_to?("validate_#{page_to_validate}") public_send("validate_#{page_to_validate}", record) if respond_to?("validate_#{page_to_validate}")
else else
@ -44,7 +44,7 @@ class CaseLog < ApplicationRecord
validates_with CaseLogValidator validates_with CaseLogValidator
before_save :update_status! before_save :update_status!
attr_accessor :page attr_accessor :page_id
enum status: { "not_started" => 0, "in_progress" => 1, "completed" => 2 } enum status: { "not_started" => 0, "in_progress" => 1, "completed" => 2 }

160
app/models/form.rb

@ -1,75 +1,34 @@
class Form class Form
attr_reader :form_definition attr_reader :form_definition, :sections, :subsections, :pages, :questions
def initialize(form_path) def initialize(form_path)
raise "No form definition file exists for given year".freeze unless File.exist?(form_path) raise "No form definition file exists for given year".freeze unless File.exist?(form_path)
@form_definition = JSON.parse(File.open(form_path).read) @form_definition = JSON.parse(File.open(form_path).read)
@sections = form_definition["sections"].map { |id, s| Form::Section.new(id, s, self) }
@subsections = sections.flat_map(&:subsections)
@pages = subsections.flat_map(&:pages)
@questions = pages.flat_map(&:questions)
end end
# Returns a hash with sections as keys def get_subsection(id)
def all_sections subsections.find { |s| s.id == id }
@all_sections ||= @form_definition["sections"]
end end
# Returns a hash with subsections as keys def get_page(id)
def all_subsections pages.find { |p| p.id == id }
@all_subsections ||= all_sections.map { |_section_key, section_value|
section_value["subsections"]
}.reduce(:merge)
end
# Returns a hash with pages as keys
def all_pages
@all_pages ||= all_subsections.map { |_subsection_key, subsection_value|
subsection_value["pages"]
}.reduce(:merge)
end
# Returns a hash with the pages of a subsection as keys
def pages_for_subsection(subsection)
all_subsections[subsection]["pages"]
end
# Returns a hash with the questions as keys
def questions_for_page(page)
all_pages[page]["questions"]
end
# Returns a hash with the questions as keys
def questions_for_subsection(subsection)
pages_for_subsection(subsection).map { |title, _value| questions_for_page(title) }.reduce(:merge)
end
# Returns a hash with soft validation questions as keys
def soft_validations_for_page(page)
all_pages[page]["soft_validations"]
end
def expected_responses_for_page(page)
questions_for_page(page).merge(soft_validations_for_page(page) || {})
end
def first_page_for_subsection(subsection)
pages_for_subsection(subsection).keys.first
end end
def subsection_for_page(page) def subsection_for_page(page)
all_subsections.find { |_subsection_key, subsection_value| subsections.find { |s| s.pages.find { |p| p.id == page.id } }
subsection_value["pages"].key?(page)
}.first
end
def page_for_question(question)
all_pages.find { |_page_key, page_value| page_value["questions"].key?(question) }.first
end end
def next_page(page, case_log) def next_page(page, case_log)
subsection = subsection_for_page(page) page_ids = subsection_for_page(page).pages.map(&:id)
page_idx = pages_for_subsection(subsection).keys.index(page) page_index = page_ids.index(page.id)
nxt_page = pages_for_subsection(subsection).keys[page_idx + 1] nxt_page = get_page(page_ids[page_index + 1])
return :check_answers if nxt_page.nil? return :check_answers if nxt_page.nil?
return nxt_page if page_routed_to?(nxt_page, case_log) return nxt_page.id if nxt_page.routed_to?(case_log)
next_page(nxt_page, case_log) next_page(nxt_page, case_log)
end end
@ -77,95 +36,16 @@ class Form
def next_page_redirect_path(page, case_log) def next_page_redirect_path(page, case_log)
nxt_page = next_page(page, case_log) nxt_page = next_page(page, case_log)
if nxt_page == :check_answers if nxt_page == :check_answers
subsection = subsection_for_page(page) "case_log_#{subsection_for_page(page).id}_check_answers_path"
"case_log_#{subsection}_check_answers_path"
else else
"case_log_#{nxt_page}_path" "case_log_#{nxt_page}_path"
end end
end end
def all_questions def conditional_question_conditions
@all_questions ||= all_pages.map { |_page_key, page_value| conditions = questions.map { |q| Hash(q.id => q.conditional_for) if q.conditional_for.present? }.compact
page_value["questions"] conditions.map { |c|
}.reduce(:merge) c.map { |k, v| v.keys.map { |key| Hash(from: k, to: key, cond: v[key]) } }
end }.flatten
def filter_conditional_questions(questions, case_log)
applicable_questions = questions
questions.each do |k, question|
unless page_routed_to?(page_for_question(k), case_log)
applicable_questions = applicable_questions.reject { |z| z == k }
end
question.fetch("conditional_for", []).each do |conditional_question_key, condition|
if condition_not_met(case_log, k, question, condition)
applicable_questions = applicable_questions.reject { |z| z == conditional_question_key }
end
end
end
applicable_questions
end
def page_routed_to?(page, case_log)
return true unless (conditions = page_dependencies(page))
conditions.all? do |question, value|
case_log[question].present? && case_log[question] == value
end
end
def page_dependencies(page)
all_pages[page]["depends_on"]
end
def subsection_dependencies_met?(subsection_name, case_log)
conditions = all_subsections[subsection_name]["depends_on"]
return true unless conditions
conditions.all? do |subsection, status|
subsection_status(subsection, case_log) == status.to_sym
end
end
def subsection_status(subsection_name, case_log)
unless subsection_dependencies_met?(subsection_name, case_log)
return :cannot_start_yet
end
questions = questions_for_subsection(subsection_name)
applicable_questions = filter_conditional_questions(questions, case_log).keys
return :not_started if applicable_questions.all? { |question| case_log[question].blank? }
return :completed if applicable_questions.all? { |question| case_log[question].present? }
:in_progress
end
def condition_not_met(case_log, question_key, question, condition)
case question["type"]
when "numeric"
operator = condition[/[<>=]+/].to_sym
operand = condition[/\d+/].to_i
case_log[question_key].blank? || !case_log[question_key].send(operator, operand)
when "text"
case_log[question_key].blank? || !condition.include?(case_log[question_key])
when "radio"
case_log[question_key].blank? || !condition.include?(case_log[question_key])
when "select"
case_log[question_key].blank? || !condition.include?(case_log[question_key])
else
raise "Not implemented yet"
end
end
def get_answer_label(case_log, question_title)
question = all_questions[question_title]
if question["type"] == "checkbox"
answer = []
question["answer_options"].each { |key, value| case_log[key] == "Yes" ? answer << value : nil }
return answer.join(", ")
end
case_log[question_title]
end end
end end

30
app/models/form/page.rb

@ -0,0 +1,30 @@
class Form::Page
attr_accessor :id, :header, :description, :questions, :soft_validations,
:depends_on, :subsection
def initialize(id, hsh, subsection)
@id = id
@header = hsh["header"]
@description = hsh["description"]
@questions = hsh["questions"].map { |q_id, q| Form::Question.new(q_id, q, self) }
@depends_on = hsh["depends_on"]
@soft_validations = hsh["soft_validations"]&.map { |v_id, s| Form::Question.new(v_id, s, self) }
@subsection = subsection
end
def expected_responses
questions + (soft_validations || [])
end
def has_soft_validations?
soft_validations.present?
end
def routed_to?(case_log)
return true unless depends_on
depends_on.all? do |question, value|
case_log[question].present? && case_log[question] == value
end
end
end

80
app/models/form/question.rb

@ -0,0 +1,80 @@
class Form::Question
attr_accessor :id, :header, :hint_text, :description, :questions,
:type, :min, :max, :step, :fields_to_add, :result_field,
:conditional_for, :readonly, :answer_options, :page, :check_answer_label
def initialize(id, hsh, page)
@id = id
@check_answer_label = hsh["check_answer_label"]
@header = hsh["header"]
@hint_text = hsh["hint_text"]
@type = hsh["type"]
@min = hsh["min"]
@max = hsh["max"]
@step = hsh["step"]
@fields_to_add = hsh["fields-to-add"]
@result_field = hsh["result-field"]
@readonly = hsh["readonly"]
@answer_options = hsh["answer_options"]
@conditional_for = hsh["conditional_for"]
@page = page
end
delegate :subsection, to: :page
delegate :form, to: :subsection
def answer_label(case_log)
return checkbox_answer_label(case_log) if type == "checkbox"
case_log[id].to_s
end
def read_only?
!!readonly
end
def conditional_on
@conditional_on ||= form.conditional_question_conditions.select do |condition|
condition[:to] == id
end
end
def enabled?(case_log)
return true if conditional_on.blank?
conditional_on.map { |condition| evaluate_condition(condition, case_log) }.all?
end
def update_answer_link_name(case_log)
if type == "checkbox"
answer_options.keys.any? { |key| case_log[key] == "Yes" } ? "Change" : "Answer"
else
case_log[id].blank? ? "Answer" : "Change"
end
end
private
def checkbox_answer_label(case_log)
answer = []
answer_options.each { |key, value| case_log[key] == "Yes" ? answer << value : nil }
answer.join(", ")
end
def evaluate_condition(condition, case_log)
case page.questions.find { |q| q.id == condition[:from] }.type
when "numeric"
operator = condition[:cond][/[<>=]+/].to_sym
operand = condition[:cond][/\d+/].to_i
case_log[condition[:from]].present? && case_log[condition[:from]].send(operator, operand)
when "text"
case_log[condition[:from]].present? && condition[:cond].include?(case_log[condition[:from]])
when "radio"
case_log[condition[:from]].present? && condition[:cond].include?(case_log[condition[:from]])
when "select"
case_log[condition[:from]].present? && condition[:cond].include?(case_log[condition[:from]])
else
raise "Not implemented yet"
end
end
end

10
app/models/form/section.rb

@ -0,0 +1,10 @@
class Form::Section
attr_accessor :id, :label, :subsections, :form
def initialize(id, hsh, form)
@id = id
@label = hsh["label"]
@form = form
@subsections = hsh["subsections"].map { |s_id, s| Form::Subsection.new(s_id, s, self) }
end
end

65
app/models/form/subsection.rb

@ -0,0 +1,65 @@
class Form::Subsection
attr_accessor :id, :label, :section, :pages, :depends_on, :form
def initialize(id, hsh, section)
@id = id
@label = hsh["label"]
@depends_on = hsh["depends_on"]
@pages = hsh["pages"].map { |s_id, p| Form::Page.new(s_id, p, self) }
@section = section
end
delegate :form, to: :section
def questions
@questions ||= pages.flat_map(&:questions)
end
def enabled?(case_log)
return true unless depends_on
depends_on.all? do |subsection_id, dependent_status|
form.get_subsection(subsection_id).status(case_log) == dependent_status.to_sym
end
end
def status(case_log)
unless enabled?(case_log)
return :cannot_start_yet
end
qs = applicable_questions(case_log)
return :not_started if qs.all? { |question| case_log[question.id].blank? }
return :completed if qs.all? { |question| case_log[question.id].present? }
:in_progress
end
def is_incomplete?(case_log)
%i[not_started in_progress].include?(status(case_log))
end
def is_started?(case_log)
%i[in_progress completed].include?(status(case_log))
end
def applicable_questions_count(case_log)
applicable_questions(case_log).count
end
def answered_questions_count(case_log)
answered_questions(case_log).count
end
def applicable_questions(case_log)
questions.select { |q| q.page.routed_to?(case_log) && q.enabled?(case_log) }
end
def answered_questions(case_log)
applicable_questions(case_log).select { |question| case_log[question.id].present? }
end
def unanswered_questions(case_log)
applicable_questions(case_log) - answered_questions(case_log)
end
end

2
app/validations/household_validations.rb

@ -66,7 +66,7 @@ module HouseholdValidations
return unless record.age1 return unless record.age1
if !record.age1.is_a?(Integer) || record.age1 < 16 || record.age1 > 120 if !record.age1.is_a?(Integer) || record.age1 < 16 || record.age1 > 120
record.errors.add "age1", "Tenant age must be an integer between 16 and 120" record.errors.add :age1, "Tenant age must be an integer between 16 and 120"
end end
end end

13
app/views/case_logs/_tasklist.html.erb

@ -1,17 +1,16 @@
<ol class="app-task-list app-task-list--no-numbers"> <ol class="app-task-list app-task-list--no-numbers">
<% @form.all_sections.map do |section_key, section_value| %> <% @form.sections.map do |section| %>
<li> <li>
<h2 class="app-task-list__section"> <h2 class="app-task-list__section">
<span class="app-task-list__section-number"> <span class="app-task-list__section-number">
<%= section_value["label"] %> <%= section.label %>
</span> </span>
</h2> </h2>
<ul class="app-task-list__items"> <ul class="app-task-list__items">
<% section_value["subsections"].map do |subsection_key, subsection_value| %> <% section.subsections.map do |subsection| %>
<li class="app-task-list__item" id=<%= subsection_key %>> <li class="app-task-list__item" id=<%= subsection.id %>>
<% questions_for_subsection = @form.questions_for_subsection(subsection_key) %> <% subsection_status = subsection.status(@case_log) %>
<% subsection_status = @form.subsection_status(subsection_key, @case_log) %> <%= subsection_link(subsection, @case_log) %>
<%= subsection_link(subsection_key, subsection_value, subsection_status, @form, @case_log) %>
<strong class="govuk-tag app-task-list__tag <%= TasklistHelper::STYLES[subsection_status] %>"> <strong class="govuk-tag app-task-list__tag <%= TasklistHelper::STYLES[subsection_status] %>">
<%= TasklistHelper::STATUSES[subsection_status] %> <%= TasklistHelper::STATUSES[subsection_status] %>
</strong> </strong>

2
app/views/case_logs/edit.html.erb

@ -8,7 +8,7 @@
<%= @case_log.status.to_s.humanize.downcase %></h2> <%= @case_log.status.to_s.humanize.downcase %></h2>
<p class="govuk-body govuk-!-margin-bottom-7">You've completed <%= get_subsections_count(@form, @case_log, :completed) %> of <%= get_subsections_count(@form, @case_log, :all) %> sections.</p> <p class="govuk-body govuk-!-margin-bottom-7">You've completed <%= get_subsections_count(@form, @case_log, :completed) %> of <%= get_subsections_count(@form, @case_log, :all) %> sections.</p>
<p class="govuk-body govuk-!-margin-bottom-7"> <p class="govuk-body govuk-!-margin-bottom-7">
<% next_incomplete_section=get_next_incomplete_section(@form, @case_log) %> <% next_incomplete_section = get_next_incomplete_section(@form, @case_log).id %>
<a class="govuk-link" href="#<%= next_incomplete_section %>" <a class="govuk-link" href="#<%= next_incomplete_section %>"
data-controller="tasklist" data-controller="tasklist"
data-action="tasklist#addHighlight" data-action="tasklist#addHighlight"

6
app/views/form/_check_answers_table.html.erb

@ -1,11 +1,11 @@
<div class="govuk-summary-list__row"> <div class="govuk-summary-list__row">
<dt class="govuk-summary-list__key"> <dt class="govuk-summary-list__key">
<%= question_info["check_answer_label"].to_s.present? ? question_info["check_answer_label"].to_s : question_info["header"].to_s%> <%= question.check_answer_label.to_s.present? ? question.check_answer_label.to_s : question.header.to_s %>
<dt> <dt>
<dd class="govuk-summary-list__value"> <dd class="govuk-summary-list__value">
<%= form.get_answer_label(@case_log, question_title) %> <%= question.answer_label(@case_log) %>
</dd> </dd>
<dd class="govuk-summary-list__actions"> <dd class="govuk-summary-list__actions">
<%= create_update_answer_link(question_title, question_info, @case_log, form) %> <%= link_to(question.update_answer_link_name(@case_log), "/case_logs/#{@case_log.id}/#{question.page.id}", class: "govuk-link").html_safe %>
</dd> </dd>
</div> </div>

10
app/views/form/_checkbox_question.html.erb

@ -1,12 +1,12 @@
<%= f.govuk_check_boxes_fieldset question_key, <%= f.govuk_check_boxes_fieldset question.id.to_sym,
legend: { text: question["header"].html_safe, size: "l" }, legend: { text: question.header.html_safe, size: "l" },
hint: { text: question["hint_text"] } do %> hint: { text: question.hint_text } do %>
<% question["answer_options"].map do |key, val| %> <% question.answer_options.map do |key, val| %>
<% if key.starts_with?("divider") %> <% if key.starts_with?("divider") %>
<%= f.govuk_check_box_divider %> <%= f.govuk_check_box_divider %>
<% else %> <% else %>
<%= f.govuk_check_box question_key, key, <%= f.govuk_check_box question.id, key,
label: { text: val }, label: { text: val },
checked: @case_log[key] == "Yes", checked: @case_log[key] == "Yes",
**stimulus_html_attributes(question) **stimulus_html_attributes(question)

6
app/views/form/_date_question.html.erb

@ -1,6 +1,6 @@
<%= f.govuk_date_field question_key, <%= f.govuk_date_field question.id.to_sym,
hint: { text: question["hint_text"] }, hint: { text: question.hint_text },
legend: { text: question["header"].html_safe, size: "l"}, legend: { text: question.header.html_safe, size: "l"},
width: 20, width: 20,
**stimulus_html_attributes(question) **stimulus_html_attributes(question)
%> %>

10
app/views/form/_numeric_question.html.erb

@ -1,7 +1,7 @@
<%= f.govuk_number_field question_key, <%= f.govuk_number_field question.id.to_sym,
hint: { text: question["hint_text"] }, hint: { text: question.hint_text },
label: { text: question["header"].html_safe, size: "l"}, label: { text: question.header.html_safe, size: "l"},
min: question["min"], max: question["max"], step: question["step"], min: question.min, max: question.max, step: question.step,
width: 20, :readonly => question["readonly"], width: 20, :readonly => question.read_only?,
**stimulus_html_attributes(question) **stimulus_html_attributes(question)
%> %>

12
app/views/form/_radio_question.html.erb

@ -1,13 +1,13 @@
<%= f.govuk_radio_buttons_fieldset question_key, <%= f.govuk_radio_buttons_fieldset question.id.to_sym,
legend: { text: question["header"].html_safe, size: "l" }, legend: { text: question.header.html_safe, size: "l" },
hint: { text: question["hint_text"] }, hint: { text: question.hint_text },
small: (question["answer_options"].size > 5) do %> small: (question.answer_options.size > 5) do %>
<% question["answer_options"].map do |key, val| %> <% question.answer_options.map do |key, val| %>
<% if key.starts_with?("divider") %> <% if key.starts_with?("divider") %>
<%= f.govuk_radio_divider %> <%= f.govuk_radio_divider %>
<% else %> <% else %>
<%= f.govuk_radio_button question_key, val, label: { text: val }, **stimulus_html_attributes(question) %> <%= f.govuk_radio_button question.id, val, label: { text: val }, **stimulus_html_attributes(question) %>
<% end %> <% end %>
<% end %> <% end %>
<% end %> <% end %>

11
app/views/form/_select_question.html.erb

@ -1,11 +1,8 @@
<%= answers = question.answer_options.map { |key, value| OpenStruct.new(id:key, name: value) }
<%= answers = question["answer_options"].map {|key, value| OpenStruct.new(id:key, name: value)} f.govuk_collection_select question.id.to_sym,
f.govuk_collection_select question_key,
answers, answers,
:name, :name,
:name, :name,
label: { text: question["header"]}, label: { text: question.header},
hint: { text: question["hint_text"] } hint: { text: question.hint_text }
%> %>

6
app/views/form/_text_question.html.erb

@ -1,6 +1,6 @@
<%= f.govuk_text_field question_key, <%= f.govuk_text_field question.id.to_sym,
hint: { text: question["hint_text"] }, hint: { text: question.hint_text },
label: { text: question["header"].html_safe, size: "l"}, label: { text: question.header.html_safe, size: "l"},
width: 20, width: 20,
**stimulus_html_attributes(question) **stimulus_html_attributes(question)
%> %>

6
app/views/form/_validation_override_question.html.erb

@ -3,13 +3,13 @@
data-soft-validations-target="override" data-soft-validations-target="override"
style='display:none;'> style='display:none;'>
<%= f.govuk_check_boxes_fieldset page_info["soft_validations"]&.keys&.first, <%= f.govuk_check_boxes_fieldset page.soft_validations&.first&.id.to_sym,
legend: { text: "soft-validations-placeholder-message", size: "l" }, legend: { text: "soft-validations-placeholder-message", size: "l" },
hint: { text: "soft-validations-placeholder-hint-text" } do %> hint: { text: "soft-validations-placeholder-hint-text" } do %>
<%= f.govuk_check_box page_info["soft_validations"]&.keys&.first, page_info["soft_validations"]&.keys&.first, <%= f.govuk_check_box page.soft_validations&.first&.id, page.soft_validations&.first&.id,
label: { text: "Yes" }, label: { text: "Yes" },
checked: @case_log[page_info["soft_validations"]&.keys&.first] == "Yes" checked: @case_log[page.soft_validations&.first&.id] == "Yes"
%> %>
<% end %> <% end %>
</div> </div>

8
app/views/form/check_answers.html.erb

@ -1,11 +1,11 @@
<%= turbo_frame_tag "case_log_form", target: "_top" do %> <%= turbo_frame_tag "case_log_form", target: "_top" do %>
<div class="govuk-grid-row"> <div class="govuk-grid-row">
<div class="govuk-grid-column-three-quarters-from-desktop"> <div class="govuk-grid-column-three-quarters-from-desktop">
<h1 class="govuk-heading-l">Check the answers you gave for <%= subsection.humanize(capitalize: false) %></h1> <h1 class="govuk-heading-l">Check the answers you gave for <%= subsection.id.humanize(capitalize: false) %></h1>
<%= display_answered_questions_summary(subsection, @case_log, form) %> <%= display_answered_questions_summary(subsection, @case_log) %>
<dl class="govuk-summary-list govuk-!-margin-bottom-9"> <dl class="govuk-summary-list govuk-!-margin-bottom-9">
<% total_questions(subsection, @case_log, form).each do |question_title, question_info| %> <% subsection.applicable_questions(@case_log).each do |question| %>
<%= render partial: 'form/check_answers_table', locals: { question_title: question_title, question_info: question_info, case_log: @case_log, form: form } %> <%= render partial: 'form/check_answers_table', locals: { question: question, case_log: @case_log } %>
<% end %> <% end %>
</dl> </dl>
<%= form_with model: @case_log, method: "get", builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %> <%= form_with model: @case_log, method: "get", builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %>

16
app/views/form/page.html.erb

@ -5,24 +5,24 @@
<%= turbo_frame_tag "case_log_form", target: "_top" do %> <%= turbo_frame_tag "case_log_form", target: "_top" do %>
<div class="govuk-grid-row"> <div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds-from-desktop"> <div class="govuk-grid-column-two-thirds-from-desktop">
<% if page_info["header"].present? %> <% if page.header.present? %>
<h1 class="govuk-heading-xl"> <h1 class="govuk-heading-xl">
<%= page_info["header"] %> <%= page.header %>
</h1> </h1>
<% end %> <% end %>
<%= form_with model: @case_log, url: form_case_log_path(@case_log), method: "post", builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %> <%= form_with model: @case_log, url: form_case_log_path(@case_log), method: "post", builder: GOVUKDesignSystemFormBuilder::FormBuilder do |f| %>
<%= f.govuk_error_summary %> <%= f.govuk_error_summary %>
<% page_info["questions"].map do |question_key, question| %> <% page.questions.map do |question| %>
<div id=<%= question_key + "_div " %><%= display_question_key_div(page_info, question_key) %> > <div id=<%= question.id + "_div " %><%= display_question_key_div(page, question) %> >
<%= render partial: "form/#{question["type"]}_question", locals: { question_key: question_key.to_sym, question: question, f: f } %> <%= render partial: "form/#{question.type}_question", locals: { question: question, f: f } %>
</div> </div>
<% end %> <% end %>
<% if page_info["soft_validations"]&.keys&.first %> <% if page.has_soft_validations? %>
<%= render partial: "form/validation_override_question", locals: { f: f, page_key: page_key, page_info: page_info } %> <%= render partial: "form/validation_override_question", locals: { f: f, page: page } %>
<% end %> <% end %>
<%= f.hidden_field :page, value: page_key %> <%= f.hidden_field :page, value: page.id %>
<%= f.govuk_submit "Save and continue" %> <%= f.govuk_submit "Save and continue" %>
<% end %> <% end %>
</div> </div>

10
config/routes.rb

@ -23,13 +23,13 @@ Rails.application.routes.draw do
post "/form", to: "case_logs#submit_form" post "/form", to: "case_logs#submit_form"
end end
form.all_pages.keys.map do |page| form.pages.map do |page|
get page.to_s, to: "case_logs##{page}" get page.id.to_s, to: "case_logs##{page.id}"
get "#{page}/soft_validations", to: "soft_validations#show" if form.soft_validations_for_page(page) get "#{page.id}/soft_validations", to: "soft_validations#show" if page.has_soft_validations?
end end
form.all_subsections.keys.map do |subsection| form.subsections.map do |subsection|
get "#{subsection}/check_answers", to: "case_logs#check_answers" get "#{subsection.id}/check_answers", to: "case_logs#check_answers"
end end
end end
end end

10
docs/adr/adr-011-form-oop-refactor.md

@ -0,0 +1,10 @@
### ADR - 011: Splitting the form parsing into objects
Initially a single "Form" class handled the parsing of the form definition JSON as well as a lot of the logic around what different sections meant. This works fine but led to a lot of places in code where we're passing around arguments to determine whether a page or section should or shouldn't do something rather than being able to ask it directly. Refactoring this into smaller form domain object classes has several benefits:
- It's easier to compare the form definition JSON to the code classes and reason about what fields can be passed and what effect they'll have
- It moves business logic out of the helpers and keeps them to just dealing with display logic
- It makes it easier to unit test form functionality, and group that into smaller chunks
- It allows for less passing of arguments. e.g. `page.routed_to?(case_log)` vs `form.was_page_routed_to?(page, case_log)`
This abstraction is likely still not the best (the form vs case log split) but this seems like an improvement that can be iterated on.

39
spec/controllers/case_logs_controller_spec.rb

@ -93,28 +93,29 @@ RSpec.describe CaseLogsController, type: :controller do
page: "accessibility_requirements" } page: "accessibility_requirements" }
end end
let(:questions_for_page) do let(:questions_for_page) do
{ "accessibility_requirements" => [
{ Form::Question.new(
"type" => "checkbox", "accessibility_requirements",
"answer_options" => {
{ "housingneeds_a" => "Fully wheelchair accessible housing", "type" => "checkbox",
"housingneeds_b" => "Wheelchair access to essential rooms", "answer_options" =>
"housingneeds_c" => "Level access housing", { "housingneeds_a" => "Fully wheelchair accessible housing",
"housingneeds_f" => "Other disability requirements", "housingneeds_b" => "Wheelchair access to essential rooms",
"housingneeds_g" => "No disability requirements", "housingneeds_c" => "Level access housing",
"divider_a" => true, "housingneeds_f" => "Other disability requirements",
"housingneeds_h" => "Do not know", "housingneeds_g" => "No disability requirements",
"divider_b" => true, "divider_a" => true,
"accessibility_requirements_prefer_not_to_say" => "Prefer not to say" }, "housingneeds_h" => "Do not know",
}, "divider_b" => true,
"tenant_code" => "accessibility_requirements_prefer_not_to_say" => "Prefer not to say" },
{ }, nil
"type" => "text", ),
} } Form::Question.new("tenant_code", { "type" => "text" }, nil),
]
end end
it "updates both question fields" do it "updates both question fields" do
allow_any_instance_of(Form).to receive(:questions_for_page).and_return(questions_for_page) allow_any_instance_of(Form::Page).to receive(:expected_responses).and_return(questions_for_page)
post :submit_form, params: { id: id, case_log: case_log_form_params } post :submit_form, params: { id: id, case_log: case_log_form_params }
case_log.reload case_log.reload

2
spec/features/case_log_spec.rb

@ -29,7 +29,7 @@ RSpec.describe "Form Features" do
click_button("Save and continue") click_button("Save and continue")
choose("case-log-benefits-all-field") choose("case-log-benefits-all-field")
click_button("Save and continue") click_button("Save and continue")
choose("case-log-hb-housing-benefit-but-not-universal-credit-field") choose("case-log-hb-prefer-not-to-say-field")
click_button("Save and continue") click_button("Save and continue")
end end

30
spec/fixtures/forms/test_form.json vendored

@ -303,13 +303,15 @@
"label": "Income and benefits", "label": "Income and benefits",
"pages": { "pages": {
"net_income": { "net_income": {
"header": "Test header",
"description": "Some extra text for the page",
"questions": { "questions": {
"earnings": { "earnings": {
"check_answer_label": "Income", "check_answer_label": "Income",
"header": "What is the tenant’s /and partner’s combined income after tax?", "header": "What is the tenant’s /and partner’s combined income after tax?",
"type": "numeric", "type": "numeric",
"min": 0, "min": 0,
"step": "1" "step": 1
}, },
"incfreq": { "incfreq": {
"check_answer_label": "Income Frequency", "check_answer_label": "Income Frequency",
@ -354,6 +356,32 @@
"answer_options": { "answer_options": {
"0": "Housing Benefit, but not Universal Credit", "0": "Housing Benefit, but not Universal Credit",
"1": "Prefer not to say" "1": "Prefer not to say"
},
"conditional_for": {
"conditional_question": ["Housing Benefit, but not Universal Credit"]
}
},
"conditional_question": {
"check_answer_label": "Conditional Question",
"header": "Question to test page conditions",
"type": "radio",
"answer_options": {
"0": "Option A",
"1": "Option B"
}
}
}
},
"dependent_page": {
"depends_on": { "incfreq": "Weekly" },
"questions": {
"dependent_question": {
"check_answer_label": "Dependent Question",
"header": "Question to test page routing",
"type": "checkbox",
"answer_options": {
"0": "Option A",
"1": "Option B"
} }
} }
} }

185
spec/helpers/check_answers_helper_spec.rb

@ -1,172 +1,29 @@
require "rails_helper" require "rails_helper"
RSpec.describe CheckAnswersHelper do RSpec.describe CheckAnswersHelper do
let(:case_log) { FactoryBot.create(:case_log) }
let(:case_log_with_met_numeric_condition) do
FactoryBot.create(
:case_log,
:in_progress,
other_hhmemb: 1,
relat2: "Partner",
)
end
let(:case_log_with_met_radio_condition) do
FactoryBot.create(:case_log, armedforces: "A current or former regular in the UK Armed Forces (exc. National Service)",
reservist: "No",
leftreg: "Yes")
end
let(:subsection) { "income_and_benefits" }
let(:subsection_with_numeric_conditionals) { "household_characteristics" }
let(:subsection_with_radio_conditionals) { "household_needs" }
let(:conditional_routing_subsection) { "conditional_question" }
let(:conditional_page_subsection) { "household_needs" }
form_handler = FormHandler.instance form_handler = FormHandler.instance
let(:form) { form_handler.get_form("test_form") } let(:form) { form_handler.get_form("test_form") }
let(:subsection) { form.get_subsection("household_characteristics") }
describe "Get answered questions total" do let(:case_log) { FactoryBot.build(:case_log, :in_progress) }
it "returns 0 if no questions are answered" do
expect(total_answered_questions(subsection, case_log, form)).to equal(0) describe "display_answered_questions_summary" do
end context "given a section that hasn't been completed yet" do
it "returns a link to the next unanswered question" do
it "returns 1 if 1 question gets answered" do expect(display_answered_questions_summary(subsection, case_log))
case_log["earnings"] = "123" .to match(/You answered 2 of 4 questions/)
expect(total_answered_questions(subsection, case_log, form)).to equal(1) expect(display_answered_questions_summary(subsection, case_log))
end .to match(/href/)
end
it "ignores questions with unmet numeric conditions" do end
case_log["tenant_code"] = "T1234"
expect(total_answered_questions(subsection_with_numeric_conditionals, case_log, form)).to equal(1) context "given a section that has been completed" do
end it "returns that you have answered all the questions" do
case_log.sex1 = "F"
it "includes conditional questions with met numeric conditions" do case_log.other_hhmemb = 0
expect(total_answered_questions( expect(display_answered_questions_summary(subsection, case_log))
subsection_with_numeric_conditionals, .to match(/You answered all the questions/)
case_log_with_met_numeric_condition, expect(display_answered_questions_summary(subsection, case_log))
form, .not_to match(/href/)
)).to equal(4)
end
it "ignores questions with unmet radio conditions" do
case_log["armedforces"] = "No"
expect(total_answered_questions(subsection_with_radio_conditionals, case_log, form)).to equal(1)
end
it "includes conditional questions with met radio conditions" do
case_log_with_met_radio_condition["reservist"] = "No"
case_log_with_met_radio_condition["illness"] = "No"
expect(total_answered_questions(
subsection_with_radio_conditionals,
case_log_with_met_radio_condition,
form,
)).to equal(4)
end
end
describe "Get total number of questions" do
it "returns the total number of questions for a subsection" do
expect(total_number_of_questions(subsection, case_log, form)).to eq(4)
end
it "ignores questions with unmet numeric conditions" do
expect(total_number_of_questions(subsection_with_numeric_conditionals, case_log, form)).to eq(4)
end
it "includes conditional questions with met numeric conditions" do
expect(total_number_of_questions(
subsection_with_numeric_conditionals,
case_log_with_met_numeric_condition,
form,
)).to eq(8)
end
it "ignores questions with unmet radio conditions" do
expect(total_number_of_questions(subsection_with_radio_conditionals, case_log, form)).to eq(4)
end
it "includes conditional questions with met radio conditions" do
expect(total_number_of_questions(
subsection_with_radio_conditionals,
case_log_with_met_radio_condition,
form,
)).to eq(6)
end
context "conditional questions with type that hasn't been implemented yet" do
let(:unimplemented_conditional) do
{ "previous_postcode" =>
{ "header" => "The actual question?",
"hint_text" => "",
"type" => "date",
"check_answer_label" => "Question Label",
"conditional_for" => { "question_2" => %w[12-12-2021] } } }
end
it "raises an error" do
allow_any_instance_of(Form).to receive(:questions_for_page).and_return(unimplemented_conditional)
expect { total_number_of_questions(subsection, case_log, form) }.to raise_error(RuntimeError, "Not implemented yet")
end
end
context "conditional routing" do
it "ignores not visited questions when no questions are answered" do
expect(total_number_of_questions(conditional_routing_subsection, case_log, form)).to eq(1)
end
it "counts correct questions when the conditional question is answered" do
case_log["preg_occ"] = "Yes"
expect(total_number_of_questions(conditional_routing_subsection, case_log, form)).to eq(2)
end
it "counts correct questions when the conditional question is answered" do
case_log["preg_occ"] = "No"
case_log["sex1"] = "Male"
expect(total_number_of_questions(conditional_routing_subsection, case_log, form)).to eq(3)
end
end
context "total questions" do
it "returns total questions" do
result = total_questions(subsection, case_log, form)
expected_keys = %w[earnings incfreq benefits hb]
expect(result.keys).to eq(expected_keys)
end
context "conditional questions on the same page" do
it "it filters out conditional questions that were not displayed" do
result = total_questions(conditional_page_subsection, case_log, form)
expected_keys = %w[armedforces illness accessibility_requirements condition_effects]
expect(result.keys).to eq(expected_keys)
end
it "it includes conditional questions that were displayed" do
case_log["armedforces"] = "A current or former regular in the UK Armed Forces (exc. National Service)"
result = total_questions(conditional_page_subsection, case_log, form)
expected_keys = %w[armedforces leftreg reservist illness accessibility_requirements condition_effects]
expect(result.keys).to eq(expected_keys)
end
end
context "conditional routing" do
it "it ignores skipped pages and the questions therein when conditional routing" do
result = total_questions(conditional_routing_subsection, case_log, form)
expected_keys = %w[preg_occ]
expect(result.keys).to match_array(expected_keys)
end
it "it includes conditional pages and questions that were displayed" do
case_log["preg_occ"] = "Yes"
case_log["sex1"] = "Female"
result = total_questions(conditional_routing_subsection, case_log, form)
expected_keys = %w[preg_occ cbl]
expect(result.keys).to match_array(expected_keys)
end
it "it includes conditional pages and questions that were displayed" do
case_log["preg_occ"] = "No"
result = total_questions(conditional_routing_subsection, case_log, form)
expected_keys = %w[preg_occ conditional_question_no_question]
expect(result.keys).to match_array(expected_keys)
end
end end
end end
end end

12
spec/helpers/conditional_questions_helper_spec.rb

@ -3,8 +3,7 @@ require "rails_helper"
RSpec.describe ConditionalQuestionsHelper do RSpec.describe ConditionalQuestionsHelper do
form_handler = FormHandler.instance form_handler = FormHandler.instance
let(:form) { form_handler.get_form("test_form") } let(:form) { form_handler.get_form("test_form") }
let(:page_key) { "armed_forces" } let(:page) { form.get_page("armed_forces") }
let(:page) { form.all_pages[page_key] }
describe "conditional questions for page" do describe "conditional questions for page" do
let(:conditional_pages) { %w[leftreg reservist] } let(:conditional_pages) { %w[leftreg reservist] }
@ -15,15 +14,14 @@ RSpec.describe ConditionalQuestionsHelper do
end end
describe "display question key div" do describe "display question key div" do
let(:question_key) { "armed_forces" } let(:conditional_question) { page.questions.find { |q| q.id == "reservist" } }
let(:conditional_question_key) { "reservist" }
it "returns a non visible div for conditional questions" do it "returns a non visible div for conditional questions" do
expect(display_question_key_div(page, conditional_question_key)).to match("style='display:none;'") expect(display_question_key_div(page, conditional_question)).to match("style='display:none;'")
end end
it "returns a visible div for conditional questions" do it "returns a visible div for questions" do
expect(display_question_key_div(page, question_key)).not_to match("style='display:none;'") expect(display_question_key_div(page, page.questions.first)).not_to match("style='display:none;'")
end end
end end
end end

22
spec/helpers/question_attribute_helper_spec.rb

@ -3,25 +3,27 @@ require "rails_helper"
RSpec.describe QuestionAttributeHelper do RSpec.describe QuestionAttributeHelper do
form_handler = FormHandler.instance form_handler = FormHandler.instance
let(:form) { form_handler.get_form("test_form") } let(:form) { form_handler.get_form("test_form") }
let(:questions) { form.questions_for_page("rent") } let(:questions) { form.get_page("rent").questions }
describe "html attributes" do describe "html attributes" do
it "returns empty hash if fields-to-add or result-field are empty " do it "returns empty hash if fields-to-add or result-field are empty " do
expect(stimulus_html_attributes(questions["tcharge"])).to eq({}) question = questions.find { |q| q.id == "tcharge" }
expect(stimulus_html_attributes(question)).to eq({})
end end
it "returns html attributes if fields-to-add or result-field are not empty " do it "returns html attributes if fields-to-add or result-field are not empty " do
expect(stimulus_html_attributes(questions["brent"])).to eq({ brent = questions.find { |q| q.id == "brent" }
expect(stimulus_html_attributes(brent)).to eq({
"data-controller": "numeric-question", "data-controller": "numeric-question",
"data-action": "numeric-question#calculateFields", "data-action": "numeric-question#calculateFields",
"data-target": "case-log-#{questions['brent']['result-field'].to_s.dasherize}-field", "data-target": "case-log-#{brent.result_field.to_s.dasherize}-field",
"data-calculated": questions["brent"]["fields-to-add"].to_json, "data-calculated": brent.fields_to_add.to_json,
}) })
end end
context "a question that requires multiple controllers" do context "a question that requires multiple controllers" do
let(:question) do let(:question) do
{ Form::Question.new("brent", {
"check_answer_label" => "Basic Rent", "check_answer_label" => "Basic Rent",
"header" => "What is the basic rent?", "header" => "What is the basic rent?",
"hint_text" => "Eligible for housing benefit or Universal Credit", "hint_text" => "Eligible for housing benefit or Universal Credit",
@ -33,15 +35,15 @@ RSpec.describe QuestionAttributeHelper do
"conditional_for" => { "conditional_for" => {
"next_question": ">1", "next_question": ">1",
}, },
} }, nil)
end end
let(:expected_attribs) do let(:expected_attribs) do
{ {
"data-controller": "numeric-question conditional-question", "data-controller": "numeric-question conditional-question",
"data-action": "numeric-question#calculateFields conditional-question#displayConditional", "data-action": "numeric-question#calculateFields conditional-question#displayConditional",
"data-target": "case-log-#{question['result-field'].to_s.dasherize}-field", "data-target": "case-log-#{question.result_field.to_s.dasherize}-field",
"data-calculated": question["fields-to-add"].to_json, "data-calculated": question.fields_to_add.to_json,
"data-info": question["conditional_for"].to_json, "data-info": question.conditional_for.to_json,
} }
end end
it "correctly merges html attributes" do it "correctly merges html attributes" do

30
spec/helpers/tasklist_helper_spec.rb

@ -8,12 +8,12 @@ RSpec.describe TasklistHelper do
describe "get next incomplete section" do describe "get next incomplete section" do
it "returns the first subsection name if it is not completed" do it "returns the first subsection name if it is not completed" do
expect(get_next_incomplete_section(form, case_log)).to eq("household_characteristics") expect(get_next_incomplete_section(form, case_log).id).to eq("household_characteristics")
end end
it "returns the first subsection name if it is partially completed" do it "returns the first subsection name if it is partially completed" do
case_log["tenant_code"] = 123 case_log["tenant_code"] = 123
expect(get_next_incomplete_section(form, case_log)).to eq("household_characteristics") expect(get_next_incomplete_section(form, case_log).id).to eq("household_characteristics")
end end
end end
@ -40,12 +40,34 @@ RSpec.describe TasklistHelper do
end end
describe "get_first_page_or_check_answers" do describe "get_first_page_or_check_answers" do
let(:subsection) { form.get_subsection("household_characteristics") }
it "returns the check answers page path if the section has been started already" do it "returns the check answers page path if the section has been started already" do
expect(get_first_page_or_check_answers("household_characteristics", case_log, form)).to match(/check_answers/) expect(first_page_or_check_answers(subsection, case_log)).to match(/check_answers/)
end end
it "returns the first question page path for the section if it has not been started yet" do it "returns the first question page path for the section if it has not been started yet" do
expect(get_first_page_or_check_answers("household_characteristics", empty_case_log, form)).to match(/tenant_code/) expect(first_page_or_check_answers(subsection, empty_case_log)).to match(/tenant_code/)
end
end
describe "subsection link" do
let(:subsection) { form.get_subsection("household_characteristics") }
context "for a subsection that's enabled" do
it "returns the subsection link url" do
expect(subsection_link(subsection, case_log)).to match(/household_characteristics/)
end
end
context "for a subsection that cannot be started yet" do
before do
allow(subsection).to receive(:status).with(case_log).and_return(:cannot_start_yet)
end
it "returns a # link" do
expect(subsection_link(subsection, case_log)).to match(/#/)
end
end end
end end
end end

66
spec/models/form/page_spec.rb

@ -0,0 +1,66 @@
require "rails_helper"
RSpec.describe Form::Page, type: :model do
let(:form) { FormHandler.instance.get_form("test_form") }
let(:section_id) { "rent_and_charges" }
let(:section_definition) { form.form_definition["sections"][section_id] }
let(:section) { Form::Section.new(section_id, section_definition, form) }
let(:subsection_id) { "income_and_benefits" }
let(:subsection_definition) { section_definition["subsections"][subsection_id] }
let(:subsection) { Form::Subsection.new(subsection_id, subsection_definition, section) }
let(:page_id) { "net_income" }
let(:page_definition) { subsection_definition["pages"][page_id] }
subject { Form::Page.new(page_id, page_definition, subsection) }
it "has an id" do
expect(subject.id).to eq(page_id)
end
it "has a header" do
expect(subject.header).to eq("Test header")
end
it "has a description" do
expect(subject.description).to eq("Some extra text for the page")
end
it "has questions" do
expected_questions = %w[earnings incfreq]
expect(subject.questions.map(&:id)).to eq(expected_questions)
end
it "has soft validations" do
expected_soft_validations = %w[override_net_income_validation]
expect(subject.soft_validations.map(&:id)).to eq(expected_soft_validations)
end
it "has a soft_validation helper" do
expect(subject.has_soft_validations?).to be true
end
it "has expected form responses" do
expected_responses = %w[earnings incfreq override_net_income_validation]
expect(subject.expected_responses.map(&:id)).to eq(expected_responses)
end
context "for a given case log" do
let(:case_log) { FactoryBot.build(:case_log, :in_progress) }
it "knows if it's been routed to" do
expect(subject.routed_to?(case_log)).to be true
end
context "given routing conditions" do
let(:page_id) { "dependent_page" }
it "evaluates not met conditions correctly" do
expect(subject.routed_to?(case_log)).to be false
end
it "evaluates not conditions correctly" do
case_log.incfreq = "Weekly"
expect(subject.routed_to?(case_log)).to be true
end
end
end
end

140
spec/models/form/question_spec.rb

@ -0,0 +1,140 @@
require "rails_helper"
RSpec.describe Form::Question, type: :model do
let(:form) { FormHandler.instance.get_form("test_form") }
let(:section_id) { "rent_and_charges" }
let(:section_definition) { form.form_definition["sections"][section_id] }
let(:section) { Form::Section.new(section_id, section_definition, form) }
let(:subsection_id) { "income_and_benefits" }
let(:subsection_definition) { section_definition["subsections"][subsection_id] }
let(:subsection) { Form::Subsection.new(subsection_id, subsection_definition, section) }
let(:page_id) { "net_income" }
let(:page_definition) { subsection_definition["pages"][page_id] }
let(:page) { Form::Page.new(page_id, page_definition, subsection) }
let(:question_id) { "earnings" }
let(:question_definition) { page_definition["questions"][question_id] }
subject { Form::Question.new(question_id, question_definition, page) }
it "has an id" do
expect(subject.id).to eq(question_id)
end
it "has a header" do
expect(subject.header).to eq("What is the tenant’s /and partner’s combined income after tax?")
end
it "has a check answers label" do
expect(subject.check_answer_label).to eq("Income")
end
it "has a question type" do
expect(subject.type).to eq("numeric")
end
it "belongs to a page" do
expect(subject.page).to eq(page)
end
it "belongs to a subsection" do
expect(subject.subsection).to eq(subsection)
end
it "has a read only helper" do
expect(subject.read_only?).to be false
end
context "when type is numeric" do
it "has a min value" do
expect(subject.min).to eq(0)
end
it "has a step value" do
expect(subject.step).to eq(1)
end
end
context "when type is radio" do
let(:question_id) { "incfreq" }
it "has answer options" do
expected_answer_options = { "0" => "Weekly", "1" => "Monthly", "2" => "Yearly" }
expect(subject.answer_options).to eq(expected_answer_options)
end
end
context "when type is checkbox" do
let(:page_id) { "dependent_page" }
let(:question_id) { "dependent_question" }
it "has answer options" do
expected_answer_options = { "0" => "Option A", "1" => "Option B" }
expect(subject.answer_options).to eq(expected_answer_options)
end
end
context "when the question is read only" do
let(:subsection_id) { "rent" }
let(:page_id) { "rent" }
let(:question_id) { "tcharge" }
it "has a read only helper" do
expect(subject.read_only?).to be true
end
context "when the answer is part of a sum" do
let(:question_id) { "pscharge" }
it "has a result_field" do
expect(subject.result_field).to eq("tcharge")
end
it "has fields to sum" do
expected_fields_to_sum = %w[brent scharge pscharge supcharg]
expect(subject.fields_to_add).to eq(expected_fields_to_sum)
end
end
end
context "for a given case log" do
let(:case_log) { FactoryBot.build(:case_log, :in_progress) }
it "has an answer label" do
case_log.earnings = 100
expect(subject.answer_label(case_log)).to eq("100")
end
it "has an update answer link text helper" do
expect(subject.update_answer_link_name(case_log)).to eq("Answer")
case_log[question_id] = 5
expect(subject.update_answer_link_name(case_log)).to eq("Change")
end
context "when type is checkbox" do
let(:section_id) { "household" }
let(:subsection_id) { "household_needs" }
let(:page_id) { "accessibility_requirements" }
let(:question_id) { "accessibility_requirements" }
it "has a joined answers label" do
case_log.housingneeds_a = 1
case_log.housingneeds_c = 1
expected_answer_label = "Fully wheelchair accessible housing, Level access housing"
expect(subject.answer_label(case_log)).to eq(expected_answer_label)
end
end
context "when a condition is present" do
let(:page_id) { "housing_benefit" }
let(:question_id) { "conditional_question" }
it "knows whether it is enabled or not for unmet conditions" do
expect(subject.enabled?(case_log)).to be false
end
it "knows whether it is enabled or not for met conditions" do
case_log.hb = "Housing Benefit, but not Universal Credit"
expect(subject.enabled?(case_log)).to be true
end
end
end
end

21
spec/models/form/section_spec.rb

@ -0,0 +1,21 @@
require "rails_helper"
RSpec.describe Form::Section, type: :model do
let(:form) { FormHandler.instance.get_form("test_form") }
let(:section_id) { "household" }
let(:section_definition) { form.form_definition["sections"][section_id] }
subject { Form::Section.new(section_id, section_definition, form) }
it "has an id" do
expect(subject.id).to eq(section_id)
end
it "has a label" do
expect(subject.label).to eq("About the household")
end
it "has subsections" do
expected_subsections = %w[household_characteristics household_needs]
expect(subject.subsections.map(&:id)).to eq(expected_subsections)
end
end

72
spec/models/form/subsection_spec.rb

@ -0,0 +1,72 @@
require "rails_helper"
RSpec.describe Form::Subsection, type: :model do
let(:form) { FormHandler.instance.get_form("test_form") }
let(:section_id) { "household" }
let(:section_definition) { form.form_definition["sections"][section_id] }
let(:section) { Form::Section.new(section_id, section_definition, form) }
let(:subsection_id) { "household_characteristics" }
let(:subsection_definition) { section_definition["subsections"][subsection_id] }
subject { Form::Subsection.new(subsection_id, subsection_definition, section) }
it "has an id" do
expect(subject.id).to eq(subsection_id)
end
it "has a label" do
expect(subject.label).to eq("Household characteristics")
end
it "has pages" do
expected_pages = %w[tenant_code person_1_age person_1_gender household_number_of_other_members]
expect(subject.pages.map(&:id)).to eq(expected_pages)
end
it "has questions" do
expected_questions = %w[tenant_code age1 sex1 other_hhmemb relat2 age2 sex2 ecstat2]
expect(subject.questions.map(&:id)).to eq(expected_questions)
end
context "for a given in progress case log" do
let(:case_log) { FactoryBot.build(:case_log, :in_progress) }
it "has a status" do
expect(subject.status(case_log)).to eq(:in_progress)
end
it "has status helpers" do
expect(subject.is_incomplete?(case_log)).to be(true)
expect(subject.is_started?(case_log)).to be(true)
end
it "has question helpers for the number of applicable questions" do
expected_questions = %w[tenant_code age1 sex1 other_hhmemb]
expect(subject.applicable_questions(case_log).map(&:id)).to eq(expected_questions)
expect(subject.applicable_questions_count(case_log)).to eq(4)
end
it "has question helpers for the number of answered questions" do
expected_questions = %w[tenant_code age1]
expect(subject.answered_questions(case_log).map(&:id)).to eq(expected_questions)
expect(subject.answered_questions_count(case_log)).to eq(2)
end
it "has a question helpers for the unanswered questions" do
expected_questions = %w[sex1 other_hhmemb]
expect(subject.unanswered_questions(case_log).map(&:id)).to eq(expected_questions)
end
end
context "for a given completed case log" do
let(:case_log) { FactoryBot.build(:case_log, :completed) }
it "has a status" do
expect(subject.status(case_log)).to eq(:completed)
end
it "has status helpers" do
expect(subject.is_incomplete?(case_log)).to be(false)
expect(subject.is_started?(case_log)).to be(true)
end
end
end

2
spec/models/form_handler_spec.rb

@ -15,7 +15,7 @@ RSpec.describe FormHandler do
form_handler = FormHandler.instance form_handler = FormHandler.instance
form = form_handler.get_form("test_form") form = form_handler.get_form("test_form")
expect(form).to be_a(Form) expect(form).to be_a(Form)
expect(form.all_pages.count).to eq(23) expect(form.pages.count).to eq(24)
end end
end end

64
spec/models/form_spec.rb

@ -8,72 +8,16 @@ RSpec.describe Form, type: :model do
let(:conditional_section_complete_case_log) { FactoryBot.build(:case_log, :conditional_section_complete) } let(:conditional_section_complete_case_log) { FactoryBot.build(:case_log, :conditional_section_complete) }
describe ".next_page" do describe ".next_page" do
let(:previous_page) { "person_1_age" } let(:previous_page) { form.get_page("person_1_age") }
it "returns the next page given the previous" do it "returns the next page given the previous" do
expect(form.next_page(previous_page, case_log)).to eq("person_1_gender") expect(form.next_page(previous_page, case_log)).to eq("person_1_gender")
end end
end end
describe "get subsection status" do
let(:section) { "income_and_benefits" }
it "returns not started if none of the questions in the subsection are answered" do
status = form.subsection_status("income_and_benefits", case_log)
expect(status).to eq(:not_started)
end
it "returns cannot start yet if the subsection is declaration" do
status = form.subsection_status("declaration", case_log)
expect(status).to eq(:cannot_start_yet)
end
it "returns in progress if some of the questions have been answered" do
case_log["previous_postcode"] = "P0 5TT"
status = form.subsection_status("local_authority", case_log)
expect(status).to eq(:in_progress)
end
it "returns completed if all the questions in the subsection have been answered" do
case_log["earnings"] = "value"
case_log["incfreq"] = "Weekly"
case_log["benefits"] = "All"
case_log["hb"] = "Do not know"
status = form.subsection_status("income_and_benefits", case_log)
expect(status).to eq(:completed)
end
it "returns not started if the subsection is declaration and all the questions are completed" do
status = form.subsection_status("declaration", completed_case_log)
expect(status).to eq(:not_started)
end
it "sets the correct status for sections with conditional questions" do
status = form.subsection_status("household_characteristics", conditional_section_complete_case_log)
expect(status).to eq(:completed)
end
end
describe ".first_page_for_subsection" do
let(:subsection) { "household_characteristics" }
it "returns the first page given a subsection" do
expect(form.first_page_for_subsection(subsection)).to eq("tenant_code")
end
end
describe ".questions_for_subsection" do
let(:subsection) { "income_and_benefits" }
it "returns all questions for subsection" do
result = form.questions_for_subsection(subsection)
expect(result.length).to eq(4)
expect(result.keys).to eq(%w[earnings incfreq benefits hb])
end
end
describe "next_page_redirect_path" do describe "next_page_redirect_path" do
let(:previous_page) { "net_income" } let(:previous_page) { form.get_page("net_income") }
let(:last_previous_page) { "housing_benefit" } let(:last_previous_page) { form.get_page("housing_benefit") }
let(:previous_conditional_page) { "conditional_question" } let(:previous_conditional_page) { form.get_page("conditional_question") }
it "returns a correct page path if there is no conditional routing" do it "returns a correct page path if there is no conditional routing" do
expect(form.next_page_redirect_path(previous_page, case_log)).to eq("case_log_net_income_uc_proportion_path") expect(form.next_page_redirect_path(previous_page, case_log)).to eq("case_log_net_income_uc_proportion_path")

Loading…
Cancel
Save