Angular Reactive forms

From bibbleWiki
Jump to navigation Jump to search

Angular Forms

Introduction

Angular provides two types of forms

  • Template Driven
    • Easy to use
    • Familar to Angular JS
    • Two-way data binding-> minimal component code
    • Automatically tracks from and input element state
  • Reactive (model driven
    • More flexible
    • Immutable data model
    • Easier to perform an action on value change
    • Access Reactive transformations such as DebounceTime or DistinctUntilChanged
    • Easily add input elements dynamically
    • Easier unit testing

Template driven forms put the responsibility in the template using basic HTML and data binding, Reactive forms move most of the responsibility to the component class

Template-Driven-Forms

In the Template we have

  • Form elements
  • Input elements
  • Data binding to the component
  • Validation rules (attributes)
  • Validation error messages
  • Form model (automatically generated)

In the Component Class we have

  • Properties for data binding
  • Method for form Operations such as submit

Directives used are

  • ngForm
  • ngModel
  • ngModelGroup

Example form

<form (ngSubmit)="save()">
  <input id="firstNameId" type="text"
  [(ngMode)]="customer.firstname"
  name="firstname"
  #firstNameVar="ngModel" />
</form>

Reactive Forms

In the template we have

  • Form elements
  • Input elements
  • Binding to form model

In the class we have

  • Form model which we create
  • Validation rules
  • Validation error messages
  • Properties for managing data (data model)
  • Methods for form Operations such as submit

Directives used (ReactiveFormsModule)

  • formGroup
  • formControl
  • formControlName
  • formGroupName
  • formArrayName

Template vs Reactive Example

Template form example

Angular forms template example.png

Reactive form example

Angular forms reactive example.png

Create a Reactive Form

Preparation

Make sure we import the ReactiveFormsModule in app.module.ts

Component Creation

Create a FormGroup in ngInit. Note we can initialize the controls at creation

export class CustomerComponent implements OnInit {
...  
  customerForm :FormGroup

  ngOnInit(): void {
    this.customerForm = new FormGroup({
      firstName: new FormControl(),
      lastName: new FormControl(),
      emailName: new FormControl(),
      sendCatog: new FormControl(true)
    })
  }
...

To set the values of the components you can use setValue or patchValue. Patch value will set a subset of the fields

export class CustomerComponent implements OnInit {
      customerForm.patchValue({
         firstName: 'Jack'
      }
  }
...

Now we can use FormBuilder using injection to simplify the code.

...
  constructor(private formBuilder: FormBuilder) {}

  ngOnInit(): void {

    this.customerForm = this.formBuilder.group({
       firstName: '',
       lastName: '',
       email: '',
       sendCatalog: true,
    })

Template Creation

We specify the form using the formGroup

  <div class="card-body">
    <form novalidate
          (ngSubmit)="save()"
          [formGroup]="customerForm">
...

We add fields with formControlName tag. Note getting this wrong, e.g. the name or the tag will result in the form not being rendered correctly. You can test this by adding a

MEEEEETemplate:FormName.dirty to find out where the error is.
          <input class="form-control"
                 id="emailId"
                 type="email"
                 placeholder="Email (required)"
                 required
                 email
                 formControlName='email'
                 [ngClass]="{'is-invalid': (customerForm.get('email').touched || customerForm.get('email').dirty) && !customerForm.get('email').valid }" />

Accessing Form Model Properties

In the template we can use

customerForm.controls.firstName.valid 
customerForm.get('firstName').valid

Or create a reference in the component to be used, in this case firstName

this.customerFrom = new FormGroup({
   firstName: this.firstName
})

Validation

Basic

We can set basic rules by passing an array of validators at creation time. Below we pass the default value and an array of validators e.g.

this.customerForm  this.formBuilder.group({
   firstName: ['default value'],
     [Validators.required, Validators.minLength(3)]],
   nextField: ''
...

At Runtime

We can do this with

  myEdit.setValidators(...);
  myEdit.clearValidators();
  myEdit.updateValueAndValidity()

Below is an example of handling a radio button on the form

Component

  setNotification(notifyVia: string): void {
    const phoneControl = this.customerForm.get('phone');
    if (notifyVia === 'text') {
      phoneControl.setValidators(Validators.required);
    } else {
      phoneControl.clearValidators();
    }
    phoneControl.updateValueAndValidity();
  }

Template

      <div class="form-group row mb-2">
        <label class="col-md-2 col-form-label pt-0">Send Notifications</label>
        <div class="col-md-8">
          <div class="form-check form-check-inline">
            <label class="form-check-label">
              <input class="form-check-input"
                     type="radio"
                     value="email"
                     formControlName="notification"
                     (click)="setNotification('email')">Email
            </label>
          </div>
          <div class="form-check form-check-inline">
            <label class="form-check-label">
              <input class="form-check-input"
                     type="radio"
                     value="text"
                     formControlName="notification"
                     (click)="setNotification('text')">Text
            </label>
          </div>
        </div>
      </div>

Custom Validator

Introduction

Validators are functions, to create one we implement the function below returning 'null if everything is ok.

function myValidator(c: AbstractControl): {[key: string] : boolean} | null {
  if(somethingIsWrong) {
    return {'myValidator', true}
  }
  return null
}

Component

function ratingRange(c: AbstractControl): { [key: string]: boolean } | null {
  if (c.value !== null && (isNaN(c.value) || c.value < 1 || c.value > 5)) {
      return { range: true };
  }
  return null;
}

Template

      <div class="form-group row mb-2">
        <label class="col-md-2 col-form-label"
               for="ratingId">Rating</label>
        <div class="col-md-8">
          <input class="form-control"
                 id="ratingId"
                 type="number"
                 formControlName="rating"
                 [ngClass]="{'is-invalid': (customerForm.get('rating').touched ||
                                             customerForm.get('rating').dirty) &&
                                             !customerForm.get('rating').valid }" />
          <span class="invalid-feedback">
            <span *ngIf="customerForm.get('rating').errors?.range">
              Please rate your experience from 1 to 5.
            </span>
          </span>
        </div>
      </div>

Passing Parameters to Validator

To pass parameters we need to wrap the function in a function with the necessary parameters. Starting with

function myValidator(c: AbstractControl): {[key: string] : boolean} | null {
  if(somethingIsWrong) {
    return {'myValidator', true}
  }
  return null
}

And wrapping we get

function myValidator(param: any): ValidationFn {
   return (c: AbstractControl): {[key: string] : boolean} | null => {
          if(somethingIsWrong) {
       return {'myValidator', true}
    }
    return null
  }
}

Add Our parameters we get

function ratingRange(min: number, max: number): ValidatorFn {
  return (c: AbstractControl): { [key: string]: boolean } | null => {
    if (c.value !== null && (isNaN(c.value) || c.value < min || c.value > max)) {
      return { range: true };
    }
    return null;
  };
}

So now we can use this using

  ngOnInit(): void {

    this.customerForm = this.formBuilder.group({
...
       rating:[null, ratingRange(1,5)],
...
    })
  }

Cross Field Validation

Introduction

This is achieved using formGroups

Component

We create and group and add the two controls to the group. This allows the validator to gain access to the controls. For the validator we pass this as the second argument to the group as an object using the key validator and the name of the function to support this. In our case eamilmatcher

  ngOnInit(): void {

    this.customerForm = this.formBuilder.group({
      firstName: ['',[Validators.required,Validators.minLength(3)]],
...
       eailGroup: this.formBuilder.group({
        email: ['',[Validators.required,Validators.email]],
        confirmEmail: ['',[Validators.required]],
       }),
       phone: '',
...

Next we add a validation function. This accesses the controls via the formGroup

function emailMatcher(c: AbstractControl): { [key: string]: boolean } | null {
  const emailControl = c.get('email');
  const confirmControl = c.get('confirmEmail');

  if (emailControl.pristine || confirmControl.pristine) {
    return null;
  }

  if (emailControl.value === confirmControl.value) {
    return null;
  }
  return { match: true };
}

Template

      <div formGroupName="emailGroup">

        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label"
                 for="emailId">Email</label>
          <div class="col-md-8">
            <input class="form-control"
                   id="emailId"
                   type="email"
                   placeholder="Email (required)"
                   formControlName='email'
                   [ngClass]="{'is-invalid': 
                      customerForm.get('emailGroup').errors ||
                      (customerForm.get('emailGroup.email').touched || 
                      customerForm.get('emailGroup.email').dirty) && 
                      !customerForm.get('emailGroup.email').valid }" />
          </div>
        </div>

        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label"
                 for="confirmEmailId">Confirm Email</label>
          <div class="col-md-8">

            <input class="form-control"
                   id="confirmEmailId"
                   type="email"
                   placeholder="Confirm Email (required)"
                   email
                   formControlName='confirmEmail'
                   [ngClass]="{'is-invalid': 
                      customerForm.get('emailGroup').errors ||
                      (customerForm.get('emailGroup.confirmEmail').touched || 
                      customerForm.get('emailGroup.confirmEmail').dirty) && 
                      !customerForm.get('emailGroup.confirmEmail').valid }" />

            <span class="invalid-feedback">
              <span *ngIf="customerForm.get('emailGroup.confirmEmail').errors?.required">
                Please confirm your email address.
              </span>
              <span *ngIf="customerForm.get('emailGroup').errors?.match">
                The confirmation does not match the email address.
              </span>
            </span>

          </div>
        </div>

      </div>

Reacting to Changes

Introduction

We send control back to the component by calling valueChanges on the form, group or control.

this.myForm.valueChanges.subscribe(value => {
})

this.myGroupControl.valueChanges.subscribe(value => {
})

this.myFormControl.valueChanges.subscribe(value => {
})

Example Radio Button

The notification is the control and the setNotification is the function to execute.

    this.customerForm.get('notification').valueChanges.subscribe(
      value => this.setNotification(value)
    )

Move the Validation Message

To do this we

  • Create array to hold messages which match the error (required, email)
  • Create a property to hold the error
  • Add a listener to the control
  • Create a validation message to match the error
  • Replace template with property
// Create a message array
  private validationMessages = {
    required: "Please enter your email address",
    email: "Please enter a valid email address"
  }

// Add property to class
  emailMessage: string;

// Start listening
  ngOnInit(): void {

    this.customerForm = this.formBuilder.group({
      firstName: ['Iain', [Validators.required, Validators.minLength(3)]],
...
    const emailControl = this.customerForm.get('emailGroup.email')
    emailControl.valueChanges.subscribe(
      value => this.setMessage(emailControl)
    )
  }

// Add the validation in typescript
  setMessage(c: AbstractControl): void {
    this.emailMessage = '';
    if((c.touched || c.dirty) && c.errors ) {
      this.emailMessage = Object.keys(c.errors).map(
        key => this.validationMessages[key]).join(' ')
    }
  }

Now we can change the template to use the new emailMessage property
Previously

            <input class="form-control"
                   id="emailId"
                   type="email"
                   placeholder="Email (required)"
                   formControlName='email'
                   [ngClass]="{'is-invalid': 
                      customerForm.get('emailGroup').errors ||
                      (customerForm.get('emailGroup.email').touched || 
                      customerForm.get('emailGroup.email').dirty) && 
                      !customerForm.get('emailGroup.email').valid }" />

Now

            <input class="form-control"
                   id="emailId"
                   type="email"
                   placeholder="Email (required)"
                   formControlName='email'
                   [ngClass]="{'is-invalid': emailMessage}" />

Reactive Transformations (Including Debounce)

As the use types it is helpful not to give feedback straight away. We can implement strategies to improve the user experience with reactive transformations Example are

  • Debounce, Ignore events until a specific time has passed without another event
  • throttleTime, Emits a value, then ignores subsequent value for a specific amount of time
  • distinctUntilChanged, Suppress duplicate consecutive items
    const emailControl = this.customerForm.get('emailGroup.email')
    emailControl.valueChanges.pipe(
       debounceTime(1000)
    ).subscribe(
      value => this.setMessage(emailControl)
    )

Duplicating Input Elements

Steps Required

  • Define the input elements to copy
  • Define a form group if needed
  • Refactor to make copies
  • Create a form array
  • Loop through FormArray
  • Duplicate the input elements

Define the input elements to copy

Component

...
    this.customerForm = this.formBuilder.group({
...
      addressType: 'home',
      street1: '',
      street2: '',
      city: '',
      state: '',
      zip: '',
    })

Template

      <div *ngIf="customerForm.get('sendCatalog').value">
        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label pt-0">Address Type</label>
          <div class="col-md-8">
            <div class="form-check form-check-inline">
              <label class="form-check-label">
                <input class="form-check-input"
                       id="addressType1Id"
                       type="radio"
                       value="home"
                       formControlName="addressType"> Home
              </label>
            </div>
            <div class="form-check form-check-inline">
              <label class="form-check-label">
                <input class="form-check-input"
                       id="addressType1Id"
                       type="radio"
                       value="work"
                       formControlName="addressType"> Work
              </label>
            </div>
            <div class="form-check form-check-inline">
              <label class="form-check-label">
                <input class="form-check-input"
                       id="addressType1Id"
                       type="radio"
                       value="other"
                       formControlName="addressType"> Other
              </label>
            </div>
          </div>
        </div>
        
        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label"
                 for="street1Id">Street Address 1</label>
          <div class="col-md-8">
            <input class="form-control"
                   id="street1Id"
                   type="text"
                   placeholder="Street address"
                   formControlName="street1">
          </div>
        </div>

        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label"
                 for="street2Id">Street Address 2</label>
          <div class="col-md-8">
            <input class="form-control"
                   id="street2Id"
                   type="text"
                   placeholder="Street address (second line)"
                   formControlName="street2">
          </div>
        </div>

        <div class="form-group row mb-2">
          <label class="col-md-2 col-form-label"
                 for="cityId">City, State, Zip Code</label>
          <div class="col-md-3">
            <input class="form-control"
                   id="cityId"
                   type="text"
                   placeholder="City"
                   formControlName="city">
          </div>
          <div class="col-md-3">
            <select class="form-control"
                    id="stateId"
                    formControlName="state">
              <option value=""
                      disabled
                      selected
                      hidden>Select a State...</option>
              <option value="AL">Alabama</option>
              <option value="AK">Alaska</option>
              <option value="AZ">Arizona</option>
              <option value="AR">Arkansas</option>
              <option value="CA">California</option>
              <option value="CO">Colorado</option>
              <option value="WI">Wisconsin</option>
              <option value="WY">Wyoming</option>
            </select>
          </div>
          <div class="col-md-2">
            <input class="form-control"
                   id="zipId"
                   type="number"
                   placeholder="Zip Code"
                   formControlName="zip">
          </div>
        </div>
      </div>

Define a form group if needed

Component

...
    this.customerForm = this.formBuilder.group({
...
      addresses: this.formBuilder.group({
        addressType: 'home',
        street1: '',
        street2: '',
        city: '',
        state: '',
        zip: '',
      })

Template

...
      <div formGroupName="addresses">
        <div *ngIf="customerForm.get('sendCatalog').value">
          <div class="form-group row mb-2">
            <label class="col-md-2 col-form-label pt-0">Address Type</label>

...
      </div>

Refactor to make copies

We just create a function to create the array

Component

...
    this.customerForm = this.formBuilder.group({
...
    addresses: buildAddresses(),
}
...

  buildAddresses():FormGroup {
    return this.formBuilder.group({
      addressType: 'home',
      street1: '',
      street2: '',
      city: '',
      state: '',
      zip: '',
    })
  }

Create a form array

Now let create getter and the array

Component

get addresses(): FormArray {
   return <FormArray>this.customerForm.get('addresses')
}
...
    this.customerForm = this.formBuilder.group({
...
    addresses: this.formBuilder.array([this.buildAddresses()]),
}
...

Template

Now we need to put the formArrayName around our original form group. We temporarily rename the form group to "0" to signify the first element

...
      <div formArrayName="addresses">
        <div formGroupName="0">
          <div *ngIf="customerForm.get('sendCatalog').value">
            <div class="form-group row mb-2">
              <label class="col-md-2 col-form-label pt-0">Address Type</label>
...
        </div>
      </div>

Loop Through Form Array

Now let create getter and the array

Component

Template

We need to use the ngFor to loop through our array and bind the group name to index

...
      <div formArrayNane="addresses">
        *ngFor="let address of addresses.controls; let i=index">
        <div [formGroupName]="i">
          <div *ngIf="customerForm.get('sendCatalog').value">
            <div class="form-group row mb-2">
              <label class="col-md-2 col-form-label pt-0">Address Type</label>
...
        </div>
      </div>

We need to make sure that the fields within the form have id associated with the current index. We do this for the label and the input control Before

            <div class="form-group row mb-2">
              <label class="col-md-2 col-form-label"
                     for="street1Id">Street Address 1</label>
              <div class="col-md-8">
                <input class="form-control"
                       id="street1Id"
                       type="text"
                       placeholder="Street address"
                       formControlName="street1">
              </div>
            </div>

After

            <div class="form-group row mb-2">
              <label class="col-md-2 col-form-label"
                     attr.for="{{'street1Id' + i}}">Street Address 1</label>
              <div class="col-md-8">
                <input class="form-control"
                       id="{{'street1Id' + i}}"
                       type="text"
                       placeholder="Street address"
                       formControlName="street1">
              </div>
            </div>

Duplicate Input Elements

Component

Finally we can do this. We need to create a function to add an address to the array

addAddress(): void {
  this.addresses.push(this.buildAddresses())
}

Template

Add we need a button to do this on the page

        <div class="form-group row mb-2">
          <div class="col-md-4">
            <button class="btn btn-outline-primary"
                    type="button"
                    [title]="addresses.valid ? 'Add another mailing address' : 'Disabled until the existing address data is valid'"
                    [disabled]="!addresses.valid"
                    (click)="addAddress()">
              Add Another Address
            </button>
          </div>
        </div>

And the result is we can add addresses dynamically Angular form addresses.png

CRUD Example

Introduction

To get CRUD working we need,

  • Backend Server
  • Service to encapsulate the interaction from the client to the server
  • Code to support the reading of the data returned from the server

Backend Server

How to set this up can be found on Angular_data_access_service

Service

A example of the service can be found Angular_data_access_service but below is the example for reading a product from the backend. This assumes that if the id = 0 we are creating a new product. The of wraps the request into an observable as that is what our form code assumes

  getProduct(id: number): Observable<Product> {
    if (id === 0) {
      return of(this.initializeProduct());
    }
    const url = `${this.productsUrl}/${id}`;
    return this.http.get<Product>(url)
      .pipe(
        tap(data => console.log('getProduct: ' + JSON.stringify(data))),
        catchError(this.handleError)
      );
  }

Form Code

  • Get the Passed Id (ngInit)
  • Subscribing to Service (ngInit)
  • Display the Product

Get the Passed Id

We get the id using the route ActivatedRoute passed into the page.

...
  ngOnInit(): void {
    this.sub = this.route.paramMap.subscribe(
      params => {
        const id = +params.get('id');
        this.getProduct(id);
      }
    );
...

Subscribing to Service

We subscribe to the service so we can get updates. We also provide what to do when either success or error

  getProduct(id: number): void {
    this.productService.getProduct(id)
      .subscribe({
        next: (product: Product) => this.displayProduct(product),
        error: err => this.errorMessage = err
      });
  }

POST vs PUT

Just a reminder of the differences POST

  • Posts data for a resource or set of resources
  • Used to
    • Create new resource when the server assigns the id
    • Update a set of resources
  • Not idempotent (ie call it 10 times you get 10 entries)

PUTS

  • Puts data for a specific resource with an id
  • Used to
    • Create new resource when the client assigns the id
    • Update a resource with the id
  • Idempotent (ie call it 10 times you get 1 entries)

Display The Product (GET)

We clear the form, set the title then using patchValue set the controls appropriately

  displayProduct(product: Product): void {
    if (this.productForm) {
      this.productForm.reset();
    }
    this.product = product;

    if (this.product.id === 0) {
      this.pageTitle = 'Add Product';
    } else {
      this.pageTitle = `Edit Product: ${this.product.productName}`;
    }

    // Update the data on the form
    this.productForm.patchValue({
      productName: this.product.productName,
      productCode: this.product.productCode,
      starRating: this.product.starRating,
      description: this.product.description
    });
    this.productForm.setControl('tags', this.fb.array(this.product.tags || []));
  }

Save The Product (PUT/POST)

Merging Original And New Data

We need to make sure that the values not shown on the form are not lost when we do an update. To do this we merge the data from the form with the original product. The following line achieves this by spreading the values and replacing any changed values

        const p = { ...this.product, ...this.productForm.value };

Calling The Service

The code checks the business rule for if this is a create or an update and calls the appropriate service function.

  saveProduct(): void {
    if (this.productForm.valid) {
      if (this.productForm.dirty) {
        const p = { ...this.product, ...this.productForm.value };

        if (p.id === 0) {
          this.productService.createProduct(p)
            .subscribe({
              next: () => this.onSaveComplete(),
              error: err => this.errorMessage = err
            });
        } else {
          this.productService.updateProduct(p)
            .subscribe({
              next: () => this.onSaveComplete(),
              error: err => this.errorMessage = err
            });
        }
      } else {
        this.onSaveComplete();
      }
    } else {
      this.errorMessage = 'Please correct the validation errors.';
    }
  }

Completion

At completion we need to reset the form to ensure the guards to protect from navigating away are reset. I our case we are navigating back to the product list

  onSaveComplete(): void {
    // Reset the form to clear the flags
    this.productForm.reset();
    this.router.navigate(['/products']);
  }

Delete The Product (DELETE)

This is not any more complicated than the save

  deleteProduct(): void {
    if (this.product.id === 0) {
      // Don't delete, it was never saved.
      this.onSaveComplete();
    } else {
      if (confirm(`Really delete the product: ${this.product.productName}?`)) {
        this.productService.deleteProduct(this.product.id)
          .subscribe({
            next: () => this.onSaveComplete(),
            error: err => this.errorMessage = err
          });
      }
    }
  }