1
\$\begingroup\$

I'm creating an Angular widget which combines a dropdown with a custom input. I've made it generic and am preparing a blogpost explaining my setup, and would love to get a review before I do so.

The most basic usage of this component would look like this:

<select-with-custom-input
  baseOptions="['one', 'two', 'three']"
  customOption="other, specify..."
  selectLabel="Pick something"
  placeholder="enter a value"
  required="true"
  classForLabel="form-label"
  classForSelect="form-control"
  classForInput="form-control mt-3"
  [(ngModel)]="myModelProperty"
></select-with-custom-input>

See this StackBlitz.com example for a live demo.

You will see a dropdown, and if you select the customOption you get a chance to fill out a custom value in an input field. The end result of the component should be one string value: either one of the base options, or the custom text you provided.

The component's code is inspired on the default ControlValueAccessor from the Angular source code, and that's what I'd love to get feedback on:

import { Component, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'select-with-custom-input',
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: SelectWithCustomInputComponent,
    multi: true,
  }],
  styles: [],
  template: `
    <label for="selectForBaseOptions"
          [class]="classForLabel"
          *ngIf="selectLabel">
      {{ selectLabel }}
    </label>
    <select [class]="classForSelect"
            id="selectForBaseOptions"
            [required]="required"
            [disabled]="_isDisabled"
            (blur)="onTouched()"
            (ngModelChange)="_selectChanged($event)"
            [(ngModel)]="_selectedOption">
      <option value="" [selected]="_selectedOption === ''"></option>
      <option *ngFor="let option of baseOptions"
              [selected]="_selectedOption === option"
              [value]="option">
        {{ option }}
      </option>
      <option [value]="customOption" [selected]="_isCustomOptionSelected">
        {{ customOption }}
      </option>
    </select>
    <input type="text"
          id="inputForCustomOption"
          [class]="classForInput"
          *ngIf="_isCustomOptionSelected"
          [required]="required"
          [disabled]="_isDisabled"
          [placeholder]="placeholder"
          (blur)="onTouched()"
          (ngModelChange)="_customValueChanged($event)"
          [(ngModel)]="_customInput">
  `,
})
export class SelectWithCustomInputComponent implements ControlValueAccessor {
  @Input() selectLabel = '';
  @Input() baseOptions = [];
  @Input() customOption = 'Other...';
  @Input() placeholder = '';
  @Input() required = false;

  // Allow frameworks like Bootstrap to style individual elements easily:
  @Input() classForLabel = '';
  @Input() classForInput = '';
  @Input() classForSelect = '';

  _selectedOption = '';
  _customInput = '';
  _isCustomOptionSelected = false;
  _isDisabled = false;

  private onChange = (_: any) => { };
  private onTouched = (_: any) => { };

  _selectChanged(value: string): void {
    this._isCustomOptionSelected = value === this.customOption;

    this.onChange(this._isCustomOptionSelected
      ? this._customInput
      : value);
  }

  _customValueChanged(value: string): void {
    this.onChange(value);
  }

  writeValue(value: string): void {
    const normalizedValue = value == null ? '' : value;

    this._isCustomOptionSelected = normalizedValue
      ? this.customOption === normalizedValue || !this.baseOptions.includes(normalizedValue)
      : false;

    if (this._isCustomOptionSelected) {
      this._selectedOption = this.customOption;
      this._customInput = normalizedValue;
    } else {
      this._selectedOption = normalizedValue;
      this._customInput = '';
    }
  }

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState?(isDisabled: boolean): void {
    this._isDisabled = isDisabled;
  }
}

I also have a test suite with component integration tests to check if the behavior is as expected, that should give (near) 100% statement coverage. If you have any comments on this spec it would also be appreciated:

import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';

import { SelectWithCustomInputComponent } from './select-with-custom-input.component';

