Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated: native rxjs debounce for typeahead component #91

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 95 additions & 108 deletions components/typeahead/typeahead.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
} from 'angular2/core';
import {NgModel}from 'angular2/common';

import {Observable, Subject} from 'rxjs/Rx';

// https://github.com/angular/angular/blob/master/modules/angular2/src/core/forms/directives/shared.ts
function setProperty(renderer:Renderer, elementRef:ElementRef, propName:string, propValue:any) {
renderer.setElementProperty(elementRef.nativeElement, propName, propValue);
}

import {Ng2BootstrapConfig, Ng2BootstrapTheme} from '../ng2-bootstrap-config';
import {positionService} from '../position';
import {TypeaheadUtils} from './typeahead-utils';
import {TypeaheadContainer} from './typeahead-container.component';
import {TypeaheadOptions} from './typeahead-options.class';
Expand Down Expand Up @@ -47,8 +47,8 @@ export class Typeahead implements OnInit {

public container:TypeaheadContainer;

private debouncer:Function;
private _matches:Array<any> = [];
private keyUpEventEmitter:EventEmitter<any> = new EventEmitter();
private _matches:Array<string>;
private placement:string = 'bottom-left';
private popup:Promise<ComponentRef>;

Expand All @@ -58,82 +58,96 @@ export class Typeahead implements OnInit {
private loader:DynamicComponentLoader) {
}

public get matches() {
return this._matches;
}
private asyncActions() {
let s = new Subject();
s.subscribe(() => {
this.typeahead
.subscribe(
(matches:string[]) => {
this._matches = matches.slice(0, this.typeaheadOptionsLimit);
this.finalizeAsyncCall();
},
(err:any) => {
console.error(err);
}
);
});

private debounce(func:Function, wait:number):Function {
let timeout:any;
let args:Array<any>;
let timestamp:number;
let waitOriginal:number = wait;

return function () {
// save details of latest call
args = [].slice.call(arguments, 0);
timestamp = Date.now();

// this trick is about implementing of 'typeaheadWaitMs'
// in this case we have adaptive 'wait' parameter
// we should use standard 'wait'('waitOriginal') in case of
// popup is opened, otherwise - 'typeaheadWaitMs' parameter
wait = this.container ? waitOriginal : this.typeaheadWaitMs;

// this is where the magic happens
let later = function () {

// how long ago was the last call
let last = Date.now() - timestamp;

// if the latest call was less that the wait period ago
// then we reset the timeout to wait for the difference
if (last < wait) {
timeout = setTimeout(later, wait - last);
// or if not we can null out the timer and run the latest
} else {
timeout = null;
func.apply(this, args);
this.keyUpEventEmitter
.debounceTime(100)
.subscribe(
() => {
s.next(null);
}
};
);
}

// we only need to set the timer now if one isn't already running
if (!timeout) {
timeout = setTimeout(later, wait);
private syncActions() {
let syncSubject = new Subject();
syncSubject.subscribe(
(value:string) => {
let normalizedQuery = this.normalizeQuery(value);

Observable.fromArray(this.typeahead)
.map(option => this.prepareOption(value, option))
.filter((option:any) => {
return option && option.toLowerCase && this.testMatch(option.toLowerCase(), normalizedQuery);
})
.toArray()
.subscribe(
(matches:string[]) => {
this._matches = matches.slice(0, this.typeaheadOptionsLimit);
this.finalizeAsyncCall();
},
(err:any) => {
console.error(err);
}
);
}
};
}
);

private processMatches() {
this._matches = [];
if (this.cd.model.toString().length >= this.typeaheadMinLength) {
// If singleWords, break model here to not be doing extra work on each iteration
let normalizedQuery = (this.typeaheadLatinize ? TypeaheadUtils.latinize(this.cd.model) : this.cd.model).toString().toLowerCase();
normalizedQuery = this.typeaheadSingleWords ? TypeaheadUtils.tokenize(normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters) : normalizedQuery;
for (let i = 0; i < this.typeahead.length; i++) {
let match:string;
this.keyUpEventEmitter
.subscribe(syncSubject);
}

if (typeof this.typeahead[i] === 'object' &&
this.typeahead[i][this.typeaheadOptionField]) {
match = this.typeaheadLatinize ? TypeaheadUtils.latinize(this.typeahead[i][this.typeaheadOptionField].toString()) : this.typeahead[i][this.typeaheadOptionField].toString();
}
private prepareOption(value:string, option:any):any {
if (value.length < this.typeaheadMinLength) {
return Observable.empty();
}

if (typeof this.typeahead[i] === 'string') {
match = this.typeaheadLatinize ? TypeaheadUtils.latinize(this.typeahead[i].toString()) : this.typeahead[i].toString();
}
let match:any;

if (!match) {
console.log('Invalid match type', typeof this.typeahead[i], this.typeaheadOptionField);
continue;
}
if (typeof option === 'object' &&
option[this.typeaheadOptionField]) {
match = this.typeaheadLatinize ?
TypeaheadUtils.latinize(option[this.typeaheadOptionField].toString()) :
option[this.typeaheadOptionField].toString();
}

if (this.testMatch(match.toLowerCase(), normalizedQuery)) {
this._matches.push(this.typeahead[i]);
if (this._matches.length > this.typeaheadOptionsLimit - 1) {
break;
}
}
}
if (typeof option === 'string') {
match = this.typeaheadLatinize ?
TypeaheadUtils.latinize(option.toString()) :
option.toString();
}

return match;
}

private normalizeQuery(value:string):any {
// If singleWords, break model here to not be doing extra work on each iteration
let normalizedQuery:any =
(this.typeaheadLatinize ? TypeaheadUtils.latinize(value) : value)
.toString()
.toLowerCase();
normalizedQuery = this.typeaheadSingleWords ?
TypeaheadUtils.tokenize(normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters) :
normalizedQuery;

return normalizedQuery;
}

public get matches() {
return this._matches;
}

private testMatch(match:string, test:any) {
Expand All @@ -153,8 +167,8 @@ export class Typeahead implements OnInit {
}

private finalizeAsyncCall() {
this.typeaheadLoading.next(false);
this.typeaheadNoResults.next(this.cd.model.toString().length >=
this.typeaheadLoading.emit(false);
this.typeaheadNoResults.emit(this.cd.model.toString().length >=
this.typeaheadMinLength && this.matches.length <= 0);

if (this.cd.model.toString().length <= 0 || this._matches.length <= 0) {
Expand All @@ -180,44 +194,25 @@ export class Typeahead implements OnInit {
this.typeaheadWaitMs = this.typeaheadWaitMs || 0;

// async should be false in case of array
if (this.typeaheadAsync === null && typeof this.typeahead !== 'function') {
if (this.typeaheadAsync === null && !(this.typeahead instanceof Observable)) {
this.typeaheadAsync = false;
}

// async should be true for any case of function
if (typeof this.typeahead === 'function') {
if (this.typeahead instanceof Observable) {
this.typeaheadAsync = true;
}

if (this.typeaheadAsync === true) {
this.debouncer = this.debounce(() => {
if (typeof this.typeahead === 'function') {
this.typeahead().then((matches:any[]) => {
this._matches = [];
if (this.cd.model.toString().length >= this.typeaheadMinLength) {
for (let i = 0; i < matches.length; i++) {
this._matches.push(matches[i]);
if (this._matches.length > this.typeaheadOptionsLimit - 1) {
break;
}
}
}

this.finalizeAsyncCall();
});
}
this.asyncActions();
}

// source is array
if (typeof this.typeahead === 'object' && this.typeahead.length) {
this.processMatches();
this.finalizeAsyncCall();
}
}, 100);
if (this.typeaheadAsync === false) {
this.syncActions();
}
}

@HostListener('keyup', ['$event'])
onChange(e:KeyboardEvent) {
onChange(e:any) {
if (this.container) {
// esc
if (e.keyCode === 27) {
Expand All @@ -244,16 +239,8 @@ export class Typeahead implements OnInit {
}
}

this.typeaheadLoading.next(true);

if (this.typeaheadAsync === true) {
this.debouncer();
}

if (this.typeaheadAsync === false) {
this.processMatches();
this.finalizeAsyncCall();
}
this.typeaheadLoading.emit(true);
this.keyUpEventEmitter.emit(e.target.value);
}

public changeModel(value:any) {
Expand Down
2 changes: 1 addition & 1 deletion demo/components/typeahead/typeahead-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h4>Static arrays</h4>
<h4>Asynchronous results</h4>
<pre>Model: {{asyncSelected | json}}</pre>
<input [(ngModel)]="asyncSelected"
[typeahead]="getAsyncData(getContext())"
[typeahead]="dataSource"
(typeaheadLoading)="changeTypeaheadLoading($event)"
(typeaheadNoResults)="changeTypeaheadNoResults($event)"
(typeaheadOnSelect)="typeaheadOnSelect($event)"
Expand Down
34 changes: 9 additions & 25 deletions demo/components/typeahead/typeahead-demo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Component} from 'angular2/core';
import {CORE_DIRECTIVES, FORM_DIRECTIVES} from 'angular2/common';
import {Observable} from 'rxjs/Rx';

import {TYPEAHEAD_DIRECTIVES} from '../../../ng2-bootstrap';

Expand All @@ -13,6 +14,7 @@ let template = require('./typeahead-demo.html');
})
export class TypeaheadDemo {
private selected:string = '';
private dataSource:Observable<any>;
private asyncSelected:string = '';
private typeaheadLoading:boolean = false;
private typeaheadNoResults:boolean = false;
Expand Down Expand Up @@ -50,32 +52,14 @@ export class TypeaheadDemo {
{id: 49, name: 'West Virginia'}, {id: 50, name: 'Wisconsin'},
{id: 51, name: 'Wyoming'}];

private getContext() {
return this;
}

private _cache:any;
private _prevContext:any;

private getAsyncData(context:any):Function {
if (this._prevContext === context) {
return this._cache;
}
constructor() {
this.dataSource = Observable.create((observer:any) => {
let query = new RegExp(this.asyncSelected, 'ig');

this._prevContext = context;
let f:Function = function ():Promise<string[]> {
let p:Promise<string[]> = new Promise((resolve:Function) => {
setTimeout(() => {
let query = new RegExp(context.asyncSelected, 'ig');
return resolve(context.states.filter((state:any) => {
return query.test(state);
}));
}, 200);
});
return p;
};
this._cache = f;
return this._cache;
observer.next(this.states.filter((state:any) => {

This comment was marked as off-topic.

This comment was marked as off-topic.

return query.test(state);
}));
});
}

private changeTypeaheadLoading(e:boolean) {
Expand Down