What do you do when your form is too large for one screen? You use a wizard.

And if you're relying on Angular Material as your UI solution, you use the Stepper component.

Now this is where I normally say, "Hey, it's really easy to do this!" But, truth be told, this one is going to get fairly complex.

However, once you've got the pattern down, you'll find yourself in a position to implement steppers in all your Angular Material UIs.

You can follow along as I guide you here or you can go straight to the code on GitHub. 

Remember: if you're having problems running the Angular app locally, be sure to check the README.

Please note: this guide is a part of an ongoing series of tutorials on how to create a customer relationship management (CRM) application from scratch. The GitHub code for each guide belongs to a specific branch. The master branch holds the entire application up to the current date. If you want to follow the whole series, just view the careydevelopmentcrm tag. Note that the website displays the guides in reverse chronological order so if you want to start from the beginning, go to the last page.

The Business Requirements

Your boss Smithers walks into your office, takes a seat, and stares at you intently.

"We have a CRM with no way to enter contact data!" he says, exasperated.

You can hear him hyperventilating.

"It's time to add a page so users can add contacts," he continues. "Otherwise, what's the point?"

He exhales through his nose quickly and leaves your office without saying goodbye.

Contact: Not Just a Film by Robert Zemeckis

So what is a contact, anyway?

Well, for the purposes of this application, it's any person that might have a relationship with the company. That's an important concept to grasp as you go through these guides. 

Remember, CRM stands for "Customer Relationship Manager" not "Contact Relationship Manager."

So why are we talking about contacts? Because every CRM views customers as people who start off as sales leads, prospects, or folks who show any kind of interest in the business.

Eventually, though, they become customers. Hopefully.

For this ecosystem, every person even remotely associated with the business, from the newest sales prospect to the most loyal lifelong customer, is called a contact. 

That's the best "name" to give those folks.

A Major Module, A Flurry of Forms

Okay, now let's get to coding. 

First, go to your command prompt and navigate to the root of your CRM source. Then, enter several commands:

ng g m features/contacts

ng g c features/contacts/add-contact

ng g c features/contacts/contact-form

ng g c features/contacts/addresses-form

ng g c features/contacts/addresses-form/address-type-form

ng g c features/contacts/basic-info-form

ng g c features/contacts/phones-form

ng g c features/contacts/phones-form/phone-type-form

ng g c features/contacts/review-form

I know what you're thinking: "Aaaaaaaaaaaaaahhh!"

But remember, the whole point of this guide is that you're using Angular Material to create a wizard that people can use to fill out a complex form.

In this case, the complex form has four parts (or four sub-forms):

  • Basic contact info (name, email, status, etc.)
  • Addresses (home and work)
  • Phone numbers (home, cell, and work)
  • Review

The Review "form" is a read-only part of the wizard that enables users to double-check their work. They'll use that part to make sure they spelled the contact's name correctly, got the address right, etc.

Now, let's go over the module and the components you just created.

You created a new module called contacts because, as I mentioned in a previous guide, you're creating new modules for each large use case. So there's one module for all user-related features, another module for contact-related features, and so on.

So here we are with a new module for contact stuff.

The first component, add-contact, is what you'll be coding today. That's going to handle the use case of creating a brand new contact from scratch.

Eventually, you'll want to implement a solution so users can edit an existing contact. For that, you'll use quite a few of the components you just created.

Let's look at those components next.

The contact-form component is the "mother" component. It's going to hold all the other components: basic-info-form, addresses-form, and phones-form.  

But what about those "type" forms?

Good question. Remember: there are multiple types of addresses and phones. There are home and work addresses and there are home, cell, and work phones.

But you don't want to create multiple forms that all use the same fields. You'd be duplicating code.

That's why address-type-form and phone-type-form exist. They're embeddable and reusable.

Modifying the Module

First you'll need to update contacts.module.ts. Get it ready to use Angular Material components.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ContactFormComponent } from './contact-form/contact-form.component';
import { AddContactComponent } from './add-contact/add-contact.component';
import { RouterModule } from '@angular/router';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatStepperModule } from '@angular/material/stepper';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatRadioModule } from '@angular/material/radio';
import { MatExpansionModule } from '@angular/material/expansion';
import { ReactiveFormsModule } from '@angular/forms';
import { AlertModule } from '../../ui/alert/alert.module';
import { BasicInfoFormComponent } from './contact-form/basic-info-form/basic-info-form.component';
import { AddressesFormComponent } from './contact-form/addresses-form/addresses-form.component';
import { PhonesFormComponent } from './contact-form/phones-form/phones-form.component';
import { AddressTypeFormComponent } from './contact-form/addresses-form/address-type-form/address-type-form.component';
import { PhoneTypeFormComponent } from './contact-form/phones-form/phone-type-form/phone-type-form.component';
import { ReviewFormComponent } from './contact-form/review-form/review-form.component';

