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

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

DZone's Guide to

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

We discuss in detail reactive forms with custom validation, allowing our users to receive real-time feedback on the authentication data they enter.

· Web Dev Zone
Free Resource

Add user login and MFA to your next project in minutes. Create a free Okta developer account, drop in one of our SDKs to your application and get back to building.

Welcome back! If you missed the first parts of this article, check them out here (Part 6a and Part 6b)!

Angular: Event Form

We're now ready to start building our Event Form component.

Note: We still need to create validation for comparing start and end dates and times as a group, but we'll address that after building the form.

Event Form Class

Open the event-form.component.ts file and let's get started.

// src/app/pages/admin/event-form/event-form.component.ts
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { FormGroup, FormBuilder, Validators, AbstractControl } from '@angular/forms';
import { Router } from '@angular/router';
import { Subscription } from 'rxjs/Subscription';
import { ApiService } from './../../../core/api.service';
import { EventModel, FormEventModel } from './../../../core/models/event.model';
import { DatePipe } from '@angular/common';
import { dateValidator } from './../../../core/forms/date.validator';
import { DATE_REGEX, TIME_REGEX, stringsToDate } from './../../../core/forms/formUtils.factory';
import { EventFormService } from './event-form.service';

@Component({
  selector: 'app-event-form',
  templateUrl: './event-form.component.html',
  styleUrls: ['./event-form.component.scss'],
  providers: [ EventFormService ]
})
export class EventFormComponent implements OnInit, OnDestroy {
  @Input() event: EventModel;
  isEdit: boolean;
  // FormBuilder form
  eventForm: FormGroup;
  datesGroup: AbstractControl;
  // Model storing initial form values
  formEvent: FormEventModel;
  // Form validation and disabled logic
  formErrors: any;
  formChangeSub: Subscription;
  // Form submission
  submitEventObj: EventModel;
  submitEventSub: Subscription;
  error: boolean;
  submitting: boolean;
  submitBtnText: string;

  constructor(
    private fb: FormBuilder,
    private api: ApiService,
    private datePipe: DatePipe,
    public ef: EventFormService,
    private router: Router) { }

  ngOnInit() {
    this.formErrors = this.ef.formErrors;
    this.isEdit = !!this.event;
    this.submitBtnText = this.isEdit ? 'Update Event' : 'Create Event';
    // Set initial form data
    this.formEvent = this._setFormEvent();
    // Use FormBuilder to construct the form
    this._buildForm();
  }

  private _setFormEvent() {
    if (!this.isEdit) {
      // If creating a new event, create new
      // FormEventModel with default null data
      return new FormEventModel(null, null, null, null, null, null, null);
    } else {
      // If editing existing event, create new
      // FormEventModel from existing data
      // Transform datetimes:
      // https://angular.io/api/common/DatePipe
      // 'shortDate': 9/3/2010
      // 'shortTime': 12:05 PM
      return new FormEventModel(
        this.event.title,
        this.event.location,
        this.datePipe.transform(this.event.startDatetime, 'shortDate'),
        this.datePipe.transform(this.event.startDatetime, 'shortTime'),
        this.datePipe.transform(this.event.endDatetime, 'shortDate'),
        this.datePipe.transform(this.event.endDatetime, 'shortTime'),
        this.event.viewPublic,
        this.event.description
      );
    }
  }

