Angulaire: création d'un élément de formulaire personnalisé et passage de l'état du formulaire

Les différentes formes de nos applications Web sont souvent construites à partir des mêmes éléments de brique. Les frameworks de composants nous aident à nous débarrasser du code reproductible, et maintenant je veux considérer l'une de ces approches. La façon dont il est accepté dans Angular.

Tâche technique:


  • vous devez créer le composant "élément de formulaire pour entrer SNILS";
  • Le composant doit formater les valeurs entrées par masque;
  • Le composant doit valider l'entrée.
  • le composant doit fonctionner dans le cadre de la forme réactive;
  • un formulaire incomplet doit conserver son état entre les redémarrages;
  • lors du chargement de la page, une fois que le formulaire édité doit immédiatement montrer des erreurs;
  • utilisé par Angular Material.

Création d'un projet et installation de dépendances


Créer un projet de test


ng new input-snils

Installer des bibliothèques tierces


Tout d'abord, le matériau angulaire lui-même

ng add @angular/material

Ensuite, nous devons masquer et vérifier les SNILS eux-mêmes selon les règles de calcul de la somme de contrôle .

Installez les bibliothèques:

npm install ngx-mask ru-validation-codes

Enregistrement des données du formulaire


Préparation d'un service pour travailler avec localStorage


Les données du formulaire seront stockées dans localStorage.

Vous pouvez immédiatement utiliser les méthodes du navigateur pour travailler avec LS, mais dans Angular, il est habituel d'essayer d'écrire du code universel et de garder toutes les dépendances externes sous contrôle. Il simplifie également les tests.

Par conséquent, il sera correct lorsque la classe obtiendra toutes ses dépendances du conteneur DI. Se souvenir du chat Matroskin - pour acheter quelque chose d'inutile à l'injecteur, vous devez d'abord vendre quelque chose d'inutile à l'injecteur.

Créer un fournisseur

window.provider.ts

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

export function getWindow() {
  return window;
}

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

Que se passe t-il ici? L'injecteur DI angulaire se souvient des jetons et donne les entités qui leur sont associées. Token - il peut s'agir d'un objet InjectionToken, d'une chaîne ou d'une classe. Cela crée un nouveau jeton d'injection au niveau racine et communique avec l'usine, qui renvoie le navigateur window.

Maintenant que nous avons une fenêtre, nous allons créer un service simple pour travailler avec 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 prend une fenêtre d'un injecteur et fournit ses propres enveloppes pour enregistrer et lire les données. Je n'ai pas rendu le préfixe configurable pour ne pas surcharger l'article avec une description de la façon de créer des modules avec une configuration.

FormPersistModule


Nous créons un service simple pour enregistrer les données du formulaire.

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 peut enregistrer des formulaires avec la clé de chaîne transmise. L'enregistrement signifie que les données du formulaire seront enregistrées dans LS à chaque modification.
Lors de l'inscription, la valeur extraite de LS est également renvoyée afin qu'il soit possible de comprendre que le formulaire a déjà été enregistré précédemment.

Unregister ( unregisterForm) met fin au processus d'enregistrement et supprime l'entrée dans LS.

Je voudrais décrire la fonctionnalité de stockage de manière déclarative et ne pas le faire à chaque fois dans le code du composant. Angular vous permet de faire des miracles à l'aide de directives, et en ce moment c'est le cas.

Créez la directive
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();
    }
  }
}

Lorsqu'il est superposé au formulaire, FormPersistDirective extrait une autre directive de l'injecteur local - FormGroupDirective et en prend l'objet de formulaire réactif pour s'enregistrer auprès de FormPersistService.

La clé d'enregistrement doit être extraite du modèle, le formulaire lui-même n'a pas d'identifiant unique inhérent.

Lors de la soumission d'un formulaire, l'inscription doit être annulée. Pour ce faire, écoutez l'événement submit à l'aide de HostListener.

La directive doit également être fournie aux composants où elle peut être utilisée. Il est recommandé de créer des petits modules distincts pour chaque entité réutilisée.

form-persist.module.ts

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

Élément du formulaire SNILS


Quelles tâches lui sont assignées?

Tout d'abord, il doit valider les données.

snilsValidator


Angular vous permet d'attacher vos validateurs à des contrôles de formulaire, et il est temps de créer les vôtres. Pour vérifier les SNILS, j'utilise la bibliothèque externe ru-validation-codes et le validateur sera assez 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' };
}

Composant InputSnilsComponent


Le modèle de composant se compose d'un champ de saisie enveloppé, une version classique de la bibliothèque de matériaux angulaires.

Avec un petit ajout, un masque de saisie sera superposé à l'entrée à l'aide de la bibliothèque externe ngx-mask, à partir de là, le masque de paramètres d'entrée - définit le masque et dropSpecialCharacters - désactive la suppression des caractères de masque spéciaux de la valeur.

Voir la documentation de ngx-mask pour plus de détails.

Voici le modèle de composant
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 question est, quel est ce formControl | snilsErrors? Il s'agit d'un canal personnalisé pour afficher les erreurs, nous allons maintenant le créer.

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

