Implement new search UI
We now have both autocomplete-based searching for quick results, as well as a full search page for a full listing of results
This commit is contained in:
		
							parent
							
								
									8b148bf1d4
								
							
						
					
					
						commit
						e9ffe0e27b
					
				
					 23 changed files with 649 additions and 393 deletions
				
			
		|  | @ -235,11 +235,13 @@ angular.module('quay').directive('entitySearch', function () { | |||
| 
 | ||||
|         // Setup the typeahead.
 | ||||
|         $(input).typeahead({ | ||||
|           'highlight': true | ||||
|           'highlight': true, | ||||
|           'hint': false, | ||||
|         }, { | ||||
|           display: 'value', | ||||
|           source: entitySearchB.ttAdapter(), | ||||
|           templates: { | ||||
|             'empty': function(info) { | ||||
|             'notFound': function(info) { | ||||
|               // Only display the empty dialog if the server load has finished.
 | ||||
|               if (info.resultKind == 'remote') { | ||||
|                 var val = $(input).val(); | ||||
|  |  | |||
|  | @ -28,18 +28,6 @@ angular.module('quay').directive('headerBar', function () { | |||
|         hotkeysAdded = true; | ||||
| 
 | ||||
|         // Register hotkeys.
 | ||||
|         if ($scope.searchingAllowed) { | ||||
|           hotkeys.add({ | ||||
|             combo: '/', | ||||
|             description: 'Show search', | ||||
|             callback: function(e) { | ||||
|               e.preventDefault(); | ||||
|               e.stopPropagation(); | ||||
|               $scope.toggleSearch(); | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
| 
 | ||||
|         if (!cUser.anonymous) { | ||||
|           hotkeys.add({ | ||||
|             combo: 'alt+c', | ||||
|  | @ -57,9 +45,6 @@ angular.module('quay').directive('headerBar', function () { | |||
|       $scope.Features = Features; | ||||
|       $scope.notificationService = NotificationService; | ||||
|       $scope.searchingAllowed = false; | ||||
|       $scope.searchVisible = false; | ||||
|       $scope.currentSearchQuery = null; | ||||
|       $scope.searchResultState = null; | ||||
|       $scope.showBuildDialogCounter = 0; | ||||
| 
 | ||||
|       // Monitor any user changes and place the current user into the scope.
 | ||||
|  | @ -79,69 +64,6 @@ angular.module('quay').directive('headerBar', function () { | |||
|         $scope.currentPageContext['repository'] = r; | ||||
|       }); | ||||
| 
 | ||||
|       var documentSearchMaxResults = 10; | ||||
|       var documentSearchScoreThreshold = 0.9; | ||||
| 
 | ||||
|       var conductDocumentationSearch = function(query) { | ||||
|         if (!query) { return; } | ||||
| 
 | ||||
|         var mapper = function(result, score) { | ||||
|           return { | ||||
|             'kind': 'doc', | ||||
|             'name': result.title.replace(/'\;/g, "'"), | ||||
|             'score': score, | ||||
|             'href': Config.DOCUMENTATION_LOCATION + result.url | ||||
|           } | ||||
|         }; | ||||
| 
 | ||||
|         DocumentationService.findDocumentation($scope, query.split(' '), function(results) { | ||||
|           if (!$scope.searchVisible) { return; } | ||||
| 
 | ||||
|           var currentResults = $scope.searchResultState['results'] || []; | ||||
|           results.forEach(function(result) { | ||||
|             if (currentResults.length < documentSearchMaxResults) { | ||||
|               currentResults.push(result); | ||||
|             } | ||||
|           }); | ||||
| 
 | ||||
|           $scope.searchResultState = { | ||||
|             'state': currentResults.length ? 'results' : 'no-results', | ||||
|             'results': currentResults, | ||||
|             'current': currentResults.length ? 0 : -1 | ||||
|           }; | ||||
|         }, mapper, documentSearchScoreThreshold); | ||||
|       } | ||||
| 
 | ||||
|       var conductSearch = function(query) { | ||||
|         if (!query) { $scope.searchResultState = null; return; } | ||||
| 
 | ||||
|         $scope.searchResultState = { | ||||
|           'state': 'loading' | ||||
|         }; | ||||
| 
 | ||||
|         var params = { | ||||
|           'query': query | ||||
|         }; | ||||
| 
 | ||||
|         ApiService.conductSearch(null, params).then(function(resp) { | ||||
|           if (!$scope.searchVisible || query != $scope.currentSearchQuery) { return; } | ||||
| 
 | ||||
|           $scope.searchResultState = { | ||||
|             'state': resp.results.length ? 'results' : 'no-results', | ||||
|             'results': resp.results, | ||||
|             'current': resp.results.length ? 0 : -1 | ||||
|           }; | ||||
| 
 | ||||
|           if (resp.results.length < documentSearchMaxResults) { | ||||
|             conductDocumentationSearch(query); | ||||
|           } | ||||
|         }, function(resp) { | ||||
|           $scope.searchResultState = null; | ||||
|         }, /* background */ true); | ||||
|       }; | ||||
| 
 | ||||
|       $scope.$watch('currentSearchQuery', conductSearch); | ||||
| 
 | ||||
|       $scope.signout = function() { | ||||
|         ApiService.logout().then(function() { | ||||
|           UserService.load(); | ||||
|  | @ -153,75 +75,6 @@ angular.module('quay').directive('headerBar', function () { | |||
|         return Config.getEnterpriseLogo(); | ||||
|       }; | ||||
| 
 | ||||
|       $scope.toggleSearch = function() { | ||||
|         $scope.searchVisible = !$scope.searchVisible; | ||||
|         if ($scope.searchVisible) { | ||||
|           $('#search-box-input').focus(); | ||||
|           if ($scope.currentSearchQuery) { | ||||
|             conductSearch($scope.currentSearchQuery); | ||||
|           } | ||||
|         } else { | ||||
|           $('#search-box-input').blur() | ||||
|           $scope.searchResultState = null; | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       $scope.getSearchBoxClasses = function(searchVisible, searchResultState) { | ||||
|         var classes = searchVisible ? 'search-visible ' : ''; | ||||
|         if (searchResultState) { | ||||
|           classes += 'results-visible'; | ||||
|         } | ||||
|         return classes; | ||||
|       }; | ||||
| 
 | ||||
|       $scope.handleSearchKeyDown = function(e) { | ||||
|         if (e.keyCode == 27) { | ||||
|           $scope.toggleSearch(); | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         var state = $scope.searchResultState; | ||||
|         if (!state || !state['results']) { return; } | ||||
| 
 | ||||
|         if (e.keyCode == 40) { | ||||
|           state['current']++; | ||||
|           e.preventDefault(); | ||||
|         } else if (e.keyCode == 38) { | ||||
|           state['current']--; | ||||
|           e.preventDefault(); | ||||
|         } else if (e.keyCode == 13) { | ||||
|           var current = state['current']; | ||||
|           if (current >= 0 && current < state['results'].length) { | ||||
|             $scope.showResult(state['results'][current]); | ||||
|           } | ||||
|           e.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         if (state['current'] < -1) { | ||||
|           state['current'] = state['results'].length - 1; | ||||
|         } else if (state['current'] >= state['results'].length) { | ||||
|           state['current'] = 0; | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       $scope.showResult = function(result) { | ||||
|         $scope.toggleSearch(); | ||||
|         $timeout(function() { | ||||
|           if (result['kind'] == 'doc') { | ||||
|             window.location = result['href']; | ||||
|             return; | ||||
|           } | ||||
| 
 | ||||
|           $scope.currentSearchQuery = ''; | ||||
|           $location.url(result['href']) | ||||
|         }, 500); | ||||
|       }; | ||||
| 
 | ||||
|       $scope.setCurrentResult = function(result) { | ||||
|         if (!$scope.searchResultState) { return; } | ||||
|         $scope.searchResultState['current'] = result; | ||||
|       }; | ||||
| 
 | ||||
|       $scope.getNamespace = function(context) { | ||||
|         if (!context) { return null; } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										54
									
								
								static/js/directives/ui/search-box/search-box.component.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								static/js/directives/ui/search-box/search-box.component.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| <span class="search-box-element"> | ||||
|   <script type="text/ng-template" id="search-result-template"> | ||||
|     <div class="search-box-result"> | ||||
|       <span class="kind">{{ result.title || result.kind }}</span> | ||||
|       <span ng-switch on="result.kind"> | ||||
|         <!-- Team --> | ||||
|         <span ng-switch-when="team"> | ||||
|           <strong> | ||||
|             <span class="avatar" data="result.avatar" size="16"></span> | ||||
|             <span class="result-name">{{ result.name }}</span> | ||||
|           </strong> | ||||
|           <span class="clarification"> | ||||
|             in | ||||
|             <span class="avatar" data="result.organization.avatar"  size="16"></span> | ||||
|             <span class="result-name">{{ result.organization.name }}</span> | ||||
|           </span> | ||||
|         </span> | ||||
|         <span ng-switch-when="user"> | ||||
|           <span class="avatar" data="result.avatar"  size="16"></span> | ||||
|           <span class="result-name">{{ result.name }}</span> | ||||
|         </span> | ||||
|         <span ng-switch-when="organization"> | ||||
|           <span class="avatar" data="result.avatar"  size="16"></span> | ||||
|           <span class="result-name">{{ result.name }}</span> | ||||
|         </span> | ||||
|         <span ng-switch-when="robot"> | ||||
|           <i class="fa ci-robot"></i> | ||||
|           <span class="result-name">{{ result.name }}</span> | ||||
|         </span> | ||||
|         <span ng-switch-when="repository"> | ||||
|           <span class="avatar" data="result.namespace.avatar"  size="16"></span> | ||||
|           <span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span> | ||||
|           <div class="result-description" ng-if="result.description"> | ||||
|             <div class="description markdown-view" content="result.description" | ||||
|                  first-line-only="true" placeholder-needed="false"></div> | ||||
|           </div> | ||||
|         </span> | ||||
|       </span> | ||||
|     </div> | ||||
|   </script> | ||||
| 
 | ||||
|   <input class="form-control" type="text" placeholder="search" | ||||
|          ng-model="$ctrl.enteredQuery" | ||||
|          typeahead="$ctrl.onTypeahead($event)" | ||||
|          ta-display-key="name" | ||||
|          ta-suggestion-tmpl="search-result-template" | ||||
|          ta-clear-on-select="true" | ||||
|          (ta-selected)="$ctrl.onSelected($event)" | ||||
|          (ta-entered)="$ctrl.onEntered($event)"> | ||||
|   <span class="search-icon"> | ||||
|     <span class="cor-loader-inline" ng-if="$ctrl.isSearching"></span> | ||||
|     <i class="fa fa-search" ng-if="!$ctrl.isSearching"></i> | ||||
|   </span> | ||||
| </span> | ||||
							
								
								
									
										56
									
								
								static/js/directives/ui/search-box/search-box.component.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								static/js/directives/ui/search-box/search-box.component.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| import { Input, Component, Inject } from 'ng-metadata/core'; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * A component that displays a search box with autocomplete. | ||||
|  */ | ||||
| @Component({ | ||||
|   selector: 'search-box', | ||||
|   templateUrl: '/static/js/directives/ui/search-box/search-box.component.html', | ||||
| }) | ||||
| export class SearchBoxComponent { | ||||
|   @Input('<query') public enteredQuery: string = ''; | ||||
| 
 | ||||
|   private isSearching: boolean = false; | ||||
|   private currentQuery: string = ''; | ||||
|   private autocompleteSelected: boolean = false; | ||||
| 
 | ||||
|   constructor(@Inject('ApiService') private ApiService: any, | ||||
|               @Inject('$timeout') private $timeout: ng.ITimeoutService, | ||||
|               @Inject('$location') private $location: ng.ILocationService) { | ||||
|   } | ||||
| 
 | ||||
|   private onTypeahead($event): void { | ||||
|     this.currentQuery = $event['query']; | ||||
|     if (this.currentQuery.length < 3) { | ||||
|       $event['callback']([]); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     var params = { | ||||
|        'query': this.currentQuery, | ||||
|     }; | ||||
| 
 | ||||
|     this.ApiService.conductSearch(null, params).then((resp) => { | ||||
|       if (this.currentQuery == $event['query']) { | ||||
|         $event['callback'](resp.results); | ||||
|         this.autocompleteSelected = false; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private onSelected($event): void { | ||||
|     this.autocompleteSelected = true; | ||||
|     this.$timeout(() => { | ||||
|       this.$location.url($event['result']['href']) | ||||
|     }, 100); | ||||
|   } | ||||
| 
 | ||||
|   private onEntered($event): void { | ||||
|     this.$timeout(() => { | ||||
|       $event['callback'](true); // Clear the value.
 | ||||
|       this.$location.url('/search'); | ||||
|       this.$location.search('q', $event['value']); | ||||
|     }, 10); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										90
									
								
								static/js/directives/ui/typeahead/typeahead.directive.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								static/js/directives/ui/typeahead/typeahead.directive.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,90 @@ | |||
| import { Input, Output, Directive, Inject, AfterContentInit, EventEmitter, HostListener } from 'ng-metadata/core'; | ||||
| import * as $ from 'jquery'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive which decorates an <input> with a typeahead autocomplete. | ||||
|  */ | ||||
| @Directive({ | ||||
|   selector: '[typeahead]', | ||||
| }) | ||||
| export class TypeaheadDirective implements AfterContentInit { | ||||
|   @Output('typeahead') typeahead = new EventEmitter<any>(); | ||||
| 
 | ||||
|   @Input('taDisplayKey') displayKey: string = ''; | ||||
|   @Input('taSuggestionTmpl') suggestionTemplate: string = ''; | ||||
|   @Input('taClearOnSelect') clearOnSelect: boolean = false; | ||||
| 
 | ||||
|   @Output('taSelected') selected = new EventEmitter<any>(); | ||||
|   @Output('taEntered') entered = new EventEmitter<any>(); | ||||
| 
 | ||||
|   private itemSelected: boolean = false; | ||||
| 
 | ||||
|   constructor(@Inject('$element') private $element: ng.IAugmentedJQuery, | ||||
|               @Inject('$compile') private $compile: ng.ICompileService, | ||||
|               @Inject('$scope') private $scope: ng.IScope, | ||||
|               @Inject('$templateRequest') private $templateRequest: ng.ITemplateRequestService) { | ||||
|   } | ||||
| 
 | ||||
|   public ngAfterContentInit(): void { | ||||
|     var templates = null; | ||||
|     if (this.suggestionTemplate) { | ||||
|       templates = {} | ||||
| 
 | ||||
|       if (this.suggestionTemplate) { | ||||
|         templates['suggestion'] = this.buildTemplateHandler(this.suggestionTemplate); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     $(this.$element).on('typeahead:select', (ev, suggestion) => { | ||||
|       if (this.clearOnSelect) { | ||||
|         $(this.$element).typeahead('val', ''); | ||||
|       } | ||||
|       this.selected.emit({'result': suggestion}) | ||||
|       this.itemSelected = true; | ||||
|     }); | ||||
| 
 | ||||
|     $(this.$element).typeahead( | ||||
|       { | ||||
|         highlight: false, | ||||
|         hint: false, | ||||
|       }, | ||||
|       { | ||||
|         templates: templates, | ||||
|         display: this.displayKey, | ||||
|         source: (query, results, asyncResults) => { | ||||
|           this.typeahead.emit({'query': query, 'callback': asyncResults}); | ||||
|           this.itemSelected = false; | ||||
|         }, | ||||
|       }); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('keyup', ['$event']) | ||||
|   public onKeyup(event: JQueryKeyEventObject): void { | ||||
|     if (!this.itemSelected && event.keyCode == 13) { | ||||
|       this.entered.emit({ | ||||
|         'value': $(this.$element).typeahead('val'), | ||||
|         'callback': (reset: boolean) => { | ||||
|           if (reset) { | ||||
|             this.itemSelected = false; | ||||
|             $(this.$element).typeahead('val', ''); | ||||
|           } | ||||
|        } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private buildTemplateHandler(templateUrl: string): Function { | ||||
|     return (value) => { | ||||
|       var resultDiv = document.createElement('div'); | ||||
|       this.$templateRequest(templateUrl).then((tplContent) => { | ||||
|         var tplEl = document.createElement('span'); | ||||
|         tplEl.innerHTML = tplContent.trim(); | ||||
|         var scope = this.$scope.$new(true); | ||||
|         scope['result'] = value; | ||||
|         this.$compile(tplEl)(scope); | ||||
|         resultDiv.appendChild(tplEl); | ||||
|       }); | ||||
|       return resultDiv; | ||||
|     }; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										49
									
								
								static/js/pages/search.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								static/js/pages/search.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | |||
| (function() { | ||||
|   /** | ||||
|    * Search page. | ||||
|    */ | ||||
|   angular.module('quayPages').config(['pages', function(pages) { | ||||
|     pages.create('search', 'search.html', SearchCtrl, { | ||||
|       'title': 'Search' | ||||
|     }); | ||||
|   }]); | ||||
| 
 | ||||
|   function SearchCtrl($scope, ApiService, $routeParams, $location) { | ||||
|     var refreshResults = function() { | ||||
|       $scope.currentPage = ($routeParams['page'] || '1') * 1; | ||||
| 
 | ||||
|       var params = { | ||||
|         'query': $routeParams['q'], | ||||
|         'page': $scope.currentPage | ||||
|       }; | ||||
| 
 | ||||
|       $scope.maxPopularity = 0; | ||||
|       $scope.resultsResource = ApiService.conductRepoSearchAsResource(params).get(function(resp) { | ||||
|         $scope.results = resp['results']; | ||||
|         $scope.hasAdditional = resp['has_additional']; | ||||
|         $scope.startIndex = resp['start_index']; | ||||
|         resp['results'].forEach(function(result) { | ||||
|           $scope.maxPopularity = Math.max($scope.maxPopularity, result['popularity']); | ||||
|         }); | ||||
|       }); | ||||
|     }; | ||||
| 
 | ||||
|     $scope.previousPage = function() { | ||||
|       $location.search('page', (($routeParams['page'] || 1) * 1) - 1); | ||||
|     }; | ||||
| 
 | ||||
|     $scope.nextPage = function() { | ||||
|       $location.search('page', (($routeParams['page'] || 1) * 1) + 1); | ||||
|     }; | ||||
| 
 | ||||
|     $scope.currentQuery = $routeParams['q']; | ||||
|     refreshResults(); | ||||
| 
 | ||||
|     $scope.$on('$routeUpdate', function(){ | ||||
|       $scope.currentQuery = $routeParams['q']; | ||||
|       refreshResults(); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   SearchCtrl.$inject = ['$scope', 'ApiService', '$routeParams', '$location']; | ||||
| })(); | ||||
|  | @ -54,6 +54,9 @@ function provideRoutes($routeProvider: ng.route.IRouteProvider, | |||
|   } | ||||
| 
 | ||||
|   routeBuilder | ||||
|     // Search
 | ||||
|     .route('/search', 'search') | ||||
| 
 | ||||
|     // Application View
 | ||||
|     .route('/application/:namespace/:name', 'app-view') | ||||
| 
 | ||||
|  |  | |||
|  | @ -18,6 +18,8 @@ import { TagSigningDisplayComponent } from './directives/ui/tag-signing-display/ | |||
| import { RepositorySigningConfigComponent } from './directives/ui/repository-signing-config/repository-signing-config.component'; | ||||
| import { TimeMachineSettingsComponent } from './directives/ui/time-machine-settings/time-machine-settings.component'; | ||||
| import { DurationInputComponent } from './directives/ui/duration-input/duration-input.component'; | ||||
| import { SearchBoxComponent } from './directives/ui/search-box/search-box.component'; | ||||
| import { TypeaheadDirective } from './directives/ui/typeahead/typeahead.directive'; | ||||
| import { BuildServiceImpl } from './services/build/build.service.impl'; | ||||
| import { AvatarServiceImpl } from './services/avatar/avatar.service.impl'; | ||||
| import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl'; | ||||
|  | @ -52,6 +54,8 @@ import { QuayRequireDirective } from './directives/structural/quay-require/quay- | |||
|     RepositorySigningConfigComponent, | ||||
|     TimeMachineSettingsComponent, | ||||
|     DurationInputComponent, | ||||
|     SearchBoxComponent, | ||||
|     TypeaheadDirective, | ||||
|   ], | ||||
|   providers: [ | ||||
|     ViewArrayImpl, | ||||
|  |  | |||
		Reference in a new issue