Angular: crear un elemento de formulario personalizado y pasarle el estado del formulario

Las diversas formas en nuestras aplicaciones web a menudo se crean a partir de los mismos elementos de ladrillo. Los marcos de componentes nos ayudan a deshacernos del código repetible, y ahora quiero considerar uno de estos enfoques. Entonces, como es habitual en Angular.

Tarea técnica:


  • necesita crear el componente "elemento de formulario para ingresar SNILS";
  • El componente debe formatear los valores ingresados ​​por máscara;
  • El componente debe validar la entrada.
  • el componente debería funcionar como parte de la forma reactiva;
  • una forma incompleta debe mantener su estado entre reinicios;
  • al cargar la página, una vez que el formulario editado muestre inmediatamente los errores;
  • utilizado por material angular.

Crear un proyecto e instalar dependencias


Crea un proyecto de prueba


ng new input-snils

Instalar bibliotecas de terceros


En primer lugar, el material angular en sí mismo

ng add @angular/material

Luego necesitamos enmascarar y verificar los SNILS de acuerdo con las reglas para calcular la suma de control .

Instala las bibliotecas:

npm install ngx-mask ru-validation-codes

Guardar datos del formulario


Preparación de un servicio para trabajar con localStorage


Los datos del formulario se almacenarán en localStorage.

Puede tomar inmediatamente los métodos del navegador para trabajar con LS, pero en Angular es costumbre intentar escribir código universal y mantener todas las dependencias externas bajo control. También simplifica las pruebas.

Por lo tanto, será correcto cuando la clase obtenga todas sus dependencias del contenedor DI. Recordando al gato Matroskin: para comprar algo innecesario del inyector, primero debe vender algo innecesario al inyector.

Crear un proveedor

window.provider.ts

import { InjectionToken } from '@angular/core';

export function getWindow() {
  return window;
}

export const WINDOW = new InjectionToken('Window', {
  providedIn: 'root',
  factory: getWindow,
});

¿Que está pasando aqui? El inyector DI de Angular recuerda los tokens y regala entidades asociadas con ellos. Token: puede ser un objeto InjectionToken, una cadena o una clase. Esto crea un nuevo InjectionToken de nivel raíz y se comunica con la fábrica, que devuelve el navegador window.

Ahora que tenemos una ventana, vamos a crear un servicio sencillo para trabajar con localStorage
storage.service.ts

@Injectable({
  providedIn: 'root'
})
export class StorageService {
  readonly prefix = 'snils-input__';

  constructor(
    @Inject(WINDOW) private window: Window,
  ) {}

  public set<T>(key: string, data: T): void {
    this.window.localStorage.setItem(this.prefix + key, JSON.stringify(data));
  }

  public get<T>(key: string): T {
    try {
      return JSON.parse(this.window.localStorage.getItem(this.prefix + key));
    } catch (e) { }
  }

  public remove(key: string): void {
    this.window.localStorage.removeItem(this.prefix + key);
  }
}

StorageService toma una ventana de un inyector y proporciona sus propios envoltorios para guardar y leer datos. No configuré el prefijo para no sobrecargar el artículo con una descripción de cómo crear módulos con una configuración.

FormPersistModule


Creamos un servicio simple para guardar los datos del formulario.

form-persist.service.ts

@Injectable({
  providedIn: 'root'
})
export class FormPersistService {
  private subscriptions: Record<string, Subscription> = {};

  constructor(
    private storageService: StorageService,
  ) { }

  /**
   * @returns restored data if exists
   */
  public registerForm<T>(formName: string, form: AbstractControl): T {
    this.subscriptions[formName]?.unsubscribe();
    this.subscriptions[formName] = this.createFormSubscription(formName, form);

    return this.restoreData(formName, form);
  }

  public unregisterForm(formName: string): void {
    this.storageService.remove(formName);

    this.subscriptions[formName]?.unsubscribe();
    delete this.subscriptions[formName];
  }

  public restoreData<T>(formName: string, form: AbstractControl): T {
    const data = this.storageService.get(formName) as T;
    if (data) {
      form.patchValue(data, { emitEvent: false });
    }

    return data;
  }

  private createFormSubscription(formName: string, form: AbstractControl): Subscription {
    return form.valueChanges.pipe(
      debounceTime(500),
    )
      .subscribe(value => {
        this.storageService.set(formName, value);
      });
  }
}