Le tuyau n'est pas propre, ce qui signifie qu'il sera exécuté chaque fois que des changements seront détectés.

Pipe accepte un paramètre de type d'erreur et détecte les erreurs de trois types:

  • «Requis» - cette erreur provient de la directive angulaire RequiredValidator intégrée
  • "Snils" - cette erreur vient de notre validateur snilsValidator
  • "Erreur de masque" - cette erreur de la directive MaskDirective de la bibliothèque ngx-mask

Et il renvoie une valeur booléenne - existe-t-il une telle erreur ou non.

Maintenant, regardons le code du composant
input-snils.component.ts lui-même

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

Il y a beaucoup de choses et je ne décrirai pas comment travailler avec ControlValueAccessor, vous pouvez lire à ce sujet dans la documentation Angular, ou par exemple ici tyapk.ru/blog/post/angular-custom-form-field-control

Quelles ont besoin d'explications ici?

Tout d'abord, nous utilisons le contrôle de formulaire interne formControl, attaché à ses modifications pour envoyer la modification de valeur, via les méthodes onChange et onTouched.
Et vice versa, les modifications apportées au formulaire externe nous parviennent via les méthodes writeValue et setDisabledState et se reflètent dans formControl.

Deuxièmement, il existe un jeton STATE_VALUE_ACCESSOR inconnu, une interface StateValueAccessor inconnue et quelques méthodes supplémentaires setPristineState et setTouchedState. Ils seront expliqués plus loin.

En attendant, créez un module personnel pour le composant
input-snils.module.ts

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

Passer des statuts à un élément


Lorsque vous utilisez ControlValueAccessor, il y a la nuance suivante: La

forme réactive a touché et des états vierges (ci-après simplement «états»).

  • vierge est initialement vrai et devient faux lorsque la valeur de contrôle est modifiée à partir du modèle
  • touché initialement faux et devient vrai lorsque le contrôle a perdu le focus

Ils peuvent également être définis de force, mais cela n'affectera pas le contrôle à l'intérieur de ControlValueAccessor, pour notre composant, c'est formControl.

Et les erreurs d'erreur de tapis ne sont rendues que lorsque le contrôle actuel est touché. Nous avons une exigence que le formulaire restauré affiche immédiatement des erreurs de validation, donc FormPersistDirective exécute markAllAsTouched si la valeur du formulaire a été lue depuis localStorage. Mais les erreurs de mat ne seront pas affichées, car elles sont à l'intérieur de notre composant ControlValueAccessor, elles dépendent du contrôle formControl et de ce contrôle indépendant, l'état touché est toujours faux.

Nous avons besoin d'un mécanisme pour lancer ces États. Pour ce faire, vous pouvez créer votre propre analogue de ControlValueAccessor, appelons-le StateValueAccessor.

Vous devez d'abord créer un jeton et une 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;
}

L'interface décrit les exigences pour que la classe l'implémentant ait (facultativement) deux méthodes spécifiées. Ces méthodes sont implémentées dans InputSnilsComponent et forcent ces états sur le contrôle interne formControl.

Ensuite, vous avez besoin d'une directive pour lier NgControl et notre composant qui implémente StateValueAccessor. Il est impossible de localiser le moment où l’état d’un formulaire change, mais nous savons que chaque fois qu’un formulaire change, Angular marque le composant comme attendant un cycle de détection de changement. Le composant testé et ses descendants exécutent le hook de cycle de vie ngDoCheck, que notre directive utilisera.

FormStatusesDirective


Créez la directive
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 est superposé à tous les contrôles et vérifications possibles pour la présence de StateValueAccessor. Pour ce faire, une dépendance facultative sur le jeton STATE_VALUE_ACCESSOR est demandée à l'injecteur, ce que le composant implémentant StateValueAccessor aurait dû vérifier.

Si rien n'est trouvé par le jeton, alors rien ne se passe, les méthodes setSVATouched et setSVAPristine ne seront que des fonctions vides.

Si StateValueAccessor est trouvé, ses méthodes setTouchedState et setPristineState seront appelées pour chaque changement d'état détecté.

Il reste à fournir à la directive un module pour exporter
form-statuses.module.ts

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

Page d'accueil


Vous devez maintenant créer le formulaire lui-même. Afin de ne pas faire d'histoires, placez-le sur la page principale de l'AppComponent. Bien sûr, dans une application réelle, il est préférable de créer un composant séparé pour le formulaire. App.component.html
modèle


<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 directive FormPersistDirective se bloque sur le formulaire, Angular en apprend grâce au sélecteur form [formPersist].

Le modèle doit être fourni avec des variables, faisons -le
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])
  });
}

Le code du composant avec le formulaire est sorti extrêmement simple et ne contient rien de superflu.

Cela ressemble à ceci:


Le code source peut être pris sur GitHub
La démo sur stackblitz
Le code sur stackblitz est légèrement différent, du fait que la version dactylographiée ne supporte pas encore l'opérateur elvis.
De plus, il y a encore des nuances qui ne se reflètent pas dans l'article, si quelqu'un en a besoin, je le compléterai.

All Articles