Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Real-World Angular Series, Part 6b: Reactive Forms and Custom Validation

DZone's Guide to

Real-World Angular Series, Part 6b: Reactive Forms and Custom Validation

In this installment, we add code to our application that allows it to recognize dates as valid and invalid. To do so, we'll use reactive forms. Read on to learn how!

· Web Dev Zone ·
Free Resource

Deploy code to production now. Release to users when ready. Learn how to separate code deployment from user-facing feature releases with LaunchDarkly.

Welcome back! If you missed the first part of this article, you can find it here

Angular: Reactive Event Form Setup

We're now ready to start building our events form component. We used a template-driven approach with our RSVP form. The event form will be a reactive form.

Angular Reactive Forms

Reactive forms, or model-driven forms, build and implement form logic in the component class rather than the template. This enables direct control over creation and manipulation of form control objects with JavaScript. A tree of form controls is created in the class and then bound to native form elements in the template.

This approach gives us much more control over testing and validation. We can also execute dynamic logic whenever any value in the form has changed.

Note: We won't cover testing in this tutorial series, but you can learn more about it in this article and others like it: Angular2 FormBuilder Unit Tests.

Reactive forms are synchronous. In addition, because the data model is generated in the component class, all form controls are always available. This differs from template-driven forms, which are asynchronous. Therefore, the controls in template-driven forms are not consistently available at all times.

Reactive forms yield lightweight templates but can result in apparently complex component classes. The risk of indirection is higher for developers coming into a project. However, the gains include much more granular control, as well as the ability to implement robust, strongly customized validation—particularly when multiple form controls need to be validated as a group.

Event Form Requirements

Let's outline the requirements for our event form. This will help us plan our logic. It should also make it clear why a reactive approach is necessary. Our event form needs the following:

  • Title field with simple validation.
  • Location field with simple validation.
  • A valid start date (e.g., 1/25/2018) at least one day in the future.
  • A valid start time (e.g., 11:30 AM).
  • A valid end date in the future, later than or equal to the start date.
  • A valid end time, later than or equal to the start date + time.
  • Start/end dates and times should be able to be entered in any order while still validating appropriately with whatever information is currently available.
  • Option to make the event public or not.
  • Description field with simple max character validation.

As you can see, the bulk of complex validation has to do with dates/times and comparing date-times to each other as well as the current date. Implementing this kind of group validation would be incredibly difficult with a template-driven form.

However, reactive forms make this quite feasible. There are many moving parts involved though, so let's do a little bit of architectural planning as well. Here's what we'll need in order to implement our reactive form with group validation:

  • ReactiveFormsModule in app module.
  • Regular expressions and strings-to-date function in formUtils factory to share between validators and component class.
  • Event form model (differs from existing API event model).
  • Service providing validation configuration and messages for component class and template.
  • Date validator factory: correctly-formatted, valid date in the future.
  • Date range group validator factory: ensure end date-time is not before start date-time.
  • Build reactive form in component class, reacting to form changes to update validation errors.
  • Build form template.

Let's get started!

Import the Reactive Forms Module

The ReactiveFormsModule resides in @angular/forms, so all we need to do to add it to our project is open our app.module.ts file and make a couple small updates:

// src/app/app.module.ts
...
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
...
@NgModule({
  ...,
  imports: [
    ...,
    ReactiveFormsModule
  ],
  ...
})
...

We'll import the ReactiveFormsModule and then add it to our NgModule's imports array. We can now take advantage of reactive forms in our components.

Update Form Utilities Factory

Let's add the necessary regular expressions and strings-to-date function to ourformUtils.factory.ts.

Note: Why aren't we putting these in an event form service? It's because our validator functions won't be classes with constructor methods, but they also need to import and utilize these helpers. Therefore, a factory is the most straightforward solution.

// src/app/core/forms/formUtils.factory.ts
...
// mm/dd/yyyy, m/d/yyyy
// https://regex101.com/r/7iSsmm/2
const DATE_REGEX = new RegExp(/^(\d{2}|\d)\/(\d{2}|\d)\/\d{4}$/);
// h:mm am/pm, hh:mm AM/PM
// https://regex101.com/r/j2Cfqd/1/
const TIME_REGEX = new RegExp(/^((1[0-2]|0?[1-9]):([0-5][0-9]) ([AaPp][Mm]))$/);

