Control Value Accessor: Integración con Angular forms (Avanzado)

por | Oct 18, 2020 | Angular | 0 Comentarios

Control Value Accessor Advanced

Control Value Accessor

Siguiendo el tutorial anterior, conseguimos desarrollar un componente desde cero e implementarlo con Angular Forms. ¿Y por qué otro, nos dejamos algo? No del todo, pero si hay una característica que lo más seguro es que la necesitemos, y es la posibilidad de marcar como requerido el componente dentro de un formulario.

App Component

Actualizamos los archivos de AppComponent para incluir un botón que marque y desmarque el componente como requerido. Y de la misma forma añadimos las funciones correspondientes en el código.

app.component.html

<app-color-selector [formControl]="colorSelectorControl"
  style="margin-left: 64px; margin-top: 32px;">
</app-color-selector>

<div style="margin-left: 64px; margin-top: 32px;">
  Color selector value: {{ colorSelectorControl.value }}
</div>

<div style="margin-left: 64px; margin-top: 32px;">
  Color selector status: {{ colorSelectorControl.status }}
</div>

<button (click)="toggleDisableState()" style="display: block; margin-left: 64px; margin-top: 16px;">
  {{ colorSelectorControl.disabled ? 'Enable' : 'Disable' }} color selector
</button>

<button (click)="markAsRequired()" style="display: block; margin-left: 64px; margin-top: 16px;">
  Mark as required control
</button>

<button (click)="markAsNotRequired()" style="display: block; margin-left: 64px; margin-top: 16px;">
  Mark as not required control
</button>

app.component.ts

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  /** FormControl que se bindea al componente de selección de color */
  colorSelectorControl: FormControl;

  constructor() {
    // Se inicializa el FormControl
    this.colorSelectorControl = new FormControl();
  }

  /**
   * Habilita o deshabilita el control del ColorSelector
   */
  toggleDisableState(): void {
    if (this.colorSelectorControl.disabled) {
      this.colorSelectorControl.enable();
    }
    else {
      this.colorSelectorControl.disable();
    }
  }

  /**
   * Marca como requerido el componente de selección de color
   */
  markAsRequired(): void {
    this.colorSelectorControl.setValidators(Validators.required);
    this.colorSelectorControl.updateValueAndValidity();
  }

  /**
   * Marca como NO requerido el componente de selección de color
   */
  markAsNotRequired(): void {
    this.colorSelectorControl.clearValidators();
    this.colorSelectorControl.updateValueAndValidity();
  }
}

Color Selector Component

Ahora le toca a los ficheros de nuestro componente. Vamos a hacer unos cambios en el constructor y en el ngOnInit con respecto al anterior tutorial. En esta ocasión primero se inicializarán las variables del componente, y después se actualizará el estado del componente. Además, tendremos un listener escuchando los cambios de estado del FormControl externo para poder actualizar el estado interno.

constructor(
  @Self() private ngControl: NgControl
) {
  this.ngControl.valueAccessor = this;

  // Se inicializan los valores del componente por defecto
  this.selection = null;
  this.disabled = false;
  this.required = false;

  this.subscriptions = new Subscription();
}

ngOnInit(): void {
  // Si el componente se está usando como control de un formulario
  if (this.ngControl) {
    this.control = this.ngControl.control as FormControl;
  }
  // En caso contrario se inicializa el control del componente por defecto
  else {
    this.control = new FormControl();
  }

  // Actualizamos el estado del componente
  this.updateState();

  // Listener para escuchar los cambios del FormControl externo
  this.subscriptions.add(
    this.control.statusChanges.subscribe(() => {
      this.updateState();
    })
  );
}

ngOnDestroy(): void {
  // Eliminamos las suscripciones de los listeners guardados
  this.subscriptions.unsubscribe();
}

Vamos a crear un validador personalizado para que nuestro componente gestione de manera automática el valor que recibe del FormControl externo.

/* Validador que controla el valor recibido por nuestro control */
const isValidColor = (control: AbstractControl) => {
  if (control.value && !isColor(control.value)) {
    return { validColor: false };
  }

  return null;
};

Además de las funciones que ya tenía nuestro componente si partimos del tutorial anterior, añadiremos tres más. Una será la encargada de comprobar si nuestro componente ha sido marcado como requerido.

/**
 * Comprueba si el componente está marcado como requerido
 * @returns Si el componente es requerido
 */
isRequired(): boolean {
  if (this.control?.validator) {
    const validator = this.control.validator({} as AbstractControl);
    if (validator?.required) {
      return true;
    }
  }

  return false;
}

