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

Add a default error summary presenter #311

Merged
merged 6 commits into from
Aug 31, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions guide/content/introduction/error-handling.slim
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,26 @@ p.govuk-body
summary link should take the user to the first field. As the form builder
doesn't know the order in which fields will be rendered, it must be specified.

== render('/partials/example-fig.*',
caption: "Custom summary error presenter injection",
code: form_with_presenter_injection,
sample_data: departments_data_raw,
show_errors: :presenters,
hide_data: true,
controller_code: custom_error_presenter_raw,
hide_html_output: true) do

p.govuk-body
| Although the #{link_to('GDS design system recommends', 'https://design-system.service.gov.uk/components/error-summary/', target: '_blank').html_safe}
that you use the same summary error message as that on the field there
may be situations in which the summary level wording can be used to
provide more or less context. Forms containing repeatable nested fields,
for example, could use the summary message to clearly point to an
attribute error instance.

p.govuk-body
| In such cases a custom error presenter that responds to <code>#formatted_error_messages</code>
can be supplied. When a presenter object is provided it will be used directly, when a class
is provided it will be instantiated with the associated object's <code>#errors.messages</code>.

== render('/partials/related-info.*', links: error_handling_info)
8 changes: 8 additions & 0 deletions guide/layouts/partials/example-fig.slim
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ figure.full-sample
code.highlight.language-ruby
| #{sample_data}

- if defined?(controller_code)
section
h4.govuk-heading-s.example-subheading.data Setup

pre.example-input
code.highlight.language-ruby
| #{controller_code}

- if defined?(raw_config)
section
h4.govuk-heading-s.example-subheading.locale Configuration
Expand Down
15 changes: 15 additions & 0 deletions guide/lib/examples/error_handling.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,20 @@ def form_with_errors_on_object_base
= f.govuk_phone_field :telephone_number, label: { text: "Phone number" }
SNIPPET
end

def form_with_presenter_injection
<<~SNIPPET
= f.govuk_error_summary(presenter: custom_error_presenter)

= f.govuk_text_field :name,
label: { text: 'Name' },
hint: { text: 'You can find it on your birth certificate' }

= f.govuk_date_field :date_of_birth,
date_of_birth: true,
legend: { text: 'Enter your date of birth' },
hint: { text: 'For example, 31 3 1980' }
SNIPPET
end
end
end
3 changes: 3 additions & 0 deletions guide/lib/helpers/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ class Person
:telephone_number
)

validates :name, presence: { message: %(Enter a name) }, on: :presenters
validates :date_of_birth, presence: { message: %(Enter a valid date of birth) }, on: :presenters

