diff --git a/README.md b/README.md index d0fe5227..e61e6c66 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,13 @@ The template you create is dependent on whichever renderer you'd like to use. Th [Kiln](https://github.com/nymag/clay-kiln) uses a component's schema.yml to determine how it is edited. [Visit the Kiln wiki](https://github.com/clay/clay-kiln/wiki/Schemas-and-Behaviors) for examples of how to write schema files for your components. +Amphora also supports two optional schema keys for page cloning in `pages.create`: + +* `_resetOnPageClone`: an object whose keys are top-level instance fields and whose values overwrite the cloned data. +* `_omitOnPageClone`: an array of top-level instance fields to remove from the cloned data after resets are applied. + +If a field is configured in both keys, Amphora logs a warning and omits the field from the stored clone. + ## Contribution Fork the project and submit a PR on a branch that is not named `master`. We use linting tools and unit tests, which are built constantly using continuous integration. If you find a bug, it would be appreciated if you could also submit a branch with a failing unit test to show your case. diff --git a/docs/introduction.md b/docs/introduction.md index 36d7d7a1..2fe59eb0 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -109,6 +109,13 @@ The template you create is dependent on whichever renderer you'd like to use. Th [Kiln](https://github.com/nymag/clay-kiln) uses a component's schema.yml to determine how it is edited. +Amphora also supports two optional schema keys for page cloning in `pages.create`: + +* `_resetOnPageClone`: an object whose keys are top-level instance fields and whose values overwrite the cloned data. +* `_omitOnPageClone`: an array of top-level instance fields to remove from the cloned data after resets are applied. + +If a field is configured in both keys, Amphora logs a warning and omits the field from the stored clone. + ## Contribution Fork the project and submit a PR on a branch that is not named `master`. We use linting tools and unit tests, which are built constantly using continuous integration. If you find a bug, it would be appreciated if you could also submit a branch with a failing unit test to show your case. diff --git a/lib/services/pages.js b/lib/services/pages.js index 64937166..f4c178f4 100644 --- a/lib/services/pages.js +++ b/lib/services/pages.js @@ -17,6 +17,8 @@ const _ = require('lodash'), timer = require('../timer'), uid = require('../uid'), meta = require('./metadata'), + componentSchema = require('../utils/schema'), + { normalizePageCloneData } = require('../utils/page-clone'), dbOps = require('./db-operations'), { getComponentName, replaceVersion, getPrefix, isLayout } = require('clayutils'), publishService = require('./publish'), @@ -88,8 +90,21 @@ function getPageClonePutOperations(pageData, locals) { pageData[pageKey] = ref; - // put new data using cascading PUT at place that page now points - return dbOps.getPutOperations(components.cmptPut, ref, resolvedData, locals); + return componentSchema.getSchema(pageValue) + .then(cloneSchema => { + const normalizedClone = normalizePageCloneData(resolvedData, cloneSchema); + + if (normalizedClone.conflictingFields.length) { + log('warn', `Component '${getComponentName(pageValue)}' defines _resetOnPageClone and _omitOnPageClone for the same field(s): ${normalizedClone.conflictingFields.join(', ')}`); + } + + return normalizedClone.data; + }) + .catch(() => resolvedData) + .then(normalizedData => { + // put new data using cascading PUT at place that page now points + return dbOps.getPutOperations(components.cmptPut, ref, normalizedData, locals); + }); })); } else { // for all object-like things (i.e., objects and arrays) diff --git a/lib/services/pages.test.js b/lib/services/pages.test.js index abae556f..6a5f1470 100644 --- a/lib/services/pages.test.js +++ b/lib/services/pages.test.js @@ -12,7 +12,7 @@ const _ = require('lodash'), siteService = require('./sites'), timer = require('../timer'), meta = require('./metadata'), - schema = require('../schema'), + schema = require('../utils/schema'), publishService = require('./publish'), composer = require('./composer'), bus = require('./bus'), @@ -36,7 +36,7 @@ describe(_.startCase(filename), function () { sandbox.stub(timer); sandbox.stub(meta); sandbox.stub(bus); - sandbox.stub(schema); + sandbox.stub(schema, 'getSchema'); sandbox.stub(composer); sandbox.stub(publishService, 'resolvePublishUrl'); db = storage(); @@ -92,6 +92,7 @@ describe(_.startCase(filename), function () { foo: true }] })); + schema.getSchema.withArgs(contentUri).returns(Promise.resolve({})); db.batch.returns(Promise.resolve()); siteService.getSiteFromPrefix.returns({notify: _.noop}); meta.createPage.returns(Promise.resolve()); @@ -107,6 +108,107 @@ describe(_.startCase(filename), function () { expect(result.content).to.match(/^domain\.com\/path\/_components\/thing1\/instances\//); }); }); + + it('normalizes cloned content using schema clone directives', function () { + const uri = 'domain.com/path/_pages', + contentUri = 'domain.com/path/_components/thing1/instances/foo', + layoutUri = 'domain.com/path/_layouts/thing2', + data = { layout: layoutUri, content: [contentUri] }, + contentData = {}, + layoutReferenceData = {}, + normalizedRefPattern = /^domain\.com\/path\/_components\/thing1\/instances\//; + + layouts.get.withArgs(layoutUri).returns(Promise.resolve(layoutReferenceData)); + components.get.withArgs(contentUri).returns(Promise.resolve(contentData)); + composer.resolveComponentReferences.returns(Promise.resolve({ + publishDate: '2020-01-01T00:00:00.000Z', + firstPublishedAt: '2020-01-02T00:00:00.000Z', + status: 'published', + content: [{ + _ref: contentUri, + foo: true + }] + })); + schema.getSchema.withArgs(contentUri).returns(Promise.resolve({ + _resetOnPageClone: { + status: 'draft', + publishDate: null + }, + _omitOnPageClone: ['firstPublishedAt'] + })); + db.batch.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({notify: _.noop}); + meta.createPage.returns(Promise.resolve()); + + return fn(uri, data).then(function () { + const args = dbOps.getPutOperations.getCall(0).args; + + expect(args[0]).to.equal(components.cmptPut); + expect(args[1]).to.match(normalizedRefPattern); + expect(args[2]).to.deep.equal({ + publishDate: null, + status: 'draft', + content: [{ + _ref: args[2].content[0]._ref, + foo: true + }] + }); + expect(args[2].content[0]._ref).to.match(normalizedRefPattern); + }); + }); + + it('warns when schema clone directives reset and omit the same field', function () { + const uri = 'domain.com/path/_pages', + contentUri = 'domain.com/path/_components/thing1/instances/foo', + layoutUri = 'domain.com/path/_layouts/thing2', + data = { layout: layoutUri, content: [contentUri] }, + contentData = {}, + layoutReferenceData = {}; + + layouts.get.withArgs(layoutUri).returns(Promise.resolve(layoutReferenceData)); + components.get.withArgs(contentUri).returns(Promise.resolve(contentData)); + composer.resolveComponentReferences.returns(Promise.resolve({ + publishDate: '2020-01-01T00:00:00.000Z' + })); + schema.getSchema.withArgs(contentUri).returns(Promise.resolve({ + _resetOnPageClone: { + publishDate: null + }, + _omitOnPageClone: ['publishDate'] + })); + db.batch.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({notify: _.noop}); + meta.createPage.returns(Promise.resolve()); + + return fn(uri, data).then(function () { + sinon.assert.calledWith(fakeLog, 'warn', sinon.match('_resetOnPageClone and _omitOnPageClone for the same field(s): publishDate')); + expect(dbOps.getPutOperations.getCall(0).args[2]).to.deep.equal({}); + }); + }); + + it('skips clone normalization when schema lookup fails', function () { + const uri = 'domain.com/path/_pages', + contentUri = 'domain.com/path/_components/thing1/instances/foo', + layoutUri = 'domain.com/path/_layouts/thing2', + data = { layout: layoutUri, content: [contentUri] }, + contentData = {}, + layoutReferenceData = {}, + resolvedData = { + publishDate: '2020-01-01T00:00:00.000Z' + }; + + layouts.get.withArgs(layoutUri).returns(Promise.resolve(layoutReferenceData)); + components.get.withArgs(contentUri).returns(Promise.resolve(contentData)); + composer.resolveComponentReferences.returns(Promise.resolve(resolvedData)); + schema.getSchema.withArgs(contentUri).returns(Promise.reject(new Error('Schema not found!'))); + db.batch.returns(Promise.resolve()); + siteService.getSiteFromPrefix.returns({notify: _.noop}); + meta.createPage.returns(Promise.resolve()); + + return fn(uri, data).then(function () { + sinon.assert.calledWith(dbOps.getPutOperations, components.cmptPut, sinon.match.string, resolvedData); + }); + }); }); describe('publish', function () { diff --git a/lib/utils/page-clone.js b/lib/utils/page-clone.js new file mode 100644 index 00000000..f10b7635 --- /dev/null +++ b/lib/utils/page-clone.js @@ -0,0 +1,36 @@ +'use strict'; + +const _ = require('lodash'); + +/** + * Normalize cloned component data using optional schema directives. + * Only top-level fields on the cloned instance are affected. + * + * @param {object} data + * @param {object} componentSchema + * @returns {{ data: object, conflictingFields: string[] }} + */ +function normalizePageCloneData(data, componentSchema = {}) { + const resetOnPageClone = _.isPlainObject(componentSchema._resetOnPageClone) ? componentSchema._resetOnPageClone : {}, + omitOnPageClone = _.isArray(componentSchema._omitOnPageClone) ? _.filter(componentSchema._omitOnPageClone, _.isString) : [], + normalizedData = _.assign({}, data, resetOnPageClone), + conflictingFields = _.intersection(_.keys(resetOnPageClone), omitOnPageClone); + + if (!_.isPlainObject(data) || _.isEmpty(resetOnPageClone) && _.isEmpty(omitOnPageClone)) { + return { + data, + conflictingFields: [] + }; + } + + _.each(omitOnPageClone, field => { + delete normalizedData[field]; + }); + + return { + data: normalizedData, + conflictingFields + }; +} + +module.exports.normalizePageCloneData = normalizePageCloneData; diff --git a/lib/utils/page-clone.test.js b/lib/utils/page-clone.test.js new file mode 100644 index 00000000..227e37b5 --- /dev/null +++ b/lib/utils/page-clone.test.js @@ -0,0 +1,85 @@ +'use strict'; + +const _ = require('lodash'), + expect = require('chai').expect, + filename = __filename.split('/').pop().split('.').shift(), + { normalizePageCloneData } = require('./' + filename); + +describe(_.startCase(filename), function () { + describe('normalizePageCloneData', function () { + it('resets top-level fields from schema values', function () { + const result = normalizePageCloneData({ + headline: 'Original headline', + publishDate: '2020-01-01T00:00:00.000Z', + nested: { + publishDate: 'leave nested values alone' + } + }, { + _resetOnPageClone: { + publishDate: null, + headline: 'Copied headline' + } + }); + + expect(result).to.deep.equal({ + data: { + headline: 'Copied headline', + publishDate: null, + nested: { + publishDate: 'leave nested values alone' + } + }, + conflictingFields: [] + }); + }); + + it('omits top-level fields from cloned data', function () { + const result = normalizePageCloneData({ + headline: 'Original headline', + publishDate: '2020-01-01T00:00:00.000Z', + firstPublishedAt: '2020-01-02T00:00:00.000Z' + }, { + _omitOnPageClone: ['publishDate', 'firstPublishedAt'] + }); + + expect(result).to.deep.equal({ + data: { + headline: 'Original headline' + }, + conflictingFields: [] + }); + }); + + it('applies resets before omits and reports conflicting fields', function () { + const result = normalizePageCloneData({ + publishDate: '2020-01-01T00:00:00.000Z', + teaser: 'copy me' + }, { + _resetOnPageClone: { + publishDate: null, + teaser: 'reset before omit' + }, + _omitOnPageClone: ['publishDate', 'teaser'] + }); + + expect(result).to.deep.equal({ + data: {}, + conflictingFields: ['publishDate', 'teaser'] + }); + }); + + it('ignores invalid clone schema directives', function () { + const data = { + publishDate: '2020-01-01T00:00:00.000Z' + }; + + expect(normalizePageCloneData(data, { + _resetOnPageClone: [], + _omitOnPageClone: 'publishDate' + })).to.deep.equal({ + data, + conflictingFields: [] + }); + }); + }); +}); diff --git a/website/versioned_docs/version-7.4.0/introduction.md b/website/versioned_docs/version-7.4.0/introduction.md index 8a61b218..1025c05f 100644 --- a/website/versioned_docs/version-7.4.0/introduction.md +++ b/website/versioned_docs/version-7.4.0/introduction.md @@ -108,6 +108,13 @@ The template you create is dependent on whichever renderer you'd like to use. Th [Kiln](https://github.com/nymag/clay-kiln) uses a component's schema.yml to determine how it is edited. +Amphora also supports two optional schema keys for page cloning in `pages.create`: + +* `_resetOnPageClone`: an object whose keys are top-level instance fields and whose values overwrite the cloned data. +* `_omitOnPageClone`: an array of top-level instance fields to remove from the cloned data after resets are applied. + +If a field is configured in both keys, Amphora logs a warning and omits the field from the stored clone. + ## Contribution Fork the project and submit a PR on a branch that is not named `master`. We use linting tools and unit tests, which are built constantly using continuous integration. If you find a bug, it would be appreciated if you could also submit a branch with a failing unit test to show your case.