Angular 12 - ControlValueAccessor and patchValue

1.6k Views Asked by At

I am struggling with an issue affecting my custom dropdown component, created using the ControlValueAccessor interface.

Basically, this dropdown component can have two different possible values:

  • a simple string (belonging to an array of choosable strings)
  • a complex object Key:
export interface Key {
  group?: string;
  key?: string;
  order?: number;
  description?: string;
  defaultQ?: string;
}

Overall, this custom component work correctly as follows:

  • manual input and selection work fine
  • if the value is a Key object, only the description attribute shall be displayed to the user
  • the CVA value behind is correctly set to a string (1st scenario) or a Key (2nd scenario).

The problem occurs when I try to initialize this dropdown component by patching the value from a parent component as follows:

this.formGroup.patchValue({ country: this.defaultCountry });

where this.defaultCountry is a Key object with "Italy" as its description. Turns out, the description is not displayed (instead [object Object] is shown), and the CVA value behind the dropdown component is not updated either (both the control.value and the parsed description are empty).

It seems like no update is triggered by the patchValue command.

This is my current DropdownComponent class:

@Component({
  selector: 'app-dropdown',
  templateUrl: './dropdown.component.html',
  styleUrls: ['./dropdown.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: DropdownComponent,
      multi: true
    }
  ]
})
export class DropdownComponent implements ControlValueAccessor, OnDestroy {
  private _destroyed: Subject<boolean> = new Subject();

  @Input()
  label = '';
  @Input()
  values: string[] | Key[] = [];
  @Input()
  readOnly = false;
  @Input()
  uppercase = false;
  @Input()
  filterResults = false;
  @Input()
  showErrors = true;
  @Input()
  position: DdPosition = 'bottom';
  @Input()
  keyGroup!: KeyGroup;
  @Input()
  formControl!: FormControl;
  @Input()
  formControlName!: string;

  @Output()
  selected: EventEmitter<unknown> = new EventEmitter<unknown>();
  @Output()
  valid: EventEmitter<boolean> = new EventEmitter<boolean>();

  onTouched = (): void => {};
  onChange = () => {};

  @ViewChild(FormControlDirective, { static: true })
  formControlDirective!: FormControlDirective;
  @ViewChild('valueSearch', { static: false })
  valueSearch: ElementRef<HTMLElement> | undefined;

  constructor(private controlContainer: ControlContainer, private keyService: KeyService) {
    this.keyService.keys$
      .pipe(
        takeUntil(this._destroyed),
        tap(keys => (this.values = keys.filter(k => k.group == this.keyGroup)))
      )
      .subscribe();
  }

  ngOnDestroy(): void {
    this._destroyed.next();
    this._destroyed.complete();
  }

  get control(): any {
    return this.formControl || this.controlContainer.control?.get(this.formControlName) || new FormControl();
  }

  get value(): any {
    if (!this.control.value) {
      return null;
    }
    if (this.keyGroup) {
      // Dropdown of keys
      return this.control.value[0] as Key;
    }
    return this.control.value;
  }

  getDescription(value: any): string { // this is the real value displayed by the HTML code
    if (!value) {
      return '';
    }
    if (typeof value === 'string') {
      return value;
    }
    // Dropdown of keys
    return (value as Key)?.description || '';
  }

  get stringsToFilter(): string[] {
    if (this.keyGroup) {
      // Dropdown of keys
      return (this.values as Key[]).map(k => k.description || '');
    }
    return this.values as string[];
  }

  clearInput(): void {
    if (this.control.disabled) {
      return;
    }
    this.control.setValue('');
    this.onChange();
    this.selected.emit(this.value);
    this.valueSearch?.nativeElement.blur();
  }

  onSelectChange(selected: string): void {
    if (this.control.disabled) {
      return;
    }
    if (this.keyGroup) {
      this.control.setValue((this.values as Key[]).filter(v => v.description === selected));
    } else {
      this.control.setValue(selected);
    }
    this.onInputChange();
  }

  onInputChange(): void {
    if (this.control.disabled) {
      return;
    }
    this.onChange();
    this.selected.emit(this.value);
  }

  onBlur(): void {
    this.onTouched();
  }

  registerOnTouched(fn: any): void {
    this.formControlDirective.valueAccessor?.registerOnTouched(fn);
  }

  registerOnChange(fn: any): void {
    this.formControlDirective.valueAccessor?.registerOnChange(fn);
  }

  writeValue(obj: any): void {
    this.formControlDirective.valueAccessor?.writeValue(this.getDescription(obj));
  }

  setDisabledState(isDisabled: boolean): void {
    this.formControlDirective.valueAccessor?.setDisabledState?.(isDisabled);
  }

  get isValueInList(): boolean {
    if (!this.getDescription(this.value) || this.getDescription(this.value) == '') {
      return true;
    }
    return this.values
      .map(v => (this.keyGroup ? (v as Key).description : (v as string)))
      .includes(this.getDescription(this.value));
  }

  get invalid(): boolean {
    return (this.control ? this.control.invalid : false) || !this.isValueInList;
  }

  get hasErrors(): boolean {
    if (!this.control) {
      return false;
    }
    const { dirty, touched } = this.control;
    return this.invalid ? dirty || touched : false;
  }
}

And this is the HTML code of DropdownComponent:

<div class="text-xs dropdown">
  
[...]

      <!-- Selected value -->
      <input
        name="select"
        id="select"
        class="px-4 appearance-none outline-none text-gray-800 w-full"
        autocomplete="off"
        [ngClass]="{
          'uppercase': uppercase,
          'cursor-pointer': readOnly
        }"
        [value]="getDescription(value)"
        [formControl]="control"
        [readOnly]="readOnly"
        (blur)="onBlur()"
        (change)="onInputChange()"
        #valueSearch
      />
      
[...]

</div>

What am I missing here? Can you help me?

Thank you.

Regards, A.M.

1

There are 1 best solutions below

0
On BEST ANSWER

I managed to solve my issue by injecting the ChangeDetectorRef in my DropdownComponent class and using it as follows:

writeValue(obj: any): void {
    this.formControlDirective.valueAccessor?.writeValue(obj);
    this.cdr.detectChanges();
  }

Like this, the value is correctly updated and shown in page.