validates :welcome_pack_reference_number, presence: { message: 'Enter the reference number you received in your welcome pack' }, on: :fields
validates :welcome_pack_received_on, presence: { message: 'Enter the date you received your welcome pack' }, on: :fields
validates :department_id, presence: { message: %(Select the department to which you've been assigned) }, on: :fields
Expand Down
23 changes: 22 additions & 1 deletion guide/lib/setup/example_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ def custom_locale_config_raw
CONFIG
end

def custom_error_presenter_raw
<<~CODE
class ErrorSummaryUpperCasePresenter
def initialize(error_messages)
@error_messages = error_messages
end

def formatted_error_messages
@error_messages.map { |attribute, messages| [attribute, messages.first.upcase] }
end
end

custom_error_presenter = ErrorSummaryUpperCasePresenter
CODE
end

# Yes, eval is bad, but when you want to display code in documentation as
# well as run it, it's kind of necessary. Not considering this a security
# threat as it's only used in the guide 👮
Expand Down Expand Up @@ -130,6 +146,10 @@ def laptops
def custom_locale_config
eval(custom_locale_config_raw)
end

def custom_error_presenter
eval(custom_error_presenter_raw)
end
# rubocop:enable Security/Eval

def form_data
Expand All @@ -140,7 +160,8 @@ def form_data
lunch_options: lunch_options,
grouped_lunch_options: grouped_lunch_options,
primary_colours: primary_colours,
laptops: laptops
laptops: laptops,
custom_error_presenter: custom_error_presenter,
}
end
end
Expand Down
10 changes: 10 additions & 0 deletions guide/lib/setup/form_builder_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ def builder(errors = false)
builder_with_field_errors
when :base
builder_with_base_errors
when :presenters
builder_with_presenter_errors
else
builder_without_errors
end
Expand All @@ -23,6 +25,10 @@ def builder_with_base_errors
GOVUKDesignSystemFormBuilder::FormBuilder.new(:person, object_with_base_errors, helper, {})
end

def builder_with_presenter_errors
GOVUKDesignSystemFormBuilder::FormBuilder.new(:person, object_with_presenter_errors, helper, {})
end

def object
Person.new
end
Expand All @@ -35,6 +41,10 @@ def object_with_base_errors
Person.new.tap { |p| p.valid?(:base_errors) }
end

def object_with_presenter_errors
Person.new.tap { |p| p.valid?(:presenters) }
end

def helper
ActionView::Base.new(action_view_context, {}, nil)
end
Expand Down
14 changes: 9 additions & 5 deletions lib/govuk_design_system_formbuilder.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require 'deep_merge/rails_compat'
require 'active_support/configurable'

[%w(refinements *.rb), %w(traits *.rb), %w(*.rb), %w(elements ** *.rb), %w(containers ** *.rb)]
[%w(presenters *.rb), %w(refinements *.rb), %w(traits *.rb), %w(*.rb), %w(elements ** *.rb), %w(containers ** *.rb)]
.flat_map { |matcher| Dir.glob(File.join(__dir__, 'govuk_design_system_formbuilder', *matcher)) }
.each { |file| require file }

Expand Down Expand Up @@ -37,16 +37,19 @@ module GOVUKDesignSystemFormBuilder
# button in radio button fieldsets. As per the GOV.UK Design System spec,
# it defaults to 'or'.
#
# * +:default_error_summary_title+ sets the text used in error summary
# blocks. As per the GOV.UK Design System spec, it defaults to
# 'There is a problem'.
#
# * +:default_collection_check_boxes_include_hidden+ controls whether or not
# a hidden field is added when rendering a collection of check boxes
#
# * +: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_title+ sets the text used in error summary
# blocks. As per the GOV.UK Design System spec, it defaults to
# 'There is a problem'.
#
# * +:default_error_summary_presenter+ the class that's instantiated when
# rendering an error summary and formats the messages for each attribute
#
# * +: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
Expand All @@ -73,6 +76,7 @@ module GOVUKDesignSystemFormBuilder
default_radio_divider_text: 'or',
default_check_box_divider_text: 'or',
default_error_summary_title: 'There is a problem',
default_error_summary_presenter: Presenters::ErrorSummaryPresenter,
default_error_summary_error_order_method: nil,
default_collection_check_boxes_include_hidden: true,
default_collection_radio_buttons_include_hidden: true,
Expand Down
11 changes: 9 additions & 2 deletions lib/govuk_design_system_formbuilder/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,13 @@ def govuk_date_field(attribute_name, hint: {}, legend: {}, caption: {}, date_of_
# which they were defined on the model).
# @option kwargs [Hash] kwargs additional arguments are applied as attributes to the error summary +div+ element
# @param block [Block] arbitrary HTML that will be rendered between title and error message list
# @param presenter [Class,Object] the class or object that is responsible for formatting a list of error
# messages that will be rendered in the summary.
#
# * When a class is specified it will be instantiated with the object's errors in the +object.errors.messages+ format.
# * When an object is specified it will be used as-is.
#
# The object must implement +#formatted_error_messages+, see {Presenters::ErrorSummaryPresenter} for more details.
#
# @note Only the first error in the +#errors+ array for each attribute will
# be included.
Expand All @@ -959,8 +966,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, order: nil, **kwargs, &block)
Elements::ErrorSummary.new(self, object_name, title, link_base_errors_to: link_base_errors_to, order: order, **kwargs, &block).html
def govuk_error_summary(title = config.default_error_summary_title, presenter: config.default_error_summary_presenter, link_base_errors_to: nil, order: nil, **kwargs, &block)
Elements::ErrorSummary.new(self, object_name, title, link_base_errors_to: link_base_errors_to, order: order, presenter: presenter, **kwargs, &block).html
end

# Generates a fieldset containing the contents of the block
Expand Down
18 changes: 16 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,13 +4,14 @@ class ErrorSummary < Base
include Traits::Error
include Traits::HTMLAttributes

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

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

def html
Expand All @@ -33,10 +34,23 @@ def summary

def list
tag.ul(class: [%(#{brand}-list), summary_class('list')]) do
safe_join(error_messages.map { |attribute, messages| list_item(attribute, messages.first) })
safe_join(presenter.formatted_error_messages.map { |args| list_item(*args) })
end
end

# If the provided @presenter is a class, instantiate it with the sorted
# error_messages from our object. Otherwise (if it's any other object),
# treat it like a presenter
def presenter
return @presenter.new(error_messages) if @presenter.is_a?(Class)

unless @presenter.respond_to?(:formatted_error_messages)
fail(ArgumentError, "error summary presenter doesn't implement #formatted_error_messages")
end

@presenter
end

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

Expand Down
29 changes: 29 additions & 0 deletions lib/govuk_design_system_formbuilder/presenters/error_summary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module Presenters
# This is the default presenter for {GOVUKDesignSystemFormBuilder::Elements::ErrorSummary} and is
# intended to be easily replaceable should you have specific requirements that aren't met here.
#
# The basic behaviour is to always show the first error message. In Rails, error message order is
# determined by the order in which the validations run, but if you need to do any other transformation
# or concatenation, this is the place to do it.
class ErrorSummaryPresenter
# @param [Hash] error_messages the error message hash in a format that matches Rails'
# `object.errors.messages`, so the format should be:
#
# @example Input format:
# ErrorSummaryPresenter.new({ attribute_one: ["first error", "second error"], attribute_two: ["third error"] })
def initialize(error_messages)
@error_messages = error_messages
end

# Converts +@error_messages+ into an array of argument arrays that will be
# passed into {GOVUKDesignSystemFormBuilder::Elements::ErrorSummary#list_item}.
#
peteryates marked this conversation as resolved.
Show resolved Hide resolved
# @return [Array<Array(Symbol, String)>] array of attribute and message arrays
jsugarman marked this conversation as resolved.
Show resolved Hide resolved
#
# @example Output format given the input above:
# [[:attribute_one, "first error"], [:attribute_two, "third error"]]
def formatted_error_messages
@error_messages.map { |attribute, messages| [attribute, messages.first] }
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class ErrorSummaryReversePresenter
def initialize(error_messages)
@error_messages = error_messages
end

def formatted_error_messages
@error_messages.map { |attribute, messages| [attribute, messages.first.reverse] }
end
end

describe GOVUKDesignSystemFormBuilder::FormBuilder do
include_context 'setup builder'
include_context 'setup examples'

describe 'changing the default error order method' do
let(:custom_presenter) { ErrorSummaryReversePresenter }
let(:object) { Person.with_errors_on_base }
let(:expected_error_message) { "This person is always invalid".reverse }
subject { builder.govuk_error_summary }

before do
GOVUKDesignSystemFormBuilder.configure do |conf|
conf.default_error_summary_presenter = custom_presenter
end
end

specify "the error messages are in the format specified by the custom presenter" do
expect(subject).to have_tag("li > a", href: "#person-base-field-error", text: expected_error_message)
end
end
end
53 changes: 53 additions & 0 deletions spec/govuk_design_system_formbuilder/builder/error_summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -442,5 +442,58 @@
end
end
end

context "when a custom presenter is supplied" do
before { object.valid? }

let(:custom_presenter) do
Class.new do
def initialize(error_messages)
@error_messages = error_messages
end

def formatted_error_messages
@error_messages.map { |attribute, messages| [attribute, messages.first.upcase] }
end
end
end

let(:expected_error_messages) do
object.errors.messages.each.with_object({}) { |(k, v), h| h[k.to_s] = v.first.upcase }
end

context "as a class that upcases the error messages" do
subject { builder.send(*args, presenter: custom_presenter) }

specify "uses the presenter to display error messages in the desired format" do
expect(subject).to have_tag("ul", with: { class: "govuk-error-summary__list" }) do
expected_error_messages.each do |attr, error_message|
with_tag("a", with: { href: underscores_to_dashes(%(#person-#{attr}-field-error)) }, text: error_message)
end
end
end
end

context "as an instance" do
subject { builder.send(*args, presenter: custom_presenter.new(object.errors.messages)) }

specify "uses the presenter to display error messages in the desired format" do
expect(subject).to have_tag("ul", with: { class: "govuk-error-summary__list" }) do
expected_error_messages.each do |attr, error_message|
with_tag("a", with: { href: underscores_to_dashes(%(#person-#{attr}-field-error)) }, text: error_message)
end
end
end
end

context "when the custom presenter doesn't implement #formatted_error_messages" do
let(:non_presenter) { "totally not a presenter" }
subject { builder.send(*args, presenter: non_presenter) }

specify "fails with an appropriate error message" do
expect { subject }.to raise_error(ArgumentError, "error summary presenter doesn't implement #formatted_error_messages")
end
end
end
end
end