diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs
index 4f386455a8..cc425e88e0 100644
--- a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs
+++ b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs
@@ -96,7 +96,9 @@
@placeholder={{@bodyPlaceholder}}
@cardConfig={{@cardOptions}}
@onChange={{@onBodyChange}}
+ @updateSecondaryInstanceModel={{@updateSecondaryInstanceModel}}
@registerAPI={{this.registerEditorAPI}}
+ @registerSecondaryAPI={{this.registerSecondaryEditorAPI}}
@cursorDidExitAtTop={{if this.feature.editorExcerpt this.focusExcerpt this.focusTitle}}
@updateWordCount={{@updateWordCount}}
@updatePostTkCount={{@updatePostTkCount}}
diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.js b/ghost/admin/app/components/gh-koenig-editor-lexical.js
index 2319ae9c8a..be28f73a5f 100644
--- a/ghost/admin/app/components/gh-koenig-editor-lexical.js
+++ b/ghost/admin/app/components/gh-koenig-editor-lexical.js
@@ -15,6 +15,7 @@ export default class GhKoenigEditorLexical extends Component {
uploadUrl = `${ghostPaths().apiRoot}/images/upload/`;
editorAPI = null;
+ secondaryEditorAPI = null;
skipFocusEditor = false;
@tracked titleIsHovered = false;
@@ -232,6 +233,12 @@ export default class GhKoenigEditorLexical extends Component {
this.args.registerAPI(API);
}
+ @action
+ registerSecondaryEditorAPI(API) {
+ this.secondaryEditorAPI = API;
+ this.args.registerSecondaryAPI(API);
+ }
+
// focus the editor when the editor canvas is clicked below the editor content,
// otherwise the browser will defocus the editor and the cursor will disappear
@action
diff --git a/ghost/admin/app/components/gh-post-settings-menu.hbs b/ghost/admin/app/components/gh-post-settings-menu.hbs
index 1d01dcc5bc..ee91b2af80 100644
--- a/ghost/admin/app/components/gh-post-settings-menu.hbs
+++ b/ghost/admin/app/components/gh-post-settings-menu.hbs
@@ -853,6 +853,7 @@
post=this.post
editorAPI=this.editorAPI
toggleSettingsMenu=this.toggleSettingsMenu
+ secondaryEditorAPI=this.secondaryEditorAPI
}}
@close={{this.closePostHistory}}
@modifier="total-overlay post-history" />
diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js
index ad1c769da6..f2a471a463 100644
--- a/ghost/admin/app/components/koenig-lexical-editor.js
+++ b/ghost/admin/app/components/koenig-lexical-editor.js
@@ -678,34 +678,43 @@ export default class KoenigLexicalEditor extends Component {
const multiplayerDocId = cardConfig.post.id;
const multiplayerUsername = this.session.user.name;
+ const KGEditorComponent = ({isInitInstance}) => {
+ return (
+
+
+
+ {} : this.args.updateWordCount} />
+ {} : this.args.updatePostTkCount} />
+
+
+ );
+ };
+
return (
Loading editor...}>
-
-
-
-
-
+
+
diff --git a/ghost/admin/app/components/modal-post-history.hbs b/ghost/admin/app/components/modal-post-history.hbs
index 3989c29db5..f7f8ab1261 100644
--- a/ghost/admin/app/components/modal-post-history.hbs
+++ b/ghost/admin/app/components/modal-post-history.hbs
@@ -33,6 +33,7 @@
@lexical={{this.selectedRevision.lexical}}
@cardConfig={{this.cardConfig}}
@registerAPI={{this.registerSelectedEditorApi}}
+ @registerSecondaryAPI={{this.registerSecondarySelectedEditorApi}}
/>
diff --git a/ghost/admin/app/components/modal-post-history.js b/ghost/admin/app/components/modal-post-history.js
index 05aba6646e..4dfa987e2e 100644
--- a/ghost/admin/app/components/modal-post-history.js
+++ b/ghost/admin/app/components/modal-post-history.js
@@ -31,6 +31,7 @@ export default class ModalPostHistory extends Component {
super(...arguments);
this.post = this.args.model.post;
this.editorAPI = this.args.model.editorAPI;
+ this.secondaryEditorAPI = this.args.model.secondaryEditorAPI;
this.toggleSettingsMenu = this.args.model.toggleSettingsMenu;
}
@@ -101,6 +102,11 @@ export default class ModalPostHistory extends Component {
this.selectedEditor = api;
}
+ @action
+ registerSecondarySelectedEditorApi(api) {
+ this.secondarySelectedEditor = api;
+ }
+
@action
registerComparisonEditorApi(api) {
this.comparisonEditor = api;
@@ -130,6 +136,7 @@ export default class ModalPostHistory extends Component {
updateEditor: () => {
const state = this.editorAPI.editorInstance.parseEditorState(revision.lexical);
this.editorAPI.editorInstance.setEditorState(state);
+ this.secondaryEditorAPI.editorInstance.setEditorState(state);
},
closePostHistoryModal: () => {
this.closeModal();
diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js
index 71c6331437..8b833f38e6 100644
--- a/ghost/admin/app/controllers/lexical-editor.js
+++ b/ghost/admin/app/controllers/lexical-editor.js
@@ -297,6 +297,11 @@ export default class LexicalEditorController extends Controller {
this._timedSaveTask.perform();
}
+ @action
+ updateSecondaryInstanceModel(lexical) {
+ this.set('post.secondaryLexicalState', JSON.stringify(lexical));
+ }
+
@action
updateTitleScratch(title) {
this.set('post.titleScratch', title);
@@ -423,6 +428,11 @@ export default class LexicalEditorController extends Controller {
this.editorAPI = API;
}
+ @action
+ registerSecondaryEditorAPI(API) {
+ this.secondaryEditorAPI = API;
+ }
+
@action
clearFeatureImage() {
this.post.set('featureImage', null);
@@ -1229,8 +1239,7 @@ export default class LexicalEditorController extends Controller {
return false;
}
- // if the Adapter failed to save the post isError will be true
- // and we should consider the post still dirty.
+ // If the Adapter failed to save the post, isError will be true, and we should consider the post still dirty.
if (post.get('isError')) {
this._leaveModalReason = {reason: 'isError', context: post.errors.messages};
return true;
@@ -1245,53 +1254,53 @@ export default class LexicalEditorController extends Controller {
return true;
}
- // titleScratch isn't an attr so needs a manual dirty check
+ // Title scratch comparison
if (post.titleScratch !== post.title) {
this._leaveModalReason = {reason: 'title is different', context: {current: post.title, scratch: post.titleScratch}};
return true;
}
- // scratch isn't an attr so needs a manual dirty check
+ // Lexical and scratch comparison
let lexical = post.get('lexical');
let scratch = post.get('lexicalScratch');
- // additional guard in case we are trying to compare null with undefined
- if (scratch || lexical) {
- if (scratch !== lexical) {
- // lexical can dynamically set direction on loading editor state (e.g. "rtl"/"ltr") per the DOM context
- // and we need to ignore this as a change from the user; see https://github.com/facebook/lexical/issues/4998
- const scratchChildNodes = scratch ? JSON.parse(scratch).root?.children : [];
- const lexicalChildNodes = lexical ? JSON.parse(lexical).root?.children : [];
+ let secondaryLexical = post.get('secondaryLexicalState');
- // // nullling is typically faster than delete
- scratchChildNodes.forEach(child => child.direction = null);
- lexicalChildNodes.forEach(child => child.direction = null);
+ let lexicalChildNodes = lexical ? JSON.parse(lexical).root?.children : [];
+ let scratchChildNodes = scratch ? JSON.parse(scratch).root?.children : [];
+ let secondaryLexicalChildNodes = secondaryLexical ? JSON.parse(secondaryLexical).root?.children : [];
- if (JSON.stringify(scratchChildNodes) === JSON.stringify(lexicalChildNodes)) {
- return false;
- }
+ lexicalChildNodes.forEach(child => child.direction = null);
+ scratchChildNodes.forEach(child => child.direction = null);
+ secondaryLexicalChildNodes.forEach(child => child.direction = null);
- this._leaveModalReason = {reason: 'lexical is different', context: {current: lexical, scratch}};
- return true;
- }
+ // Compare initLexical with scratch
+ let isSecondaryDirty = secondaryLexical && scratch && JSON.stringify(secondaryLexicalChildNodes) !== JSON.stringify(scratchChildNodes);
+
+ // Compare lexical with scratch
+ let isLexicalDirty = lexical && scratch && JSON.stringify(lexicalChildNodes) !== JSON.stringify(scratchChildNodes);
+
+ // If both comparisons are dirty, consider the post dirty
+ if (isSecondaryDirty && isLexicalDirty) {
+ this._leaveModalReason = {reason: 'initLexical and lexical are different from scratch', context: {secondaryLexical, lexical, scratch}};
+ return true;
}
- // new+unsaved posts always return `hasDirtyAttributes: true`
+ // New+unsaved posts always return `hasDirtyAttributes: true`
// so we need a manual check to see if any
if (post.get('isNew')) {
- let changedAttributes = Object.keys(post.changedAttributes());
-
+ let changedAttributes = Object.keys(post.changedAttributes() || {});
if (changedAttributes.length) {
this._leaveModalReason = {reason: 'post.changedAttributes.length > 0', context: post.changedAttributes()};
}
return changedAttributes.length ? true : false;
}
- // we've covered all the non-tracked cases we care about so fall
+ // We've covered all the non-tracked cases we care about so fall
// back on Ember Data's default dirty attribute checks
let {hasDirtyAttributes} = post;
-
if (hasDirtyAttributes) {
this._leaveModalReason = {reason: 'post.hasDirtyAttributes === true', context: post.changedAttributes()};
+ return true;
}
return hasDirtyAttributes;
diff --git a/ghost/admin/app/models/post.js b/ghost/admin/app/models/post.js
index 1ffb06d8d0..835d24d0a2 100644
--- a/ghost/admin/app/models/post.js
+++ b/ghost/admin/app/models/post.js
@@ -136,6 +136,9 @@ export default Model.extend(Comparable, ValidationEngine, {
scratch: null,
lexicalScratch: null,
titleScratch: null,
+ //This is used to store the initial lexical state from the
+ // secondary editor to get the schema up to date in case its outdated
+ secondaryLexicalState: null,
// For use by date/time pickers - will be validated then converted to UTC
// on save. Updated by an observer whenever publishedAtUTC changes.
diff --git a/ghost/admin/app/templates/lexical-editor.hbs b/ghost/admin/app/templates/lexical-editor.hbs
index ce7620137d..6d8404ddfb 100644
--- a/ghost/admin/app/templates/lexical-editor.hbs
+++ b/ghost/admin/app/templates/lexical-editor.hbs
@@ -74,6 +74,7 @@
@body={{readonly this.post.lexicalScratch}}
@bodyPlaceholder={{concat "Begin writing your " this.post.displayName "..."}}
@onBodyChange={{this.updateScratch}}
+ @updateSecondaryInstanceModel={{this.updateSecondaryInstanceModel}}
@headerOffset={{editor.headerHeight}}
@scrollContainerSelector=".gh-koenig-editor"
@scrollOffsetBottomSelector=".gh-mobile-nav-bar"
@@ -98,6 +99,7 @@
}}
@postType={{this.post.displayName}}
@registerAPI={{this.registerEditorAPI}}
+ @registerSecondaryAPI={{this.registerSecondaryEditorAPI}}
@savePostTask={{this.savePostTask}}
/>
@@ -137,6 +139,7 @@
@updateSlugTask={{this.updateSlugTask}}
@savePostTask={{this.savePostTask}}
@editorAPI={{this.editorAPI}}
+ @secondaryEditorAPI={{this.secondaryEditorAPI}}
@toggleSettingsMenu={{this.toggleSettingsMenu}}
/>
{{/if}}
diff --git a/ghost/admin/tests/acceptance/editor/lexical-test.js b/ghost/admin/tests/acceptance/editor/lexical-test.js
index c62b00b64b..cd1c634a3a 100644
--- a/ghost/admin/tests/acceptance/editor/lexical-test.js
+++ b/ghost/admin/tests/acceptance/editor/lexical-test.js
@@ -1,5 +1,5 @@
import loginAsRole from '../../helpers/login-as-role';
-import {blur, currentURL, fillIn, find, waitUntil} from '@ember/test-helpers';
+import {blur, click, currentURL, fillIn, find, waitUntil} from '@ember/test-helpers';
import {enableLabsFlag} from '../../helpers/labs-flag';
import {expect} from 'chai';
import {invalidateSession} from 'ember-simple-auth/test-support';
@@ -29,6 +29,13 @@ describe('Acceptance: Lexical editor', function () {
expect(currentURL(), 'currentURL').to.equal('/editor/post/');
});
+ it('can leave editor without unsaved changes modal', async function () {
+ await visit('/editor/post/');
+ await click('[data-test-link="posts"]');
+ expect(find('[data-test-modal="unsaved-post-changes"]')).to.not.exist;
+ expect(currentURL(), 'currentURL').to.equal('/posts');
+ });
+
it('saves on title change', async function () {
await visit('/editor/post/');
await fillIn('[data-test-editor-title-input]', 'Test Post');
diff --git a/ghost/admin/tests/unit/controllers/editor-test.js b/ghost/admin/tests/unit/controllers/editor-test.js
index 5aeaab58ab..003de0507c 100644
--- a/ghost/admin/tests/unit/controllers/editor-test.js
+++ b/ghost/admin/tests/unit/controllers/editor-test.js
@@ -206,6 +206,52 @@ describe('Unit: Controller: lexical-editor', function () {
});
describe('hasDirtyAttributes', function () {
+ it('detects new post with changed attributes as dirty (autosave)', async function () {
+ const initialLexicalString = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const lexicalScratch = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content updated","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ let controller = this.owner.lookup('controller:lexical-editor');
+ controller.set('post', createPost({
+ title: '',
+ titleScratch: '',
+ status: 'draft',
+ lexical: initialLexicalString,
+ lexicalScratch: lexicalScratch,
+ secondaryLexicalState: initialLexicalString
+ }));
+
+ let isDirty = controller.hasDirtyAttributes;
+ expect(isDirty).to.be.true;
+ });
+
+ it('does not detect new post as dirty when there are no changes', async function () {
+ const controller = this.owner.lookup('controller:lexical-editor');
+ const post = createPost({});
+ post.titleScratch = post.title;
+ post.lexicalScratch = post.lexical;
+ controller.set('post', post);
+
+ let isDirty = controller.hasDirtyAttributes;
+ expect(isDirty).to.be.false;
+ });
+
+ it('marks isNew post as dirty when lexicalScratch differs from lexical and secondaryLexical', async function () {
+ const initialLexicalString = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const lexicalScratch = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content scratch","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ let controller = this.owner.lookup('controller:lexical-editor');
+ controller.set('post', createPost({
+ title: '',
+ titleScratch: '',
+ status: 'draft',
+ lexical: initialLexicalString,
+ lexicalScratch: lexicalScratch,
+ secondaryLexicalState: initialLexicalString,
+ changedAttributes: () => ({title: ['', 'New Title']})
+ }));
+
+ let isDirty = controller.hasDirtyAttributes;
+ expect(isDirty).to.be.true;
+ });
+
it('Changes in the direction field in the lexical string are not considered dirty', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
@@ -213,28 +259,169 @@ describe('Unit: Controller: lexical-editor', function () {
const lexicalStringNoNullDirection = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
const lexicalStringUpdatedContent = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Here's some new text","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
- // we can't seem to call setPost directly, so we have to set the post manually
- controller.set('post', createPost({
+ const post = createPost({
title: 'this is a title',
- titleScratch: 'this is a title',
status: 'published',
lexical: initialLexicalString,
- lexicalScratch: initialLexicalString
- }));
+ tags: [],
+ authors: [],
+ postRevisions: []
+ });
+ const postJson = {...post.serialize(), id: 1};
+ this.owner.lookup('service:store').unloadRecord(post);
+ this.owner.lookup('service:store').pushPayload({posts: [postJson]});
+ // scratch attrs are not serialized/deserialized so need to be set manually
+ const savedPost = this.owner.lookup('service:store').peekRecord('post', 1);
+ savedPost.titleScratch = postJson.title;
+ savedPost.lexicalScratch = initialLexicalString;
+ savedPost.secondaryLexicalState = initialLexicalString;
+ controller.set('post', savedPost);
// synthetically update the lexicalScratch as if the editor itself made the modifications on loading the initial editorState
controller.send('updateScratch',JSON.parse(lexicalStringNoNullDirection));
// this should NOT result in the post being dirty - while lexical !== lexicalScratch, we ignore the direction field
- let isDirty = controller.get('hasDirtyAttributes');
+ let isDirty = controller.hasDirtyAttributes;
expect(isDirty).to.be.false;
// now we try a synthetic change in the actual text content that should result in a dirty post
controller.send('updateScratch',JSON.parse(lexicalStringUpdatedContent));
// this should NOT result in the post being dirty - while lexical !== lexicalScratch, we ignore the direction field
- isDirty = controller.get('hasDirtyAttributes');
+ isDirty = controller.hasDirtyAttributes;
expect(isDirty).to.be.true;
});
+
+ it('dirty is false if secondaryLexical and scratch matches, but lexical is outdated', async function () {
+ const initialLexicalString = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const lexicalScratch = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const secondLexicalInstance = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Here's some new text","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+
+ let controller = this.owner.lookup('controller:lexical-editor');
+
+ const post = createPost({
+ title: 'this is a title',
+ status: 'published',
+ lexical: initialLexicalString,
+ tags: [],
+ authors: [],
+ postRevisions: []
+ });
+ const postJson = {...post.serialize(), id: 1};
+ this.owner.lookup('service:store').unloadRecord(post);
+ this.owner.lookup('service:store').pushPayload({posts: [postJson]});
+ // scratch attrs are not serialized/deserialized so need to be set manually
+ const savedPost = this.owner.lookup('service:store').peekRecord('post', 1);
+ savedPost.titleScratch = postJson.title;
+ savedPost.lexicalScratch = lexicalScratch;
+ savedPost.secondaryLexicalState = secondLexicalInstance;
+ controller.set('post', savedPost);
+
+ let isDirty = controller.hasDirtyAttributes;
+
+ expect(isDirty).to.be.false;
+ });
+
+ it('dirty is true if secondaryLexical and lexical does not match scratch', async function () {
+ const initialLexicalString = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content","type": "extended-text","version": 1}],"direction": null,"format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const lexicalScratch = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Sample content1234","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+ const secondLexicalInstance = `{"root":{"children":[{"children": [{"detail": 0,"format": 0,"mode": "normal","style": "","text": "Here's some new text","type": "extended-text","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "paragraph","version": 1}],"direction": "ltr","format": "","indent": 0,"type": "root","version": 1}}`;
+
+ let controller = this.owner.lookup('controller:lexical-editor');
+
+ const post = createPost({
+ title: 'this is a title',
+ status: 'published',
+ lexical: initialLexicalString,
+ tags: [],
+ authors: [],
+ postRevisions: []
+ });
+ const postJson = {...post.serialize(), id: 1};
+ this.owner.lookup('service:store').unloadRecord(post);
+ this.owner.lookup('service:store').pushPayload({posts: [postJson]});
+ // scratch attrs are not serialized/deserialized so need to be set manually
+ const savedPost = this.owner.lookup('service:store').peekRecord('post', 1);
+ savedPost.titleScratch = postJson.title;
+ savedPost.lexicalScratch = lexicalScratch;
+ savedPost.secondaryLexicalState = secondLexicalInstance;
+ controller.set('post', savedPost);
+
+ controller.send('updateScratch',JSON.parse(lexicalScratch));
+
+ let isDirty = controller.hasDirtyAttributes;
+
+ expect(isDirty).to.be.true;
+ });
+
+ it('dirty is false if no Post', async function () {
+ let controller = this.owner.lookup('controller:lexical-editor');
+ controller.set('post', null);
+
+ let isDirty = controller.hasDirtyAttributes;
+
+ expect(isDirty).to.be.false;
+ });
+
+ it('returns true if current tags differ from previous tags', async function () {
+ let controller = this.owner.lookup('controller:lexical-editor');
+ const tag1 = this.owner.lookup('service:store').createRecord('tag', {id: 1, name: 'test'});
+ const tag2 = this.owner.lookup('service:store').createRecord('tag', {id: 2, name: 'changed'});
+ const post = createPost({
+ tags: [tag1],
+ authors: [],
+ postRevisions: []
+ });
+ const postJson = {...post.serialize(), id: 1};
+ this.owner.lookup('service:store').unloadRecord(post);
+ this.owner.lookup('service:store').pushPayload({posts: [postJson]});
+
+ const savedPost = this.owner.lookup('service:store').peekRecord('post', 1);
+ controller.set('post', savedPost);
+
+ savedPost.tags = [tag1, tag2];
+
+ let isDirty = controller.hasDirtyAttributes;
+
+ expect(isDirty).to.be.true;
+ });
+
+ it('returns false when the post is new but has no changed attributes', async function () {
+ let controller = this.owner.lookup('controller:lexical-editor');
+ // no attrs = defaults = empty changedAttributes
+ const post = createPost({});
+ controller.set('post', post);
+ // update scratch attrs to match controller.setPost behavior
+ post.titleScratch = post.title;
+ post.lexicalScratch = post.lexical;
+
+ let isDirty = controller.hasDirtyAttributes;
+ expect(isDirty).to.be.false;
+ });
+
+ it('skips new post check if post is not new', async function () {
+ let controller = this.owner.lookup('controller:lexical-editor');
+ const post = createPost({
+ title: 'Sample Title',
+ status: 'draft',
+ lexical: '',
+ tags: [],
+ authors: [],
+ postRevisions: []
+ });
+ const postJson = {...post.serialize(), id: 1};
+ this.owner.lookup('service:store').unloadRecord(post);
+ this.owner.lookup('service:store').pushPayload({posts: [postJson]});
+ // scratch attrs are not serialized/deserialized so need to be set manually
+ const savedPost = this.owner.lookup('service:store').peekRecord('post', 1);
+ savedPost.titleScratch = 'Sample Title';
+ savedPost.lexicalScratch = '';
+ savedPost.secondaryLexicalState = '';
+ controller.set('post', savedPost);
+
+ let isDirty = controller.hasDirtyAttributes;
+ // The test passes if no errors occur and it doesn't return true for new post condition
+ expect(isDirty).to.be.false;
+ });
});
});
diff --git a/ghost/core/test/e2e-browser/admin/publishing.spec.js b/ghost/core/test/e2e-browser/admin/publishing.spec.js
index 013f62ca17..788a08c2c2 100644
--- a/ghost/core/test/e2e-browser/admin/publishing.spec.js
+++ b/ghost/core/test/e2e-browser/admin/publishing.spec.js
@@ -298,11 +298,12 @@ test.describe('Publishing', () => {
test('Renders secondary hidden lexical editor', async ({sharedPage: adminPage}) => {
await adminPage.goto('/ghost');
-
await createPostDraft(adminPage, {title: 'Secondary lexical editor test', body: 'This is my post body.'});
-
- // Check if the secondary lexical editor exists but is hidden.
- expect(await adminPage.locator('[data-secondary-instance="true"]')).toBeHidden();
+ const secondaryLexicalEditor = adminPage.locator('[data-secondary-instance="true"]');
+ // Check if the secondary lexical editor exists
+ await expect(secondaryLexicalEditor).toHaveCount(1);
+ // Check if it is hidden
+ await expect(secondaryLexicalEditor).toBeHidden();
});
});