FormPersistService puede registrar formularios con la clave de cadena pasada. El registro significa que los datos del formulario se guardarán en LS con cada cambio.
Al registrarse, el valor extraído de LS también se devuelve para que sea posible comprender que el formulario ya se ha guardado anteriormente.

Unregister ( unregisterForm) finaliza el proceso de guardado y elimina la entrada en LS.

Me gustaría describir la funcionalidad de almacenamiento de manera declarativa, y no hacerlo todo el tiempo en el código del componente. Angular te permite hacer milagros con la ayuda de directivas, y ahora es el caso.

Cree la directiva
form-persist.directive.ts

@Directive({
  selector: 'form[formPersist]', // tslint:disable-line: directive-selector
})
export class FormPersistDirective implements OnInit {
  @Input() formPersist: string;

  constructor(
    private formPersistService: FormPersistService,
    @Self() private formGroup: FormGroupDirective,
  ) { }

  @HostListener('submit')
  onSubmit() {
    this.formPersistService.unregisterForm(this.formPersist);
  }

  ngOnInit() {
    const savedValue = this.formPersistService.registerForm(this.formPersist, this.formGroup.control);
    if (savedValue) {
      this.formGroup.control.markAllAsTouched();
    }
  }
}

Cuando se superpone en el formulario, FormPersistDirective extrae otra directiva del inyector local: FormGroupDirective y toma el objeto de formulario reactivo desde allí para registrarse con FormPersistService.

La clave de registro debe tomarse de la plantilla, el formulario en sí no tiene ningún identificador único inherente.

Al enviar un formulario, el registro debe cancelarse. Para hacer esto, escuche el evento de envío usando HostListener.

La directiva también debe entregarse a los componentes donde se puede usar. Es una buena práctica crear pequeños módulos separados para cada entidad reutilizada.

form-persist.module.ts

@NgModule({
  declarations: [FormPersistDirective],
  exports: [FormPersistDirective]
})
export class FormPersistModule { }

Elemento del formulario SNILS


¿Qué tareas se le asignan?

En primer lugar, debe validar los datos.

snilsValidator


Angular le permite adjuntar sus validadores para formar controles, y es hora de hacer los suyos. Para verificar los SNILS, utilizo la biblioteca externa de códigos de validación de ru y el validador será bastante simple.

snils.validator.ts

import { checkSnils } from 'ru-validation-codes';

export function snilsValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value === ''  || control.value === null) {
    return null;
  }

  return checkSnils(control.value)
    ? null
    : { snils: 'error' };
}

Componente InputSnilsComponent


La plantilla del componente consta de un campo de entrada envuelto, una versión clásica de la biblioteca de material angular.

Con una pequeña adición, una máscara de entrada se superpondrá en la entrada usando la biblioteca externa ngx-mask, desde aquí la máscara de parámetros de entrada - establece la máscara y dropSpecialCharacters - desactiva la eliminación de caracteres de máscara especiales del valor.

Consulte la documentación de ngx-mask para obtener más detalles.

Aquí está la plantilla de componente
input-snils.component.html

<mat-form-field appearance="outline">
  <input
    matInput
    autocomplete="snils"
    [formControl]="formControl"
    [mask]="mask"
    [dropSpecialCharacters]="false"
    [placeholder]="placeholder"
    [readonly]="readonly"
    [required]="required"
    [tabIndex]="tabIndex"
  >
  <mat-error [hidden]="formControl | snilsErrors: 'required'">  </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'format'">   </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'snils'"> </mat-error>
</mat-form-field>

La pregunta es, ¿cuál es este formulario? Control | snilsErrors? Esta es una tubería personalizada para mostrar errores, ahora la crearemos.

snils-errors.pipe.ts

type ErrorType = 'required' | 'format' | 'snils';

@Pipe({
  name: 'snilsErrors',
  pure: false,
})
export class SnilsErrorsPipe implements PipeTransform {

  transform(control: AbstractControl, errorrType: ErrorType): boolean {
    switch (errorrType) {
      case 'required': return !control.hasError('required');
      case 'format': return !control.hasError('Mask error');
      case 'snils': return control.hasError('Mask error') || !control.hasError('snils');
      default: return false;
    }
  }
}

La tubería no está limpia, lo que significa que se ejecutará cada vez que se detecten cambios.

