From 887f4d3ac26cc01f90f1632bb72c37255dfcfc11 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Mon, 19 Aug 2024 19:03:13 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20editor=20unsaved=20chang?= =?UTF-8?q?es=20modal=20showing=20too=20often=20(#20787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref [ENG-661](https://linear.app/tryghost/issue/ENG-661/) ref [ONC-253](https://linear.app/tryghost/issue/ONC-253/) ref [PLG-174](https://linear.app/tryghost/issue/PLG-174/) - restored the original but reverted fix for unsaved changes modal from https://github.com/TryGhost/Ghost/pull/20687 - updated code to remove some incorrect early-falsy-return logic in `editorController.hasDirtyAttributes` that prevented save of unsaved changes on the underlying model (e.g. excerpt) - updated unit tests so they are testing real post model instances and therefore are testing what we expect them to test - added acceptance tests to ensure autosave is working for title and excerpt fields --------- Co-authored-by: Ronald Langeveld --- .../components/gh-koenig-editor-lexical.hbs | 2 + .../components/gh-koenig-editor-lexical.js | 7 + .../app/components/gh-post-settings-menu.hbs | 1 + .../app/components/koenig-lexical-editor.js | 57 ++--- .../app/components/modal-post-history.hbs | 1 + .../app/components/modal-post-history.js | 7 + ghost/admin/app/controllers/lexical-editor.js | 59 ++--- ghost/admin/app/models/post.js | 3 + ghost/admin/app/templates/lexical-editor.hbs | 3 + .../tests/acceptance/editor/lexical-test.js | 9 +- .../tests/unit/controllers/editor-test.js | 201 +++++++++++++++++- .../test/e2e-browser/admin/publishing.spec.js | 9 +- 12 files changed, 298 insertions(+), 61 deletions(-) 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(); }); });