Skip to content

Commit 28b4406

Browse files
rubysclaude
andcommitted
Add questions UI to billable options forms
Phase 3: View Layer for Questions - Added questions section to billable forms (options only) - Dynamic nested form fields with add/remove functionality - Question type selector (radio/textarea) with conditional choices field - Created Stimulus questions controller for dynamic interactions - Converts choices between array (DB) and newline-separated text (UI) - Supports creating, editing, and deleting questions - All tests passing (910 tests, 0 failures) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0599cb0 commit 28b4406

File tree

3 files changed

+163
-2
lines changed

3 files changed

+163
-2
lines changed

app/controllers/billables_controller.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def edit
4343

4444
# POST /billables or /billables.json
4545
def create
46-
@billable = Billable.new(billable_params.except(:options, :packages))
46+
params_to_save = process_question_params(billable_params.except(:options, :packages))
47+
@billable = Billable.new(params_to_save)
4748

4849
@billable.order = (Billable.maximum(:order) || 0) + 1
4950

@@ -64,8 +65,10 @@ def create
6465

6566
# PATCH/PUT /billables/1 or /billables/1.json
6667
def update
68+
params_to_save = process_question_params(billable_params.except(:options, :packages))
69+
6770
respond_to do |format|
68-
if @billable.update(billable_params.except(:options, :packages))
71+
if @billable.update(params_to_save)
6972
update_includes
7073

7174
# Redirect back to tables page if that's where the request came from
@@ -252,4 +255,18 @@ def update_includes
252255
end
253256
end
254257
end
258+
259+
def process_question_params(params)
260+
return params unless params[:questions_attributes]
261+
262+
params[:questions_attributes].each do |key, question_attrs|
263+
if question_attrs[:choices].is_a?(String)
264+
# Convert newline-separated text to array
265+
choices_array = question_attrs[:choices].split("\n").map(&:strip).reject(&:blank?)
266+
params[:questions_attributes][key][:choices] = choices_array
267+
end
268+
end
269+
270+
params
271+
end
255272
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Connects to data-controller="questions"
4+
export default class extends Controller {
5+
static targets = ["question", "choicesContainer", "destroyField"]
6+
7+
connect() {
8+
this.questionIndex = this.questionTargets.length
9+
}
10+
11+
addQuestion(event) {
12+
event.preventDefault()
13+
14+
const container = document.getElementById("questions-container")
15+
const timestamp = new Date().getTime()
16+
17+
const newQuestionHTML = `
18+
<div class="question-fields border border-gray-300 p-4 rounded-md" data-questions-target="question">
19+
<input type="hidden" name="billable[questions_attributes][${timestamp}][id]" value="">
20+
21+
<div class="flex gap-4 items-start">
22+
<div class="flex-1">
23+
<label for="billable_questions_attributes_${timestamp}_question_text">Question</label>
24+
<textarea name="billable[questions_attributes][${timestamp}][question_text]"
25+
id="billable_questions_attributes_${timestamp}_question_text"
26+
rows="2"
27+
class="block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full"></textarea>
28+
</div>
29+
30+
<div class="w-48">
31+
<label for="billable_questions_attributes_${timestamp}_question_type">Type</label>
32+
<select name="billable[questions_attributes][${timestamp}][question_type]"
33+
id="billable_questions_attributes_${timestamp}_question_type"
34+
class="block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full"
35+
data-action="change->questions#toggleChoices">
36+
<option value="radio">Radio Buttons</option>
37+
<option value="textarea">Text Area</option>
38+
</select>
39+
</div>
40+
</div>
41+
42+
<div class="choices-container mt-3" data-questions-target="choicesContainer">
43+
<label for="billable_questions_attributes_${timestamp}_choices">Choices (one per line)</label>
44+
<textarea name="billable[questions_attributes][${timestamp}][choices]"
45+
id="billable_questions_attributes_${timestamp}_choices"
46+
rows="3"
47+
placeholder="Beef&#10;Chicken&#10;Fish&#10;Vegetarian"
48+
class="block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full"></textarea>
49+
</div>
50+
51+
<input type="hidden" name="billable[questions_attributes][${timestamp}][order]" value="${this.questionIndex}">
52+
53+
<div class="mt-3">
54+
<a href="#" class="text-red-600 hover:text-red-800" data-action="click->questions#removeQuestion">Remove Question</a>
55+
</div>
56+
</div>
57+
`
58+
59+
container.insertAdjacentHTML('beforeend', newQuestionHTML)
60+
this.questionIndex++
61+
}
62+
63+
removeQuestion(event) {
64+
event.preventDefault()
65+
const questionDiv = event.target.closest('[data-questions-target="question"]')
66+
const destroyField = questionDiv.querySelector('[data-questions-target="destroyField"]')
67+
68+
if (destroyField) {
69+
// Question already exists in database, mark for destruction
70+
destroyField.value = "1"
71+
questionDiv.style.display = "none"
72+
} else {
73+
// New question, just remove from DOM
74+
questionDiv.remove()
75+
}
76+
}
77+
78+
toggleChoices(event) {
79+
const select = event.target
80+
const questionDiv = select.closest('[data-questions-target="question"]')
81+
const choicesContainer = questionDiv.querySelector('[data-questions-target="choicesContainer"]')
82+
83+
if (select.value === 'textarea') {
84+
choicesContainer.style.display = 'none'
85+
} else {
86+
choicesContainer.style.display = 'block'
87+
}
88+
}
89+
}

app/views/billables/_form.html.erb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,59 @@
6262
<% end %>
6363
</ul>
6464
<% end %>
65+
66+
<% if @type == 'option' %>
67+
<div class="my-5" data-controller="questions">
68+
<h2 class="font-bold text-2xl mt-4">Questions for this option:</h2>
69+
<div id="questions-container" class="space-y-4 mt-4">
70+
<%= form.fields_for :questions do |question_form| %>
71+
<div class="question-fields border border-gray-300 p-4 rounded-md" data-questions-target="question">
72+
<%= question_form.hidden_field :id %>
73+
74+
<div class="flex gap-4 items-start">
75+
<div class="flex-1">
76+
<%= question_form.label :question_text, "Question" %>
77+
<%= question_form.text_area :question_text, rows: 2,
78+
class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
79+
</div>
80+
81+
<div class="w-48">
82+
<%= question_form.label :question_type, "Type" %>
83+
<%= question_form.select :question_type, [['Radio Buttons', 'radio'], ['Text Area', 'textarea']], {},
84+
{ class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full",
85+
data: { action: "change->questions#toggleChoices" } } %>
86+
</div>
87+
</div>
88+
89+
<div class="choices-container mt-3" data-questions-target="choicesContainer"
90+
style="<%= question_form.object.question_type == 'textarea' ? 'display: none;' : '' %>">
91+
<%= question_form.label :choices, "Choices (one per line)" %>
92+
<%= question_form.text_area :choices,
93+
value: (question_form.object.choices || []).join("\n"),
94+
rows: 3,
95+
placeholder: "Beef\nChicken\nFish\nVegetarian",
96+
class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
97+
</div>
98+
99+
<%= question_form.hidden_field :order %>
100+
101+
<div class="mt-3">
102+
<%= link_to "Remove Question", "#",
103+
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 %>
108+
</div>
109+
</div>
110+
<% end %>
111+
</div>
112+
113+
<div class="mt-4">
114+
<%= link_to "Add Question", "#",
115+
class: "inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700",
116+
data: { action: "click->questions#addQuestion" } %>
117+
</div>
118+
</div>
119+
<% end %>
65120
<% end %>

0 commit comments

Comments
 (0)