Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom sort order for error summaries #290

Merged
merged 8 commits into from
Jun 14, 2021
Merged
5 changes: 5 additions & 0 deletions lib/govuk_design_system_formbuilder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ module GOVUKDesignSystemFormBuilder
# * +:default_collection_radio_buttons_include_hidden+ controls whether or not
# a hidden field is added when rendering a collection of radio buttons
#
# * +:default_error_summary_error_order_method+ is the method that the library
# will check for on the bound object to see whether or not to try ordering the
# error messages
#
# * +:localisation_schema_fallback+ sets the prefix elements for the array
# used to build the localisation string. The final two elements are always
# are the object name and attribute name. The _special_ value +__context__+,
Expand All @@ -62,6 +66,7 @@ module GOVUKDesignSystemFormBuilder
default_submit_button_text: 'Continue',
default_radio_divider_text: 'or',
default_error_summary_title: 'There is a problem',
default_error_summary_error_order_method: nil,
default_collection_check_boxes_include_hidden: true,
default_collection_radio_buttons_include_hidden: true,
default_submit_validate: false,
Expand Down
7 changes: 5 additions & 2 deletions lib/govuk_design_system_formbuilder/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,9 @@ def govuk_date_field(attribute_name, hint: {}, legend: {}, caption: {}, date_of_
# @param title [String] the error summary heading
# @param link_base_errors_to [Symbol,String] set the field that errors on +:base+ are linked
# to, as there won't be a field representing the object base.
# @param order [Array<Symbol>] the attribute order in which error messages are displayed. Ordered
# attributes will appear first and unordered ones will be last, sorted in the default manner (in
# which they were defined on the model).
# @option kwargs [Hash] kwargs additional arguments are applied as attributes to the error summary +div+ element
#
# @note Only the first error in the +#errors+ array for each attribute will
Expand All @@ -939,8 +942,8 @@ def govuk_date_field(attribute_name, hint: {}, legend: {}, caption: {}, date_of_
# = f.govuk_error_summary 'Uh-oh, spaghettios'
#
# @see https://design-system.service.gov.uk/components/error-summary/ GOV.UK error summary
def govuk_error_summary(title = config.default_error_summary_title, link_base_errors_to: nil, **kwargs)
Elements::ErrorSummary.new(self, object_name, title, link_base_errors_to: link_base_errors_to, **kwargs).html
def govuk_error_summary(title = config.default_error_summary_title, link_base_errors_to: nil, order: nil, **kwargs)
Elements::ErrorSummary.new(self, object_name, title, link_base_errors_to: link_base_errors_to, order: order, **kwargs).html
end

# Generates a fieldset containing the contents of the block
Expand Down
35 changes: 33 additions & 2 deletions lib/govuk_design_system_formbuilder/elements/error_summary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ class ErrorSummary < Base
include Traits::Error
include Traits::HTMLAttributes

def initialize(builder, object_name, title, link_base_errors_to:, **kwargs)
def initialize(builder, object_name, title, link_base_errors_to:, order:, **kwargs)
super(builder, object_name, nil)

@title = title
@link_base_errors_to = link_base_errors_to
@html_attributes = kwargs
@order = order
end

def html
Expand All @@ -35,11 +36,41 @@ def summary
end

def list
@builder.object.errors.messages.map do |attribute, messages|
error_messages.map do |attribute, messages|
list_item(attribute, messages.first)
end
end

def error_messages
messages = @builder.object.errors.messages

if reorder_errors?
return messages.sort_by.with_index(1) do |(attr, _val), i|
error_order.index(attr) || (i + messages.size)
end
end

@builder.object.errors.messages
end

def reorder_errors?
object = @builder.object

@order || (error_order_method &&
object.respond_to?(error_order_method) &&
object.send(error_order_method).present?)
end

def error_order
@order || @builder.object.send(config.default_error_summary_error_order_method)
end

# this method will be called on the bound object to see if custom error ordering
# has been enabled
def error_order_method
config.default_error_summary_error_order_method
end

def list_item(attribute, message)
tag.li(link_to(message, same_page_link(field_id(attribute)), data: { turbolinks: false }))
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
expect(subject).to have_tag('h2', text: default_error_summary_title, with: { class: 'govuk-error-summary__title' })
end

context %(overriding with 'Engage') do
context %(overriding with custom text) do
let(:error_summary_title) { %(We've been hit!) }
let(:args) { [method, error_summary_title] }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
describe GOVUKDesignSystemFormBuilder::FormBuilder do
include_context 'setup builder'
include_context 'setup examples'

describe 'changing the default error order method' do
let(:object) { OrderedErrorsWithCustomOrder.new }
let(:custom_method_name) { :error_order }

before { object.valid? }

subject { builder.govuk_error_summary }

let(:actual_order) { extract_field_names_from_errors_summary_list(parsed_subject) }
let(:expected_order) { object.error_order.map(&:to_s) }

before do
GOVUKDesignSystemFormBuilder.configure do |conf|
conf.default_error_summary_error_order_method = custom_method_name
end
end

specify "the error messages are displayed in the order they were defined in the model" do
expect(actual_order).to eql(expected_order)
end
end
end
67 changes: 64 additions & 3 deletions spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@
end

describe 'error messages' do
subject! { builder.send(method) }
let(:kwargs) { {} }
subject! { builder.send(*args, **kwargs) }

context 'there are multiple errors each with one error message' do
context 'when there are multiple errors each with one error message' do
let(:object) { Person.new(favourite_colour: nil, projects: []) }

specify 'the error summary should contain a list with one error message per field' do
Expand All @@ -50,7 +51,7 @@
end
end

context 'there are multiple errors and one has multiple error messages' do
context 'when there are multiple errors and one has multiple error messages' do
let(:object) { Person.new(name: nil, favourite_colour: nil) }

specify 'the error summary should contain a list with one error message per field' do
Expand Down Expand Up @@ -292,6 +293,66 @@
end
end
end

describe "custom sort order" do
let(:actual_order) { extract_field_names_from_errors_summary_list(parsed_subject) }

context "by default" do
# the object here is Person, defined in spec/support/examples.rb
#
# the validation order is: name, favourite colour, projects, cv
#
# name is present on the object
specify "errors are displayed in the order they're defined in the model" do
expect(object.name).to be_present

expect(actual_order).to eql(%w(favourite_colour projects cv))
end
end

describe "overriding" do
let(:object) { OrderedErrors.new }
let(:overridden_order) { %w(e d c b a) }
let(:overridden_order_symbols) { overridden_order.map(&:to_sym) }
let(:kwargs) { { order: overridden_order_symbols } }

context "when all attributes are named in the ordering" do
# the default validation order is (:a, :b, :c, :d, :e)
#
# the overridden order is (:e, :d, :c, :b, :a)
specify "the error messages are displayed in the overridden order" do
expect(actual_order).to eql(overridden_order)
end
end

context "when there are attributes with errors that aren't named in the ordering" do
let(:object) { OrderedErrorsWithCustomOrderAndExtraAttributes.new }

# the default validation order is (:a, :b, :c, :d, :e)
#
# the overridden order is (:e, :d, :c, :b, :a)
#
# the extra attributes (:g, :h, :i) validation order is (:i, :h, :g)
specify "the errors for attributes with overridden ordering are first" do
expect(actual_order).to start_with(overridden_order)
end

specify "the errors for extra attributes appear last, in the order they were defined in the model" do
expect(actual_order).to end_with(%w(i h g))
end
end

context "when the ordering specifies attributes that aren't present on the object" do
let(:kwargs) { { order: overridden_order_symbols.append(%i(x y z)) } }

# there's no error_order method, ensure it doesn't blow up. it shouldn't
# because #index will return nil
specify "the error messages are displayed in the order they were defined in the model" do
expect(actual_order).to eql(overridden_order)
end
end
end
end
end
end

Expand Down
38 changes: 36 additions & 2 deletions spec/support/examples.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ def initialize(_args = nil)
class Person < Being
include ActiveModel::Model

validates :name, presence: { message: 'Enter a name' }
validates :name,
presence: { message: 'Enter a name' },
length: { minimum: 2, message: 'Name should be longer than 1' }
validates :favourite_colour, presence: { message: 'Choose a favourite colour' }
validates :projects, presence: { message: 'Select at least one project' }
validates :cv, length: { maximum: 30 }, presence: true

validate :born_on_must_be_in_the_past, if: -> { born_on.present? }
validate :photo_must_be_jpeg, if: -> { photo.present? }
validates :name, length: { minimum: 2, message: 'Name should be longer than 1' }

def self.valid_example
new(
Expand Down Expand Up @@ -107,3 +108,36 @@ def initialize(code:, name:)
end

WrongDate = Struct.new(:d, :m, :y)

class OrderedErrors
include ActiveModel::Model
include ActiveModel::Attributes

attribute :a, :string
attribute :b, :string
attribute :c, :string
attribute :d, :string
attribute :e, :string

validates :a, presence: true, length: { minimum: 3 }
validates :b, presence: true, length: { minimum: 3 }
validates :c, presence: true, length: { minimum: 3 }
validates :d, presence: true, length: { minimum: 3 }
validates :e, presence: true, length: { minimum: 3 }
end

class OrderedErrorsWithCustomOrder < OrderedErrors
def error_order
%i(e d c b a)
end
end

class OrderedErrorsWithCustomOrderAndExtraAttributes < OrderedErrors
attribute :g, :string
attribute :h, :string
attribute :i, :string

validates :i, presence: true, length: { minimum: 3 }
validates :h, presence: true, length: { minimum: 3 }
validates :g, presence: true, length: { minimum: 3 }
end
12 changes: 12 additions & 0 deletions spec/support/utility.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,22 @@ def underscores_to_dashes(val)
val.to_s.tr('_', '-')
end

def dashes_to_underscores(val)
val.to_s.tr('-', '_')
end

def rails_version
ENV.fetch('RAILS_VERSION') { '6.1.1' }
end

def rails_version_later_than_6_1_0?
rails_version >= '6.1.0'
end

def extract_field_names_from_errors_summary_list(document)
document
.css('li > a')
.map { |element| element['href'] }
.map { |href| href.match(%r[#{object_name}-(?<attribute_name>.*)-field-error])[:attribute_name] }
.map { |attribute| dashes_to_underscores(attribute) }
end