// Converts date + time strings to a Date object.
// Date and time parameters should have already
// been validated with DATE_REGEX and TIME_REGEX.
function stringsToDate(dateStr: string, timeStr: string) {
  if (!DATE_REGEX.test(dateStr) || !TIME_REGEX.test(timeStr)) {
    console.error('Cannot convert date/time to Date object.');
    return;
  }
  const date = new Date(dateStr);
  const timeArr = timeStr.split(/[\s:]+/); // https://regex101.com/r/H4dMvA/1
  let hour = parseInt(timeArr[0], 10);
  const min = parseInt(timeArr[1], 10);
  const pm = timeArr[2].toLowerCase() === 'pm';

  if (!pm && hour === 12) {
    hour = 0;
  }
  if (pm && hour < 12) {
    hour += 12;
  }
  date.setHours(hour);
  date.setMinutes(min);
  return date;
}

export { ..., DATE_REGEX, TIME_REGEX, stringsToDate };

The TIME_REGEX regular expression matches date strings in the general format m/d/yyyy. TheTIME_REGEX regular expression matches strings in the general format h:mm am/pm.

Note: Check out the links to see full explanations of these regular expressions and to enter your own test strings.

The stringsToDate() function accepts a dateStr string and timeStr string as parameters and returns a JavaScript Date in the user's local time zone. This function is needed because the user enters strings in the event form, but we need to do date comparisons for validation, as well as submit startDatetime and endDatetime as date objects.

When we call this function, we'll expect that the parameters have already been validated against the appropriate regular expressions. We'll do a quick check to make sure, log an error, andreturn just in case something went wrong.

Then we'll use the dateStr to create a new JS date object. This date has no time set yet, so it will default to midnight. We'll need to use the timeStr to set hours and minutes. We can create an array from the timeStr, splitting on colons and spaces. This way, we can get hours, minutes, and AM/PM. Our array is all strings, so we'll use parseInt() to cast the hours and minutes as numbers. AM/PM could be entered as uppercase or lowercase, so we'll use the toLowerCase()string method and do a comparison to cast pm as a boolean.

The setHours() date method expects 24 hours (0-24), but we're working with a 12-hour string. We'll translate hours to the appropriate 24-hour time based on the pm boolean. Then we cansetHours() and setMinutes() to create our full date object, which we'll return.

Finally, we need to export the new members we created so they can be imported by other files.

Add Form Event Model

Our existing EventModel for sending and retrieving data from the API is not exactly the same as the model we want for our event form. Recall that MongoDB stores startDatetime andendDatetime as dates. However, our form will have four separate fields with string values: startDatestartTimeendDate, and endTime. We'll use the stringsToDate() function we just created to convert these form control values to date objects before we submit the form, but this means we need a different model for the form itself.

Open the event.model.ts file and make the following additions:

// src/app/core/models/event.model.ts
class EventModel {
  ...
}

class FormEventModel {
  constructor(
    public title: string,
    public location: string,
    public startDate: string,
    public startTime: string,
    public endDate: string,
    public endTime: string,
    public viewPublic: boolean,
    public description?: string
  ) { }
}

export { EventModel, FormEventModel };

Instead of exporting the EventModel class directly, we'll move the exportstatement to the bottom. We'll also add a FormEventModel class. This differs from our EventModel: it has separate fields for start and end dates and times, all annotated with type string.

Create Event Form Service

Now we'll create an event form service with the Angular CLI:

$ ng g service pages/admin/event-form/event-form

This scaffolds an event-form.service.ts file. Open it and add the following code:

// src/app/pages/admin/event-form/event-form.service.ts
import { Injectable } from '@angular/core';

