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 @@ +
+
Source - array of string. Order direction - descending
+ +
+
+
Source - array of string. Order direction - ascending
+ +
+
+
+ Source - array of objects. Order direction - ascending, + sort by city, group by state +
+ + + + {{model.city}} - {{model.code}} + +
+ +
+
Source - Observable of array of string. Order direction - descending
+ +
diff --git a/demo/src/app/components/+typeahead/demos/ordering/ordering.ts b/demo/src/app/components/+typeahead/demos/ordering/ordering.ts new file mode 100644 index 0000000000..7331b7e4aa --- /dev/null +++ b/demo/src/app/components/+typeahead/demos/ordering/ordering.ts @@ -0,0 +1,136 @@ +import { Component, OnInit } from '@angular/core'; + +import { TypeaheadOrder } from 'ngx-bootstrap/typeahead'; +import { Observable, of, Subscriber } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +@Component({ + selector: 'demo-typeahead-ordering', + templateUrl: './ordering.html' +}) +export class DemoTypeaheadOrderingComponent implements OnInit { + selected1: string; + selected2: string; + selected3: string; + selected4: string; + sortConfig1: TypeaheadOrder = { + direction: 'desc' + }; + sortConfig2: TypeaheadOrder = { + direction: 'asc' + }; + sortConfig3: TypeaheadOrder = { + direction: 'asc', + field: 'city' + }; + states$: Observable; + states: string[] = [ + 'New Mexico', + 'New York', + 'North Dakota', + 'North Carolina', + 'Ohio', + 'Oklahoma', + 'Oregon', + 'Pennsylvania', + 'Rhode Island', + 'South Carolina', + 'South Dakota', + 'Tennessee', + 'Texas', + 'Utah', + 'Alaska', + 'Alabama', + 'Iowa', + 'Kansas', + 'Kentucky', + 'Louisiana', + 'Maine', + 'Maryland', + 'Massachusetts', + 'Michigan', + 'Minnesota', + 'Mississippi', + 'Missouri', + 'Montana', + 'Nebraska', + 'Nevada', + 'New Hampshire', + 'New Jersey', + 'Arizona', + 'Arkansas', + 'California', + 'Colorado', + 'Connecticut', + 'Delaware', + 'Florida', + 'Georgia', + 'Hawaii', + 'Idaho', + 'Illinois', + 'Indiana', + 'Vermont', + 'Virginia', + 'Washington', + 'West Virginia', + 'Wisconsin', + 'Wyoming' + ]; + cities = [{ + city: 'Norton', + state: 'Virginia', + code: '61523' + }, { + city: 'Grundy', + state: 'Virginia', + code: '77054' + }, { + city: 'Coeburn', + state: 'Virginia', + code: '01665' + }, { + city: 'Phoenix', + state: 'Arizona', + code: '29128' + }, { + city: 'Tucson', + state: 'Arizona', + code: '32084' + }, { + city: 'Mesa', + state: 'Arizona', + code: '21465' + }, { + city: 'Independence', + state: 'Missouri', + code: '26887' + }, { + city: 'Kansas City', + state: 'Missouri', + code: '79286' + }, { + city: 'Springfield', + state: 'Missouri', + code: '92325' + }, { + city: 'St. Louis', + state: 'Missouri', + code: '64891' + }]; + + ngOnInit(): void { + this.states$ = new Observable((observer: Subscriber) => { + // Runs on every search + observer.next(this.selected4); + }) + .pipe( + switchMap((token: string) => { + const query = new RegExp(token, 'i'); + + return of( + this.states.filter((state: string) => query.test(state)) + ); + }) + ); + } +} diff --git a/demo/src/app/components/+typeahead/typeahead-section.list.ts b/demo/src/app/components/+typeahead/typeahead-section.list.ts index f0e9de4939..731ea55d14 100644 --- a/demo/src/app/components/+typeahead/typeahead-section.list.ts +++ b/demo/src/app/components/+typeahead/typeahead-section.list.ts @@ -29,6 +29,7 @@ import { ExamplesComponent } from '../../docs/demo-section-components/demo-examp import { NgApiDocComponent, NgApiDocConfigComponent } from '../../docs/api-docs'; import { DemoTypeaheadFirstItemActiveComponent } from './demos/first-item-active/first-item-active'; +import { DemoTypeaheadOrderingComponent } from './demos/ordering/ordering'; export const demoComponentContent: ContentSection[] = [ { @@ -275,6 +276,16 @@ export const demoComponentContent: ContentSection[] = [ component: require('!!raw-loader!./demos/selected-first-item/selected-first-item.ts'), html: require('!!raw-loader!./demos/selected-first-item/selected-first-item.html'), outlet: DemotypeaheadSelectFirstItemComponent + }, + { + title: 'Order results', + anchor: 'typeahead-ordering', + description: ` +

Use typeaheadOrderBy property to order your result by a certain field and in certain direction

+ `, + component: require('!!raw-loader!./demos/ordering/ordering.ts'), + html: require('!!raw-loader!./demos/ordering/ordering.html'), + outlet: DemoTypeaheadOrderingComponent } ] }, diff --git a/demo/src/ng-api-doc.ts b/demo/src/ng-api-doc.ts index a30424adaf..7d2dc88bc4 100644 --- a/demo/src/ng-api-doc.ts +++ b/demo/src/ng-api-doc.ts @@ -2332,6 +2332,16 @@ export const ngdoc: any = { "type": "boolean", "description": "

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(options: T[]): T[] { + if (!options.length) { + return options; + } + + if (this.typeaheadOrderBy !== null + && this.typeaheadOrderBy !== undefined + && typeof this.typeaheadOrderBy === 'object' + && Object.keys(this.typeaheadOrderBy).length === 0) { + // tslint:disable-next-line:no-console + console.error('Field and direction properties for typeaheadOrderBy have to be set according to documentation!'); + + return options; + } + + const { field, direction } = this.typeaheadOrderBy; + + if (!direction || !(direction === 'asc' || direction === 'desc')) { + // tslint:disable-next-line:no-console + console.error('typeaheadOrderBy direction has to equal "asc" or "desc". Please follow the documentation.'); + + return options; + } + + if (typeof options[0] === 'string') { + return direction === 'asc' ? options.sort() : options.sort().reverse(); + } + + if (!field || typeof field !== 'string') { + // tslint:disable-next-line:no-console + console.error('typeaheadOrderBy field has to set according to the documentation.'); + + return options; + } + + return options.sort((a: T, b: T) => { + const stringA = getValueFromObject(a, field); + const stringB = getValueFromObject(b, field); + + if (stringA < stringB) { + return direction === 'asc' ? -1 : 1; + } + + if (stringA > stringB) { + return direction === 'asc' ? 1 : -1; + } + + return 0; + }); + } + protected hasMatches(): boolean { return this._matches.length > 0; }