  private _buildForm() {
    this.eventForm = this.fb.group({
      title: [this.formEvent.title, [
        Validators.required,
        Validators.minLength(this.ef.textMin),
        Validators.maxLength(this.ef.titleMax)
      ]],
      location: [this.formEvent.location, [
        Validators.required,
        Validators.minLength(this.ef.textMin),
        Validators.maxLength(this.ef.locMax)
      ]],
      viewPublic: [this.formEvent.viewPublic,
        Validators.required
      ],
      description: [this.formEvent.description,
        Validators.maxLength(this.ef.descMax)
      ],
      datesGroup: this.fb.group({
        startDate: [this.formEvent.startDate, [
          Validators.required,
          Validators.maxLength(this.ef.dateMax),
          Validators.pattern(DATE_REGEX),
          dateValidator()
        ]],
        startTime: [this.formEvent.startTime, [
          Validators.required,
          Validators.maxLength(this.ef.timeMax),
          Validators.pattern(TIME_REGEX)
        ]],
        endDate: [this.formEvent.endDate, [
          Validators.required,
          Validators.maxLength(this.ef.dateMax),
          Validators.pattern(DATE_REGEX),
          dateValidator()
        ]],
        endTime: [this.formEvent.endTime, [
          Validators.required,
          Validators.maxLength(this.ef.timeMax),
          Validators.pattern(TIME_REGEX)
        ]]
      })
    });
    // Set local property to eventForm datesGroup control
    this.datesGroup = this.eventForm.get('datesGroup');

    // Subscribe to form value changes
    this.formChangeSub = this.eventForm
      .valueChanges
      .subscribe(data => this._onValueChanged(data));

    // If edit: mark fields dirty to trigger immediate
    // validation in case editing an event that is no
    // longer valid (for example, an event in the past)
    if (this.isEdit) {
      const _markDirty = group => {
        for (const i in group.controls) {
          if (group.controls.hasOwnProperty(i)) {
            group.controls[i].markAsDirty();
          }
        }
      };
      _markDirty(this.eventForm);
      _markDirty(this.datesGroup);
    }

    this._onValueChanged();
  }

  private _onValueChanged(data?: any) {
    if (!this.eventForm) { return; }
    const _setErrMsgs = (control: AbstractControl, errorsObj: any, field: string) => {
      if (control && control.dirty && control.invalid) {
        const messages = this.ef.validationMessages[field];
        for (const key in control.errors) {
          if (control.errors.hasOwnProperty(key)) {
            errorsObj[field] += messages[key] + '<br>';
          }
        }
      }
    };

    // Check validation and set errors
    for (const field in this.formErrors) {
      if (this.formErrors.hasOwnProperty(field)) {
        if (field !== 'datesGroup') {
          // Set errors for fields not inside datesGroup
          // Clear previous error message (if any)
          this.formErrors[field] = '';
          _setErrMsgs(this.eventForm.get(field), this.formErrors, field);
        } else {
          // Set errors for fields inside datesGroup
          const datesGroupErrors = this.formErrors['datesGroup'];
          for (const dateField in datesGroupErrors) {
            if (datesGroupErrors.hasOwnProperty(dateField)) {
              // Clear previous error message (if any)
              datesGroupErrors[dateField] = '';
              _setErrMsgs(this.datesGroup.get(dateField), datesGroupErrors, dateField);
            }
          }
        }
      }
    }
  }

  private _getSubmitObj() {
    const startDate = this.datesGroup.get('startDate').value;
    const startTime = this.datesGroup.get('startTime').value;
    const endDate = this.datesGroup.get('endDate').value;
    const endTime = this.datesGroup.get('endTime').value;
    // Convert form startDate/startTime and endDate/endTime
    // to JS dates and populate a new EventModel for submission
    return new EventModel(
      this.eventForm.get('title').value,
      this.eventForm.get('location').value,
      stringsToDate(startDate, startTime),
      stringsToDate(endDate, endTime),
      this.eventForm.get('viewPublic').value,
      this.eventForm.get('description').value,
      this.event ? this.event._id : null
    );
  }

  onSubmit() {
    this.submitting = true;
    this.submitEventObj = this._getSubmitObj();

    if (!this.isEdit) {
      this.submitEventSub = this.api
        .postEvent$(this.submitEventObj)
        .subscribe(
          this._handleSubmitSuccess.bind(this),
          this._handleSubmitError.bind(this)
        );
    } else {
      this.submitEventSub = this.api
        .editEvent$(this.event._id, this.submitEventObj)
        .subscribe(
          res => this._handleSubmitSuccess(res),
          err => this._handleSubmitError(err)
        );
    }
  }

