جاوااسکریپت - Angular4 - هیچ دسترسیدهنده مقداری برای کنترل فرم وجود ندارد


من یک عنصر سفارشی دارم :

<div formControlName="surveyType">
  <div *ngFor="let type of surveyTypes"
       (click)="onSelectType(type)"
       [class.selected]="type === selectedType">
    <md-icon>{{ type.icon }}</md-icon>
    <span>{{ type.description }}</span>
  </div>
</div>

وقتی میخواهم formControlName را اضافه کنم، پیام خطا دریافت میکنم:

خطا خطا: هیچ دسترسیدهندهای برای کنترل فرم با نام: 'surveyType' وجود ندارد

من سعی کردم ngDefaultControl را اضافه کنم اما موفق نشدم. به نظر میرسد دلیلش این است که هیچ input یا select وجود ندارد... و نمیدانم چه کار کنم.

میخواهم کلیک خود را به این formControl متصل کنم تا وقتی کسی روی کل کارت کلیک کرد، 'type' من را به formControl ارسال کند. آیا این ممکن است؟

پاسخ شماره ۱

شما میتوانید از formControlName تنها روی دستوراتی که ControlValueAccessor را پیادهسازی میکنند، استفاده کنید.

رابط را پیادهسازی کنید

بنابراین، برای انجام کاری که میخواهید، باید یک کامپوننت ایجاد کنید که ControlValueAccessor را پیادهسازی کند، که به معنی پیادهسازی سه تابع زیر است:

  • writeValue (به Angular میگوید چگونه مقدار را از مدل به نمایه بنویسد)
  • registerOnChange (یک تابع کنترلکننده ثبت میکند که هنگام تغییر نما فراخوانی میشود)
  • registerOnTouched (یک هندلر را ثبت میکند تا زمانی که مؤلفه یک رویداد لمس دریافت کرد فراخوانی شود، مفید برای دانستن اینکه آیا مؤلفه فوکوس شده است).

ثبت یک ارائهدهنده

سپس، شما باید به Angular بگویید که این دایرکتیو یک ControlValueAccessor است (رابط کافی نخواهد بود زیرا هنگام تبدیل TypeScript به JavaScript از کد حذف میشود). این کار را با ثبت یک ارائهدهنده انجام میدهید.

تأمینکننده باید NG_VALUE_ACCESSOR را ارائه دهد و از یک مقدار موجود استفاده کند. همچنین در اینجا به forwardRef نیاز خواهید داشت. توجه داشته باشید که NG_VALUE_ACCESSOR باید یک تأمینکننده چندتایی باشد.

به عنوان مثال، اگر دیریکتیو سفارشی شما به نام MyControlComponent باشد، باید چیزی مشابه موارد زیر را داخل شیئی که به دکوریتور @Component ارسال میشود، اضافه کنید:

providers: [
  { 
    provide: NG_VALUE_ACCESSOR,
    multi: true,
    useExisting: forwardRef(() => MyControlComponent),
  }
]

استفاده

کامپوننت شما آماده استفاده است. با فرمهای مبتنی بر قالب، اتصال ngModel حالا به درستی کار خواهد کرد.

با فرمهای واکنشی، اکنون میتوانید بهدرستی از formControlName استفاده کنید و کنترل فرم همانطور که انتظار میرود رفتار خواهد کرد.

منابع

  • کنترلهای فرم سفارشی در انگولار توسط Thoughtram
  • کنترلهای فرم سفارشی Angular با فرمهای واکنشی و NgModel توسط کوری رایلان

پاسخ شماره ۲

شما باید از formControlName="surveyType" روی یک input استفاده کنید و نه روی یک div

پاسخ شماره ۳

این خطا به این معنی است که انگولار نمیداند وقتی یک formControl روی یک div قرار میدهید چه کاری انجام دهد. برای رفع این مشکل، دو گزینه دارید.

  1. شما formControlName را روی یک عنصر قرار میدهید که به طور پیشفرض توسط Angular پشتیبانی میشود. این عناصر عبارتند از: input، textarea و select.
  2. شما رابط ControlValueAccessor را پیادهسازی میکنید. با انجام این کار، به انگولار میگویید «چگونه به مقدار کنترل شما دسترسی پیدا کند» (از این رو نام آن). یا به بیان ساده: وقتی یک formControlName روی یک عنصر قرار میدهید که به طور طبیعی مقدار مرتبطی ندارد، چه کاری باید انجام شود.

اکنون، پیادهسازی رابط ControlValueAccessor در ابتدا میتواند کمی دلهرهآور باشد. بهویژه به این دلیل که مستندات خوبی در این زمینه وجود ندارد و شما نیاز دارید که مقدار زیادی کد تکراری به برنامه خود اضافه کنید. بنابراین اجازه دهید سعی کنم این را در چند مرحله ساده که به راحتی قابل دنبال کردن باشد توضیح دهم.

کنترل فرم خود را به یک کامپوننت جداگانه منتقل کنید

