Form validation

Overview

This tutorial will cover how to handle basic form validation within the context of the demo app. Handling form data has already been covered in the tutorial on injecting state; here we will build on those concepts to add a validation state and errors to the existing form. Over the course of the tutorial we will build an example pattern for creating both dynamic client-side validation and mock server-side validation.

Prerequisites

You can open the tutorial on codesandbox.io or download the demo project and run npm install to get started.

This tutorial assumes that you have gone through the form widgets tutorial as well as the state management tutorial.

Create a place to store form errors

Add form errors to the application context.

Right now the error object should mirror WorkerFormData in both WorkerForm.ts and ApplicationContext.ts. In the wild this error configuration could be handled in a number of ways; one might be to provide an option for multiple validation steps with individual error messages for a single input. Here we will go for the simplest solution with a boolean valid/invalid state for each input.

Create an interface for WorkerFormErrors in WorkerForm.ts

  1. export interface WorkerFormErrors {
  2. firstName?: boolean;
  3. lastName?: boolean;
  4. email?: boolean;
  5. }

Defining the properties in the WorkerFormErrors interface as optional allows us to effectively create three possible states for form fields: unvalidated, valid, and invalid.

Next add a formErrors method to the ApplicationContext class

As an exercise, complete the following three steps:

  • Create a private field for _formErrors in the ApplicationContext class
  • Define a public getter for the _formErrors field within the ApplicationContext
  • Update the getProperties function in the WorkerFormContainer.ts file to pass through the new error object
    Hint: Follow the existing _formData private field in the ApplicationContext class to see how it’s used. The _formErrors variable you need to add can follow the same flow.

Make sure the following lines are present somewhere in ApplicationContext.ts:

  1. // modify import to include WorkerFormErrors
  2. import { WorkerFormData, WorkerFormErrors } from './widgets/WorkerForm';
  3. // private field
  4. private _formErrors: WorkerFormErrors = {};
  5. // public getter
  6. get formErrors(): WorkerFormErrors {
  7. return this._formErrors;
  8. }

The modified getProperties function in WorkerFormContainer.ts:

  1. function getProperties(inject: ApplicationContext, properties: any) {
  2. const {
  3. formData,
  4. formErrors,
  5. formInput: onFormInput,
  6. submitForm: onFormSave
  7. } = inject;
  8. return {
  9. formData,
  10. formErrors,
  11. onFormInput: onFormInput.bind(inject),
  12. onFormSave: onFormSave.bind(inject)
  13. };
  14. }

Finally, modify WorkerFormProperties in WorkerForm.ts to accept the formErrors object passed in by the application context:

  1. export interface WorkerFormProperties {
  2. formData: WorkerFormData;
  3. formErrors: WorkerFormErrors;
  4. onFormInput: (data: Partial<WorkerFormData>) => void;
  5. onFormSave: () => void;
  6. }

Tie validation to form inputs

Perform validation on onInput

We now have a place to store form errors in the application state, and those errors are passed into the form widget. The form still lacks any actual validation of the user input; for that, we need to dust off our regular expressions and write a basic validation function.

Create a private _validateInput method in ApplicationContext.ts