  private _handleSubmitSuccess(res) {
    this.error = false;
    this.submitting = false;
    // Redirect to event detail
    this.router.navigate(['/event', res._id]);
  }

  private _handleSubmitError(err) {
    console.error(err);
    this.submitting = false;
    this.error = true;
  }

  resetForm() {
    this.eventForm.reset();
  }

  ngOnDestroy() {
    if (this.submitEventSub) {
      this.submitEventSub.unsubscribe();
    }
    this.formChangeSub.unsubscribe();
  }

}

There's a substantial amount of code and logic here, so we'll tackle it piece by piece.

We'll import things as we need them, so we won't cover every import up front. However, some of the significant functionality for our reactive form comes from the @angular/forms imports. These include FormGroupFormBuilderValidators, and AbstractControl.

We'll import and provide an instance of the EventFormService in our @Component()'s providers array rather than in the app module.

Note:EventFormService is only used by this component, so it doesn't need to be provided as an app-wide singleton. We only need one instance for the event form component.

Next, let's set up our component class properties. If editing an existing event, we need the@Input() event and an isEdit flag. When we build our reactive form, the eventForm will be a FormGroup. We'll also be creating a subgroup in the form for our datesGroup. We'll need to access this datesGroupAbstractControl and its properties throughout the form, particularly for validating the dates and times as a group. Then we need properties to handle form validation and submission.

In our constructor, we'll need the reactive FormBuilder and our trusty API service to submit events to MongoDB. We'll also use DatePipe to transform any existing date-times to date and time strings when editing an event. We'll use our EventFormService's properties and ensure that they're publicly available to the template. Finally, we'll need Router to redirect the user to the event detail page when they're finished adding or editing an event.

The ngOnInit() method will set up our form component. We'll set the local formErrors property to the formErrors member from our event form service. Then we'll set isEdit based on whether an event input was passed to the component. The submitBtnText is dependent on whether we're updating or creating an event. We'll then use two private methods to set theformEvent and to build the form itself.

The private _setFormEvent() method returns a new EventFormModel() based on whether a new event is being created or an existing event is being updated. In ngOnInit(), we set the form's model (formEvent) to the model that this function returns. If not editing, the EventFormModel instance is created with null set for all required fields. If editing an event, we'll populate theEventFormModel with the inputted event. We'll use DatePipe to transform start and end date-times to appropriately formatted strings.

Next, we'll create the _buildForm() method:

  private _buildForm() {
    this.eventForm = this.fb.group({
      title: [this.formEvent.title, [
        Validators.required,
        Validators.minLength(this.ef.textMin),
        Validators.maxLength(this.ef.titleMax)
      ]],
      location: [this.formEvent.location, [
        Validators.required,
        Validators.minLength(this.ef.textMin),
        Validators.maxLength(this.ef.locMax)
      ]],
      viewPublic: [this.formEvent.viewPublic,
        Validators.required
      ],
      description: [this.formEvent.description,
        Validators.maxLength(this.ef.descMax)
      ],
      datesGroup: this.fb.group({
        startDate: [this.formEvent.startDate, [
          Validators.required,
          Validators.maxLength(this.ef.dateMax),
          Validators.pattern(DATE_REGEX),
          dateValidator()
        ]],
        startTime: [this.formEvent.startTime, [
          Validators.required,
          Validators.maxLength(this.ef.timeMax),
          Validators.pattern(TIME_REGEX)
        ]],
        endDate: [this.formEvent.endDate, [
          Validators.required,
          Validators.maxLength(this.ef.dateMax),
          Validators.pattern(TIME_REGEX),
          dateValidator()
        ]],
        endTime: [this.formEvent.endTime, [
          Validators.required,
          Validators.maxLength(this.ef.timeMax),
          Validators.pattern(TIME_REGEX)
        ]]
      })
    });
    // Set local property to eventForm datesGroup control
    this.datesGroup = this.eventForm.get('datesGroup');

    // Subscribe to form value changes
    this.formChangeSub = this.eventForm
      .valueChanges
      .subscribe(data => this._onValueChanged(data));

    // If edit: mark fields dirty to trigger immediate
    // validation in case editing an event that is no
    // longer valid (for example, an event in the past)
    if (this.isEdit) {
      const _markDirty = group => {
        for (const i in group.controls) {
          if (group.controls.hasOwnProperty(i)) {
            group.controls[i].markAsDirty();
          }
        }
      };
      _markDirty(this.eventForm);
      _markDirty(this.datesGroup);
    }

    this._onValueChanged();
  }