Pipe acepta un parámetro de tipo de error y detecta errores de tres tipos:

  • “Obligatorio”: este error proviene de la directiva angular RequiredValidator incorporada
  • "Snils" - este error es de nuestro validador snilsValidator
  • "Error de máscara": este error de la directiva MaskDirective de la biblioteca ngx-mask

Y devuelve un valor booleano: ¿existe tal error o no?

Ahora, veamos el código del componente
input-snils.component.ts

@Component({
  selector: 'app-input-snils',
  templateUrl: './input-snils.component.html',
  styleUrls: ['./input-snils.component.css'],
  encapsulation: ViewEncapsulation.None,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true,
    },
    {
      provide: STATE_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
    },
  ]
})
export class InputSnilsComponent implements OnInit, ControlValueAccessor, StateValueAccessor, OnDestroy {
  public mask = '000-000-000 00';
  public formControl = new FormControl('', [snilsValidator]);
  private sub = new Subscription();

  @Input() readonly: boolean;
  @Input() placeholder = '';
  @Input() tabIndex = 0;
  @Input() required: boolean;

  private onChange = (value: any) => { };
  private onTouched = () => { };
  registerOnChange = (fn: (value: any) => {}) => this.onChange = fn;
  registerOnTouched = (fn: () => {}) => this.onTouched = fn;


  ngOnInit() {
    this.sub = this.linkForm();
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  private linkForm(): Subscription {
    return this.formControl.valueChanges.subscribe(value => {
      this.onTouched();
      this.onChange(value);
    });
  }

  writeValue(outsideValue: string): void {
    if (outsideValue) {
      this.onTouched();
    }
    this.formControl.setValue(outsideValue, { emitEvent: false });
  }

  setDisabledState(disabled: boolean) {
    disabled
      ? this.formControl.disable()
      : this.formControl.enable();
  }

  validate(): ValidationErrors | null {
    return this.formControl.errors;
  }

  setPristineState(pristine: boolean) {
    pristine
      ? this.formControl.markAsPristine()
      : this.formControl.markAsDirty();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }

  setTouchedState(touched: boolean) {
    touched
      ? this.formControl.markAsTouched()
      : this.formControl.markAsUntouched();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }
}

Hay muchas cosas y no describiré cómo trabajar con ControlValueAccessor, puede leer sobre esto en la documentación angular, o por ejemplo aquí tyapk.ru/blog/post/angular-custom-form-field-control

¿Qué necesita una explicación aquí?

Primero, usamos el control interno del formulario formControl, lo adjuntamos a sus cambios para enviar el cambio de valor a la parte superior, a través de los métodos onChange y onTouched.
Y viceversa, los cambios en el formulario externo nos llegan a través de los métodos writeValue y setDisabledState y se reflejan en formControl.

En segundo lugar, hay un token STATE_VALUE_ACCESSOR desconocido, una interfaz StateValueAccessor desconocida y un par de métodos setPristineState y setTouchedState adicionales. Se explicarán más adelante.

Mientras tanto, cree un módulo personal para el componente
input-snils.module.ts

@NgModule({
  declarations: [InputSnilsComponent, SnilsErrorsPipe],
  imports: [
    CommonModule,
    MatFormFieldModule,
    MatInputModule,
    NgxMaskModule.forChild(),
    ReactiveFormsModule,
  ],
  exports: [InputSnilsComponent],
})
export class InputSnilsModule { }

Pasando estados a un artículo


Cuando se utiliza ControlValueAccessor, existe el siguiente matiz: La

forma reactiva ha tocado y estados prístinos (en adelante, simplemente "estados").