export const routes = [
  { path: '', pathMatch: 'full', redirectTo: 'account-info' },
  { path: 'add-contact', component: AddContactComponent }
];

@NgModule({
  declarations: [
    ContactFormComponent,
    AddContactComponent,
    BasicInfoFormComponent,
    AddressesFormComponent,
    PhonesFormComponent,
    AddressTypeFormComponent,
    PhoneTypeFormComponent,
    ReviewFormComponent
  ],
  imports: [
    CommonModule,
    FlexLayoutModule,
    MatIconModule,
    MatInputModule,
    MatButtonModule,
    MatProgressSpinnerModule,
    MatSelectModule,
    MatStepperModule,
    MatRadioModule,
    MatExpansionModule,
    ReactiveFormsModule,
    AlertModule,
    RouterModule.forChild(routes)
  ]
})

export class ContactsModule { }

Key takeaway here: all those modules that begin with "Mat" are Angular Material modules that you'll need to create the forms and the wizard.

The Queen Mother

Let's get into the nitty-gritty here by coding that mother form. Go back to your Microsoft Visual Studio IDE and edit contact-form.component.html. Make it look like this:

<div fxFlex fxLayout="column" fxLayoutGap="0px" class="route-content">
  <div fxLayout="row wrap">
    <div fxFlex="100" fxLayout="column">
      <div>
        <h4>Add Contact</h4>
      </div>
      <div style="margin-bottom:20px">
        <alert></alert>
      </div>
      <div class="absolute-center" *ngIf="dataLoading">
        <mat-spinner [diameter]="80"></mat-spinner>
      </div>
      <div *ngIf="!dataLoading" style="margin-right:10px">
        <mat-vertical-stepper #stepper (selectionChange)="onStepChange($event)">
          <mat-step label="Basic Info">
            <contact-basic-info-form></contact-basic-info-form>
            <div class="stepper-navigation">
              <button mat-button matStepperNext>Next</button>
            </div>
          </mat-step>
          <mat-step label="Addresses">
            <contact-addresses-form style="margin-top:15px"></contact-addresses-form>
            <div class="stepper-navigation">
              <button mat-button matStepperPrevious>Back</button>
              <button mat-button matStepperNext>Next</button>
            </div>
          </mat-step>
          <mat-step label="Phones">
            <contact-phones-form></contact-phones-form>
            <div class="stepper-navigation">
              <button mat-button matStepperPrevious>Back</button>
              <button mat-button matStepperNext>Next</button>
            </div>
          </mat-step>
          <mat-step label="Review & Save">
            <contact-review-form [contact]="contact" [errorMessages]="errorMessages"></contact-review-form>
            <div style="margin-top:40px; margin-bottom:20px;">
              <button *ngIf="!formSubmitted" mat-raised-button color="primary" (click)="saveInfo()">Save Info</button>
              <mat-spinner *ngIf="formSubmitted" [diameter]="50"></mat-spinner>
            </div>
            <div>
              <button mat-button matStepperPrevious>Back</button>
            </div>
          </mat-step>
        </mat-vertical-stepper>
      </div>
    </div>
  </div>
</div>

That wizard handles a lot, but as you can see, there isn't a whole lot of code in there.

Why? Because of all the subcomponents you're going to use to do the heavy lifting.

And keep in mind: this file won't work right away. You'll have to do quite a bit more coding before this thing is ready to fly.

By the way: if you want to do exactly what I'm showing you how to do here, you'll probably need to snag all the code from GitHub and launch it locally. I don't think I can cover all the code in one article.

But back to the code itself. First up: note that it's using <mat-veritical-stepper>.

That's because a vertical stepper works much better on a small device than a horizontal stepper. And since mobile is everything, I think it's best to go with a vertical layout here.

Next, you'll see there are four steps:

  • Basic Info
  • Addresses
  • Phones
  • Review & Save

That shouldn't surprise you at this point since I've already explained that the wizard/stepper will include four sub-forms.

The form for each step is encapsulated in a single element. For example: <contact-basic-info-form> includes the entire Basic Info form.

You could put the whole form directly in the code above. But doing it this way makes the code more readable and gives it modularity.

And while I'm on that subject, you might notice something is missing in the code above. If you check out other examples of Angular Material steppers, you'll likely see something like this in the <mat-step> element: [stepContro]="formGroup"

Why is that missing here?

