Back to overview

Angular Form

Angular
source code can be found from my github.

Useful Basic FormControl Members

NameDescription
valuereturn the current value of the form control, defined using the any type.
setValue(value)sets the value of the form control.
valueChangesreturns an Observable<any>, through which changes can be observed.
enabledreturns true if the form control is enabled.
disabledreturns true if the form control is disabled.
enable()enables the form control.
disable()disables the form control.
reset(value)resets the form control, with an optional value. The form control will be reset to its default state if the value argument is omitted.

Managing Control State

NameDescription
untouchedreturns true if the HTML element is untouched, meaning that the element has not been selected.
touchedThis property returns true if the HTML element has been touched, meaning that the element has been selected.
markAsTouched()marks the element as touched.
markAsUntouched()marks the element as untouched.
pristinereturns true if the element contents have not been edited by the user.
dirtyreturns true if the element contents have been edited by the user
markAsPristine()marks the element as pristine.
markAsDirty()marks the element as dirty

FormControl Members for Managing Validators

NameDescription
validatorreturns a function that combines all of the configured validators so that the form control can be validated with a single function call
hasValidator(v)returns true if the form control has been configured with the specified validator.
setValidators(v)sets the form control validators. The argument can be a single validator or an array of validators
addValidators(v)adds one or more validators to the form control
removeValidators(v)removes one or more validators from the control
clearValidators()removes all of the validators from the form control

FormControl Members for Managing Validation State

  • status: VALID, INVALID, PENDING, or DISABLED
  • statusChanges: returns an Observable<FormControlStatus>, which will emit a FormControlStatus value when the state of the form control changes
  • valid
  • invalid
  • pending: returns true if the form control’s value is being validated asynchronously
  • errors: returns a ValidationErrors object that contains the errors generated by the form control’s validators, or null if there are no errors.
  • getError(v): returns the error message, if there is one, for the specified validator. This method accepts an optional path for use with nested form controls
  • hasError(v): returns true if the specified validator has generated an error message. This method accepts an optional path for use with nested form controls
  • setErrors(errs): This method is used to add errors to the form control’s validation status, which is useful when performing manual validation in the component. This method accepts an optional path for use with nested form controls,

Simple Form - FormControl

export class FormComponent {
  nameField = new FormControl('', {
    validators: [Validators.required, Validators.minLength(3), Validators.pattern('^[A-Za-z ]+$')],
    updateOn: 'change', // change(default), blur, submit
    nonNullable: true,
  });

  ngOnInit() {
    this.nameField.statusChanges.subscribe((newStatus) => {
      if (newStatus == 'INVALID' && this.nameField.errors != null) {
        const errs = Object.keys(this.nameField.errors).join(', ');
        this.messageService.reportMessage(new Message(`INVALID: ${errs}`));
      } else {
        this.messageService.reportMessage(new Message(newStatus));
      }
    });
    // this.nameField.valueChanges.subscribe((newValue) => {
    //   this.messageService.reportMessage(new Message(newValue || '(Empty)'));
    // });
  }
}

app/shared/validation/validationHelper.pipe.ts:

import {Pipe} from '@angular/core';
import {FormControl, ValidationErrors} from '@angular/forms';

@Pipe({name: 'validationFormat'})
export class ValidationHelper {
  transform(source: any, name: any): string[] {
    if (source instanceof FormControl) {
      return this.formatMessages((source as FormControl).errors, name);
    }
    return this.formatMessages(source as ValidationErrors, name);
  }

  formatMessages(errors: ValidationErrors | null, name: string): string[] {
    let messages: string[] = [];

    for (let errorName in errors) {
      switch (errorName) {
        case 'required':
          messages.push(`You must enter a ${name}`);
          break;
        case 'minlength':
          messages.push(`A ${name} must be at least ${errors['minlength'].requiredLength} characters`);
          break;
        case 'pattern':
          messages.push(`The ${name} contains illegal characters`);
          break;
      }
    }
    return messages;
  }
}

app/shared/shared.module.ts:

import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';

import {ValidationHelper} from './validation/validationHelper.pipe';

