diff --git a/package.json b/package.json index 36b3bdfd4..e6559231a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "highlight.js": "^9.9.0", "jasmine-core": "2.4.1", "jasmine-spec-reporter": "2.5.0", - "karma": "1.2.0", + "karma": "1.6.0", "karma-browserstack-launcher": "^1.2.0", "karma-chrome-launcher": "^2.0.0", "karma-jasmine": "^1.1.0", diff --git a/src/app/shared/navbar/index.ts b/src/app/shared/navbar/index.ts index f5899d036..a44923a77 100644 --- a/src/app/shared/navbar/index.ts +++ b/src/app/shared/navbar/index.ts @@ -1 +1,2 @@ export * from './navbar'; +export * from './searchbar'; diff --git a/src/app/shared/navbar/navbar.html b/src/app/shared/navbar/navbar.html index 5582045af..590e37f12 100644 --- a/src/app/shared/navbar/navbar.html +++ b/src/app/shared/navbar/navbar.html @@ -8,6 +8,7 @@ Components Guides + .mat-button { - &:last-child { - margin-left: auto; - } - } } .docs-angular-logo { diff --git a/src/app/shared/navbar/searchbar/index.ts b/src/app/shared/navbar/searchbar/index.ts new file mode 100644 index 000000000..2520d4d6f --- /dev/null +++ b/src/app/shared/navbar/searchbar/index.ts @@ -0,0 +1 @@ +export * from './searchbar'; diff --git a/src/app/shared/navbar/searchbar/searchbar.html b/src/app/shared/navbar/searchbar/searchbar.html new file mode 100644 index 000000000..20156607e --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.html @@ -0,0 +1,17 @@ +
+ + search +
+ + + + {{item.name}} + + diff --git a/src/app/shared/navbar/searchbar/searchbar.scss b/src/app/shared/navbar/searchbar/searchbar.scss new file mode 100644 index 000000000..3d0ff9407 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.scss @@ -0,0 +1,60 @@ +$input-bg: #85D9E2; + +@mixin color-placeholder() { + -webkit-font-smoothing: antialiased; + color: white; +} + +:host { + position: relative; + flex: 2; + + * { + box-sizing: border-box; + } + + &.docs-expanded .docs-search-input-container { + width: 100%; + } + + .docs-search-input-container { + display: block; + position: relative; + margin-left: auto; + height: 100%; + width: 200px; + transition: width .2s ease; + i.material-icons { + position: absolute; + left: 15px; top: 50%; + transform: translateY(-50%); + height: 28px; + width: 28px; + } + } + + input { + background: $input-bg; + border: none; + border-radius: 2px; + color: white; + font-size: 18px; + height: 95%; + line-height: 95%; + padding-left: 50px; + position: relative; + transition: width .2s ease; + width: 100%; + + /* Set placeholder text to be white */ + &::-webkit-input-placeholder { @include color-placeholder(); } /* Chrome/Opera/Safari */ + &::-moz-placeholder { @include color-placeholder(); } /* Firefox 19+ */ + &:-moz-placeholder { @include color-placeholder(); } /* Firefox 18- */ + &:ms-input-placeholder { @include color-placeholder(); } /* IE 10+ */ + + &:focus { + outline: none; + } + } + +} diff --git a/src/app/shared/navbar/searchbar/searchbar.spec.ts b/src/app/shared/navbar/searchbar/searchbar.spec.ts new file mode 100644 index 000000000..970bc2178 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.spec.ts @@ -0,0 +1,99 @@ +import {Injectable} from '@angular/core'; +import {TestBed, inject, async, ComponentFixture} from '@angular/core/testing'; +import {Router, RouterModule} from '@angular/router'; +import {MaterialModule} from '@angular/material'; +import {ReactiveFormsModule, FormControl} from '@angular/forms'; + +import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items'; +import {SearchBar} './searchbar'; + +const mockRouter = { + navigate: jasmine.createSpy('navigate'), + navigateByUrl: jasmine.createSpy('navigateByUrl'); +}; + +const testDocItem = { + id: 'test-doc-item', + name: 'TestingExample', + examples: ['test-examples'] +}; + + +describe('SearchBar', () => { + let fixture: ComponentFixture; + let component: SearchBar; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [RouterModule, ReactiveFormsModule, MaterialModule], + declarations: [SearchBar], + providers: [ + {provide: DocumentationItems, useClass: MockDocumentationItems}, + {provide: Router, useValue: mockRouter}, + ], + }); + + TestBed.compileComponents(); + fixture = TestBed.createComponent(SearchBar); + component = fixture.componentInstance; + component.searchControl = new FormControl(''); + fixture.detectChanges(); + })); + + afterEach(() => { + component._router.navigateByUrl.calls.reset(); + }); + + it('should toggle isExpanded', () => { + expect(component._isExpanded).toBe(false); + component.toggleIsExpanded(); + expect(component._isExpanded).toBe(true); + }); + + describe('Filter Search Suggestions', () => { + it('should return all items matching search query', () => { + const query = 'testing'; + const result = component.filterSearchSuggestions(query); + expect(result).toEqual([testDocItem]); + }); + + it('should return empty list if no items match', () => { + const query = 'does not exist'; + const result = component.filterSearchSuggestions(query); + expect(result).toEqual([]); + }); + }); + + describe('Navigate', () => { + + it('should take an id and navigate to the given route', () => { + component.navigate('button-toggle'); + expect(component._router.navigateByUrl).toHaveBeenCalled(); + }); + + it('should not navigate if no id is given', () => { + component.navigate(''); + expect(component._router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + + it('should show a snackbar error', () => { + spyOn(component._snackBar, 'open'); + component._showError(); + expect(component._snackBar.open).toHaveBeenCalled(); + expect(component._snackBar.open).toHaveBeenCalledWith( + 'No search results found.', + null, {duration: 3000}); + }); + + it('should return the proper display value for form control', () => { + const result = component.displayFn(testDocItem); + expect(result).toEqual(testDocItem.name); + }); +}); + + +class MockDocumentationItems extends DocumentationItems { + getAllItems(): DocItem[] { return [testDocItem]; } +} + diff --git a/src/app/shared/navbar/searchbar/searchbar.ts b/src/app/shared/navbar/searchbar/searchbar.ts new file mode 100644 index 000000000..fb913fd88 --- /dev/null +++ b/src/app/shared/navbar/searchbar/searchbar.ts @@ -0,0 +1,80 @@ +import {Component, ViewChild} from '@angular/core'; +import {MdAutocompleteTrigger, MdSnackBar} from '@angular/material'; +import {Router} from '@angular/router'; +import {FormControl} from '@angular/forms'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/operator/mergeMap'; + +import {DocumentationItems, DocItem} from '../../documentation-items/documentation-items'; + + +@Component({ + selector: 'search-bar-component', + templateUrl: './searchbar.html', + styleUrls: ['./searchbar.scss'], + host: { + '[class.docs-expanded]': '_isExpanded' + } +}) + +export class SearchBar { + + @ViewChild(MdAutocompleteTrigger) + private _autocompleteTrigger: MdAutocompleteTrigger; + + public allDocItems: DocItem[]; + public filteredSuggestions: Observable; + public searchControl: FormControl = new FormControl(''); + public sub; + + private _isExpanded: boolean = false; + + constructor( + private _docItems: DocumentationItems, + private _router: Router, + private _snackBar: MdSnackBar + ) { + this.allDocItems = _docItems.getAllItems(); + this.filteredSuggestions = this.searchControl.valueChanges + .startWith(null) + .map(item => item ? this.filterSearchSuggestions(item) : this.allDocItems.slice()); + } + + // This handles the user interacting with the autocomplete panel clicks or keyboard. + public ngAfterViewInit() { + // We listen to the changes on `filteredSuggestions in order to + // listen to the latest _autocompleteTrigger.optionSelections + this.sub = this.filteredSuggestions + .flatMap(_ => Observable.merge(...this._autocompleteTrigger.optionSelections)) + .subscribe(evt => this.navigate(evt.source.value.id)); + } + + public ngOnDestroy() { + if (this.sub) { this.sub.unsubscribe(); } + } + + public toggleIsExpanded() { + this._isExpanded = !this._isExpanded; + } + + public displayFn(item: DocItem) { + return item.name; + } + + public filterSearchSuggestions(searchTerm): DocItem[] { + return this.allDocItems.filter(item => new RegExp(`^${searchTerm}`, 'gi').test(item.name)); + } + + public handlePlainSearch(searchTerm) { + const item = this.allDocItems.find(item => item.name.toLowerCase() === searchTerm); + item ? this.navigate(item.id) : this._showError(); + } + + public navigate(id) { + return id ? this._router.navigateByUrl(`/components/component/${id}`) : null; + } + + private _showError() { + this._snackBar.open('No search results found.', null, {duration: 3000}); + } +} diff --git a/src/app/shared/shared-module.ts b/src/app/shared/shared-module.ts index 77eeec4fc..b9c299542 100644 --- a/src/app/shared/shared-module.ts +++ b/src/app/shared/shared-module.ts @@ -1,9 +1,11 @@ import {NgModule} from '@angular/core'; import {HttpModule} from '@angular/http'; +import {ReactiveFormsModule} from '@angular/forms'; import {DocViewer} from './doc-viewer/doc-viewer'; import {ExampleViewer} from './example-viewer/example-viewer'; import {DocumentationItems} from './documentation-items/documentation-items'; import {NavBar} from './navbar/navbar'; +import {SearchBar} from './navbar/searchbar/searchbar'; import {MaterialModule} from '@angular/material'; import {BrowserModule} from '@angular/platform-browser'; import {RouterModule} from '@angular/router'; @@ -16,9 +18,10 @@ import {GuideItems} from './guide-items/guide-items'; HttpModule, RouterModule, BrowserModule, + ReactiveFormsModule, MaterialModule, ], - declarations: [DocViewer, ExampleViewer, NavBar, PlunkerButton], + declarations: [DocViewer, ExampleViewer, NavBar, SearchBar, PlunkerButton], exports: [DocViewer, ExampleViewer, NavBar, PlunkerButton], providers: [DocumentationItems, GuideItems], entryComponents: [