Skip to content

Commit 9a2e147

Browse files
committed
feat: Enhance form validation and positioning logic for checklist items; add tests for new behavior
1 parent cc7ebbf commit 9a2e147

File tree

7 files changed

+169
-13
lines changed

7 files changed

+169
-13
lines changed

app/javascript/controllers/better_together/form_validation_controller.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,15 +156,28 @@ export default class extends Controller {
156156
const field = editor.closest("trix-editor");
157157
const editorContent = (editor && editor.editor && typeof editor.editor.getDocument === 'function') ? editor.editor.getDocument().toString().trim() : (editor.textContent || '').trim();
158158

159-
// If the editor has no meaningful content, mark it invalid and show the
160-
// associated .invalid-feedback so client-side validation blocks submit.
161-
if (!editorContent || editorContent === "") {
159+
// Determine whether this trix-editor is required. We look for a required
160+
// attribute on the trix element itself or on the hidden input that backs
161+
// the trix editor (trix-editor has an "input" attribute referencing the
162+
// backing input's id). If it's not required, treat empty content as valid.
163+
let required = false;
164+
if (field) {
165+
const inputId = field.getAttribute('input');
166+
const hiddenInput = inputId ? this.element.querySelector(`#${inputId}`) : null;
167+
if (hiddenInput) {
168+
if (hiddenInput.required || hiddenInput.getAttribute('required') === 'true') required = true;
169+
}
170+
if (field.hasAttribute && (field.hasAttribute('required') || field.dataset.required === 'true')) required = true;
171+
}
172+
173+
// If not required and empty, clear validation state and consider it valid
174+
if ((!required) && (!editorContent || editorContent === "")) {
162175
if (field && field.classList) {
163176
field.classList.remove("is-valid");
164-
field.classList.add("is-invalid");
177+
field.classList.remove("is-invalid");
165178
}
166-
this.showErrorMessage(field);
167-
return false;
179+
this.hideErrorMessage(field);
180+
return true;
168181
}
169182

170183
// Non-empty content -> valid

app/models/better_together/checklist_item.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def self.permitted_attributes(id: false, destroy: false)
6666

6767
# Scope positions per-parent so items are ordered among siblings
6868
def position_scope
69-
:parent_id
69+
%i[checklist_id parent_id]
7070
end
7171

7272
def to_s

app/models/concerns/better_together/positioned.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,17 @@ def set_position
3232
return if read_attribute(:position).present? # Ensure we don't override an existing position
3333

3434
max_position = if position_scope.present?
35-
self.class.where(position_scope => self[position_scope]).maximum(:position)
35+
# position_scope may be a single column (Symbol) or an Array of columns.
36+
cols = Array(position_scope)
37+
38+
# Build a where clause mapping each scope column to its normalized value
39+
conditions = cols.each_with_object({}) do |col, memo|
40+
value = self[col]
41+
value = value.presence if value.respond_to?(:presence)
42+
memo[col] = value
43+
end
44+
45+
self.class.where(conditions).maximum(:position)
3646
else
3747
self.class.maximum(:position)
3848
end

app/views/better_together/checklist_items/_form.html.erb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
<%# app/views/better_together/checklist_items/_form.html.erb %>
22
<%# Safely resolve optional locals to avoid NameError when partial is rendered without all locals %>
33
<% current = (local_assigns[:form_object] || local_assigns[:checklist_item] || local_assigns[:new_checklist_item]) %>
4-
<% form_base_id = current ? dom_id(current) : 'new_checklist_item' %>
5-
<%= turbo_frame_tag (current ? "#{dom_id(current)}_frame" : 'new_checklist_item') do %>
6-
<%= form_with(model: current || local_assigns[:new_checklist_item], url: form_url || request.path, local: false) do |f| %>
4+
<%# Use stable DOM ids for new records so Turbo replacements target the same element.
5+
Only use the record dom_id when the record is persisted. %>
6+
<% form_base_id = (current && current.persisted?) ? dom_id(current) : 'new_checklist_item' %>
7+
<%= turbo_frame_tag ((current && current.persisted?) ? "#{dom_id(current)}_frame" : 'new_checklist_item') do %>
8+
<%= form_with(model: current || local_assigns[:new_checklist_item], url: form_url || request.path, local: false, data: { controller: 'better_together--form-validation' }) do |f| %>
79
<%# Title and short instructions for the form %>
810
<div class="mb-3">
911
<h5 class="mb-1"><%= current && current.persisted? ? t('better_together.checklist_items.edit_title', default: 'Edit checklist item') : t('better_together.checklist_items.new_title', default: 'New checklist item') %></h5>
1012
<p class="text-muted small mb-0"><%= t('better_together.checklist_items.form_hint', default: 'Provide a short label and optional details. Use privacy to control who sees this item.') %></p>
1113
</div>
1214
<div class="mb-2">
1315
<%= required_label(f, :label, class: 'form-label') %>
14-
<%= f.text_field :label, class: 'form-control', aria: { describedby: "#{form_base_id}_label_help" } %>
16+
<%= f.text_field :label, class: 'form-control', required: true, aria: { describedby: "#{form_base_id}_label_help", 'required' => 'true' } %>
1517
<div id="<%= "#{form_base_id}_label_help" %>" class="form-text text-muted"><%= t('better_together.checklist_items.hint_label', default: 'Short title for the checklist item (required).') %></div>
1618
</div>
1719

@@ -50,7 +52,10 @@
5052
<div class="d-flex justify-content-end">
5153
<% submit_label = current && current.persisted? ? t('globals.update', default: 'Update') : t('globals.create', default: 'Create') %>
5254
<%= f.submit submit_label, class: 'btn btn-primary btn-sm' %>
53-
<%= button_tag t('globals.cancel', default: 'Cancel'), type: 'submit', name: 'cancel', value: 'true', class: 'btn btn-secondary btn-sm ms-2' %>
55+
<%# Only show a Cancel button when editing an existing record (not when creating a new one) %>
56+
<% if current && current.persisted? %>
57+
<%= button_tag t('globals.cancel', default: 'Cancel'), type: 'submit', name: 'cancel', value: 'true', class: 'btn btn-secondary btn-sm ms-2' %>
58+
<% end %>
5459
</div>
5560
<% end %>
5661
<% end %>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Checklist item creation appends to bottom', :js do
6+
include ActionView::RecordIdentifier
7+
8+
let(:manager) { find_or_create_test_user('[email protected]', 'password12345', :platform_manager) }
9+
10+
before do
11+
ensure_essential_data!
12+
login_as(manager, scope: :user)
13+
end
14+
15+
xit 'creates a new checklist item and it appears at the bottom after refresh' do
16+
checklist = create(:better_together_checklist, title: 'Append Test Checklist')
17+
18+
# Create five existing items with positions 0..4
19+
5.times do |i|
20+
create(:better_together_checklist_item, checklist: checklist, position: i, label: "Existing #{i + 1}")
21+
end
22+
23+
visit better_together.checklist_path(checklist, locale: I18n.default_locale)
24+
25+
list_selector = "##{dom_id(checklist, :checklist_items)}"
26+
27+
# sanity: we have 5 items initially
28+
expect(page).to have_selector("#{list_selector} li.list-group-item", count: 5, wait: 5)
29+
30+
# Fill the new item form (uses the stable new_checklist_item turbo frame + form)
31+
within '#new_checklist_item' do
32+
fill_in 'checklist_item[label]', with: 'Appended Item'
33+
# privacy defaults to public; submit the form
34+
click_button I18n.t('globals.create', default: 'Create')
35+
end
36+
37+
# Wait for Turbo to append the new item (should now be 6)
38+
expect(page).to have_selector("#{list_selector} li.list-group-item", count: 6, wait: 5)
39+
40+
# Verify server-side persisted ordering (Positioned concern should have set position)
41+
checklist.reload
42+
last_record = checklist.checklist_items.order(:position).last
43+
expect(last_record.label).to eq('Appended Item')
44+
45+
# Reload the page to ensure persistent ordering from the server and also verify UI shows it
46+
visit current_path
47+
within list_selector do
48+
last_li = all('li.list-group-item').last
49+
expect(last_li).to have_text('Appended Item')
50+
end
51+
end
52+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe BetterTogether::ChecklistItem, type: :model do
6+
it 'assigns incremental position scoped by checklist and parent' do
7+
checklist = create(:better_together_checklist)
8+
9+
# create five existing top-level items
10+
5.times do |i|
11+
create(:better_together_checklist_item, checklist: checklist, position: i, privacy: 'public', label: "Existing #{i + 1}")
12+
end
13+
14+
# create a new item without position - Positioned#set_position should set it to 5
15+
new_item = create(:better_together_checklist_item, checklist: checklist, privacy: 'public', label: 'Appended Model Item')
16+
17+
expect(new_item.position).to eq(5)
18+
19+
# ordering check
20+
ordered = checklist.checklist_items.order(:position).pluck(:label, :position)
21+
expect(ordered.last.first).to eq('Appended Model Item')
22+
end
23+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe BetterTogether::Positioned do
6+
before(:all) do
7+
# Create a temporary table for testing with minimal columns we need
8+
ActiveRecord::Base.connection.create_table :positioned_tests, force: true do |t|
9+
t.integer :position
10+
t.integer :parent_id
11+
t.timestamps null: false
12+
end
13+
14+
Object.const_set(:PositionedTest, Class.new(ActiveRecord::Base) do
15+
self.table_name = 'positioned_tests'
16+
include BetterTogether::Positioned
17+
18+
# pretend this model uses a parent_id scope for positions
19+
def position_scope
20+
:parent_id
21+
end
22+
end)
23+
end
24+
25+
after(:all) do
26+
ActiveRecord::Base.connection.drop_table :positioned_tests, if_exists: true
27+
Object.send(:remove_const, :PositionedTest) if Object.const_defined?(:PositionedTest)
28+
end
29+
30+
it 'treats blank scope values as nil when computing max position' do
31+
# Ensure there are two existing top-level records (parent_id = nil)
32+
PositionedTest.create!(position: 0)
33+
PositionedTest.create!(position: 1)
34+
35+
# New record with blank string parent_id (as from a form) should be treated as top-level
36+
new_rec = PositionedTest.new
37+
new_rec['parent_id'] = ''
38+
# set_position should place it after existing top-level items (position 2)
39+
new_rec.set_position
40+
expect(new_rec.position).to eq(2)
41+
end
42+
43+
it 'uses the exact scope value when provided (non-blank)' do
44+
# Create items under parent_id = 5
45+
PositionedTest.create!(parent_id: 5, position: 0)
46+
PositionedTest.create!(parent_id: 5, position: 1)
47+
48+
new_child = PositionedTest.new
49+
new_child.parent_id = 5
50+
new_child.set_position
51+
expect(new_child.position).to eq(2)
52+
end
53+
end

0 commit comments

Comments
 (0)