  • prístino es inicialmente verdadero y cambia a falso cuando el valor de control se cambia desde la plantilla
  • tocado inicialmente falso y cambia a verdadero cuando el control pierde el foco

También se pueden configurar a la fuerza, pero esto no afectará el control dentro del ControlValueAccessor, para nuestro componente es formControl.

Y los errores de mat-error se representan solo cuando se toca el control actual. Tenemos el requisito de que el formulario restaurado muestre inmediatamente los errores de validación, por lo que FormPersistDirective ejecuta markAllAsTouched si el valor del formulario se ha leído desde localStorage. Pero los errores mat no se mostrarán, ya que están dentro de nuestro componente ControlValueAccessor, dependen del control formControl, y de este control independiente el estado tocado sigue siendo falso.

Necesitamos un mecanismo para lanzar estos estados. Para hacer esto, puede hacer su propio análogo de ControlValueAccessor, llamémoslo StateValueAccessor.

Primero necesitas crear un token y una interfaz.

state-value-accessor.token.ts

export const STATE_VALUE_ACCESSOR = new InjectionToken<StateValueAccessor>('STATE_VALUE_ACCESSOR');


state-value-accessor.interface.ts
export interface StateValueAccessor {
  setTouchedState?(touched: boolean): void;
  setPristineState?(pristine: boolean): void;
}

La interfaz describe los requisitos para que la clase que lo implementa tenga (opcionalmente) dos métodos específicos. Estos métodos se implementan en InputSnilsComponent y fuerzan estos estados en el control interno formControl.

Entonces necesita una directiva para vincular NgControl y nuestro componente que implementa StateValueAccessor. Es imposible determinar el momento en que cambia el estado de un formulario, pero sabemos que cada vez que un formulario cambia, Angular marca el componente como esperando un ciclo de detección de cambio. El componente probado y sus descendientes ejecutan el gancho de ciclo de vida ngDoCheck, que utilizará nuestra directiva.

FormStatusesDirective


Cree la directiva
form-statuses.directive.ts

const noop: (v?: boolean) => void = () => { };

@Directive({
  selector: '[formControlName],[ngModel],[formControl]' // tslint:disable-line: directive-selector
})
export class FormStatusesDirective implements DoCheck, OnInit {
  private setSVATouched = noop;
  private setSVAPristine = noop;

  constructor(
    @Self() private control: NgControl,
    @Self() @Optional()  @Inject(STATE_VALUE_ACCESSOR) private stateValueAccessor: StateValueAccessor,
  ) { }

  ngOnInit() {
    if (this.stateValueAccessor?.setTouchedState) {
      this.setSVATouched = wrapIfChanges(touched => this.stateValueAccessor.setTouchedState(touched));
    }

    if (this.stateValueAccessor?.setPristineState) {
      this.setSVAPristine = wrapIfChanges(pristine => this.stateValueAccessor.setPristineState(pristine));
    }
  }

  ngDoCheck() {
    this.setSVAPristine(this.control.pristine);
    this.setSVATouched(this.control.touched);
  }
}

FormStatusesDirective se superpone a todos los controles posibles y verifica la presencia de StateValueAccessor. Para hacer esto, se solicita una dependencia opcional del token STATE_VALUE_ACCESSOR del inyector, que el componente que implementa StateValueAccessor debería haber verificado.

Si el token no encuentra nada, entonces no sucede nada, los métodos setSVATouched y setSVAPristine serán solo funciones vacías.

Si se encuentra StateValueAccessor, se llamará a sus métodos setTouchedState y setPristineState para cada cambio de estado detectado.

Queda por proporcionar a la directiva un módulo para exportar
form-statuses.module.ts

@NgModule({
  declarations: [FormStatusesDirective],
  exports: [FormStatusesDirective]
})
export class FormStatusesModule { }

Pagina principal


Ahora necesita crear el formulario en sí. Para no hacer un escándalo, colóquelo en la página principal de AppComponent. Por supuesto, en una aplicación real, es mejor crear un componente separado para el formulario.
Plantilla
App.component.html

<section class="form-wrapper">
  <form
    class="form"
    [formGroup]="form"
    formPersist="inputSnils"
  >
    <app-input-snils
      class="input-snils"
      formControlName="snils"
      [required]="true"
    ></app-input-snils>

    <button
      class="ready-button"
      mat-raised-button
      [disabled]="form.invalid"
      type="submit"
    >
      Submit
    </button>
  </form>
</section>

La directiva FormPersistDirective se cuelga en el formulario, Angular lo aprende a través del selector de formulario [formPersist].

La plantilla debe estar provista de variables,
hagámosla app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  public form = new FormGroup({
    snils: new FormControl('', [Validators.required])
  });
}

El código de componente con el formulario salió extremadamente simple y no contiene nada superfluo.

Se ve así:


El código fuente se puede tomar en GitHub.
La demostración en stackblitz
El código en stackblitz es ligeramente diferente, debido a que la versión mecanografiada aún no es compatible con el operador de elvis.
Además, todavía hay matices que no se reflejan en el artículo, si alguien lo necesita, lo complementaré.

All Articles