The {{#get}} helper

closes #4439

- adds basic get helper which works with the current API
- allows theme developers to make requests against the API
- supports block params and @error message
- includes 100% test coverage using posts

----

The `{{#get}}` helper is an asynchronous block helper which allows for making
requests for data from the API. This allows theme developers to customise the
data which can be shown on a particular page of a blog.

Requests can be made to the posts, tags or users API endpoints:

```
{{#get "posts" limit="3"}}
  {{#foreach posts}}
     <a href="{{url}}">{{title}}</a>
  {{/foreach}}
{{/get}}
```

The `{{#get}}` helper must be used as a block helper, it supports `{{else}}`
logic, for when no data matching the request is available or if an error has
occurred:

```
{{#get "posts" tag="photo"}}
  ...
{{else}}
  {{#if @error}}
    <p>Something went wrong: {{@error}}</p>
  {{else}}
    <p>No posts found</p>
  {{/if}}
{{/get}}
```

The helper also supports block params, meaning the data it outputs can be
given a different name:

```
{{#get "posts" featured="true" as |featured|}}
  {{#foreach featured}}
    ...
  {{/foreach}}
{{/get}}
```

Please Note: At present asynchronous helpers cannot be nested.
This commit is contained in:
Hannah Wolfe 2015-06-27 16:42:10 +01:00
parent 4c1828c027
commit 3e40637cd4
3 changed files with 415 additions and 0 deletions

101
core/server/helpers/get.js Normal file
View File

@ -0,0 +1,101 @@
// # Get Helper
// Usage: `{{#get "posts" limit="5"}}`, `{{#get "tags" limit="all"}}`
// Fetches data from the API
var _ = require('lodash'),
hbs = require('express-hbs'),
Promise = require('bluebird'),
errors = require('../errors'),
api = require('../api'),
resources,
get;
// Endpoints that the helper is able to access
resources = ['posts', 'tags', 'users'];
/**
* ## Is Browse
* Is this a Browse request or a Read request?
* @param {Object} context
* @param {Object} options
* @returns {boolean}
*/
function isBrowse(context, options) {
var browse = true;
if (options.id || options.slug) {
browse = false;
}
return browse;
}
/**
* ## Parse Options
* Ensure options passed in make sense
*
* @param {Object} data
* @param {Object} options
* @returns {*}
*/
function parseOptions(data, options) {
if (_.isArray(options.tag)) {
options.tag = _.pluck(options.tag, 'slug').join(',');
}
if (_.isObject(options.author)) {
options.author = options.author.slug;
}
return options;
}
/**
* ## Get
* @param {Object} context
* @param {Object} options
* @returns {Promise}
*/
get = function get(context, options) {
options = options || {};
options.hash = options.hash || {};
options.data = options.data || {};
var self = this,
data = hbs.handlebars.createFrame(options.data),
apiOptions = _.omit(options.hash, 'context'),
apiMethod;
if (!options.fn) {
data.error = 'Get helper must be called as a block';
errors.logWarn(data.error);
return Promise.resolve();
}
if (!_.contains(resources, context)) {
data.error = 'Invalid resource given to get helper';
errors.logWarn(data.error);
return Promise.resolve(options.inverse(self, {data: data}));
}
// Determine if this is a read or browse
apiMethod = isBrowse(context, apiOptions) ? api[context].browse : api[context].read;
// Parse the options we're going to pass to the API
apiOptions = parseOptions(this, apiOptions);
return apiMethod(apiOptions).then(function success(result) {
result = _.merge(self, result);
if (_.isEmpty(result[context])) {
return options.inverse(self, {data: data});
}
return options.fn(result, {
data: data,
blockParams: [result[context]]
});
}).catch(function error(err) {
data.error = err.message;
return options.inverse(self, {data: data});
});
};
module.exports = get;

View File

@ -21,6 +21,7 @@ coreHelpers.date = require('./date');
coreHelpers.encode = require('./encode');
coreHelpers.excerpt = require('./excerpt');
coreHelpers.foreach = require('./foreach');
coreHelpers.get = require('./get');
coreHelpers.ghost_foot = require('./ghost_foot');
coreHelpers.ghost_head = require('./ghost_head');
coreHelpers.image = require('./image');
@ -117,6 +118,7 @@ registerHelpers = function (adminHbs) {
registerAsyncThemeHelper('post_class', coreHelpers.post_class);
registerAsyncThemeHelper('next_post', coreHelpers.next_post);
registerAsyncThemeHelper('prev_post', coreHelpers.prev_post);
registerAsyncThemeHelper('get', coreHelpers.get);
// Register admin helpers
registerAdminHelper('asset', coreHelpers.asset);

View File

@ -0,0 +1,312 @@
/*globals describe, before, beforeEach, afterEach, it*/
/*jshint expr:true*/
var should = require('should'),
sinon = require('sinon'),
hbs = require('express-hbs'),
Promise = require('bluebird'),
utils = require('./utils'),
// Stuff we are testing
handlebars = hbs.handlebars,
helpers = require('../../../server/helpers'),
api = require('../../../server/api');
describe('{{#get}} helper', function () {
var sandbox;
before(function () {
utils.loadHelpers();
});
beforeEach(function () {
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
it('has loaded get block helper', function () {
should.exist(handlebars.helpers.get);
});
describe('posts', function () {
var testPostsArr = [
{id: 1, title: 'Test Post 1', author: 'cameron'},
{id: 2, title: 'Test Post 2', author: 'cameron', featured: true},
{id: 3, title: 'Test Post 3', tags: [{slug: 'test'}]},
{id: 4, title: 'Test Post 4'}
];
beforeEach(function () {
var browseStub = sandbox.stub(api.posts, 'browse'),
readStub = sandbox.stub(api.posts, 'read');
browseStub.returns(new Promise.resolve({posts: testPostsArr}));
browseStub.withArgs({limit: '3'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 3)}));
browseStub.withArgs({limit: '1'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 1)}));
browseStub.withArgs({tag: 'test'}).returns(new Promise.resolve({posts: testPostsArr.slice(2, 3)}));
browseStub.withArgs({tag: 'none'}).returns(new Promise.resolve({posts: []}));
browseStub.withArgs({author: 'cameron'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 2)}));
browseStub.withArgs({featured: 'true'}).returns(new Promise.resolve({posts: testPostsArr.slice(2, 3)}));
readStub.withArgs({id: '2'}).returns(new Promise.resolve({posts: testPostsArr.slice(1, 2)}));
});
it('should handle default browse posts call', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {}, fn: fn, inverse: inverse}
).then(function () {
fn.called.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.eql(testPostsArr);
fn.firstCall.args[0].posts.should.have.lengthOf(4);
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse posts call with limit 3', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {limit: '3'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(3);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 3));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse posts call with limit 1', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {limit: '1'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 1));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse posts call with limit 1', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {limit: '1'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 1));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse post call with explicit tag', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {tag: 'test'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse post call with relative tag', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {tag: [{slug: 'test'}]}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse post call with explicit author', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {author: 'cameron'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(2);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 2));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse post call with relative author', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {author: {slug: 'cameron'}}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(2);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 2));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle browse post call with featured:true', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {featured: 'true'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle read post by id call', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {id: '2'}, fn: fn, inverse: inverse}
).then(function () {
fn.calledOnce.should.be.true;
fn.firstCall.args[0].should.be.an.Object.with.property('posts');
fn.firstCall.args[0].posts.should.have.lengthOf(1);
fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(1, 2));
inverse.called.should.be.false;
done();
}).catch(done);
});
it('should handle empty result set', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {tag: 'none'}, fn: fn, inverse: inverse}
).then(function () {
fn.called.should.be.false;
inverse.calledOnce.should.be.true;
inverse.firstCall.args[1].should.be.an.Object.and.have.property('data');
inverse.firstCall.args[1].data.should.be.an.Object.and.not.have.property('error');
done();
}).catch(done);
});
});
describe('general error handling', function () {
it('should return an error for an unknown resource', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'magic',
{hash: {}, fn: fn, inverse: inverse}
).then(function () {
fn.called.should.be.false;
inverse.calledOnce.should.be.true;
inverse.firstCall.args[1].should.be.an.Object.and.have.property('data');
inverse.firstCall.args[1].data.should.be.an.Object.and.have.property('error');
inverse.firstCall.args[1].data.error.should.eql('Invalid resource given to get helper');
done();
}).catch(done);
});
it('should handle error from the API', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts',
{hash: {tag: 'thing!'}, fn: fn, inverse: inverse}
).then(function () {
fn.called.should.be.false;
inverse.calledOnce.should.be.true;
inverse.firstCall.args[1].should.be.an.Object.and.have.property('data');
inverse.firstCall.args[1].data.should.be.an.Object.and.have.property('error');
inverse.firstCall.args[1].data.error.should.match(/^Validation/);
done();
}).catch(done);
});
it('should show warning for call without any options', function (done) {
var fn = sinon.spy(),
inverse = sinon.spy();
helpers.get.call(
{},
'posts'
).then(function () {
fn.called.should.be.false;
inverse.called.should.be.false;
done();
}).catch(done);
});
});
});