Angular: membuat elemen formulir kustom dan menyampaikan status formulir ke sana

Berbagai bentuk dalam aplikasi web kami sering dibangun dari elemen bata yang sama. Kerangka kerja komponen membantu kita menyingkirkan kode berulang, dan sekarang saya ingin mempertimbangkan salah satu dari pendekatan ini. Jadi, seperti kebiasaan di Angular.

Tugas teknis:


  • Anda perlu membuat komponen "elemen formulir untuk memasukkan SNILS";
  • Komponen harus memformat nilai yang dimasukkan dengan mask;
  • Komponen harus memvalidasi input.
  • komponen harus berfungsi sebagai bagian dari bentuk reaktif;
  • formulir yang tidak lengkap harus mempertahankan kondisinya di antara reboot;
  • saat memuat halaman, setelah formulir yang diedit harus segera menunjukkan kesalahan;
  • digunakan oleh Material Sudut.

Membuat proyek dan menginstal dependensi


Buat proyek uji


ng new input-snils

Instal perpustakaan pihak ketiga


Pertama-tama, Material Sudut itu sendiri

ng add @angular/material

Maka kita perlu menutupi dan memeriksa SNIL sendiri sesuai dengan aturan untuk menghitung checksum .

Instal perpustakaan:

npm install ngx-mask ru-validation-codes

Menyimpan data formulir


Mempersiapkan layanan untuk bekerja dengan Penyimpanan lokal


Formulir data akan disimpan di Penyimpanan lokal.

Anda dapat segera mengambil metode peramban untuk bekerja dengan LS, tetapi di Angular biasanya mencoba untuk menulis kode universal, dan menjaga semua dependensi eksternal terkendali. Ini juga menyederhanakan pengujian.

Oleh karena itu, itu akan benar ketika kelas mendapatkan semua dependensinya dari wadah DI. Mengingat kucing Matroskin - untuk membeli sesuatu yang tidak perlu dari injector, Anda harus terlebih dahulu menjual sesuatu yang tidak perlu ke injector.

Buat penyedia

window.provider.ts

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

export function getWindow() {
  return window;
}

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

Apa yang terjadi di sini? Injector DI Angular mengingat token dan memberikan entitas yang terkait dengannya. Token - itu bisa berupa objek InjectionToken, string atau kelas. Ini menciptakan InjectionToken tingkat root baru dan berkomunikasi dengan pabrik, yang mengembalikan browser window.

Sekarang kita memiliki jendela, mari kita buat layanan sederhana untuk bekerja dengan storage.service.ts LocalStorage


@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 mengambil jendela dari injektor dan menyediakan pembungkusnya sendiri untuk menyimpan dan membaca data. Saya tidak membuat awalan dapat dikonfigurasi agar tidak membebani artikel dengan deskripsi cara membuat modul dengan konfigurasi.

FormPersistModule


Kami membuat layanan sederhana untuk menyimpan data formulir.

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 dapat mendaftarkan formulir dengan kunci string yang diteruskan. Registrasi berarti bahwa formulir data akan disimpan di LS dengan setiap perubahan.
Saat mendaftar, nilai yang diekstraksi dari LS juga dikembalikan sehingga dimungkinkan untuk memahami bahwa formulir telah disimpan sebelumnya.

Batalkan registrasi ( unregisterForm) menghentikan proses simpan dan menghapus entri dalam LS.

Saya ingin menggambarkan fungsi penyimpanan secara deklaratif, dan tidak melakukannya setiap kali dalam kode komponen. Sudut memungkinkan Anda melakukan mukjizat dengan bantuan arahan, dan saat ini yang terjadi.

Buat direktif
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();
    }
  }
}

Ketika ditumpangkan pada formulir, FormPersistDirective menarik keluar arahan lain dari injektor lokal - FormGroupDirective dan mengambil objek formulir reaktif dari sana untuk mendaftar dengan FormPersistService.

Kunci pendaftaran harus diambil dari templat, formulir itu sendiri tidak memiliki pengenal unik yang melekat di dalamnya.

Saat mengirimkan formulir, pendaftaran harus dibatalkan. Untuk melakukan ini, dengarkan acara pengiriman menggunakan HostListener.

Arahan juga perlu dikirim ke komponen di mana ia dapat digunakan. Ini adalah praktik yang baik untuk membuat modul kecil yang terpisah untuk setiap entitas yang digunakan kembali.

form-persist.module.ts

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

Elemen bentuk SNILS


Tugas apa yang ditugaskan padanya?

Pertama-tama, itu harus memvalidasi data.

snilsValidator


Angular memungkinkan Anda untuk melampirkan validator Anda untuk membentuk kontrol, dan inilah saatnya untuk membuat Anda sendiri. Untuk memeriksa SNILS, saya menggunakan pustaka ru-validation-codes eksternal dan validator akan cukup sederhana.

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

Komponen InputSnilsComponent


Templat komponen terdiri dari bidang input terbungkus, versi klasik dari perpustakaan Bahan Angular.

Dengan satu tambahan kecil, masker input akan ditumpangkan pada input menggunakan perpustakaan eksternal ngx-mask, dari sini topeng parameter input - setel topeng dan dropSpecialCharacters - mematikan penghapusan karakter topeng khusus dari nilai.

Lihat dokumentasi ngx-mask untuk lebih jelasnya.

Berikut adalah templat komponen
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>

Pertanyaannya adalah, apa formControl ini | snilsErrors? Ini adalah pipa khusus untuk menampilkan kesalahan, sekarang kita akan membuatnya.

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