The first thing we'll do in _buildForm() is create a FormBuilder group. This is our reactive form, and we'll use the eventForm property to interact with and respond to changes in the form itself. We'll pass a controls configuration object to the group() method.

Each field in our form is an object key with an array value. The first item in the array is the field's default value, which we'll populate with the corresponding properties from our formEvent model we created in the previous steps. The second item in the array is either a single validator or an array of validators. Validators are the built-in validation methods for Angular forms, such asrequiredminLength()pattern(), etc. We'll use the properties from ourEventFormService to set the appropriate min and max length validators, and also addrequired to mandatory fields.

Note: We can also use custom validators, such as our dateValidator() that we created in the Angular: Custom Form Validation section.

Notice that there is a datesGroup property in the form configuration object. This is another FormBuilder group. This group contains the fields for startDatestartTimeendDate, andendTime. We'll group them into their own control because we want to validate these fields together. The config object for this nested group should work the same. We'll add the necessary built-in validators. In addition, we'll add our custom dateValidator() to the validator arrays forstartDate and endDate. We'll also validate patterns using the DATE_REGEX and TIME_REGEX from our form utilities factory.

After building the form (eventForm), we'll set the Event Form component's datesGroup property to the nested group we created. We need to use the form's get() method in order to access the datesGroup form control safely.

We'll subscribe() to the event form's valueChanges observable. This stream updates whenever any value in the form is modified. We'll subscribe and handle changes with a private_onValueChanged() method that we'll create shortly.

There is a possibility that we can prefill the event form with data that is no longer considered valid. This may happen if we're editing an event that occurred in the past. Simply relisting an event that's already over is not acceptable: we'd expect that the admin should want to change any expired dates first. Therefore, we want to validate the form before the admin user has even interacted with it. To do this, we'll check to see if isEdit and if so, we'll mark all fields as dirty. This will trigger validation to run. We'll iterate over the controls in our eventForm group and in the nested datesGroup and markAsDirty().

Finally, we'll call the _onValueChanged() method so that it runs on initialization. This method looks like this:

  private _onValueChanged(data?: any) {
    if (!this.eventForm) { return; }
    const _setErrMsgs = (control: AbstractControl, errorsObj: any, field: string) => {
      if (control && control.dirty && control.invalid) {
        const messages = this.ef.validationMessages[field];
        for (const key in control.errors) {
          if (control.errors.hasOwnProperty(key)) {
            errorsObj[field] += messages[key] + '<br>';
          }
        }
      }
    };

    // Check validation and set errors
    for (const field in this.formErrors) {
      if (this.formErrors.hasOwnProperty(field)) {
        if (field !== 'datesGroup') {
          // Set errors for fields not inside datesGroup
          // Clear previous error message (if any)
          this.formErrors[field] = '';
          _setErrMsgs(this.eventForm.get(field), this.formErrors, field);
        } else {
          // Set errors for fields inside datesGroup
          const datesGroupErrors = this.formErrors['datesGroup'];
          for (const dateField in datesGroupErrors) {
            if (datesGroupErrors.hasOwnProperty(dateField)) {
              // Clear previous error message (if any)
              datesGroupErrors[dateField] = '';
              _setErrMsgs(this.datesGroup.get(dateField), datesGroupErrors, dateField);
            }
          }
        }
      }
    }
  }