Otra mantendrá actualizados los validadores de nuestro componente, ya sea el propio o los externos que reciba.

/**
 * Se actualizan los validadores del componente
 */
updateValidators(): void {
  const validators = this.control.validator ? [this.control.validator, isValidColor] : isValidColor;
  this.control.setValidators(validators);
  this.control.updateValueAndValidity({ emitEvent: false });
}

Y por último, la función que se encargará de mantener actualizado el estado del componente.

/**
 * Actualiza el estado del componente
 */
updateState(): void {
  this.updateValidators();
  this.required = this.isRequired();
}

El archivo entero quedaría así.

color-selector.component.ts

import {
  Component,
  OnInit,
  Self,
  OnDestroy,
} from '@angular/core';
import {
  ControlValueAccessor,
  NgControl,
  AbstractControl,
  FormControl,
} from '@angular/forms';
import { Subscription } from 'rxjs';

/** 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];

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

/* Validador que controla el valor recibido por nuestro control */
const isValidColor = (control: AbstractControl) => {
  if (control.value && !isColor(control.value)) {
    return { validColor: false };
  }

  return null;
};

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

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

  /* Controla si el componente es requerido */
  required: boolean;

  /* Suscripciones que se ejecutan en nuestro componente */
  subscriptions: Subscription;

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

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

  /** FormControl para controlarse a sí mismo */
  control: FormControl;

  constructor(
    @Self() private ngControl: NgControl
  ) {
    this.ngControl.valueAccessor = this;

    // Se inicializan los valores del componente por defecto
    this.selection = null;
    this.disabled = false;
    this.required = false;

    this.subscriptions = new Subscription();
  }

  ngOnInit(): void {
    // Si el componente se está usando como control de un formulario
    if (this.ngControl) {
      this.control = this.ngControl.control as FormControl;
    }
    // En caso contrario se inicializa el control del componente por defecto
    else {
      this.control = new FormControl();
    }

    // Actualizamos el estado del componente
    this.updateState();

    // Listener para escuchar los cambios del FormControl externo
    this.subscriptions.add(
      this.control.statusChanges.subscribe(() => {
        this.updateState();
      })
    );
  }

  ngOnDestroy(): void {
    // Eliminamos las suscripciones de los listeners guardados
    this.subscriptions.unsubscribe();
  }

  /**
   * Selecciona un color
   * @param color Color sobre el que se ha hecho click
   */
  colorSelected(color: ColorSelected): void {
    if (color === this.selection) {
      this.clearSelection();
    }
    else {
      this.selection = color;
    }

    if (!this.disabled) {
      this.onChanged(this.selection);
      this.onTouched();
    }
  }

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

  /**
   * Recibe un valor desde fuera del componente (a través del CVA)
   * @param color Valor recibido desde fuera del componente
   */
  writeValue(color: any): void {
    if (isColor(color)) {
      this.selection = color;
    }
    else if (!color) {
      this.clearSelection();
    }
  }

  /**
   * 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;
  }

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

  /**
   * Se actualizan los validadores del componente
   */
  updateValidators(): void {
    const validators = this.control.validator ? [this.control.validator, isValidColor] : isValidColor;
    this.control.setValidators(validators);
    this.control.updateValueAndValidity({ emitEvent: false });
  }

  /**
   * Comprueba si el componente está marcado como requerido
   * @returns Si el componente es requerido
   */
  isRequired(): boolean {
    if (this.control?.validator) {
      const validator = this.control.validator({} as AbstractControl);
      if (validator?.required) {
        return true;
      }
    }

    return false;
  }

  /**
   * Actualiza el estado del componente
   */
  updateState(): void {
    this.updateValidators();
    this.required = this.isRequired();
  }

}

Incluimos en el HTML un asterisco que indique cuando nuestro componente está siendo requerido.

color-selector.component.html

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

  <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 *ngIf="required" class="required">*</div>
</div>

Finalmente, añadimos un par de clases al CSS para pintar de color rojo el borde del componente y el asterisco cuando el estado del FormControl sea inválido.

color-selector.component.scss

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

  &.ng-invalid {
    border: 1px solid red;

    .required {
      color: red;
    }
  }

  .required {
    position: absolute;
    top: 0;
    right: 0;
    top: 2px;
    right: 4px;
    color: darkgrey;
  }
}

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

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

  .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;
  }

}

Resultado

Llegados a este punto, si compilamos el proyecto con ng serve -o se nos abrirá en el navegador y veremos el resultado.

Control Value Accessor: Integración con Angular forms (Avanzado)

Stackblitz

A continuación tenéis el proyecto en Stackblitz para poder toquetear y hacer pruebas en vivo con él 😉