@NgModule({
  declarations: [ValidationHelper],
  imports: [CommonModule],
  exports: [ValidationHelper],
})
export class SharedModule {}

form.component.html:

<div class="form-group">
  <label>Name</label>
  <input class="form-control" name="name" [formControl]="nameField" #name="ngForm" />

  <ul class="text-danger list-unstyled mt-1" *ngIf="name.dirty && name.invalid">
+    <li *ngFor="let err of name.errors | validationFormat:'name'">{{ err }}</li>
  </ul>
</div>

FormGroup Members for Adding and Removing Controls

NameDescription
addControl(name, ctrl)adds a control to the FormGroup with the specified name. No action is taken if there is already a control with this name
setControl(name, ctrl)adds a control to the FormGroup with the specified name, replacing any existing control with this name
removeControl(name)removes the control with the specified name
controlsreturns a map containing the controls in the group, using their names as keys
get(name)returns the control with the specified name

FormGroup Methods for Managing Control Values

NameDescription
valuereturns an object containing the values of the form controls in the group, using the names given to each control as the names of the properties.
setValue(val)sets the contents of the form controls using an object, whose property names correspond to the names given to each control. The specified value object must define properties for all the form controls in the group.
patchValue(val)sets the contents of the form controls using an object, whose property names correspond to the names given to each control. Unlike the setValue method, values are not required for all form controls.
reset(val)resets the form to its pristine and untouched state and uses the specified value to populate the form controls.

Displaying Validation Messages with a Form Group

NameDescription
getError(v, path)returns the error message, if there is one, for the specified validator. The optional path argument is used to identify the control.
hasError(v, path)returns true if the specified validator has generated an error message. The optional path argument is used to identify the control.

form.getError("required", "category") will return category control's required error message if any. But this is not very useful. Let's create a reusable and custom structural directives to display all errors for a control.

Custom directive to display all errors for a control ❤️

Create src/app/shared/validation/validationErrors.directive.ts and import/export in the shared.module:

import {Directive, Input, OnInit, TemplateRef, ViewContainerRef} from '@angular/core';
import {FormGroup} from '@angular/forms';

import {ValidationHelper} from './validationHelper';

/**
 * A structural directive to generate validation errors for a formGroup field control
 */
@Directive({selector: '[validationErrors]'})
export class ValidationErrorsDirective implements OnInit {
  constructor(private container: ViewContainerRef, private template: TemplateRef<unknown>) {}

  @Input('validationErrorsControl') name = ''; // control: 'xxx' from <li *validationErrors="productForm; control: 'name'; let err">
  @Input('validationErrorsLabel') label?: string; // label: 'xxx'
  @Input('validationErrors') formGroup?: FormGroup; // e.g. productForm

  ngOnInit() {
    const formatter = new ValidationHelper();

    if (this.formGroup && this.name) {
      const control = this.formGroup?.get(this.name);

      if (control) {
        control.statusChanges.subscribe(() => {
          if (this.container.length > 0) {
            this.container.clear();
          }
          if (control && control.dirty && control.invalid && control.errors) {
            formatter.formatMessages(control.errors, this.label ?? this.name).forEach((err) => {
              this.container.createEmbeddedView(this.template, {$implicit: err});
            });
          }
        });
      }
    }
  }
}

Now our form.component.html can use the directive to show all errors for a specific control:

<input class="form-control" formControlName="price" />
<ul class="text-danger list-unstyled mt-1">
+  <li *validationErrors="productForm; control: 'price'; let err">{{ err }}</li>
</ul>

Nested FormGroup and Validation