Recall that our reactive form will handle validation and error messaging in the component class rather than in the template. In our EventFormService, we created an object called FormErrors with keys matching the form controls and values as empty strings. We created a local property from this object, which we'll now update to the current error state of our form whenever values have changed. We'll assess any errors from the form controls to map them to thevalidationMessages object in the event form service. Because datesGroup is nested, we'll need to do this for each of the eventForm and datesGroup form groups, so we'll abstract this logic to a _setErrMsgs() function.

We can then iterate over the formErrors object and set validation errors for any errors found on the form controls.

Before we can submit our form, we need to do a little bit of data preparation:

  private _getSubmitObj() {
    const startDate = this.datesGroup.get('startDate').value;
    const startTime = this.datesGroup.get('startTime').value;
    const endDate = this.datesGroup.get('endDate').value;
    const endTime = this.datesGroup.get('endTime').value;
    // Convert form startDate/startTime and endDate/endTime
    // to JS dates and populate a new EventModel for submission
    return new EventModel(
      this.eventForm.get('title').value,
      this.eventForm.get('location').value,
      stringsToDate(startDate, startTime),
      stringsToDate(endDate, endTime),
      this.eventForm.get('viewPublic').value,
      this.eventForm.get('description').value,
      this.event ? this.event._id : null
    );
  }

Remember that our API expects an EventModel, but the data we currently have in our form is a FormEventModel with start and end dates/times separated as strings. In order to submit the data to the API, we'll need to create a new EventModel(). We'll use our stringsToDate() factory function to get JavaScript dates for startDatetime and endDatetime. We'll also add the event's ID if it has one.

Once we have the new EventModel, we can submit it to the API like so:

  onSubmit() {
    this.submitting = true;
    this.submitEventObj = this._getSubmitObj();

    if (!this.isEdit) {
      this.submitEventSub = this.api
        .postEvent$(this.submitEventObj)
        .subscribe(
          data => this._handleSubmitSuccess(data),
          err => this._handleSubmitError(err)
        );
    } else {
      this.submitEventSub = this.api
        .editEvent$(this.event._id, this.submitEventObj)
        .subscribe(
          data => this._handleSubmitSuccess(data),
          err => this._handleSubmitError(err)
        );
    }
  }

  private _handleSubmitSuccess(res) {
    this.error = false;
    this.submitting = false;
    // Redirect to event detail
    this.router.navigate(['/event', res._id]);
  }

  private _handleSubmitError(err) {
    console.error(err);
    this.submitting = false;
    this.error = true;
  }

This should look familiar from our RSVP form component. We'll call the appropriate endpoint depending on whether isEdit is true or not. We'll then handle success or error. Our_handleSubmitSuccess() method will redirect the user to the newly created or updated event detail page.

Finally, we'll do a little housekeeping:

  resetForm() {
    this.eventForm.reset();
  }

  ngOnDestroy() {
    if (this.submitEventSub) {
      this.submitEventSub.unsubscribe();
    }
    this.formChangeSub.unsubscribe();
  }

The resetForm() method will clear and reset the form to a pristine, untouched state. We'll add a button to the template that will call this method when clicked.

Finally, we'll do our usual cleanup of subscriptions in ngOnDestroy().

Note: We'll add the date group validation after we implement the event form template.

Event Form Template

Our event-form.component.html template will be refreshingly straightforward. Open the file and add:

