Merge pull request #2529 from coreos-inc/search-ui

Implement new search UI
This commit is contained in:
josephschorr 2017-05-02 15:56:59 -04:00 committed by GitHub
commit 5a9a231754
23 changed files with 649 additions and 393 deletions

View file

@ -42,10 +42,11 @@ def filter_to_repos_for_user(query, username=None, namespace=None, repo_kind='im
return Repository.select().where(Repository.id == '-1')
# Filter on the type of repository.
try:
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
except RepositoryKind.DoesNotExist:
raise DataModelException('Unknown repository kind')
if repo_kind is not None:
try:
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
except RepositoryKind.DoesNotExist:
raise DataModelException('Unknown repository kind')
# Add the start ID if necessary.
if start_id is not None:

View file

@ -318,6 +318,9 @@ def repository_is_starred(user, repository):
def get_when_last_modified(repository_ids):
""" Returns a map from repository ID to the last modified time (in s) for each repository in the
given repository IDs list.
"""
if not repository_ids:
return {}
@ -334,6 +337,26 @@ def get_when_last_modified(repository_ids):
return last_modified_map
def get_stars(repository_ids):
""" Returns a map from repository ID to the number of stars for each repository in the
given repository IDs list.
"""
if not repository_ids:
return {}
tuples = (Star
.select(Star.repository, fn.Count(Star.id))
.where(Star.repository << repository_ids)
.group_by(Star.repository)
.tuples())
star_map = {}
for record in tuples:
star_map[record[0]] = record[1]
return star_map
def get_visible_repositories(username, namespace=None, kind_filter='image', include_public=False,
start_id=None, limit=None):
""" Returns the repositories visible to the given user (if any).
@ -471,10 +494,12 @@ def _get_sorted_matching_repositories(lookup_value, repo_kind='image', include_p
query = (Repository
.select(Repository, Namespace)
.join(Namespace, on=(Namespace.id == Repository.namespace_user))
.where(clause,
Repository.kind == Repository.kind.get_id(repo_kind))
.where(clause)
.group_by(Repository.id, Namespace.id))
if repo_kind is not None:
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
if not include_private:
query = query.where(Repository.visibility == _basequery.get_public_repo_visibility())

View file

@ -164,11 +164,13 @@ class EntitySearch(ApiResource):
def search_entity_view(username, entity, get_short_name=None):
kind = 'user'
title = 'user'
avatar_data = avatar.get_data_for_user(entity)
href = '/user/' + entity.username
if entity.organization:
kind = 'organization'
title = 'org'
avatar_data = avatar.get_data_for_org(entity)
href = '/organization/' + entity.username
elif entity.robot:
@ -179,9 +181,11 @@ def search_entity_view(username, entity, get_short_name=None):
href = '/organization/' + parts[0] + '?tab=robots&showRobot=' + entity.username
kind = 'robot'
title = 'robot'
avatar_data = None
data = {
'title': title,
'kind': kind,
'avatar': avatar_data,
'name': entity.username,
@ -233,20 +237,15 @@ def conduct_admined_team_search(username, query, encountered_teams, results):
})
def conduct_repo_search(username, query, results):
def conduct_repo_search(username, query, results, offset=0, limit=5):
""" Finds matching repositories. """
matching_repos = model.repository.get_filtered_matching_repositories(query, username, limit=5)
matching_repos = model.repository.get_filtered_matching_repositories(query, username, limit=limit,
repo_kind=None,
offset=offset)
for repo in matching_repos:
results.append({
'kind': 'repository',
'namespace': search_entity_view(username, repo.namespace_user),
'name': repo.name,
'description': repo.description,
'is_public': model.repository.is_repository_public(repo),
'score': REPOSITORY_SEARCH_SCORE,
'href': '/repository/' + repo.namespace_user.username + '/' + repo.name
})
# TODO: make sure the repo.kind.name doesn't cause extra queries
results.append(repo_result_view(repo, username))
def conduct_namespace_search(username, query, results):
@ -266,6 +265,30 @@ def conduct_robot_search(username, query, results):
results.append(search_entity_view(username, robot, get_short_name))
def repo_result_view(repo, username, last_modified=None, stars=None, popularity=None):
kind = 'application' if repo.kind.name == 'application' else 'repository'
view = {
'kind': kind,
'title': 'app' if kind == 'application' else 'repo',
'namespace': search_entity_view(username, repo.namespace_user),
'name': repo.name,
'description': repo.description,
'is_public': model.repository.is_repository_public(repo),
'score': REPOSITORY_SEARCH_SCORE,
'href': '/' + kind + '/' + repo.namespace_user.username + '/' + repo.name
}
if last_modified is not None:
view['last_modified'] = last_modified
if stars is not None:
view['stars'] = stars
if popularity is not None:
view['popularity'] = popularity
return view
@resource('/v1/find/all')
class ConductSearch(ApiResource):
""" Resource for finding users, repositories, teams, etc. """
@ -306,3 +329,51 @@ class ConductSearch(ApiResource):
result['score'] = result['score'] * lm_score
return {'results': sorted(results, key=itemgetter('score'), reverse=True)}
MAX_PER_PAGE = 10
@resource('/v1/find/repositories')
class ConductRepositorySearch(ApiResource):
""" Resource for finding repositories. """
@parse_args()
@query_param('query', 'The search query.', type=str, default='')
@query_param('page', 'The page.', type=int, default=1)
@nickname('conductRepoSearch')
def get(self, parsed_args):
""" Get a list of apps and repositories that match the specified query. """
query = parsed_args['query']
if not query:
return {'results': []}
page = min(max(1, parsed_args['page']), 10)
offset = (page - 1) * MAX_PER_PAGE
limit = offset + MAX_PER_PAGE + 1
username = get_authenticated_user().username if get_authenticated_user() else None
# Lookup matching repositories.
matching_repos = list(model.repository.get_filtered_matching_repositories(query, username,
repo_kind=None,
limit=limit,
offset=offset))
# Load secondary information such as last modified time, star count and action count.
repository_ids = [repo.id for repo in matching_repos]
last_modified_map = model.repository.get_when_last_modified(repository_ids)
star_map = model.repository.get_stars(repository_ids)
action_sum_map = model.log.get_repositories_action_sums(repository_ids)
# Build the results list.
results = [repo_result_view(repo, username, last_modified_map.get(repo.id),
star_map.get(repo.id, 0),
float(action_sum_map.get(repo.id, 0)))
for repo in matching_repos]
return {
'results': results[0:MAX_PER_PAGE],
'has_additional': len(results) > MAX_PER_PAGE,
'page': page,
'page_size': MAX_PER_PAGE,
'start_index': offset,
}

View file

@ -0,0 +1,12 @@
from endpoints.api.search import ConductRepositorySearch
from endpoints.api.test.shared import client_with_identity, conduct_api_call
from test.fixtures import *
def test_repository_search(client):
with client_with_identity('devtable', client) as cl:
params = {'query': 'simple'}
result = conduct_api_call(cl, ConductRepositorySearch, 'GET', params, None, 200).json
assert not result['has_additional']
assert result['start_index'] == 0
assert result['page'] == 1
assert result['results'][0]['name'] == 'simple'

View file

@ -4,16 +4,18 @@ from flask_principal import AnonymousIdentity
from endpoints.api import api
from endpoints.api.team import OrganizationTeamSyncing
from endpoints.api.test.shared import client_with_identity, conduct_api_call
from endpoints.api.repository import RepositoryTrust
from endpoints.api.signing import RepositorySignatures
from endpoints.api.search import ConductRepositorySearch
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
from endpoints.api.signing import RepositorySignatures
from endpoints.api.repository import RepositoryTrust
from test.fixtures import *
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
BUILD_PARAMS = {'build_uuid': 'test-1234'}
REPO_PARAMS = {'repository': 'devtable/someapp'}
SEARCH_PARAMS = {'query': ''}
@pytest.mark.parametrize('resource,method,params,body,identity,expected', [
(OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403),
@ -26,6 +28,11 @@ REPO_PARAMS = {'repository': 'devtable/someapp'}
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'reader', 403),
(OrganizationTeamSyncing, 'DELETE', TEAM_PARAMS, {}, 'devtable', 200),
(ConductRepositorySearch, 'GET', SEARCH_PARAMS, None, None, 200),
(ConductRepositorySearch, 'GET', SEARCH_PARAMS, None, 'freshuser', 200),
(ConductRepositorySearch, 'GET', SEARCH_PARAMS, None, 'reader', 200),
(ConductRepositorySearch, 'GET', SEARCH_PARAMS, None, 'devtable', 200),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, None, 401),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403),

View file

@ -98,6 +98,7 @@ def aci_signing_key():
return send_file(signer.open_public_key_file(), mimetype=PGP_KEY_MIMETYPE)
@web.route('/plans/')
@no_cache
@route_show_if(features.BILLING)
@ -105,6 +106,12 @@ def plans():
return index('')
@web.route('/search')
@no_cache
def search():
return index('')
@web.route('/guide/')
@no_cache
def guide():

View file

@ -20,6 +20,7 @@ EXTERNAL_JS = [
'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js',
'cdnjs.cloudflare.com/ajax/libs/angular-recaptcha/3.2.1/angular-recaptcha.min.js',
'cdnjs.cloudflare.com/ajax/libs/ng-tags-input/3.1.1/ng-tags-input.min.js',
'cdnjs.cloudflare.com/ajax/libs/typeahead.js/0.11.1/typeahead.bundle.min.js',
]
EXTERNAL_CSS = [

View file

@ -44,8 +44,9 @@ nav.navbar-default .navbar-nav>li>a.active {
width: 150px;
}
.header-bar-element .header-bar-content.search-visible {
box-shadow: 0px 1px 4px #ccc;
.header-bar-element .search-box-element {
margin-top: 10px;
margin-right: 16px;
}
.header-bar-element .header-bar-content {
@ -57,151 +58,6 @@ nav.navbar-default .navbar-nav>li>a.active {
background: white;
}
.header-bar-element .search-box {
position: absolute;
left: 0px;
right: 0px;
top: -60px;
z-index: 4;
height: 56px;
transition: top 0.3s cubic-bezier(.23,.88,.72,.98);
background: white;
box-shadow: 0px 1px 16px #444;
padding: 10px;
}
.header-bar-element .search-box.search-visible {
top: 50px;
}
.header-bar-element .search-box.results-visible {
box-shadow: 0px 1px 4px #ccc;
}
.header-bar-element .search-box .search-label {
display: inline-block;
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
color: #ccc;
margin-right: 10px;
position: absolute;
top: 20px;
left: 14px;
}
.header-bar-element .search-box .search-box-wrapper {
position: absolute;
top: 0px;
left: 100px;
right: 10px;
padding: 10px;
}
.header-bar-element .search-box .search-box-wrapper input {
font-size: 18px;
width: 100%;
padding: 6px;
border: 0px;
}
.header-bar-element .search-results {
position: absolute;
left: 0px;
right: 0px;
top: -106px;
z-index: 3;
transition: top 0.4s cubic-bezier(.23,.88,.72,.98), height 0.25s ease-in-out;
background: white;
box-shadow: 0px 1px 16px #444;
padding-top: 20px;
}
.header-bar-element .search-results.loading, .header-bar-element .search-results.results {
top: 106px;
}
.header-bar-element .search-results.loading {
height: 50px;
}
.header-bar-element .search-results.no-results {
height: 150px;
}
.header-bar-element .search-results ul {
padding: 0px;
margin: 0px;
}
.header-bar-element .search-results li {
list-style: none;
padding: 6px;
margin-bottom: 4px;
padding-left: 20px;
position: relative;
}
.header-bar-element .search-results li .kind {
text-transform: uppercase;
font-size: 12px;
display: inline-block;
margin-right: 10px;
color: #aaa;
width: 80px;
text-align: right;
}
.header-bar-element .search-results .avatar {
margin-left: 6px;
margin-right: 2px;
}
.header-bar-element .search-results li.current {
background: rgb(223, 242, 255);
cursor: pointer;
}
.header-bar-element .search-results li i.fa {
margin-left: 6px;
margin-right: 4px;
}
.header-bar-element .search-results li .result-description {
overflow: hidden;
text-overflow: ellipsis;
max-height: 24px;
padding-left: 10px;
display: inline-block;
color: #aaa;
vertical-align: middle;
margin-top: 2px;
}
.header-bar-element .search-results li .description img {
display: none;
}
.header-bar-element .search-results li .score:before {
content: "Score: ";
}
.header-bar-element .search-results li .score {
float: right;
color: #ccc;
}
.header-bar-element .search-results li .result-name {
vertical-align: middle;
}
.header-bar-element .search-results li .clarification {
font-size: 12px;
margin-left: 6px;
display: inline-block;
}
.header-bar-element .avatar {
margin-right: 6px;
}
@ -248,3 +104,16 @@ nav.navbar-default .navbar-nav>li>a.active {
text-align: center;
display: inline-block;
}
.header-bar-element .block-search {
padding: 6px;
padding-top: 0px;
margin-top: 0px;
text-align: right
}
.header-bar-element .block-search search-box {
margin-top: -6px;
display: inline-block;
margin-bottom: 6px;
}

View file

@ -0,0 +1,78 @@
.search-box-element {
display: inline-block;
position: relative;
}
.search-box-element input {
width: 300px;
display: inline-block;
border-radius: 0px;
height: 30px;
font-style: italic;
}
.search-box-element .search-icon {
position: absolute;
font-size: 18px;
color: #ccc;
top: 2px;
right: 6px;
}
.search-box-element .search-icon .cor-loader-inline {
top: -2px;
right: 2px;
position: absolute;
}
.search-box-element .search-icon .cor-loader-inline .co-m-loader-dot__one,
.search-box-element .search-icon .cor-loader-inline .co-m-loader-dot__two,
.search-box-element .search-icon .cor-loader-inline .co-m-loader-dot__three {
background: #ccc;
}
.search-box-result .kind {
text-transform: uppercase;
font-size: 12px;
display: inline-block;
margin-right: 10px;
color: #aaa;
width: 40px;
text-align: right;
}
.search-box-result .avatar {
margin-left: 6px;
margin-right: 2px;
}
.search-box-result i.fa {
margin-left: 6px;
margin-right: 4px;
}
.search-box-result .result-description {
overflow: hidden;
text-overflow: ellipsis;
max-height: 24px;
padding-left: 10px;
display: inline-block;
color: #aaa;
vertical-align: middle;
margin-top: 2px;
}
.search-box-result .description img {
display: none;
}
.search-box-result .result-name {
vertical-align: middle;
}
.search-box-result .clarification {
font-size: 12px;
margin-left: 6px;
display: inline-block;
vertical-align: middle;
}

View file

@ -0,0 +1,15 @@
.tt-menu {
background-color: #fff;
border: 1px solid #ccc;
border: 1px solid rgba(0, 0, 0, 0.2);
right: 0px;
}
.tt-suggestion.tt-is-under-cursor {
color: #fff;
background-color: #428bca;
}
.tt-hint {
display: none;
}

View file

@ -0,0 +1,84 @@
.search {
padding: 40px;
}
.search .empty {
margin-top: 40px;
}
.search .search-top-bar {
text-align: center;
padding: 10px;
}
.search .search-results-section {
border-top: 1px solid #ccc;
padding-top: 16px;
margin-top: 30px;
}
.search .search-results-section h5 {
display: block;
text-align: center;
color: #aaa;
text-transform: uppercase;
}
.search .search-results li {
padding-bottom: 10px;
margin-bottom: 24px;
border-bottom: 1px solid #eee;
}
.search .search-results li .result-info-bar {
color: #888;
}
.search .search-results li .result-info-bar .activity {
float: right;
}
.search .search-results li .result-info-bar .activity .strength-indicator {
display: inline-block;
margin-left: 10px;
}
.search .search-results li .description .markdown-view-content p {
display: none;
}
.search .search-results li .description .markdown-view-content p:first-child {
display: block;
overflow: hidden;
max-height: 4em;
}
.search .search-results li h4 {
vertical-align: middle;
}
.search .search-results li h4 .fa {
margin-right: 6px;
display: inline-block;
}
.search .search-results li .star-count {
float: right;
color: #888;
line-height: 26px;
}
.search .search-results li .star-count .star-count-number {
display: inline-block;
}
.search .search-results li .star-icon {
color: #ffba6d;
font-size: 26px;
margin-left: 10px;
vertical-align: middle;
}
.search .search-results li .search-result-box {
padding: 6px;
}

View file

@ -1,6 +1,6 @@
<span class="header-bar-parent">
<div class="header-bar-element">
<div class="header-bar-content" ng-class="searchVisible ? 'search-visible' : ''">
<div class="header-bar-content">
<!-- Quay -->
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex1-collapse">
@ -10,11 +10,6 @@
<span id="quay-logo" ng-style="{'background-image': 'url(' + getEnterpriseLogo() + ')'}"
ng-class="Config.ENTERPRISE_LOGO_URL ? 'enterprise-logo' : 'hosted-logo'"></span>
</a>
<span class="user-tools visible-xs" style="float: right;">
<i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()"
data-placement="bottom" data-title="Search" bs-tooltip
ng-if="searchingAllowed"></i>
</span>
</div>
<!-- Collapsable stuff -->
@ -55,10 +50,8 @@
<!-- Normal -->
<ul class="nav navbar-nav navbar-right hidden-xs" ng-switch on="user.anonymous">
<li>
<span class="navbar-left user-tools">
<i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()"
data-placement="bottom" data-title="Search - Keyboard Shortcut: /" bs-tooltip
ng-if="searchingAllowed"></i>
<span class="navbar-left user-tools hidden-sm">
<search-box ng-if="searchingAllowed"></search-box>
</span>
</li>
<li>
@ -146,70 +139,11 @@
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div>
<div class="search-box" ng-class="getSearchBoxClasses(searchVisible, searchResultState)">
<div class="search-label">Search For</div>
<div class="search-box-wrapper">
<input id="search-box-input" type="search" placeholder="(Enter Search Terms)"
ng-model-options="{'debounce': 250}" ng-model="currentSearchQuery"
ng-keydown="handleSearchKeyDown($event)">
<div class="visible-sm block-search" ng-if="searchingAllowed">
<search-box></search-box>
</div>
</div>
<div class="search-results"
ng-class="searchVisible && searchResultState ? searchResultState.state : ''"
ng-class="{'height': (searchResultState.results.length * 40) + 28}">
<div class="cor-loader" ng-if="searchResultState.state == 'loading'"></div>
<div ng-if="searchResultState.state == 'no-results'">No matching results found</div>
<ul ng-if="searchResultState.state == 'results'">
<li ng-repeat="result in searchResultState.results" ng-mouseover="setCurrentResult($index)"
ng-class="searchResultState.current == $index ? 'current' : ''"
ng-click="showResult(result)">
<span class="kind">{{ result.kind }}</span>
<span class="score" style="display: none">{{ result.score.toString().substr(0, 4) }}</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">
under organization
<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="doc">
<i class="fa fa-book"></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>
</li>
</ul>
</div>
<div class="create-robot-dialog" info="createRobotInfo"
robot-created="handleRobotCreated(robot, currentPageContext)">
</div>

View file

@ -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();

View file

@ -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(/&#39\;/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; }

View 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>

View 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);
}
}

View 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
View 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'];
})();

View file

@ -54,6 +54,9 @@ function provideRoutes($routeProvider: ng.route.IRouteProvider,
}
routeBuilder
// Search
.route('/search', 'search')
// Application View
.route('/application/:namespace/:name', 'app-view')

View file

@ -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,

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,47 @@
<div class="cor-container search">
<div class="search-top-bar">
<search-box query="currentQuery"></search-box>
</div>
<div class="search-results-section">
<h5>Applications and Repositories</h5>
<div class="resource-view" resource="resultsResource" error-message="'Could not search results'">
<div class="empty" ng-if="!results.length">
<div class="empty-primary-msg">No matching applications or repositories found</div>
<div class="empty-secondary-msg">
Please try changing your query.
</div>
</div>
<ol class="search-results" start="{{ startIndex + 1 }}">
<li ng-repeat="result in results">
<div class="search-result-box">
<span class="star-count">
<span class="star-count-number">{{ result.stars }}</span>
<i class="star-icon starred fa fa-star"></i>
</span>
<h4>
<i class="fa fa-hdd-o" ng-if="result.kind == 'repository'"></i>
<i class="fa ci-app-cube" ng-if="result.kind == 'application'"></i>
<a href="{{ result.href }}">{{ result.namespace.name }}/{{ result.name }}</a>
</h4>
<p class="description">
<span class="markdown-view" content="result.description"></span>
</p>
<p class="result-info-bar">
Last Modified: <span am-time-ago="result.last_modified * 1000"></span>
<span class="activity">
activity
<span class="strength-indicator" value="::result.popularity"
maximum="::maxPopularity"
log-base="10"></span>
</span>
</p>
</div>
</li>
</ol>
<a class="btn btn-default" ng-click="previousPage()" ng-if="currentPage > 1">Previous Page</a>
<a class="btn btn-default" ng-click="nextPage()" ng-if="hasAdditional">Next Page</a>
</div>
</div>
</div>

View file

@ -16,6 +16,7 @@ var config = {
// Use window.angular to maintain compatibility with non-Webpack components
externals: {
angular: "angular",
jquery: "$",
},
module: {
rules: [