In fact, you should use it if you're putting the whole form in the code above. However, when you try to associate [stepControl] with a form in a child component, you've got to do a bit of coding gymnastics. And even after that, you might still get cryptic error messages.

So it's best to leave that one off for this solution. Instead, you can handle the relationship between the various forms and the stepper programatically.

By the way, you might have noticed that this is not a linear form.

A linear form, in the world of Angular Material, is a form that requires a user to complete one step before moving on to the next step. But it can get a bit user-hostile.

I'd rather show users all their errors at once in the Review section. Then, they can go back and fix the problems.

Follow the Script

Next, update contact-form.component.ts. I'll cover the changes piece-by-piece since there's quite a bit going on.

export class ContactFormComponent implements OnInit, AfterViewInit, OnDestroy {

First, note that there are three implementations here when there's normally just one (OnInit).

The code needs AfterViewInit so it can get a handle on the child components. They're not yet available in ngOnInit().  But they are available in ngAfterViewInit().

And the code needs OnDestroy because it's keeping a Subscription. It's important to unsubsribe from Subscriptions in ngOnDestroy() to prevent memory leaks.

Next, add these properties to the class:

  errorMessages: string[] = [];
  contact: Contact = {} as Contact;
  currentStepIndex: number = 0;
  basicInfoFormSubscription: Subscription;
  formSubmitted: boolean = false;
  allFormsValid: boolean = false;


  @ViewChild(BasicInfoFormComponent) basicInfoComponent: BasicInfoFormComponent;
  @ViewChild(AddressesFormComponent) addressesComponent: AddressesFormComponent;
  @ViewChild(PhonesFormComponent) phonesComponent: PhonesFormComponent;

The errorMessages array holds a list of error messages that the application will show the user in the event that one of the forms isn't valid.

Keep in mind, though, as of now the only form that includes validation is the Basic Info form. 

Why? Because a user could know a contact only by an email address. So the application won't force the user to enter a physical address or a phone number.

The contact object holds the info about the contact currently being created. It gets updated as the user steps through different forms.

You can see the Contact interface here.

The currentStepIndex identifies which step the user is currently on:

  • 0 - Basic Info form
  • 1 - Addresses form
  • 2 - Phones form
  • 3- Review form

The basicInfoFormSubscription field keeps track of changes to the Basic Info form.

Why is that needed? Because when the Basic Info form is invalid, the code will light up the step icon a pretty shade of red so the user can clearly see that it's invalid.

However, when the user makes changes to correct the problems, the application will turn that icon back to its default color. But it can't do that unless it knows the user made the changes.

That's why the application tracks the changes to the Basic Info form.

Here's what it looks like when the form is invalid:

 

And here's what it looks like when the form is valid:

 

As you can see, the bullet icon (with the "1" in it) changes color back to blue once the user corrects an invalid field. It does that by listening for changes to the form and updating the color if the form becomes valid.

The formSubmitted boolean tells the application if the user submitted the form. When that happens, the app hides the Save Info button and shows a spinner instead.

You can check out my guide on Angular Material progress spinners for more info about how to do that.

The three @ViewChild properties are child components. There's one for each of the three editable forms. It doesn't need one for the review form because that's not editable.

Why does it only need a handle on editable forms? Because it will get data from those forms to update the contact object.

Here's the code for the Subscription object I mentioned earlier:

  private handleSubscriptions() {
    this.handleBasicInfoFormSubscription();
  }

  private handleBasicInfoFormSubscription() {
    //tracks changes to the form
    //if the form becomes invalid, this will light the icon button red
    //if the invalid form becomes valid, it will turn the icon button to original color
    this.basicInfoFormSubscription = this.basicInfoComponent
      .basicInfoFormGroup
      .valueChanges
      .pipe(
        debounceTime(500),
        distinctUntilChanged()
      )
      .subscribe(
        (values) => {
          this.handleFormCheck();
        }
      );
  }

The code grabs the basicInfoFormGroup object from BasicInfoComponent and listens for value changes via the valueChanges() method. 

Since valueChages() returns an Observable, the code subscribes to it to detect changes.

But before the subscribe() method, you'll see a pipe() method.  That's doing a couple of things.

First of all, the valueChanges() method listens for everything. As in, every single keypress.

That's overkill in here. The app just needs to listen for changes every half second or so.

So the code uses the pipeable operator debounceTime().  In this case, it's set to a value of 500 for 500 milliseconds or half a second.

The distinctUntilChanged() operator lives up to its name. That's how the code tells Angular to emit only items that are distinct from the previous item. In other words: don't emit anything if nothing has changed.

Once the form has changed, though, the handleFormCheck() method will check to see if it's now valid. If it is, it will change the color of the bullet icon back to its default.

Next, you'll need to code the onStepChange() method:

