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 = $('