11

I created a customized searchable dropdown. For this angular component, it implemented ControlValueAccessor and also got NG_VALUE_ACCESSOR, ControlContainer provider. Hence, this component could be part of parent form.

For testing, I already mocked the provider for ControlContainer. I had also searched how to mock NG_VALUE_ACCESSOR online, but not finding anything could work.

searchable-dropdown.component.ts:

import {
  Component,
  OnInit,
  ViewChild,
  HostListener,
  ElementRef,
  Input,
  forwardRef,
  SkipSelf,
  OnChanges,
  SimpleChanges
} from '@angular/core';
import { ControlContainer, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

import { controlContainerFactory } from './helper';

@Component({
  selector: 'app-searchable-dropdown',
  templateUrl: './searchable-dropdown.component.html',
  styleUrls: ['./searchable-dropdown.component.css'],
  providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }],
  viewProviders: [
    { provide: ControlContainer, useFactory: controlContainerFactory, deps: [[new SkipSelf(), ControlContainer]] }
  ]
})
export class SearchableDropdownComponent implements OnInit, ControlValueAccessor, OnChanges {
  @Input() formControlName: string;
  @Input() label: string;
  @Input() placeholder: string;
  @Input() options: string[];

  SearchText = '';
  filterOptions: string[];
  propagateChange: any;
  propagateTouch: any;

  dropDownDirection = 'down';
  preventDropdown = false;
  canShowDropdown = false;
  @ViewChild('container') container: ElementRef;
  @ViewChild('input') input: ElementRef;

  constructor() {}

  get searchText(): string {
    return this.SearchText;
  }

  set searchText(value: string) {
    this.SearchText = value;
    this.propagateChange(value);
  }

  ngOnInit() {
    this.filterOptions = this.options;
  }

  ngOnChanges(changes: SimpleChanges): void {
    const { options } = changes;
    if (options) {
      this.options = options.currentValue;
      this.filterOptions = this.options;
    }
  }

  writeValue(obj: any): void {
    if (obj) {
      this.searchText = obj;
      this.filterOptions = this.options;
      this.preventDropdown = true;
      setTimeout(() => {
        this.preventDropdown = false;
      }, 0);
    }
  }

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

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

  @HostListener('document:keydown.escape', ['$event']) hideDropdown(): void {
    this.canShowDropdown = false;
  }

  @HostListener('document:click', ['$event.target']) onClick(targetElement: HTMLElement): void {
    if (this.input.nativeElement.contains(targetElement)) {
      this.showDropdown();
    } else {
      this.hideDropdown();
    }
  }

  @HostListener('window:scroll', []) onScroll(): void {
    if (window.innerHeight < this.container.nativeElement.getBoundingClientRect().top + 280) {
      this.dropDownDirection = 'up';
    } else {
      this.dropDownDirection = 'down';
    }
  }

  showDropdown() {
    this.canShowDropdown = !this.preventDropdown;
  }

  onKeyDown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      const result = this.filter(this.options, this.searchText);
      this.searchText = result.length ? result[0] : '';
      this.filterOptions = this.options;
      this.canShowDropdown = false;
    }
  }

  onSearch(value: string): void {
    this.searchText = value;
    this.filterOptions = this.filter(this.options, value);
  }

  filter = (array: string[], text: string) => {
    return array.filter(option =>
      option.match(
        new RegExp(
          `.*${text
            .replace(/[\W\s]/gi, '')
            .split('')
            .join('.*')}.*`,
          'i'
        )
      )
    );
  }

  pickOption(option: string) {
    this.searchText = option;
    this.filterOptions = this.options;
    setTimeout(() => {
      this.hideDropdown();
    }, 0);
  }
}

searchable-dropdown.component.html:

<div class="search">
  <label>
    {{ label }}:
    <div #container>
      <div class="input">
        <input
          #input
          type="text"
          [placeholder]="placeholder"
          [value]="searchText"
          [formControlName]="formControlName"
          (focus)="showDropdown()"
          (keydown)="onKeyDown($event)"
          (ngModelChange)="onSearch($event)"
        />
        <i
          class="anticon"
          [ngClass]="canShowDropdown ? 'anticon-up' : 'anticon-down'"
          (click)="canShowDropdown = !canShowDropdown"
        ></i>
      </div>
      <ul *ngIf="canShowDropdown" [ngClass]="dropDownDirection">
        <li *ngFor="let option of filterOptions" (click)="pickOption(option)">
          {{ option }}
        </li>
      </ul>
    </div>
  </label>
</div>

searchable-dropdown.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA, forwardRef } from '@angular/core';
import {
  FormsModule,
  ReactiveFormsModule,
  ControlContainer,
  FormGroupDirective,
  FormGroup,
  FormControl,
  NG_VALUE_ACCESSOR
} from '@angular/forms';
// import { By } from '@angular/platform-browser';

import { SearchableDropdownComponent } from './searchable-dropdown.component';

fdescribe('SearchableDropdownComponent', () => {
  let component: SearchableDropdownComponent;
  let fixture: ComponentFixture<SearchableDropdownComponent>;
  let element: HTMLElement;

  const formGroup = new FormGroup({ test: new FormControl('') });
  const formGroupDirective = new FormGroupDirective([], []);
  formGroupDirective.form = formGroup;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule, ReactiveFormsModule],
      declarations: [SearchableDropdownComponent],
      providers: [
        {
          provide: ControlContainer,
          useValue: formGroupDirective
        }
        // ,
        // { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }
      ],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(SearchableDropdownComponent);
    component = fixture.componentInstance;
    component.formControlName = 'test';
    fixture.detectChanges();

    element = fixture.debugElement.nativeElement;
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('label innerText should match', () => {
    component.label = 'test label name';
    fixture.detectChanges();
    let label = element.querySelector('label');
    expect(label.innerText).toContain('test label name');

    component.label = 'new test label name';
    fixture.detectChanges();
    label = element.querySelector('label');
    expect(label.innerText).toContain('new test label name');
  });

  it('input placeholder should match', () => {
    component.placeholder = 'test placeholder';
    fixture.detectChanges();
    let input = element.querySelector('input');
    expect(input.getAttribute('placeholder')).toBe('test placeholder');

    component.placeholder = 'new test placeholder';
    fixture.detectChanges();
    input = element.querySelector('input');
    expect(input.getAttribute('placeholder')).toBe('new test placeholder');
  });

  it('dropdown should match with options', () => {
    component.options = ['1', '2', '3'];
    component.canShowDropdown = true;
    component.ngOnInit();
    fixture.detectChanges();
    let ul = element.querySelector('ul');
    expect(ul.children.length).toBe(3);

    component.options = ['1', '2'];
    // component.onSearch('');
    fixture.detectChanges();
    ul = element.querySelector('ul');
    console.log(ul.children);
  });

  it('input change should trigger back to form control', () => {
    // component.searchText = 'new search';
    console.log(formGroup.value);
  });
});

What is the right way to test for component provided with NG_VALUE_ACCESSOR?

How should I mock the provider for NG_VALUE_ACCESSOR? In this way, when component's searchText changed, it will trigger change on provided mocked ControlContainer?

2
  • 1
    For me, my test never covered the line { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => SearchableDropdownComponent) }. Were you able to mock the provider? I believe that's my solution too Commented Apr 27, 2020 at 11:07
  • I used schemas: [NO_ERRORS_SCHEMA] eventually, and mock like typescripts formGroup = new FormGroup({ test: new FormControl('') }); formGroupDirective = new FormGroupDirective([], []); formGroupDirective.form = formGroup; ... providers: [ { provide: ControlContainer, useValue: formGroupDirective } ] Commented May 7, 2020 at 2:20

1 Answer 1

5

For cover the forwardRef function just has to call manually the injector get like this:

beforeEach(() => {
    fixture = TestBed.createComponent(MyComponent);
    component = fixture.componentInstance;
    fixture.debugElement.injector.get(NG_VALUE_ACCESSOR);
    fixture.detectChanges();
});
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.