export interface ContactFormGroup {
  name: FormControl<string>;
  email: FormControl<string>;
  category: FormControl<string>;
  price: FormControl<string>;
  details: FormGroup<{
    supplier: FormControl<string>;
    keywords: FormControl<string>;
  }>;
  contactNumber?: FormControl<number | null>; //? makes controls as optional
}
productForm = new FormGroup<ContactFormGroup>({
  name: new FormControl<string>('', {
    validators: [Validators.required, Validators.minLength(3), Validators.pattern('^[A-Za-z ]+$')],
    updateOn: 'change',
    nonNullable: true
  }),
  email: new FormControl<string>('', {nonNullable: false}),
  category: new FormControl('', {validators: Validators.required}),
  price: new FormControl('', {validators: [Validators.required, Validators.pattern('^[0-9.]+$')]}),
+  details: new FormGroup({
+    supplier: new FormControl('', {validators: Validators.required}),
+    keywords: new FormControl('', {validators: Validators.required}),
+  }),
});
<ng-container formGroupName="details">
  <div class="form-group">
    <label>Supplier</label>
    <input class="form-control" formControlName="supplier" />
    <ul class="text-danger list-unstyled mt-1">
      <li *validationErrors="productForm; control: 'details.supplier'; label: 'supplier'; let err">{{ err }}</li>
    </ul>
  </div>
  <div class="form-group">
    <label>Keywords</label>
    <input class="form-control" formControlName="keywords" />
    <ul class="text-danger list-unstyled mt-1">
      <li *validationErrors="productForm; control: 'details.keywords'; label: 'keyword'; let err">{{ err }}</li>
    </ul>
  </div>
</ng-container>

FormArray - Creating Form Components Dynamically

The FormGroup class is useful when the structure and number of elements in the form are known in advance. For applications that need to dynamically add and remove elements, Angular provides the FormArray class. Both FormArray and FormControl are derived from the AbstractControl class and provide the same features for managing FormGroup objects; the difference is that the FormArray class allows FormControl objects to be created without specifying names and stores its controls as an array, making it easier to add and remove controls.

Useful FormArray Members for Managing Controls

  • controls: This property returns an array containing the child controls.
  • length: This property returns the number of controls that are in the FormArray.
  • at(index)
  • push(control)
  • insert(index, control)
  • setControl(index, control)
  • removeAt(index): This method replaces the control at the specified index.
  • clear()

The FormArray Methods for Setting Values

  • setValue(values)
  • patchValue(values)
  • reset(values)

Now let's change keywords type to string[].

export class Product {
  constructor(
    public id?: number,
    public name?: string,
    public category?: string,
    public price?: number,
    public details?: Details,
  ) {}
}

export class Details {
+  constructor(public supplier?: string, public keywords?: string[]) {}
}
export class ProductDataSource {
  constructor() {
    this.data = new Array<Product>(
+      new Product(1, 'Kayak', 'Watersports', 275, {supplier: 'Acme', keywords: ['boat', 'small']}),
+      new Product(2, 'Lifejacket', 'Watersports', 48.95, {supplier: 'Smoot Co', keywords: ['safety']}),
      new Product(3, 'Soccer Ball', 'Soccer', 19.5),
    );
  }
}
export class FormComponent implements OnInit {
+  keywordGroup = new FormArray([this.createKeywordFormControl()]);

  productForm = new FormGroup({
    name: new FormControl('', {
      validators: [Validators.required, Validators.minLength(3), Validators.pattern('^[A-Za-z ]+$')],
      updateOn: 'change',
    }),
    category: new FormControl('', {validators: Validators.required}),
    price: new FormControl('', {validators: [Validators.required, Validators.pattern('^[0-9.]+$')]}),
    details: new FormGroup({
      supplier: new FormControl('', {validators: Validators.required}),
-      keywords: new FormControl('', {validators: Validators.required}),
+      keywords: this.keywordGroup,
    }),
  });

  createKeywordFormControl(): FormControl {
+    return new FormControl('', {validators: Validators.pattern('^[A-Za-z ]+$')});
  }

+  addKeywordControl() {
+    this.keywordGroup.push(this.createKeywordFormControl());
+  }

+  removeKeywordControl(index: number) {
+    this.keywordGroup.removeAt(index);
+  }

