Lazy Home: Lazy load latest blog posts and assets and group by week and
project. Javascript tutti.js and timeline.js is needed, and then the following to init the timeline: $('.timeline') .timeline({ url: '/api/timeline' }); # Javascript Notes: ## ES6 transpile: * Files in src/scripts/js/es6/common will be transpiled from modern es6 js to old es5 js, and then added to tutti.js * Files in src/scripts/js/es6/individual will be transpiled from modern es6 js to old es5 js to individual module files ## JS Testing * Added the Jest test framework to write javascript tests. * `npm test` will run all the javascript tests Thanks to Sybren for reviewing
This commit is contained in:
1
src/scripts/js/es6/common/templates.js
Normal file
1
src/scripts/js/es6/common/templates.js
Normal file
@@ -0,0 +1 @@
|
||||
export { Nodes } from './templates/templates'
|
115
src/scripts/js/es6/common/templates/__tests__/assets.test.js
Normal file
115
src/scripts/js/es6/common/templates/__tests__/assets.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Assets } from '../assets'
|
||||
import {} from ''
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('Assets', () => {
|
||||
describe('create$listItem', () => {
|
||||
let nodeDoc;
|
||||
let spyGet;
|
||||
beforeEach(()=>{
|
||||
nodeDoc = {
|
||||
_id: 'my-asset-id',
|
||||
name: 'My Asset',
|
||||
pretty_created: '2 hours ago',
|
||||
node_type: 'asset',
|
||||
project: {
|
||||
name: 'My Project',
|
||||
url: 'url-to-project'
|
||||
},
|
||||
properties: {
|
||||
content_type: 'image'
|
||||
}
|
||||
};
|
||||
|
||||
spyGet = spyOn($, 'get').and.callFake(function(url) {
|
||||
let ajaxMock = $.Deferred();
|
||||
let response = {
|
||||
variations: [{
|
||||
size: 'l',
|
||||
link: 'wrong-img-link',
|
||||
width: 150,
|
||||
height: 170,
|
||||
},{
|
||||
size: 'm',
|
||||
link: 'img-link',
|
||||
width: 50,
|
||||
height: 70,
|
||||
},{
|
||||
size: 's',
|
||||
link: 'wrong-img-link',
|
||||
width: 5,
|
||||
height: 7,
|
||||
}]
|
||||
}
|
||||
ajaxMock.resolve(response);
|
||||
return ajaxMock.promise();
|
||||
});
|
||||
});
|
||||
describe('image content', () => {
|
||||
test('node with picture', done => {
|
||||
nodeDoc.picture = 'picture_id';
|
||||
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.attr('href')).toEqual('/nodes/my-asset-id/redir');
|
||||
expect($card.attr('title')).toEqual('My Asset');
|
||||
|
||||
let $body = $card.find('.card-body');
|
||||
expect($body.length).toEqual(1);
|
||||
|
||||
let $title = $body.find('.card-title');
|
||||
expect($title.length).toEqual(1);
|
||||
|
||||
expect(spyGet).toHaveBeenCalledTimes(1);
|
||||
expect(spyGet).toHaveBeenLastCalledWith('/api/files/picture_id');
|
||||
|
||||
let $image = $card.find('img');
|
||||
expect($image.length).toEqual(1);
|
||||
|
||||
let $imageSubsititure = $card.find('.pi-asset');
|
||||
expect($imageSubsititure.length).toEqual(0);
|
||||
|
||||
let $progress = $card.find('.progress');
|
||||
expect($progress.length).toEqual(0);
|
||||
|
||||
let $watched = $card.find('.card-label');
|
||||
expect($watched.length).toEqual(0);
|
||||
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.attr('href')).toEqual('/nodes/my-asset-id/redir');
|
||||
expect($card.attr('title')).toEqual('My Asset');
|
||||
|
||||
let $body = $card.find('.card-body');
|
||||
expect($body.length).toEqual(1);
|
||||
|
||||
let $title = $body.find('.card-title');
|
||||
expect($title.length).toEqual(1);
|
||||
|
||||
expect(spyGet).toHaveBeenCalledTimes(0);
|
||||
|
||||
let $image = $card.find('img');
|
||||
expect($image.length).toEqual(0);
|
||||
|
||||
let $imageSubsititure = $card.find('.pi-asset');
|
||||
expect($imageSubsititure.length).toEqual(1);
|
||||
|
||||
let $progress = $card.find('.progress');
|
||||
expect($progress.length).toEqual(0);
|
||||
|
||||
let $watched = $card.find('.card-label');
|
||||
expect($watched.length).toEqual(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
97
src/scripts/js/es6/common/templates/assets.js
Normal file
97
src/scripts/js/es6/common/templates/assets.js
Normal file
@@ -0,0 +1,97 @@
|
||||
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 };
|
48
src/scripts/js/es6/common/templates/nodes.js
Normal file
48
src/scripts/js/es6/common/templates/nodes.js
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
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 };
|
52
src/scripts/js/es6/common/templates/posts.js
Normal file
52
src/scripts/js/es6/common/templates/posts.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NodesFactoryInterface } from './nodes'
|
||||
import { thenLoadImage } from './utils';
|
||||
|
||||
class Posts extends NodesFactoryInterface {
|
||||
static create$item(post) {
|
||||
let content = [];
|
||||
let $title = $('<div>')
|
||||
.addClass('display-4 text-uppercase font-weight-bold')
|
||||
.text(post.name);
|
||||
content.push($title);
|
||||
let $text = $('<div>')
|
||||
.addClass('lead')
|
||||
.text(post['pretty_created']);
|
||||
content.push($text);
|
||||
let $jumbotron = $('<a>')
|
||||
.addClass('jumbotron text-white jumbotron-overlay')
|
||||
.attr('href', '/nodes/' + post._id + '/redir')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('container')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('row')
|
||||
.append(
|
||||
$('<div>')
|
||||
.addClass('col-md-9')
|
||||
.append(content)
|
||||
)
|
||||
)
|
||||
);
|
||||
thenLoadImage(post.picture, 'l')
|
||||
.then((img)=>{
|
||||
$jumbotron.attr('style', 'background-image: url(' + img.link + ');')
|
||||
})
|
||||
.fail((error)=>{
|
||||
let msg = xhrErrorResponseMessage(error);
|
||||
console.log(msg || error);
|
||||
})
|
||||
let $post = $('<div>')
|
||||
.addClass('expand-image-links imgs-fluid')
|
||||
.append(
|
||||
$jumbotron,
|
||||
$('<div>')
|
||||
.addClass('node-details-description mx-auto py-5')
|
||||
.html(post['properties']['_content_html'])
|
||||
);
|
||||
|
||||
return $post;
|
||||
}
|
||||
}
|
||||
|
||||
export { Posts };
|
8
src/scripts/js/es6/common/templates/templates.js
Normal file
8
src/scripts/js/es6/common/templates/templates.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Nodes } from './nodes';
|
||||
import { Assets } from './assets';
|
||||
import { Posts } from './posts';
|
||||
|
||||
Nodes.registerTemplate('asset', Assets);
|
||||
Nodes.registerTemplate('post', Posts);
|
||||
|
||||
export { Nodes };
|
24
src/scripts/js/es6/common/templates/utils.js
Normal file
24
src/scripts/js/es6/common/templates/utils.js
Normal file
@@ -0,0 +1,24 @@
|
||||
function thenLoadImage(imgId, size = 'm') {
|
||||
return $.get('/api/files/' + imgId)
|
||||
.then((resp)=> {
|
||||
var show_variation = null;
|
||||
if (typeof resp.variations != 'undefined') {
|
||||
for (var variation of resp.variations) {
|
||||
if (variation.size != size) continue;
|
||||
show_variation = variation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (show_variation == null) {
|
||||
throw 'Image not found: ' + imgId + ' size: ' + size;
|
||||
}
|
||||
return show_variation;
|
||||
})
|
||||
}
|
||||
|
||||
function thenLoadVideoProgress(nodeId) {
|
||||
return $.get('/api/users/video/' + nodeId + '/progress')
|
||||
}
|
||||
|
||||
export { thenLoadImage, thenLoadVideoProgress };
|
1
src/scripts/js/es6/individual/timeline.js
Normal file
1
src/scripts/js/es6/individual/timeline.js
Normal file
@@ -0,0 +1 @@
|
||||
export { Timeline } from './timeline/timeline';
|
180
src/scripts/js/es6/individual/timeline/timeline.js
Normal file
180
src/scripts/js/es6/individual/timeline/timeline.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Consumes data in the form:
|
||||
* {
|
||||
* groups: [{
|
||||
* label: 'Week 32',
|
||||
* url: null, // optional
|
||||
* groups: [{
|
||||
* label: 'Spring',
|
||||
* url: '/p/spring',
|
||||
* items:{
|
||||
* post: [nodeDoc, nodeDoc], // primary (fully rendered)
|
||||
* asset: [nodeDoc, nodeDoc] // secondary (rendered as list item)
|
||||
* },
|
||||
* groups: ...
|
||||
* }]
|
||||
* }],
|
||||
* continue_from: 123456.2 // python timestamp
|
||||
* }
|
||||
*/
|
||||
|
||||
class Timeline {
|
||||
constructor(target, params, builder) {
|
||||
this._$targetDom = $(target)
|
||||
this._url = params['url'];
|
||||
this._queryParams = params['queryParams'] || {};
|
||||
this._builder = builder;
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._workStart();
|
||||
this._thenLoadMore()
|
||||
.then((it)=>{
|
||||
this._$targetDom.empty();
|
||||
this._$targetDom.append(it);
|
||||
if (this._hasMore()) {
|
||||
let btn = this._create$LoadMoreBtn();
|
||||
this._$targetDom.append(btn);
|
||||
}
|
||||
})
|
||||
.always(this._workStop.bind(this));
|
||||
}
|
||||
|
||||
_loadMore(event) {
|
||||
let $spinner = $('<i>').addClass('pi-spin spinner');
|
||||
let $loadmoreBtn = $(event.target)
|
||||
.append($spinner)
|
||||
.addClass('disabled');
|
||||
|
||||
this._workStart();
|
||||
this._thenLoadMore()
|
||||
.then((it)=>{
|
||||
$loadmoreBtn.before(it);
|
||||
})
|
||||
.always(()=>{
|
||||
if (this._hasMore()) {
|
||||
$loadmoreBtn.removeClass('disabled');
|
||||
$spinner.remove();
|
||||
} else {
|
||||
$loadmoreBtn.remove();
|
||||
}
|
||||
this._workStop();
|
||||
});
|
||||
}
|
||||
|
||||
_hasMore() {
|
||||
return !!this._queryParams['from'];
|
||||
}
|
||||
|
||||
_thenLoadMore() {
|
||||
this._workStart();
|
||||
let qParams = $.param(this._queryParams);
|
||||
return $.getJSON(this._url + '?' + qParams)
|
||||
.then(this._render.bind(this))
|
||||
.fail(this._workFailed.bind(this))
|
||||
.always(this._workStop.bind(this))
|
||||
}
|
||||
|
||||
_render(toRender) {
|
||||
this._queryParams['from'] = toRender['continue_from'];
|
||||
return toRender['groups']
|
||||
.map(this._create$Group.bind(this));
|
||||
}
|
||||
|
||||
_create$Group(group) {
|
||||
return this._builder.build$Group(0, group);
|
||||
}
|
||||
|
||||
_create$LoadMoreBtn() {
|
||||
return $('<a>')
|
||||
.addClass('btn btn-outline-primary js-load-next')
|
||||
.attr('href', 'javascript:void(0);')
|
||||
.click(this._loadMore.bind(this))
|
||||
.text('Load More Weeks');
|
||||
}
|
||||
|
||||
_workStart() {
|
||||
this._$targetDom.trigger('pillar:workStart');
|
||||
return arguments;
|
||||
}
|
||||
|
||||
_workStop() {
|
||||
this._$targetDom.trigger('pillar:workStop');
|
||||
return arguments;
|
||||
}
|
||||
|
||||
_workFailed(error) {
|
||||
let msg = xhrErrorResponseMessage(error);
|
||||
this._$targetDom.trigger('pillar:failure', msg);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
class GroupBuilder {
|
||||
build$Group(level, group) {
|
||||
let content = []
|
||||
let $label = this._create$Label(level, group['label'], group['url']);
|
||||
if (group['items']) {
|
||||
content = content.concat(this._create$Items(group['items']));
|
||||
}
|
||||
if(group['groups']) {
|
||||
content = content.concat(group['groups'].map(this.build$Group.bind(this, level+1)));
|
||||
}
|
||||
return $('<div>')
|
||||
.append(
|
||||
$label,
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
_create$Items(items) {
|
||||
let content = [];
|
||||
let primaryNodes = items['post'];
|
||||
let secondaryNodes = items['asset'];
|
||||
if (primaryNodes) {
|
||||
content.push(
|
||||
$('<div>')
|
||||
.append(primaryNodes.map(pillar.templates.Nodes.create$item))
|
||||
);
|
||||
}
|
||||
if (secondaryNodes) {
|
||||
content.push(
|
||||
$('<div>')
|
||||
.addClass('card-deck card-padless card-deck-responsive card-undefined-columns js-asset-list py-3')
|
||||
.append(pillar.templates.Nodes.createListOf$nodeItems(secondaryNodes))
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
_create$Label(level, label, url) {
|
||||
let size = level == 0 ? 'h5' : 'h6'
|
||||
if (url) {
|
||||
return $('<div>')
|
||||
.addClass(size +' sticky-top')
|
||||
.append(
|
||||
$('<a>')
|
||||
.addClass('text-muted')
|
||||
.attr('href', url)
|
||||
.text(label)
|
||||
);
|
||||
}
|
||||
return $('<div>')
|
||||
.addClass(size + ' text-muted sticky-top')
|
||||
.text(label);
|
||||
}
|
||||
}
|
||||
|
||||
$.fn.extend({
|
||||
timeline: function(params) {
|
||||
this.each(function(i, target) {
|
||||
new Timeline(target,
|
||||
params || {},
|
||||
new GroupBuilder()
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
export { Timeline };
|
2
src/scripts/js/es6/test_config/test-env.js
Normal file
2
src/scripts/js/es6/test_config/test-env.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import $ from 'jquery';
|
||||
global.$ = global.jQuery = $;
|
Reference in New Issue
Block a user