diff --git a/lib/render.js b/lib/render.js index 372301f8..289e94d5 100644 --- a/lib/render.js +++ b/lib/render.js @@ -177,7 +177,7 @@ function formDataFromLayout(locals, uri) { * @return {Promise} */ function formDataForRenderer(unresolvedData, { _layoutRef, _ref }, locals) { - return composer.resolveComponentReferences(unresolvedData, locals) + return composer.resolveComponentReferences(unresolvedData, locals, composer.referenceProperty, _ref) .then((data) => ({ data, options: { locals, _ref, _layoutRef } diff --git a/lib/routes/_components.js b/lib/routes/_components.js index b8b53b51..8d7bf9bd 100644 --- a/lib/routes/_components.js +++ b/lib/routes/_components.js @@ -20,7 +20,9 @@ function getComposed(uri, locals) { return controller.get(uri, locals).then(function (data) { // TODO: Check that we can't just reference the composer // require here because otherwise it's a circular dependency (via html-composer) - return require('../services/composer').resolveComponentReferences(data, locals); + const composer = require('../services/composer'); + + return composer.resolveComponentReferences(data, locals, composer.referenceProperty, uri); }); } diff --git a/lib/routes/_layouts.js b/lib/routes/_layouts.js index 898c77ba..5de9d75b 100644 --- a/lib/routes/_layouts.js +++ b/lib/routes/_layouts.js @@ -23,7 +23,9 @@ function getComposed(uri, locals) { return controller.get(uri, locals).then(data => { // TODO: Check that we can't just reference the composer // require here because otherwise it's a circular dependency (via html-composer) - return require('../services/composer').resolveComponentReferences(data, locals); + const composer = require('../services/composer'); + + return composer.resolveComponentReferences(data, locals, composer.referenceProperty, uri); }); } diff --git a/lib/services/components.js b/lib/services/components.js index 0485986c..63a48224 100644 --- a/lib/services/components.js +++ b/lib/services/components.js @@ -64,7 +64,7 @@ function publish(uri, data, locals) { } return get(replaceVersion(uri), locals) - .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences)) + .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences, uri)) .then(versionedData => dbOps.cascadingPut(put)(uri, versionedData, locals)); } diff --git a/lib/services/composer.js b/lib/services/composer.js index 20a811be..452a4fb3 100644 --- a/lib/services/composer.js +++ b/lib/services/composer.js @@ -5,7 +5,11 @@ const _ = require('lodash'), components = require('./components'), references = require('./references'), mapLayoutToPageData = require('../utils/layout-to-page-data'), - referenceProperty = '_ref'; + { getSchema } = require('../utils/schema'), + { isLayout, isComponent } = require('clayutils'), + referenceProperty = '_ref', + renderOrderProperty = 'renderOrder', + defaultRenderOrder = 1; var log = require('./logger').setup({ file: __filename }); /** @@ -14,35 +18,80 @@ var log = require('./logger').setup({ file: __filename }); * @param {object} data * @param {object} locals Extra data that some GETs use * @param {function|string} [filter='_ref'] + * @param {string} [uri] * @returns {Promise} - Resolves with composed component data */ -function resolveComponentReferences(data, locals, filter = referenceProperty) { - const referenceObjects = references.listDeepObjects(data, filter); - - return bluebird.all(referenceObjects).each(referenceObject => { - return components.get(referenceObject[referenceProperty], locals) - .then(obj => { - // the thing we got back might have its own references - return resolveComponentReferences(obj, locals, filter).finally(() => { - _.assign(referenceObject, _.omit(obj, referenceProperty)); - }).catch(function (error) { - const logObj = { - stack: error.stack, - cmpt: referenceObject[referenceProperty] - }; - - if (error.status) { - logObj.status = error.status; - } - - log('error', `${error.message}`, logObj); - - return bluebird.reject(error); +function resolveComponentReferences(data, locals, filter = referenceProperty, uri) { + const referenceObjects = references.listDeepValuesByKey(data, filter), + schemaPromise = uri && Object.keys(referenceObjects).length && (isComponent(uri) || isLayout(uri)) ? getSchema(uri) : bluebird.resolve(); + + return schemaPromise.then(schema => { + const orderedRefs = orderReferences(referenceObjects, schema); + + return bluebird.each(orderedRefs, ([, referenceObject]) => { + const ref = referenceObject[referenceProperty]; + + return components.get(ref, locals) + .then(obj => { + // the thing we got back might have its own references + return resolveComponentReferences(obj, locals, filter, ref).finally(() => { + _.assign(referenceObject, _.omit(obj, referenceProperty)); + }).catch(function (error) { + const logObj = { + stack: error.stack, + cmpt: referenceObject[referenceProperty] + }; + + if (error.status) { + logObj.status = error.status; + } + + log('error', `${error.message}`, logObj); + + return bluebird.reject(error); + }); }); - }); + }); }).then(() => data); } +/** + * Orders deep references based on the parent's schema. + * @param {object} referenceObjects + * @param {object} schema + * @returns {array} + */ +function orderReferences(referenceObjects, schema) { + const pairedRefs = _.toPairs(referenceObjects); + + if (schema) { + // group and sort by orders from the schema + return _.chain(pairedRefs) + .groupBy(([path]) => { + const splitPath = path.split('.'); + + // remove array indexes + if (!isNaN(splitPath[splitPath.length - 1])) { + splitPath.pop(); + } + + return [ + _.get(schema, [...splitPath, '_componentList', renderOrderProperty]), + _.get(schema, [...splitPath, '_component', renderOrderProperty]), + defaultRenderOrder + ].find(order => _.isNumber(order)); + }) + .toPairs() + .sortBy(a => a[0]) + .map(a => a[1]) + .flatten() + .value(); + } else { + // if there's no schema, return original refs + return pairedRefs; + } +} + /** * Compose a page, recursively filling in all component references with * instance data. @@ -65,12 +114,14 @@ function composePage(pageData, locals) { * @returns {Boolean} */ function filterBaseInstanceReferences(obj) { - return _.isString(obj[referenceProperty]) && obj[referenceProperty].indexOf('/instances/') !== -1; + return _.isObject(obj) && _.isString(obj[referenceProperty]) && obj[referenceProperty].indexOf('/instances/') !== -1; } module.exports.resolveComponentReferences = resolveComponentReferences; module.exports.composePage = composePage; module.exports.filterBaseInstanceReferences = filterBaseInstanceReferences; +module.exports.referenceProperty = referenceProperty; // For testing module.exports.setLog = (fakeLogger) => log = fakeLogger; +module.exports.orderReferences = orderReferences; diff --git a/lib/services/composer.test.js b/lib/services/composer.test.js index bf552fc0..3e724106 100644 --- a/lib/services/composer.test.js +++ b/lib/services/composer.test.js @@ -200,4 +200,44 @@ describe(_.startCase(filename), function () { expect(fn({ bar: 'foo' })).to.be.false; }); }); + + describe('orderReferences', function () { + const fn = lib[this.title], + referenceObjects = { + someComponent: { _ref: 'ref1' }, + anotherComponent: { _ref: 'ref2' }, + 'componentList.0': { _ref: 'ref3' }, + 'componentList.1': { _ref: 'ref4' } + }; + + it('maintains order without a schema', function () { + const result = fn(referenceObjects); + + expect(result).to.have.deep.property('0.0', 'someComponent'); + expect(result).to.have.deep.property('1.0', 'anotherComponent'); + expect(result).to.have.deep.property('2.0', 'componentList.0'); + expect(result).to.have.deep.property('3.0', 'componentList.1'); + }); + + it('orders based on schema', function () { + const schema = { + someComponent: { + _component: { + renderOrder: 3 + } + }, + componentList: { + _componentList: { + renderOrder: 2 + } + } + }, + result = fn(referenceObjects, schema); + + expect(result).to.have.deep.property('0.0', 'anotherComponent'); + expect(result).to.have.deep.property('1.0', 'componentList.0'); + expect(result).to.have.deep.property('2.0', 'componentList.1'); + expect(result).to.have.deep.property('3.0', 'someComponent'); + }); + }); }); diff --git a/lib/services/layouts.js b/lib/services/layouts.js index 9335c5aa..b07940cc 100644 --- a/lib/services/layouts.js +++ b/lib/services/layouts.js @@ -72,7 +72,7 @@ function publish(uri, data, locals) { } return get(replaceVersion(uri), locals) - .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences)) + .then(latestData => composer.resolveComponentReferences(latestData, locals, composer.filterBaseInstanceReferences, uri)) .then(versionedData => dbOps.cascadingPut(put)(uri, versionedData, locals)) .then(data => meta.publishLayout(uri, user).then(() => { bus.publish('publishLayout', { uri, data, user }); diff --git a/lib/services/pages.js b/lib/services/pages.js index 51e78a08..4a0a8345 100644 --- a/lib/services/pages.js +++ b/lib/services/pages.js @@ -45,7 +45,7 @@ function addOp(uri, data, ops) { * @returns {boolean} */ function isInstanceReferenceObject(data) { - return _.isString(data._ref) && data._ref.indexOf('/instances/') > -1; + return _.isObject(data) && _.isString(data._ref) && data._ref.indexOf('/instances/') > -1; } /** @@ -72,7 +72,7 @@ function getPageClonePutOperations(pageData, locals) { // for all strings that are component references promises.push(components.get(pageValue, locals) // only follow the paths of instance references. Don't clone default components - .then(refData => composer.resolveComponentReferences(refData, locals, isInstanceReferenceObject)) + .then(refData => composer.resolveComponentReferences(refData, locals, isInstanceReferenceObject, pageValue)) .then(resolvedData => { // for each instance reference within resolved data _.each(references.listDeepObjects(resolvedData, isInstanceReferenceObject), obj => { @@ -149,7 +149,7 @@ function getRecursivePublishedPutOperations(locals) { * 4) Get list of put operations needed */ return components.get(rootComponentRef, locals) - .then(data => composer.resolveComponentReferences(data, locals)) + .then(data => composer.resolveComponentReferences(data, locals, composer.referenceProperty, rootComponentRef)) .then(data => dbOps.getPutOperations(components.cmptPut, replaceVersion(rootComponentRef, 'published'), data, locals)); }; } diff --git a/lib/services/references.js b/lib/services/references.js index a699fe23..f9d130bf 100644 --- a/lib/services/references.js +++ b/lib/services/references.js @@ -39,6 +39,31 @@ function listDeepObjects(obj, filter) { return list; } +/** + * Search through an object and find all deep key-value pairs matching a filter. + * @param {object} obj + * @param {Function} [filter=_.identity] Optional filter + * @param {array} [path] Path of this value + * @returns {object} + */ +function listDeepValuesByKey(obj, filter = _.identity, path = []) { + let result = {}; + + if (_.isObject(obj)) { + // loop through any objects or arrays + result = _.reduce(obj, (cumm, curr, key) => _.assign(cumm, listDeepValuesByKey(curr, filter, [...path, key])), {}); + } + + if (_.iteratee(filter)(obj) && path.length) { + // add the key to results if it matches the filter + const key = path.join('.'); + + result[key] = obj; + } + + return result; +} + /** * @param {string} version * @returns {function} @@ -155,3 +180,4 @@ module.exports.urlToUri = urlToUri; module.exports.omitPageConfiguration = omitPageConfiguration; module.exports.getPageReferences = getPageReferences; module.exports.listDeepObjects = listDeepObjects; +module.exports.listDeepValuesByKey = listDeepValuesByKey; diff --git a/lib/services/references.test.js b/lib/services/references.test.js index c2cec967..37f2031a 100644 --- a/lib/services/references.test.js +++ b/lib/services/references.test.js @@ -170,4 +170,47 @@ describe(_.startCase(filename), function () { ]); }); }); + + describe('listDeepValuesByKey', function () { + const fn = lib[this.title]; + + it('listDeepValuesByKey gets all deep values by key', function () { + const result = fn({a:{b:{c:{d:'e'}}, f:{g:{h:'e'}}}, abc:[6,{p:2}]}); + + expect(result).to.deep.equal({ + a: {b:{c:{d:'e'}}, f:{g:{h:'e'}}}, + 'a.b': {c:{d:'e'}}, + 'a.b.c': {d:'e'}, + 'a.b.c.d': 'e', + 'a.f': {g:{h:'e'}}, + 'a.f.g': {h:'e'}, + 'a.f.g.h': 'e', + abc: [6,{p:2}], + 'abc.0': 6, + 'abc.1': {p:2}, + 'abc.1.p': 2 + }); + }); + + it('listDeepValuesByKey gets all deep objects using filter', function () { + const result = fn({a:{b:{c:{d:'e'}}, f:{g:{h:'e'}}}}, _.isObject); + + expect(Object.values(result)).to.have.length(5); + }); + + it('listDeepValuesByKey can filter by existence of properties', function () { + const result = fn({a:{b:{c:{d:'e'}}, f:{d:{g:'e'}}}}, 'd'); + + expect(Object.values(result)).to.have.length(2); + }); + + it('listDeepValuesByKey can filter by component', function () { + const result = fn({a: {type:'yarn'}, b: {c: {type:'sweater'}}}, function (obj) { return !!obj.type; }); + + expect(result).to.deep.equal({ + a: { type: 'yarn' }, + 'b.c': { type: 'sweater' } + }); + }); + }); });