In a few websites (most recently Myntra), I have seen sign-up forms where the user is expected to enter an uppercase character or special character in the password field. As the user enters his password, the type of the character entered is displayed. The form also displays how many times the user has entered a particular type of character.
I never gave this feature much attention until I started exploring Regex. Regex was a topic I have avoided for the longest time because of its vastness. The below site helped me understand and test Regex to a great extent.
regex101: build, test, and debug regex
Jumping back to the story, I would like to share an example of a feature very similar to the one I have seen on these websites.
Character Validator Form:
Our objectives are as follows:
- The user can enter a maximum of 8 characters in the textbox. This limitation is added to make testing easier. The user is expected to enter at least 1 uppercase character,2 special characters, 2 numbers, and 1 lowercase character.
- We have displayed 4 filter types: UpperCase, SpecialCharacters, Numbers, and LowerCase. These types will be red in color as long as the count of characters under each of these types is less than the expected count.If the count under any of these types exceeds the expected count, the label color changes from red to green.
- Finally the user can submit the form only when the expected character counts for each filter type is satisfied.
Since the filter types and the expected count of characters under each type is a constant ,we shall use enum named count to define this data. The key of the enum contains the filter name and the value corresponding to the key contains the expected count of characters the user needs to enter for that filter type.
export enum count {
UpperCase = 1,
SpecialCharacters = 2,
Numbers = 2,
LowerCase = 1,
}
export function getFilterList() {
let filterList: any = [];
for (const key of Object.keys(count)) {
if (isNaN(parseInt(key))) {
filterList.push(key);
}
}
return filterList;
}
We have also exported a function getFilterList() which returns an array of all the keys(filter names) of the enum. We shall use this list at multiple places going forward.
<form [formGroup]="characterValidatorForm">
<input
valueCheck
(filterCountUpdated)="filterCountUpdated($event)"
type="text"
[filterList]="filters"
formControlName="textbox"
[maxlength]="8"
/>
<button [disabled]="!characterValidatorForm.valid">Submit</button>
</form>
<p>{{ errMessage }}</p>
<ng-container *ngFor="let filter of filters">
<p [ngClass]="filter.count >= filter.expCount ? 'matched' : 'not-matched'">
{{ filter.name }} : {{ filter.count }} characters
</p>
</ng-container>
AppComponent Template:
The template contains a simple form characterValidatorForm which contains 1 text box with a directive valueCheck applied to it. This directive will do most of our job in this example.
We have an array of objects named filters. Each object contains 3 properties: name,count and expCount. The name property will contain the values UpperCase, Special Characters, Numbers and LowerCase. The count property will contain the count of the characters entered by the user under each of the above types and the expCount property contains the expected count of characters to be entered under each type for the form to be valid.
The Submit button remains disabled as long as the form is invalid.
We are passing the filters array as @Input() to the valueCheck directive.
The filterCountUpdated() is called each time the filterCountUpdated event is triggered from the valueCheck directive. This event will be triggered whenever a filter type’s count property is updated.
We are also displaying an error message errMessage whenever the expected count for a filter type doesn’t match expected count of characters for the filter type.
If the count property for a filter type exceeds or equals the expCount property, then the CSS class matched will be applied to the tag. Else, the CSS class not-matched will be applied.
.matched{
color:green;
}
.not-matched{
color:red;
}
Now lets jump to the AppComponent Class.
import {
Component,
Directive,
EventEmitter,
HostBinding,
HostListener,
Input,
Output,
} from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { count, getFilterList } from '../filterTypes';
import { characterValidator } from './characterValidator';
@Component({
selector: 'my-app',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
public filterNameList = getFilterList();
public filters = [
{
name: this.filterNameList[0],
count: 0,
expCount: count.UpperCase,
},
{
name: this.filterNameList[1],
count: 0,
expCount: count.SpecialCharacters,
},
{
name: this.filterNameList[2],
count: 0,
expCount: count.Numbers,
},
{
name: this.filterNameList[3],
count: 0,
expCount: count.LowerCase,
},
];
public characterValidatorForm = new FormGroup({
textbox: new FormControl(''),
});
public errMessage: string = '';
ngOnInit() {
this.setValidator();
this.returnErrorMessage(); //first time display
}
filterCountUpdated(filterList) {
this.filters.forEach((filter) => {
let match = filterList.findIndex((fltr) => fltr.name === filter.name);
filter.count = filterList[match].count;
this.setValidator();
this.returnErrorMessage(); //error display every time count is updated
});
}
returnErrorMessage() {
let errObject = this.characterValidatorForm.get('textbox').errors;
this.errMessage = errObject ? Object.keys(errObject)[0] : '';
}
setValidator() {
this.characterValidatorForm
.get('textbox')
.setValidators([characterValidator(this.filters)]);
this.characterValidatorForm.get('textbox').updateValueAndValidity();
}
}
The Class is pretty straightforward.
- As already discussed, the class defines the filters array. We have used the exported method getFilterList() and the enum count to populate the name and expCount properties of each object in the array.
- The characterValidatorForm has only 1 FormControl textbox.
public characterValidatorForm = new FormGroup({
textbox: new FormControl(‘’)
});
- In the ngOnInit() lifecycle hook, we have applied a custom validator named characterValidator to this FormControl to display error messages via the setValidator().
ngOnInit() {
this.setValidator();
this.returnErrorMessage();
}
Also we have written the logic for the error message display. We have used the returnErrorMessage() to set the errMessage property for display to the user.
returnErrorMessage() and setValidator() is called twice: for first time when the component loads and also everytime the count of a filter type gets updated.
returnErrorMessage() {
let errObject = this.characterValidatorForm.get(‘textbox’).errors;
this.errMessage = errObject ? Object.keys(errObject)[0] : ‘’;
}
We shall get back to the filterCountUpdated() once we are done with the directive.
ValueCheckDirective Class:
@Directive({
selector: '[valueCheck]',
})
export class ValueCheckDirective {
constructor() {}
@Output('filterCountUpdated') filterCountUpdated = new EventEmitter<[]>();
@Input('filterList') filterList: any;
public filterNameList = getFilterList();
@HostListener('keyup', ['$event.target.value'])
onKeyUp(value: any) {
this.filterList.map((x) => (x.count = 0));
if (!value.length) {
this.filterCountUpdated.emit(this.filterList);
}
for (let i = 0; i < value.length; i++) {
if (value[i].match(/^[A-Z]*$/)) {
this.updateFilterCount(this.filterNameList[0]);
} else if (value[i].match(/^[a-z]*$/)) {
this.updateFilterCount(this.filterNameList[3]);
} else if (value[i].match(/^[0-9]*$/)) {
this.updateFilterCount(this.filterNameList[2]);
} else if (value[i].match(/^\W$/)) {
this.updateFilterCount(this.filterNameList[1]);
}
}
}
updateFilterCount(filterName: string) {
let match = this.filterList.findIndex(
(filter) => filter.name === filterName
);
this.filterList[match].count++;
this.filterCountUpdated.emit(this.filterList);
}
}
- The filters array is received as @Input() filterList from the AppComponent.
@Input(‘filterList’) filterList: any;
We shall be updating the count property in the filterList and sending it back to the AppComponent to update the display.
Here again, we have called the exported method getFilterList() to store the filter names in the property filterNameList.
public filterNameList = getFilterList();
- Whenever the user enters something in the textbox, a keyup event is triggered. We have set up a @HostListener to listen to this keyup event and call the onKeyUp() method.
A. Each time this event is triggered, we want the count property of each filter type to be reset to 0. The count calculation should be done freshly for each keypress.
this.filterList.map((x) => (x.count = 0));
B.Consider a scenario where the user enters a few characters and then deletes all of them. In that case, we need to update the AppComponent with the latest count values of each filter type.
if (!value.length) {
this.filterCountUpdated.emit(this.filterList);
}
C. The text entered in the textbox could be a single character string or a string of characters. We are iterating through the string of characters and matching each character with a Regex expression using the match().
/^[A-Z]*$/ checks if the character is an uppercase character.
/^[a-z]*$/ checks if the character is a lowercase character.
/^[0–9]*$/ checks if the character is a number.
/^\W$/ checks if the character is anything other than a number or an alphabet.
for (let i = 0; i < value.length; i++) {
if (value[i].match(/^[A-Z]*$/)) { this.updateFilterCount(this.filterNameList[0]); }
else if (value[i].match(/^[a-z]*$/)) { this.updateFilterCount(this.filterNameList[3]);
}
else if (value[i].match(/^[0–9]*$/)) { this.updateFilterCount(this.filterNameList[2]);
}
else if (value[i].match(/^\W$/)) {
this.updateFilterCount(this.filterNameList[1]);
}
}
Every time there is a match for any regex expression, we are calling the updateFilterCount() passing the filter name as an argument.
D. In the updateFilterCount(), we are first retrieving the index of the object in the filterList array which has the same name property as the filter name passed in the method argument. We use that index to increment the corresponding object’s count property. Finally, we send the updated filterList array back to the AppComponent via the filterCountUpdated event emitter.
updateFilterCount(filterName: string) {
let match = this.filterList.findIndex( (filter) => filter.name === filterName );
this.filterList[match].count++;
this.filterCountUpdated.emit(this.filterList);
}
Now let's check how the event triggered by filterCountUpdated is handled in the AppComponent by the filterCountUpdated() method.
filterCountUpdated(filterList) {
console.log(filterList);
this.filters.forEach((filter) => {
let match = filterList.findIndex((fltr) => fltr.name === filter.name);
filter.count = filterList[match].count;
this.setValidator();
this.returnErrorMessage();
});
}
When I type “H!” in the textbox as you see below, I have logged the updated filterList array sent by the directive to the AppComponent. We are iterating through the filters array property and updating the count property of each filter type from the filterList array.
The updated count of each filter type is displayed in the AppComponent template.
We are also resetting the custom validator characterValidator and updating the error message property errMessage.
In order to understand how the error validation happens for the FormControl textbox, we need to check the custom validator characterValidator.
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { getFilterList } from '../filterTypes';
export function characterValidator(filterList: any) {
return (control: AbstractControl): ValidationErrors | null => {
let errorObj: any = {};
let errorMessage: string = '';
let filterNameList: any = getFilterList();
if (!control.value.length) {
errorMessage = 'Field must be filled';
errorObj[errorMessage] = true;
} else if (!validateFilterExpectedCount(filterNameList[0], filterList)) {
errorMessage = `There must be atleast ${filterList[0].expCount} character(s) in ${filterList[0].name}`;
errorObj[errorMessage] = true;
} else if (!validateFilterExpectedCount(filterNameList[1], filterList)) {
errorMessage = `There must be atleast ${filterList[1].expCount} character(s) in ${filterList[1].name}`;
errorObj[errorMessage] = true;
} else if (!validateFilterExpectedCount(filterNameList[2], filterList)) {
errorMessage = `There must be atleast ${filterList[2].expCount} character(s) in ${filterList[2].name}`;
errorObj[errorMessage] = true;
} else if (!validateFilterExpectedCount(filterNameList[3], filterList)) {
errorMessage = `There must be atleast ${filterList[3].expCount} character(s) in ${filterList[3].name}`;
errorObj[errorMessage] = true;
} else {
errorObj = null;
}
return errorObj;
};
}
function validateFilterExpectedCount(filterName: string, filterList: any) {
let match = filterList.findIndex((filter) => filter.name === filterName);
return filterList[match].count >= parseInt(filterList[match].expCount);
}
export function characterValidator(filterList: any) {
return (control: AbstractControl): ValidationErrors | null => {
//logic
}
}
We have passed the filters array in the AppComponent as argument to the characterValidator function as below in the setValidator().
setValidator() {
this.characterValidatorForm.get('textbox')
.setValidators([characterValidator(this.filters)]);
this.characterValidatorForm.get('textbox').updateValueAndValidity();
}
The characterValidator function will either return an error object which contains the error message in the key and value as true OR returns null. In the latter case, it implies the FormControl has no error.
Thus for each filter type, we are checking if the count of characters entered by the user equals or exceeds the expected count. If no, we display an error message. The error messages are not hardcoded and is entirely controlled by the enum count. Thus we have to update the filter name or expected count, we just need to update the enum. No changes are needed anywhere else.
You can check the entire work at the below link: