Control Value Accessor: Integración con Angular forms (Básico)

por | Sep 20, 2020 | Angular | 1 Comentario

Control Value Accessor Basic

Control Value Accessor

Hay ocasiones en las que necesitamos desarrollar un componente que no sigue las guidelines o reglas de diseño establecidas. Bien porque el control no existe o porque se requiere modificar en profundidad alguno existente. En una situación como ésta, lo más común es que simplemente creemos el componente con la funcionalidad requerida, y mediante propiedades de entrada y salida comuniquemos al componente con nuestra aplicación. En la práctica puede parecer que el componente ya está integrado con Angular como si fuera nativo, pero en realidad solo está simulando ese comportamiento.

Prefacio

Pongámonos en situación con un sencillo ejemplo. En nuestra aplicación usamos formularios con ngModel (Template-driven Forms). El modelo de datos que usamos es el siguiente.

class Hero {
  constructor(
    public id: number,
    public name: string,
    public power: string,
    public alterEgo?: string
  ) {  }
}

// Datos de ejemplo para nuestro componente
let myHero = new Hero(
  42,
  'SkyDog',
  'Fetch any object at any distance',
  'Leslie Rollover'
);

Y en el HTML tenemos lo siguiente.

<div>
  <label for="name">Name</label>
  <input type="text" id="name" name="name"
    [(ngModel)]="myHero.name" required />
</div>

<div>
  <label for="alterEgo">Alter Ego</label>
  <input type="text" id="alterEgo" name="alterEgo"
    [(ngModel)]="myHero.alterEgo" required />
</div>

<div>
  <label for="power">Hero Power</label>
  <custom-control id="power" name="power"
    [value]="myHero.power"
    (valueChange)="myHero.power = $event"
    [required]="true">
  </custom-control>
</div>

Podemos observar que los 2 primeros controles utilizan ngModelrequired, lo cual mantiene actualizado el valor y estado de validación del modelo en todo momento. En cambio, en nuestro componente custom-control hemos tenido que crear una propiedad de entrada, y mediante un evento de salida actualizar el valor manualmente. Además, si queremos mantener la posibilidad de validar si es requerido hemos tenido que añadir otra propiedad más (y hacer las validaciones internamente).

Por ello vamos a afirmar, que a pesar de poder cumplir con sus especificaciones y diseño requeridos, NO es un componente que se pueda considerar integrado con los formularios de Angular y reusable en este sentido. Puesto que si en vez de formularios simples (Template-driven forms), en un momento dado empezaramos a utilizar formularios dinámicos (Reactive Forms), la forma de trabajar con sus propiedades cambiaría. Tendríamos que actualizar la forma de trabajar con nuestro componente en todos los sitios del proyecto en los que ya se estuviera usando.

Integración con Angular Forms

¿Cuál es la solución? Convertir nuestro componente en un Control Value Accessor. Este es el modo en el que el equipo de Angular expone su API de formularios y la pone a nuestra disposición. Su uso consiste en implementar una interfaz, la cual contiene las funciones comunes a todos los controles de formulario de Angular, ya sean simples o dinámicos:

  • writeValue – Recibe el valor del form control en nuestro componente
  • registerOnChange – Nos provee la función necesaria para indicar al form control que el valor de nuestro componente ha cambiado
  • registerOnTouched – Nos provee la función necesaria para indicar al form control que el estado del componente ha cambiado
  • setDisabledState – Recibimos el valor del form control cada vez que se habilita o deshabilita en nuestro componente
Control Value Accessor: Integración con Angular forms (Básico)

Objetivo

Para comprender mejor el funcionamiento de la interfaz Control Value Accessor, desarrollaremos un componente sencillo que la implemente para seleccionar un color primario RGB. El resultado se verá similar a la imágen que os dejo a continuación.

Control Value Accessor: Integración con Angular forms (Básico)

Tecnologías

El stack de versiones que se usarán para el desarrollo del proyecto es el siguiente.

  • Angular CLI (9.0.5)
  • Node (12.14.1)
  • Npm (6.13.7)
  • Typescript (3.7.5)

Creación del proyecto

Vamos a empezar creando un proyecto nuevo que llamaremos cva-basic.

ng new cva-basic

Creamos un componente llamado color-selector.

ng g c components/color-selector

Dejamos preparado el archivo app.module.ts para más tarde.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { ColorSelectorComponent } from './components/color-selector/color-selector.component';

@NgModule({
  declarations: [
    AppComponent,
    ColorSelectorComponent
  ],
  imports: [
    BrowserModule,
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

También dejamos preparados los archivos app.component.htmlapp.component.scss para que muestre el componente.

app.component.html

<div class="container">

  <app-color-selector>
  </app-color-selector>

</div>

app.component.scss

.container {
  display: flex;
  flex-flow: row;
  place-content: center;
  place-items: center;
}

Componente Color Selector

Nuestro componente va a consistir en 3 círculos con los colores primarios, que actuarán como botones de selección. Cuando se haga click sobre uno de ellos se quedará «seleccionado» y mostrará un tick para indicarlo. Si se hace click sobre el que ya está seleccionado, éste se desmarcará.

En el archivo color-selector.component.ts tendremos una variable que guardará el color que tenemos seleccionado (si es que lo hay). Y una función que será la que seleccione o deseleccione un color. Además, definiremos un tipo personalizado para los colores que acepta el componente.

color-selector.component.ts

import { Component, OnInit } from '@angular/core';

/** Función adaptadora para el array de colores */
const colorsType = <T extends string>(array: T[]) => array;

/** Colores admitidos por el componente */
const colors = colorsType(['red', 'green', 'blue']);

/** Modelo de colores del componente */
type ColorSelected = (typeof colors)[number];

@Component({
  selector: 'app-color-selector',
  templateUrl: './color-selector.component.html',
  styleUrls: ['./color-selector.component.scss']
})
export class ColorSelectorComponent implements OnInit {
  /** Color seleccionado */
  selection: ColorSelected;

  constructor() {
    // Se inicializa el color sin valor
    this.selection = null;
  }

  ngOnInit(): void { }
  
  /**
   * Limpia el valor de la selección del componente
   */
  clearSelection(): void {
    this.selection = null;
  }

  /**
   * Selecciona un color
   * @param color Color sobre el que se ha hecho click
   */
  colorSelected(color: ColorSelected): void {
    // Si se ha hecho click sobre el color que ya estaba seleccionado
    if (color === this.selection) {
      this.clearSelection();  // Deselección
    }
    // Si se ha hecho click en un color no seleccionado
    else {
      this.selection = color;  // Selección
    }
  }

}

En al archivo color-selector.component.html tendremos 3 divs, cada cual con un ID para establecer su color correspondiente por CSS. Dinámicamente se le asignará una clase al color seleccionado, que cambiará su estilo para que se diferencie bien del resto. Todos los colores ejecutarán la función de selección cuando se produzca un click sobre ellos, pero cada uno le pasará como parámetro el color sobre el que se hizo click.

color-selector.component.html

<div class="container">

  <div id="red" class="color"
    [ngClass]="{'selected': selection === 'red'}"
    (click)="colorSelected('red')">
  </div>

  <div id="green" class="color"
    [ngClass]="{'selected': selection === 'green'}"
    (click)="colorSelected('green')">
  </div>

  <div id="blue" class="color"
    [ngClass]="{'selected': selection === 'blue'}"
    (click)="colorSelected('blue')">
  </div>

</div>

Y por último, el archivo color-selector.component.scss. En él se encontrarán todos los estilos necesarios para el componente.

color-selector.component.scss

:host {
  position: relative;
  display: flex;
  flex-flow: row;
  width: 192px;
  padding: 8px 16px;
  border: 1px solid lightgrey;
  border-radius: 8px;

  .container {
    display: flex;
    flex-flow: row;
    width: 100%;
    height: 100%;
    place-content: space-between;

    .color {
      width: 48px;
      height: 48px;
      border-radius: 100%;
      cursor: pointer;

      &.selected::after {
        content: "2714";
        color: white;
        display: flex;
        flex-flow: row;
        height: 100%;
        place-content: center;
        place-items: center;
        border-radius: 100%;
        background-color: rgba(0,0,0,0.5);
      }
    }

    #red {
      background-color: red;
    }

    #green {
      background-color: green;
    }

    #blue {
      background-color: blue;
    }

  }

}

Si ejecutamos el proyecto con ng serve observaremos que el componente ya tiene el comportamiento que queríamos.

Control Value Accessor: Integración con Angular forms (Básico)

Preparación para el CVA

Vamos a empezar por dejar preparado AppComponent para cuando vayamos a «convertir» nuestro componente en CVA (Control Value Accessor).

app.component.html

<div class="container">

  <app-color-selector [formControl]="colorSelectorControl">
  </app-color-selector>

  <div class="margin-top">
    Color selector value: {{ colorSelectorControl.value }}
  </div>

</div>

app.component.ts

import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  colorSelectorControl: FormControl;

  constructor() {
    this.colorSelectorControl = new FormControl();
  }
}

app.component.scss

.container {
  display: flex;
  flex-flow: column;
  place-content: center;
  place-items: center;
}

.margin-top {
  margin-top: 16px;
}

Si ejecutamos en este punto el proyecto con ng serve -o podremos ver en la consola del navegador un error. Éste nos informa que se está usando una directiva de CVA (en nuestro caso es por [formControl]=»colorSelectorControl») en un componente que no es un CVA, lo cual es normal ya que aún no lo implementamos.

Control Value Accessor: Integración con Angular forms (Básico)

Integración como CVA

Lo primero que tenemos que hacer es implementar la interfaz de ControlValueAccessor, y declarar las variables necesarias para su uso.

/** Valida si un valor es de tipo ColorSelected */
const isColor = (x: any): x is ColorSelected => colors.includes(x);

@Component({ ... })
export class ColorSelectorComponent implements OnInit, ControlValueAccessor {
  /** Color seleccionado */
  selection: ColorSelected;

