Search code examples
angularangular-controlvalueaccessor

ValueAccessor updates the model incorrectly


StackBlitz

I've created a custom select component for angular, which implements the bootstrap styles

:host ::ng-deep {
  @import "~bootstrap/scss/bootstrap-utilities.scss";
  @import "~bootstrap/scss/functions";
  @import "~bootstrap/scss/variables";
  @import "~bootstrap/scss/utilities";
  @import "~bootstrap/scss/forms/form-select";
}

Now I want to be able to use the FormsModule on this custom component

<!-- Apply ngModel on my custom component -->
<bs-select [(ngModel)]="selectedDish" [disabled]="disableSelectBox">
  <option [ngValue]="null" selected>Choose a dish</option>
  <option *ngFor="let dish of dishes" [ngValue]="dish">{{ dish.name }}</option>
</bs-select>


disableSelectBox = false;
selectedDish: Dish | null = null;
dishes: Dish[] = [
  { id: 1, name: 'Salmon', description: 'Salmon with mini-tomatoes', ingredients: ['Salmon', 'tomatoes', 'Pepper sauce'] },
  { id: 2, name: 'Spaghetti', description: 'Spaghetti Bolognaise', ingredients: ['Pasta', 'Minced meat', 'Tomato sauce', 'Mushrooms'] },
  { id: 3, name: 'Lasagna', description: 'Lasagna Bolognaise', ingredients: ['Pasta', 'Minced meat', 'Tomato sauce', 'Cheese'] }
];

Therefor I need to write my own ValueAccessor that applies to the BsSelectComponent, just like angular did (base class). But it's not working properly for me. I've created a StackBlitz here. On top is the standard <select> element with its SelectControlValueAccessor, bound to the same field. Below is my own select box with bootstrap styles, using my custom ValueAccessor.

Value accessor for <bs-select>:

@Directive({
  selector: 'bs-select',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => BsSelectValueAccessor),
      multi: true,
    },
  ],
})
export class BsSelectValueAccessor implements ControlValueAccessor {
  constructor(private renderer: Renderer2, private elementRef: ElementRef, private selectBox: BsSelectComponent) {}

  value: any;

  //#region When options are created
  idCounter = 0;
  registerOption() {
    // Called when options inside the select are created
    return (this.idCounter++).toString();
  }
  //#endregion

  //#region Implement Value Accessor
  onChange = (_: any) => {};
  onTouched = () => {};

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
  registerOnChange(fn: (p: any) => {}) {
    this.onChange = (valueString: string) => {
      // View -> Model
      this.value = this.getOptionValue(valueString);
      fn(this.value);
      // fn(valueString);
    };
  }
  writeValue(value: any) {
    this.value = value;
    // console.log(`WriteValue ${this.selectBox.identifier}`, value);

    const id = this.getOptionId(value);
    const valueString = this.buildValueString(id, value);
    this.setProperty('value', valueString);
  }
  setDisabledState(isDisabled: boolean): void {
    this.setProperty('disabled', isDisabled);
  }
  //#endregion

  //#region Call implemented methods
  @HostListener('change', ['$event']) hostOnChange(ev: InputEvent) {
    this.onChange((<any>ev.target).value);
  }

  @HostListener('blur', ['$event']) hostBlur(ev: Event) {
    this.onTouched();
  }
  //#endregion

  // We need to keep a list of the options. Below directive adds the option values to this list.
  optionMap = new Map<string, any>();

  //#region Other methods
  protected setProperty(key: string, value: any): void {
    if (this.elementRef) {
      this.renderer.setProperty(this.elementRef.nativeElement, key, value);
    }
    // if (this.selectBox.selectBox) {
    //   this._renderer.setProperty(this.selectBox.selectBox.nativeElement, key, value);
    // }
  }
  buildValueString(id: string | null, value: any) {
    if (id == null) {
      return `${value}`;
    }

    if (value && (typeof value === 'object')) {
      value = 'Object';
    }

    return `${id}: ${value}`.slice(0, 50);
  }
  getOptionId(value: any) {
    for (const id of Array.from(this.optionMap.keys())) {
      if (this.compareWithFunction(this.optionMap.get(id), value)) {
        return id;
      }
    }

    // This shouldn't happen, since all options have a [ngValue] assigned
    // debugger;
    
    return null;
  }
  getOptionValue(valueString: string) {
    const id = this.extractId(valueString);
    return this.optionMap.has(id) ? this.optionMap.get(id) : valueString;
  }
  extractId(valueString: string) {
    return valueString.split(':')[0];
  }
  //#endregion

  //#region CompareWith
  private compareWithFunction: (value1: any, value2: any) => boolean = Object.is;
  @Input() set compareWith(value: (value1: any, value2: any) => boolean) {
    if (typeof value !== 'function') {
      throw new Error('compareWith must be a function');
    }
    this.compareWithFunction = value;
  }
  //#endregion
}

Directive that adds and removes the key/value to the Map of the BsSelectValueAccessor (which is why you need OnDestroy):

@Directive({ selector: 'option' })
export class BsSelectOption implements OnDestroy {
  constructor(
    private element: ElementRef,
    private renderer: Renderer2,
    @Optional() @Host() private selectAccessor: BsSelectValueAccessor
  ) {
    if (this.selectAccessor) {
      this.id = this.selectAccessor.registerOption();
    }
  }

  id!: string;

  @Input('ngValue') set ngValue(value: any) {
    if (this.selectAccessor) {
      this.selectAccessor.optionMap.set(this.id, value);
      this.setElementValue(
        this.selectAccessor.buildValueString(this.id, value)
      );
      // console.log('ngValue', this.select.value);
      this.selectAccessor.writeValue(this.selectAccessor.value);
    }
  }

  @Input('value') set value(value: any) {
    this.setElementValue(value);
    if (this.selectAccessor) {
      this.selectAccessor.writeValue(this.selectAccessor.value);
    }
  }

  setElementValue(value: string) {
    // console.log('setElementValue', value);
    const nativeSelect = this.selectAccessor['selectBox'].selectBox;
    if (nativeSelect) {
      this.renderer.setProperty(nativeSelect.nativeElement, 'value', value);
    }
  }

  ngOnDestroy() {
    if (this.selectAccessor) {
      this.selectAccessor.optionMap.delete(this.id);
      this.selectAccessor.writeValue(this.selectAccessor.value);
    }
  }
}

For some reason, selecting a value in my own SelectBox, puts the string in the model instead of the object specified by [ngValue]. What am I doing wrong?


Solution

  • Your solution is really close, but there are 3 small mistakes.

    1. The directive BsSelectOption is not being executed and that is because it is not declared in any module. To fix it, add it to the AppModule declarations array.

    2. You are not writing to the Option DOM elements their values, so when the user select an option, it isn't possible to know the value of the option selected. It should be as follows.

    setElementValue(value: string) {
      // Property "element" is the option DOM element.
      this.renderer.setProperty(this.element.nativeElement, 'value', value);
    }
    
    1. When the selected option change (or is initialized) we need to store the current value, one way of doing it, since you are using the native HTML Select element under the hood, you can write its value.
    protected setProperty(key: string, value: any): void {
      // Before you were targeting the BsSelectComponent element, which could work
      // but would require some changes to the existing code.
      if (this.selectBox.selectBox) {
        this.renderer.setProperty(this.selectBox.selectBox.nativeElement, key, value);
      }
    }
    

    Demo: StackBlitz