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
This commit is contained in:
Tobias Johansson 2018-11-22 15:31:53 +01:00
parent a897e201ba
commit 6ae9a5ddeb
53 changed files with 1954 additions and 623 deletions

View File

@ -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) {

View File

@ -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']

View File

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

View File

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

View File

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

View File

@ -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']:

View File

@ -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('<p id="no-hits">We didn\'t find any items. Try searching something else.</p>');
}
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 = '<p id="no-hits">We didn\'t find any items. Try searching something else.</p>';
$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('');
});

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -0,0 +1 @@
export { QuickSearch } from './QuickSearch';

View File

@ -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 $('<div>')
.addClass('qs-msg text-center p-3')
.append(
$('<div>')
.addClass('h1 pi-displeased'),
$('<div>')
.addClass('h2')
.append(
$('<a>')
.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 = $('<input>')
.addClass('qs-input')
.attr('type', 'search')
.attr('autocomplete', 'off')
.attr('spellcheck', 'false')
.attr('autocorrect', 'false')
.attr('placeholder', 'Search...');
let workingSymbol = $('<i>')
.addClass('pi-cancel qs-busy-symbol');
let inputComponent = [input, workingSymbol];
if (Object.keys(searches).length > 1) {
let i = 0;
let select = $('<select>')
.append(
$.map(searches, (it, value) => {
let option = $('<option>')
.attr('value', value)
.text(it['name']);
if (i === 0) {
option.attr('selected', 'selected');
}
i += 1;
return option;
})
);
inputComponent.push(select);
}
return inputComponent;
}
/**
* Creates the search result
* @param {List} results
* @param {String} advancedUrl
* @returns {$element} The jQuery object that is rendered as the result
*/
function create$results(results, advancedUrl) {
let $results = results.reduce((agg, res)=> {
if(res['result'].length) {
agg.push(
$('<a>')
.addClass('h4 mt-4 d-flex')
.attr('href', res['url'])
.text(res['name'])
)
agg.push(
$('<div>')
.addClass('card-deck card-deck-responsive card-padless js-asset-list p-3')
.append(
...pillar.templates.Nodes.createListOf$nodeItems(res['result'], 10, 0)
)
)
}
return agg;
}, [])
$results.push(
$('<a>')
.attr('href', advancedUrl)
.text('Advanced search...')
)
return $('<div>')
.addClass('m-auto qs-result')
.append(...$results)
}
export { create$noHits, create$results, create$input }

View File

@ -1 +0,0 @@
export { Nodes } from './templates/templates'

View File

@ -1,5 +1,4 @@
import { Assets } from '../assets'
import {} from ''
import { Assets } from '../nodes/Assets'
jest.useFakeTimers();
@ -8,11 +7,16 @@ describe('Assets', () => {
let nodeDoc;
let spyGet;
beforeEach(()=>{
// mock now to get a stable pretty printed created
Date.now = jest.fn(() => new Date(Date.UTC(2018,
10, //November! zero based month!
28, 11, 46, 30)).valueOf()); // A Tuesday
nodeDoc = {
_id: 'my-asset-id',
name: 'My Asset',
pretty_created: '2 hours ago',
node_type: 'asset',
_created: "Wed, 07 Nov 2018 16:35:09 GMT",
project: {
name: 'My Project',
url: 'url-to-project'
@ -52,8 +56,9 @@ describe('Assets', () => {
let $card = Assets.create$listItem(nodeDoc);
jest.runAllTimers();
expect($card.length).toEqual(1);
expect($card.prop('tagName')).toEqual('A');
expect($card.hasClass('card asset')).toBeTruthy();
expect($card.prop('tagName')).toEqual('A'); // <a>
expect($card.hasClass('asset')).toBeTruthy();
expect($card.hasClass('card')).toBeTruthy();
expect($card.attr('href')).toEqual('/nodes/my-asset-id/redir');
expect($card.attr('title')).toEqual('My Asset');
@ -77,14 +82,17 @@ describe('Assets', () => {
let $watched = $card.find('.card-label');
expect($watched.length).toEqual(0);
expect($card.find(':contains(3 weeks ago)').length).toBeTruthy();
done();
});
test('node without picture', done => {
let $card = Assets.create$listItem(nodeDoc);
expect($card.length).toEqual(1);
expect($card.prop('tagName')).toEqual('A');
expect($card.hasClass('card asset')).toBeTruthy();
expect($card.prop('tagName')).toEqual('A'); // <a>
expect($card.hasClass('asset')).toBeTruthy();
expect($card.hasClass('card')).toBeTruthy();
expect($card.attr('href')).toEqual('/nodes/my-asset-id/redir');
expect($card.attr('title')).toEqual('My Asset');
@ -107,9 +115,10 @@ describe('Assets', () => {
let $watched = $card.find('.card-label');
expect($watched.length).toEqual(0);
expect($card.find(':contains(3 weeks ago)').length).toBeTruthy();
done();
});
});
})
});

View File

@ -0,0 +1,48 @@
import { Assets } from '../nodes/Assets'
import { Users } from '../users/Users'
import { Component } from '../init' // Component is initialized in init
describe('Component', () => {
test('can create Users listItem', () => {
let userDoc = {
_id: 'my-user-id',
username: 'My User Name',
full_name: 'My full name',
roles: ['admin', 'subscriber']
};
let $user_actual = Component.create$listItem(userDoc);
expect($user_actual.length).toBe(1);
let $user_reference = Users.create$listItem(userDoc);
expect($user_actual).toEqual($user_reference);
});
test('can create Asset listItem', () => {
let nodeDoc = {
_id: 'my-asset-id',
name: 'My Asset',
node_type: 'asset',
project: {
name: 'My Project',
url: 'url-to-project'
},
properties: {
content_type: 'image'
}
};
let $asset_actual = Component.create$listItem(nodeDoc);
expect($asset_actual.length).toBe(1);
let $asset_reference = Assets.create$listItem(nodeDoc);
expect($asset_actual).toEqual($asset_reference);
});
test('fail to create unknown', () => {
expect(()=>Component.create$listItem({})).toThrow('Can not create component using: {}')
expect(()=>Component.create$listItem()).toThrow('Can not create component using: undefined')
expect(()=>Component.create$listItem({strange: 'value'}))
.toThrow('Can not create component using: {"strange":"value"}')
});
});

View File

@ -0,0 +1,67 @@
import { prettyDate } from '../utils'
describe('prettydate', () => {
beforeEach(() => {
Date.now = jest.fn(() => new Date(Date.UTC(2016,
10, //November! zero based month!
8, 11, 46, 30)).valueOf()); // A Tuesday
});
test('bad input', () => {
expect(prettyDate(undefined)).toBeUndefined();
expect(prettyDate(null)).toBeUndefined();
expect(prettyDate('my birthday')).toBeUndefined();
});
test('past dates',() => {
expect(pd({seconds: -5})).toBe('just now');
expect(pd({minutes: -5})).toBe('5m ago')
expect(pd({days: -7})).toBe('last Tuesday')
expect(pd({days: -8})).toBe('1 week ago')
expect(pd({days: -14})).toBe('2 weeks ago')
expect(pd({days: -31})).toBe('8 Oct')
expect(pd({days: -(31 + 366)})).toBe('8 Oct 2015')
});
test('past dates with time',() => {
expect(pd({seconds: -5, detailed: true})).toBe('just now');
expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
// summer time bellow
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')
});
test('future dates',() => {
expect(pd({seconds: 5})).toBe('just now')
expect(pd({minutes: 5})).toBe('in 5m')
expect(pd({days: 7})).toBe('next Tuesday')
expect(pd({days: 8})).toBe('in 1 week')
expect(pd({days: 14})).toBe('in 2 weeks')
expect(pd({days: 30})).toBe('8 Dec')
expect(pd({days: 30 + 365})).toBe('8 Dec 2017')
});
test('future dates',() => {
expect(pd({seconds: 5, detailed: true})).toBe('just now')
expect(pd({minutes: 5, detailed: true})).toBe('in 5m')
expect(pd({days: 7, detailed: true})).toBe('next Tuesday at 11:46')
expect(pd({days: 8, detailed: true})).toBe('in 1 week at 11:46')
expect(pd({days: 14, detailed: true})).toBe('in 2 weeks at 11:46')
expect(pd({days: 30, detailed: true})).toBe('8 Dec at 11:46')
expect(pd({days: 30 + 365, detailed: true})).toBe('8 Dec 2017 at 11:46')
});
function pd(params) {
let theDate = new Date(Date.now());
theDate.setFullYear(theDate.getFullYear() + (params['years'] || 0));
theDate.setMonth(theDate.getMonth() + (params['months'] || 0));
theDate.setDate(theDate.getDate() + (params['days'] || 0));
theDate.setHours(theDate.getHours() + (params['hours'] || 0));
theDate.setMinutes(theDate.getMinutes() + (params['minutes'] || 0));
theDate.setSeconds(theDate.getSeconds() + (params['seconds'] || 0));
return prettyDate(theDate, (params['detailed'] || false))
}
});

View File

@ -1,97 +0,0 @@
import { NodesFactoryInterface } from './nodes'
import { thenLoadImage, thenLoadVideoProgress } from './utils';
class Assets extends NodesFactoryInterface{
static create$listItem(node) {
var markIfPublic = true;
let $card = $('<a class="card asset card-image-fade pr-0 mx-0 mb-2">')
.addClass('js-tagged-asset')
.attr('href', '/nodes/' + node._id + '/redir')
.attr('title', node.name);
let $thumbnailContainer = $('<div class="embed-responsive embed-responsive-16by9">');
function warnNoPicture() {
let $cardIcon = $('<div class="card-img-top card-icon embed-responsive-item">');
$cardIcon.html('<i class="pi-' + node.node_type + '">');
$thumbnailContainer.append($cardIcon);
}
if (!node.picture) {
warnNoPicture();
} else {
$(window).trigger('pillar:workStart');
thenLoadImage(node.picture)
.fail(warnNoPicture)
.then((imgVariation)=>{
let img = $('<img class="card-img-top embed-responsive-item">')
.attr('alt', node.name)
.attr('src', imgVariation.link)
.attr('width', imgVariation.width)
.attr('height', imgVariation.height);
$thumbnailContainer.append(img);
})
.always(function(){
$(window).trigger('pillar:workStop');
});
}
$card.append($thumbnailContainer);
/* Card body for title and meta info. */
let $cardBody = $('<div class="card-body py-2 d-flex flex-column">');
let $cardTitle = $('<div class="card-title mb-1 font-weight-bold">');
$cardTitle.text(node.name);
$cardBody.append($cardTitle);
let $cardMeta = $('<ul class="card-text list-unstyled d-flex text-black-50 mt-auto">');
let $cardProject = $('<a class="font-weight-bold pr-2">')
.attr('href', '/p/' + node.project.url)
.attr('title', node.project.name)
.text(node.project.name);
$cardMeta.append($cardProject);
$cardMeta.append('<li>' + node.pretty_created + '</li>');
$cardBody.append($cardMeta);
if (node.properties.duration){
let $cardDuration = $('<div class="card-label right">' + node.properties.duration + '</div>');
$thumbnailContainer.append($cardDuration);
/* Video progress and 'watched' label. */
$(window).trigger('pillar:workStart');
thenLoadVideoProgress(node._id)
.fail(console.log)
.then((view_progress)=>{
if (!view_progress) return
let $cardProgress = $('<div class="progress rounded-0">');
let $cardProgressBar = $('<div class="progress-bar">');
$cardProgressBar.css('width', view_progress.progress_in_percent + '%');
$cardProgress.append($cardProgressBar);
$thumbnailContainer.append($cardProgress);
if (view_progress.done){
let card_progress_done = $('<div class="card-label">WATCHED</div>');
$thumbnailContainer.append(card_progress_done);
}
})
.always(function() {
$(window).trigger('pillar:workStop');
});
}
/* 'Free' ribbon for public assets. */
if (markIfPublic && node.permissions && node.permissions.world){
$card.addClass('free');
}
$card.append($cardBody);
return $card;
}
}
export { Assets };

View File

@ -0,0 +1,34 @@
import { ComponentCreatorInterface } from './ComponentCreatorInterface'
const REGISTERED_CREATORS = []
export class Component extends ComponentCreatorInterface {
static create$listItem(doc) {
let creator = Component.getCreator(doc);
return creator.create$listItem(doc);
}
static create$item(doc) {
let creator = Component.getCreator(doc);
return creator.create$item(doc);
}
static canCreate(candidate) {
return !!Component.getCreator(candidate);
}
static regiseterCreator(creator) {
REGISTERED_CREATORS.push(creator);
}
static getCreator(doc) {
if (doc) {
for (let candidate of REGISTERED_CREATORS) {
if (candidate.canCreate(doc)) {
return candidate;
}
}
}
throw 'Can not create component using: ' + JSON.stringify(doc);
}
}

View File

@ -0,0 +1,27 @@
export class ComponentCreatorInterface {
/**
* @param {JSON} doc
* @returns {$element}
*/
static create$listItem(doc) {
throw 'Not Implemented';
}
/**
*
* @param {JSON} doc
* @returns {$element}
*/
static create$item(doc) {
throw 'Not Implemented';
}
/**
*
* @param {JSON} candidate
* @returns {boolean}
*/
static canCreate(candidate) {
throw 'Not Implemented';
}
}

View File

@ -0,0 +1,18 @@
import { Nodes } from './nodes/Nodes';
import { Assets } from './nodes/Assets';
import { Posts } from './nodes/Posts';
import { Users } from './users/Users';
import { Component } from './component/Component';
Nodes.registerTemplate('asset', Assets);
Nodes.registerTemplate('post', Posts);
Component.regiseterCreator(Nodes);
Component.regiseterCreator(Users);
export {
Nodes,
Users,
Component
};

View File

@ -1,48 +0,0 @@
let CREATE_NODE_ITEM_MAP = {}
class Nodes {
static create$listItem(node) {
return CREATE_NODE_ITEM_MAP[node.node_type].create$listItem(node);
}
static create$item(node) {
return CREATE_NODE_ITEM_MAP[node.node_type].create$item(node);
}
static createListOf$nodeItems(nodes, initial=8, loadNext=8) {
let nodesLeftToRender = nodes.slice();
let nodesToCreate = nodesLeftToRender.splice(0, initial);
let listOf$items = nodesToCreate.map(Nodes.create$listItem);
if (loadNext > 0 && nodesLeftToRender.length) {
let $link = $('<a>')
.addClass('btn btn-outline-primary px-5 mb-auto btn-block js-load-next')
.attr('href', 'javascript:void(0);')
.click((e)=> {
let $target = $(e.target);
$target.replaceWith(Nodes.createListOf$nodeItems(nodesLeftToRender, loadNext, loadNext));
})
.text('Load More');
listOf$items.push($link);
}
return listOf$items;
}
static registerTemplate(key, klass) {
CREATE_NODE_ITEM_MAP[key] = klass;
}
}
class NodesFactoryInterface{
static create$listItem(node) {
throw 'Not Implemented'
}
static create$item(node) {
throw 'Not Implemented'
}
}
export { Nodes, NodesFactoryInterface };

View File

@ -0,0 +1,45 @@
import { NodesBase } from "./NodesBase";
import { thenLoadVideoProgress } from '../utils';
export class Assets extends NodesBase{
static create$listItem(node) {
var markIfPublic = true;
let $card = super.create$listItem(node);
$card.addClass('asset');
if (node.properties && node.properties.duration){
let $thumbnailContainer = $card.find('.js-thumbnail-container')
let $cardDuration = $('<div class="card-label right">' + node.properties.duration + '</div>');
$thumbnailContainer.append($cardDuration);
/* Video progress and 'watched' label. */
$(window).trigger('pillar:workStart');
thenLoadVideoProgress(node._id)
.fail(console.log)
.then((view_progress)=>{
if (!view_progress) return
let $cardProgress = $('<div class="progress rounded-0">');
let $cardProgressBar = $('<div class="progress-bar">');
$cardProgressBar.css('width', view_progress.progress_in_percent + '%');
$cardProgress.append($cardProgressBar);
$thumbnailContainer.append($cardProgress);
if (view_progress.done){
let card_progress_done = $('<div class="card-label">WATCHED</div>');
$thumbnailContainer.append(card_progress_done);
}
})
.always(function() {
$(window).trigger('pillar:workStop');
});
}
/* 'Free' ribbon for public assets. */
if (markIfPublic && node.permissions && node.permissions.world){
$card.addClass('free');
}
return $card;
}
}

View File

@ -0,0 +1,63 @@
import { NodesBase } from './NodesBase';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
let CREATE_NODE_ITEM_MAP = {}
export class Nodes extends ComponentCreatorInterface {
/**
* Creates a small list item out of a node document
* @param {NodeDoc} node mongodb or elastic node document
*/
static create$listItem(node) {
let factory = CREATE_NODE_ITEM_MAP[node.node_type] || NodesBase;
return factory.create$listItem(node);
}
/**
* Creates a full view out of a node document
* @param {NodeDoc} node mongodb or elastic node document
*/
static create$item(node) {
let factory = CREATE_NODE_ITEM_MAP[node.node_type] || NodesBase;
return factory.create$item(node);
}
/**
* Creates a list of items and a 'Load More' button
* @param {List} nodes A list of nodes to be created
* @param {Int} initial Number of nodes to show initially
* @param {Int} loadNext Number of nodes to show when clicking 'Load More'. If 0, no load more button will be shown
*/
static createListOf$nodeItems(nodes, initial=8, loadNext=8) {
let nodesLeftToRender = nodes.slice();
let nodesToCreate = nodesLeftToRender.splice(0, initial);
let listOf$items = nodesToCreate.map(Nodes.create$listItem);
if (loadNext > 0 && nodesLeftToRender.length) {
let $link = $('<a>')
.addClass('btn btn-outline-primary px-5 mb-auto btn-block js-load-next')
.attr('href', 'javascript:void(0);')
.click((e)=> {
let $target = $(e.target);
$target.replaceWith(Nodes.createListOf$nodeItems(nodesLeftToRender, loadNext, loadNext));
})
.text('Load More');
listOf$items.push($link);
}
return listOf$items;
}
static canCreate(candidate) {
return !!candidate.node_type;
}
/**
* Register template classes to handle the cunstruction of diffrent node types
* @param { String } node_type The node type whose template that is registered
* @param { NodesBase } klass The class to handle the creation of jQuery objects
*/
static registerTemplate(node_type, klass) {
CREATE_NODE_ITEM_MAP[node_type] = klass;
}
}

View File

@ -0,0 +1,58 @@
import { thenLoadImage, prettyDate } from '../utils';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class NodesBase extends ComponentCreatorInterface {
static create$listItem(node) {
let nid = (node._id || node.objectID); // To support both mongo and elastic nodes
let $card = $('<a class="card node card-image-fade asset">')
.attr('data-node-id', nid)
.attr('href', '/nodes/' + nid + '/redir')
.attr('title', node.name);
let $thumbnailContainer = $('<div class="card-thumbnail js-thumbnail-container">');
function warnNoPicture() {
let $cardIcon = $('<div class="card-img-top card-icon">');
$cardIcon.html('<i class="pi-' + node.node_type + '">');
$thumbnailContainer.append($cardIcon);
}
if (!node.picture) {
warnNoPicture();
}
else {
$(window).trigger('pillar:workStart');
thenLoadImage(node.picture)
.fail(warnNoPicture)
.then((imgVariation) => {
let img = $('<img class="card-img-top">')
.attr('alt', node.name)
.attr('src', imgVariation.link)
.attr('width', imgVariation.width)
.attr('height', imgVariation.height);
$thumbnailContainer.append(img);
})
.always(function () {
$(window).trigger('pillar:workStop');
});
}
$card.append($thumbnailContainer);
/* Card body for title and meta info. */
let $cardBody = $('<div class="card-body p-2 d-flex flex-column">');
let $cardTitle = $('<div class="card-title px-2 mb-2 font-weight-bold">');
$cardTitle.text(node.name);
$cardBody.append($cardTitle);
let $cardMeta = $('<ul class="card-text px-2 list-unstyled d-flex text-black-50 mt-auto">');
let $cardProject = $('<a class="font-weight-bold pr-2">')
.attr('href', '/p/' + node.project.url)
.attr('title', node.project.name)
.text(node.project.name);
$cardMeta.append($cardProject);
let created = node._created || node.created_at; // mongodb + elastic
$cardMeta.append('<li>' + prettyDate(created) + '</li>');
$cardBody.append($cardMeta);
$card.append($cardBody);
return $card;
}
static canCreate(candidate) {
return !!candidate.node_type;
}
}

View File

@ -1,11 +1,10 @@
import { NodesFactoryInterface } from './nodes'
import { NodesBase } from "./NodesBase";
class Posts extends NodesFactoryInterface {
export class Posts extends NodesBase {
static create$item(post) {
let content = [];
let $title = $('<a>')
.attr('href', '/nodes/' + post._id + '/redir')
.addClass('h2 text-uppercase font-weight-bold d-block pb-3')
let $title = $('<div>')
.addClass('display-4 text-uppercase font-weight-bold')
.text(post.name);
content.push($title);
let $post = $('<div>')
@ -20,5 +19,3 @@ class Posts extends NodesFactoryInterface {
return $post;
}
}
export { Posts };

View File

@ -1,8 +0,0 @@
import { Nodes } from './nodes';
import { Assets } from './assets';
import { Posts } from './posts';
Nodes.registerTemplate('asset', Assets);
Nodes.registerTemplate('post', Posts);
export { Nodes };

View File

@ -0,0 +1,23 @@
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class Users extends ComponentCreatorInterface {
static create$listItem(userDoc) {
return $('<div>')
.addClass('users p-2 border-bottom')
.attr('data-user-id', userDoc._id || userDoc.objectID )
.append(
$('<h6>')
.addClass('mb-0 font-weight-bold')
.text(userDoc.full_name),
$('<small>')
.text(userDoc.username),
$('<small>')
.addClass('d-block roles text-info')
.text(userDoc.roles.join(', '))
)
}
static canCreate(candidate) {
return !!candidate.username;
}
}

View File

@ -0,0 +1,46 @@
import { Users } from '../Users'
describe('Users', () => {
let userDoc;
describe('create$listItem', () => {
beforeEach(()=>{
userDoc = {
_id: 'my-user-id',
username: 'My User Name',
full_name: 'My full name',
roles: ['admin', 'subscriber']
};
});
test('happy case', () => {
let $user = Users.create$listItem(userDoc);
expect($user.length).toBe(1);
expect($user.hasClass('users')).toBeTruthy();
expect($user.data('user-id')).toBe('my-user-id');
let $username = $user.find(':contains(My User Name)');
expect($username.length).toBe(1);
let $fullName = $user.find(':contains(My full name)');
expect($fullName.length).toBe(1);
let $roles = $user.find('.roles');
expect($roles.length).toBe(1);
expect($roles.text()).toBe('admin, subscriber')
});
})
describe('create$item', () => {
beforeEach(()=>{
userDoc = {
_id: 'my-user-id',
username: 'My User Name',
full_name: 'My full name',
roles: ['admin', 'subscriber']
};
});
test('Not Implemented', () => {
// Replace with proper test once implemented
expect(()=>Users.create$item(userDoc)).toThrow('Not Implemented');
});
})
});

View File

@ -21,4 +21,102 @@ function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress')
}
export { thenLoadImage, thenLoadVideoProgress };
function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}
export { thenLoadImage, thenLoadVideoProgress, prettyDate };

View File

@ -1 +0,0 @@
export { transformPlaceholder } from './utils/placeholder'

View File

@ -0,0 +1 @@
export { transformPlaceholder } from './placeholder'

View File

@ -20,7 +20,7 @@
const DEFAULT_URL = '/api/timeline';
const transformPlaceholder = pillar.utils.transformPlaceholder;
class Timeline {
export class Timeline {
constructor(target, builder) {
this._$targetDom = $(target);
this._url;
@ -197,5 +197,3 @@ $.fn.extend({
});
}
})
export { Timeline };

View File

@ -1,4 +1,4 @@
export { Timeline } from './timeline/timeline';
export { Timeline } from './Timeline';
// Init timelines on document ready
$(function() {

View File

@ -65,18 +65,21 @@ var elasticSearcher = (function() {
return false;
}),
//get response from elastic and rebuild json
//so we can be a drop in of angolia
execute: (function(){
params = {
getParams:(function(){
var params = {
q: deze.query,
page: deze.page,
project: deze.project_id,
};
//add term filters
Object.assign(params, deze.terms);
return params;
}),
var pstr = jQuery.param( params );
//get response from elastic and rebuild json
//so we can be a drop in of angolia
execute: (function(){
var pstr = jQuery.param( deze.getParams() );
if (pstr === deze.last_query) return;
$.getJSON("/api/newsearch" + deze.url + "?"+ pstr)
@ -117,6 +120,7 @@ var elasticSearcher = (function() {
page: deze.page,
toggleTerm: deze.toggleTerm,
isRefined: deze.isRefined,
getParams: deze.getParams,
};
})();
@ -155,114 +159,3 @@ var elasticSearch = (function($, url) {
}(jQuery));
$(document).ready(function() {
var searchInput = $('#cloud-search');
if (!searchInput.length) return;
var tu = searchInput.typeahead({hint: true}, {
//source: algoliaIndex.ttAdapter(),
source: elasticSearch($),
async: true,
displayKey: 'name',
limit: 9, // Above 10 it stops working from
// some magic reason
minLength: 0,
templates: {
suggestion: function(hit) {
var hitFree = (hit.is_free ? '<div class="search-hit-ribbon"><span>free</span></div>' : '');
var hitPicture;
if (hit.picture){
hitPicture = '<img src="' + hit.picture + '"/>';
} else {
hitPicture = '<div class="search-hit-thumbnail-icon">';
hitPicture += (hit.media ? '<i class="pi-' + hit.media + '"></i>' : '<i class="dark pi-'+ hit.node_type + '"></i>');
hitPicture += '</div>';
}
var $span = $('<span>').addClass('project').text(hit.project.name);
var $searchHitName = $('<div>').addClass('search-hit-name')
.attr('title', hit.name)
.text(hit.name);
const $nodeType = $('<span>').addClass('node_type').text(hit.node_type);
const hitMedia = (hit.media ? ' · ' + $('<span>').addClass('media').text(hit.media)[0].outerHTML : '');
return $('<a/>', {
href: '/nodes/'+ hit.objectID + '/redir',
class: "search-site-result",
id: hit.objectID
}).append(
'<div class="search-hit">' +
'<div class="search-hit-thumbnail">' +
hitPicture +
hitFree +
'</div>' +
$searchHitName.html() +
'<div class="search-hit-meta">' +
$span.html() + ' · ' +
$nodeType.html() +
hitMedia +
'</div>' +
'</div>'
)
}
}
});
$('.search-site-result.advanced, .search-icon').on('click', function(e){
e.stopPropagation();
e.preventDefault();
window.location.href = '/search?q='+ $("#cloud-search").val() + '&page=1';
});
searchInput.bind('typeahead:select', function(ev, hit) {
$('.search-icon').removeClass('pi-search').addClass('pi-spin spin');
window.location.href = '/nodes/'+ hit.objectID + '/redir';
});
searchInput.bind('typeahead:active', function() {
$('#search-overlay').addClass('active');
$('.page-body').addClass('blur');
});
searchInput.bind('typeahead:close', function() {
$('#search-overlay').removeClass('active');
$('.page-body').removeClass('blur');
});
searchInput.keyup(function(e) {
if ( $('.tt-dataset').is(':empty') ){
if(e.keyCode == 13){
window.location.href = '/search#q='+ $("#cloud-search").val() + '&page=1';
}
}
});
searchInput.bind('typeahead:render', function(event, suggestions, async, dataset) {
if( suggestions != undefined && $('.tt-all-results').length <= 0){
$('.tt-dataset').append(
$("<a/>", {
id: "search-advanced",
href: '/search?q='+ $("#cloud-search").val() + '&page=1',
class: "search-site-result advanced tt-suggestion",
}).append(
'<div class="search-hit">' +
'<div class="search-hit-thumbnail">' +
'<div class="search-hit-thumbnail-icon">' +
'<i class="pi-search"></i>' +
'</div>' +
'</div>' +
'<div class="search-hit-name">' +
'Use Advanced Search' +
'</div>' +
'</div>'
)
);
}
});
});

View File

@ -42,6 +42,9 @@ $color-info: #68B3C8 !default
$color-success: #27AE60 !default
$color-danger: #EB5E28 !default
$short-transition: 150ms
$long-transition: 650ms
/* Borrowed from dillo.space :) */
$color_upvote: #ff8b60 !default
$color_downvote: #74a4ff !default

View File

@ -6,6 +6,7 @@ body.edit, body.sharing, body.attract, body.flamenco,
body.svnman, body.edit_node_types, body.search-project
nav.navbar
@extend .navbar-dark
padding-right: 5px
#project-container
display: flex

View File

@ -311,6 +311,9 @@ $search-hit-width_grid: 100px
display: none
#search-sidebar
.facet-title
text-transform: capitalize
.toggleRefine
display: block
padding-left: 7px
@ -480,4 +483,4 @@ $search-hit-width_grid: 100px
&.active
color: white
background-color: $primary
border-color: transparent
border-color: transparent

View File

@ -143,6 +143,7 @@
border-color: $primary
box-shadow: none
color: $color-text
outline: 0
=label-generic
color: $color-text-dark-primary

View File

@ -46,6 +46,7 @@
@import "components/buttons"
@import "components/tooltip"
@import "components/overlay"
@import "components/search"
@import _comments
@import _notifications

View File

@ -11,6 +11,17 @@ body
max-width: 100%
min-width: auto
body.has-overlay
overflow: hidden
padding-right: 5px
.page-body
filter: blur(15px)
transition: filter $short-transition
.card
box-shadow: 1px 1px rgba(black, .1), 1px 10px 25px rgba(black, .05)
.page-content
background-color: $white

View File

@ -1,38 +1,45 @@
.card-deck
// Custom, as of bootstrap 4.1.3 there is no way to do this.
&.card-deck-responsive
@extend .row
.card
@extend .col-md-4
margin: 0 0 20px 0
$card-width-percentage: 30%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
+media-xs
$card-width-percentage: 100%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
+media-sm
flex: 1 0 50%
max-width: 50%
$card-width-percentage: 46%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: 100% / ($card-width-percentage / 1%)
+media-md
flex: 1 0 33%
max-width: 33%
$card-width-percentage: 30%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: 100% / ($card-width-percentage / 1%)
+media-lg
flex: 1 0 33%
max-width: 33%
$card-width-percentage: 30%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: 100% / ($card-width-percentage / 1%)
+media-xl
flex: 1 0 25%
max-width: 25%
$card-width-percentage: 22%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: ($card-width-percentage * 2) / ($card-width-percentage / 1%)
+media-xxl
flex: 1 0 20%
max-width: 20%
&.card-3-columns .card
+media-xl
flex: 1 0 33%
max-width: 33%
+media-xxl
flex: 1 0 33%
max-width: 33%
$card-width-percentage: 22%
flex: 1 0 $card-width-percentage
max-width: $card-width-percentage
margin-right: ($card-width-percentage * 2) / ($card-width-percentage / 1%)
&.card-deck-vertical
@extend .flex-column
@ -43,6 +50,7 @@
@extend .w-100
@extend .flex-row
@extend .p-0
@extend .mb-2
flex: initial
flex-wrap: wrap
@ -109,12 +117,10 @@ $card-progress-height: 5px
font-size: $font-size-xs
.card-img-top
background-color: $color-background
background-position: center
background-size: cover
object-fit: cover
.progress
height: $card-progress-height
position: absolute
@ -122,15 +128,30 @@ $card-progress-height: 5px
width: 100%
z-index: 1
.card-img-top
&.card-icon
display: flex
align-items: center
justify-content: center
font-size: 2em
.card-thumbnail
@extend .embed-responsive
background-color: rgba($dark, .2)
border-top-left-radius: $card-inner-border-radius
border-top-right-radius: $card-inner-border-radius
color: $dark
i
opacity: .2
&:before
padding-top: 56.25%
.card-img-top
@extend .align-items-center
@extend .d-flex
@extend .h-100
@extend .position-absolute
bottom: 0
left: 50% !important
top: 0
transform: translateX(-50%)
width: auto !important
i
font-size: 3em
opacity: .2
/* Tiny label for cards. e.g. 'WATCHED' on videos. */
.card-label

View File

@ -21,7 +21,7 @@ ul.dropdown-menu
// When not in mobile, open menus on mouse hover .dropdown in the navbar.
body:not(.is-mobile)
nav .dropdown:hover
nav .dropdown:not(.quick-search):hover
ul.dropdown-menu
display: block

View File

@ -291,3 +291,18 @@ body.is-mobile
.navbar+.page-content
padding-top: $nav-link-height
body.has-overlay
nav.navbar
background-color: $white !important
box-shadow: none !important
padding: 5px
transition: padding-top $short-transition ease-in-out, padding-bottom $short-transition ease-in-out
.nav-secondary
&:not(.keep-when-overlay)
opacity: 0
transition: opacity $short-transition, visibility $short-transition
visibility: hidden
display: none !important

View File

@ -1,87 +1,67 @@
#search-overlay
position: absolute
position: fixed
top: 0
left: 0
right: 0
bottom: 0
width: 100%
height: 100%
pointer-events: none
visibility: hidden
opacity: 0
z-index: $z-index-base + 4
transition: opacity 150ms ease-in-out
overflow-y: scroll
z-index: $zindex-sticky + 1
transition: visibility $long-transition, opacity $long-transition
&.active
&.show
opacity: 1
visibility: visible
background-color: rgba($color-background-nav, .7)
background-color: rgba($body-bg, .7)
.search-input
+media-lg
max-width: 350px
+media-md
max-width: 350px
+media-sm
max-width: 120px
+media-xs
display: block
margin: 0 10px
position: absolute
z-index: $z-index-base
right: 5px
position: relative
float: left
padding: 0
margin: 0
.qs-result
max-width: 80vw
.search-icon
position: absolute
top: 4px
left: 10px
cursor: pointer
#qs-toggle
opacity: 1
visibility: visible
&:after
@extend .tooltip-inner
.quick-search
opacity: 0
transition: opacity $short-transition
visibility: hidden
content: 'Use advanced search...'
font-size: .85em
font-style: normal
left: -10px
opacity: 0
pointer-events: none
position: absolute
top: 30px
transition: top 150ms ease-in-out, opacity 150ms ease-in-out
width: 150px
.quick-search.show
opacity: 1
visibility: visible
&:hover
&:after
opacity: 1
top: 35px
#cloud-search, .tt-hint
+text-overflow-ellipsis
border: thin solid $color-background
border-radius: 3px
font:
size: 1em
weight: 400
margin: 0
min-height: 32px
outline: none
padding: 0 20px 0 40px
transition: border 100ms ease-in-out
.qs-input
input
border-style: solid
border-radius: $border-radius
height: $input-height-inner
padding: $input-padding-y 25px $input-padding-y $input-padding-x
width: 30vw
&:focus
box-shadow: none
border: none
&+i+select
border-color: $color-primary
&::placeholder
color: rgba($color-text, .5)
transition: color 150ms ease-in-out
&.multi-scope // has a select next to it
border-right: none
border-bottom-right-radius: 0
border-top-right-radius: 0
&:hover
&::placeholder
color: rgba($color-text, .6)
select
border-width: 2px
border-left: none
border-bottom-right-radius: $border-radius
border-top-right-radius: $border-radius
height: $input-height-inner
margin-left: 5px
padding: $input-padding-y $input-padding-x
&:focus, option
// background-color: $dark
// color: $light
.qs-busy-symbol
margin-left: -2em

View File

@ -40,6 +40,7 @@
@import "components/checkbox"
@import "components/overlay"
@import "components/card"
@import "components/search"
@import "comments"
@import "notifications"

View File

@ -2,16 +2,16 @@
| {% set node_type = asset.properties.content_type if asset.properties.content_type else asset.node_type %}
a.card.asset.card-image-fade.pr-0.mx-0.mb-2(
a.card.asset.card-image-fade.mb-2(
class="js-item-open {% if asset.permissions.world and not current_user.has_cap('subscriber') %}free{% endif %}",
data-node_id="{{ asset._id }}",
title="{{ asset.name }}",
href='{{ url_for_node(node=asset) }}')
.embed-responsive.embed-responsive-16by9
.card-thumbnail
| {% if asset.picture %}
.card-img-top.embed-responsive-item(style="background-image: url({{ asset.picture.thumbnail('m', api=api) }})")
img.card-img-top(src="{{ asset.picture.thumbnail('m', api=api) }}", alt="{{ asset.name }}")
| {% else %}
.card-img-top.card-icon.embed-responsive-item
.card-img-top.card-icon
i(class="pi-{{ node_type }}")
| {% endif %}

View File

@ -23,7 +23,7 @@ include ../../../mixins/components
data-placement="top")
i.pi-list
+card-deck(id="asset_list_{{node._id}}",class="px-2")
+card-deck(id="asset_list_{{node._id}}",class="pl-4")
| {% for child in children %}
| {{ asset_list_item(child, current_user) }}
| {% endfor %}

View File

@ -17,6 +17,8 @@ include ../mixins/components
| {% endif %}
| {% endblock navigation_tabs %}
| {% block navigation_search %}{% endblock navigation_search %}
| {% block page_title %}Search{% if project %} {{ project.name }}{% endif %}{% endblock %}
| {% block head %}
@ -82,7 +84,7 @@ script.
.border-left.search-list
+card-deck(0)(id='hits', class="m-0 px-2 card-deck-vertical")
+card-deck()(id='hits', class="m-0 px-2 card-deck-vertical")
#search-details.border-left.search-details
#search-error
@ -94,7 +96,7 @@ script.
script(type="text/template", id="facet-template")
.card.border-0.p-0.m-2
.card-body.p-3.m-0
h6.text-muted {{ title }}
h6.text-muted.facet-title {{ title }}
| {{#values}}
a.facet_link.toggleRefine(
class='{{#refined}}refined{{/refined}}',
@ -106,38 +108,6 @@ script(type="text/template", id="facet-template")
small.text-black-50.float-right {{ count }}
| {{/values}}
// Hit template
script(type="text/template", id="hit-template")
a.card.asset.card-image-fade.pl-0.mx-0.mb-1(
data-hit-id='{{ objectID }}',
href="/nodes/{{ objectID }}/redir",
class="js-search-hit {{#is_free}}free{{/is_free}}")
.embed-responsive.embed-responsive-16by9
| {{#picture}}
.card-img-top.embed-responsive-item(style="background-image: url({{{ picture }}})")
| {{/picture}}
| {{^picture}}
.card-img-top.card-icon.embed-responsive-item
| {{#media}}
i(class="pi-{{{ media }}}")
| {{/media}}
| {{^media}}
i(class="pi-{{{ node_type }}}")
| {{/media}}
| {{/picture}}
.card-body.py-2.d-flex.flex-column
.card-title.mb-1.font-weight-bold
| {{ name }}
ul.card-text.list-unstyled.d-flex.text-black-50.mt-auto
li.pr-2.project {{ project.name }}
| {{#media}}
li.pr-2.text-capitalize {{{ media }}}
| {{/media}}
li.pr-2 {{{ created_at }}}
// Pagination template
script(type="text/template", id="pagination-template")
ul.search-pagination.
@ -184,7 +154,7 @@ script.
loadingBarHide();
loadingBarShow();
displayNode($(this).data('hit-id'));
displayNode($(this).data('node-id'));
$('.js-search-hit').removeClass('active');
$(this).addClass('active');
});

View File

@ -21,15 +21,6 @@ include ../mixins/components
a(href="{{url_for('projects.view', project_url=project.url, _external=True)}}")
i.pi-folder
| {% if not project.is_private %}
li.tabs-search(
title="Search",
data-toggle="tooltip",
data-placement="left")
a(href="{{url_for('projects.search', project_url=project.url, _external=True)}}")
i.pi-search
| {% endif %}
| {% if project.has_method('PUT') %}
li.active(
title="Edit Project",

View File

@ -49,7 +49,7 @@ style.
script(type="text/template", id="facet-template")
.card.border-0.p-0.m-2
.card-body.p-3.m-0
h6.text-muted {{ title }}
h6.text-muted.facet-title {{ title }}
| {{#values}}
a.facet_link.toggleRefine(
class='{{#refined}}refined{{/refined}}',
@ -61,16 +61,6 @@ script(type="text/template", id="facet-template")
small.text-black-50.float-right {{ count }}
| {{/values}}
// Hit template
script(type="text/template", id="hit-template")
.search-hit.users.cursor-pointer.p-2.border-bottom(data-user-id='{{ objectID }}')
h6.mb-0.font-weight-bold {{ full_name }}
small {{ username }}
small.d-block.search-hit-roles.text-info
| {{ roles }}
// Pagination template
script(type="text/template", id="pagination-template")
ul.search-pagination.

View File

@ -0,0 +1,335 @@
import copy
from pillar.api.search.queries import create_node_search, create_multi_node_search
from pillar.tests import AbstractPillarTest
EMPTY_QUERY = {
"query": {
"bool": {
"must": [
{
"bool": {}
}
]
}
},
}
AGGREGATIONS = {
"aggs": {
"node_type": {
"terms": {
"field": "node_type"
}
},
"media": {
"terms": {
"field": "media"
}
},
"tags": {
"terms": {
"field": "tags"
}
},
"is_free": {
"terms": {
"field": "is_free"
}
}
}
}
SORTED_DESC_BY_CREATED = {
"sort": [{
"created_at": {"order": "desc"}
}]
}
PAGE_1 = {
"from": 0,
"size": 10
}
IN_PROJECT = {
"term": {
"project.id": "MyProjectId"
}
}
NODE_INDEX = {
"index": ["test_nodes"]
}
class TestSearchNodesGlobal(AbstractPillarTest):
def test_empty_query(self):
with self.app.app_context():
search = create_node_search(query='', terms={}, page=0)
expected = {
**EMPTY_QUERY,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_empty_query_page_2(self):
with self.app.app_context():
search = create_node_search(query='', terms={}, page=1)
page_2 = copy.deepcopy(PAGE_1)
page_2['from'] = 10
expected = {
**EMPTY_QUERY,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**page_2
}
self.assertEquals(expected, search.to_dict())
def test_empty_query_with_terms(self):
with self.app.app_context():
terms = {
'is_free': '0',
'node_type': 'asset',
}
search = create_node_search(query='', terms=terms, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['filter'] = [
{
"term": {
"is_free": False
}
},
{
"term": {
"node_type": "asset"
}
}
]
expected = {
**query,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_query(self):
with self.app.app_context():
search = create_node_search(query='is there life on mars?', terms={}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"match": {
"name": "is there life on mars?"
}
},
{
"match": {
"project.name": "is there life on mars?"
}
},
{
"match": {
"user.name": "is there life on mars?"
}
},
{
"match": {
"description": "is there life on mars?"
}
},
{
"term": {
"media": "is there life on mars?"
}
},
{
"term": {
"tags": "is there life on mars?"
}
}
]
}
}
]
expected = {
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
class TestSearchNodesInProject(AbstractPillarTest):
def test_empty_query(self):
with self.app.app_context():
search = create_node_search(query='', terms={}, page=0, project_id='MyProjectId')
project_query = copy.deepcopy(EMPTY_QUERY)
project_query['query']['bool']['filter'] = [IN_PROJECT]
expected = {
**project_query,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_empty_query_page_2(self):
with self.app.app_context():
search = create_node_search(query='', terms={}, page=1, project_id='MyProjectId')
project_query = copy.deepcopy(EMPTY_QUERY)
project_query['query']['bool']['filter'] = [IN_PROJECT]
page_2 = copy.deepcopy(PAGE_1)
page_2['from'] = 10
expected = {
**project_query,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**page_2
}
self.assertEquals(expected, search.to_dict())
def test_empty_query_with_terms(self):
with self.app.app_context():
terms = {
'is_free': '1',
'media': 'video',
}
search = create_node_search(query='', terms=terms, page=1, project_id='MyProjectId')
project_query = copy.deepcopy(EMPTY_QUERY)
project_query['query']['bool']['filter'] = [
IN_PROJECT,
{
"term": {
"is_free": True
}
},
{
"term": {
"media": "video"
}
}
]
page_2 = copy.deepcopy(PAGE_1)
page_2['from'] = 10
expected = {
**project_query,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**page_2
}
self.assertEquals(expected, search.to_dict())
def test_query(self):
with self.app.app_context():
search = create_node_search(query='is there life on mars?', terms={}, page=0, project_id='MyProjectId')
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['filter'] = [IN_PROJECT]
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"match": {
"name": "is there life on mars?"
}
},
{
"match": {
"project.name": "is there life on mars?"
}
},
{
"match": {
"user.name": "is there life on mars?"
}
},
{
"match": {
"description": "is there life on mars?"
}
},
{
"term": {
"media": "is there life on mars?"
}
},
{
"term": {
"tags": "is there life on mars?"
}
}
]
}
}
]
expected = {
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
class TestSearchMultiNodes(AbstractPillarTest):
def test(self):
with self.app.app_context():
queries = [{
'query': '',
'terms': {},
'page': 0,
'project_id': ''
}, {
'query': '',
'terms': {},
'page': 1,
'project_id': 'MyProjectId'
}]
search = create_multi_node_search(queries)
first = {
**EMPTY_QUERY,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**PAGE_1
}
project_query = copy.deepcopy(EMPTY_QUERY)
project_query['query']['bool']['filter'] = [IN_PROJECT]
page_2 = copy.deepcopy(PAGE_1)
page_2['from'] = 10
second = {
** project_query,
**AGGREGATIONS,
**SORTED_DESC_BY_CREATED,
**page_2,
}
expected = [
NODE_INDEX,
first,
NODE_INDEX,
second
]
self.assertEquals(expected, search.to_dict())

View File

@ -0,0 +1,285 @@
import copy
from pillar.api.search import queries
from pillar.tests import AbstractPillarTest
SOURCE = {
"_source": {
"include": ["full_name", "objectID", "username"]
}
}
EMPTY_QUERY = {
"query": {
"bool": {
"must": [
{
"bool": {}
}
]
}
},
}
AGGREGATIONS = {
"aggs": {
"roles": {
"terms": {
"field": "roles"
}
}
}
}
PAGE_1 = {
"from": 0,
"size": 10
}
class TestSearchUsers(AbstractPillarTest):
def test_empty_query(self):
with self.app.app_context():
search = queries.create_user_search(query='', terms={}, page=0)
expected = {
**SOURCE,
**EMPTY_QUERY,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_query(self):
with self.app.app_context():
search = queries.create_user_search(query='Jens', terms={}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"match": {
"username": "Jens"
}
},
{
"match": {
"full_name": "Jens"
}
},
{
"match": {
"email": {
"query": "Jens",
"boost": 1
}
}
},
{
"term": {
"username_exact": {
"value": "Jens",
"boost": 50
}
}
}
]
}
}
]
expected = {
**SOURCE,
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_email_query(self):
with self.app.app_context():
search = queries.create_user_search(query='mail@mail.com', terms={}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"term": {
"email_exact": {
"value": "mail@mail.com",
"boost": 50
}
}
},
{
"match": {
"username": "mail@mail.com"
}
},
{
"match": {
"full_name": "mail@mail.com"
}
},
{
"match": {
"email": {
"query": "mail@mail.com",
"boost": 25
}
}
},
{
"term": {
"username_exact": {
"value": "mail@mail.com",
"boost": 50
}
}
}
]
}
}
]
expected = {
**SOURCE,
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
class TestSearchUsersAdmin(AbstractPillarTest):
def test_empty_query(self):
with self.app.app_context():
search = queries.create_user_admin_search(query='', terms={}, page=0)
expected = {
**EMPTY_QUERY,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_query(self):
with self.app.app_context():
search = queries.create_user_admin_search(query='Jens', terms={}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"match": {
"username": "Jens"
}
},
{
"match": {
"full_name": "Jens"
}
},
{
"match": {
"email": {
"query": "Jens",
"boost": 1
}
}
},
{
"term": {
"username_exact": {
"value": "Jens",
"boost": 50
}
}
}
]
}
}
]
expected = {
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_terms(self):
with self.app.app_context():
search = queries.create_user_admin_search(query='', terms={'roles': 'Admin'}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['filter'] = [
{
"term": {
"roles": "Admin"
}
}
]
expected = {
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())
def test_object_id_query(self):
with self.app.app_context():
search = queries.create_user_admin_search(query='563aca02c379cf0005e8e17d', terms={}, page=0)
query = copy.deepcopy(EMPTY_QUERY)
query['query']['bool']['must'] = [
{
"bool": {
"should": [
{
"match": {
"username": "563aca02c379cf0005e8e17d"
}
},
{
"match": {
"full_name": "563aca02c379cf0005e8e17d"
}
},
{
"match": {
"email": {
"query": "563aca02c379cf0005e8e17d",
"boost": 1
}
}
},
{
"term": {
"username_exact": {
"value": "563aca02c379cf0005e8e17d",
"boost": 50
}
}
},
{
"term": {
"objectID": {
"value": "563aca02c379cf0005e8e17d",
"boost": 100
}
}
}
]
}
}
]
expected = {
**query,
**AGGREGATIONS,
**PAGE_1
}
self.assertEquals(expected, search.to_dict())