Added custom excerpt to post revisions (#20323)
closes https://linear.app/tryghost/issue/MOM-170 When the subtitle field is included in the editor it creates a disconnect with post revisions if the underlying custom excerpt data is not included so we'd like to both preview and restore the subtitle when the in-editor subtitle field is enabled. - added `post_revisions.custom_excerpt` column to schema - added migration to add `post_revisions.custom_excerpt` to existing databases - added migration to populate `post_revisions.custom_excerpt` with the current `post.custom_excerpt` value from the associated record - ensures no data is inadvertently lost when restoring an old version - using current data matches what would have happened previously where custom_excerpt was never overwritten when restoring an old version - updated post revisions handling to accept the `custom_excerpt` field - updated Admin's revision preview and restoration to display and set the `custom_excerpt` field
This commit is contained in:
parent
d40ef32ca8
commit
b447a26832
@ -20,7 +20,15 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gh-editor-title" data-test-post-history-preview-title>{{this.currentTitle}}</div>
|
<div class="gh-editor-title" data-test-post-history-preview-title>
|
||||||
|
{{this.currentTitle}}
|
||||||
|
</div>
|
||||||
|
{{#if (feature "editorSubtitle")}}
|
||||||
|
<div class="gh-editor-subtitle" data-test-post-history-preview-subtitle>
|
||||||
|
{{this.selectedRevision.custom_excerpt}}
|
||||||
|
</div>
|
||||||
|
<hr class="gh-editor-title-divider">
|
||||||
|
{{/if}}
|
||||||
<KoenigLexicalEditor
|
<KoenigLexicalEditor
|
||||||
@lexical={{this.selectedRevision.lexical}}
|
@lexical={{this.selectedRevision.lexical}}
|
||||||
@cardConfig={{this.cardConfig}}
|
@cardConfig={{this.cardConfig}}
|
||||||
|
@ -51,6 +51,7 @@ export default class ModalPostHistory extends Component {
|
|||||||
latest: index === 0,
|
latest: index === 0,
|
||||||
createdAt: revision.get('createdAt'),
|
createdAt: revision.get('createdAt'),
|
||||||
title: revision.get('title'),
|
title: revision.get('title'),
|
||||||
|
custom_excerpt: revision.get('customExcerpt'),
|
||||||
feature_image: revision.get('featureImage'),
|
feature_image: revision.get('featureImage'),
|
||||||
feature_image_alt: revision.get('featureImageAlt'),
|
feature_image_alt: revision.get('featureImageAlt'),
|
||||||
feature_image_caption: revision.get('featureImageCaption'),
|
feature_image_caption: revision.get('featureImageCaption'),
|
||||||
|
@ -4,6 +4,7 @@ import {inject as service} from '@ember/service';
|
|||||||
import {task} from 'ember-concurrency';
|
import {task} from 'ember-concurrency';
|
||||||
|
|
||||||
export default class RestoreRevisionModal extends Component {
|
export default class RestoreRevisionModal extends Component {
|
||||||
|
@service feature;
|
||||||
@service notifications;
|
@service notifications;
|
||||||
|
|
||||||
get title() {
|
get title() {
|
||||||
@ -38,6 +39,10 @@ export default class RestoreRevisionModal extends Component {
|
|||||||
post.featureImageAlt = revision.feature_image_alt;
|
post.featureImageAlt = revision.feature_image_alt;
|
||||||
post.featureImageCaption = revision.feature_image_caption;
|
post.featureImageCaption = revision.feature_image_caption;
|
||||||
|
|
||||||
|
if (this.feature.editorSubtitle) {
|
||||||
|
post.customExcerpt = revision.custom_excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
yield post.save({adapterOptions: {saveRevision: true}});
|
yield post.save({adapterOptions: {saveRevision: true}});
|
||||||
|
|
||||||
updateTitle();
|
updateTitle();
|
||||||
|
@ -3,6 +3,7 @@ import Model, {attr, belongsTo} from '@ember-data/model';
|
|||||||
export default class PostRevisionModel extends Model {
|
export default class PostRevisionModel extends Model {
|
||||||
@attr('string') lexical;
|
@attr('string') lexical;
|
||||||
@attr('string') title;
|
@attr('string') title;
|
||||||
|
@attr('string') customExcerpt;
|
||||||
@attr('string') featureImage;
|
@attr('string') featureImage;
|
||||||
@attr('string') featureImageAlt;
|
@attr('string') featureImageAlt;
|
||||||
@attr('string') featureImageCaption;
|
@attr('string') featureImageCaption;
|
||||||
|
@ -5,15 +5,6 @@ import {EmbeddedRecordsMixin} from '@ember-data/serializer/rest';
|
|||||||
export default class PostRevisionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {
|
export default class PostRevisionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) {
|
||||||
// settings for the EmbeddedRecordsMixin.
|
// settings for the EmbeddedRecordsMixin.
|
||||||
attrs = {
|
attrs = {
|
||||||
author: {embedded: 'always'},
|
author: {embedded: 'always'}
|
||||||
lexical: {key: 'lexical'},
|
|
||||||
title: {key: 'title'},
|
|
||||||
createdAt: {key: 'created_at'},
|
|
||||||
postStatus: {key: 'post_status'},
|
|
||||||
reason: {key: 'reason'},
|
|
||||||
featureImage: {key: 'feature_image'},
|
|
||||||
featureImageAlt: {key: 'feature_image_alt'},
|
|
||||||
featureImageCaption: {key: 'feature_image_caption'},
|
|
||||||
postIdLocal: {key: 'post_id'}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import loginAsRole from '../../helpers/login-as-role';
|
import loginAsRole from '../../helpers/login-as-role';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import {click, find, findAll} from '@ember/test-helpers';
|
import {click, find, findAll} from '@ember/test-helpers';
|
||||||
|
import {enableLabsFlag} from '../../helpers/labs-flag';
|
||||||
import {expect} from 'chai';
|
import {expect} from 'chai';
|
||||||
import {setupApplicationTest} from 'ember-mocha';
|
import {setupApplicationTest} from 'ember-mocha';
|
||||||
import {setupMirage} from 'ember-cli-mirage/test-support';
|
import {setupMirage} from 'ember-cli-mirage/test-support';
|
||||||
@ -19,14 +20,16 @@ describe('Acceptance: Post revisions', function () {
|
|||||||
it('can restore a draft post revision', async function () {
|
it('can restore a draft post revision', async function () {
|
||||||
const post = this.server.create('post', {
|
const post = this.server.create('post', {
|
||||||
title: 'Current Title',
|
title: 'Current Title',
|
||||||
|
customExcerpt: 'Current excerpt',
|
||||||
status: 'draft'
|
status: 'draft'
|
||||||
});
|
});
|
||||||
this.server.create('post-revision', {
|
this.server.create('post-revision', {
|
||||||
post,
|
post,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
featureImage: post.featureImage,
|
customExcerpt: 'New subtitle',
|
||||||
featureImageAlt: post.featureImageAlt,
|
featureImage: 'https://example.com/new-image.jpg',
|
||||||
featureImageCaption: post.featureImageCaption,
|
featureImageAlt: 'New feature alt text',
|
||||||
|
featureImageCaption: 'New feature caption',
|
||||||
postStatus: 'draft',
|
postStatus: 'draft',
|
||||||
author: post.authors.models[0],
|
author: post.authors.models[0],
|
||||||
createdAt: moment(post.updatedAt).subtract(1, 'hour'),
|
createdAt: moment(post.updatedAt).subtract(1, 'hour'),
|
||||||
@ -35,9 +38,10 @@ describe('Acceptance: Post revisions', function () {
|
|||||||
this.server.create('post-revision', {
|
this.server.create('post-revision', {
|
||||||
post,
|
post,
|
||||||
title: 'Old Title',
|
title: 'Old Title',
|
||||||
featureImage: post.featureImage,
|
customExcerpt: 'Old subtitle',
|
||||||
featureImageAlt: post.featureImageAlt,
|
featureImage: 'https://example.com/old-image.jpg',
|
||||||
featureImageCaption: post.featureImageCaption,
|
featureImageAlt: 'Old feature alt text',
|
||||||
|
featureImageCaption: 'Old feature caption',
|
||||||
postStatus: 'draft',
|
postStatus: 'draft',
|
||||||
author: post.authors.models[0],
|
author: post.authors.models[0],
|
||||||
createdAt: moment(post.updatedAt).subtract(1, 'day'),
|
createdAt: moment(post.updatedAt).subtract(1, 'day'),
|
||||||
@ -62,6 +66,12 @@ describe('Acceptance: Post revisions', function () {
|
|||||||
|
|
||||||
// latest post is previewed by default
|
// latest post is previewed by default
|
||||||
expect(find('[data-test-post-history-preview-title]')).to.have.trimmed.text('Current Title');
|
expect(find('[data-test-post-history-preview-title]')).to.have.trimmed.text('Current Title');
|
||||||
|
expect(find('[data-test-post-history-preview-feature-image]')).to.have.attribute('src', 'https://example.com/new-image.jpg');
|
||||||
|
expect(find('[data-test-post-history-preview-feature-image]')).to.have.attribute('alt', 'New feature alt text');
|
||||||
|
expect(find('[data-test-post-history-preview-feature-image-caption]')).to.have.trimmed.text('New feature caption');
|
||||||
|
|
||||||
|
// subtitle is not visible (needs feature flag)
|
||||||
|
expect(find('[data-test-post-history-preview-subtitle]')).to.not.exist;
|
||||||
|
|
||||||
// previous post can be previewed
|
// previous post can be previewed
|
||||||
await click('[data-test-revision-item="1"] [data-test-button="preview-revision"]');
|
await click('[data-test-revision-item="1"] [data-test-button="preview-revision"]');
|
||||||
@ -74,5 +84,62 @@ describe('Acceptance: Post revisions', function () {
|
|||||||
await click('[data-test-modal="restore-revision"] [data-test-button="restore"]');
|
await click('[data-test-modal="restore-revision"] [data-test-button="restore"]');
|
||||||
expect(find('[data-test-modal="restore-revision"]')).to.not.exist;
|
expect(find('[data-test-modal="restore-revision"]')).to.not.exist;
|
||||||
expect(find('[data-test-editor-title-input]')).to.have.value('Old Title');
|
expect(find('[data-test-editor-title-input]')).to.have.value('Old Title');
|
||||||
|
// post has been saved with correct data
|
||||||
|
expect(post.attrs.title).to.equal('Old Title');
|
||||||
|
expect(post.attrs.featureImage).to.equal('https://example.com/old-image.jpg');
|
||||||
|
expect(post.attrs.featureImageAlt).to.equal('Old feature alt text');
|
||||||
|
expect(post.attrs.featureImageCaption).to.equal('Old feature caption');
|
||||||
|
|
||||||
|
// subtitle (customExcerpt) is not restored (needs feature flag)
|
||||||
|
expect(post.attrs.customExcerpt).to.equal('Current excerpt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can preview and restore subtitle (with editorSubtitle feature flag)', async function () {
|
||||||
|
enableLabsFlag(this.server, 'editorSubtitle');
|
||||||
|
|
||||||
|
const post = this.server.create('post', {
|
||||||
|
title: 'Current Title',
|
||||||
|
customExcerpt: 'Current subtitle',
|
||||||
|
status: 'draft'
|
||||||
|
});
|
||||||
|
this.server.create('post-revision', {
|
||||||
|
post,
|
||||||
|
title: post.title,
|
||||||
|
customExcerpt: 'New subtitle',
|
||||||
|
postStatus: 'draft',
|
||||||
|
author: post.authors.models[0],
|
||||||
|
createdAt: moment(post.updatedAt).subtract(1, 'hour'),
|
||||||
|
reason: 'explicit_save'
|
||||||
|
});
|
||||||
|
this.server.create('post-revision', {
|
||||||
|
post,
|
||||||
|
title: 'Old Title',
|
||||||
|
customExcerpt: 'Old subtitle',
|
||||||
|
postStatus: 'draft',
|
||||||
|
author: post.authors.models[0],
|
||||||
|
createdAt: moment(post.updatedAt).subtract(1, 'day'),
|
||||||
|
reason: 'initial_revision'
|
||||||
|
});
|
||||||
|
|
||||||
|
await visit(`/editor/post/${post.id}`);
|
||||||
|
|
||||||
|
// open post history menu
|
||||||
|
await click('[data-test-psm-trigger]');
|
||||||
|
await click('[data-test-toggle="post-history"]');
|
||||||
|
|
||||||
|
// subtitle is visible
|
||||||
|
expect(find('[data-test-post-history-preview-subtitle]')).to.exist;
|
||||||
|
expect(find('[data-test-post-history-preview-subtitle]')).to.have.trimmed.text('New subtitle');
|
||||||
|
|
||||||
|
// previous post can be previewed
|
||||||
|
await click('[data-test-revision-item="1"] [data-test-button="preview-revision"]');
|
||||||
|
expect(find('[data-test-post-history-preview-subtitle]')).to.have.trimmed.text('Old subtitle');
|
||||||
|
|
||||||
|
// previous post can be restored
|
||||||
|
await click('[data-test-revision-item="1"] [data-test-button="restore-revision"]');
|
||||||
|
await click('[data-test-modal="restore-revision"] [data-test-button="restore"]');
|
||||||
|
|
||||||
|
// post has been saved with correct data
|
||||||
|
expect(post.attrs.customExcerpt).to.equal('Old subtitle');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
const {createAddColumnMigration} = require('../../utils');
|
||||||
|
|
||||||
|
module.exports = createAddColumnMigration('post_revisions', 'custom_excerpt', {
|
||||||
|
type: 'string',
|
||||||
|
maxlength: 2000,
|
||||||
|
nullable: true
|
||||||
|
});
|
@ -0,0 +1,32 @@
|
|||||||
|
const logging = require('@tryghost/logging');
|
||||||
|
const {createTransactionalMigration} = require('../../utils');
|
||||||
|
const DatabaseInfo = require('@tryghost/database-info');
|
||||||
|
|
||||||
|
module.exports = createTransactionalMigration(
|
||||||
|
async function up(knex) {
|
||||||
|
logging.info('Populating post_revisions.custom_excerpt with post.excerpt');
|
||||||
|
|
||||||
|
if (DatabaseInfo.isSQLite(knex)) {
|
||||||
|
// SQLite doesn't support JOINs in UPDATE queries
|
||||||
|
await knex.raw(`
|
||||||
|
UPDATE post_revisions
|
||||||
|
SET custom_excerpt = (
|
||||||
|
SELECT posts.custom_excerpt
|
||||||
|
FROM posts
|
||||||
|
WHERE post_revisions.post_id = posts.id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await knex.raw(`
|
||||||
|
UPDATE post_revisions
|
||||||
|
JOIN posts ON post_revisions.post_id = posts.id
|
||||||
|
SET post_revisions.custom_excerpt = posts.custom_excerpt
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.info('Finished populating post_revisions.custom_excerpt');
|
||||||
|
},
|
||||||
|
async function down() {
|
||||||
|
// Not required
|
||||||
|
}
|
||||||
|
);
|
@ -413,7 +413,8 @@ module.exports = {
|
|||||||
reason: {type: 'string', maxlength: 50, nullable: true},
|
reason: {type: 'string', maxlength: 50, nullable: true},
|
||||||
feature_image: {type: 'string', maxlength: 2000, nullable: true},
|
feature_image: {type: 'string', maxlength: 2000, nullable: true},
|
||||||
feature_image_alt: {type: 'string', maxlength: 191, nullable: true, validations: {isLength: {max: 125}}},
|
feature_image_alt: {type: 'string', maxlength: 191, nullable: true, validations: {isLength: {max: 125}}},
|
||||||
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true}
|
feature_image_caption: {type: 'text', maxlength: 65535, nullable: true},
|
||||||
|
custom_excerpt: {type: 'string', maxlength: 2000, nullable: true, validations: {isLength: {max: 300}}}
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
|
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
|
||||||
|
@ -973,6 +973,7 @@ Post = ghostBookshelf.Model.extend({
|
|||||||
feature_image_alt: model.get('posts_meta')?.feature_image_alt,
|
feature_image_alt: model.get('posts_meta')?.feature_image_alt,
|
||||||
feature_image_caption: model.get('posts_meta')?.feature_image_caption,
|
feature_image_caption: model.get('posts_meta')?.feature_image_caption,
|
||||||
title: model.get('title'),
|
title: model.get('title'),
|
||||||
|
custom_excerpt: model.get('custom_excerpt'),
|
||||||
post_status: model.get('status')
|
post_status: model.get('status')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route
|
|||||||
*/
|
*/
|
||||||
describe('DB version integrity', function () {
|
describe('DB version integrity', function () {
|
||||||
// Only these variables should need updating
|
// Only these variables should need updating
|
||||||
const currentSchemaHash = '8e52be4cf84cae82a406e5a153ecbd43';
|
const currentSchemaHash = 'e6fff125e9a6cd6167acaf9983a21319';
|
||||||
const currentFixturesHash = 'a489d615989eab1023d4b8af0ecee7fd';
|
const currentFixturesHash = 'a489d615989eab1023d4b8af0ecee7fd';
|
||||||
const currentSettingsHash = '5c957ceb48c4878767d7d3db484c592d';
|
const currentSettingsHash = '5c957ceb48c4878767d7d3db484c592d';
|
||||||
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
|
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
|
||||||
|
@ -7,6 +7,7 @@ type PostLike = {
|
|||||||
feature_image_alt: string | null;
|
feature_image_alt: string | null;
|
||||||
feature_image_caption: string | null;
|
feature_image_caption: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
custom_excerpt: string | null;
|
||||||
reason: string;
|
reason: string;
|
||||||
post_status: string;
|
post_status: string;
|
||||||
}
|
}
|
||||||
@ -19,6 +20,7 @@ type Revision = {
|
|||||||
feature_image_alt: string | null;
|
feature_image_alt: string | null;
|
||||||
feature_image_caption: string | null;
|
feature_image_caption: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
custom_excerpt: string | null;
|
||||||
post_status: string;
|
post_status: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
created_at_ts: number;
|
created_at_ts: number;
|
||||||
@ -71,8 +73,9 @@ export class PostRevisions {
|
|||||||
const featuredImagedHasChanged = latestRevision.feature_image !== current.feature_image;
|
const featuredImagedHasChanged = latestRevision.feature_image !== current.feature_image;
|
||||||
const lexicalHasChanged = latestRevision.lexical !== current.lexical;
|
const lexicalHasChanged = latestRevision.lexical !== current.lexical;
|
||||||
const titleHasChanged = latestRevision.title !== current.title;
|
const titleHasChanged = latestRevision.title !== current.title;
|
||||||
|
const customExcerptHasChanged = latestRevision.custom_excerpt !== current.custom_excerpt;
|
||||||
// CASE: we only want to save a revision if something has changed since the previous revision
|
// CASE: we only want to save a revision if something has changed since the previous revision
|
||||||
if (lexicalHasChanged || titleHasChanged || featuredImagedHasChanged) {
|
if (lexicalHasChanged || titleHasChanged || featuredImagedHasChanged || customExcerptHasChanged) {
|
||||||
// CASE: user has explicitly requested a revision by hitting cmd+s or leaving the editor
|
// CASE: user has explicitly requested a revision by hitting cmd+s or leaving the editor
|
||||||
if (forceRevision) {
|
if (forceRevision) {
|
||||||
return {value: true, reason: 'explicit_save'};
|
return {value: true, reason: 'explicit_save'};
|
||||||
@ -119,6 +122,7 @@ export class PostRevisions {
|
|||||||
feature_image_alt: input.feature_image_alt,
|
feature_image_alt: input.feature_image_alt,
|
||||||
feature_image_caption: input.feature_image_caption,
|
feature_image_caption: input.feature_image_caption,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
|
custom_excerpt: input.custom_excerpt,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
post_status: input.post_status
|
post_status: input.post_status
|
||||||
};
|
};
|
||||||
|
@ -17,6 +17,7 @@ function makePostLike(data: any = {}) {
|
|||||||
feature_image_alt: data.feature_image_alt || null,
|
feature_image_alt: data.feature_image_alt || null,
|
||||||
feature_image_caption: data.feature_image_caption || null,
|
feature_image_caption: data.feature_image_caption || null,
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
custom_excerpt: 'Subtitle',
|
||||||
reason: 'reason',
|
reason: 'reason',
|
||||||
post_status: 'published'
|
post_status: 'published'
|
||||||
}, data);
|
}, data);
|
||||||
@ -33,6 +34,7 @@ function makeRevision(data: any = {}) {
|
|||||||
feature_image_alt: data.feature_image_alt || null,
|
feature_image_alt: data.feature_image_alt || null,
|
||||||
feature_image_caption: data.feature_image_caption || null,
|
feature_image_caption: data.feature_image_caption || null,
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
|
custom_excerpt: 'Subtitle',
|
||||||
reason: 'reason',
|
reason: 'reason',
|
||||||
post_status: 'published'
|
post_status: 'published'
|
||||||
}, data);
|
}, data);
|
||||||
@ -100,6 +102,26 @@ describe('PostRevisions', function () {
|
|||||||
assert.deepEqual(actual, expected);
|
assert.deepEqual(actual, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return true if the current and previous custom_excerpt values are different and forceRevision is true', function () {
|
||||||
|
const postRevisions = new PostRevisions({config, model: {}});
|
||||||
|
|
||||||
|
const expected = {value: true, reason: 'explicit_save'};
|
||||||
|
const actual = postRevisions.shouldGenerateRevision(makePostLike({
|
||||||
|
lexical: 'blah',
|
||||||
|
html: 'blah',
|
||||||
|
title: 'blah',
|
||||||
|
custom_excerpt: 'blah2'
|
||||||
|
}), [{
|
||||||
|
lexical: 'blah',
|
||||||
|
custom_excerpt: 'not blah'
|
||||||
|
}].map(makeRevision), {
|
||||||
|
forceRevision: true,
|
||||||
|
isPublished: false
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return true if the current and previous feature_image values are different and forceRevision is true', function () {
|
it('should return true if the current and previous feature_image values are different and forceRevision is true', function () {
|
||||||
const postRevisions = new PostRevisions({config, model: {}});
|
const postRevisions = new PostRevisions({config, model: {}});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user