Display form errors using custom validity messages

This commit is contained in:
2026-07-28 00:45:08 +02:00
parent 5d051de666
commit eb2797bd90
13 changed files with 81 additions and 51 deletions

View File

@@ -55,9 +55,9 @@ class ApplicationController < ActionController::Base
private
def render_no_content(record)
helpers.render_errors(record)
render html: nil, layout: true
def render_errors(record)
target = record.new_record? ? [:new, record.parent || record.class] : [:edit, record]
render 'errors', assigns: {record: record, target: target}
end
def rescue_turbo(exception)

View File

@@ -43,12 +43,15 @@ class QuantitiesController < ApplicationController
permitted = params.require(:quantity).permit(:parent_id)
@previous_ancestors = @quantity.ancestors
# Until UI blocks all disallowed reparents, render error messages if present
render_no_content(@quantity) unless @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
if @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
else
# Until UI blocks all disallowed reparents, render error messages if present.
flash.now.alert = @quantity.errors.full_messages
render :nothing
end
end
def destroy

View File

@@ -22,7 +22,7 @@ class UnitsController < ApplicationController
@before = @unit.successive
flash.now[:notice] = t('.success', unit: @unit)
else
render :new
render_errors @unit
end
end
@@ -33,7 +33,7 @@ class UnitsController < ApplicationController
if @unit.update(params.except(:base_id).expect(Unit::ATTRIBUTES))
flash.now[:notice] = t('.success', unit: @unit)
else
render :edit
render_errors @unit
end
end

View File

@@ -91,10 +91,7 @@ module ApplicationHelper
[:text_field, :password_field, :text_area].each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
options[:maxlength] ||= object.class.type_for_attribute(method).limit
if object.errors.include?(method)
options[:pattern] = except_pattern(object.public_send(method), options[:pattern])
end
options[:maxlength] ||= object.type_for_attribute(method).limit
super
end
RUBY_EVAL
@@ -129,10 +126,6 @@ module ApplicationHelper
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag(svg_name, super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
end
end
def tabular_fields_for(record_name, record_object = nil, options = {}, &block)
@@ -142,8 +135,6 @@ module ApplicationHelper
record_object, options = nil, record_object if record_object.is_a?(Hash)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block)
end
@@ -210,16 +201,9 @@ module ApplicationHelper
link_to name, options, html_options
end
def render_errors(records)
# Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
def render_flash
flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise
# Conversion of flash to Array only required because of Devise.
Array(messages).map do |message|
tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) +

View File

@@ -1,6 +1,8 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
// TODO: access `data-` attributes through
// `keyname in`/`dataset.keyname`/`delete dataset.keyname`
// Show page if hidden for testing purposes.
@@ -146,6 +148,33 @@ Turbo.StreamActions.unselect = function() {
})
}
Turbo.StreamActions.validate = function() {
this.targetElements.forEach((form) => {
this.templateContent.querySelectorAll("span").forEach((span) => {
var control = document.querySelector(
`[form="${form.id}"][name="${span.dataset.name}"]`
)
control.setCustomValidity(span.textContent)
// Add event listener in capture phase to clear custom validity before
// `[oninput]` or any other bubble phase handler is invoked.
// https://www.quirksmode.org/js/events_order.html
// TODO: keep invalidating current value whenever user re-enters it. Store
// invalid value and validation message in `[data-invalid-message/value]`
// and check input value on "input" event (below) (using `[pattern]` may not
// be suitable due to inability to customize validtion message; also
// pattern may be used for other, conflicting purposes).
// After user executes any other action (while form is open), remove `[data-]`
// attributes and custom validation, as value may no longer be invalid.
control.addEventListener(
"input",
function(event) { event.target.setCustomValidity("") },
{capture: true, once: true}
)
})
form.reportValidity()
})
}
// Keyboard event processing handlers.
function formProcessKey(event) {
@@ -209,6 +238,8 @@ window.dragStart = dragStart
* rapid pointer moves.
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
* TODO: replace Leave table with Enter document?
* https://www.quirksmode.org/js/events_order.html#link4
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)

View File

@@ -3,6 +3,7 @@ class Unit < ApplicationRecord
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
alias_method :parent, :base
has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error

View File

@@ -0,0 +1,7 @@
<%= turbo_stream.validate dom_target(*@target, :form) do %>
<% @record.errors.attribute_names.map do |attr| %>
<%= tag.span data: {name: field_name(dom_class(@record), attr)} do %>
<%- @record.errors.full_messages_for(attr).map { |m| m + '.' }.join(' ') %>
<% end %>
<% end %>
<% end %>

View File

@@ -44,7 +44,7 @@
</header>
<div id="flashes">
<%= render_flash_messages %>
<%= render_flash %>
</div>
<%# Allows overwriting/clearing navigation menu for some views %>

View File

@@ -1,6 +1,6 @@
<%= yield %>
<%# Some views may convert ActiveRecord errors to flashes, render at the end. %>
<%# Render unconditionally, to clear previous flash messages. %>
<%= turbo_stream.update :flashes do %>
<%= render_flash_messages %>
<%= render_flash %>
<% end %>
<%= yield %>

View File

@@ -1,2 +1 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %>