Angular Custom Form Control with Custom Validator Sync Issue

153 Views Asked by At

Custom control component implementing both ControlValueAccessor and Validator interface.

This custom control is unable to display error properly from the consumer component's validators.

@Component({
  selector: 'custom-input',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: forwardRef(() => CustomInput),
    },
    {
      provide: NG_VALIDATORS,
      multi: true,
      useExisting: forwardRef(() => CustomInput),
    },
  ],
  template: `
    <article style="border: 1px solid green; padding: .5rem ">
      <form [formGroup]="inputForm">
        <input formControlName="telInput" placeholder="Telephone" (blur)="onFocusOut($event);">
      </form>
    <section class="code">
      inner errors: {{ inputForm.get('telInput')?.errors | json }}
    </section >
</article>
  `,
})
export class CustomInput implements ControlValueAccessor, Validator {
  inputForm!: FormGroup;

  formCallbacks = {
    onChange: (anyVal: any) => {},
    onTouched: () => {},
    onValidatorChange: () => {},
  };

  writeValue(obj: any): void {
    this.inputForm.setValue({ telInput: obj });
  }
  registerOnChange(fn: any): void {
    this.formCallbacks.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.formCallbacks.onTouched = fn;
  }
  setDisabledState?(isDisabled: boolean): void {
    isDisabled ? this.inputForm.disable() : this.inputForm.enable();
  }

  validate(control: AbstractControl): ValidationErrors | null {
    console.log('[inner] validate', control.errors);
    return control.errors;
  }

  registerOnValidatorChange?(fn: () => void): void {
    this.formCallbacks.onValidatorChange = fn;
  }

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.inputForm = this.fb.group({
      telInput: [''],
    });
    this.inputForm.valueChanges.subscribe((val) => {
      this.formCallbacks.onChange(val.telInput);
    });
  }

  onFocusOut(event: any) {
    this.formCallbacks.onTouched();
  }
}

However when this custom control is used with a reactive form and a validator, the errors are not syncing properly between the component form and custom input.

function someValidator(ac: AbstractControl): ValidationErrors | null {
  if (ac.value === null) {
    return null;
  }
  const inputVal = ac.value;
  const noErrorsOk = inputVal.length > 2;
  const err = inputVal !== null && noErrorsOk ? null : { dataFormat: true };
  console.log('[outer] validator err', err);
  return err;
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CustomInput, ReactiveFormsModule, CommonModule],
  template: `
    <article>
      <form [formGroup]="mainForm">
        <custom-input formControlName="joesNumber"></custom-input>
      </form>
      
      <section class="code">
        outer error: {{ mainForm.get('joesNumber')?.errors | json }}
      </section>

      <section>
        <p>Notes: length > 2 is valid. inner error is always null and outer error is alwasy dataFormat error. </p>
      </section>
    </article>
  `,
})
export class App {
  mainForm!: FormGroup;

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.mainForm = this.fb.group({
      joesNumber: ['', [someValidator]],
    });
    this.mainForm.statusChanges.subscribe((val) => {
      console.log('[outer] statusChange', val);
    });
  }
}

playground

https://stackblitz.com/edit/stackblitz-starters-3zwvin

1

There are 1 best solutions below

3
Eliseo On

When we create a custom form control with Validator is to make a function validator that it's not the same that the errors we pass when create the formControl. you can not return the control.errors

  validate(control: AbstractControl): ValidationErrors | null {
    if (control.value=="fool")
        return {innerError:"I'm can not be fool"}
    return null;
  }

If you want to give a class to your input if the formControl is invalid you can declare a variable control

control!:AbstractControl

And give value in the validate function

validate(control: AbstractControl): ValidationErrors | null {
    if (!this.control)
        this.control=control
    ...
  }

So write some like

<input [class.invalid]="control?.errors"/>

Update

There're another way to "reach" the "outher control", we can get the control using injector and get it in ngAfterViewInit

  constructor(private injector:Injector,...){}

  ngAfterViewInit(): void {
    const ngControl: any = this.injector.get(NgControl, null);
    if (ngControl) {
      setTimeout(() => {
        this.outerControl = ngControl.control as FormControl;
      })
    }
  }

The setTimeout is to not get the error "ExpressionChangedAfterItHasBeenCheckedError"

In this way, using a ErrorStateMatcher like this a bit old SO, we can manage the mat-input and the mat-error in the mat-form-field

Rewrite the code with a few changes

class CustomFieldErrorMatcher implements ErrorStateMatcher {
  constructor(private customControl: FormControl) { }
  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    return this.customControl && this.customControl.touched && this.customControl.invalid;
  }
}

So we can use a mat-form-field

<article style="border: 1px solid green; padding: 0.5rem">
  <form [formGroup]="inputForm">
    <mat-form-field>
      <input
        formControlName="telInput"
        placeholder="Telephone"
        matInput
        [errorStateMatcher]="errorMatcher()"
        (blur)="onFocusOut($event)"
      />
      <mat-error *ngIf="outerControl?.hasError('required')">
        Required
      </mat-error>
      <mat-error *ngIf="outerControl?.hasError('isFool')">
        I'm can not be fool
      </mat-error>
    </mat-form-field>
  </form>
</article>

See how we can show one or another error using the "outerControl"

Note also that the inner FormControl not declare with any validator, you use the validate function

a stackblitz

Well, this work in several cases, but we can also not create a custom form control else a custom form field control like show the guide Custom form field control

So, our component implements

export class MyCustomFieldControl implements ControlValueAccessor, 
                                      Validate, 
                                      MatFormFieldControl<MyCustomFieldControl>,
                                      OnDestroy{
}