Innqube/ng2-iq-select2

View on GitHub
projects/ng2-iq-select2/src/lib/iq-select2/iq-select2.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {AfterViewInit, Component, EventEmitter, forwardRef, Input, Output, TemplateRef, ViewChild} from '@angular/core';
import {IqSelect2Item} from './iq-select2-item';
import {IqSelect2ResultsComponent} from '../iq-select2-results/iq-select2-results.component';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from '@angular/forms';
import {Messages} from './messages';
import {Observable, of} from 'rxjs';
import {debounceTime, distinctUntilChanged, filter, map, mergeMap, switchMap, tap} from 'rxjs/operators';

const KEY_CODE_DOWN_ARROW = 40;
const KEY_CODE_UP_ARROW = 38;
const KEY_CODE_ENTER = 13;
const KEY_CODE_TAB = 9;
const KEY_CODE_DELETE = 8;
const VALUE_ACCESSOR = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => IqSelect2Component),
    multi: true
};
const noop = () => {
};

@Component({
    selector: 'iq-select2',
    templateUrl: './iq-select2.component.html',
    styleUrls: ['./iq-select2.component.css'],
    providers: [VALUE_ACCESSOR]
})
export class IqSelect2Component implements AfterViewInit, ControlValueAccessor {

    MORE_RESULTS_MSG = 'Showing ' + Messages.PARTIAL_COUNT_VAR + ' of ' + Messages.TOTAL_COUNT_VAR + ' results. Refine your search to show more results.';
    NO_RESULTS_MSG = 'No results available';

    @Input() dataSourceProvider: (term: string, selected?: any[]) => Observable<any[]>;
    @Input() selectedProvider: (ids: string[]) => Observable<any[]>;
    @Input() iqSelect2ItemAdapter: (entity: any) => IqSelect2Item;
    @Input() referenceMode: 'id' | 'entity' = 'id';
    @Input() multiple = false;
    @Input() searchDelay = 250;
    @Input() css: string;
    @Input() placeholder = '';
    @Input() minimumInputLength = 2;
    @Input() disabled = false;
    @Input() searchIcon;
    @Input() deleteIcon;
    @Input() messages: Messages = {
        moreResultsAvailableMsg: this.MORE_RESULTS_MSG,
        noResultsAvailableMsg: this.NO_RESULTS_MSG
    };
    @Input() resultsCount;
    @Input() clientMode = false;
    @Input() badgeColor = 'info';
    @Output() onSelect: EventEmitter<IqSelect2Item> = new EventEmitter<IqSelect2Item>();
    @Output() onRemove: EventEmitter<IqSelect2Item> = new EventEmitter<IqSelect2Item>();
    @ViewChild('termInput') private termInput;
    @ViewChild('results') results: IqSelect2ResultsComponent;
    templateRef: TemplateRef<any>;
    term = new FormControl();
    resultsVisible = false;
    listData: IqSelect2Item[];
    fullDataList: IqSelect2Item[];
    selectedItems: IqSelect2Item[] = [];
    searchFocused = false;
    private placeholderSelected = '';
    onTouchedCallback: () => void = noop;
    onChangeCallback: (_: any) => void = noop;

    constructor() {
    }

    ngAfterViewInit() {
        this.subscribeToChangesAndLoadDataFromObservable();
    }

    writeValue(selectedValues: any): void {
        if (selectedValues) {
            if (this.referenceMode === 'id') {
                this.populateItemsFromIds(selectedValues);
            } else {
                this.populateItemsFromEntities(selectedValues);
            }
        } else {
            this.placeholderSelected = '';
            this.selectedItems = [];
        }
    }

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

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

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

    private subscribeToChangesAndLoadDataFromObservable() {
        const observable = this.term.valueChanges.pipe(
            debounceTime(this.searchDelay),
            distinctUntilChanged()
        );
        this.subscribeToResults(observable);
    }

