Errors in an application are the bane of not just the owners of the application, but even more so for the users of that application. I know for sure there will be errors on occasion, most certainly validation errors but there could be other general errors too. So I need a neat mechanism for showing them to the user in the least annoying way possible.
I need to cater for a number of different scenarios within different parts of the application. There are also different communication methods occurring too, in some parts there are XHR calls being made that will respond to errors as JSON
. In other parts it is going to be TURBO_STREAM
calls so they will need to be considered in a different way.
I'll start with basic client side validation. These can be used to show the user when they are missing data on a form or if the data is in a format that the application cannot accept. No server communication is required for this, there are some tricks I can apply using HTML5 validation and TailwindCSS to make this look nice.
Having an attribute being mandatory is a pretty common requirement so I'll add support for that to begin with. I also accept a text message to display to the user if the field is ever invalid. Note that I have chosen to use a single message for this, not attempt to work out which part is invalid. So for a required email address for instance this invalid_message
would say something like An email address in the format address@example.com must be entered.
This lets the user know specifically what the expectations are for the field.
app/components/resource_form/base_component.rb
module ResourceForm
class BaseComponent < ViewComponent::Base
include IconsHelper
attr_reader :attribute, :label, :resource, :options
# options:
# - help: String, A message to place near the label to help the user understand the attribute
# - readonly: Boolean, is this attribute currently editable?
# - editable: Boolean, is this attribute ever editable?
# - required: Boolean, is this field required to have a value?
# - invalid_message: String, if this field is invalid what message to display
...
def required?
@options[:required]
end
def invalid_message
@options[:invalid_message]
end
end
Over in the HTML I can apply these changes too. In this example it's a TextComponent
.
app/components/resource_form/text_component.html.erb
<turbo-frame id="<%= dom_id(resource, "#{attribute}_turbo_frame") %>" class="contents">
<input
class="peer block max-w-lg w-full shadow-sm focus:ring-sky-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md"
autofocus="autofocus"
type="text"
<%= 'required' if required? %>
value="<%= resource.public_send(attribute) %>"
name="<%= field_name %>"
id="<%= field_id %>"
data-action="<%= data_actions.join(' ') %>"
data-resource-form--component-field-id-param="<%= field_id %>"
data-editor-url-param="<%= polymorphic_path(resource) %>"
data-editor-field-id-param="<%= field_id %>"
data-editor-attribute-param="<%= attribute %>"
data-editor-resource-name-param="<%= resource.resource_name %>"
>
<p id="<%= field_id %>--client_side_invalid_message" class="invisible peer-invalid:visible text-red-700 font-light">
<%= invalid_message %>
</p>
</turbo-frame>
Using TailwindCSS I can have the message appear automatically whenever the field itself is invalid. First, give the input a class of peer
, second, specify the classes invisible peer-invalid:visible
on the error message. This means that the message will be invisible unless its peer tag is invalid, at which point the message will be shown. Here is what it looks like:
Validation of the form is automatically carried out by the browser but I also want to prevent the user from submitting a form that is not in a valid state. I can do that in the JavaScript which is handling the button click:
app/javascript/controllers/resource_controller.js
async create(event) {
...
const form = cash(`#${formId}`)[0]
const formData = new FormData(document.getElementById(formId));
if (form.checkValidity()) {
const response = await post(resourceUrl, {body: formData, responseKind: 'json'})
if (response.ok) {
modal.hide()
}
}
}
checkValidity()
will return true or false depending on whether all fields on the form are valid or not.
The second part is validation that occurs on the server side. Generally this will be because of something like a uniqueness constraint or it could be some other inter-system communication that has failed.
To display these server side generated error messages I'm going to add a new div
that I can use as a target for the insertion of any messages.
app/components/resource_form/text_component.html.erb
<div id="<%= field_id %>--server_side_invalid_message" class="text-red-700 font-light">
</div>
I can take advantage of Stimulus inter-controller event messaging to raise an error
custom event. If the post
response does not return an OK status this event is dispatched containing the content of the errors.
app/javascript/controllers/resource_controller.js
if (form.checkValidity()) {
const response = await post(resourceUrl, {body: formData, responseKind: 'json'})
if (response.ok) {
modal.hide()
} else {
const me = this
response.json.then(function (errors) {
me.dispatch('error', {detail: {resourceName: resourceName, errors: errors}})
})
}
}
The errors controller itself picks out the name of the field that has the issue and attaches a new message (or messages) to that field by appending a p
tag to the div
I mentioned a moment ago.
app/javascript/controllers/errors_controller.js
import {Controller} from "@hotwired/stimulus"
import cash from "cash-dom"
export default class extends Controller {
show({ detail: { resourceName, errors }}) {
for (const [field, messages] of Object.entries(errors)) {
const messageTag = cash(`#${resourceName}_${field}--server_side_invalid_message`)
messageTag.empty()
for (const message of messages) {
messageTag.append(`<p>${field} ${message}</p>`)
}
}
}
}
The event will be called resource:error
so I just need to connect my controller and call the show method whenever one of these events is received.
app/views/item_sell_packs/index.html.erb
<div class="w-full" data-controller="errors" data-action="resource:error->errors#show">
Here is an example of it in action with a slightly contrived example:
Both of these validation errors are working fine as is when using the "Edit" page of the app or the "New" modal dialog which is rendering the form. I also need to consider the inline editing of fields from the "Index" page. Luckily the HTML5 client side validation works with no modification necessary, but handling the errors that come back from server side validation will need some tweaking.
Right now if the user attempts an update inline and it fails validation nothing is displayed. I need to render a TURBO_STREAM
response that includes an error message instead:
app/controllers/item_sell_packs_controller.rb
def update
respond_to do |format|
if @resource.update(resource_params)
...
else
...
format.turbo_stream do
render(
partial: 'editable_cell',
locals: {
attribute: editable_cell_attribute,
formatter: editable_cell_formatter,
error: @resource.errors.full_messages.join(', ')
},
status: :ok
)
end
end
end
end
The TURBO_STREAM
response on the error condition will now include an error
local to the partial. This is used to show an error message to the user when this partial is rendered. Note also that the status is being returned as :ok
. The reason for this is that if this rendered a :unprocessable_entity
as normally would be expected this would mean that on the browser side the turbo stream does not get rendered as it believes there is an error and therefore no content. Here is what will display now after an unsuccessful attempt to update:
The last error situation I'll attend to are runtime exceptions within the application itself. At this point in time I'm only going to broadly handle the StandardError
. Ideally as more specific possible errors are identified these would be handled individually (such as ActiveRecord::RecordNotFound
). This gives the user the best chance of understanding exactly what has gone wrong.
I can rescue all StandardError
s from the controller by adding a new Errors
concern. I already have a way to show notifications to users for other situations (see Notifications and Animation with Hotwire) so I can use that in the same way for these general errors.
app/controllers/concerns/errors.rb
# Broadcast any errors to the users
module Errors
extend ActiveSupport::Concern
included do
rescue_from StandardError, with: :show_error
end
def show_error(_exception)
Turbo::StreamsChannel.broadcast_append_to(
'errors',
partial: 'notification',
locals: {
name: 'notification_error',
type: 'error',
message: 'We have encountered an error and cannot continue, contact us for help.'
},
target: 'notifications'
)
respond_to do |format|
format.json do
render(
json: { error: 'We have encountered an error and cannot continue, contact us for help.' },
status: :internal_server_error
)
end
format.turbo_stream { raise }
end
end
end
Note that I'm re-raising the exception instead of just swallowing it. As the owner of this application I want to know that this error occurred. raise
with no arguments will re-raise the last exception that was rescued. Lastly I need to choose which pages should receive this "errors" stream:
app/views/item_sell_packs/index.html.erb
<%= turbo_stream_from "errors" %>
By purposefully raising an error in my controller I can see what it looks like:
app/controllers/item_sell_packs_controller.rb
def destroy
raise 'Something broke'
@resource.destroy!
...
end
Of course there needs to be tests added for this new functionality around error handling. The easiest is the Capybara tests that check if the correct messages are being shown for the client side and server side validation.
test/system/item_sell_packs_test.rb
test 'inline editing validation' do
login
visit item_sell_packs_url
# Click to edit
click_on 'carton'
assert_selector("input#item_sell_pack_#{item_sell_packs(:carton).id}_name")
# Set to blank
find("input#item_sell_pack_#{item_sell_packs(:carton).id}_name").send_keys(:backspace)
assert_selector(
"#item_sell_pack_#{item_sell_packs(:carton).id}_name--client_side_invalid_message",
text: 'A name must be entered'
)
# Use a name that already exists
find("input#item_sell_pack_#{item_sell_packs(:carton).id}_name").send_keys('box', :enter)
assert_selector(
"#item_sell_pack_#{item_sell_packs(:carton).id}_name--server_side_invalid_message",
text: 'Name has already been taken'
)
end
test 'should show validation errors on item sell pack update' do
login
visit item_sell_pack_url(@item_sell_pack)
click_on 'Edit', match: :first
fill_in 'Name', with: ''
assert_selector(
"#item_sell_pack_#{@item_sell_pack.id}_name--client_side_invalid_message",
text: 'A name must be entered'
)
fill_in 'Name', with: 'box'
click_on 'Update'
assert_selector(
"#item_sell_pack_#{@item_sell_pack.id}_name--server_side_invalid_message",
text: 'Name has already been taken'
)
end
A little more tricky is the controller tests for the error handling and turbo stream broadcasting. This required using the stubbing and mocking capabilities of Minitest.
test/controllers/item_sell_packs_controller_test.rb
test 'broadcast error for JSON' do
mock = Minitest::Mock.new
mock.expect(:call, nil) do |channel, partial:, locals:, target:|
channel == 'errors' &&
partial == 'notification' &&
locals == {
name: 'notification_error',
type: 'error',
message: 'We have encountered an error and cannot continue, contact us for help.'
} &&
target == 'notifications'
end
Turbo::StreamsChannel.stub(:broadcast_append_to, mock) do
@item_sell_pack.stub(:update, -> { raise StandardError }) do
authenticate
get edit_item_sell_pack_url(@item_sell_pack, format: :json)
parsed_response = JSON.parse(@response.body)
assert_equal(
'We have encountered an error and cannot continue, contact us for help.',
parsed_response['error'],
'JSON error response was not correct'
)
assert_equal(500, @response.status, 'Request did not return status code 500')
end
end
end
This first test sets up a mock expectation that the Turbo::StreamsChannel
will receive the correct method call and parameters in order for it to broadcast the error message. It is also testing the JSON response to ensure the status code and content contain the correct error message.
test/controllers/item_sell_packs_controller_test.rb
test 'raises error for TURBO_STREAM' do
ItemSellPack.stub(:find, ->(_id) { raise StandardError }) do
authenticate
assert_raises(StandardError) do
get item_sell_pack_url(@item_sell_pack, format: :turbo_stream)
end
end
end
The second test here asserts that if there is a StandardError
raised anywhere during the controller call that exception is re-raised properly. I can do this by doing stub
on the find
method of the ItemSellPack
class and use a lambda to raise an error.