Je voudrais créer un élément de formulaire personnalisé avec l'interface ControlValueAccessor dans Angular 2+. Cet élément serait un wrapper sur un <select>
. Est-il possible de propager les propriétés formControl à l'élément encapsulé? Dans mon cas, l'état de validation n'est pas propagé à la sélection imbriquée comme vous pouvez le voir sur la capture d'écran ci-jointe.
Mon composant est disponible comme suit:
const OPTIONS_VALUE_ACCESSOR: any = {
multi: true,
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OptionsComponent)
};
@Component({
providers: [OPTIONS_VALUE_ACCESSOR],
selector: 'inf-select[name]',
templateUrl: './options.component.html'
})
export class OptionsComponent implements ControlValueAccessor, OnInit {
@Input() name: string;
@Input() disabled = false;
private propagateChange: Function;
private onTouched: Function;
private settingsService: SettingsService;
selectedValue: any;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
ngOnInit(): void {
if (!this.name) {
throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
}
}
writeValue(obj: any): void {
this.selectedValue = obj;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}
Voici mon modèle de composant:
<select class="form-control"
[disabled]="disabled"
[(ngModel)]="selectedValue"
(ngModelChange)="propagateChange($event)">
<option value="">Select an option</option>
<option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
{{option.description}}
</option>
</select>
Je vois deux options:
FormControl
à <select>
FormControl
chaque fois que la valeur <select>
FormControl
changeFormControl
à <select>
FormControl
Ci-dessous les variables suivantes sont disponibles:
selectModel
est le NgModel
du <select>
formControl
est le FormControl
du composant reçu en argumentOption 1: propager les erreurs
ngAfterViewInit(): void {
this.selectModel.control.valueChanges.subscribe(() => {
this.selectModel.control.setErrors(this.formControl.errors);
});
}
Option 2: propager les valideurs
ngAfterViewInit(): void {
this.selectModel.control.setValidators(this.formControl.validator);
this.selectModel.control.setAsyncValidators(this.formControl.asyncValidator);
}
La différence entre les deux est que la propagation des erreurs signifie avoir déjà les erreurs, tandis que l'option secondes implique d'exécuter les validateurs une deuxième fois. Certains d'entre eux, comme les validateurs asynchrones, peuvent être trop coûteux à réaliser.
Propager toutes les propriétés?
Il n'y a pas de solution générale pour propager toutes les propriétés. Différentes propriétés sont définies par diverses directives ou d'autres moyens, ayant ainsi un cycle de vie différent, ce qui signifie qu'elles nécessitent une manipulation particulière. La solution actuelle concerne la propagation des erreurs de validation et des validateurs. Il existe de nombreuses propriétés disponibles là-haut.
Notez que vous pouvez obtenir des changements de statut différents de l'instance FormControl
en vous abonnant à FormControl.statusChanges()
. De cette façon, vous pouvez savoir si le contrôle est VALID
, INVALID
, DISABLED
ou PENDING
(la validation asynchrone est toujours en cours d'exécution).
Comment fonctionne la validation sous le capot?
Sous le capot, les validateurs sont appliqués à l'aide de directives ( vérifier le code source ). Les directives ont providers: [REQUIRED_VALIDATOR]
Ce qui signifie que leur propre injecteur hiérarchique est utilisé pour enregistrer cette instance de validateur. Ainsi, selon les attributs appliqués sur l'élément, les directives ajouteront des instances de validateur sur l'injecteur associé à l'élément cible.
Ensuite, ces validateurs sont récupérés par NgModel
et FormControlDirective
.
Les validateurs ainsi que les accesseurs de valeur sont récupérés comme:
constructor(@Optional() @Host() parent: ControlContainer,
@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
@Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR)
et respectivement:
constructor(@Optional() @Self() @Inject(NG_VALIDATORS) validators: Array<Validator|ValidatorFn>,
@Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: Array<AsyncValidator|AsyncValidatorFn>,
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR)
valueAccessors: ControlValueAccessor[])
Notez que @Self()
est utilisé, donc propre injecteur (de l'élément auquel la directive est appliquée) est utilisé afin d'obtenir les dépendances.
NgModel
et FormControlDirective
ont une instance de FormControl
qui met à jour la valeur et exécute les validateurs.
Par conséquent, le point principal avec lequel interagir est l'instance FormControl
.
Tous les validateurs ou accesseurs de valeur sont également enregistrés dans l'injecteur de l'élément auquel ils sont appliqués. Cela signifie que le parent ne doit pas accéder à cet injecteur. Ce serait donc une mauvaise pratique d'accéder depuis le composant actuel à l'injecteur fourni par le <select>
.
Exemple de code pour l'option 1 (facilement remplaçable par l'option 2)
L'exemple suivant a deux validateurs: un qui est requis et un autre qui est un modèle qui force l'option à correspondre à "l'option 3".
options.component.ts
import {AfterViewInit, Component, forwardRef, Input, OnInit, ViewChild} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, NgModel} from '@angular/forms';
import {SettingsService} from '../settings.service';
const OPTIONS_VALUE_ACCESSOR: any = {
multi: true,
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => OptionsComponent)
};
@Component({
providers: [OPTIONS_VALUE_ACCESSOR],
selector: 'inf-select[name]',
templateUrl: './options.component.html',
styleUrls: ['./options.component.scss']
})
export class OptionsComponent implements ControlValueAccessor, OnInit, AfterViewInit {
@ViewChild('selectModel') selectModel: NgModel;
@Input() formControl: FormControl;
@Input() name: string;
@Input() disabled = false;
private propagateChange: Function;
private onTouched: Function;
private settingsService: SettingsService;
selectedValue: any;
constructor(settingsService: SettingsService) {
this.settingsService = settingsService;
}
ngOnInit(): void {
if (!this.name) {
throw new Error('Option name is required. eg.: <options [name]="myOption"></options>>');
}
}
ngAfterViewInit(): void {
this.selectModel.control.valueChanges.subscribe(() => {
this.selectModel.control.setErrors(this.formControl.errors);
});
}
writeValue(obj: any): void {
this.selectedValue = obj;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}
options.component.html
<select #selectModel="ngModel"
class="form-control"
[disabled]="disabled"
[(ngModel)]="selectedValue"
(ngModelChange)="propagateChange($event)">
<option value="">Select an option</option>
<option *ngFor="let option of settingsService.getOption(name)" [value]="option.description">
{{option.description}}
</option>
</select>
options.component.scss
:Host {
display: inline-block;
border: 5px solid transparent;
&.ng-invalid {
border-color: purple;
}
select {
border: 5px solid transparent;
&.ng-invalid {
border-color: red;
}
}
}
Utilisation
Définissez l'instance FormControl
:
export class AppComponent implements OnInit {
public control: FormControl;
constructor() {
this.control = new FormControl('', Validators.compose([Validators.pattern(/^option 3$/), Validators.required]));
}
...
Liez l'instance FormControl
au composant:
<inf-select name="myName" [formControl]="control"></inf-select>
Service de paramètres factices
/**
* TODO remove this class, added just to make injection work
*/
export class SettingsService {
public getOption(name: string): [{ description: string }] {
return [
{ description: 'option 1' },
{ description: 'option 2' },
{ description: 'option 3' },
{ description: 'option 4' },
{ description: 'option 5' },
];
}
}