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!
Join the DZone community and get the full member experience.
Join For FreeWelcome 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.
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
.
// 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.
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: startDate
, startTime
, endDate
, 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 export
ing the EventModel
class directly, we'll move the export
statement 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 required
, pattern
, minlength
, and maxlength
). We'll create the custom validator for date
shortly. 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
}
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 Date
object 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 month
, day
, 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 400
or 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!
Tune in tomorrow when we'll cover Angular event forms and how to create custom form group validation!
Published at DZone with permission of Kim Maida, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments