From e9ffe0e27bcbdb1ca0ca0323d9d063af2a2be200 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 7 Apr 2017 17:25:44 -0400 Subject: [PATCH] Implement new search UI We now have both autocomplete-based searching for quick results, as well as a full search page for a full listing of results --- data/model/_basequery.py | 9 +- data/model/repository.py | 29 +++- endpoints/api/search.py | 93 ++++++++-- endpoints/api/test/test_search.py | 12 ++ endpoints/api/test/test_security.py | 11 +- endpoints/web.py | 7 + external_libraries.py | 1 + static/css/directives/ui/header-bar.css | 163 ++---------------- static/css/directives/ui/search-box.css | 78 +++++++++ static/css/directives/ui/typeahead.css | 15 ++ static/css/pages/search.css | 84 +++++++++ static/directives/header-bar.html | 76 +------- static/js/directives/ui/entity-search.js | 6 +- static/js/directives/ui/header-bar.js | 147 ---------------- .../ui/search-box/search-box.component.html | 54 ++++++ .../ui/search-box/search-box.component.ts | 56 ++++++ .../ui/typeahead/typeahead.directive.ts | 90 ++++++++++ static/js/pages/search.js | 49 ++++++ static/js/quay-routes.module.ts | 3 + static/js/quay.module.ts | 4 + static/lib/typeahead.bundle.min.js | 7 - static/partials/search.html | 47 +++++ webpack.config.js | 1 + 23 files changed, 649 insertions(+), 393 deletions(-) create mode 100644 endpoints/api/test/test_search.py create mode 100644 static/css/directives/ui/search-box.css create mode 100644 static/css/directives/ui/typeahead.css create mode 100644 static/css/pages/search.css create mode 100644 static/js/directives/ui/search-box/search-box.component.html create mode 100644 static/js/directives/ui/search-box/search-box.component.ts create mode 100644 static/js/directives/ui/typeahead/typeahead.directive.ts create mode 100644 static/js/pages/search.js delete mode 100644 static/lib/typeahead.bundle.min.js create mode 100644 static/partials/search.html diff --git a/data/model/_basequery.py b/data/model/_basequery.py index f37a843ea..524a91415 100644 --- a/data/model/_basequery.py +++ b/data/model/_basequery.py @@ -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: diff --git a/data/model/repository.py b/data/model/repository.py index dcd624a3d..c2f65bdfe 100644 --- a/data/model/repository.py +++ b/data/model/repository.py @@ -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()) diff --git a/endpoints/api/search.py b/endpoints/api/search.py index 582d25f50..68d927d85 100644 --- a/endpoints/api/search.py +++ b/endpoints/api/search.py @@ -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, + } diff --git a/endpoints/api/test/test_search.py b/endpoints/api/test/test_search.py new file mode 100644 index 000000000..212377352 --- /dev/null +++ b/endpoints/api/test/test_search.py @@ -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' diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 2ea155367..9f1da90cc 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -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), diff --git a/endpoints/web.py b/endpoints/web.py index c73e5dad9..cd158bbc6 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -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(): diff --git a/external_libraries.py b/external_libraries.py index edefae890..a5809f377 100644 --- a/external_libraries.py +++ b/external_libraries.py @@ -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 = [ diff --git a/static/css/directives/ui/header-bar.css b/static/css/directives/ui/header-bar.css index b1d4ca49c..19b5ed04f 100644 --- a/static/css/directives/ui/header-bar.css +++ b/static/css/directives/ui/header-bar.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; +} \ No newline at end of file diff --git a/static/css/directives/ui/search-box.css b/static/css/directives/ui/search-box.css new file mode 100644 index 000000000..16bd41efa --- /dev/null +++ b/static/css/directives/ui/search-box.css @@ -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; +} diff --git a/static/css/directives/ui/typeahead.css b/static/css/directives/ui/typeahead.css new file mode 100644 index 000000000..a4ac56eb8 --- /dev/null +++ b/static/css/directives/ui/typeahead.css @@ -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; +} \ No newline at end of file diff --git a/static/css/pages/search.css b/static/css/pages/search.css new file mode 100644 index 000000000..b2a7d1dc1 --- /dev/null +++ b/static/css/pages/search.css @@ -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; +} diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 81d77923c..bf6ede974 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -1,6 +1,6 @@
-
+
@@ -55,10 +50,8 @@
-
- -