Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 17 additions & 2 deletions lib/services/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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)
Expand Down
106 changes: 104 additions & 2 deletions lib/services/pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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();
Expand Down Expand Up @@ -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());
Expand All @@ -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 () {
Expand Down
36 changes: 36 additions & 0 deletions lib/utils/page-clone.js
Original file line number Diff line number Diff line change
@@ -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;
85 changes: 85 additions & 0 deletions lib/utils/page-clone.test.js
Original file line number Diff line number Diff line change
@@ -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: []
});
});
});
});
7 changes: 7 additions & 0 deletions website/versioned_docs/version-7.4.0/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down