Component-Based Forms
Reactive sub-forms implemented with independent, highly re-usable Angular Components
This article was inspired by a recent discussion with a manager regarding how to integrate someone with a math/Aerospace Engineering background (that would be me) into a group that worked on heavily form-based applications. I inquired about the nature of their forms and they were mostly very long and complex, a few of which required a bit of math in the process of validation. The manager was skeptical that multiple devs could work on the same form at the same time, especially given the size and complexity of just a single form.
I talked about the concept of Component-Based Architecture and how the same idea could be (and, in fact, has been) implemented in Angular forms.
The process is simple; delegate form groups to individual Components. Have those Components return a reference to a FormGroup and make each component responsible for validation of its sub-group. Construct the original, large FormGroup from the constituents created by individual Components.
Not only does this process allow multiple developers to work on a single, large form, it promotes a high level of re-use since the same combinations of controls in a form group are likely to be encountered multiple times across one or more applications.
This concept is nothing new and a number of articles have been published online, such as
This article discusses how to apply Component-Based Forms to the most common operation in e-commerce, credit-card payment.
Front-End Credit-Card Processing
The second form I ever created back in the late 1990s involved credit-card processing. That was an interesting experience because it involved an introduction to Luhn numbers and Luhn validation.
Now, before you get a case of math anxiety, understand that absolutely zero math will be covered in this article. Any necessary computations have already been encapsulated away into a small library of Typescript functions that are available with the GitHub that accompanies this article.
The credit-card portion of a payment form typically involves three items,
1 — The credit card number (optional selection of card type)
2 — Card expiration date (mm/yyyy — may be one or two form controls)
3 — Card CVV (usually xxx or yyyy)
Front-end credit card processing is often backed by a data file that specifies the list of accepted cards and relevant data for each card. This file may be static or server-generated. For the group of controls to be valid, the following conditions must apply
1 — The credit card must be in the list of supported cards
2 — The credit card number must be theoretically valid for its type
3 — The credit card expiration date must be the current month/year or in the future
4 — The CVV must have the correct number of digits for the card type
You may have seen payment forms that require entering a card type. This is not actually necessary as the first 4–6 numbers in the credit card number are the BIN . The card type can often be deduced from the first two entered numbers with a RegEx pattern.
The concept of a card number being ‘theoretically’ valid means that the card number is the correct length for the card type and that it passes a metric known as Luhn validation. A Google search will yield a substantial amount of material on this topic. For purposes of this article, Luhn validation is coded for you in a Typescript library not all that different from the JavaScript and ActionScript versions I wrote decades ago.
One issue with front-end credit card processing is that a theoretically valid card number with an acceptable expiration date and CVV may not actually be valid to charge. The expiration date and/or CVV may have been entered incorrectly or the card might be stolen or not yet activated. In these cases, the otherwise valid card number returns from a server check as being not valid for charges.
What we hope to achieve with client-side validation is to minimize the chance that a good card comes back from a server check as invalid for simple reasons such as one digit in the card number was incorrect. The user might have chosen the correct expiration month, but forgot the year, causing the expiration date to be behind the current date. Or, perhaps the user intended to use their Visa card but mistakenly started typing their MC number. It is good UX to indicate to the user that a MC card number is being typed while the number is being entered.
Correctness of a credit-card form group is a unique example of cross-field validation. For example, we can not simply state that a CVV is valid when the user has entered any three digits. The number of required digits varies based on card type, so we must first know the card type. The card type is available after a partial entry of the card number, but may become invalid if the user changes their mind, backspaces, and starts to enter another card number.
The next few sections discuss how to handle the individual aspects of front-end credit-card validation and then we will see how to tie it all together in a reusable Angular Component.
Credit Card Data
For the code covered by this article, supported credit cards are specified by several pieces of information, indicated in the CCData Interface in the file,
/src/app/shared/cc-data/card-data.ts
Typically, credit card numbers are fixed-length, but some card numbers are allowed to vary between an optional minimum and maximum number of digits. Presence of these properties overrides the length property.
The RegEx pattern is the minimal pattern necessary to identify the card, which usually requires the first two digits. The information in the above file is old as it does not consider MasterCard BIN numbers that can begin with 2 as of 2017. I’m willing to wager that almost every dev knows more about RegEx than I do, so modifying the pattern is left as an exercise should you wish to use this code in production.
Functions For Credit Card Type and Valid Numbers
There are four Typescript functions in the folder, /src/app/shared/libs,
- get-card-type.ts (access credit card type from current number)
- in-enum.ts (is the supplied value in a string Enum?)
- is-length-valid.ts (is the card number length valid given a card type?)
- is-valid-luhn.ts (does the card number pass Luhn validation?)
The card type (i.e. MasterCard or American Express) is computed in get-card-type.ts and is determined by matching a RegEx pattern as the credit card number is typed by the user. Once the card type is known, it can be used to further validate the card number (in terms of proper length) and the CVV.
Once the card type is determined, the correct number of digits (or range of digits) can be looked up and used to further validate the card number. The card number length (number of typed digits excluding spaces/dashes) is checked with is-length-valid.ts. While correct number of digits is a necessary condition, it is not sufficient to completely validate the card number.
After the correct number of digits have been entered, is-valid-luhn.ts performs Luhn validation on the entered card number. If that validation passes, then we have checked the card number to the maximum extent available on the front end. The card may still be invalid to charge, but we have eliminated user entry error as a cause for an invalid card number.
Identification of card type and reflection of this information in the UI also helps the user understand that they may have accidentally typed a Visa card number in when they intended to put this particular charge on a MC or Amex.
Expiration Date
There is not much that can be done other than validate that the card expiration is in the future. We want to catch situations where the current month is March, for example, and the user selects January as the expiration month, but forgets to select the correct year. The form is likely to be initialized with the current year as the expiration year, so this is an easy mistake to make for someone in a hurry to get through checkout :)
CVV Validation
The CVV is likely to be a three- or four-digit number and all we can do is verify that it is a number with the correct number of digits for the known card type.
So, now that we understand how to validate each individual element, let’s see how it all comes together in an actual example.
Main Form
The primary form in /src/app/app.component.html represents a subset of a typical payment form containing name, address, and credit card payment information. The relevant portion of this form is shown below.
<form [formGroup]="paymentForm" (ngSubmit)="onSubmit()">
<label>
First Name:
<input type="text" formControlName="firstName">
</label>
<label>
Last Name:
<input type="text" formControlName="lastName">
</label>
<!-- remainder of form here -->
<app-credit-card [placeHolder]="'Enter Card Number'">
</app-credit-card>
<button class="submit-button form-pad-top" type="submit" [disabled]="!creditCardComponent.valid">Submit</button>
</form>
Note that the area of the form expected to contain the credit card number, expiration, and CVV controls has been replaced by a component , CreditCardComponent (/src/app/credit-card/credit-card.component.ts), with the selector, app-credit-card.
This main payment form is a typical reactive form, whose creation can be found in /src/app/app.component.ts,
@ViewChild(CreditCardComponent, {static: true})
public creditCardComponent;public ngOnInit(): void
{
this.paymentForm = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
creditCard: this.creditCardComponent.ccSubGroup,
});
}
This demo is unconcerned with validation of first and last name.
Note that layout for credit-card controls and creation of a FormGroup for that layout has been delegated to CreditCardComponent. Since all credit-card related validation is also delegated to that component, it can be easily re-used anywhere we need a set of credit-card controls inside any form.
To better understand how this all fits together, let’s walk through that component in detail.
CreditCardComponent
The sub-form layout contains a text Input for the credit-card number (it may contain spaces or dashes), two select boxes for expiration month/year, and an number Input for the CVV. The layout also contains an area to display an image of the credit card type as soon as it can be detected from user input. Some DIV’s are provided to display textual explanation of various errors to the user.
Note that this is not a form in and of itself; this component controls the layout of a FormGroup inside another Form — the main payment form for our example.
The CreditCardComponent constructor creates the ccSubGroup FormGroup as follows,
This demo illustrates one production feature I’ve been asked to implement in the past, disabling the expiration date and CVV controls until a valid credit-card number is entered. I’ve had mixed results in the past binding to the disabled attribute and setting an initial value in the FormControl constructor, so the enable/disable operations are handled via code. You could also create a separate group for these controls and enable/disable the entire group.
The ccSubGroup may be accessed by a parent component since it is already public for binding purposes. This is exactly what we saw in the FormGroup creation for the main payment form, above.
Before deconstructing any further, it is necessary to discuss validation strategy for this entire set of controls. As mentioned earlier, this is an interesting case of cross-control validation.
Typical practice is to apply a single validator to an entire form group instead of one validator per control. Consider, however, the choreography of interaction with our group of controls.
1 — User begins typing. Credit card type is identified within a small number of digits. Change the credit card image. Card type is constant unless the user deletes enough digits to invalidate the current card. Then, card type is ‘unknown.’
2 — Length of the credit card input is invalid until the correct number of digits is entered. Luhn validation is then applied to the card number. If that validation passes, the credit card number is considered valid.
3 — Expiration month and year may be set at any time and we can only verify that it is the present month/year or in the future.
4 — CVV can not be validated until the card type is known since the number of digits may vary. So, card number and CVV validation are coupled in that the card number specifies the card type.
Now, it is possible to validate the group as a whole, but there are redundant operations such as fetching the card type. Typically, the card type can be determined with two digits. The remainder of the card type lookups are not necessary. It is also clumsy to have a control validator communicate the card type outside the validator in order to dynamically switch the card-type image.
Here is such a validator should you wish to apply such an approach. It may be found in /src/app/shared/validators/card-validator.ts. Note, however, that the card type is not communicated outside the validator, so it must be looked up again outside the validator in order to dynamically switch the card-type image.
My personal preference with this type of coupling between controls is to provide complete programmatic control over the validation process inside a key-up handler. This approach has the benefit of being simple, efficient, and can easily accommodate a wide variety of change requests.
The first step in this process is to offload input management and validation of the credit card number to an Angular attribute directive. This can be seen in the CreditCardComponent template, /src/app/credit-card/credit-card.component.html,
<label for="creditcard">
Credit Card
<input creditCardNumber
[class]="ccnClass"
type="text"
id="creditcard"
formControlName="ccNumber"
placeholder="{{placeHolder}}"
(onCreditCardNumber)="onCardNumber($event)"
(onCreditCardType)="onCardType($event)"
(onCreditCardError)="onCardError($event)"
>
</label>
This directive is located in /src/app/shared/directives/credit-card-number.directive.ts. To conserve space, its implementation is summarized; you may review the source code at your convenience.
1 — Three Outputs are provided, one of which indicates the credit card type. This Output is only emitted when the card type changes from its previously set type. The second Output emits the currently typed card number (which may include spaces or dashes). The final Output is emitted on any error in the credit card number.
2 — A ‘keydown’ HostListener allows only a specific set of characters and other keys such as Backspace or Delete. The allowable list is minimal, so add allowable keys as needed.
3 — A ‘keyup’ handler cycles through all the validation checks. The card type is cached as a class variable, so it is only updated when the card type changes. Luhn validation is only performed when a card number of the correct number of digits has been entered.
4 — Tests are made against a specific set of errors and, if found, dispatched to the host component. There may be more than one error, so only the first one found is emitted.
Optimization Note: The HostListener’s applied to keyup and keydown fire change detection on the attribute directive. For a simple credit card number input with no children, this is likely not to be an issue. Consider using RxJS fromEvent as an alternative. This is left as an exercise for the reader.
This approach could be called ‘old school,’ but it is simple and efficient. Most of the validation work is performed by the utility functions listed earlier in this article.
Now, we can return to CreditCardComponent. The component provides handlers, onCreditCardNumber, onCreditCardType, and onCreditCard error to handle Output of credit card number, type, and errors from the card number directive. The card type is used to look up a credit card name and image that are displayed in the component through simple binding.
Validation of expiration month and year is relatively straightforward. We only check that the selected expiration month and year are the current month/year or in the future.
An input handler is added to the CVV Input control to check the CVV value as it is typed,
<input [class]="cvvClass"
id="cvv"
type="number"
min="1"
max="9999"
step="1"
formControlName="cvv"
(input)="onCVVChanged($event)">
</label>
Validation of the CVV currently checks the exact number of digits for the current card type. You could use an Angular ValidatorFn factory that accepts a fixed digit range (since this is known at the time the form group is constructed) and return a ValidatorFn that checks against the digit range. This is less exact, but more conforming to the ‘Angular’ way of validating form controls.
Note that the current treatment of the CVV Input only demonstrates validation; this control does allow decimal entry, which should be disallowed in a production control.
Classes applied to various controls are also set programmatically. This allows for a very detailed control over visual appearance as the user types at the expense of having to write more code.
Some DIV’s are optionally rendered in the layout to provide textual explanation of the current error. For example,
<div *ngIf="cardError === CreditCardErrors.INVALID_LENGTH" class="form-error-message">Length Invalid</div><div *ngIf="cardError === CreditCardErrors.INVALID_NUMBER" class="form-error-message">Invalid Card Number</div>
You could also use one DIV and set the message imperatively through binding. Such options are left to you as an exercise.
The component also provides an accessor that is responsible for indicating to the parent component (who owns the complete payment form) that the credit card group (or sub-form) is fully valid.
public get valid(): boolean
{
return this.isValidCardNumber &&
this.isValidExpDate &&
this.isValidCVV;
}
Integrating the Sub-Form with the Main Payment Form
Let’s return to the main app component, /src/app/app.component.ts and review the reactive form setup for the full payment form,
public ngOnInit(): void
{
this.paymentForm = new FormGroup({
firstName: new FormControl(''),
lastName: new FormControl(''),
creditCard: this.creditCardComponent.ccSubGroup,
});
}
The credit-card sub-group is handled completely by the CreditCardComponent and for demo purposes, its valid accessor is used to control whether or not the Submit button is enabled.
<form [formGroup]="paymentForm" (ngSubmit)="onSubmit()">
<label>
First Name:
<input type="text" formControlName="firstName">
</label>
<label>
Last Name:
<input type="text" formControlName="lastName">
</label>
<!-- remainder of form here -->
<div>.</div>
<div>.</div>
<div>.</div>
<app-credit-card [placeHolder]="'Enter Card Number'"></app-credit-card>
<button class="submit-button form-pad-top" type="submit" [disabled]="!creditCardComponent.valid">Submit</button>
</form>
The creditCardComponent variable is a ViewChild that provides a direct reference to the CreditCardComponent in the template,
@ViewChild(CreditCardComponent, {static: true})
public creditCardComponent;
As soon as the user types in a valid credit card number, sets a correct expiration month/year, and a valid CVV, the Submit button is enabled and the CC form Inputs are marked with ‘valid’ styling.
Re-Using the CreditCardComponent in Another Application
It clearly takes more effort to break a large form into sub-forms and implement each sub-form with a separate component. The benefits of this approach include cleaner, simpler implementation of large, complex forms and the ability to re-use the sub-form components in another application.
Suppose a second application is developed that also requires credit-card processing. Like the above example, the new payment form requires a credit-card number, expiration month/year, and CVV, so the newly created CreditCardComponent should be applicable. However, a different layout is requested, namely that the CVV field should be below the expiration month and day select boxes. After all, you know designers … they love to change things :). Let’s also presume that different red/green colors have been requested to indicate error and valid conditions. The designer does not wish to display the credit-card image indicating the current card type.
So, we have two sets of changes in the new application, styles and layout. Styles are simply a matter of a new style sheet and layout requires only a different template. The internal operations of CreditCardComponent are unchanged in the new application. So, an easy way to re-use the component is to extend CreditCardComponent and overwrite the metadata. This is illustrated in /src/app/credit-card-2/credit-card-2.component.ts,
import { Component } from '@angular/core';
import { CreditCardComponent } from '../credit-card/credit-card.component';
@Component({
selector: 'app-credit-card-2',
templateUrl: './credit-card-2.component.html',
styleUrls: ['./credit-card-2.component.scss']
})
export class CreditCard2Component extends CreditCardComponent
{
constructor()
{
super();
}
}
Usage of the new component is illustrated by a second main application component, /src/app/app-2.component.ts. You may see the new component in action by changing the switch variable in /src/app/app.module.ts that controls which main component is used to bootstrap the application,
const example: string = 'example1';
@NgModule({
declarations: [
AppComponent,
App2Component,
CreditCardComponent,
CreditCardNumberDirective,
CreditCard2Component,
],
imports: [
BrowserModule,
ReactiveFormsModule,
],
providers: [],
bootstrap: example === 'example1' ? [AppComponent] : [App2Component],
})
export class AppModule { }
Change the string, ‘example1’ to ‘example2’. Then, re-run the application to see the new layout and styles in action. Credit-card processing and logic, however, are unchanged between the two application. So, we maintain flexibility with a high level of re-use with the sub-form. The alternative would have been to copy and paste template and code blocks from one application to another. Then, you end up with two monolithic forms that share a lot of duplicated template sections and component code.
The sub-form approach lends itself nicely to implementation inside a multi-repo framework such as Nx. Importing one component and making modifications as illustrated above are incredibly simple!
I hope you have found some useful ideas (and code) in this article.
Good luck with your Angular efforts!
EnterpriseNG is coming November 4th & 5th, 2021.
Come hear top community speakers, experts, leaders, and the Angular team present for 2 stacked days on everything you need to make the most of Angular in your enterprise applications.
Topics will be focused on the following four areas:
• Monorepos
• Micro frontends
• Performance & Scalability
• Maintainability & Quality
Learn more here >> https://enterprise.ng-conf.org/