    private subscribeToResults(observable: Observable<string>): void {
        observable.pipe(
            tap(() => this.resultsVisible = false),
            filter((term) => term.length >= this.minimumInputLength),
            switchMap(term => this.loadDataFromObservable(term)),
            map(items => items.filter(item => !(this.multiple && this.alreadySelected(item)))),
            tap(() => this.resultsVisible = this.searchFocused)
        ).subscribe((items) => this.listData = items);
    }

    private loadDataFromObservable(term: string): Observable<IqSelect2Item[]> {
        return this.clientMode ? this.fetchAndfilterLocalData(term) : this.fetchData(term);
    }

    private fetchAndfilterLocalData(term: string): Observable<IqSelect2Item[]> {
        if (!this.fullDataList) {
            return this.fetchData('').pipe(
                mergeMap((items) => {
                    this.fullDataList = items;
                    return this.filterLocalData(term);
                })
            );
        } else {
            return this.filterLocalData(term);
        }
    }

    private filterLocalData(term: string): Observable<IqSelect2Item[]> {
        return of(this.fullDataList.filter((item) => this.containsText(item, term)));
    }

    private containsText(item, term: string) {
        return item.text.toUpperCase().indexOf(term.toUpperCase()) !== -1;
    }

    private fetchData(term: string): Observable<IqSelect2Item[]> {
        return this
            .dataSourceProvider(term, this.buildValue())
            .pipe(map((items: any[]) => this.adaptItems(items)));
    }

    private adaptItems(items: any[]): IqSelect2Item[] {
        const convertedItems = [];
        items.map((item) => this.iqSelect2ItemAdapter(item))
            .forEach((iqSelect2Item) => convertedItems.push(iqSelect2Item));
        return convertedItems;
    }

    private populateItemsFromEntities(selectedValues: any) {
        if (this.multiple) {
            this.handleMultipleWithEntities(selectedValues);
        } else {
            const iqSelect2Item = this.iqSelect2ItemAdapter(selectedValues);
            this.selectedItems = [iqSelect2Item];
            this.placeholderSelected = iqSelect2Item.text;
        }
    }

    private handleMultipleWithEntities(selectedValues: any) {
        this.selectedItems = [];
        selectedValues.forEach((entity) => {
            const item = this.iqSelect2ItemAdapter(entity);
            const ids = this.getSelectedIds();

            if (ids.indexOf(item.id) === -1) {
                this.selectedItems.push(item);
            }
        });
    }

    private populateItemsFromIds(selectedValues: any) {
        if (this.multiple) {
            this.handleMultipleWithIds(selectedValues);
        } else {
            this.handleSingleWithId(selectedValues);
        }
    }

    private handleMultipleWithIds(selectedValues: any) {
        if (selectedValues !== undefined && this.selectedProvider !== undefined) {
            const uniqueIds = [];
            selectedValues.forEach((id) => {
                if (uniqueIds.indexOf(id) === -1) {
                    uniqueIds.push(id);
                }
            });

            this.selectedProvider(uniqueIds).subscribe((items: any[]) => {
                this.selectedItems = items.map(this.iqSelect2ItemAdapter);
            });
        }
    }

    private handleSingleWithId(id: any) {
        if (id !== undefined && this.selectedProvider !== undefined) {
            this.selectedProvider([id]).subscribe((items: any[]) => {
                items.forEach((item) => {
                    const iqSelect2Item = this.iqSelect2ItemAdapter(item);
                    this.selectedItems = [iqSelect2Item];
                    this.placeholderSelected = iqSelect2Item.text;
                });
            });
        }
    }

    private alreadySelected(item: IqSelect2Item): boolean {
        let result = false;
        this.selectedItems.forEach(selectedItem => {
            if (selectedItem.id === item.id) {
                result = true;
            }
        });
        return result;
    }

