Angular: criando um elemento de formulário personalizado e passando o estado do formulário para ele

Os vários formulários em nossos aplicativos da Web geralmente são criados com os mesmos elementos de bloco. As estruturas de componentes nos ajudam a nos livrar do código repetível e agora quero considerar uma dessas abordagens. Então, como é habitual em Angular.

Tarefa técnica:


  • você precisa criar o componente "elemento de formulário para inserir SNILS";
  • O componente deve formatar os valores inseridos por máscara;
  • O componente deve validar a entrada.
  • o componente deve funcionar como parte da forma reativa;
  • um formulário incompleto deve manter seu estado entre as reinicializações;
  • ao carregar a página, uma vez que o formulário editado deve mostrar imediatamente erros;
  • usado pelo material angular.

Criando um projeto e instalando dependências


Crie um projeto de teste


ng new input-snils

Instale bibliotecas de terceiros


Primeiro de tudo, o próprio material angular

ng add @angular/material

Então, precisamos mascarar e verificar os próprios SNILS de acordo com as regras para o cálculo da soma de verificação .

Instale as bibliotecas:

npm install ngx-mask ru-validation-codes

Salvando dados do formulário


Preparando um Serviço para Trabalhar com LocalStorage


Os dados do formulário serão armazenados no localStorage.

Você pode usar imediatamente os métodos do navegador para trabalhar com o LS, mas no Angular é comum tentar escrever código universal e manter todas as dependências externas sob controle. Também simplifica o teste.

Portanto, estará correto quando a classe obtiver todas as suas dependências do contêiner de DI. Lembrando o gato Matroskin - para comprar algo desnecessário do injetor, você deve primeiro vender algo desnecessário ao injetor.

Crie um provedor

window.provider.ts

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

export function getWindow() {
  return window;
}

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

O que está acontecendo aqui? O Injetor DI da Angular se lembra de tokens e distribui entidades que estão associadas a eles. Token - pode ser um objeto InjectionToken, uma string ou uma classe. Isso cria um novo InjectionToken no nível da raiz e se comunica com a fábrica, que retorna o navegador window.

Agora que temos uma janela, vamos criar um serviço simples para trabalhar com 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);
  }
}

O StorageService utiliza uma janela de um injetor e fornece seus próprios invólucros para salvar e ler dados. Não tornei o prefixo configurável para não sobrecarregar o artigo com uma descrição de como criar módulos com uma configuração.

FormPersistModule


Criamos um serviço simples para salvar dados de formulário.

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 pode registrar formulários com a chave de cadeia passada. O registro significa que os dados do formulário serão salvos no LS a cada alteração.
Ao se registrar, o valor extraído do LS também é retornado para que seja possível entender que o formulário já foi salvo anteriormente.

Unregister ( unregisterForm) finaliza o processo de salvamento e exclui a entrada no LS.

Gostaria de descrever a funcionalidade de armazenamento declarativamente e não fazê-lo sempre no código do componente. Angular permite fazer milagres com a ajuda de diretivas, e agora é esse o caso.

Crie a diretiva
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();
    }
  }
}

Quando sobreposto ao formulário, o FormPersistDirective extrai outra diretiva do injetor local - FormGroupDirective e pega o objeto de formulário reativo de lá para se registrar no FormPersistService.

A chave de registro deve ser retirada do modelo, o próprio formulário não possui nenhum identificador exclusivo inerente.

Ao enviar um formulário, o registro deve ser cancelado. Para fazer isso, ouça o evento de envio usando o HostListener.

A diretiva também precisa ser entregue aos componentes onde pode ser usada. É uma boa prática criar pequenos módulos separados para cada entidade reutilizada.

form-persist.module.ts

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

Elemento do formulário SNILS


Que tarefas são atribuídas a ele?

Primeiro de tudo, ele deve validar os dados.

snilsValidator


Angular permite que você anexe seus validadores para formar controles, e é hora de criar seus próprios. Para verificar o SNILS, eu uso a biblioteca externa de códigos de validação de ru e o validador será bastante simples.

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

Component InputSnilsComponent


O modelo do componente consiste em um campo de entrada agrupado, uma versão clássica da biblioteca de material angular.

Com uma pequena adição, uma máscara de entrada será sobreposta na entrada usando a biblioteca externa ngx-mask, a partir daqui os parâmetros de entrada mask - define a máscara e dropSpecialCharacters - desativa a remoção de caracteres especiais da máscara do valor.

Consulte a documentação da ngx-mask para obter mais detalhes.Aqui

está o modelo do 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>

A questão é: qual é esse formControl | snilsErrors? Este é um canal personalizado para exibir erros, agora vamos criá-lo.

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

