diff --git a/core/server/helpers/foreach.js b/core/server/helpers/foreach.js index 2aac6c05e4..5b86fb0811 100644 --- a/core/server/helpers/foreach.js +++ b/core/server/helpers/foreach.js @@ -2,71 +2,93 @@ // Usage: `{{#foreach data}}{{/foreach}}` // // Block helper designed for looping through posts - var hbs = require('express-hbs'), + errors = require('../errors'), + + hbsUtils = hbs.handlebars.Utils, foreach; foreach = function (context, options) { + if (!options) { + errors.logWarn('Need to pass an iterator to #foreach'); + } + var fn = options.fn, inverse = options.inverse, i = 0, - j = 0, columns = options.hash.columns, - key, ret = '', - data; + data, + contextPath; + + if (options.data && options.ids) { + contextPath = hbsUtils.appendContextPath(options.data.contextPath, options.ids[0]) + '.'; + } + + if (hbsUtils.isFunction(context)) { + context = context.call(this); + } if (options.data) { data = hbs.handlebars.createFrame(options.data); } - function setKeys(_data, _i, _j, _columns) { - if (_i === 0) { - _data.first = true; + function execIteration(field, index, last) { + if (data) { + data.key = field; + data.index = index; + data.number = index + 1; + data.first = index === 0; + data.last = !!last; + data.even = index % 2 === 1; + data.odd = !data.even; + data.rowStart = index % columns === 0; + data.rowEnd = index % columns === (columns - 1); + + if (contextPath) { + data.contextPath = contextPath + field; + } } - if (_i === _j - 1) { - _data.last = true; - } - // first post is index zero but still needs to be odd - if (_i % 2 === 1) { - _data.even = true; - } else { - _data.odd = true; - } - if (_i % _columns === 0) { - _data.rowStart = true; - } else if (_i % _columns === (_columns - 1)) { - _data.rowEnd = true; - } - return _data; + + ret = ret + fn(context[field], { + data: data, + blockParams: hbsUtils.blockParams([context[field], field], [contextPath + field, null]) + }); } + + function iterateArray(context) { + var j; + for (j = context.length; i < j; i += 1) { + execIteration(i, i, i === context.length - 1); + } + } + + function iterateObject(context) { + var priorKey, + key; + + for (key in context) { + if (context.hasOwnProperty(key)) { + // We're running the iterations one step out of sync so we can detect + // the last iteration without have to scan the object twice and create + // an itermediate keys array. + if (priorKey) { + execIteration(priorKey, i - 1); + } + priorKey = key; + i += 1; + } + } + if (priorKey) { + execIteration(priorKey, i - 1, true); + } + } + if (context && typeof context === 'object') { - if (context instanceof Array) { - for (j = context.length; i < j; i += 1) { - if (data) { - data.index = i; - data.first = data.rowEnd = data.rowStart = data.last = data.even = data.odd = false; - data = setKeys(data, i, j, columns); - } - ret = ret + fn(context[i], {data: data}); - } + if (hbsUtils.isArray(context)) { + iterateArray(context); } else { - for (key in context) { - if (context.hasOwnProperty(key)) { - j += 1; - } - } - for (key in context) { - if (context.hasOwnProperty(key)) { - if (data) { - data.key = key; - data.first = data.rowEnd = data.rowStart = data.last = data.even = data.odd = false; - data = setKeys(data, i, j, columns); - } - ret = ret + fn(context[key], {data: data}); - i += 1; - } - } + iterateObject(context); } } diff --git a/core/test/unit/server_helpers/foreach_spec.js b/core/test/unit/server_helpers/foreach_spec.js index 3a6ba960a5..9d9652e984 100644 --- a/core/test/unit/server_helpers/foreach_spec.js +++ b/core/test/unit/server_helpers/foreach_spec.js @@ -1,6 +1,8 @@ -/*globals describe, before, it*/ +/*globals describe, before, beforeEach, afterEach, it*/ /*jshint expr:true*/ var should = require('should'), + sinon = require('sinon'), + _ = require('lodash'), hbs = require('express-hbs'), utils = require('./utils'), @@ -9,157 +11,350 @@ var should = require('should'), helpers = require('../../../server/helpers'); describe('{{#foreach}} helper', function () { + var options, context, _this, resultData, sandbox = sinon.sandbox.create(); + before(function () { utils.loadHelpers(); }); - // passed into the foreach helper. takes the input string along with the metadata about - // the current row and builds a csv output string that can be used to check the results. - function fn(input, data) { - data = data.data; + afterEach(function () { + sandbox.restore(); + }); - // if there was no private data passed into the helper, no metadata - // was created, so just return the input - if (!data) { - return input + '\n'; + describe('(function call)', function () { + beforeEach(function () { + context = []; + _this = {}; + resultData = []; + + function fn(input, data) { + resultData.push(_.cloneDeep(data)); + } + + options = { + fn: sandbox.spy(fn), + inverse: sandbox.spy(), + data: {} + }; + }); + + function runTest(self, context, options) { + helpers.foreach.call(self, context, options); } - return input + ',' + data.first + ',' + data.rowEnd + ',' + data.rowStart + ',' + - data.last + ',' + data.even + ',' + data.odd + '\n'; - } + it('is loaded', function () { + should.exist(handlebars.helpers.foreach); + }); - function inverse(input) { - return input; - } + it('should not populate data if no private data is supplied (array)', function () { + delete options.data; + options.hash = { + columns: 0 + }; - it('is loaded', function () { - should.exist(handlebars.helpers.foreach); + // test with context as an array + context = 'hello world this is ghost'.split(' '); + + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(context, function (value, index) { + options.fn.getCall(index).args[0].should.eql(value); + should(options.fn.getCall(index).args[1].data).be.undefined; + }); + }); + + it('should not populate data if no private data is supplied (object)', function () { + delete options.data; + options.hash = { + columns: 0 + }; + + context = { + one: 'hello', + two: 'world', + three: 'this', + four: 'is', + five: 'ghost' + }; + + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(_.keys(context), function (value, index) { + options.fn.getCall(index).args[0].should.eql(context[value]); + should(options.fn.getCall(index).args[1].data).be.undefined; + }); + }); + + it('should populate data when private data is supplied (array)', function () { + var expected = [ + {first: true, last: false, even: false, odd: true, rowStart: false, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: false}, + {first: false, last: false, even: false, odd: true, rowStart: false, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: false}, + {first: false, last: true, even: false, odd: true, rowStart: false, rowEnd: false} + ]; + + options.hash = { + columns: 0 + }; + + context = 'hello world this is ghost'.split(' '); + + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(context, function (value, index) { + options.fn.getCall(index).args[0].should.eql(value); + should(options.fn.getCall(index).args[1].data).not.be.undefined; + + // Expected properties + resultData[index].data.should.containEql(expected[index]); + + // Incrementing properties + resultData[index].data.should.have.property('key', index); + resultData[index].data.should.have.property('index', index); + resultData[index].data.should.have.property('number', index + 1); + }); + + resultData[_.size(context) - 1].data.should.eql(options.fn.lastCall.args[1].data); + }); + + it('should populate data when private data is supplied (object)', function () { + var expected = [ + {first: true, last: false, even: false, odd: true, rowStart: false, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: false}, + {first: false, last: false, even: false, odd: true, rowStart: false, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: false}, + {first: false, last: true, even: false, odd: true, rowStart: false, rowEnd: false} + ]; + + options.hash = { + columns: 0 + }; + + context = { + one: 'hello', + two: 'world', + three: 'this', + four: 'is', + five: 'ghost' + }; + + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(_.keys(context), function (value, index) { + options.fn.getCall(index).args[0].should.eql(context[value]); + should(options.fn.getCall(index).args[1].data).not.be.undefined; + + // Expected properties + resultData[index].data.should.containEql(expected[index]); + + // Incrementing properties + resultData[index].data.should.have.property('key', value); + resultData[index].data.should.have.property('index', index); + resultData[index].data.should.have.property('number', index + 1); + }); + + resultData[_.size(context) - 1].data.should.eql(options.fn.lastCall.args[1].data); + }); + + it('should handle rowStart and rowEnd for multiple columns (array)', function () { + var expected = [ + {first: true, last: false, even: false, odd: true, rowStart: true, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: true}, + {first: false, last: false, even: false, odd: true, rowStart: true, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: true}, + {first: false, last: true, even: false, odd: true, rowStart: true, rowEnd: false} + ]; + options.hash = { + columns: 2 + }; + + // test with context as an array + context = 'hello world this is ghost'.split(' '); + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(context, function (value, index) { + options.fn.getCall(index).args[0].should.eql(value); + should(options.fn.getCall(index).args[1].data).not.be.undefined; + + // Expected properties + resultData[index].data.should.containEql(expected[index]); + + // Incrementing properties + resultData[index].data.should.have.property('key', index); + resultData[index].data.should.have.property('index', index); + resultData[index].data.should.have.property('number', index + 1); + }); + + resultData[_.size(context) - 1].data.should.eql(options.fn.lastCall.args[1].data); + }); + + it('should handle rowStart and rowEnd for multiple columns (array)', function () { + var expected = [ + {first: true, last: false, even: false, odd: true, rowStart: true, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: true}, + {first: false, last: false, even: false, odd: true, rowStart: true, rowEnd: false}, + {first: false, last: false, even: true, odd: false, rowStart: false, rowEnd: true}, + {first: false, last: true, even: false, odd: true, rowStart: true, rowEnd: false} + ]; + options.hash = { + columns: 2 + }; + + // test with context as an object + context = { + one: 'hello', + two: 'world', + three: 'this', + four: 'is', + five: 'ghost' + }; + + runTest(_this, context, options); + + options.fn.called.should.be.true; + options.fn.getCalls().length.should.eql(_.size(context)); + + _.each(_.keys(context), function (value, index) { + options.fn.getCall(index).args[0].should.eql(context[value]); + should(options.fn.getCall(index).args[1].data).not.be.undefined; + + // Expected properties + resultData[index].data.should.containEql(expected[index]); + + // Incrementing properties + resultData[index].data.should.have.property('key', value); + resultData[index].data.should.have.property('index', index); + resultData[index].data.should.have.property('number', index + 1); + }); + + resultData[_.size(context) - 1].data.should.eql(options.fn.lastCall.args[1].data); + }); + + it('should return the correct inverse result if no context is provided', function () { + _this = 'the inverse data'; + options.hash = { + columns: 0 + }; + + runTest(_this, context, options); + + options.fn.called.should.be.false; + options.inverse.called.should.be.true; + options.inverse.calledOnce.should.be.true; + }); }); - it('should return the correct result when no private data is supplied', function () { - var options = {}, - context = [], - _this = {}, - rendered; + describe('(compile)', function () { + function shouldCompileToExpected(templateString, hash, expected) { + var template = handlebars.compile(templateString), + result = template(hash); - options.fn = fn; - options.inverse = inverse; - options.hash = { - columns: 0 - }; + result.should.eql(expected); + } - // test with context as an array + /** Many of these are copied direct from the handlebars spec */ + it('foreach with object and @key', function () { + var templateString = '