Winkel: Erstellen eines benutzerdefinierten Formularelements und Übergeben des Formularstatus an dieses

Die verschiedenen Formen in unseren Webanwendungen bestehen häufig aus denselben Bausteinelementen. Komponenten-Frameworks helfen uns dabei, wiederholbaren Code loszuwerden, und jetzt möchte ich einen dieser Ansätze in Betracht ziehen. Also, wie es in Angular üblich ist.

Technische Aufgabe:


  • Sie müssen die Komponente "Formularelement zur Eingabe von SNILS" erstellen.
  • Die Komponente sollte die eingegebenen Werte nach Maske formatieren.
  • Die Komponente muss die Eingabe validieren.
  • Die Komponente sollte als Teil der reaktiven Form arbeiten.
  • Ein unvollständiges Formular muss seinen Status zwischen den Neustarts beibehalten.
  • Beim Laden der Seite sollte das bearbeitete Formular sofort Fehler anzeigen.
  • wird von Angular Material verwendet.

Erstellen eines Projekts und Installieren von Abhängigkeiten


Erstellen Sie ein Testprojekt


ng new input-snils

Installieren Sie Bibliotheken von Drittanbietern


Zunächst Angular Material selbst

ng add @angular/material

Dann müssen wir die SNILS selbst gemäß den Regeln für die Berechnung der Prüfsumme maskieren und überprüfen .

Installieren Sie die Bibliotheken:

npm install ngx-mask ru-validation-codes

Speichern von Formulardaten


Vorbereiten eines Dienstes für die Arbeit mit localStorage


Formulardaten werden in localStorage gespeichert.

Sie können sofort die Browsermethoden für die Arbeit mit LS verwenden. In Angular ist es jedoch üblich, zu versuchen, universellen Code zu schreiben und alle externen Abhängigkeiten unter Kontrolle zu halten. Es vereinfacht auch das Testen.

Daher ist es korrekt, wenn die Klasse alle ihre Abhängigkeiten vom DI-Container erhält. Erinnern Sie sich an die Katze Matroskin - um etwas Unnötiges vom Injektor zu kaufen, müssen Sie zuerst etwas Unnötiges an den Injektor verkaufen.

Erstellen Sie einen window.provider.ts- Anbieter



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

export function getWindow() {
  return window;
}

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

Was ist denn hier los? Angulars DI-Injektor merkt sich Token und verschenkt mit ihnen verknüpfte Objekte. Token - Dies kann ein InjectionToken-Objekt, eine Zeichenfolge oder eine Klasse sein. Dadurch wird ein neues InjectionToken auf Stammebene erstellt und mit der Factory kommuniziert, die den Browser zurückgibt window.

Nun , da wir ein Fenster haben, lassen Sie uns einen einfachen Dienst schaffen für die Arbeit mit 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 nimmt ein Fenster von einem Injektor und stellt eigene Wrapper zum Speichern und Lesen von Daten bereit. Ich habe das Präfix nicht konfigurierbar gemacht, um den Artikel nicht mit einer Beschreibung zum Erstellen von Modulen mit einer Konfiguration zu überladen.

FormPersistModule


Wir erstellen einen einfachen Service zum Speichern von Formulardaten.

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 kann Formulare mit dem übergebenen Zeichenfolgenschlüssel registrieren. Registrierung bedeutet, dass die Formulardaten bei jeder Änderung in LS gespeichert werden.
Bei der Registrierung wird auch der aus LS extrahierte Wert zurückgegeben, damit verstanden werden kann, dass das Formular bereits früher gespeichert wurde.

Unregister ( unregisterForm) beendet den Speichervorgang und löscht den Eintrag in LS.

Ich möchte die Speicherfunktionalität deklarativ beschreiben und nicht jedes Mal im Komponentencode. Mit Angular können Sie mithilfe von Anweisungen Wunder vollbringen, und genau das ist jetzt der Fall.

Erstellen Sie die Anweisung
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();
    }
  }
}

Bei Überlagerung des Formulars zieht FormPersistDirective eine weitere Anweisung aus dem lokalen Injektor heraus - FormGroupDirective - und verwendet das reaktive Formularobjekt von dort, um sich bei FormPersistService zu registrieren.

Der Registrierungsschlüssel muss aus der Vorlage entnommen werden. Das Formular selbst enthält keine eindeutige Kennung.

Beim Absenden eines Formulars sollte die Registrierung storniert werden. Warten Sie dazu mit HostListener auf das Submit-Ereignis.

Die Richtlinie muss auch an Komponenten geliefert werden, in denen sie verwendet werden kann. Es wird empfohlen, für jede wiederverwendete Entität separate kleine Module zu erstellen.

form-persist.module.ts

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

Element des SNILS-Formulars


Welche Aufgaben sind ihm zugeordnet?

Zunächst müssen die Daten validiert werden.

snilsValidator


Mit Angular können Sie Ihre Validatoren an Formularsteuerelemente anhängen und Ihre eigenen erstellen. Um die SNILS zu überprüfen, verwende ich die externe Bibliothek mit ru-Validierungscodes, und der Validator ist recht einfach.

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

Komponente InputSnilsComponent


Die Komponentenvorlage besteht aus einem umschlossenen Eingabefeld, einer klassischen Version aus der Angular Material-Bibliothek.

Mit einem kleinen Zusatz wird der Eingabe mit der externen Bibliothek ngx-mask eine Eingabemaske überlagert. Hier deaktiviert die Eingabeparameter-Maske - setzt die Maske und dropSpecialCharacters - das Entfernen von speziellen Maskenzeichen aus dem Wert.

Weitere Informationen finden Sie in der Dokumentation zur ngx-Maske .

Hier ist die Komponentenvorlage
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>

Die Frage ist, was ist das formControl | snilsErrors? Dies ist eine benutzerdefinierte Pipe zum Anzeigen von Fehlern. Jetzt werden wir sie erstellen.

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

Das Rohr ist nicht sauber, was bedeutet, dass es jedes Mal ausgeführt wird, wenn Änderungen erkannt werden.

Pipe akzeptiert einen Fehlertypparameter und erkennt Fehler von drei Typen:

  • "Erforderlich" - Dieser Fehler stammt aus der integrierten Angular-Direktive RequiredValidator
  • "Snils" - Dieser Fehler stammt von unserem Validator snilsValidator
  • "Mask error" - Dieser Fehler aus der MaskDirective-Direktive aus der ngx-mask-Bibliothek

Und es gibt einen booleschen Wert zurück - gibt es einen solchen Fehler oder nicht?

Schauen wir uns nun den Komponentencode
input-snils.component.ts selbst an

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

Es gibt viele Dinge und ich werde nicht beschreiben, wie man mit ControlValueAccessor arbeitet. Sie können dies in der Angular-Dokumentation oder zum Beispiel hier lesen. Tyapk.ru/blog/post/angular-custom-form-field-control

Was muss hier erklärt werden?

Zuerst verwenden wir das interne formControl-Formularsteuerelement, hängen es an seine Änderungen an, um die Wertänderung über die Methoden onChange und onTouched nach oben zu senden.
Umgekehrt werden Änderungen am externen Formular über die Methoden writeValue und setDisabledState vorgenommen und in formControl wiedergegeben.

Zweitens gibt es ein unbekanntes STATE_VALUE_ACCESSOR-Token, eine unbekannte StateValueAccessor-Schnittstelle und einige zusätzliche setPristineState- und setTouchedState-Methoden. Sie werden später erklärt.

Erstellen Sie in der Zwischenzeit ein persönliches Modul für die Komponente
input-snils.module.ts

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

Status an einen Artikel übergeben


Bei Verwendung von ControlValueAccessor gibt es die folgende Nuance: Die

