Merge pull request #2529 from coreos-inc/search-ui
Implement new search UI
This commit is contained in:
commit
5a9a231754
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;
|
||||
};
|
||||
}
|
||||
}
|
Reference in a new issue