diff --git a/demo/src/app/components/+typeahead/demos/index.ts b/demo/src/app/components/+typeahead/demos/index.ts index 93e6ab08b4..063dbd1dd6 100644 --- a/demo/src/app/components/+typeahead/demos/index.ts +++ b/demo/src/app/components/+typeahead/demos/index.ts @@ -23,6 +23,7 @@ import { DemoTypeaheadScrollableComponent } from './scrollable/scrollable'; import { DemotypeaheadSelectFirstItemComponent } from './selected-first-item/selected-first-item'; import { DemoTypeaheadShowOnBlurComponent } from './show-on-blur/show-on-blur'; import { DemoTypeaheadSingleWorldComponent } from './single-world/single-world'; +import { DemoTypeaheadOrderingComponent } from './ordering/ordering'; export const DEMO_COMPONENTS = [ DemoTypeaheadAdaptivePositionComponent, @@ -51,5 +52,6 @@ export const DEMO_COMPONENTS = [ DemoTypeaheadScrollableComponent, DemotypeaheadSelectFirstItemComponent, DemoTypeaheadShowOnBlurComponent, - DemoTypeaheadSingleWorldComponent + DemoTypeaheadSingleWorldComponent, + DemoTypeaheadOrderingComponent ]; diff --git a/demo/src/app/components/+typeahead/demos/ordering/ordering.html b/demo/src/app/components/+typeahead/demos/ordering/ordering.html new file mode 100644 index 0000000000..034a9c0a67 --- /dev/null +++ b/demo/src/app/components/+typeahead/demos/ordering/ordering.html @@ -0,0 +1,40 @@ +
Use typeaheadOrderBy
property to order your result by a certain field and in certain direction
Toggle animation
\n" }, + { + "name": "ariaDescribedby", + "type": "string", + "description": "aria-describedby attribute value to set on the modal window
\n" + }, + { + "name": "ariaLabelledBy", + "type": "string", + "description": "aria-labelledby attribute value to set on the modal window
\n" + }, { "name": "backdrop", "type": "boolean | \"static\"", @@ -3915,6 +3925,11 @@ export const ngdoc: any = { "type": "number", "description": "maximum length of options items list. The default value is 20
\n" }, + { + "name": "typeaheadOrderBy", + "type": "TypeaheadOrder", + "description": "Used to specify a custom order of matches. When options source is an array of objects\na field for sorting has to be set up. In case of options source is an array of string,\na field for sorting is absent. The ordering direction could be changed to ascending or descending.
\n" + }, { "name": "typeaheadPhraseDelimiters", "defaultValue": "'\"", diff --git a/src/index.ts b/src/index.ts index 0fdfafff10..c8d1ea31b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,7 +141,8 @@ export { TypeaheadContainerComponent, TypeaheadDirective, TypeaheadMatch, - TypeaheadModule + TypeaheadModule, + TypeaheadOrder } from './typeahead/index'; export { diff --git a/src/spec/typeahead.directive.spec.ts b/src/spec/typeahead.directive.spec.ts index 408b8d86de..91e400290c 100644 --- a/src/spec/typeahead.directive.spec.ts +++ b/src/spec/typeahead.directive.spec.ts @@ -8,7 +8,7 @@ import { FormsModule } from '@angular/forms'; import { of } from 'rxjs'; import { dispatchMouseEvent, dispatchTouchEvent, dispatchKeyboardEvent } from '@netbasal/spectator'; -import { TypeaheadMatch, TypeaheadDirective, TypeaheadModule } from '../typeahead'; +import { TypeaheadMatch, TypeaheadDirective, TypeaheadModule, TypeaheadOrder } from '../typeahead'; interface State { id: number; @@ -32,6 +32,15 @@ class TestTypeaheadComponent { {id: 3, name: 'Arizona', region: 'West'}, {id: 4, name: 'Arkansas', region: 'South'} ]; + statesString: string[] = [ + 'Alabama', + 'Alaska', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut' + ]; onBlurEvent(activeItem) { return undefined; } } @@ -76,6 +85,10 @@ describe('Directive: Typeahead', () => { expect(directive.typeaheadMinLength).toBe(1); }); + it('should set a default value for typeaheadOrderBy', () => { + expect(directive.typeaheadOrderBy).toBeUndefined(); + }); + it('should get a value for typeaheadMinLength if user added it', () => { directive.typeaheadMinLength = 4; @@ -117,9 +130,7 @@ describe('Directive: Typeahead', () => { expect(typeaheadContainer).toBeNull(); }); - }); - describe('onInput', () => { it('should be called show method', fakeAsync(() => { inputElement.value = 'a'; dispatchTouchEvent(inputElement, 'input'); @@ -205,7 +216,7 @@ describe('Directive: Typeahead', () => { }); }); - describe('onChange grouped', () => { + describe('if typeaheadGroupField is not null', () => { beforeEach( fakeAsync(() => { inputElement.value = 'Ala'; @@ -271,37 +282,6 @@ describe('Directive: Typeahead', () => { }); - describe('if typeaheadHideResultsOnBlur', () => { - beforeEach( - fakeAsync(() => { - inputElement.value = 'Ala'; - dispatchTouchEvent(inputElement, 'input'); - directive.typeaheadHideResultsOnBlur = false; - fixture.detectChanges(); - tick(100); - }) - ); - - it('equal true should be opened', - fakeAsync(() => { - dispatchMouseEvent(document, 'click'); - tick(); - - expect(fixture.nativeElement.querySelector('.dropdown').classList).toContain('open'); - }) - ); - - it('equal false should be closed', - fakeAsync(() => { - directive.typeaheadHideResultsOnBlur = true; - dispatchMouseEvent(document, 'click'); - tick(); - - expect(fixture.debugElement.query(By.css('typeahead-container'))).toBeNull(); - }) - ); - }); - describe('onChange', () => { beforeEach( fakeAsync(() => { @@ -486,7 +466,7 @@ describe('Directive: Typeahead', () => { })); }); - describe('if typeaheadHideResultsOnBlur', () => { + describe('if typeaheadHideResultsOnBlur is not null', () => { beforeEach( fakeAsync(() => { inputElement.value = 'Ala'; @@ -516,4 +496,190 @@ describe('Directive: Typeahead', () => { }) ); }); + + describe('if typeaheadOrderBy is not null', () => { + describe('and source of options is an array of string should result in 2 items, when "Ala" is entered', + () => { + beforeEach( + fakeAsync(() => { + directive.typeahead = component.statesString; + directive.typeaheadOptionField = null; + inputElement.value = 'Ala'; + fixture.detectChanges(); + tick(100); + }) + ); + + it('and order direction "asc". 1st - Alabama, 2sd - Alaska', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'asc'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item).toBe('Alabama'); + expect(directive.matches[1].item).toBe('Alaska'); + }) + ); + + it( + 'and order direction "desc". 1st - Alaska, 2sd - Alabama', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'desc'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item).toBe('Alaska'); + expect(directive.matches[1].item).toBe('Alabama'); + }) + ); + + it('and typeaheadOrderBy is empty object, shouldn\'t break the app', + fakeAsync(() => { + // tslint:disable-next-line:no-object-literal-type-assertion + directive.typeaheadOrderBy = {} as TypeaheadOrder; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + }) + ); + + it('and order direction is not equal "asc" or "desc", shouldn\'t break the app', + fakeAsync(() => { + // tslint:disable-next-line + directive.typeaheadOrderBy = {direction: 'test' as 'asc'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + }) + ); + + it('and order field is setup, it shouldn\'t affect the result', + fakeAsync(() => { + // tslint:disable-next-line + directive.typeaheadOrderBy = {direction: 'asc', field: 'name'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + }) + ); + }); + + describe('and source of options is an array of objects', () => { + describe('should result in 2 items, when "Ala" is entered', () => { + beforeEach( + fakeAsync(() => { + inputElement.value = 'Ala'; + fixture.detectChanges(); + tick(100); + }) + ); + + it('and order direction "asc", order field - "name". 1st - Alabama, 2sd - Alaska', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'asc', field: 'name'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item.name).toBe('Alabama'); + expect(directive.matches[1].item.name).toBe('Alaska'); + }) + ); + + it('and order direction "desc", order field - "name". 1st - Alaska, 2sd - Alabama', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'desc', field: 'name'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item.name).toBe('Alaska'); + expect(directive.matches[1].item.name).toBe('Alabama'); + }) + ); + + it( + // tslint:disable-next-line:max-line-length + 'and order direction "desc", order field is null. 1st - Alabama, 2sd - Alaska. Lack of the field doesn\'t affect the result', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'desc', field: null}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item.name).toBe('Alabama'); + expect(directive.matches[1].item.name).toBe('Alaska'); + }) + ); + + it( + // tslint:disable-next-line:max-line-length + 'and order direction "desc", order field is "test". 1st - Alabama, 2sd - Alaska. The wrong field doesn\'t affect the result', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'desc', field: 'test'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(2); + expect(directive.matches[0].item.name).toBe('Alabama'); + expect(directive.matches[1].item.name).toBe('Alaska'); + }) + ); + }); + + describe('should result in 4 items, when "a" is entered', () => { + beforeEach( + fakeAsync(() => { + inputElement.value = 'a'; + fixture.detectChanges(); + tick(100); + }) + ); + + it('and order direction "asc", order field - "region". Result = Alabama-Arkansas-Alaska-Arizona', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'asc', field: 'region'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(4); + expect(directive.matches[0].item.name).toBe('Alabama'); + expect(directive.matches[1].item.name).toBe('Arkansas'); + expect(directive.matches[2].item.name).toBe('Alaska'); + expect(directive.matches[3].item.name).toBe('Arizona'); + }) + ); + + it('and order direction "desc", order field - "id". Result = Arkansas-Arizona-Alaska-Alabama', + fakeAsync(() => { + directive.typeaheadOrderBy = {direction: 'desc', field: 'id'}; + dispatchTouchEvent(inputElement, 'input'); + fixture.detectChanges(); + tick(100); + + expect(directive.matches.length).toBe(4); + expect(directive.matches[0].item.name).toBe('Arkansas'); + expect(directive.matches[1].item.name).toBe('Arizona'); + expect(directive.matches[2].item.name).toBe('Alaska'); + expect(directive.matches[3].item.name).toBe('Alabama'); + }) + ); + }); + }); + }); }); diff --git a/src/typeahead/public_api.ts b/src/typeahead/public_api.ts index e379109ba0..f5602212ba 100644 --- a/src/typeahead/public_api.ts +++ b/src/typeahead/public_api.ts @@ -1,6 +1,8 @@ + export { latinMap } from './latin-map'; export { TypeaheadOptions } from './typeahead-options.class'; export { TypeaheadMatch } from './typeahead-match.class'; +export { TypeaheadOrder } from './typeahead-order.class'; export { escapeRegexp, diff --git a/src/typeahead/typeahead-order.class.ts b/src/typeahead/typeahead-order.class.ts new file mode 100644 index 0000000000..e4ec3eac4a --- /dev/null +++ b/src/typeahead/typeahead-order.class.ts @@ -0,0 +1,6 @@ +export class TypeaheadOrder { + /** field for sorting */ + field?: string; + /** ordering direction, could be 'asc' or 'desc' */ + direction: 'asc' | 'desc'; +} diff --git a/src/typeahead/typeahead.directive.ts b/src/typeahead/typeahead.directive.ts index 31772123e6..d97963da1a 100644 --- a/src/typeahead/typeahead.directive.ts +++ b/src/typeahead/typeahead.directive.ts @@ -13,16 +13,17 @@ import { TemplateRef, ViewContainerRef } from '@angular/core'; - import { NgControl } from '@angular/forms'; import { from, Subscription, isObservable } from 'rxjs'; import { ComponentLoader, ComponentLoaderFactory } from 'ngx-bootstrap/component-loader'; +import { debounceTime, filter, mergeMap, switchMap, toArray } from 'rxjs/operators'; + import { TypeaheadContainerComponent } from './typeahead-container.component'; import { TypeaheadMatch } from './typeahead-match.class'; import { TypeaheadConfig } from './typeahead.config'; import { getValueFromObject, latinize, tokenize } from './typeahead-utils'; -import { debounceTime, filter, mergeMap, switchMap, toArray } from 'rxjs/operators'; +import { TypeaheadOrder } from './typeahead-order.class'; @Directive({selector: '[typeahead]', exportAs: 'bs-typeahead'}) export class TypeaheadDirective implements OnInit, OnDestroy { @@ -53,6 +54,11 @@ export class TypeaheadDirective implements OnInit, OnDestroy { * contains the group value, matches are grouped by this field when set. */ @Input() typeaheadGroupField: string; + /** Used to specify a custom order of matches. When options source is an array of objects + * a field for sorting has to be set up. In case of options source is an array of string, + * a field for sorting is absent. The ordering direction could be changed to ascending or descending. + */ + @Input() typeaheadOrderBy: TypeaheadOrder; /** should be used only in case of typeahead attribute is Observable of array. * If true - loading of options will be async, otherwise - sync. * true make sense if options array is large. @@ -396,7 +402,7 @@ export class TypeaheadDirective implements OnInit, OnDestroy { debounceTime(this.typeaheadWaitMs), switchMap(() => this.typeahead) ) - .subscribe((matches: TypeaheadMatch[]) => { + .subscribe((matches: any[]) => { this.finalizeAsyncCall(matches); }) ); @@ -412,14 +418,14 @@ export class TypeaheadDirective implements OnInit, OnDestroy { return from(this.typeahead) .pipe( - filter((option: TypeaheadMatch) => { + filter((option: any) => { return option && this.testMatch(this.normalizeOption(option), normalizedQuery); }), toArray() ); }) ) - .subscribe((matches: TypeaheadMatch[]) => { + .subscribe((matches: any[]) => { this.finalizeAsyncCall(matches); }) ); @@ -474,7 +480,7 @@ export class TypeaheadDirective implements OnInit, OnDestroy { return match.indexOf(test) >= 0; } - protected finalizeAsyncCall(matches: TypeaheadMatch[]): void { + protected finalizeAsyncCall(matches: any[]): void { this.prepareMatches(matches || []); this.typeaheadLoading.emit(false); @@ -512,14 +518,15 @@ export class TypeaheadDirective implements OnInit, OnDestroy { } } - protected prepareMatches(options: TypeaheadMatch[]): void { - const limited: TypeaheadMatch[] = options.slice(0, this.typeaheadOptionsLimit); + protected prepareMatches(options: any[]): void { + const limited = options.slice(0, this.typeaheadOptionsLimit); + const sorted = !this.typeaheadOrderBy ? limited : this.orderMatches(limited); if (this.typeaheadGroupField) { let matches: TypeaheadMatch[] = []; // extract all group names - const groups = limited + const groups = sorted .map((option: TypeaheadMatch) => getValueFromObject(option, this.typeaheadGroupField) ) @@ -531,7 +538,7 @@ export class TypeaheadDirective implements OnInit, OnDestroy { // add each item of group to array of matches matches = matches.concat( - limited + sorted .filter( // tslint:disable-next-line:no-any (option: any) => @@ -550,7 +557,7 @@ export class TypeaheadDirective implements OnInit, OnDestroy { this._matches = matches; } else { - this._matches = limited.map( + this._matches = sorted.map( // tslint:disable-next-line:no-any (option: any) => new TypeaheadMatch( @@ -561,6 +568,57 @@ export class TypeaheadDirective implements OnInit, OnDestroy { } } + protected orderMatches