reaktive Form hat berührte und makellose Zustände (im Folgenden einfach „Zustände“).

  • makellos ist anfangs wahr und ändert sich in falsch, wenn der Steuerwert aus der Vorlage geändert wird
  • anfänglich falsch berührt und wechselt zu wahr, wenn die Steuerung den Fokus verliert

Sie können auch zwangsweise festgelegt werden, dies hat jedoch keine Auswirkungen auf das Steuerelement im ControlValueAccessor. Bei unserer Komponente handelt es sich um formControl.

Und Mattenfehlerfehler werden nur gerendert, wenn die aktuelle Steuerung berührt wird. Wir haben die Anforderung, dass das wiederhergestellte Formular sofort Validierungsfehler anzeigt, sodass FormPersistDirective markAllAsTouched ausführt, wenn der Formularwert aus localStorage gelesen wurde. Mattenfehler werden jedoch nicht angezeigt, da sie sich in unserer ControlValueAccessor-Komponente befinden. Sie hängen vom formControl-Steuerelement ab, und von diesem unabhängigen Steuerelement ist der berührte Status immer noch falsch.

Wir brauchen einen Mechanismus, um diese Zustände zu werfen. Dazu können Sie Ihr eigenes Analogon zu ControlValueAccessor erstellen. Nennen wir es StateValueAccessor.

Zuerst müssen Sie ein Token und eine Schnittstelle erstellen.

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

Die Schnittstelle beschreibt die Anforderungen an die Klasse, die sie implementiert, um (optional) zwei angegebene Methoden zu haben. Diese Methoden sind in InputSnilsComponent implementiert und erzwingen diese Zustände der internen Steuerung formControl.

Dann benötigen Sie eine Direktive, um NgControl und unsere Komponente zu binden, die StateValueAccessor implementiert. Es ist unmöglich, den Moment zu bestimmen, in dem sich der Status eines Formulars ändert. Wir wissen jedoch, dass Angular die Komponente bei jeder Änderung eines Formulars als auf einen Änderungserkennungszyklus wartend markiert. Die getestete Komponente und ihre Nachkommen führen den ngDoCheck-Lebenszyklus-Hook aus, den unsere Direktive verwenden wird.

FormStatusesDirective


Erstellen Sie die Direktive
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 überlagert alle möglichen Steuerelemente und prüft, ob StateValueAccessor vorhanden ist. Zu diesem Zweck wird vom Injektor eine optionale Abhängigkeit vom Token STATE_VALUE_ACCESSOR angefordert, die die Komponente, die StateValueAccessor implementiert, hätte überprüfen müssen.

Wenn das Token nichts findet, passiert nichts. Die Methoden setSVATouched und setSVAPristine sind nur leere Funktionen.

Wenn StateValueAccessor gefunden wird, werden die Methoden setTouchedState und setPristineState für jede erkannte Statusänderung aufgerufen.

Es bleibt, der Direktive ein Modul zum Exportieren von form-statuses.module.ts zur Verfügung zu stellen


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

Hauptseite


Jetzt müssen Sie das Formular selbst erstellen. Platzieren Sie es auf der Hauptseite der AppComponent, um keine Aufregung zu verursachen. In einer realen Anwendung ist es natürlich besser, eine separate Komponente für das Formular zu erstellen. App.component.html
Vorlage


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

Die Direktive FormPersistDirective hängt am Formular, Angular erfährt davon über die Formularauswahl [formPersist].

Die Vorlage muss mit Variablen versehen sein, machen wir es
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])
  });
}

Der Komponentencode mit dem Formular war äußerst einfach und enthält nichts Überflüssiges.

Es sieht so aus:


Der Quellcode kann auf GitHub übernommen werden.
Die Demo auf stackblitz
Der Code auf stackblitz unterscheidet sich geringfügig, da die dortige Typoskript-Version den elvis-Operator noch nicht unterstützt.
Darüber hinaus gibt es immer noch Nuancen, die sich nicht im Artikel widerspiegeln. Wenn jemand sie benötigt, werde ich sie ergänzen.

All Articles