describe('SelectWithCustomInputComponent', () => {
  let fixture: ComponentFixture<SelectWithCustomInputComponent>;
  let component: SelectWithCustomInputComponent;
  let nativeElement: HTMLElement;
  let select: HTMLSelectElement;
  let latestValueReceivedFromComponent;
  let hasOnTouchedBeenCalled = false;

  // Has an *ngIf so need to get this element as late as possible
  const getInputElement = () => nativeElement.querySelector('input');

  function makeFixtureStable() {
    fixture.detectChanges();
    tick();
  }

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [ FormsModule ],
      declarations: [ SelectWithCustomInputComponent ],
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(SelectWithCustomInputComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    component.registerOnChange(val => latestValueReceivedFromComponent = val);
    component.registerOnTouched(_ => hasOnTouchedBeenCalled = true);
    nativeElement = fixture.nativeElement;
    select = nativeElement.querySelector('select');
  });

  it('should see null as empty string', fakeAsync(() => {
    component.writeValue(null);
    makeFixtureStable();
    expect(select.value).toEqual('');
  }));

  it('should see undefined as empty string', fakeAsync(() => {
    component.writeValue(undefined);
    makeFixtureStable();
    expect(select.value).toEqual('');
  }));

  it('should see empty string as empty string', fakeAsync(() => {
    component.writeValue('');
    makeFixtureStable();
    expect(select.value).toEqual('');
  }));

  it('should be able to select a base option', fakeAsync(() => {
    component.baseOptions = ['option 1', 'option 2'];
    component.writeValue('option 2');
    makeFixtureStable();
    expect(select.value).toEqual('option 2');
  }));

  it('should not show the input if a base option is selected', fakeAsync(() => {
    component.baseOptions = ['option 1'];
    component.writeValue('option 1');
    makeFixtureStable();
    expect(getInputElement()).toBeFalsy();
  }));

  it('should be able to load custom option', fakeAsync(() => {
    component.baseOptions = ['option 1'];
    component.writeValue('my custom text');
    makeFixtureStable();
    expect(getInputElement().value).toEqual('my custom text');
  }));

  it('should propagate disabled state to select', fakeAsync(() => {
    component.setDisabledState(true);
    makeFixtureStable();
    expect(select.disabled).toBeTruthy();
  }));

  it('should propagate disabled state to input', fakeAsync(() => {
    component.writeValue('my custom text');
    component.setDisabledState(true);
    makeFixtureStable();
    expect(getInputElement().disabled).toBeTruthy();
  }));

  it('should propagate placeholder state to input', fakeAsync(() => {
    component.placeholder = 'enter your value';
    component.writeValue('my custom text');
    makeFixtureStable();
    expect(getInputElement().getAttribute('placeholder')).toEqual('enter your value');
  }));

  it('should register change of option', fakeAsync(() => {
    component.baseOptions = ['A', 'B'];
    makeFixtureStable();
    select.value = 'B';
    select.dispatchEvent(new Event('change'));
    makeFixtureStable();
    expect(latestValueReceivedFromComponent).toEqual('B');
  }));

  it('should register change to provided custom option', fakeAsync(() => {
    component.baseOptions = ['A', 'B'];
    component.customOption = 'Custom!';
    makeFixtureStable();
    select.value = 'Custom!';
    select.dispatchEvent(new Event('change'));
    makeFixtureStable();
    expect(latestValueReceivedFromComponent).toEqual('');
  }));

  it('should register value entered in input', fakeAsync(() => {
    component.baseOptions = ['A'];
    makeFixtureStable();
    select.value = 'Other...';
    select.dispatchEvent(new Event('change'));
    makeFixtureStable();
    expect(latestValueReceivedFromComponent).toEqual('');
    getInputElement().value = 'custom text';
    getInputElement().dispatchEvent(new Event('input'));
    expect(latestValueReceivedFromComponent).toEqual('custom text');
  }));

  it('should fire touched handler on select blur', fakeAsync(() => {
    component.baseOptions = ['A'];
    select.dispatchEvent(new Event('blur'));
    makeFixtureStable();
    expect(hasOnTouchedBeenCalled).toBeTruthy();
  }));

  it('should fire touched handler on input blur', fakeAsync(() => {
    component.baseOptions = ['A'];
    makeFixtureStable();
    select.value = 'Other...';
    select.dispatchEvent(new Event('change'));
    makeFixtureStable();
    hasOnTouchedBeenCalled = false;
    getInputElement().dispatchEvent(new Event('blur'));
    makeFixtureStable();
    expect(hasOnTouchedBeenCalled).toBeTruthy();
  }));
});

PS. This was built with an Angular 7 CLI project in mind.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.