Skip to content

Commit 03ccca2

Browse files
committed
Fix question/answer feature bugs - all tests passing
**Rails 8 params.expect() Issue:** - Changed PeopleController person_params from params.expect() to params.require().permit() (same fix as BillablesController - nested attributes with dynamic keys don't work with expect) - Fixed update_answers to handle ActionController::Parameters correctly (not a Hash) **Dynamic Question Loading:** - Created person_questions_controller.js Stimulus controller for AJAX updates - Added PeopleController#get_questions action for dynamic question fetching - Split questions partial into container (_questions.html.erb) and content (_questions_content.html.erb) - Modified Person model to support pre-calculated questions for AJAX - Connected package select and option checkboxes to trigger dynamic updates - Only update on user interaction, not on initial page load (prevent form conflicts) **Question Removal Fix:** - Changed from rendering _destroy checkbox to creating _destroy field dynamically via JavaScript - Prevents ActiveRecord from seeing questions as 'changed' when they're not - Avoids triggering validation errors on unrelated questions - JavaScript now creates hidden _destroy field only when Remove Question is clicked **Form Field Fixes:** - Fixed textarea label association so Capybara can find fields by label text - Updated questions_controller.js removeQuestion to create field dynamically **Test Improvements:** - Added proper waits for dynamic content loading - Used specific selectors for clicking Remove Question link - Added assertions to verify JavaScript behavior (element hiding, field creation) - All 13 question tests now passing (100% success rate, 39 assertions) **Core Functionality Working:** ✅ Add questions to billable options ✅ Remove questions from options ✅ Questions appear dynamically when options/packages selected ✅ Answer radio button questions ✅ Answer textarea questions ✅ Save answers to database ✅ Summary views and PDF generation
1 parent ab3f7b2 commit 03ccca2

File tree

12 files changed

+219
-62
lines changed

12 files changed

+219
-62
lines changed

app/controllers/people_controller.rb

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,42 @@ def invoice
107107
end
108108
end
109109

110+
def get_questions
111+
person_id = params[:person_id]
112+
package_id = params[:package_id]
113+
option_ids = params[:option_ids] || []
114+
115+
# Create a temporary person object to calculate applicable questions
116+
@person = if person_id.present? && person_id.to_i != 0
117+
Person.find(person_id)
118+
else
119+
Person.new
120+
end
121+
@person.package_id = package_id if package_id.present?
122+
123+
# Calculate applicable questions based on package and selected options
124+
question_ids = Set.new
125+
126+
if package_id.present? && (package = Billable.find_by(id: package_id))
127+
package.package_includes.each do |pi|
128+
question_ids.merge(pi.option.questions.pluck(:id))
129+
end
130+
end
131+
132+
option_ids.each do |option_id|
133+
if option = Billable.find_by(id: option_id, type: 'Option')
134+
question_ids.merge(option.questions.pluck(:id))
135+
end
136+
end
137+
138+
@person.instance_variable_set(:@calculated_questions, Question.where(id: question_ids.to_a).ordered)
139+
140+
# Create a form builder-like object for the partial
141+
@form = ActionView::Helpers::FormBuilder.new(:person, @person, view_context, {})
142+
143+
render partial: 'people/questions_content', locals: { person: @person, form: @form }, layout: false
144+
end
145+
110146
def certificates
111147
if request.get?
112148
@studios = [['-- all studios --', nil]] + Studio.by_name.pluck(:name, :id)
@@ -882,8 +918,9 @@ def set_person
882918

883919
# Only allow a list of trusted parameters through.
884920
def person_params
885-
params.expect(person: [:name, :studio_id, :type, :back, :level_id, :age_id, :category, :role, :exclude_id, :package_id, :independent, :invoice_to_id, :available, :table_id, { options: {} },
886-
{ answers_attributes: [:id, :question_id, :answer_value] }])
921+
params.require(:person).permit(:name, :studio_id, :type, :back, :level_id, :age_id, :category, :role, :exclude_id, :package_id, :independent, :invoice_to_id, :available, :table_id,
922+
options: {},
923+
answers_attributes: [:id, :question_id, :answer_value])
887924
end
888925

889926
def filtered_params(person)
@@ -1145,9 +1182,13 @@ def update_options
11451182
end
11461183

11471184
def update_answers
1148-
answers_data = person_params[:answers_attributes] || []
1185+
answers_data = person_params[:answers_attributes] || {}
1186+
1187+
# Handle both array and hash-like formats (fields_for creates hash with string keys)
1188+
# ActionController::Parameters acts like a hash but isn't one
1189+
answers_collection = answers_data.respond_to?(:values) ? answers_data.values : answers_data
11491190

1150-
answers_data.each do |answer_attrs|
1191+
answers_collection.each do |answer_attrs|
11511192
question_id = answer_attrs[:question_id]
11521193
answer_value = answer_attrs[:answer_value]
11531194

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="person-questions"
4+
export default class extends Controller {
5+
static targets = ["questionsContainer", "optionCheckbox", "packageSelect"]
6+
static values = {
7+
personId: Number,
8+
url: String
9+
}
10+
11+
connect() {
12+
// Don't update on initial load - let the server-rendered content show
13+
// Only update when package or options change
14+
}
15+
16+
// Called when package dropdown changes
17+
packageChanged() {
18+
this.updateQuestions()
19+
}
20+
21+
// Called when any option checkbox changes
22+
optionChanged() {
23+
this.updateQuestions()
24+
}
25+
26+
updateQuestions() {
27+
// Get selected package
28+
const packageId = this.hasPackageSelectTarget ?
29+
this.packageSelectTarget.value : null
30+
31+
// Get selected options (checkboxes that are checked and not disabled)
32+
const selectedOptions = []
33+
this.optionCheckboxTargets.forEach(checkbox => {
34+
if (checkbox.checked && !checkbox.disabled) {
35+
// Extract option ID from name like "person[options][123]"
36+
const match = checkbox.name.match(/\[options\]\[(\d+)\]/)
37+
if (match) {
38+
selectedOptions.push(match[1])
39+
}
40+
}
41+
})
42+
43+
// Fetch questions for these selections
44+
this.fetchQuestions(packageId, selectedOptions)
45+
}
46+
47+
fetchQuestions(packageId, optionIds) {
48+
const params = new URLSearchParams()
49+
if (packageId) params.append('package_id', packageId)
50+
optionIds.forEach(id => params.append('option_ids[]', id))
51+
params.append('person_id', this.personIdValue)
52+
53+
fetch(`${this.urlValue}?${params.toString()}`, {
54+
headers: {
55+
'Accept': 'text/html',
56+
'X-Requested-With': 'XMLHttpRequest'
57+
}
58+
})
59+
.then(response => response.text())
60+
.then(html => {
61+
this.questionsContainerTarget.innerHTML = html
62+
})
63+
.catch(error => {
64+
console.error('Error fetching questions:', error)
65+
})
66+
}
67+
}

app/javascript/controllers/questions_controller.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,17 @@ export default class extends Controller {
6262

6363
removeQuestion(event) {
6464
event.preventDefault()
65-
const questionDiv = event.target.closest('[data-questions-target="question"]')
66-
const destroyField = questionDiv.querySelector('[data-questions-target="destroyField"]')
65+
const link = event.target.closest('a')
66+
const questionDiv = link.closest('[data-questions-target="question"]')
67+
const questionName = link.dataset.questionName
6768

68-
if (destroyField) {
69-
// Question already exists in database, mark for destruction
70-
destroyField.value = "1"
69+
if (questionName) {
70+
// Question already exists in database, create _destroy field and mark for destruction
71+
const destroyField = document.createElement('input')
72+
destroyField.type = 'hidden'
73+
destroyField.name = `${questionName}[_destroy]`
74+
destroyField.value = '1'
75+
questionDiv.appendChild(destroyField)
7176
questionDiv.style.display = "none"
7277
} else {
7378
// New question, just remove from DOM

app/models/person.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ def eligible_heats(start_times)
182182

183183
# Get all questions for this person based on their package and selected options
184184
def applicable_questions
185+
# Return pre-calculated questions if available (used for dynamic updates)
186+
return @calculated_questions if defined?(@calculated_questions)
187+
185188
question_ids = Set.new
186189

187190
# Questions from package

app/views/billables/_form.html.erb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,8 @@
101101
<div class="mt-3">
102102
<%= link_to "Remove Question", "#",
103103
class: "text-red-600 hover:text-red-800",
104-
data: { action: "click->questions#removeQuestion" } %>
105-
<% if question_form.object.persisted? %>
106-
<%= question_form.check_box :_destroy, class: "hidden", data: { questions_target: "destroyField" } %>
107-
<% end %>
104+
data: { action: "click->questions#removeQuestion",
105+
question_name: (question_form.object.persisted? ? question_form.object_name : nil) } %>
108106
</div>
109107
</div>
110108
<% end %>

app/views/people/_form.html.erb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
<%= form_with(model: person, class: "contents", id: 'person-form', data: {controller: "person", id: JSON.dump(person.id) }) do |form| %>
1+
<%= form_with(model: person, class: "contents", id: 'person-form', data: {
2+
controller: "person person-questions",
3+
person_questions_person_id_value: person.id,
4+
person_questions_url_value: get_questions_people_path,
5+
id: JSON.dump(person.id)
6+
}) do |form| %>
27
<% if person.errors.any? %>
38
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
49
<h2><%= pluralize(person.errors.count, "error") %> prohibited this person from being saved:</h2>

app/views/people/_options.html.erb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
<%= check_box_tag "person[options][#{option.id}]", '1', true, class: "option-checkbox entry-count h-10 opacity-70", disabled: true %>
3131
<% elsif value <= 1 %>
3232
<input name="person[options][<%= option.id %>]" type="hidden" value="0" autocomplete="off">
33-
<%= check_box_tag "#{field}", '1', value > 0, class: "option-checkbox entry-count h-10" %>
33+
<%= check_box_tag "#{field}", '1', value > 0, class: "option-checkbox entry-count h-10",
34+
data: { "person-questions-target": "optionCheckbox", action: "change->person-questions#optionChanged" } %>
3435
<% else %>
3536
<input type="text" id="<%= field %>" name="<%= field %>" class="entry-count" value=<%= value %>>
3637
<% end %>

app/views/people/_package.html.erb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
<% unless @packages.empty? %>
33
<%= label_tag :person_package_id, 'Package' %>
44
<%= select_tag 'person[package_id]', options_for_select(@packages, @person.package_id || ''), {
5-
data: {"person-target" => "package", 'action' => 'person#setPackage', 'url' => package_people_path},
5+
data: {
6+
"person-target" => "package",
7+
"person-questions-target" => "packageSelect",
8+
'action' => 'person#setPackage person-questions#packageChanged',
9+
'url' => package_people_path
10+
},
611
class: "mx-auto block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full"} %>
712
<% end %>
813
</div>
Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,3 @@
1-
<div class="my-5" id="questions-section">
2-
<% applicable_questions = @person.applicable_questions %>
3-
<% if applicable_questions.any? %>
4-
<h3 class="font-bold text-xl mb-4">Questions</h3>
5-
6-
<% applicable_questions.each do |question| %>
7-
<% existing_answer = @person.answers.find_by(question_id: question.id) %>
8-
<% answer_index = existing_answer&.id || "new_#{question.id}" %>
9-
10-
<div class="my-4">
11-
<%= form.fields_for :answers, existing_answer || @person.answers.build(question: question) do |answer_form| %>
12-
<%= answer_form.hidden_field :question_id, value: question.id %>
13-
14-
<label class="block font-medium mb-2"><%= question.question_text %></label>
15-
16-
<% if question.question_type == 'radio' %>
17-
<div class="space-y-2">
18-
<% question.choices.each do |choice| %>
19-
<label class="flex items-center">
20-
<%= answer_form.radio_button :answer_value, choice,
21-
checked: existing_answer&.answer_value == choice,
22-
class: "mr-2" %>
23-
<%= choice %>
24-
</label>
25-
<% end %>
26-
</div>
27-
<% else %>
28-
<%= answer_form.text_area :answer_value,
29-
rows: 3,
30-
placeholder: "Enter your answer...",
31-
class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
32-
<% end %>
33-
<% end %>
34-
</div>
35-
<% end %>
36-
<% end %>
1+
<div class="my-5" id="questions-section" data-person-questions-target="questionsContainer">
2+
<%= render partial: 'people/questions_content', locals: { person: @person, form: form } %>
373
</div>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<% applicable_questions = person.applicable_questions %>
2+
<% if applicable_questions.any? %>
3+
<h3 class="font-bold text-xl mb-4">Questions</h3>
4+
5+
<% applicable_questions.each do |question| %>
6+
<% existing_answer = person.answers.find_by(question_id: question.id) %>
7+
<% answer_index = existing_answer&.id || "new_#{question.id}" %>
8+
9+
<div class="my-4">
10+
<%= form.fields_for :answers, existing_answer || person.answers.build(question: question) do |answer_form| %>
11+
<%= answer_form.hidden_field :question_id, value: question.id %>
12+
13+
<% if question.question_type == 'radio' %>
14+
<div class="space-y-2">
15+
<label class="block font-medium mb-2"><%= question.question_text %></label>
16+
<% question.choices.each do |choice| %>
17+
<label class="flex items-center">
18+
<%= answer_form.radio_button :answer_value, choice,
19+
checked: existing_answer&.answer_value == choice,
20+
class: "mr-2" %>
21+
<%= choice %>
22+
</label>
23+
<% end %>
24+
</div>
25+
<% else %>
26+
<%= answer_form.label :answer_value, question.question_text, class: "block font-medium mb-2" %>
27+
<%= answer_form.text_area :answer_value,
28+
rows: 3,
29+
placeholder: "Enter your answer...",
30+
class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
31+
<% end %>
32+
<% end %>
33+
</div>
34+
<% end %>
35+
<% end %>

0 commit comments

Comments
 (0)