@Injectable()
export class EventFormService {
  validationMessages: any;
  // Set up errors object
  formErrors = {
    title: '',
    location: '',
    viewPublic: '',
    description: '',
    datesGroup: {
      startDate: '',
      startTime: '',
      endDate: '',
      endTime: '',
    }
  };
  // Min/maxlength validation
  textMin = 3;
  titleMax = 36;
  locMax = 200;
  dateMax = 10;
  timeMax = 8;
  descMax = 2000;
  // Formats
  dateFormat = 'm/d/yyyy';
  timeFormat = 'h:mm AM/PM';

  constructor() {
    this.validationMessages = {
      title: {
        required: `Title is <strong>required</strong>.`,
        minlength: `Title must be ${this.textMin} characters or more.`,
        maxlength: `Title must be ${this.titleMax} characters or less.`
      },
      location: {
        required: `Location is <strong>required</strong>.`,
        minlength: `Location must be ${this.textMin} characters or more.`,
        maxlength: `Location must be ${this.locMax} characters or less.`
      },
      startDate: {
        required: `Start date is <strong>required</strong>.`,
        maxlength: `Start date cannot be longer than ${this.dateMax} characters.`,
        pattern: `Start date must be in the format <strong>${this.dateFormat}</strong>.`,
        date: `Start date must be a <strong>valid date</strong> at least one day <strong>in the future</strong>.`
      },
      startTime: {
        required: `Start time is <strong>required</strong>.`,
        pattern: `Start time must be a <strong>valid time</strong> in the format <strong>${this.timeFormat}</strong>.`,
        maxlength: `Start time must be ${this.timeMax} characters or less.`
      },
      endDate: {
        required: `End date is <strong>required</strong>.`,
        maxlength: `End date cannot be longer than ${this.dateMax} characters.`,
        pattern: `End date must be in the format <strong>${this.dateFormat}</strong>.`,
        date: `End date must be a <strong>valid date</strong> at least one day <strong>in the future</strong>.`
      },
      endTime: {
        required: `End time is <strong>required</strong>.`,
        pattern: `End time must be a <strong>valid time</strong> in the format <strong>${this.timeFormat}</strong>.`,
        maxlength: `End time must be ${this.timeMax} characters or less.`
      },
      viewPublic: {
        required: `You must specify whether this event should be publicly listed.`
      },
      description: {
        maxlength: `Description must be ${this.descMax} characters or less.`
      }
    };
  }

}

We'll use an object to map our validationMessages. In our component class, we'll then update a formErrors object with the appropriate messages based on the results of validation. Let's also set up minimum and maximum field lengths. These will be used in the component class, in the template (for HTML5 validation), and in the validation error messages. We'll also create date and time format strings that can be used in the template as placeholder attributes and in thevalidationMessages.

After setting up the validation properties, we'll access these properties in the constructor() to set the validationMessages appropriately for each form control. Most of the validators are built-in (such as requiredpatternminlength, and maxlength). We'll create the custom validator for dateshortly. For now, we can set up a message indicating what this custom validator will check: that the date is valid and at least one day in the future.

Angular: Custom Form Validation

Let's write some custom validation for our Event Form component. Custom validators are functions of type ValidatorFn. A validator function returns another function which takes a form control as a parameter and either returns null if validation passes or shouldn't run yet or an object with a key/value pair if validation fails. The returned object generally consists of the intended validator name and a boolean value of true, indicating that there is an error, like this:

// returned by validator for 'date' if value is invalid
{
  'date': true
}

Note: You can check out the Angular custom validation documentation for a simple custom validator example.

Date Validator Requirements

We'll create a validator function that does the following:

  • Validates that the string input reflects a real date.
  • Validates that the date is in the future.

Remember that our dates are going to be entered as strings like the following:

5/25/2017
06/01/2017

Without relying on a third-party datepicker or HTML5 date input elements that aren't widely supported by all browsers, we want to be able to make sure the user can't enter something that looks like a valid date but isn't, such as 2/31/2017.

If we create a JS date based on this (new Date('2/31/2017')), we'll get a Dateobject for March 3. This is a valid JavaScript date because of automatic conversion, but we don't want to allow users to input things like 2/31/2017 and just assume they want the date converted. Therefore, simply using pattern validation and then creating a new JS date will not be sufficient for validating this field.

