Skip to content

Remote form validation

Michael J. Giarlo edited this page Dec 11, 2020 · 14 revisions

Table of Contents

Remote Forms 101

When Rails 5.2 came out, one of the new features was "remote forms." These aren't forms that are emotionally aloof nor have they relocated to some far-off zip code. Before pinning down what remote forms are, let's make clear what "local" forms are.

With local forms, the "classic" flavor of Rails forms, when they are submitted, an HTTP POST request is sent to the server at which point the form is processed (validated, saved, etc.) and the controller either renders the same form with errors or redirects to a new page to display to the user the changes they have made. Long story short: submitting the form results in a page change while the user's changes are handled in the request/response loop.

How are remote forms different? When the user submits the form, Rails uses the Turbolinks JavaScript library to send the form (typically via HTTP POST) to the controller via XHR (XMLHttpRequest, or "AJAX"), at which point the controller either sends back JSON (confirming success or listing errors) or redirects to another page. The Rails-UJS library sets up hooks for our code to listen for events coming back from the server, and H2 is using Stimulus controllers to interact with these hooks. More on this later when we get into specific examples. For now... why does this matter? Why use remote forms at all? The basic notion here is to get errors in front of users faster (without page transitions) and to present to users a more reactive and responsive UI, which is more in line with current user expectations.

Stimulus 101

Since we are using Stimulus as our primary JavaScript library for attaching custom client-side behavior in H2, including handling remote forms, let's make a brief foray into Stimulus basics. The three Stimulus entities you'll need to know about are controllers, targets, and actions:

  • Controller: A Stimulus controller is a file in app/javascript/controllers/ that provides client-side behavior around a particular domain, such as attached files, contributors, or a contact form.
  • Target: A Stimulus target is a reference to an HTML element, allowing a controller to read values from and write values to the HTML that is in the scope of the controller (more on scope later).
  • Action: A Stimulus action is a binding between an event (click, select, submit) and a behavioral response that is invoked on the controller. A controller may define one or more actions.

An example would be helpful in showing how all the pieces of remote forms (including Stimulus and other Rails classes) interact.

Example: Embargo Date Validation

A good example for learning how remote forms work and all the pieces fit together is validating a work's embargo date. Let's take it from the inside (i.e., the server) out (i.e., to the client).

Form Validation (Server-side)

In order to validate embargo dates, we need some logic that handles the validation by examining values sent by a user in a form and determining whether they are valid or not. The H2 app's validators tend to be small classes inhering from ActiveModel::EachValidator, such as EmbargoDateValidator. This is a relatively simple validation: the value is only invalid if it is non-nil and more than three years in the future. Note that the string that is added as an error to the record is shown to end-users, so it should be intelligible to end-users.

The validator itself isn't very useful, though, since it hasn't been hooked into any forms.

For works, the H2 app has two (Reform-based) forms: a form for draft works and a form for works that have been deposited. We want H2 users to be able to save drafts of works early and often without having to get everything in the form right, such as required fields. But we also don't want users to put in values that later show as invalid, so the general guideline the H2 app follows is for the draft work form to validate the format of values (such as dates and e-mail addresses) and for the deposited work form to validate the presence of values (required vs. not).

We can wire our embargo date validator to the draft work with a single line of code:

  validates :embargo_date, embargo_date: true

This tells the form to validate the value in the embargo_date field using the embargo_date validator, which Rails finds automagically thanks to a naming convention: embargo_date: true instructs Rails to look for a class called EmbargoDateValidator, which H2 stores in app/validators/ (NOTE: this isn't a special directory; everything under app/ is auto-loaded, so it could have just as easily lived in app/services/).

With this code in place, whenever any draft work form is validated, embargo date validation is included.

Rails Controller (Server-side)

Next, we need to use the draft work form in the controller action(s) that will be receiving form values needing validation. There are two such actions here: WorksController#create and WorksController#update. The distinction between these two actions with regard to validation is not meaningful, so let's use the create action for our example.

The controller needs to know whether the user submitted the form using the Save draft button or the Deposit button, since the validation behavior for these two cases is different. That is done here: https://github.com/sul-dlss/happy-heron/blob/494ab6d13ef83457bbe40aaff2643cf7eb820676/app/controllers/works_controller.rb#L26 The small work_form method sniffs the params submitted by the client to determine which button was pushed, and if it wasn't the deposit button, the draft work form is used to validate form values. If the form validates and saves successfully, the user is taken to the work show page. If there are errors, though, the controller renders the errors.

The 'errors' here refers to a JBuilder-rendered JSON template that loops over all errors in the form, returning a list of key-value pairs where the keys correspond to form fields with invalid user-supplied values and the values are themselves arrays of error strings (as set in the validator class above).

This is where we start to consider client-side issues, when rendering server-side errors that are parsed by client-side code.

Naming conventions

Note that all of our code to this point has followed Ruby and Rails syntax and naming conventions. The key-value error pairs covered in the prior section come straight from Rails, so they took follow Ruby/Rails naming conventions. But we know the errors JSON will be acted upon by client-side JavaScript, so it is at this point of rendering the errors that we translate keys from Rubyish conventions to JavaScriptish conventions. This is handled by the normalize_key method defined as a helper method in the WorksController. (The helper_method macro is what exposes this method from the controller to works-related views, such as app/views/works/errors.json.jbuilder, which is response for rendering the errors in JSON.)

For embargo dates, Rails uses snake_case (embargo_date) and JavaScript uses kebab-case (embargo-date), so the normalize_key method uses a look-up table to handle normalization. More on this in the client-side sections below.

Binding Stimulus within HTML (Served to Client)

With all the code in place for server-side form validation, we need to make sure the HTML mark-up served to the user has been wired up with client-side behaviors in the right spot. In this case, that means we want embargo date validation to happen proximal to the point in the form where the user will enter an embargo date. In H2, we're using view components to structure our views and make them more modular and testable, and we happen to have a component dedicated to handling embargo-related information in the work form.

Recalling the earlier Stimulus 101 section section, we will need a Stimulus controller dedicated to embargo dates, one or more targets, and one or more actions.

First, we add the data-controller="embargo-date" attribute to the main embargo-related <div> in our embargo view component template. Where you add this attribute is significant: a Stimulus controller is scoped to the HTML element it's attached to and has access to all child elements. We tend to use a <div> nearby where we'll be attaching actions and needing targets to help other developers reason about the code. What the data-controller attribute does is it maps to app/javascript/controllers/embargo_date_controller.js, with Stimulus smartly translating the JavaScript-y embargo-date value to the Rails-y embargo_date_controller so that each domain can be internally consistent with regard to naming conventions).