  /** Función para actualizar el valor del CVA */
  onChanged: any;

  /** Funcion para marcar como 'touched' el CVA */
  onTouched: any;

  /** Controla si el componente está habilitado */
  disabled: boolean;

}

En el constructor del componente inyectamos NgControl, y lo marcamos con los decoradores @Self()@Optional(). El primero le indica al sistema de inyección de dependencias de Angular que nuestro componente es el NgControl (CVA). Y el segundo marca como opcional el uso de nuestro componente en formularios. Dentro del constructor inicializamos las variables del componente con un valor por defecto.

constructor(
  @Self() @Optional() private ngControl: NgControl
) {
  // Si el componente se está usando como control de formulario
  if (this.ngControl) {
    this.ngControl.valueAccessor = this;
  }
  // En caso contrario se inicializan las funciones de CVA por defecto
  else {
    this.onChanged = () => null;
    this.onTouched = () => null;
  }

  // Se inicializa el color sin valor
  this.selection = null;

  // Se inicializa el control como habilitado
  this.disabled = false;
}

Ahora tenemos que hacer un pequeño cambio a la función colorSelected, para que además de actualizar el valor interno del componente también actualice el valor externo del CVA.

/**
 * Selecciona un color
 * @param color Color sobre el que se ha hecho click
 */
colorSelected(color: ColorSelected): void {
  // Si se ha hecho click sobre el color que ya estaba seleccionado
  if (color === this.selection) {
    this.clearSelection();  // Deselección
  }
  // Si se ha hecho click en un color no seleccionado
  else {
    this.selection = color;  // Selección
  }

  // Se actualiza el valor del CVA si el componente está habilitado
  if (!this.disabled) {
    this.onChanged(this.selection);
    this.onTouched();
  }
}

Implementamos la función writeValue de la interfaz CVA. A través de la cual recibiremos los cambios en el valor del CVA, y así poder actualizar el valor interno correspondientemente.

/**
 * Recibe un valor desde fuera del componente (a través del CVA)
 * @param color Valor recibido desde fuera del componente
 */
writeValue(color: any): void {
  // Si el valor recibido es un color admitido
  if (isColor(color)) {
    this.selection = color;
  }
  // En caso contrario se limpia la selección
  else if (!color) {
    this.clearSelection();
  }
}

Las siguientes dos funciones a implementar son registerOnChangeregisterOnTouched. Son ejecutadas una única vez, en la que recibimos las funciones para poder actualizar el valor y estado del CVA.

/**
 * Recibe la función para emitir un cambio en el valor del CVA
 * @param fn Función a implementar
 */
registerOnChange(fn: any): void {
  this.onChanged = fn;
}

/**
 * Recibe la función para emitir un cambio en el estado 'touched' del CVA
 * @param fn Función a implementar
 */
registerOnTouched(fn: any): void {
  this.onTouched = fn;
}

Ejecutamos el proyecto con ng serve y comprobamos que el valor del FormControl que tenemos en AppComponent se actualiza correctamente. ¡Nuestro componente ya está integrado como ControlValueAccessor 🤩!

Control Value Accessor: Integración con Angular forms (Básico)

Deshabilitando el CVA

Vamos a implementar la última función de CVA (aunque es opcional), encargada de recibir si el control está habilitado o deshabilitado, setDisabledState

/**
 * Recibe si el CVA está habilitado o no
 * @param isDisabled Estado del CVA
 */
setDisabledState(isDisabled: boolean): void {
  this.disabled = isDisabled;
}

Actualizamos los estilos del componente para que se diferencie cuando está deshabilitado.

color-selector.component.scss

:host {
  ...

  .container {
    ...

    &.disabled::after {
      content: "";
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      border-radius: 6px;
      background-color: lightgrey;
      opacity: 0.8;
    }

    .color {
     ...

      &.selected::after { ... }
    }

    #red { ... }

    #green { ... }

    #blue { ... }

  }

}

Incluimos la nueva clase CSS en el HTML.

color-selector.component.html

<div class="container" [ngClass]="{'disabled': disabled}">

  ...

</div>

Agregamos un botón en AppComponent para poder habilitar y deshabilitar el componente.

app.component.html

<div class="container">

  ...

  <button (click)="toggleDisableState()" class="margin-top">
    {{ colorSelectorControl.disabled ? 'Enable' : 'Disable' }} ColorSelector
  </button>

</div>

app.component.html

@Component({ ... })
export class AppComponent {
  ...

  constructor() { ... }

  /**
   * Habilita o deshabilita el control del ColorSelector
   */
  toggleDisableState(): void {
    // Si el control esta deshabilitado
    if (this.colorSelectorControl.disabled) {
      this.colorSelectorControl.enable();  // Se habilita
    }
    // Si está habilitado
    else {
      this.colorSelectorControl.disable();  // Se deshabilita
    }
  }
}
Control Value Accessor: Integración con Angular forms (Básico)

Stackblitz

A continuación tenéis el proyecto disponible en Stackblitz.