<!-- src/app/pages/admin/event-form/event-form.component.html -->
<form [formGroup]="eventForm" (ngSubmit)="onSubmit()">
  <!-- Title -->
  <div class="form-group">
    <label for="title">Title</label>
    <input
      id="title"
      type="text"
      class="form-control"
      formControlName="title"
      [maxlength]="ef.titleMax">
    <div
      *ngIf="formErrors.title"
      class="small text-danger formErrors"
      [innerHTML]="formErrors.title">
    </div>
  </div>

  <!-- Location -->
  <div class="form-group">
    <label for="location">Location</label>
    <input
      id="location"
      type="text"
      class="form-control"
      formControlName="location"
      [maxlength]="ef.locMax">
    <div
      *ngIf="formErrors.location"
      class="small text-danger formErrors"
      [innerHTML]="formErrors.location">
    </div>
  </div>

  <div
    formGroupName="datesGroup"
    [ngClass]="{'has-danger': eventForm.get('datesGroup').errors}">
    <div class="row">
      <!-- Start date -->
      <div class="form-group col-sm-12 col-md-6">
        <label for="startDate">Start Date</label>
        <input
          id="startDate"
          type="text"
          class="form-control"
          formControlName="startDate"
          [placeholder]="ef.dateFormat"
          [maxlength]="ef.dateMax">
        <div
          *ngIf="formErrors.datesGroup.startDate"
          class="small text-danger formErrors"
          [innerHTML]="formErrors.datesGroup.startDate">
        </div>
      </div>

      <!-- Start time -->
      <div class="form-group col-sm-12 col-md-6">
        <label for="startTime">Start Time</label>
        <input
          id="startTime"
          type="text"
          class="form-control"
          formControlName="startTime"
          [placeholder]="ef.timeFormat"
          [maxlength]="ef.timeMax">
        <div
          *ngIf="formErrors.datesGroup.startTime"
          class="small text-danger formErrors"
          [innerHTML]="formErrors.datesGroup.startTime">
        </div>
      </div>
    </div>

    <div class="row">
      <!-- End date -->
      <div class="form-group col-sm-12 col-md-6">
        <label for="endDate">End Date</label>
        <input
          id="endDate"
          type="text"
          class="form-control"
          formControlName="endDate"
          [placeholder]="ef.dateFormat"
          [maxlength]="ef.dateMax">
        <div
          *ngIf="formErrors.datesGroup.endDate"
          class="small text-danger formErrors"
          [innerHTML]="formErrors.datesGroup.endDate">
        </div>
      </div>

      <!-- End time -->
      <div class="form-group col-sm-12 col-md-6">
        <label for="endTime">End Time</label>
        <input
          id="endTime"
          type="text"
          class="form-control"
          formControlName="endTime"
          [placeholder]="ef.timeFormat"
          [maxlength]="ef.timeMax">
        <div
          *ngIf="formErrors.datesGroup.endTime"
          class="small text-danger formErrors"
          [innerHTML]="formErrors.datesGroup.endTime">
        </div>
      </div>
    </div>

    <p *ngIf="eventForm.get('datesGroup').errors" class="alert alert-danger small">
      <strong>Dates/times out of range:</strong> Events cannot end before they begin. Please double-check the start and end dates and times.
    </p>
  </div>

  <!-- View Public -->
  <div class="form-group">
    <label class="label-inline-group">List event publicly?</label>
    <div class="form-check form-check-inline">
      <label class="form-check-label">
        <input
          id="viewPublic-yes"
          type="radio"
          class="form-check-input"
          [value]="true"
          formControlName="viewPublic"> Yes
      </label>
    </div>
    <div class="form-check form-check-inline">
      <label class="form-check-label">
        <input
          id="viewPublic-no"
          type="radio"
          class="form-check-input"
          [value]="false"
          formControlName="viewPublic"> No
      </label>
    </div>
    <div
      *ngIf="formErrors.viewPublic"
      class="small text-danger formErrors"
      [innerHTML]="formErrors.viewPublic">
    </div>
  </div>

  <!-- Description -->
  <div class="form-group">
    <label for="description">Description:</label>
    <textarea
      id="description"
      class="form-control"
      rows="3"
      formControlName="description"
      [maxlength]="ef.descMax"></textarea>
    <div
      *ngIf="formErrors.description"
      class="small text-danger formErrors"
      [innerHTML]="formErrors.description">
    </div>
  </div>

  <!-- Submit -->
  <div class="form-group">
    <button
      type="submit"
      class="btn btn-primary"
      [attr.disabled]="eventForm.invalid || submitting ? true : null"
      [innerText]="submitBtnText"></button>
      <!-- https://github.com/angular/angular/issues/11271#issuecomment-289806196 -->
    <app-submitting *ngIf="submitting"></app-submitting>
    <a
      *ngIf="!submitting"
      class="btn btn-link"
      (click)="resetForm()"
      tabindex="0">Reset Form</a>

    <!-- API submission error -->
    <p *ngIf="error" class="mt-3 alert alert-danger">
      <strong>Error:</strong> There was a problem submitting the event. Please try again.
    </p>
  </div>