Pipa tidak bersih, yang berarti akan dieksekusi setiap kali perubahan terdeteksi.

Pipe menerima parameter tipe kesalahan dan mendeteksi kesalahan tiga tipe:

  • "Diperlukan" - kesalahan ini berasal dari Angular directive RequiredValidator bawaan
  • "Snils" - kesalahan ini berasal dari validator kami snilsValidator
  • "Mask error" - kesalahan ini dari arahan MaskDirective dari perpustakaan ngx-mask

Dan mengembalikan nilai boolean - apakah ada kesalahan atau tidak.

Sekarang, mari kita lihat kode komponen
input-snils.component.ts itu sendiri

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

Ada banyak hal dan saya tidak akan menjelaskan cara bekerja dengan ControlValueAccessor, Anda dapat membaca tentang ini di dokumentasi Angular, atau misalnya di sini tyapk.ru/blog/post/angular-custom-form-field-control

Apa yang perlu penjelasan di sini?

Pertama, kami menggunakan kontrol internal form formControl, kami lampirkan perubahannya untuk mengirim perubahan nilai ke atas, melalui metode onChange dan onTouched.
Dan sebaliknya, perubahan pada formulir eksternal datang kepada kami melalui metode writeValue dan setDisabledState dan tercermin dalam formControl.

Kedua, ada token STATE_VALUE_ACCESSOR yang tidak dikenal, antarmuka StateValueAccessor yang tidak diketahui dan beberapa metode setPristineState dan setTouchedState tambahan. Mereka akan dijelaskan nanti.

Sementara itu, buat modul pribadi untuk komponen
input-snils.module.ts

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

Melewati status ke suatu item


Saat menggunakan ControlValueAccessor, ada nuansa berikut: Bentuk

reaktif telah menyentuh dan kondisi asli (selanjutnya hanya "menyatakan").

  • murni awalnya benar dan berubah menjadi false ketika nilai kontrol diubah dari templat
  • disentuh awalnya salah dan berubah menjadi true ketika kontrol kehilangan fokus

Mereka juga dapat diatur secara paksa, tetapi ini tidak akan mempengaruhi kontrol di dalam ControlValueAccessor, untuk komponen kami itu adalah formControl.

Dan kesalahan mat-error diberikan hanya ketika kontrol saat ini disentuh. Kami memiliki persyaratan bahwa formulir yang dipulihkan segera menampilkan kesalahan validasi, sehingga FormPersistDirective mengeksekusi markAllAsTouched jika nilai formulir telah dibaca dari localStorage. Tetapi mat-error tidak akan ditampilkan, karena mereka berada di dalam komponen ControlValueAccessor kami, mereka bergantung pada kontrol formControl, dan pada kontrol independen ini keadaan yang disentuh masih salah.

Kami membutuhkan mekanisme untuk melempar status ini. Untuk melakukan ini, Anda dapat membuat analog Anda sendiri dari ControlValueAccessor, sebut saja StateValueAccessor.

Pertama, Anda perlu membuat token dan antarmuka.

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

Antarmuka menjelaskan persyaratan untuk kelas yang mengimplementasikannya memiliki (opsional) dua metode yang ditentukan. Metode-metode ini diimplementasikan dalam InputSnilsComponent dan memaksa negara-negara ini pada formControl kontrol internal.

Maka Anda perlu arahan untuk mengikat NgControl dan komponen kami yang mengimplementasikan StateValueAccessor. Tidak mungkin menentukan dengan tepat saat keadaan formulir berubah, tetapi kami tahu bahwa setiap kali formulir berubah, Angular menandai komponen sebagai menunggu siklus deteksi perubahan. Komponen yang diuji dan turunannya menjalankan kait siklus hidup ngDoCheck, yang akan digunakan oleh arahan kami.

FormStatusesDirective


Buat direktif
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 ditumpangkan pada semua kontrol yang mungkin dan memeriksa keberadaan StateValueAccessor. Untuk melakukan ini, ketergantungan opsional pada token STATE_VALUE_ACCESSOR diminta dari injektor, yang harus diperiksa oleh komponen yang mengimplementasikan StateValueAccessor.

Jika tidak ada yang ditemukan oleh token, maka tidak ada yang terjadi, metode setSVATouched dan setSVAPristine hanya akan fungsi kosong.

Jika StateValueAccessor ditemukan, maka metode setTouchedState dan setPristineState akan dipanggil untuk setiap perubahan status yang terdeteksi.

Tetap memberi arahan dengan modul untuk mengekspor
form-statuses.module.ts

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

Halaman Utama


Sekarang Anda perlu membuat formulir itu sendiri. Agar tidak membuat keributan, letakkan di halaman utama AppComponent. Tentu saja, dalam aplikasi nyata, lebih baik membuat komponen terpisah untuk formulir.
Templat
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>

Arahan FormPersistDirective tergantung pada formulir, Angular mempelajarinya melalui pemilih form [formPersist].

Templat harus dilengkapi dengan variabel, mari lakukan
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])
  });
}

Kode komponen dengan formulir keluar sangat sederhana dan tidak mengandung sesuatu yang berlebihan.

Ini terlihat seperti ini:


Kode sumber dapat diambil di GitHub
Demo pada stackblitz
Kode pada stackblitz sedikit berbeda, karena fakta bahwa versi naskah tidak mendukung operator elvis.
Selain itu, masih ada nuansa yang tidak tercermin dalam artikel, jika seseorang membutuhkannya, saya akan menambahkannya.

All Articles