Rails: Bootstrap form validation

From FVue
Jump to: navigation, search

Bootstrap has nice server-side form validation (https://getbootstrap.com/docs/5.3/forms/validation):

<input class="form-control is-invalid">
<div class="invalid-feedback">can't be blank</div>

But doesn't work out-of-the-box in Rails, because Rails' form.input puts a div.field_with_errors around it:

<div class="field_with_errors">
  <input class="form-control is-invalid">
</div>
<div class="invalid-feedback">can't be blank</div>

Causing div.invalid-feedback to hide, because Bootstrap CSS-rule doesn't work, because .is-invalid & .invalid-feedback aren't siblings any more (~ in CSS means siblings/on the same level):

.is-invalid ~ .invalid-feedback {
  display: block; 
}

Solution: overwrite field_error_proc

By overriding ActionView::Base.field_error_proc, all children of .field_with_errors will now automatically receive Bootstrap class .is-invalid:

# config/initializers/field_error_bootstrap.rb 
# See: https://stackoverflow.com/questions/7454682/customizing-field-with-errors#73173569
ActionView::Base.field_error_proc = proc do |html|
  frag = Nokogiri::HTML5::DocumentFragment.parse(html)
  klass = frag.children[0].attributes['class']
  if klass.present?
    frag.children[0].attributes['class'].value = [klass, 'is-invalid'].join(' ')
  else
    frag.children[0].set_attribute('class', 'is-invalid')
  end
  frag.to_html.html_safe
end

If you want the above constrained to a namespaced part of your website, e.g. module Admin, add to the above:

  ActionView::Base.field_error_proc = proc do |html|
+   if controller.class.module_parents.include?(Admin)
      ...
+   else
+     html
+   end
  end

Now to list the errors underneath the field, add this helper:

# app/helpers/application_helper.rb
# Bootstrap field error
def errors_for(obj, attr)
  res = ''
  if obj && invalid?(obj, attr)
    errors = obj.errors&.messages&.try(:'[]', attr).try(:join, ', ')
    res = "<div id='#{obj.class.name.underscore}_#{attr}_feedback' class='invalid-feedback'>" \
      "<i class='bi bi-exclamation-triangle-fill me-1'></i>#{errors}</div>".html_safe
  end
  res
end

Now you can write form_with fields like this:

<%= form_with ... do |f| %>
  <%= f.label :name, "name" %>
  <%= errors_for(@person, :name) %>
<% end %>

Workaround

Adding .d-block (display: block):

<div class="invalid-feedback d-block">can't be blank</div>

has this drawback: you still need to add .is-invalid to the HTML form elements.

Caveat: form_with object/scope/model/url and multiple models

The solution didn't work for this form - borders weren't displayed red:

 form_with(model: [current_tenant, :admin, user, order], scope: :cart)

Case: ActionView::Helpers::ActiveModelHelper.error_wrapping does an object_has_errors? and object was Order-object with 0 errors, because errors are in Cart-object.

Solution: rewrite form_with so that object_has_errors? uses Cart-object instead of Order-object:

 url = url_for([current_tenant, :admin, user, order])
 form_with(model: cart, url:)

Invalid-field-borders are now displayed Bootstrap-red :)

Comments

blog comments powered by Disqus


See also