</form>

Our <form> element has a [formGroup]="eventForm" directive that associates the event form with the form in our template. We'll also need the (ngSubmit)="onSubmit()" event to submit our form when the button is clicked.

In order to associate HTML input elements with our form model, we'll add a formControlName directive to each input.

Note: We're handling validation in the class for the most part, so why are we applying a [maxlength] to some inputs in the template? This is to allow HTML5 form elements to limit the number of characters the user can type in the browser.

If we added the novalidate attribute to the <form> element, all browser validation would be disabled: the user would see our maxlength error message if they typed too many characters, but they'd be able to keep typing. For an ideal user experience, we want to take advantage of both browser-based validation and our dynamic Angular validation.

After each field, we'll add a <div> that uses NgIf to show when the formErrors object has errors populated for that field. We'll use the [innerHTML] attribute to render the error markup.

The inputs for our start and end dates and times need to be grouped inside a container with a formGroupName="datesGroup" directive. We'll also use the NgClass directive to set a Bootstrap class (.has-danger) on the group's inputs if the group validation produces an error.

Note: We haven't built the custom date range validation yet, but we'll set up our template now to accommodate it.

Our inputs then need formControlNames matching the controls belonging to the nesteddatesGroup form group. We'll also add [placeholder] attributes with the dateFormat andtimeFormat properties we created in the event form service. Then we'll add [maxlength] and error messages for the individual field validation.

At the bottom of this form group, we'll add a <p> element alert to handle showing the custom dates/times group validation that we'll create. This message will conditionally indicate when the dates are out of range.

The rest of the form fields should feel familiar and abide by the same rules as the first few that we created (i.e., title and location).

We want to disable our submit button if the form isn't valid. However, unlike template-driven forms, reactive forms don't play nicely with a dynamic[disabled] directive. Instead, we'll use a dynamic attribute([attr.disabled]) on our submit button, setting it to true if the form is invalid or currently submitting. If the button should be enabled, we'll set it to null so that thedisabled attribute is not activated.

During submission, we'll show our submitting component. We'll then display a "Reset Form" link. This needs to be an anchor tag rather than a <button>element so it doesn't interfere with the (ngSubmit) on the <form> element. When clicked, the link will execute our resetForm() method. We'll also add a tabindex so that Bootstrap treats it as a link even though it doesn't have an href attribute.

Finally, if an error has occurred, we'll show an alert recommending that the user should try submitting the form again.

That's it for the form template!

Angular: Custom Form Group Validation

Now that we have our Event Form component class and template, let's implement the form group validation that we've mentioned a few times for verifying a date range for events.

Validation Requirements

Let's review the requirements for this validation. We have four fields that we'll validate together: start date, start time, end date, and end time. Obviously, the start date+time and end date+time are tied to each other and should produce valid date-times together. We then need to compare the start date-time and end date-time to make sure the user isn't creating an event that ends before it begins.

We already have individual field-level validation to ensure that the dates and times are formatted correctly. We don't want the date range validation to run if the individual date fields in thedatesGroup are invalid.

We want to be able to validate the date range with just dates, even if times aren't available or valid yet. If the user enters a start date of 10/15/2018 and an end date of 10/14/2018, we still need to show that these dates are out of range even if times haven't been entered yet. Therefore, as long as the start and end dates are valid, we'll perform date range validation.

