DEV Community

Cover image for How to manage object in Angular FormControl
Dharmen Shah
Dharmen Shah

Posted on • Originally published at indepth.dev

How to manage object in Angular FormControl

In this tutorial, we will learn how to manage object in FormControl and handle conversion for input

Generally we use FormControl with either string or boolean types and hence it manages only simple values. But what if we want to manage just more than primitive data types? We can do that, let’s see how.

For example, in this tutorial we will learn how to get country-code and number as separate entities from the same form-control, and we will also create custom input for the same to handle the conversion between UI and value.

The Telephone Class

Let’s assume that we have a Telephone class, which will hold the related values:

export class Telephone {
  constructor(
    public countryCode: string,
    public phoneNumber: string
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Next, let’s take an input[type=tel] in which we want to hold the Telephone:

@Component({
  selector: "app-root",
  template: `
    <input
      type="tel"
      name="telephone"
      id="telephone"
      [formControl]="telephone"
    />
    <div>Value: {{ telephone.value | json }}</div>
  `
})
export class AppComponent {
  telephone = new FormControl(new Telephone("", ""));
}
Enter fullscreen mode Exit fullscreen mode

Notice how we have initialized the new Telephone class in FormControl:

Let’s take a look at output now:

Form control with object

The first thing you will notice is that input is showing [object] [Object] in it’s value, because it's a string representation of the object (in our case, it's a member of Telephone class). To fix input’s value, we can simply provide the toString() method in the Telephone class. You can read more about it on MDN docs.

export class Telephone {
  constructor(public countryCode: string, public phoneNumber: string) {}
  toString() {
    return this.countryCode && this.phoneNumber
      ? `${this.countryCode}-${this.phoneNumber}`
      : "";
  }
}
Enter fullscreen mode Exit fullscreen mode

The second thing is that FormControl’s value has the desired Telephone’s structure initially.

But, if you modify the input, FormControl’s value will change to string. We will need to work on the value conversion from UI to FormControl.

For that, we will create a custom input directive for input[type=tel] using CustomValueAccessor.

Custom Input for Telephone

The initial code for InputTelDirective directive looks like below:

// src/app/shared/directives/input-tel.directive.ts

import { Directive } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Directive({
  selector: 'input[type=tel]',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: InputTelDirective,
      multi: true
    }
  ]
})
export class InputTelDirective implements ControlValueAccessor {
  constructor() {}
  writeValue(obj: any): void {}
  registerOnChange(fn: any): void {}
  registerOnTouched(fn: any): void {}
}
Enter fullscreen mode Exit fullscreen mode

If you’re new to ControlValueAccessor, you can read more about it at: Never again be confused when implementing ControlValueAccessor in Angular forms and How to use ControlValueAccessor to enhance date input with automatic conversion and validation

For this example, we are only concerned about writeValue and registerOnChange. Simply put, writeValue is used to convert FormControl’s value to UI value and registerOnChange is used to convert UI value to FormControl’s value.

Conversion from UI to FormControl

We are going to assume that the user will enter the value in this form: +CC-XXXXXX, where characters before hyphen (-) combine to country-code and the rest is actual contact number. Note that this is not intended to be a robust directive, just something from which we can start learning.

To handle that, let’s first add a listener on input event:

@HostListener("input", ["$event.target.value"])
  onInput = (_: any) => {};
Enter fullscreen mode Exit fullscreen mode

Next, let’s modify the registerOnChange method:

registerOnChange(fn: any): void {
    this.onInput = (value: string) => {
      let telephoneValues = value.split("-");
      const telephone = new Telephone(
        telephoneValues[0],
        telephoneValues[1] || ""
      );
      fn(telephone);
    };
  }
Enter fullscreen mode Exit fullscreen mode

Let’s look at the output now:

Form control with Custom Value Accessor

Works nice! It is converting UI value to a valid FormControl’s value, i.e. Telephone class’s member.

Conversion from FormControl to UI