O tubo não está limpo, o que significa que será executado sempre que forem detectadas alterações.

O pipe aceita um parâmetro do tipo de erro e detecta erros de três tipos:

  • “Obrigatório” - esse erro é da diretiva Angular RequiredValidator interno
  • "Snils" - esse erro é do nosso validador snilsValidator
  • "Erro de máscara" - esse erro da diretiva MaskDirective da biblioteca ngx-mask

E ele retorna um valor booleano - existe um erro ou não.

Agora, vejamos o próprio código do 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 });
  }
}

Há muitas coisas e não descreverei como trabalhar com o ControlValueAccessor. Você pode ler sobre isso na documentação do Angular ou, por exemplo, aqui: tyapk.ru/blog/post/angular-custom-form-field-control

O que precisa de explicação aqui?

Primeiro, usamos o controle interno do formulário formControl, anexamos às suas alterações para enviar a alteração de valor, através dos métodos onChange e onTouched.
E vice-versa, as alterações no formulário externo chegam até nós através dos métodos writeValue e setDisabledState e são refletidas no formControl.

Em segundo lugar, há um token STATE_VALUE_ACCESSOR desconhecido, uma interface StateValueAccessor desconhecida e alguns métodos extras setPristineState e setTouchedState. Eles serão explicados mais tarde.

Enquanto isso, crie um módulo pessoal para o componente
input-snils.module.ts

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

Passando status para um item


Ao usar o ControlValueAccessor, há as seguintes nuances: A

forma reativa tocou nos estados primitivos (a seguir, simplesmente "estados").

  • pristine é true inicialmente e muda para false quando o valor de controle é alterado do modelo
  • tocou inicialmente false e muda para true quando o controle perdeu o foco

Eles também podem ser definidos à força, mas isso não afetará o controle dentro do ControlValueAccessor, pois nosso componente é formControl.

E erros de erro de esteira são renderizados somente quando o controle atual é tocado. Temos o requisito de que o formulário restaurado exiba imediatamente erros de validação; portanto, FormPersistDirective executa markAllAsTouched se o valor do formulário tiver sido lido em localStorage. Mas erros mat não serão exibidos, pois estão dentro do nosso componente ControlValueAccessor, eles dependem do controle formControl e, nesse controle independente, o estado tocado ainda é falso.

Precisamos de um mecanismo para lançar esses estados. Para fazer isso, você pode criar seu próprio análogo do ControlValueAccessor, vamos chamá-lo StateValueAccessor.

Primeiro, você precisa criar um token e uma interface.

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

A interface descreve os requisitos para a classe que a implementa ter (opcionalmente) dois métodos especificados. Esses métodos são implementados no InputSnilsComponent e forçam esses estados no controle interno formControl.

Então você precisa de uma diretiva para vincular NgControl e nosso componente que implementa StateValueAccessor. É impossível identificar o momento em que o estado de um formulário é alterado, mas sabemos que sempre que um formulário é alterado, o Angular marca o componente como aguardando um ciclo de detecção de alterações. O componente testado e seus descendentes executam o gancho de ciclo de vida ngDoCheck, que nossa diretiva usará.

FormStatusesDirective


Crie a diretiva
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 é sobreposto a todos os controles e verificações possíveis quanto à presença de StateValueAccessor. Para fazer isso, uma dependência opcional no token STATE_VALUE_ACCESSOR é solicitada ao injetor, que o componente que implementa StateValueAccessor deve ter verificado.

Se nada for encontrado pelo token, nada acontecerá, os métodos setSVATouched e setSVAPristine serão apenas funções vazias.

Se StateValueAccessor for encontrado, seus métodos setTouchedState e setPristineState serão chamados para cada alteração de estado detectada.

Resta fornecer à diretiva um módulo para exportar
form-statuses.module.ts

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

Página principal


Agora você precisa criar o próprio formulário. Para não fazer barulho, coloque-o na página principal do AppComponent. Obviamente, em um aplicativo real, é melhor criar um componente separado para o formulário. App.component.html
modelo


<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>

A diretiva FormPersistDirective trava no formulário, Angular descobre através do seletor de formulário [formPersist].

O modelo precisa ser fornecido com variáveis, vamos fazê-lo
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])
  });
}

O código do componente com o formulário saiu extremamente simples e não contém nada de supérfluo.

É assim:


O código-fonte pode ser obtido no GitHub
A demonstração no stackblitz
O código no stackblitz é um pouco diferente, devido ao fato de a versão datilografada ainda não suportar o operador elvis.
Além disso, ainda existem nuances que não estão refletidas no artigo, se alguém precisar, eu o complementarei.

All Articles