Like the existing formInput function, _validateInput should take a partial WorkerFormData input object. The validation function should return a WorkerFormErrors object. The example app shows only the most basic validation checks – the email regex pattern for example is concise but somewhat lax. You are free to substitute a more robust email test, or add other modifications like a minimum character count for the first and last names.

  1. private _validateInput(input: Partial<WorkerFormData>): WorkerFormErrors {
  2. const errors: WorkerFormErrors = {};
  3. // validate input
  4. for (let key in input) {
  5. switch (key) {
  6. case 'firstName':
  7. errors.firstName = !input.firstName;
  8. break;
  9. case 'lastName':
  10. errors.lastName = !input.lastName;
  11. break;
  12. case 'email':
  13. errors.email = !input.email || !input.email.match(/^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-][email protected][a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/);
  14. }
  15. }
  16. return errors;

For now, we will test our validation by calling it directly in every onInput event. Add the following line to formInput in ApplicationContext.ts:

  1. this._formErrors = deepAssign({}, this._formErrors, this._validateInput(input));

Update the render method of the WorkerForm class to display validation state

At this point in our progress, the WorkerForm widget holds the validation state of each form field in its formErrors property, updated every time an onInput handler is called. All that remains is to pass the valid/invalid property to the inputs themselves. Luckily the Dojo TextInput widget contains an invalid property that sets the aria-invalid attribute on a DOM node, and toggles classes used for visual styling.

The updated render function in WorkerForm.ts should set the invalid property on all form field widgets to reflect formErrors. We also add a novalidate attribute to the form element to prevent native browser validation.

  1. protected render() {
  2. const {
  3. formData: { firstName, lastName, email },
  4. formErrors
  5. } = this.properties;
  6. return v('form', {
  7. classes: this.theme(css.workerForm),
  8. novalidate: 'true',
  9. onsubmit: this._onSubmit
  10. }, [
  11. v('fieldset', { classes: this.theme(css.nameField) }, [
  12. v('legend', { classes: this.theme(css.nameLabel) }, [ 'Name' ]),
  13. w(TextInput, {
  14. key: 'firstNameInput',
  15. label:'First Name',
  16. labelHidden: true,
  17. placeholder: 'Given name',
  18. value: firstName,
  19. required: true,
  20. invalid: this.properties.formErrors.firstName,
  21. onInput: this.onFirstNameInput
  22. }),
  23. w(TextInput, {
  24. key: 'lastNameInput',
  25. label: 'Last Name',
  26. labelHidden: true,
  27. placeholder: 'Surname name',
  28. value: lastName,
  29. required: true,
  30. invalid: this.properties.formErrors.lastName,
  31. onInput: this.onLastNameInput
  32. })
  33. ]),
  34. w(TextInput, {
  35. label: 'Email address',
  36. type: 'email',
  37. value: email,
  38. required: true,
  39. invalid: this.properties.formErrors.email,
  40. onInput: this.onEmailInput
  41. }),
  42. w(Button, {}, [ 'Save' ])
  43. ]);
  44. }

Now when you view the app in the browser, the border color of each form field changes as you type. Next we’ll add error messages and update onInput validation to only occur after the first blur event.

Extending TextInput

Create an error message

Simply changing the border color of form fields to be red or green doesn’t impart much information to the user – we need to add some error message text along with invalid state. On a basic level, our error text must be associated with a form input, styleable, and accessible. A single form field with an error message might look something like this:

  1. v('div', { classes: this.theme(css.inputWrapper) }, [
  2. w(TextInput, {
  3. ...
  4. aria: {
  5. describedBy: this._errorId
  6. },
  7. onInput: this._onInput
  8. }),
  9. invalid === true ? v('span', {
  10. id: this._errorId,
  11. classes: this.theme(css.error),
  12. 'aria-live': 'polite'
  13. }, [ 'Please enter valid text for this field' ]) : null
  14. ])

The error message is associated with the text input through aria-describedby, and the aria-live attribute ensures it will be read if it is added to the DOM or changed. Wrapping both the input and the error message in a containing <div> allows us to position the error message relative to the input if desired.

Extend TextInput to create a ValidatedTextInput widget with an error message and onValidate method

Re-creating the same error message boilerplate for multiple text inputs seems overly repetitive, so we’re going to extend TextInput instead. This will also allow us to have better control over when validation occurs, e.g. by adding it to blur events as well. For now, just create a ValidatedTextInput widget that accepts the same properties interface as TextInput but with an errorMessage string and onValidate method. It should return the same node structure modeled above.

You will also need to create validatedTextInput.m.css with error and inputWrapper classes, although we will forgo adding specific styles in this tutorial:

  1. .inputWrapper {}
  2. .error {}
  1. import { WidgetBase } from '@dojo/framework/widget-core/WidgetBase';
  2. import { TypedTargetEvent } from '@dojo/framework/widget-core/interfaces';
  3. import { v, w } from '@dojo/framework/widget-core/d';
  4. import uuid from '@dojo/framework/core/uuid';
  5. import { ThemedMixin, theme } from '@dojo/framework/widget-core/mixins/Themed';
  6. import TextInput, { TextInputProperties } from '@dojo/widgets/text-input';
  7. import * as css from '../styles/validatedTextInput.m.css';
  8. export interface ValidatedTextInputProperties extends TextInputProperties {
  9. errorMessage?: string;
  10. onValidate?: (value: string) => void;
  11. }
  12. export const ValidatedTextInputBase = ThemedMixin(WidgetBase);
  13. @theme(css)
  14. export default class ValidatedTextInput extends ValidatedTextInputBase<ValidatedTextInputProperties> {
  15. private _errorId = uuid();
  16. protected render() {
  17. const {
  18. disabled,
  19. label,
  20. maxLength,
  21. minLength,
  22. name,
  23. placeholder,
  24. readOnly,
  25. required,
  26. type = 'text',
  27. value,
  28. invalid,
  29. errorMessage,
  30. onBlur,
  31. onInput
  32. } = this.properties;
  33. return v('div', { classes: this.theme(css.inputWrapper) }, [
  34. w(TextInput, {
  35. aria: {
  36. describedBy: this._errorId
  37. },
  38. disabled,
  39. invalid,
  40. label,
  41. maxLength,
  42. minLength,
  43. name,
  44. placeholder,
  45. readOnly,
  46. required,
  47. type,
  48. value,
  49. onBlur,
  50. onInput
  51. }),
  52. invalid === true ? v('span', {
  53. id: this._errorId,
  54. classes: this.theme(css.error),
  55. 'aria-live': 'polite'
  56. }, [ errorMessage ]) : null
  57. ]);
  58. }
  59. }

You may have noticed that we created ValidatedTextInput with an onValidate property, but we have yet to use it. This will become important in the next few steps by allowing us to have greater control over when validation occurs. For now, just treat it as a placeholder.

Use ValidatedTextInput within WorkerForm

Now that ValidatedTextInput exists, let’s import it and swap it with TextInput in WorkerForm, and write some error message text while we’re at it:

Import block

  1. import { WidgetBase } from '@dojo/framework/widget-core/WidgetBase';
  2. import { TypedTargetEvent } from '@dojo/framework/widget-core/interfaces';
  3. import { v, w } from '@dojo/framework/widget-core/d';
  4. import { ThemedMixin, theme } from '@dojo/framework/widget-core/mixins/Themed';
  5. import Button from '@dojo/widgets/button';
  6. import ValidatedTextInput from './ValidatedTextInput';
  7. import * as css from '../styles/workerForm.m.css';

Inside render()

  1. v('fieldset', { classes: this.theme(css.nameField) }, [
  2. v('legend', { classes: this.theme(css.nameLabel) }, [ 'Name' ]),
  3. w(ValidatedTextInput, {
  4. key: 'firstNameInput',
  5. label: 'First Name',
  6. labelHidden: true,
  7. placeholder: 'Given name',
  8. value: firstName,
  9. required: true,
  10. onInput: this.onFirstNameInput,
  11. onValidate: this.onFirstNameValidate,
  12. invalid: formErrors.firstName,
  13. errorMessage: 'First name is required'
  14. }),
  15. w(ValidatedTextInput, {
  16. key: 'lastNameInput',
  17. label: 'Last Name',
  18. labelHidden: true,
  19. placeholder: 'Surname name',
  20. value: lastName,
  21. required: true,
  22. onInput: this.onLastNameInput,
  23. onValidate: this.onLastNameValidate,
  24. invalid: formErrors.lastName,
  25. errorMessage: 'Last name is required'
  26. })
  27. ]),
  28. w(ValidatedTextInput, {
  29. label: 'Email address',
  30. type: 'email',
  31. value: email,
  32. required: true,
  33. onInput: this.onEmailInput,
  34. onValidate: this.onEmailValidate,
  35. invalid: formErrors.email,
  36. errorMessage: 'Please enter a valid email address'
  37. }),

Create onFormValidate method separate from onFormInput

Update the context to pass in an onFormValidate method

Currently the validation logic is unceremoniously dumped in formInput within ApplicationContext.ts. Now let’s break that out into its own formValidate function, and borrow the onFormInput pattern to pass onFormValidate to WorkerForm. There are three steps to this:

  • Add a formValidate method to ApplicationContext.ts and update _formErrors there instead of in formInput:
  1. public formValidate(input: Partial<WorkerFormData>): void {
  2. this._formErrors = deepAssign({}, this._formErrors, this._validateInput(input));
  3. this._invalidator();
  4. }
  5. public formInput(input: Partial<WorkerFormData>): void {
  6. this._formData = deepAssign({}, this._formData, input);
  7. this._invalidator();
  8. }
  • Update WorkerFormContainer to pass formValidate as onFormValidate:
  1. function getProperties(inject: ApplicationContext, properties: any) {
  2. const {
  3. formData,
  4. formErrors,
  5. formInput: onFormInput,
  6. formValidate: onFormValidate,
  7. submitForm: onFormSave
  8. } = inject;
  9. return {
  10. formData,
  11. formErrors,
  12. onFormInput: onFormInput.bind(inject),
  13. onFormValidate: onFormValidate.bind(inject),
  14. onFormSave: onFormSave.bind(inject)
  15. };
  16. }
  • Within WorkerForm first add onFormValidate to the WorkerFormProperties interface:
  1. export interface WorkerFormProperties {
  2. formData: WorkerFormData;
  3. formErrors: WorkerFormErrors;
  4. onFormInput: (data: Partial<WorkerFormData>) => void;
  5. onFormValidate: (data: Partial<WorkerFormData>) => void;
  6. onFormSave: () => void;
  7. }

Then create internal methods for each form field’s validation and pass those methods (e.g. onFirstNameValidate) to each ValidatedTextInput widget. This should follow the same pattern as onFormInput and onFirstNameInput, onLastNameInput, and onEmailInput:

  1. protected onFirstNameValidate(firstName: string) {
  2. this.properties.onFormValidate({ firstName });
  3. }
  4. protected onLastNameValidate(lastName: string) {
  5. this.properties.onFormValidate({ lastName });
  6. }
  7. protected onEmailValidate(email: string) {
  8. this.properties.onFormValidate({ email });
  9. }

Handle calling onValidate within ValidatedTextInput

You might have noticed that the form no longer validates on user input events. This is because we no longer handle validation within formInput in ApplicationContext.ts, but we also haven’t added it anywhere else. To do that, add the following private method to ValidatedTextInput:

  1. private _onInput(value: string) {
  2. const { onInput, onValidate } = this.properties;
  3. onInput && onInput(value);
  4. onValidate && onValidate(value);
  5. }

Now pass it to TextInput in place of this.properties.onInput:

  1. w(TextInput, {
  2. aria: {
  3. describedBy: this._errorId
  4. },
  5. disabled,
  6. invalid,
  7. label,
  8. maxLength,
  9. minLength,
  10. name,
  11. placeholder,
  12. readOnly,
  13. required,
  14. type,
  15. value,
  16. onBlur,
  17. onInput: this._onInput
  18. })

Form errors should be back now, along with error messages for invalid fields.

Making use of the blur event

Only begin validation after the first blur event

Right now the form displays validation as soon as the user begins typing in a field, which can be a poor user experience. Seeing “invalid email address” types of errors at the beginning of typing an email is both unnecessary and distracting. A better pattern would be to hold off on validation until the first blur event, and then begin updating the validation on input events.

Blur events
The blur event fires when an element loses focus.

Now that calling onValidate is handled within the ValidatedTextInput widget, this is possible.

Create a private _onBlur function that calls onValidate

In ValidatedTextInput.ts:

  1. private _onBlur(value: string) {
  2. const { onBlur, onValidate } = this.properties;
  3. onValidate && onValidate(value);
  4. onBlur && onBlur();
  5. }

We only need to use this function on the first blur event, since subsequent validation can be handled by onInput. The following code will use either this._onBlur or this.properties.onBlur depending on whether the input has been previously validated:

  1. w(TextInput, {
  2. aria: {
  3. describedBy: this._errorId
  4. },
  5. disabled,
  6. invalid,
  7. label,
  8. maxLength,
  9. minLength,
  10. name,
  11. placeholder,
  12. readOnly,
  13. required,
  14. type,
  15. value,
  16. onBlur: typeof invalid === 'undefined' ? this._onBlur : onBlur,
  17. onInput: this._onInput
  18. }),

Now all that remains is to modify _onInput to only call onValidate if the field already has a validation state:

  1. private _onInput(value: string) {
  2. const { invalid, onInput, onValidate } = this.properties;
  3. onInput && onInput(value);
  4. if (typeof invalid !== 'undefined') {
  5. onValidate && onValidate(value);
  6. }
  7. }

Try inputting an email address with these changes; it should only show an error message (or green border) after leaving the form field, while subsequent edits immediately trigger changes in validation.

Validating on submit

Create mock server-side validation when the form is submitted

Thus far our code provides nice hints to the user, but does nothing to prevent bad data being submitted to our worker array. We need to add two separate checks to the submitForm action:

  • Immediately fail to submit if the existing validation function catches any errors.
  • Perform some additional checks (in this case we’ll look for email uniqueness). This is where we would insert server-side validation in a real app.
    Create a private _validateOnSubmit method in ApplicationContext.ts

The new _validateOnSubmit should start by running the existing input validation against all _formData, and returning false if there are any errors:

  1. private _validateOnSubmit(): boolean {
  2. const errors = this._validateInput(this._formData);
  3. this._formErrors = deepAssign({ firstName: true, lastName: true, email: true }, errors);
  4. if (this._formErrors.firstName || this._formErrors.lastName || this._formErrors.email) {
  5. console.error('Form contains errors');
  6. return false;
  7. }
  8. return true;
  9. }

Next let’s add an extra check: let’s say each worker’s email must be unique, so we’ll test the input email value against the _workerData array. Realistically this check would be performed server-side for security:

  1. private _validateOnSubmit(): boolean {
  2. const errors = this._validateInput(this._formData);
  3. this._formErrors = deepAssign({ firstName: true, lastName: true, email: true }, errors);
  4. if (this._formErrors.firstName || this._formErrors.lastName || this._formErrors.email) {
  5. console.error('Form contains errors');
  6. return false;
  7. }
  8. for (let worker of this._workerData) {
  9. if (worker.email === this._formData.email) {
  10. console.error('Email must be unique');
  11. return false;
  12. }
  13. }
  14. return true;
  15. }

After modifying the submitForm function in ApplicationContext.ts, only valid worker entries should successfully submit. We also need to clear _formErrors along with _formData on a successful submission:

  1. public submitForm(): void {
  2. if (!this._validateOnSubmit()) {
  3. this._invalidator();
  4. return;
  5. }
  6. this._workerData = [ ...this._workerData, this._formData ];
  7. this._formData = {};
  8. this._formErrors = {};
  9. this._invalidator();
  10. }

Summary

There is no way this tutorial could cover all possible use cases, but the basic patterns for storing, injecting, and displaying validation state provide a strong base for creating more complex form validation. Some possible next steps include:

  • Configuring error messages in an object passed to WorkerForm
  • Creating a toast to display submission-time errors
  • Add multiple validation steps for a single form field
    If you would like, you can open the completed demo application on codesandbox.io or alternatively download the project.