2
\$\begingroup\$

I wrote an Angular 4 app and a part of it was a multiselect component. The multiselect component acts like a basic HTML select but gives you the ability to select more than one option, typeahead, and a more usable interface for the user.

You can see the working demo here on plunker.

The component works but it is slow, 'glitchy', and difficult to use. The entire app was a solo sprint I coded alone so its far from perfect but I'm going back now to make improvements.

What the MultiSelect Component Does

It works by taking an observable of options as input from a parent component, building the HTML multiselect interface with those options, and then tracking changes the user makes and emitting an event when a change is made. Using it allows a form component to easily include it, pass it options and then subscribe to changes.

The files to review on plunker are:

  • src/multiselect.model.ts
  • src/multiselect.component.ts
  • src/multiselect.template.html

The rest of the app on Plunker was created simply to demo the multiselect. The pertanent files are below as well.

Feedback I'm Looking For

The current implementation is slow, any feedback on how to manage, sort, or organize the choices more efficiently? The click events that fire when selecting and deselecting an item are very glitchy (sometimes the click works, sometimes it doesn't) any feedback on how to better attach or handle them? And ofcourse general code style, naming, understand-ability.

multiselect.component.ts

import { Component, ViewChild, Output, Input, EventEmitter, HostListener } from '@angular/core';
import { FormGroup, FormControl, FormBuilder, Validators } from '@angular/forms';
import { Observable } from 'rxjs/Observable';
import 'rxjs/Rx';
import { MultiSelect } from './multiselect.model';

@Component({
    selector: 'multiselect',
    templateUrl: 'src/multiselect.template.html'
})
export class MultiSelectComponent {

    // Parent comp. passes in observable of
    // selectable data
    @Input() options: Observable<MultiSelect[]>;
    @Input() placeholder: string;

    // When the data is chosen, we pass back the
    // newly selected values
    @Output() onOptionsUpdate = new EventEmitter<MultiSelect[]>();


    @ViewChild('input') input;
    public search: FormControl = new FormControl();
    private model: MultiSelect[] = new Array<MultiSelect>();
    public previewItems: MultiSelect[] = new Array<MultiSelect>();
    public selected: MultiSelect[] = new Array<MultiSelect>();
    public hidePreviewItems: boolean = true;
    public hasPreviewItems: boolean = false;

    constructor() {

        this.search.valueChanges
            .debounceTime(400)
            .distinctUntilChanged()
            .subscribe((value: string) => {
                if (value == '')
                    this.previewItems = [];
                else
                    this.previewItems = this.getMatching(value);
            });

    }

    ngOnInit() {
        this.options.subscribe((selections: MultiSelect[]) => this.model = selections);
    }

    onItemDeselect(selected) : void {

        // Remove from selected list
        for (let i = 0; i < this.selected.length; i++) {
            if (this.selected[i].value == selected.value) {
                this.selected.splice(i, 1);
                break;
            }
        }

        // Mark not selected in model
        for (let i = 0; i < this.model.length; i++) {
            if (this.model[i].value == selected.value) {
                this.model[i].selected = false;
                break;
            }
        }

        this.onOptionsUpdate.emit(this.selected);

    }

    onItemSelect(selected) : void {
        this.selected.push(selected);
        this.previewItems = [];
        this.input.nativeElement.value = '';

        for (let i = 0; i < this.model.length; i++) {
            if (this.model[i].value == selected.value) {
                this.model[i].selected = true;
                break;
            }
        }

        this.hidePreviewItems = true;
        this.onOptionsUpdate.emit(this.selected);

    }

    onInputFocus() : void {
        this.hidePreviewItems = false;
    }

    onInputBlur() : void {
        let me = this;
        setTimeout(() => {
            if (!me.hidePreviewItems) me.hidePreviewItems = true;
        }, 120);
    }

    getMatching(keyword: string) : MultiSelect[] {
        let results : MultiSelect[] = [];
        keyword = this.trim(keyword).toLowerCase();
        for(let i = 0; i < this.model.length; i++) {
            if (!this.model[i].selected && this.model[i].display.toString().toLowerCase().indexOf(keyword) != -1) {
                if(!this.exists(results, this.model[i])) {
                    results.push(this.model[i]);
                }
            }
        }

        return results;
    }

    exists(objList : MultiSelect[], obj : MultiSelect) : boolean {
        for(let i = 0; i < objList.length; i++) {
            if (objList[i].value === obj.value) return true;
        }
        return false;
    }

    trim(s: string) : string {
        let l = 0, r = s.length-1;
        while(l < s.length && s[l] == ' ') l++;
        while(r > l && s[r] == ' ') r--;

        return s.substring(l, r + 1);
    }

}

multiselect.template.html

<div class="en-multiselect-container">

    <div class="en-ms-input">

        <div>
            <input type="text" #input [attr.placeholder]="placeholder" [formControl]="search" (focus)="onInputFocus()" (blur)="onInputBlur()"/>
        </div>
        <div class="en-ms-preview" [class.en-ms-preview-hidden]="hidePreviewItems">
            <div *ngFor="let prev of previewItems" (click)="onItemSelect(prev)">
                <span class="icon-add"></span>
                <span>{{ prev.display }}</span>
            </div>
        </div>

    </div>

    <div class="en-ms-output">
        <div [hidden]="selected.length > 0" class="en-ms-default-all">
            <span>All Items</span>
        </div>
        <div *ngFor="let sel of selected" (click)="onItemDeselect(sel)">
            <span>{{ sel.display }}</span>
        </div>
    </div>

</div>

multiselect.model.ts

export class MultiSelect {

    constructor(_value: any, _display: any, _selected?: boolean) {
        this.value = _value;
        this.display = _display;
        this.selected = _selected || false;
    }

    value: string | number;
    display: string | number;
    selected: boolean;

}
\$\endgroup\$
  • \$\begingroup\$ search.debounceTime(400).distinctUntilChanged() may effect in the delays you mentioned. Try changing to this.search.valueChanges.subscribe((value: string) => {. I am not sure your question is in the right SE section though... \$\endgroup\$ – Igor Soloydenko Apr 5 '17 at 21:08
  • \$\begingroup\$ actually, your plunker works for me. so, not sure what is wrong with it... \$\endgroup\$ – Igor Soloydenko Apr 5 '17 at 21:13
  • \$\begingroup\$ @IgorSoloydenko Yes I noticed the same thing...Usually the component is running in a full size (and rather large) angular application. The size of the whole app might be a part of the problem because I agree it runs well on Plunker. \$\endgroup\$ – rayepps Apr 5 '17 at 21:16
1
\$\begingroup\$

Slightly cleaned up version

I took your code and made it more terse.

a) There are a couple fewer fields now. Instead, they are get accessors that calculate the same thing on the fly based on the minimal set of fields that store the state. As a rule of thumb, the fewer fields we have in our class, the easier it is to maintain correctness.

b) Similarly, a couple of functions were erased/replaced with TS/JS alternatives that are available out of the box.

This code is probably NOT fixing the "slowliness"/"glitchy" issues you mentioned, however it should be now a bit easier to debug the component since there are less moving parts.

@Component({
    selector: 'multiselect',
    templateUrl: 'src/multiselect.template.html'
})
export class MultiSelectComponent {
    @Input() options: Observable<MultiSelect[]>;
    @Input() placeholder: string;

    public get selected(): MultiSelect[] {
        return (!this.model || this.model.length <= 0) ? [] : this.model.filter(item => item.selected);
    }
    @Output() selectedChange = new EventEmitter<MultiSelect[]>();

    @ViewChild('input') input;

    private model: MultiSelect[] = [];
    public search: FormControl = new FormControl();
    public hidePreviewItems = true;
    public previewItems: MultiSelect[] = [];
    public get hasPreviewItems(): boolean {
        return this.previewItems && this.previewItems.length > 0;
    }

    constructor() {
        this.search.valueChanges
            .debounceTime(400)
            .distinctUntilChanged()
            .subscribe((value: string) => {
                this.previewItems = value == '' ? [] : this.getMatching(value);
            });
    }

    ngOnInit(): void {
        this.options.subscribe((selections: MultiSelect[]) => this.model = selections);
    }

    onItemSelect(selectedMultiSelect: MultiSelect) : void {
        this.hidePreviewItems = true;
        this.previewItems = [];
        this.input.nativeElement.value = '';

        selectedMultiSelect.selected = true;
        this.selectedChange.emit(this.selected);
    }

    onItemDeselect(selectedMultiSelect: MultiSelect) : void {
        selectedMultiSelect.selected = false;
        this.selectedChange.emit(this.selected);
    }

    onInputFocus() : void {
        this.hidePreviewItems = false;
    }

    onInputBlur() : void {
        const that = this;
        setTimeout(() => that.hidePreviewItems = true, 120);
    }

    getMatching(keyword: string) : MultiSelect[] {
        const normalize = (textToNormalize: string) => textToNormalize.trim().toLowerCase();
        const normalizedKeyword = normalize(keyword);
        return this.model.filter(multiSelectItem => {
            return !multiSelectItem.selected && normalize(multiSelectItem.display.toString()).indexOf(keyword) >= 0;
        });
    }
}
\$\endgroup\$

Your Answer

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

Not the answer you're looking for? Browse other questions tagged or ask your own question.