Error Handling

Part 22 of building a Rails 7 application

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:

image.png

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:

image.png

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:

image.png

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 StandardErrors 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

image.png

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.

developer.mozilla.org/en-US/docs/Learn/Form..

uxwritinghub.com/error-message-examples