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:
Kevin Ansfield 2024-06-05 14:47:33 +01:00 committed by GitHub
parent d40ef32ca8
commit b447a26832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 160 additions and 20 deletions

View File

@ -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}}

View File

@ -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'),

View File

@ -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();

View File

@ -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;

View File

@ -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'}
}; };
} }

View File

@ -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');
}); });
}); });

View File

@ -0,0 +1,7 @@
const {createAddColumnMigration} = require('../../utils');
module.exports = createAddColumnMigration('post_revisions', 'custom_excerpt', {
type: 'string',
maxlength: 2000,
nullable: true
});

View File

@ -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
}
);

View File

@ -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},

View File

@ -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')
}; };

View File

@ -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';

View File

@ -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
}; };

View File

@ -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: {}});