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
|
@ -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:
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
12
endpoints/api/test/test_search.py
Normal file
12
endpoints/api/test/test_search.py
Normal 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'
|
|
@ -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),
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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;
|
||||
}
|
78
static/css/directives/ui/search-box.css
Normal file
78
static/css/directives/ui/search-box.css
Normal 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;
|
||||
}
|
15
static/css/directives/ui/typeahead.css
Normal file
15
static/css/directives/ui/typeahead.css
Normal 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;
|
||||
}
|
84
static/css/pages/search.css
Normal file
84
static/css/pages/search.css
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
7
static/lib/typeahead.bundle.min.js
vendored
7
static/lib/typeahead.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
47
static/partials/search.html
Normal file
47
static/partials/search.html
Normal 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>
|
|
@ -16,6 +16,7 @@ var config = {
|
|||
// Use window.angular to maintain compatibility with non-Webpack components
|
||||
externals: {
|
||||
angular: "angular",
|
||||
jquery: "$",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
|
Reference in a new issue