angulartypescriptangular-reactive-formsangular-formsangular-formbuilder

Angular custom FormControl & AsyncValidator Submit not working on first click


I have somes Forms, that usese some custom FormControls, but when I place one of these FormControls last, it causes the Submit button to not work on first click (without prior leaving the last input field with a click)

Custom FormControl

@Component({
  selector: 'app-myinput',
  templateUrl: './myinput.component.html',
  styleUrls: ['./myinput.component.css'],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: forwardRef(() => MyinputComponent),
      multi: true,
    },
    {
      provide: NG_ASYNC_VALIDATORS,
      useExisting: forwardRef(() => MyinputComponent),
      multi: true,
    },
  ],
})
export class MyinputComponent
  implements
    ControlValueAccessor,
    MatFormFieldControl<string>,
    AsyncValidator,
    OnInit,
    OnDestroy
{
  public static nextId = 0;

  private _disabled = false;
  private _required = false;
  private _placeholder: string;
  private subscription: Subscription;

  public form: FormGroup<stuffForm> = this.formbuilder.group({
    importantStuff: new FormControl('', {
      validators: [Validators.required],
      asyncValidators: [asyncValidator()],
    }),
  });

  public stateChanges: Subject<void> = new Subject();
  public focused: boolean = false;
  public touched: boolean = false;
  public controlType?: string | undefined = 'app-myinput';
  public id: string = `app-myinput${MyinputComponent.nextId++}`;
  public onChange: any = () => {};
  public onTouched: any = () => {};
  public onValidatorChanged: any = () => {};
  public shouldLabelFloat: boolean;
  public ngControl: NgControl;
  constructor(
    private formbuilder: FormBuilder,
    private _elementRef: ElementRef<HTMLElement>,
    private _injector: Injector,
    @Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField
  ) {}

  validate(
    control: AbstractControl<any, any>
  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
    return this.form.controls.importantStuff.statusChanges.pipe(
      filter((status: FormControlStatus) => status != 'PENDING'),
      map((status: FormControlStatus) => {
        return this.form.controls.importantStuff.errors;
      }),
      take(1)
    );
  }
  registerOnValidatorChange?(fn: () => void): void {
    this.onValidatorChanged = fn;
  }

  ngOnInit(): void {
    if (this._injector.get(NgControl) != null) {
      this.ngControl = this._injector.get(NgControl);
      this.ngControl.valueAccessor = this;
    }
    this.subscription = this.form.controls.importantStuff.valueChanges
      .pipe(filter((x) => x != ''))
      .subscribe(() => this.form.markAllAsTouched());
  }

  autofilled?: boolean | undefined;
  public get empty() {
    return !this.form.value;
  }

  // eslint-disable-next-line @angular-eslint/no-input-rename
  @Input('aria-describedby') userAriaDescribedBy: string;

  @Input()
  get placeholder(): string {
    return this._placeholder;
  }
  set placeholder(value: string) {
    this._placeholder = value;
    this.stateChanges.next();
  }

  @Input()
  get required(): boolean {
    return this._required;
  }
  set required(value: BooleanInput) {
    this._required = coerceBooleanProperty(value);
    this.stateChanges.next();
  }

  @Input()
  get disabled(): boolean {
    return this._disabled;
  }
  set disabled(value: BooleanInput) {
    this._disabled = coerceBooleanProperty(value);
    this._disabled ? this.form.disable() : this.form.enable();
    this.stateChanges.next();
  }

  @Input()
  get value(): string | null {
    if (this.form.valid) {
      return this.form.controls.importantStuff.value;
    }
    return null;
  }
  set value(importantStuff: string | null) {
    this.form.controls.importantStuff.setValue(importantStuff);
    this.stateChanges.next();
  }
  get errorState(): boolean {
    return this.form.invalid && this.touched;
  }

  onFocusIn(event: FocusEvent) {
    if (!this.focused) {
      this.focused = true;
      this.stateChanges.next();
    }
  }

  onFocusOut(event: FocusEvent) {
    if (
      !this._elementRef.nativeElement.contains(event.relatedTarget as Element)
    ) {
      this.touched = true;
      this.focused = false;
      this.onTouched();
      this.stateChanges.next();
    }
  }

  setDescribedByIds(ids: string[]) {
    const controlElement = this._elementRef.nativeElement.querySelector(
      '.example-tel-input-container'
    )!;
    controlElement.setAttribute('aria-describedby', ids.join(' '));
  }

  onContainerClick(event: MouseEvent): void {
    this.onTouched();
  }

  writeValue(importantStuff: string): void {
    this.form.controls.importantStuff.setValue(importantStuff);
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }
  setDisabledState?(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
  public change(value: Event) {
    this.form.controls.importantStuff.setValue(
      (value.target as HTMLInputElement).value
    );
    this.onChange(this.form.controls.importantStuff.value);
  }

  ngOnDestroy(): void {
    this.stateChanges.complete();
    if (this.subscription) {
      this.subscription.unsubscribe();
    }
  }
}

export interface stuffForm {
  importantStuff: FormControl<string | null>;
}

Custom FormControl HTML

<mat-form-field [formGroup]="form">
  <mat-label>importantStuff</mat-label>
  <input
    matInput
    formControlName="importantStuff"
    (change)="change($event)"
    (focusin)="onFocusIn($event)"
    (focusout)="onFocusOut($event)"
    (input)="onChange(form.controls.importantStuff.value)"
  />
</mat-form-field>

From TS

export class MyFormComponent implements OnInit {
  public form = this.formbuilder.group<StoreForm>({
    some: new FormControl('', {
      nonNullable: true,
    }),
    importantStuff: new FormControl('', { nonNullable: true }),
  });

  constructor(private formbuilder: FormBuilder) {}

  ngOnInit() {}

  public submit() {
    console.log(this.form.value);
  }

  public isValid(): boolean {
    return this.form.valid; // This is a method, because in the original code, there are more requirements
  }
}

export interface StoreForm {
  some: FormControl<string>;
  importantStuff: FormControl<string>;
}

Form HTML

<form [formGroup]="form" (ngSubmit)="submit()">
  <mat-form-field>
    <mat-label>Some Field</mat-label>
    <input formControlName="some" type="text" matInput />
  </mat-form-field>
  <app-myinput formControlName="importantStuff"></app-myinput>
  <button mat-raised-button [disabled]="!isValid()" type="submit">Ok</button>
</form>

I enter a value into the custom formControl and the async validator inside the custom control evaluates that the field is valid, the Submit button will be enabled. I click on the submit button an nothing happens;

The form re-validates the itself on focusout, which disables the button before the submit function is called. After realizing that the value is indeed valid (as it was before) the button will be re-enabled and will call submit on next click.

Stackblitz Reproduction

Unfortunetly, Angular Material Design does not work in the Repro, but since it reproduces the behavior, I think it is enough.


Solution

  • I ended up removing the

        (change)="change($event)"
    

    from the Custom Control. The change Event was called on focusout, causing validation to run again (faster than the button could update its disabled state) resulting in the problem behavior