  onStepChange(event: any) {
    let previousIndex: number = event.previouslySelectedIndex;
    let currentIndex: number = event.selectedIndex;

    this.currentStepIndex = currentIndex;

    if (previousIndex == BASIC_INFO_INDEX) {
      this.basicInfoComponent.populateContact(this.contact);

      let validForm: boolean = (this.basicInfoComponent.basicInfoFormGroup.valid);
      if (!validForm) {
        this.changeIcon(previousIndex);
        this.allFormsValid = false;
      } else {
        this.clearIconError(previousIndex);
        this.allFormsValid = true;
      }
    } else if (previousIndex == ADDRESSES_INDEX) {
      this.addressesComponent.populateContact(this.contact);
    } else if (previousIndex == PHONES_INDEX) {
      this.phonesComponent.populateContact(this.contact);
    }

    if (currentIndex == REVIEW_INDEX) {
      this.validateForms();
    }
  }

If you go waaaaaaaaaay back the HTML you saw earlier, you'll notice that the onStepChange() method gets invoked during the selectionChange event.

The selectionChange event gets triggered when the user goes from one step to another.

Here, the code is doing a couple of things.

First, it's getting the previously selected index.

Remember the index numbers from before? 0 is Basic Info, 1 is Addresses, etc.

Anyhoo, it needs that info because the application will grab the contact info from the previous step. That makes sense because that's the step the user just finished. So the data should be ready to go.

Next, the code also grabs the current index. That's important because if the current index is the Review index, then it's time to validate all the forms. If one or more forms fail validation, the app will display error messages to the user.

(Fresh reminder though that this app only validates the Basic Info form at this time. Still, it's scalable so it can validate other forms in the future.)

If you look at the if statements, you'll see they follow pretty much the same pattern, with Basic Info the oddball. For each form, the code invokes a method called populateContact() on the child component.

That method does exactly what you think it does. It takes the data from the form and populates the contact object.

For the Basic Info form, the code also handles the task of checking to see if the form is valid and colors the bullet icon accordingly.

It will also set allFormsValid. It can do that because it's the only form that the app checks for validity at this time.

Once the user hits the Review form, here's the validation logic that the app will follow:

  private validateForms() {    
    this.errorMessages = [];

    this.validateBasicInfoForm();
    this.validateAtLeastOneContactMethod();
  }

  private validateBasicInfoForm() {
    let basicInfoForm: FormGroup = this.basicInfoComponent.basicInfoFormGroup;

    Object.keys(basicInfoForm.controls).forEach(key => {
      const controlErrors: ValidationErrors = basicInfoForm.get(key).errors;
      if (controlErrors != null) {
        this.addErrorByKey(key);    
      }
    });
  }

  private validateAtLeastOneContactMethod() {
    if (!this.contact.email
      && (!this.contact.addresses || this.contact.addresses.length == 0)
      && (!this.contact.phones || this.contact.phones.length == 0)) {

        this.errorMessages.push("Please include at least one method of contact (phone, email, address)")
    }
  }

  private addErrorByKey(key: string) {
    if (key == 'firstName') this.errorMessages.push("Please enter a valid first name");
    if (key == 'lastName') this.errorMessages.push("Please enter a valid last name");
    if (key == 'source') this.errorMessages.push("Please select a source");
  }

There are two validation methods: validateBasicInfoForm() and validateAtLeastOneContactMethod().

The validateBasicInfoForm() method grabs the FormGroup from the child component and checks each field for validity. If it finds an invalid field, it calls the addErrorByKey() method to create a user-friendly error message based on the invalid field.

The validateAtLeastOneContactMethod() method exists because each contact must have at least one method of contact. Otherwise, there's no reason to enter any of the person's info in the system.

So that method ensures that there's at least an email address, snail mail address, or phone number associated with the contact.

What About the Child Forms?

Simply put: the child forms and their related components aren't that complicated. At the end of the day, they're fairly basic Angular forms.

In other words, you don't need me to guide you through them. You got this.

If you're brand spankin' new to Angular Material forms, though, feel free to check out a couple of my guides. I've got one on form field validation and another on responsive forms.

Otherwise, just check out the code on GitHub. 

The Review "Form"

The Review "form" does deserve a bit of special consideration, though. That's because it's read-only.

Here's the code for review-form.component.html.

<div *ngIf="errorMessages && errorMessages.length > 0">
  <div>
    <p style="margin-left: 16px" class="error-spree-header">Form Contains Errors</p>
  </div>
  <div>
    <ul>
      <li *ngFor="let errorMessage of errorMessages" class="error-spree">{{errorMessage}}</li>
    </ul>
  </div>
</div>

<div *ngIf="errorMessages.length == 0">
  <div>
    <p class="review-header">Contact Details</p>
  </div>
  <div>
    <div class="name">
      {{contact.firstName}} {{contact.lastName}}
    </div>
    <div class="title" *ngIf="contact.title">
      {{contact.title}}
    </div>
    <div class="company" *ngIf="contact.company">
      {{contact.company}}
    </div>
    <div class="email" *ngIf="contact.email">
      {{contact.email}}
    </div>

    <div *ngIf="contact.addresses && contact.addresses.length > 0" class="address-section">
      <div *ngFor="let address of contact.addresses" style="margin-top:20px">
        <div class="address-type">
          {{dropdownService.getDisplay(address.addressType, availableAddressTypes)}} Address
        </div>
        <div *ngIf="address.street1" class="address-line">
          {{address.street1}}
        </div>
        <div *ngIf="address.street2" class="address-line">
          {{address.street2}}
        </div>
        <div class="address-line">
          {{address.city}}<span *ngIf="address.city">, </span>{{address.state}} {{address.zip}}
        </div>
      </div>
    </div>

    <div *ngIf="contact.phones && contact.phones.length > 0" class="phone-section">
      <div class="phone-header">Phone(s)</div>
      <div *ngFor="let phone of contact.phones">
        <div class="phone">
          {{phone.phone}} ({{dropdownService.getDisplay(phone.phoneType, availablePhoneTypes)}})
        </div>
      </div>
    </div>

    <div *ngIf="contact.linesOfBusiness && contact.linesOfBusiness.length > 0" class="lob-section">
      <div class="lob-header">Lines of Business</div>
      <div *ngFor="let lob of contact.linesOfBusiness" class="lob-line">
        {{dropdownService.getDisplay(lob, availableLinesOfBusiness)}}
      </div>
    </div>

    <div class="other-details-section">
      <div class="other-details-header">Other Details</div>
      <div class="other-line" *ngIf="contact.status">
        <span class="other-label">Status:</span> <span class="other-value">{{dropdownService.getDisplay(contact.status, availableContactStatuses)}}</span>
      </div>
      <div class="other-line" *ngIf="contact.source">
        <span class="other-label">Source:</span> <span class="other-value">{{dropdownService.getDisplay(contact.source, availableSources)}}</span>
      </div>
      <div class="other-line" *ngIf="contact.sourceDetails">
        <span class="other-label">Source Details:</span> <span class="other-value">{{contact.sourceDetails}}</span>
      </div>
      <div class="other-line">
        <span class="other-label">Has Authority:</span>
        <span class="other-value" *ngIf="contact.authority">Yes</span>
        <span class="other-value" *ngIf="!contact.authority">No</span>
      </div>
    </div>
  </div>
</div>

The first <div> at the top is where the step displays the error messages if they exist.

But how does the review form have a handle on the errorMessages array in ContactFormComponent?

Well if you go back and look at contact-form.component.html you'll see that it gets passed in as an input. So does the contact object.

Here's the relevant line:

<contact-review-form [contact]="contact" [errorMessages]="errorMessages"></contact-review-form>

If no error messages exist, the "form" just displays the contact's info. That's so the user can review the details before clicking the Save Info button.

Run, Forms, Run!

Now it's time to test this thing out.

And let me once again say: you'll have to get the source from GitHub and deploy it locally. I absolutely did not cover all the code here.

Once you've done that, deploy the Spring Boot user service so you can login. Then, fire up the CRM app on the Angular side.

Navigate to the login page: http://localhost:4200/login. Login with the usual credentials (darth/thedarkside).

Then, head over to the contact form by selecting Contacts and Add Contacts from the left-hand sidebar.

Start filling out the Basic Info on the form.

 

Then, click on the Review form.

 

Please note: that Save Info button won't work unless you've also rolled out the Spring Boot contact service. But I'm not covering that here.

Now, go back to the Basic Info form and intentionally erase the first name. Then, go back to the Review form.

You should see this:

 

And feel free to look at the other forms as well:

 

Give yourself a tour make sure that everything looks the way it should.

Wrapping It Up

And that's it. That's how you implement an Angular Material stepper in your UI.

Now it's your turn to update, refine, and refactor the code as you see fit. Look for better ways to accomplish what I've shown you here.

And adapt the code to suit your own business needs.

Finally, feel free to check out the code on GitHub.

Have fun!

Photo by Yusuf Evli on Unsplash