Fortunately, it's quite straightforward to construct the appropriate validation for what we want.

Create Date Validator

Let's start by creating and exporting the validator function. Make a new file in the src/app/core/forms directory called date.validator.ts:

// src/app/core/forms/date.validator.ts
import { AbstractControl, ValidatorFn } from '@angular/forms';
import { DATE_REGEX } from './formUtils.factory';

export function dateValidator(): ValidatorFn {
  return (control: AbstractControl): {[key: string]: any} => {
    const dateStr = control.value;
    // First check for m/d/yyyy format
    // If pattern is wrong, don't validate yet
    if (!DATE_REGEX.test(dateStr)) {
      return null;
    }
    // Length of months (will update for leap years)
    const monthLengthArr = [ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
    // Object to return if date is invalid
    const invalidObj = { 'date': true };
    // Parse the date input to integers
    const dateArr = dateStr.split('/');
    const month = parseInt(dateArr[0], 10);
    const day = parseInt(dateArr[1], 10);
    const year = parseInt(dateArr[2], 10);
    // Today's date
    const now = new Date();

    // Validate year and month
    if (year < now.getFullYear() || year > 3000 || month === 0 || month > 12) {
      return invalidObj;
    }
    // Adjust for leap years
    if (year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0)) {
      monthLengthArr[1] = 29;
    }
    // Validate day
    if (!(day > 0 && day <= monthLengthArr[month - 1])) {
      return invalidObj;
    };
    // If date is properly formatted, check the date vs today to ensure future
    // This is done this way to account for new Date() shifting invalid
    // date strings. This way we know the string is a correct date first.
    const date = new Date(dateStr);
    if (date <= now) {
      return invalidObj;
    }
    return null;
  };
}

First, we'll import the AbstractControl class and ValidatorFn interface from@angular/forms. We'll also need the DATE_REGEX constant from our form utilities.

As mentioned above, the validator function returns another function which accesses the form control. This is how we'll get the input value that needs validation. We'll set a local dateStr constant to the control.value.

We'll use the test() method to check if the dateStr matches the DATE_REGEX. If it doesn't, we should not proceed with validating the date string: it's not in the proper format yet. We'll returnnull and wait to validate until the value matches the pattern.

We'll set a monthLengthArr array with the number of days in each month, handling leap years later on. This lets us verify that the user hasn't entered an invalid day for any given month.

The invalidObj is what we'll return if validation fails. We'll also split() the dateStr on '/' to get an array containing the monthday, and year, which we'll parse as integers. We'll also create a new date representing now(today's date) for comparisons to ensure the form value is in the future.

Next, we'll make sure the date has a valid year and month. If the year is less than now's year, greater than 3000, the month is 0, or the month is greater than 12, the date is invalid and we'll return the invalidObj.

Now we'll adjust for leap years and then validate the day. If the year is evenly divisible by 400or not evenly divisible by 100 but is evenly divisible by 4, then it's a leap year. In this case, the number of days in February (monthLengthArr[1]) should be 29 instead of 28. We can then validate that the inputted day is greater than 0 and less than or equal to the number of days in the specified month. If this is not true, we'll return the invalidObj.

If the code is still executing at this point, we can determine that the dateStr is a valid date. We can now create a JS date object (date) and compare it to now. If the inputted date is less than or equal to now, the date is in the past and we'll return the invalidObj. Otherwise, all validation has passed, so we'll return null.

We now have a dateValidator function that we can use in our reactive form to validate our date inputs!

Note: In order to use custom validator functions with template-driven forms, we would need to create a validation directive to attach the validator behavior to a form DOM element. You can read more about this in the Angular custom validation documentation. We don't need a directive to use the validator with a reactive form because we'll associate the validator directly with the model rather than the template's input element.

Tune in tomorrow when we'll cover Angular event forms and how to create custom form group validation!

Deploy code to production now. Release to users when ready. Learn how to separate code deployment from user-facing feature releases with LaunchDarkly.

Topics:
web dev ,angular ,web application development ,reactive forms

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}