  handleStateChange(newState: StateUpdate) {
    this.editing = newState.mode == MODES.EDIT;
+    this.keywordGroup.clear();

    if (this.editing && newState.id) {
      Object.assign(this.product, this.repository.getProduct(newState.id) ?? new Product());

+      // create keyword input, whose count is based on the product keywords
+      this.product.details?.keywords?.forEach((val) => {
+        this.keywordGroup.push(this.createKeywordFormControl());
+      });
    } else {
      this.product = new Product();
    }

+    // at least create one keyword input
+    if (this.keywordGroup.length == 0) {
+      this.keywordGroup.push(this.createKeywordFormControl());
+    }

+    // populate or clear the form controls when user clicks the Create New Product or Edit button:
+    this.productForm.reset(this.product);
  }

  submitForm() {
    if (this.productForm.valid) {
       // filter out empty keyword controls
+      this.product = this.productForm.value;
+      const keywords = this.product.details?.keywords?.filter((keyword) => keyword === '');
+      if (this.product.details) {
+        this.product.details.keywords = keywords;
+      }

      this.repository.saveProduct(this.product);
      this.product = new Product();

      this.keywordGroup.clear();
      this.keywordGroup.push(this.createKeywordFormControl());

      this.productForm.reset();
    }
  }

  resetForm() {
+    this.keywordGroup.clear();
+    this.keywordGroup.push(this.createKeywordFormControl());

    this.editing = true;
    this.product = new Product();
    this.productForm.reset();
  }
}
<ng-container formGroupName="details">
  <div class="form-group">
    <label>Supplier</label>
    <input class="form-control" formControlName="supplier" />
    <ul class="text-danger list-unstyled mt-1">
      <li *validationErrors="productForm; control: 'details.supplier'; label: 'supplier'; let err">{{ err }}</li>
    </ul>
  </div>
  <!-- <div class="form-group">
      <label>Keywords</label>
      <input class="form-control" formControlName="keywords" />
      <ul class="text-danger list-unstyled mt-1">
        <li *validationErrors="productForm; control: 'details.keywords'; label: 'keyword'; let err">{{ err }}</li>
      </ul>
    </div> -->
  <ng-container formGroupName="keywords">
    <button class="btn btn-sm btn-primary my-2" (click)="addKeywordControl()" type="button">Add Keyword</button>

    <div class="form-group" *ngFor="let c of keywordGroup.controls; let i = index; let count = count">
      <label>Keyword {{ i + 1 }}</label>
      <div class="input-group">
        <input class="form-control" [formControlName]="i" [value]="c.value" />
        <button *ngIf="count > 1" class="btn btn-danger" type="button" (click)="removeKeywordControl(i)">Delete</button>
      </div>
      <ul class="text-danger list-unstyled mt-1">
        <li *validationErrors="productForm; control: 'details.keywords.' + i; label: 'keyword'; let err">{{ err }}</li>
      </ul>
    </div>
  </ng-container>
</ng-container>

We can create many keyword controls, when user submit the form, several keyword controls can be empty, we can use required validator to forbidden the submission. But I'd rather not force user to stop and hit delete button and allow empty values to be submitted. We can filter out empty strings from form.value.

The other way is to use the below FilteredFormArray:

import {FormArray} from '@angular/forms';

export type ValueFilter = (value: any) => boolean;

/**
 * filter out unwanted values ('' or null) for FormArray controls
 * There are two issues with using methods like this.
 * 1. that internal methods `_updateValue` are subject to change or removal without notice, which means that figure releases of Angular may remove the _ updateValue method and break the code
 * 2. Angular packages are compiled using a TypeScript setting that excludes internal methods from the type declaration files that are used during project development. So TypeScript compiler doesn’t know that the FormArray class defines an _updateValue method and won’t allow the use of the override or the super keywords. For this reason, I have had to copy the original code from the FormArray class and integrate support for filtering, rather than just calling the FormArray implementation of the method and filtering the result.
 */
export class FilteredFormArray extends FormArray {
  filter: ValueFilter | undefined = (val) => val === '' || val == null;

  // _updateValue is internal API: originally defined as an abstract method in the AbstractControl class and then overridden in the FormArray class.
  _updateValue() {
    (this as {value: any}).value = this.controls
      .filter((control) => (control.enabled || this.disabled) && !this.filter?.(control.value))
      .map((control) => control.value);
  }
}