Create Date Range Validator

Create a new file in the src/app/core/forms directory called date-range.validator.ts and add the following code:

// src/app/core/forms/date-range.validator.ts
import { AbstractControl } from '@angular/forms';
import { stringsToDate } from './formUtils.factory';

export function dateRangeValidator(c: AbstractControl): {[key: string]: any} {
  // Get controls in group
  const startDateC = c.get('startDate');
  const startTimeC = c.get('startTime');
  const endDateC = c.get('endDate');
  const endTimeC = c.get('endTime');
  // Object to return if date is invalid
  const invalidObj = { 'dateRange': true };

  // If start and end dates are valid, can check range (with prefilled times)
  // Final check happens when all dates/times are valid
  if (startDateC.valid && endDateC.valid) {
    const checkStartTime = startTimeC.invalid ? '12:00 AM' : startTimeC.value;
    const checkEndTime = endTimeC.invalid ? '11:59 PM' : endTimeC.value;
    const startDatetime = stringsToDate(startDateC.value, checkStartTime);
    const endDatetime = stringsToDate(endDateC.value, checkEndTime);

    if (endDatetime >= startDatetime) {
      return null;
    } else {
      return invalidObj;
    }
  }
  return null;
}

We'll import AbstractControl and our stringsToDate factory.

The dateRangeValidator() function takes an AbstractControl as a parameter. This is the form group that we'll be validating. We can then use the get() method to set constants for the form controls for each of the fields in the group we're validating. Then we'll set a constant for theinvalidObj that will be returned if the validation fails.

If the start and end dates are valid, we'll check the date range. If the start time is invalid or not available, we'll validate the date-time at 12:00 AM. If the end time is invalid or not available, we'll validate the date-time at 11:59 PM. This ensures that the user can enter same-day events without errors. We'll then get JavaScript Date objects by sending the dates and times as parameters to our stringsToDate() method.

Finally, we can compare the date-times. If the end date-time is greater than or equal to the start date-time, the fields pass validation. Otherwise, we'll return our invalidObj.

If the start and end dates aren't valid, we won't perform date range validation, so we'll return null.

That's it for our date range validator function!

Add Date Range Validator to Event Form Component

Now we'll add our dateRangeValidator() factory to the Event Form component class. Open the event-form.component.ts file:

// src/app/pages/admin/event-form/event-form.component.ts
...
import { dateRangeValidator } from './../../../core/forms/date-range.validator';
...
  private _buildForm() {
    this.eventForm = this.fb.group({
      ...,
      datesGroup: this.fb.group({
        ...
      }, { validator: dateRangeValidator })
    });

...

First, we'll import the dateRangeValidator. Then we'll set it in the extraparameter map to our datesGroup FormBuilder group(). The type annotation for group() is as follows:

group(controlsConfig: {[key: string]: any}, extra?: {[key: string]: any})

Valid keys for the extra? parameter map are validator and asyncValidator. Here we'll use{ validator: dateRangeValidator }.

We already added the necessary markup to our template to support date group validation. Our date range validation should now work. Let's try it out!

Note: Because date range is the only custom group validation, our template safely assumes that any errors on the datesGroup control are the dateRange error.

Our validation should look and function like this:

Angular custom group validation date range

Angular's reactive forms are quite powerful. We've now explored how they give us plenty of flexibility to customize complex forms.

Summary

In Part 6 of our Real-World Angular Series, we've covered reactive forms with custom validation. In the next part of the tutorial series, we'll delete events, list events a user has RSVPed to, and silently renew authentication tokens with Auth0.

Launch your application faster with Okta’s user management API. Register today for the free forever developer edition!

Topics:
web dev ,angular ,web application development

Published at DZone with permission of Kim Maida, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

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

{{ parent.tldr }}

{{ parent.urlSource.name }}