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

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