    onItemSelected(item: IqSelect2Item) {
        if (this.multiple) {
            this.selectedItems.push(item);
            const index = this.listData.indexOf(item, 0);
            if (index > -1) {
                this.listData.splice(index, 1);
            }
        } else {
            this.selectedItems.length = 0;
            this.selectedItems.push(item);
        }

        this.onChangeCallback(this.buildValue());
        this.term.patchValue('', {emitEvent: false});
        setTimeout(() => this.focus(), 1);
        this.resultsVisible = false;
        this.onSelect.emit(item);
        if (!this.multiple) {
            this.placeholderSelected = item.text;
        }
    }

    private getSelectedIds(): any {
        if (this.multiple) {
            const ids: string[] = [];

            this.selectedItems.forEach(item => ids.push(item.id));

            return ids;
        } else {
            return this.selectedItems.length === 0 ? null : this.selectedItems[0].id;
        }
    }

    private getEntities(): any[] {
        if (this.multiple) {
            const entities = [];

            this.selectedItems.forEach(item => {
                entities.push(item.entity);
            });

            return entities;
        } else {
            return this.selectedItems.length === 0 ? null : this.selectedItems[0].entity;
        }
    }

    removeItem(item: IqSelect2Item) {
        const index = this.selectedItems.indexOf(item, 0);

        if (index > -1) {
            this.selectedItems.splice(index, 1);
        }

        this.onChangeCallback(this.buildValue());
        this.onRemove.emit(item);
        if (!this.multiple) {
            this.placeholderSelected = '';
        }
    }

    private buildValue() {
        return 'id' === this.referenceMode ? this.getSelectedIds() : this.getEntities();
    }

    onFocus() {
        this.searchFocused = true;
    }

    onBlur() {
        this.term.patchValue('', {emitEvent: false});
        this.searchFocused = false;
        this.resultsVisible = false;
        this.onTouchedCallback();
    }

    focus() {
        if (!this.disabled) {
            this.termInput.nativeElement.focus();
            this.resultsVisible = false;
        }
        this.searchFocused = !this.disabled;
    }

    focusAndShowResults() {
        if (!this.disabled) {
            this.termInput.nativeElement.focus();
            this.subscribeToResults(of(''));
        }
        this.searchFocused = !this.disabled;
    }

    onKeyUp(ev) {
        if (this.results) {
            if (ev.keyCode === KEY_CODE_DOWN_ARROW) {
                this.results.activeNext();
            } else if (ev.keyCode === KEY_CODE_UP_ARROW) {
                this.results.activePrevious();
            } else if (ev.keyCode === KEY_CODE_ENTER) {
                this.results.selectCurrentItem();
            }
        } else {
            if (this.minimumInputLength === 0) {
                if (ev.keyCode === KEY_CODE_ENTER || ev.keyCode === KEY_CODE_DOWN_ARROW) {
                    this.focusAndShowResults();
                }
            }
        }
    }

    onKeyDown(ev) {
        if (this.results) {
            if (ev.keyCode === KEY_CODE_TAB) {
                this.results.selectCurrentItem();
            }
        }

        if (ev.keyCode === KEY_CODE_DELETE) {
            const textEntered = !this.term.value || this.term.value.length === 0;
            if (textEntered && this.selectedItems.length > 0) {
                this.removeItem(this.selectedItems[this.selectedItems.length - 1]);
            }
        }
    }

    onKeyPress(ev) {
        if (ev.keyCode === KEY_CODE_ENTER) {
            ev.preventDefault();
        }
    }

    getCss(): string {
        return 'select2-selection-container ' + (this.css === undefined ? '' : this.css);
    }

    getPlaceholder(): string {
        return this.selectedItems.length > 0 ? this.placeholderSelected : this.placeholder;
    }

    getCountMessage(): string {
        let msg = this.messages && this.messages.moreResultsAvailableMsg ? this.messages.moreResultsAvailableMsg : this.MORE_RESULTS_MSG;
        msg = msg.replace(Messages.PARTIAL_COUNT_VAR, String(this.listData.length));
        msg = msg.replace(Messages.TOTAL_COUNT_VAR, String(this.resultsCount - this.selectedItems.length));
        return msg;
    }

    getBadgeColor(): string {
        return this.multiple ? 'badge-' + this.badgeColor : '';
    }

}