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:
2018-11-12 12:57:25 +01:00
parent e2432f6e9f
commit 2990738b5d
20 changed files with 1358 additions and 6 deletions

View File

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

View 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();
});
});
})
});

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

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

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

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

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

View File

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

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

View File

@@ -0,0 +1,2 @@
import $ from 'jquery';
global.$ = global.jQuery = $;

View File

@@ -0,0 +1,24 @@
.timeline
.jumbotron
padding-top: 6em
padding-bottom: 6em
*
font-size: $h1-font-size
.lead
font-size: $font-size-base
.h5
text-align: right
background: $body-bg
opacity: 0.8
margin-right: -15px
margin-left: -15px
padding-right: 15px
.sticky-top
top: 2.5rem
body.is-mobile
.timeline
.js-asset-list
@extend .card-deck-vertical

View File

@@ -57,6 +57,7 @@
@import "components/shortcode"
@import "components/statusbar"
@import "components/search"
@import "components/timeline"
@import "components/flyout"
@import "components/forms"