diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 782583e..2ec0aeb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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) diff --git a/app/controllers/quantities_controller.rb b/app/controllers/quantities_controller.rb index 5c0c5ba..86dc12d 100644 --- a/app/controllers/quantities_controller.rb +++ b/app/controllers/quantities_controller.rb @@ -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 diff --git a/app/controllers/units_controller.rb b/app/controllers/units_controller.rb index c876cbc..b064773 100644 --- a/app/controllers/units_controller.rb +++ b/app/controllers/units_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a903eee..f997d0b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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)) + diff --git a/app/javascript/application.js b/app/javascript/application.js index a679411..230a5de 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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) diff --git a/app/models/unit.rb b/app/models/unit.rb index 597769d..bc82353 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -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 diff --git a/app/views/application/errors.turbo_stream.erb b/app/views/application/errors.turbo_stream.erb new file mode 100644 index 0000000..e955533 --- /dev/null +++ b/app/views/application/errors.turbo_stream.erb @@ -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 %> diff --git a/app/views/application/nothing.turbo_stream.erb b/app/views/application/nothing.turbo_stream.erb new file mode 100644 index 0000000..e69de29 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 6167976..4fbe58e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -44,7 +44,7 @@
- <%= render_flash_messages %> + <%= render_flash %>
<%# Allows overwriting/clearing navigation menu for some views %> diff --git a/app/views/layouts/application.turbo_stream.erb b/app/views/layouts/application.turbo_stream.erb index 9ba0c29..1312da1 100644 --- a/app/views/layouts/application.turbo_stream.erb +++ b/app/views/layouts/application.turbo_stream.erb @@ -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 %> diff --git a/app/views/users/passwords/create.turbo_stream.erb b/app/views/users/passwords/create.turbo_stream.erb index 99acbf4..6e7b33a 100644 --- a/app/views/users/passwords/create.turbo_stream.erb +++ b/app/views/users/passwords/create.turbo_stream.erb @@ -1,2 +1 @@ -<%# For some reason flash messages are duplicated in bot flash and flash.now %> <% flash.discard %> diff --git a/config/initializers/turbo_streams_tag_builder.rb b/config/initializers/turbo_streams_tag_builder.rb index 70fc9c1..b104a62 100644 --- a/config/initializers/turbo_streams_tag_builder.rb +++ b/config/initializers/turbo_streams_tag_builder.rb @@ -34,4 +34,8 @@ ActiveSupport.on_load :turbo_streams_tag_builder do def unselect(target) action :unselect, target, allow_inferred_rendering: false end + + def validate(target, content = nil, **rendering, &block) + action :validate, target, content, **rendering, &block + end end diff --git a/test/system/units_test.rb b/test/system/units_test.rb index 54c92dd..ccc6c6f 100644 --- a/test/system/units_test.rb +++ b/test/system/units_test.rb @@ -163,31 +163,32 @@ class UnitsTest < ApplicationSystemTestCase link = all(:link, exact_text: label).sample link.click - get_values = ->{ all(:field).map { |f| [f[:name], f.value] }.to_h } - values = nil - within :table_row, {}, with_focus: true do - # Provide duplicate :symbol as server-side invalidated input. - field = find(:table_cell, column(:symbol)).find(:fillable_field) - field.fill_in with: (symbols - [field.value]).sample - values = get_values[] - send_keys :enter - end + get_values = ->{ + find(:table_row, {}, with_focus: true) + .all(:field).map { |f| [f[:name], f.value] }.to_h + } - assert_selector '.flash.alert', - text: t('activerecord.errors.models.unit.attributes.symbol.taken') + # Provide duplicate :symbol as server-side invalidated input. + field = find(:field, focused: true) + field.fill_in with: (symbols - [field.value]).sample + values = get_values[] + send_keys :enter + + assert_matches_selector field, :field, validation_message: + /#{t('activerecord.errors.models.unit.attributes.symbol.taken')}/ + assert_no_selector '.flash.alert' + assert_equal values, get_values[] if action == :edit assert_no_selector :link, exact_text: link[:text] else assert_matches_selector link, :link, disabled: true end + click_on t(:cancel) - within :table_row, {}, with_focus: true do - assert_equal values, get_values[] - click_on t(:cancel) - end assert_no_selector '.flash.alert' assert_equal symbols, list_symbols refresh + assert_no_selector '.flash.alert' assert_equal symbols, list_symbols end