I'm struggling with some strange behavior while using my custom input component.
First of all, I built a simple abstract class that has the main "features" and methods of the component, then, the input-component which has very few code:
// Abstract class
export abstract class BaseFormInput<T> implements ControlValueAccessor, Validator, AfterViewInit, OnDestroy {
@Input() label: string
@Output() onChange: EventEmitter<T> = new EventEmitter<T>()
private changeInternal: (obj: T) => void
private changeSub: Subscription
private disabled$ = new BehaviorSubject(false)
private required$ = new BehaviorSubject(false)
public input = new FormControl(null)
ngOnDestroy() {
this.changeSub.unsubscribe()
}
ngAfterViewInit() {
this.changeSub = this.input.valueChanges.subscribe(v => {
if (!this.disabled$.getValue()) {
this.onChange.emit(v)
this.changeInternal(v)
}
})
}
writeValue = (obj: T) => this.input.setValue(obj)
registerOnChange = (fn: (obj: T) => void) => this.changeInternal = fn
registerOnTouched = (_fn: (obj: any) => void) => {}
setDisabledState = (isDisabled: boolean) => this.disabled$.next(isDisabled)
validate(control: AbstractControl): ValidationErrors {
this.required$.next(control.hasValidator(Validators.required))
// THIS LINE HAS WEIRD BEHAVIOR
console.log(control, control.errors)
return null
}
public get isDisabled$(){
return this.disabled$.asObservable()
}
public get isRequired$(){
return this.required$.asObservable()
}
}
The input component is simply designed like this:
@Component({
selector: "ec-input-text",
template: `<div class="form-control">
<label *ngIf="label">
{{ label }}
<span *ngIf="isRequired$ | async">*</span>
</label>
<input *ngIf="type !== 'textarea'" [type]="type" [formControl]="input" [attr.disabled]="isDisabled$ | async" />
<textarea *ngIf="type === 'textarea'" [formControl]="input" [attr.disabled]="isDisabled$ | async"></textarea>
<ng-template></ng-template>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => InputTextComponent), multi: true },
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => InputTextComponent), multi: true }
]
})
export class InputTextComponent extends BaseFormInput<string> {
@Input() type: "text" | "password" | "email" | "textarea" = "text"
@Input() maxLength: number
}
Finally, I created a register-component, which uses the input.
HTML:
<form [formGroup]="form">
<ec-input-text label="First name" formControlName="firstName" />
<ec-input-text label="Last name" formControlName="lastName" />
<ec-input-text label="E-mail" formControlName="email" type="email" />
<ec-input-text label="Password" formControlName="password" type="password" />
</form>
The TS of the register-component has a public property like this:
public form = new FormGroup({
firstName: new FormControl(null, [Validators.required, Validators.maxLength(50)]),
lastName: new FormControl(null, [Validators.required, Validators.maxLength(50)]),
email: new FormControl(null, [Validators.required, Validators.maxLength(100)]),
password: new FormControl(null, Validators.required)
})
Now, the issue is the following: in the validate method of the abstract class (where I put a comment), I tried to log the control errors, and I get a strange behavior: when logging the formControl, I can see in the console that the property errors is null, but if I log control.errors it logs:
{ required: true }
Even though the control is valid and I typed the value (in fact, control.value has a value and results valid). So if i do:
console.log(control)
And I expand it, errors is null (expected behavior, correct!)
But if I do:
console.log(control.errors)
It is valorized (not correct, the control is valid!)
How can I figure this out? Thanks in advance!
Do not use attr.disabled
or disabled
in reactive forms, you can try a directive or just manually disabling it using reactive form methods. It can lead to difficult to solve bugs, so recommending disabling it programmatically.
Disabling Form Controls When Working With Reactive Forms in Angular
You are not checking for the validation errors at the right place, the validate method is designed for you to insert custom validation that does validation specific to your control's value, mostly won't involve checking the other errors (not showing correctly).
When you check the errors of the control at this location, it is showing the previous state, so I guess the other validations are not updated. So please perform validation inside the validate
function and do not check the other errors.
Also you can import the ControlContainer
, get the formControlName
and get the actual form control, you can use it to check the Validators.required
is added or not. Although this is not fool proof it's a good starting point to access the form control inside the custom form element.
constructor(public inj: Injector) {}
ngOnInit() {
const controlContainer = this.inj.get(ControlContainer);
this.control = controlContainer!.control!.get(this.formControlName);
this.required$.next(this.control!.hasValidator(Validators.required));
}
import {
Component,
OnDestroy,
AfterViewInit,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
forwardRef,
Directive,
inject,
Injector,
} from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
ReactiveFormsModule,
ControlValueAccessor,
Validator,
FormGroup,
FormControl,
AbstractControl,
ValidationErrors,
Validators,
NG_VALUE_ACCESSOR,
NG_VALIDATORS,
NgControl,
ControlContainer,
} from '@angular/forms';
import { BehaviorSubject, Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
@Directive()
export abstract class BaseFormInput<T>
implements ControlValueAccessor, Validator, AfterViewInit, OnDestroy
{
@Input() label!: string;
@Input() formControlName!: string;
@Output() onChange: EventEmitter<T> = new EventEmitter<T>();
private changeInternal!: (obj: T) => void;
private changeSub!: Subscription;
private disabled$ = new BehaviorSubject(false);
private required$ = new BehaviorSubject(false);
public input = new FormControl(null);
control!: AbstractControl<any, any> | null;
ngOnDestroy() {
this.changeSub.unsubscribe();
}
constructor(public inj: Injector) {}
ngOnInit() {
const controlContainer = this.inj.get(ControlContainer);
this.control = controlContainer!.control!.get(this.formControlName);
this.required$.next(this.control!.hasValidator(Validators.required));
}
ngAfterViewInit() {
this.changeSub = this.input.valueChanges.subscribe((v: any) => {
if (!this.disabled$.getValue()) {
this.onChange.emit(v);
this.changeInternal(v);
}
});
}
writeValue = (obj: any) => this.input.setValue(obj);
registerOnChange = (fn: (obj: T) => void) => (this.changeInternal = fn);
registerOnTouched = (_fn: (obj: any) => void) => {};
setDisabledState = (isDisabled: boolean) => this.disabled$.next(isDisabled);
validate(control: AbstractControl): ValidationErrors | null {
// THIS LINE HAS WEIRD BEHAVIOR
// console.log(
// control,
// control.getError('required'),
// control.errors,
// control.value
// );
return null;
}
public get isDisabled$() {
return this.disabled$.asObservable();
}
public get isRequired$() {
return this.required$.asObservable();
}
}
@Component({
selector: 'ec-input-text',
standalone: true,
imports: [ReactiveFormsModule, CommonModule],
template: `<div class="form-control">
<label *ngIf="label">
{{ label }}
<span *ngIf="isRequired$ | async">*</span>
</label>
<input *ngIf="type !== 'textarea'" [type]="type" [formControl]="input" />
<textarea *ngIf="type === 'textarea'" [formControl]="input" ></textarea>
<ng-template></ng-template>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputTextComponent),
multi: true,
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => InputTextComponent),
multi: true,
},
],
})
export class InputTextComponent extends BaseFormInput<string> {
@Input() type: 'text' | 'password' | 'email' | 'textarea' = 'text';
@Input() maxLength!: number;
}
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule, CommonModule, InputTextComponent],
template: `
<form [formGroup]="form">
<ec-input-text label="First name" formControlName="firstName" />
<ec-input-text label="Last name" formControlName="lastName" />
<ec-input-text label="E-mail" formControlName="email" type="email" />
<ec-input-text label="Password" formControlName="password" type="password" />
</form><br/>
{{form.errors | json}}
<br/>
{{form.controls.firstName.errors | json}}
`,
})
export class App {
public form = new FormGroup({
firstName: new FormControl(null, [
Validators.required,
Validators.maxLength(50),
]),
lastName: new FormControl(null, [
Validators.required,
Validators.maxLength(50),
]),
email: new FormControl(null, [
Validators.required,
Validators.maxLength(100),
]),
password: new FormControl(null, Validators.required),
});
}
bootstrapApplication(App);
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With