On this same line of code, you will see two other data tags related to Stimulus:

  • data-action="error->embargo-date#error"
  • data-edit-deposit-target="embargo-dateField"

The data-action attribute sets up an event handler that listens for the error event, and sends every such event to the embargo-date controller's error() function. More on what that does in the following section. But note that this action would not do anything without the data-controller="embargo-date" attribute having been defined on the same element or a parent element. With these two small changes, we have attached new client-side behavior. On its own, it doesn't get us the validation we need---it merely adds error handling, which H2 does in the field-specific Stimulus controllers (e.g., embargo date, contributors, keywords), since different fields might respond to errors in different ways.

The data-edit-deposit-target="embargo-dateField" attribute is interesting in a couple of ways. First, it shows that Stimulus controller scopes may be nested, and we use nested controller scopes to have an outer scope that handles validation of the form (the edit-deposit controller) and inner scopes (such as the embargo-date controller) to handle field-specific behavior. What this line does: it adds a Stimulus target to the edit-deposit controller named embargo-dateField. How does Stimulus know this target is intended for the edit-deposit controller? It figures that out by inspecting what's between data- and -target in the name of the attribute. Naming conventions are very important here. Related to that, the other important bit to notice is the name of the value: embargo-dateField. This is a naming convention H2 uses in the edit-deposit controller, which we will soon see in action (in the following section).

The remaining changes in the view component add two targets for the embargo-date controller:

  • data-embargo-date-target="container": the container target is conventionally used in H2 for the <div> that will be marked as invalid via the is-invalid CSS class.
  • data-embargo-date-target="error": the error target is conventionally used in H2 for the <div> that will display the field-specific error message using the invalid-feedback CSS class.

When this HTML is served up to the client, Stimulus parses the various data- tags and attaches behaviors to those locations.

Stimulus Controllers (Client-side)

As mentioned above, two Stimulus controllers work in concert to handle form validation on the client side, with the edit-deposit controller doing the heavy lifting.

First, a target is added to the edit-deposit controller's list of static targets, and it is named embargo-dateField.

Let's build on our example and say we have a user who fills out an invalid embargo date and clicks one of the two submit buttons. Turbolinks submits the form seamless to the server; the server validates the form and finds an error in the embargo_date field; errors JSON is returned to the client with an HTTP 400 which triggers an ajax:error event. The edit-deposit controller has an action bound to this event in the work form component, and that action invokes the displayErrors() function in the edit-deposit controller. This function passes the errors JSON object to the edit-deposit parseErrors() function and does the following:

    for (const [fieldName, errorList] of Object.entries(data)) {
      const key = `${fieldName}Field`
      this.dispatchError(key, errorList)
    }
    window.scrollTo(0, 80)

data is the errors JSON object, and per above contains key/value pairs. So for the embargo date portion of what is returned, the fieldName is set to embargo-date since we normalized that key from Rails-y-looking to JavaScript-y-looking on the way out from the server, and the errorList is an array of error strings. This information is passed on to the dispatchError() function, which takes the field name of embargo-dateField and looks for a target of that name to which the error list is dispatched.

This is where the name embargo-date name is significant, as it corresponds to a Stimulus controller named embargo_date_controller.js whose sole responsibility is to handle the dispatched error event, which it does by adding the is-invalid CSS class to the embargo date container target and adding the error message strings to the embargo date error target, the result of which is an obvious indication to the end-user that their form values are problematic, and how/why they are problematic.

Examples

It may be useful to see multiple examples in the context of the pull requests where they originated: