🐛 Fixed editor unsaved changes modal showing too often (#20787)
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 <hi@ronaldlangeveld.com>
This commit is contained in:
parent
b6582e6623
commit
d6df261446
@ -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}}
|
||||
|
@ -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
|
||||
|
@ -853,6 +853,7 @@
|
||||
post=this.post
|
||||
editorAPI=this.editorAPI
|
||||
toggleSettingsMenu=this.toggleSettingsMenu
|
||||
secondaryEditorAPI=this.secondaryEditorAPI
|
||||
}}
|
||||
@close={{this.closePostHistory}}
|
||||
@modifier="total-overlay post-history" />
|
||||
|
@ -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 (
|
||||
<div data-secondary-instance={isInitInstance ? true : false} style={isInitInstance ? {width: 0, height: 0, overflow: 'hidden'} : {}}>
|
||||
<KoenigComposer
|
||||
editorResource={this.editorResource}
|
||||
cardConfig={cardConfig}
|
||||
enableMultiplayer={enableMultiplayer}
|
||||
fileUploader={{useFileUpload, fileTypes}}
|
||||
initialEditorState={this.args.lexical}
|
||||
multiplayerUsername={multiplayerUsername}
|
||||
multiplayerDocId={multiplayerDocId}
|
||||
multiplayerEndpoint={multiplayerEndpoint}
|
||||
onError={this.onError}
|
||||
darkMode={this.feature.nightShift}
|
||||
isTKEnabled={true}
|
||||
>
|
||||
<KoenigEditor
|
||||
editorResource={this.editorResource}
|
||||
cursorDidExitAtTop={isInitInstance ? null : this.args.cursorDidExitAtTop}
|
||||
placeholderText={isInitInstance ? null : this.args.placeholderText}
|
||||
darkMode={isInitInstance ? null : this.feature.nightShift}
|
||||
onChange={isInitInstance ? this.args.updateSecondaryInstanceModel : this.args.onChange}
|
||||
registerAPI={isInitInstance ? this.args.registerSecondaryAPI : this.args.registerAPI}
|
||||
/>
|
||||
<WordCountPlugin editorResource={this.editorResource} onChange={isInitInstance ? () => {} : this.args.updateWordCount} />
|
||||
<TKCountPlugin editorResource={this.editorResource} onChange={isInitInstance ? () => {} : this.args.updatePostTkCount} />
|
||||
</KoenigComposer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={['koenig-react-editor', 'koenig-lexical', this.args.className].filter(Boolean).join(' ')}>
|
||||
<ErrorHandler config={this.config}>
|
||||
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||
<KoenigComposer
|
||||
editorResource={this.editorResource}
|
||||
cardConfig={cardConfig}
|
||||
enableMultiplayer={enableMultiplayer}
|
||||
fileUploader={{useFileUpload, fileTypes}}
|
||||
initialEditorState={this.args.lexical}
|
||||
multiplayerUsername={multiplayerUsername}
|
||||
multiplayerDocId={multiplayerDocId}
|
||||
multiplayerEndpoint={multiplayerEndpoint}
|
||||
onError={this.onError}
|
||||
darkMode={this.feature.nightShift}
|
||||
isTKEnabled={true}
|
||||
>
|
||||
<KoenigEditor
|
||||
editorResource={this.editorResource}
|
||||
cursorDidExitAtTop={this.args.cursorDidExitAtTop}
|
||||
placeholderText={this.args.placeholder}
|
||||
darkMode={this.feature.nightShift}
|
||||
onChange={this.args.onChange}
|
||||
registerAPI={this.args.registerAPI}
|
||||
/>
|
||||
<WordCountPlugin editorResource={this.editorResource} onChange={this.args.updateWordCount} />
|
||||
<TKCountPlugin editorResource={this.editorResource} onChange={this.args.updatePostTkCount} />
|
||||
</KoenigComposer>
|
||||
<KGEditorComponent />
|
||||
<KGEditorComponent isInitInstance={true} />
|
||||
</Suspense>
|
||||
</ErrorHandler>
|
||||
</div>
|
||||
|
@ -33,6 +33,7 @@
|
||||
@lexical={{this.selectedRevision.lexical}}
|
||||
@cardConfig={{this.cardConfig}}
|
||||
@registerAPI={{this.registerSelectedEditorApi}}
|
||||
@registerSecondaryAPI={{this.registerSecondarySelectedEditorApi}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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}}
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user