برای پیادهسازی ControlValueAccessor، شما نیاز دارید یک کامپوننت جدید (یا دایرکتیو) ایجاد کنید. کد مربوط به کنترل فرم خود را به آنجا منتقل کنید. به این صورت، استفاده مجدد از آن نیز آسان خواهد بود. قرار داشتن یک کنترل بهطور پیشفرض در یک کامپوننت ممکن است علت اصلی نیاز شما به پیادهسازی رابط ControlValueAccessor باشد، زیرا در غیر این صورت قادر نخواهید بود از کامپوننت سفارشی خود همراه با فرمهای Angular استفاده کنید.

کد پیشفرض را به کد خود اضافه کنید

پیادهسازی رابط ControlValueAccessor نسبتاً پرجزئیات است، در اینجا کد پایهای که همراه آن میآید آمده است:

import {Component, OnInit, forwardRef} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';


@Component({
  selector: 'app-custom-input',
  templateUrl: './custom-input.component.html',
  styleUrls: ['./custom-input.component.scss'],

  // a) copy paste this providers property (adjust the component name in the forward ref)
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true
    }
  ]
})
// b) Add "implements ControlValueAccessor"
export class CustomInputComponent implements ControlValueAccessor {

  // c) copy paste this code
  onChange: any = () => {}
  onTouch: any = () => {}
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }

  // d) copy paste this code
  writeValue(input: string) {
    // TODO
  }

پس هر بخش جداگانه چه کاری انجام میدهد؟

  • الف) به Angular در زمان اجرا اطلاع دهید که شما رابط ControlValueAccessor را پیادهسازی کردهاید
  • b) اطمینان حاصل میکند که شما رابط ControlValueAccessor را پیادهسازی میکنید
  • ج) احتمالاً این قسمت گیجکنندهترین بخش است. در اصل کاری که انجام میدهید این است که به Angular این امکان را میدهید که ویژگیها/متدهای کلاس شما onChange و onTouch را با پیادهسازی خودش در زمان اجرا جایگزین کند، به طوری که بعداً بتوانید آن توابع را فراخوانی کنید. بنابراین این نکته مهم است که بفهمید: شما نیازی به پیادهسازی onChange و onTouch خودتان ندارید (غیر از پیادهسازی اولیه خالی). تنها کاری که با (ج) انجام میدهید این است که اجازه میدهید Angular توابع خودش را به کلاس شما متصل کند. چرا؟ تا بتوانید بعداً متدهای onChange و onTouch ارائه شده توسط Angular را در زمان مناسب فراخوانی کنید. در ادامه خواهیم دید که این چگونه کار میکند.
  • د) همچنین میبینیم که روش writeValue در بخش بعدی چگونه کار میکند، وقتی که آن را پیادهسازی میکنیم. آن را اینجا قرار دادهام تا تمام ویژگیهای مورد نیاز در ControlValueAccessor پیادهسازی شوند و کد شما همچنان کامپایل شود.

پیادهسازی writeValue

کاری که writeValue انجام میدهد، این است که کاری را در داخل کامپوننت سفارشی شما انجام دهد، زمانی که کنترل فرم از خارج تغییر میکند. بنابراین به عنوان مثال، اگر کامپوننت کنترل فرم سفارشی خود را app-custom-input نامگذاری کرده باشید و بخواهید از آن در کامپوننت والد به این شکل استفاده کنید:

<form [formGroup]="form">
  <app-custom-input formControlName="myFormControl"></app-custom-input>
</form>

سپس writeValue هر زمان که کامپوننت والد به نحوی مقدار myFormControl را تغییر دهد فعال میشود. این میتواند برای مثال در هنگام مقداردهی اولیه فرم (this.form=this.formBuilder.group({myFormControl: ""});) یا در زمان بازنشانی فرم this.form.reset(); باشد.

آنچه معمولاً میخواهید انجام دهید این است که اگر مقدار کنترل فرم از بیرون تغییر کرد، آن را در یک متغیر محلی که نمایانگر مقدار کنترل فرم است بنویسید. به عنوان مثال، اگر CustomInputComponent شما حول یک کنترل فرم مبتنی بر متن باشد، میتواند به این شکل باشد:

writeValue(input: string) {
  this.input = input;
}

و در HTML مربوط به CustomInputComponent:

<input type="text"
       [ngModel]="input">

شما همچنین میتوانید آن را مستقیماً به عنصر ورودی همانطور که در مستندات انگولار توضیح داده شده است، بنویسید.

حالا شما مدیریت کردهاید که چه اتفاقی در داخل کامپوننتتان میافتد وقتی چیزی در بیرون تغییر میکند. حالا بیایید به جهت دیگر نگاه کنیم. چگونه دنیای بیرون را مطلع میکنید وقتی چیزی در داخل کامپوننتتان تغییر میکند؟

فراخوانی onChange

گام بعدی این است که کامپوننت والد را از تغییرات داخل CustomInputComponent خود مطلع کنید. این جاست که توابع onChange و onTouch از (c) بالا وارد عمل میشوند. با فراخوانی این توابع میتوانید خارج از کامپوننت خود را از تغییرات داخلی آن مطلع سازید. برای انتشار تغییرات مقدار به خارج، باید تابع onChange را با مقدار جدید به عنوان آرگومان فراخوانی کنید. به عنوان مثال، اگر کاربر چیزی در فیلد input در کامپوننت سفارشی شما تایپ کند، شما onChange را با مقدار بهروز شده فراخوانی میکنید:

<input type="text"
       [ngModel]="input"
       (ngModelChange)="onChange($event)">

اگر دوباره پیادهسازی (c) از بالا را بررسی کنید، متوجه خواهید شد چه اتفاقی میافتد: انگولار پیادهسازی خودش را به ویژگی کلاس onChange متصل کرده است. آن پیادهسازی یک آرگومان انتظار دارد، که همان مقدار بهروزرسانیشده کنترل است. کاری که شما اکنون انجام میدهید این است که آن روش را فراخوانی میکنید و بدین ترتیب به انگولار اطلاع میدهید که تغییر رخ داده است. انگولار اکنون پیش میرود و مقدار فرم را در بیرون تغییر خواهد داد. این بخش کلیدی در تمام این موضوع است. شما به انگولار گفتید چه زمانی باید کنترل فرم را بهروزرسانی کند و با چه مقداری با فراخوانی onChange. شما به آن امکان «دسترسی به مقدار کنترل» را دادهاید.

راستی: نام onChange توسط من انتخاب شده است. شما میتوانید هر چیزی اینجا انتخاب کنید، برای مثال propagateChange یا مشابه آن. با این حال که هر اسمی بدهید، این همان تابعی خواهد بود که یک آرگومان میگیرد، که توسط Angular ارائه شده و توسط متد registerOnChange در زمان اجرا به کلاس شما متصل میشود.

فراخوانی onTouch

از آنجایی که کنترلهای فرم میتوانند «لمس شده» باشند، شما همچنین باید به Angular این امکان را بدهید که بفهمد کنترل فرم سفارشی شما کی لمس شده است. میتوانید این کار را، همانطور که حدس زدید، با فراخوانی تابع onTouch انجام دهید. بنابراین برای مثال ما در اینجا، اگر میخواهید مطابق با شیوه Angular برای کنترلهای فرم از پیش ساخته شده عمل کنید، باید هنگام خارج شدن کادر ورودی از حالت تمرکز، onTouch را فراخوانی کنید:

<input type="text"
       [(ngModel)]="input"
       (ngModelChange)="onChange($event)"
       (blur)="onTouch()">

دوباره، onTouch یک نام است که توسط من انتخاب شده است، اما عملکرد واقعی آن توسط Angular ارائه شده و هیچ آرگومانی نمیگیرد. این منطقی است، زیرا شما فقط به Angular اطلاع میدهید که کنترل فرم لمس شده است.

همه چیز را کنار هم گذاشتن

پس وقتی همه چیز با هم جمع میشود، چگونه به نظر میرسد؟ باید اینطور به نظر برسد:

// custom-input.component.ts
import {Component, OnInit, forwardRef} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';


@Component({
  selector: 'app-custom-input',
  templateUrl: './custom-input.component.html',
  styleUrls: ['./custom-input.component.scss'],

  // Step 1: copy paste this providers property
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomInputComponent),
      multi: true
    }
  ]
})
// Step 2: Add "implements ControlValueAccessor"
export class CustomInputComponent implements ControlValueAccessor {

  // Step 3: Copy paste this stuff here
  onChange: any = () => {}
  onTouch: any = () => {}
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }

  // Step 4: Define what should happen in this component, if something changes outside
  input: string;
  writeValue(input: string) {
    this.input = input;
  }

  // Step 5: Handle what should happen on the outside, if something changes on the inside
  // in this simple case, we've handled all of that in the .html
  // a) we've bound to the local variable with ngModel
  // b) we emit to the ouside by calling onChange on ngModelChange

}
// custom-input.component.html
<input type="text"
       [(ngModel)]="input"
       (ngModelChange)="onChange($event)"
       (blur)="onTouch()">
// parent.component.html
<app-custom-input [formControl]="inputTwo"></app-custom-input>

// OR

<form [formGroup]="form" >
  <app-custom-input formControlName="myFormControl"></app-custom-input>
</form>

نمونههای بیشتر

  • مثال با ورودی: https://stackblitz.com/edit/angular-control-value-accessor-simple-example-tsmean
  • مثال با ورودی با بارگذاری تنبل: https://stackblitz.com/edit/angular-control-value-accessor-lazy-input-example-tsmean
  • مثال با دکمه: https://stackblitz.com/edit/angular-control-value-accessor-button-example-tsmean

فرمهای تو در تو

توجه داشته باشید که Control Value Accessors ابزار مناسبی برای گروههای فرم تو در تو نیستند. برای گروههای فرم تو در تو میتوانید به سادگی از یک @Input() subform استفاده کنید. Control Value Accessors برای پوشش دادن کنترلها طراحی شدهاند، نه گروهها! این مثال را ببینید تا نحوه استفاده از یک ورودی برای فرم تو در تو را ببینید: https://stackblitz.com/edit/angular-nested-forms-input-2

منابع

  • https://angular.io/api/forms/ControlValueAccessor
  • https://www.tsmean.com/articles/angular/angular-control-value-accessor-example/