If you try to set the initial state through FormControl, it won’t reflect on input:

telephone = new FormControl(new Telephone("+91", "1234567890"));
Enter fullscreen mode Exit fullscreen mode

Let’s modify the writeValue method to handle the above:

writeValue(value: Telephone | null): void {
    const telephone = value || new Telephone("", "");
    this._renderer.setAttribute(
      this._elementRef.nativeElement,
      "value",
      telephone.toString()
    );
  }
Enter fullscreen mode Exit fullscreen mode

Now the value set through FormControl will get reflected to input.

Validation

Now we will add the validation part so that input supports validation out-of-the box.

We will first add NG_VALIDATORS in providers:

// src/app/shared/directives/input-tel.directive.ts

// …

@Directive({
  selector: 'input[type=tel]',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: InputTelDirective,
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: InputTelDirective,
      multi: true,
    },
  ],
})
Enter fullscreen mode Exit fullscreen mode

Next, we will add isValid method in Telephone class to check validity:

export class Telephone {
  // ...
  isValid() {
    return !!(this.countryCode && this.phoneNumber);
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we will implement the Validator interface and add validate method:

export class InputTelDirective implements ControlValueAccessor, Validator {

  // ...

  validate(control: AbstractControl): ValidationErrors | null {
    const telephone = control.value as Telephone;
    return telephone.isValid() ? null : { telephone: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s modify the template to utilize validation:

@Component({
  selector: "app-root",
  template: `
    <input
      type="tel"
      name="telephone"
      id="telephone"
      [formControl]="telephone"
      [class.is-invalid]="
        (telephone?.touched || telephone?.dirty) && telephone?.invalid
      "
    />
    <div
      class="invalid-feedback"
      *ngIf="(telephone.touched || telephone.dirty) && telephone.invalid"
    >
      Invalid Telephone
    </div>
    <div>Value: {{ telephone.value | json }}</div>
  `
})
export class AppComponent {
  telephone = new FormControl(new Telephone("", ""));
}
Enter fullscreen mode Exit fullscreen mode

Let's look at the output now:

Form control with Validation

Conclusion

We learned how we can manage the object in FormControl and use ControlValueAccessor to handle the conversion.

The whole code for directive looks like below:

import { Directive, ElementRef, HostListener, Renderer2 } from "@angular/core";
import {
  AbstractControl,
  ControlValueAccessor,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator,
} from "@angular/forms";

@Directive({
  selector: "input[type=tel]",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: InputTelDirective,
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: InputTelDirective,
      multi: true,
    },
  ],
})
export class InputTelDirective implements ControlValueAccessor, Validator {
  constructor(
    private _elementRef: ElementRef<HTMLInputElement>,
    private _renderer: Renderer2
  ) {}

  @HostListener("input", ["$event.target.value"])
  onInput = (_: any) => {};

  writeValue(value: Telephone | null): void {
    const telephone = value || new Telephone("", "");
    this._renderer.setAttribute(
      this._elementRef.nativeElement,
      "value",
      telephone.toString()
    );
  }

  registerOnChange(fn: any): void {
    this.onInput = (value: string) => {
      let telephoneValues = value.split("-");
      const telephone = new Telephone(
        telephoneValues[0],
        telephoneValues[1] || ""
      );
      fn(telephone);
    };
  }

  registerOnTouched(fn: any): void {}

  validate(control: AbstractControl): ValidationErrors | null {
    const telephone = control.value as Telephone;
    return telephone.isValid() ? null : { telephone: true };
  }
}

export class Telephone {
  constructor(public countryCode: string, public phoneNumber: string) {}
  toString() {
    return this.countryCode && this.phoneNumber
      ? `${this.countryCode}-${this.phoneNumber}`
      : "";
  }
  isValid() {
    return !!(this.countryCode && this.phoneNumber);
  }
}

Enter fullscreen mode Exit fullscreen mode

I have also created a GitHub repo for all the code above.

Discussion (0)