From 6ae9a5ddeb2ab9b81223ffc080c70aab52df7225 Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Thu, 22 Nov 2018 15:31:53 +0100 Subject: [PATCH] Quick-Search: Added Quick-search in the topbar Changed how and what we store in elastic to unify it with how we store things in mongodb so we can have more generic javascript code to render the data. Elastic changes: Added: Node.project.url Altered to store id instead of url Node.picture Made Post searchable ./manage.py elastic reset_index ./manage.py elastic reindex Thanks to Pablo and Sybren --- gulpfile.js | 9 +- pillar/api/search/documents.py | 6 +- pillar/api/search/queries.py | 130 ++++--- pillar/api/search/routes.py | 61 ++-- pillar/api/timeline.py | 2 - pillar/celery/search_index_tasks.py | 45 +-- src/scripts/elasticsearch.js | 106 +++--- .../js/es6/common/quicksearch/MultiSearch.js | 58 +++ .../js/es6/common/quicksearch/QuickSearch.js | 204 +++++++++++ .../js/es6/common/quicksearch/SearchFacade.js | 68 ++++ .../js/es6/common/quicksearch/SearchParams.js | 14 + src/scripts/js/es6/common/quicksearch/init.js | 1 + .../js/es6/common/quicksearch/templates.js | 93 +++++ src/scripts/js/es6/common/templates.js | 1 - .../{assets.test.js => Assets.test.js} | 25 +- .../templates/__tests__/Component.test.js | 48 +++ .../common/templates/__tests__/utils.test.js | 67 ++++ src/scripts/js/es6/common/templates/assets.js | 97 ----- .../common/templates/component/Component.js | 34 ++ .../component/ComponentCreatorInterface.js | 27 ++ src/scripts/js/es6/common/templates/init.js | 18 + src/scripts/js/es6/common/templates/nodes.js | 48 --- .../js/es6/common/templates/nodes/Assets.js | 45 +++ .../js/es6/common/templates/nodes/Nodes.js | 63 ++++ .../es6/common/templates/nodes/NodesBase.js | 58 +++ .../templates/{posts.js => nodes/Posts.js} | 11 +- .../js/es6/common/templates/templates.js | 8 - .../js/es6/common/templates/users/Users.js | 23 ++ .../templates/users/__tests__/Users.test.js | 46 +++ src/scripts/js/es6/common/templates/utils.js | 100 +++++- src/scripts/js/es6/common/utils.js | 1 - src/scripts/js/es6/common/utils/init.js | 1 + .../timeline/{timeline.js => Timeline.js} | 4 +- .../{timeline.js => timeline/init.js} | 2 +- src/scripts/tutti/4_search.js | 125 +------ src/styles/_config.sass | 3 + src/styles/_project.sass | 1 + src/styles/_search.sass | 5 +- src/styles/_utils.sass | 1 + src/styles/blog.sass | 1 + src/styles/components/_base.sass | 11 + src/styles/components/_card.sass | 85 +++-- src/styles/components/_dropdown.sass | 2 +- src/styles/components/_navbar.sass | 15 + src/styles/components/_search.sass | 112 +++--- src/styles/theatre.sass | 1 + src/templates/_macros/_asset_list_item.pug | 8 +- .../nodes/custom/group/view_embed.pug | 2 +- src/templates/nodes/search.pug | 40 +-- src/templates/projects/edit_layout.pug | 9 - src/templates/users/index.pug | 12 +- tests/test_api/test_search_queries_nodes.py | 335 ++++++++++++++++++ tests/test_api/test_search_queries_users.py | 285 +++++++++++++++ 53 files changed, 1954 insertions(+), 623 deletions(-) create mode 100644 src/scripts/js/es6/common/quicksearch/MultiSearch.js create mode 100644 src/scripts/js/es6/common/quicksearch/QuickSearch.js create mode 100644 src/scripts/js/es6/common/quicksearch/SearchFacade.js create mode 100644 src/scripts/js/es6/common/quicksearch/SearchParams.js create mode 100644 src/scripts/js/es6/common/quicksearch/init.js create mode 100644 src/scripts/js/es6/common/quicksearch/templates.js delete mode 100644 src/scripts/js/es6/common/templates.js rename src/scripts/js/es6/common/templates/__tests__/{assets.test.js => Assets.test.js} (80%) create mode 100644 src/scripts/js/es6/common/templates/__tests__/Component.test.js create mode 100644 src/scripts/js/es6/common/templates/__tests__/utils.test.js delete mode 100644 src/scripts/js/es6/common/templates/assets.js create mode 100644 src/scripts/js/es6/common/templates/component/Component.js create mode 100644 src/scripts/js/es6/common/templates/component/ComponentCreatorInterface.js create mode 100644 src/scripts/js/es6/common/templates/init.js delete mode 100644 src/scripts/js/es6/common/templates/nodes.js create mode 100644 src/scripts/js/es6/common/templates/nodes/Assets.js create mode 100644 src/scripts/js/es6/common/templates/nodes/Nodes.js create mode 100644 src/scripts/js/es6/common/templates/nodes/NodesBase.js rename src/scripts/js/es6/common/templates/{posts.js => nodes/Posts.js} (63%) delete mode 100644 src/scripts/js/es6/common/templates/templates.js create mode 100644 src/scripts/js/es6/common/templates/users/Users.js create mode 100644 src/scripts/js/es6/common/templates/users/__tests__/Users.test.js delete mode 100644 src/scripts/js/es6/common/utils.js create mode 100644 src/scripts/js/es6/common/utils/init.js rename src/scripts/js/es6/individual/timeline/{timeline.js => Timeline.js} (99%) rename src/scripts/js/es6/individual/{timeline.js => timeline/init.js} (65%) create mode 100644 tests/test_api/test_search_queries_nodes.py create mode 100644 tests/test_api/test_search_queries_users.py diff --git a/gulpfile.js b/gulpfile.js index 6c8633cd..10e892df 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -89,9 +89,11 @@ gulp.task('scripts', function(done) { }); function browserify_base(entry) { + let pathSplited = path.dirname(entry).split(path.sep); + let moduleName = pathSplited[pathSplited.length - 1]; return browserify({ entries: [entry], - standalone: 'pillar.' + path.basename(entry, '.js'), + standalone: 'pillar.' + moduleName, }) .transform(babelify, { "presets": ["@babel/preset-env"] }) .bundle() @@ -99,16 +101,17 @@ function browserify_base(entry) { .pipe(sourceStream(path.basename(entry))) .pipe(buffer()) .pipe(rename({ + basename: moduleName, extname: '.min.js' })); } function browserify_common() { - return glob.sync('src/scripts/js/es6/common/*.js').map(browserify_base); + return glob.sync('src/scripts/js/es6/common/**/init.js').map(browserify_base); } gulp.task('scripts_browserify', function(done) { - glob('src/scripts/js/es6/individual/*.js', function(err, files) { + glob('src/scripts/js/es6/individual/**/init.js', function(err, files) { if(err) done(err); var tasks = files.map(function(entry) { diff --git a/pillar/api/search/documents.py b/pillar/api/search/documents.py index 612667cb..0f8fb977 100644 --- a/pillar/api/search/documents.py +++ b/pillar/api/search/documents.py @@ -81,6 +81,7 @@ class Node(es.DocType): fields={ 'id': es.Keyword(), 'name': es.Keyword(), + 'url': es.Keyword(), } ) @@ -153,18 +154,21 @@ def create_doc_from_node_data(node_to_index: dict) -> typing.Optional[Node]: doc.objectID = str(node_to_index['objectID']) doc.node_type = node_to_index['node_type'] doc.name = node_to_index['name'] + doc.description = node_to_index.get('description') doc.user.id = str(node_to_index['user']['_id']) doc.user.name = node_to_index['user']['full_name'] doc.project.id = str(node_to_index['project']['_id']) doc.project.name = node_to_index['project']['name'] + doc.project.url = node_to_index['project']['url'] if node_to_index['node_type'] == 'asset': doc.media = node_to_index['media'] - doc.picture = node_to_index.get('picture') + doc.picture = str(node_to_index.get('picture')) doc.tags = node_to_index.get('tags') doc.license_notes = node_to_index.get('license_notes') + doc.is_free = node_to_index.get('is_free') doc.created_at = node_to_index['created'] doc.updated_at = node_to_index['updated'] diff --git a/pillar/api/search/queries.py b/pillar/api/search/queries.py index 5f657e75..99759334 100644 --- a/pillar/api/search/queries.py +++ b/pillar/api/search/queries.py @@ -3,16 +3,18 @@ import logging import typing from elasticsearch import Elasticsearch -from elasticsearch_dsl import Search, Q +from elasticsearch_dsl import Search, Q, MultiSearch from elasticsearch_dsl.query import Query from pillar import current_app log = logging.getLogger(__name__) -NODE_AGG_TERMS = ['node_type', 'media', 'tags', 'is_free'] +BOOLEAN_TERMS = ['is_free'] +NODE_AGG_TERMS = ['node_type', 'media', 'tags', *BOOLEAN_TERMS] USER_AGG_TERMS = ['roles', ] ITEMS_PER_PAGE = 10 +USER_SOURCE_INCLUDE = ['full_name', 'objectID', 'username'] # Will be set in setup_app() client: Elasticsearch = None @@ -27,26 +29,25 @@ def add_aggs_to_search(search, agg_terms): search.aggs.bucket(term, 'terms', field=term) -def make_must(must: list, terms: dict) -> list: +def make_filter(must: list, terms: dict) -> list: """ Given term parameters append must queries to the must list """ for field, value in terms.items(): - if value: - must.append({'match': {field: value}}) + if value not in (None, ''): + must.append({'term': {field: value}}) return must -def nested_bool(must: list, should: list, terms: dict, *, index_alias: str) -> Search: +def nested_bool(filters: list, should: list, terms: dict, *, index_alias: str) -> Search: """ Create a nested bool, where the aggregation selection is a must. :param index_alias: 'USER' or 'NODE', see ELASTIC_INDICES config. """ - must = make_must(must, terms) + filters = make_filter(filters, terms) bool_query = Q('bool', should=should) - must.append(bool_query) - bool_query = Q('bool', must=must) + bool_query = Q('bool', must=bool_query, filter=filters) index = current_app.config['ELASTIC_INDICES'][index_alias] search = Search(using=client, index=index) @@ -55,12 +56,34 @@ def nested_bool(must: list, should: list, terms: dict, *, index_alias: str) -> S return search +def do_multi_node_search(queries: typing.List[dict]) -> typing.List[dict]: + """ + Given user query input and term refinements + search for public published nodes + """ + search = create_multi_node_search(queries) + return _execute_multi(search) + + def do_node_search(query: str, terms: dict, page: int, project_id: str='') -> dict: """ Given user query input and term refinements search for public published nodes """ + search = create_node_search(query, terms, page, project_id) + return _execute(search) + +def create_multi_node_search(queries: typing.List[dict]) -> MultiSearch: + search = MultiSearch(using=client) + for q in queries: + search = search.add(create_node_search(**q)) + + return search + + +def create_node_search(query: str, terms: dict, page: int, project_id: str='') -> Search: + terms = _transform_terms(terms) should = [ Q('match', name=query), @@ -71,52 +94,30 @@ def do_node_search(query: str, terms: dict, page: int, project_id: str='') -> di Q('term', media=query), Q('term', tags=query), ] - - must = [] + filters = [] if project_id: - must.append({'term': {'project.id': project_id}}) - + filters.append({'term': {'project.id': project_id}}) if not query: should = [] - - search = nested_bool(must, should, terms, index_alias='NODE') + search = nested_bool(filters, should, terms, index_alias='NODE') if not query: search = search.sort('-created_at') add_aggs_to_search(search, NODE_AGG_TERMS) search = paginate(search, page) - if log.isEnabledFor(logging.DEBUG): log.debug(json.dumps(search.to_dict(), indent=4)) - - response = search.execute() - - if log.isEnabledFor(logging.DEBUG): - log.debug(json.dumps(response.to_dict(), indent=4)) - - return response.to_dict() + return search def do_user_search(query: str, terms: dict, page: int) -> dict: """ return user objects represented in elasicsearch result dict""" - must, should = _common_user_search(query) - search = nested_bool(must, should, terms, index_alias='USER') - add_aggs_to_search(search, USER_AGG_TERMS) - search = paginate(search, page) - - if log.isEnabledFor(logging.DEBUG): - log.debug(json.dumps(search.to_dict(), indent=4)) - - response = search.execute() - - if log.isEnabledFor(logging.DEBUG): - log.debug(json.dumps(response.to_dict(), indent=4)) - - return response.to_dict() + search = create_user_search(query, terms, page) + return _execute(search) def _common_user_search(query: str) -> (typing.List[Query], typing.List[Query]): - """Construct (must,shoud) for regular + admin user search.""" + """Construct (filter,should) for regular + admin user search.""" if not query: return [], [] @@ -144,8 +145,31 @@ def do_user_search_admin(query: str, terms: dict, page: int) -> dict: search all user fields and provide aggregation information """ - must, should = _common_user_search(query) + search = create_user_admin_search(query, terms, page) + return _execute(search) + +def _execute(search: Search) -> dict: + if log.isEnabledFor(logging.DEBUG): + log.debug(json.dumps(search.to_dict(), indent=4)) + resp = search.execute() + if log.isEnabledFor(logging.DEBUG): + log.debug(json.dumps(resp.to_dict(), indent=4)) + return resp.to_dict() + + +def _execute_multi(search: typing.List[Search]) -> typing.List[dict]: + if log.isEnabledFor(logging.DEBUG): + log.debug(json.dumps(search.to_dict(), indent=4)) + resp = search.execute() + if log.isEnabledFor(logging.DEBUG): + log.debug(json.dumps(resp.to_dict(), indent=4)) + return [r.to_dict() for r in resp] + + +def create_user_admin_search(query: str, terms: dict, page: int) -> Search: + terms = _transform_terms(terms) + filters, should = _common_user_search(query) if query: # We most likely got and id field. we should find it. if len(query) == len('563aca02c379cf0005e8e17d'): @@ -155,26 +179,34 @@ def do_user_search_admin(query: str, terms: dict, page: int) -> dict: 'boost': 100, # how much more it counts for the score } }}) - - search = nested_bool(must, should, terms, index_alias='USER') + search = nested_bool(filters, should, terms, index_alias='USER') add_aggs_to_search(search, USER_AGG_TERMS) search = paginate(search, page) + return search - if log.isEnabledFor(logging.DEBUG): - log.debug(json.dumps(search.to_dict(), indent=4)) - response = search.execute() - - if log.isEnabledFor(logging.DEBUG): - log.debug(json.dumps(response.to_dict(), indent=4)) - - return response.to_dict() +def create_user_search(query: str, terms: dict, page: int) -> Search: + search = create_user_admin_search(query, terms, page) + return search.source(include=USER_SOURCE_INCLUDE) def paginate(search: Search, page_idx: int) -> Search: return search[page_idx * ITEMS_PER_PAGE:(page_idx + 1) * ITEMS_PER_PAGE] +def _transform_terms(terms: dict) -> dict: + """ + Ugly hack! Elastic uses 1/0 for boolean values in its aggregate response, + but expects true/false in queries. + """ + transformed = terms.copy() + for t in BOOLEAN_TERMS: + orig = transformed.get(t) + if orig in ('1', '0'): + transformed[t] = bool(int(orig)) + return transformed + + def setup_app(app): global client diff --git a/pillar/api/search/routes.py b/pillar/api/search/routes.py index 78ca0e20..d529b22f 100644 --- a/pillar/api/search/routes.py +++ b/pillar/api/search/routes.py @@ -18,7 +18,7 @@ TERMS = [ ] -def _term_filters() -> dict: +def _term_filters(args) -> dict: """ Check if frontent wants to filter stuff on specific fields AKA facets @@ -26,35 +26,53 @@ def _term_filters() -> dict: return mapping with term field name and provided user term value """ - return {term: request.args.get(term, '') for term in TERMS} + return {term: args.get(term, '') for term in TERMS} -def _page_index() -> int: +def _page_index(page) -> int: """Return the page index from the query string.""" try: - page_idx = int(request.args.get('page') or '0') + page_idx = int(page) except TypeError: log.info('invalid page number %r received', request.args.get('page')) raise wz_exceptions.BadRequest() return page_idx -@blueprint_search.route('/') +@blueprint_search.route('/', methods=['GET']) def search_nodes(): searchword = request.args.get('q', '') project_id = request.args.get('project', '') - terms = _term_filters() - page_idx = _page_index() + terms = _term_filters(request.args) + page_idx = _page_index(request.args.get('page', 0)) result = queries.do_node_search(searchword, terms, page_idx, project_id) return jsonify(result) +@blueprint_search.route('/multisearch', methods=['GET']) +def multi_search_nodes(): + import json + if len(request.args) != 1: + log.info(f'Expected 1 argument, received {len(request.args)}') + + json_obj = json.loads([a for a in request.args][0]) + q = [] + for row in json_obj: + q.append({ + 'query': row.get('q', ''), + 'project_id': row.get('project', ''), + 'terms': _term_filters(row), + 'page': _page_index(row.get('page', 0)) + }) + + result = queries.do_multi_node_search(q) + return jsonify(result) @blueprint_search.route('/user') def search_user(): searchword = request.args.get('q', '') - terms = _term_filters() - page_idx = _page_index() + terms = _term_filters(request.args) + page_idx = _page_index(request.args.get('page', 0)) # result is the raw elasticseach output. # we need to filter fields in case of user objects. @@ -65,27 +83,6 @@ def search_user(): resp.status_code = 500 return resp - # filter sensitive stuff - # we only need. objectID, full_name, username - hits = result.get('hits', {}) - - new_hits = [] - - for hit in hits.get('hits'): - source = hit['_source'] - single_hit = { - '_source': { - 'objectID': source.get('objectID'), - 'username': source.get('username'), - 'full_name': source.get('full_name'), - } - } - - new_hits.append(single_hit) - - # replace search result with safe subset - result['hits']['hits'] = new_hits - return jsonify(result) @@ -97,8 +94,8 @@ def search_user_admin(): """ searchword = request.args.get('q', '') - terms = _term_filters() - page_idx = _page_index() + terms = _term_filters(request.args) + page_idx = _page_index(_page_index(request.args.get('page', 0))) try: result = queries.do_user_search_admin(searchword, terms, page_idx) diff --git a/pillar/api/timeline.py b/pillar/api/timeline.py index a4271223..f566022d 100644 --- a/pillar/api/timeline.py +++ b/pillar/api/timeline.py @@ -11,7 +11,6 @@ from flask import Blueprint, current_app, request, url_for import pillar from pillar import shortcodes from pillar.api.utils import jsonify, pretty_duration, str2id -from pillar.web.utils import pretty_date blueprint = Blueprint('timeline', __name__) @@ -209,7 +208,6 @@ class TimeLineBuilder: @classmethod def node_prettyfy(cls, node: dict)-> dict: - node['pretty_created'] = pretty_date(node['_created']) duration_seconds = node['properties'].get('duration_seconds') if duration_seconds is not None: node['properties']['duration'] = pretty_duration(duration_seconds) diff --git a/pillar/celery/search_index_tasks.py b/pillar/celery/search_index_tasks.py index 2f99003c..f378d707 100644 --- a/pillar/celery/search_index_tasks.py +++ b/pillar/celery/search_index_tasks.py @@ -1,4 +1,6 @@ import logging + +import bleach from bson import ObjectId from pillar import current_app @@ -10,7 +12,7 @@ from pillar.api.search import algolia_indexing log = logging.getLogger(__name__) -INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri'} +INDEX_ALLOWED_NODE_TYPES = {'asset', 'texture', 'group', 'hdri', 'post'} SEARCH_BACKENDS = { @@ -28,34 +30,6 @@ def _get_node_from_id(node_id: str): return node -def _handle_picture(node: dict, to_index: dict): - """Add picture URL in-place to the to-be-indexed node.""" - - picture_id = node.get('picture') - if not picture_id: - return - - files_collection = current_app.data.driver.db['files'] - lookup = {'_id': ObjectId(picture_id)} - picture = files_collection.find_one(lookup) - - for item in picture.get('variations', []): - if item['size'] != 't': - continue - - # Not all files have a project... - pid = picture.get('project') - if pid: - link = generate_link(picture['backend'], - item['file_path'], - str(pid), - is_public=True) - else: - link = item['link'] - to_index['picture'] = link - break - - def prepare_node_data(node_id: str, node: dict=None) -> dict: """Given a node id or a node document, return an indexable version of it. @@ -86,25 +60,30 @@ def prepare_node_data(node_id: str, node: dict=None) -> dict: users_collection = current_app.data.driver.db['users'] user = users_collection.find_one({'_id': ObjectId(node['user'])}) + clean_description = bleach.clean(node.get('_description_html') or '', strip=True) + if not clean_description and node['node_type'] == 'post': + clean_description = bleach.clean(node['properties'].get('_content_html') or '', strip=True) + to_index = { 'objectID': node['_id'], 'name': node['name'], 'project': { '_id': project['_id'], - 'name': project['name'] + 'name': project['name'], + 'url': project['url'], }, 'created': node['_created'], 'updated': node['_updated'], 'node_type': node['node_type'], + 'picture': node.get('picture') or '', 'user': { '_id': user['_id'], 'full_name': user['full_name'] }, - 'description': node.get('description'), + 'description': clean_description or None, + 'is_free': False } - _handle_picture(node, to_index) - # If the node has world permissions, compute the Free permission if 'world' in node.get('permissions', {}): if 'GET' in node['permissions']['world']: diff --git a/src/scripts/elasticsearch.js b/src/scripts/elasticsearch.js index 8ccef89e..95481a17 100644 --- a/src/scripts/elasticsearch.js +++ b/src/scripts/elasticsearch.js @@ -11,10 +11,8 @@ $(document).ready(function() { var what = ''; // Templates binding - var hitTemplate = Hogan.compile($('#hit-template').text()); var statsTemplate = Hogan.compile($('#stats-template').text()); var facetTemplate = Hogan.compile($('#facet-template').text()); - var sliderTemplate = Hogan.compile($('#slider-template').text()); var paginationTemplate = Hogan.compile($('#pagination-template').text()); // defined in tutti/4_search.js @@ -47,6 +45,7 @@ $(document).ready(function() { renderFacets(content); renderPagination(content); renderFirstHit($(hits).children('.search-hit:first')); + updateUrlParams(); }); /*************** @@ -66,7 +65,7 @@ $(document).ready(function() { window.setTimeout(function() { // Ignore getting that first result when there is none. - var hit_id = firstHit.attr('data-hit-id'); + var hit_id = firstHit.attr('data-node-id'); if (hit_id === undefined) { done(); return; @@ -87,12 +86,6 @@ $(document).ready(function() { // Initial search initWithUrlParams(); - function convertTimestamp(iso8601) { - var d = new Date(iso8601) - return d.toLocaleDateString(); - } - - function renderStats(content) { var stats = { nbHits: numberWithDelimiter(content.count), @@ -103,20 +96,17 @@ $(document).ready(function() { } function renderHits(content) { - var hitsHtml = ''; - for (var i = 0; i < content.hits.length; ++i) { - var created = content.hits[i].created_at; - if (created) { - content.hits[i].created_at = convertTimestamp(created); - } - var updated = content.hits[i].updated_at; - if (updated) { - content.hits[i].updated_at = convertTimestamp(updated); - } - hitsHtml += hitTemplate.render(content.hits[i]); + $hits.empty(); + if (content.hits.length === 0) { + $hits.html('

We didn\'t find any items. Try searching something else.

'); + } + else { + listof$hits = content.hits.map(function(hit){ + return pillar.templates.Component.create$listItem(hit) + .addClass('js-search-hit cursor-pointer search-hit'); + }) + $hits.append(listof$hits); } - if (content.hits.length === 0) hitsHtml = '

We didn\'t find any items. Try searching something else.

'; - $hits.html(hitsHtml); } function renderFacets(content) { @@ -133,7 +123,7 @@ $(document).ready(function() { var refined = search.isRefined(label, item.key); values.push({ facet: label, - label: item.key, + label: item.key_as_string || item.key, value: item.key, count: item.doc_count, refined: refined, @@ -153,7 +143,7 @@ $(document).ready(function() { buckets.forEach(storeValue(values, label)); facets.push({ - title: label, + title: removeUnderscore(label), values: values.slice(0), }); } @@ -218,6 +208,9 @@ $(document).ready(function() { $pagination.html(paginationTemplate.render(pagination)); } + function removeUnderscore(s) { + return s.replace(/_/g, ' ') + } // Event bindings // Click binding @@ -300,37 +293,46 @@ $(document).ready(function() { }; function initWithUrlParams() { - var sPageURL = location.hash; - if (!sPageURL || sPageURL.length === 0) { - return true; + var pageURL = decodeURIComponent(window.location.search.substring(1)), + urlVariables = pageURL.split('&'), + query, + i; + for (i = 0; i < urlVariables.length; i++) { + var parameterPair = urlVariables[i].split('='), + key = parameterPair[0], + sValue = parameterPair[1]; + if (!key) continue; + if (key === 'q') { + query = sValue; + continue; + } + if (key === 'page') { + var page = Number.parseInt(sValue) + search.setCurrentPage(isNaN(page) ? 0 : page) + continue; + } + if (key === 'project') { + continue; // We take the project from the path + } + if (sValue !== undefined) { + var iValue = Number.parseInt(sValue), + value = isNaN(iValue) ? sValue : iValue; + search.toggleTerm(key, value); + continue; + } + console.log('Unhandled url parameter pair:', parameterPair) } - var sURLVariables = sPageURL.split('&'); - if (!sURLVariables || sURLVariables.length === 0) { - return true; - } - var query = decodeURIComponent(sURLVariables[0].split('=')[1]); $inputField.val(query); - search.setQuery(query, what); - - for (var i = 2; i < sURLVariables.length; i++) { - var sParameterName = sURLVariables[i].split('='); - var facet = decodeURIComponent(sParameterName[0]); - var value = decodeURIComponent(sParameterName[1]); - } - // Page has to be set in the end to avoid being overwritten - var page = decodeURIComponent(sURLVariables[1].split('=')[1]) - 1; - search.setCurrentPage(page); + do_search(query || ''); } - function setURLParams(state) { - var urlParams = '?'; - var currentQuery = state.query; - urlParams += 'q=' + encodeURIComponent(currentQuery); - var currentPage = state.page + 1; - urlParams += '&page=' + currentPage; - location.replace(urlParams); + function updateUrlParams() { + var prevState = history.state, + prevTitle = document.title, + params = search.getParams(), + newUrl = window.location.pathname + '?'; + delete params['project'] // We take the project from the path + newUrl += jQuery.param(params) + history.replaceState(prevState, prevTitle, newUrl); } - - // do empty search to fill aggregations - do_search(''); }); diff --git a/src/scripts/js/es6/common/quicksearch/MultiSearch.js b/src/scripts/js/es6/common/quicksearch/MultiSearch.js new file mode 100644 index 00000000..88747294 --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/MultiSearch.js @@ -0,0 +1,58 @@ +import {SearchParams} from './SearchParams'; + +export class MultiSearch { + constructor(kwargs) { + this.uiUrl = kwargs['uiUrl']; // Url for advanced search + this.apiUrl = kwargs['apiUrl']; // Url for api calls + this.searchParams = MultiSearch.createMultiSearchParams(kwargs['searchParams']); + this.q = ''; + } + + setSearchWord(q) { + this.q = q; + this.searchParams.forEach((qsParam) => { + qsParam.setSearchWord(q); + }); + } + + getSearchUrl() { + return this.uiUrl + '?q=' + this.q; + } + + getAllParams() { + let retval = $.map(this.searchParams, (msParams) => { + return msParams.params; + }); + return retval; + } + + parseResult(rawResult) { + return $.map(rawResult, (subResult, index) => { + let name = this.searchParams[index].name; + let pStr = this.searchParams[index].getParamStr(); + let result = $.map(subResult.hits.hits, (hit) => { + return hit._source; + }); + return { + name: name, + url: this.uiUrl + '?' + pStr, + result: result, + hasResults: !!result.length + }; + }); + } + + thenExecute() { + let data = JSON.stringify(this.getAllParams()); + let rawAjax = $.getJSON(this.apiUrl, data); + let prettyPromise = rawAjax.then(this.parseResult.bind(this)); + prettyPromise['abort'] = rawAjax.abort.bind(rawAjax); // Hack to be able to abort the promise down the road + return prettyPromise; + } + + static createMultiSearchParams(argsList) { + return $.map(argsList, (args) => { + return new SearchParams(args); + }); + } +} \ No newline at end of file diff --git a/src/scripts/js/es6/common/quicksearch/QuickSearch.js b/src/scripts/js/es6/common/quicksearch/QuickSearch.js new file mode 100644 index 00000000..630b6182 --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/QuickSearch.js @@ -0,0 +1,204 @@ +import { create$noHits, create$results, create$input } from './templates' +import {SearchFacade} from './SearchFacade'; +/** + * QuickSearch : Interacts with the dom document + * 1-SearchFacade : Controls which multisearch is active + * *-MultiSearch : One multi search is typically Project or Cloud + * *-SearchParams : The search params for the individual searches + */ + +export class QuickSearch { + /** + * Interacts with the dom document and deligates the input down to the SearchFacade + * @param {selector string} searchToggle The quick-search toggle + * @param {*} kwargs + */ + constructor(searchToggle, kwargs) { + this.$body = $('body'); + this.$quickSearch = $('.quick-search'); + this.$inputComponent = $(kwargs['inputTarget']); + this.$inputComponent.empty(); + this.$inputComponent.append(create$input(kwargs['searches'])); + this.$searchInput = this.$inputComponent.find('input'); + this.$searchSelect = this.$inputComponent.find('select'); + this.$resultTarget = $(kwargs['resultTarget']); + this.$searchSymbol = this.$inputComponent.find('.qs-busy-symbol'); + this.searchFacade = new SearchFacade(kwargs['searches'] || {}); + this.$searchToggle = $(searchToggle); + this.isBusy = false; + this.attach(); + } + + attach() { + if (this.$searchSelect.length) { + this.$searchSelect + .change(this.execute.bind(this)) + .change(() => this.$searchInput.focus()); + this.$searchInput.addClass('multi-scope'); + } + + this.$searchInput + .keyup(this.onInputKeyUp.bind(this)); + + this.$inputComponent + .on('pillar:workStart', () => { + this.$searchSymbol.addClass('spinner') + this.$searchSymbol.toggleClass('pi-spin pi-cancel') + }) + .on('pillar:workStop', () => { + this.$searchSymbol.removeClass('spinner') + this.$searchSymbol.toggleClass('pi-spin pi-cancel') + }); + + this.searchFacade.setOnResultCB(this.renderResult.bind(this)); + this.searchFacade.setOnFailureCB(this.onSearchFailed.bind(this)); + this.$searchToggle + .one('click', this.execute.bind(this)); // Initial search executed once + + this.registerShowGui(); + this.registerHideGui(); + } + + registerShowGui() { + this.$searchToggle + .click((e) => { + this.showGUI(); + e.stopPropagation(); + }); + } + + registerHideGui() { + this.$searchSymbol + .click(() => { + this.hideGUI(); + }); + this.$body.click((e) => { + let $target = $(e.target); + let isClickInResult = $target.hasClass('.qs-result') || !!$target.parents('.qs-result').length; + let isClickInInput = $target.hasClass('.qs-input') || !!$target.parents('.qs-input').length; + if (!isClickInResult && !isClickInInput) { + this.hideGUI(); + } + }); + $(document).keyup((e) => { + if (e.key === 'Escape') { + this.hideGUI(); + } + }); + } + + showGUI() { + this.$body.addClass('has-overlay'); + this.$quickSearch.trigger('pillar:searchShow'); + this.$quickSearch.addClass('show'); + if (!this.$searchInput.is(':focus')) { + this.$searchInput.focus(); + } + } + + hideGUI() { + this.$body.removeClass('has-overlay'); + this.$searchToggle.addClass('pi-search'); + this.$searchInput.blur(); + this.$quickSearch.removeClass('show'); + this.$quickSearch.trigger('pillar:searchHidden'); + } + + onInputKeyUp(e) { + let newQ = this.$searchInput.val(); + let currQ = this.searchFacade.getSearchWord(); + this.searchFacade.setSearchWord(newQ); + let searchUrl = this.searchFacade.getSearchUrl(); + if (e.key === 'Enter') { + window.location.href = searchUrl; + return; + } + if (newQ !== currQ) { + this.execute(); + } + } + + execute() { + this.busy(true); + let scope = this.getScope(); + this.searchFacade.setCurrentScope(scope); + let q = this.$searchInput.val(); + this.searchFacade.setSearchWord(q); + this.searchFacade.execute(); + } + + renderResult(results) { + this.$resultTarget.empty(); + this.$resultTarget.append(this.create$result(results)); + this.busy(false); + } + + create$result(results) { + let withHits = results.reduce((aggr, subResult) => { + if (subResult.hasResults) { + aggr.push(subResult); + } + return aggr; + }, []); + + if (!withHits.length) { + return create$noHits(this.searchFacade.getSearchUrl()); + } + return create$results(results, this.searchFacade.getSearchUrl()); + } + + onSearchFailed(err) { + toastr.error(xhrErrorResponseMessage(err), 'Unable to perform search:'); + this.busy(false); + this.$inputComponent.trigger('pillar:failed', err); + } + + getScope() { + return !!this.$searchSelect.length ? this.$searchSelect.val() : 'cloud'; + } + + busy(val) { + if (val !== this.isBusy) { + var eventType = val ? 'pillar:workStart' : 'pillar:workStop'; + this.$inputComponent.trigger(eventType); + } + this.isBusy = val; + } +} + +$.fn.extend({ + /** + * $('#qs-toggle').quickSearch({ + * resultTarget: '#search-overlay', + * inputTarget: '#qs-input', + * searches: { + * project: { + * name: 'Project', + * uiUrl: '{{ url_for("projects.search", project_url=project.url)}}', + * apiUrl: '/api/newsearch/multisearch', + * searchParams: [ + * {name: 'Assets', params: {project: '{{ project._id }}', node_type: 'asset'}}, + * {name: 'Blog', params: {project: '{{ project._id }}', node_type: 'post'}}, + * {name: 'Groups', params: {project: '{{ project._id }}', node_type: 'group'}}, + * ] + * }, + * cloud: { + * name: 'Cloud', + * uiUrl: '/search', + * apiUrl: '/api/newsearch/multisearch', + * searchParams: [ + * {name: 'Assets', params: {node_type: 'asset'}}, + * {name: 'Blog', params: {node_type: 'post'}}, + * {name: 'Groups', params: {node_type: 'group'}}, + * ] + * }, + * }, + * }); + * @param {*} kwargs + */ + quickSearch: function (kwargs) { + $(this).each((i, qsElem) => { + new QuickSearch(qsElem, kwargs); + }); + } +}) \ No newline at end of file diff --git a/src/scripts/js/es6/common/quicksearch/SearchFacade.js b/src/scripts/js/es6/common/quicksearch/SearchFacade.js new file mode 100644 index 00000000..491c144a --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/SearchFacade.js @@ -0,0 +1,68 @@ +import {MultiSearch} from './MultiSearch'; + +export class SearchFacade { + /** + * One SearchFacade holds n-number of MultiSearch objects, and delegates search requests to the active mutlisearch + * @param {*} kwargs + */ + constructor(kwargs) { + this.searches = SearchFacade.createMultiSearches(kwargs); + this.currentScope = 'cloud'; // which multisearch to use + this.currRequest; + this.resultCB; + this.failureCB; + this.q = ''; + } + + setSearchWord(q) { + this.q = q; + $.each(this.searches, (k, mSearch) => { + mSearch.setSearchWord(q); + }); + } + + getSearchWord() { + return this.q; + } + + getSearchUrl() { + return this.searches[this.currentScope].getSearchUrl(); + } + + setCurrentScope(scope) { + this.currentScope = scope; + } + + execute() { + if (this.currRequest) { + this.currRequest.abort(); + } + this.currRequest = this.searches[this.currentScope].thenExecute(); + this.currRequest + .then((results) => { + this.resultCB(results); + }) + .fail((err, reason) => { + if (reason == 'abort') { + return; + } + this.failureCB(err); + }); + } + + setOnResultCB(cb) { + this.resultCB = cb; + } + + setOnFailureCB(cb) { + this.failureCB = cb; + } + + static createMultiSearches(kwargs) { + var searches = {}; + $.each(kwargs, (key, value) => { + searches[key] = new MultiSearch(value); + }); + return searches; + } +} \ No newline at end of file diff --git a/src/scripts/js/es6/common/quicksearch/SearchParams.js b/src/scripts/js/es6/common/quicksearch/SearchParams.js new file mode 100644 index 00000000..ff9d1be0 --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/SearchParams.js @@ -0,0 +1,14 @@ +export class SearchParams { + constructor(kwargs) { + this.name = kwargs['name'] || ''; + this.params = kwargs['params'] || {}; + } + + setSearchWord(q) { + this.params['q'] = q || ''; + } + + getParamStr() { + return jQuery.param(this.params); + } +} \ No newline at end of file diff --git a/src/scripts/js/es6/common/quicksearch/init.js b/src/scripts/js/es6/common/quicksearch/init.js new file mode 100644 index 00000000..a95b0281 --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/init.js @@ -0,0 +1 @@ +export { QuickSearch } from './QuickSearch'; \ No newline at end of file diff --git a/src/scripts/js/es6/common/quicksearch/templates.js b/src/scripts/js/es6/common/quicksearch/templates.js new file mode 100644 index 00000000..d009c5eb --- /dev/null +++ b/src/scripts/js/es6/common/quicksearch/templates.js @@ -0,0 +1,93 @@ +/** + * Creates the jQuery object that is rendered when nothing is found + * @param {String} advancedUrl Url to the advanced search with the current query + * @returns {$element} The jQuery element that is rendered wher there are no hits + */ +function create$noHits(advancedUrl) { + return $('
') + .addClass('qs-msg text-center p-3') + .append( + $('
') + .addClass('h1 pi-displeased'), + $('
') + .addClass('h2') + .append( + $('') + .attr('href', advancedUrl) + .text('Advanced search') + ) + ) +} +/** + * Creates the jQuery object that is rendered as the search input + * @param {Dict} searches The searches dict that is passed in on construction of the Quick-Search + * @returns {$element} The jQuery object that renders the search input components. + */ +function create$input(searches) { + let input = $('') + .addClass('qs-input') + .attr('type', 'search') + .attr('autocomplete', 'off') + .attr('spellcheck', 'false') + .attr('autocorrect', 'false') + .attr('placeholder', 'Search...'); + let workingSymbol = $('') + .addClass('pi-cancel qs-busy-symbol'); + let inputComponent = [input, workingSymbol]; + if (Object.keys(searches).length > 1) { + let i = 0; + let select = $('