Ghost/ghost/admin/tests/unit/controllers/editor-test.js
Kevin Ansfield 887f4d3ac2
🐛 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>
2024-08-19 18:03:13 +00:00

428 lines
21 KiB
JavaScript

import EmberObject from '@ember/object';
import RSVP from 'rsvp';
import {defineProperty} from '@ember/object';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {settled} from '@ember/test-helpers';
import {setupTest} from 'ember-mocha';
import {task} from 'ember-concurrency';
describe('Unit: Controller: lexical-editor', function () {
setupTest();
let createPost;
const _createPost = function (attrs) {
const store = this.owner.lookup('service:store');
return store.createRecord('post', attrs);
};
beforeEach(function () {
createPost = _createPost.bind(this);
});
describe('generateSlug', function () {
it('should generate a slug and set it on the post', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({slug: ''}));
controller.set('post.titleScratch', 'title');
await settled();
expect(controller.get('post.slug')).to.equal('');
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('title-slug');
});
it('should not set the destination if the title is "(Untitled)" and the post already has a slug', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({slug: 'whatever'}));
expect(controller.get('post.slug')).to.equal('whatever');
controller.set('post.titleScratch', '(Untitled)');
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('whatever');
});
it('should generate a new slug if the previous title was (Untitled)', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({
slug: '',
title: '(Untitled)',
titleScratch: 'title'
}));
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('title-slug');
});
it('should generate a new slug if the previous title ended with (Copy)', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({
slug: '',
title: 'title (Copy)',
titleScratch: 'newTitle'
}));
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('newTitle-slug');
});
it('should not generate a new slug if it appears a custom slug was set', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({
slug: 'custom-slug',
title: 'original title',
titleScratch: 'newTitle'
}));
expect(controller.get('post.slug')).to.equal('custom-slug');
expect(controller.get('post.titleScratch')).to.equal('newTitle');
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('custom-slug');
});
it('should generate new slugs if the title changes', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('slugGenerator', EmberObject.create({
generateSlug(slugType, str) {
return RSVP.resolve(`${str}-slug`);
}
}));
controller.set('post', createPost({
slug: 'somepost',
title: 'somepost',
titleScratch: 'newtitle'
}));
await controller.generateSlugTask.perform();
expect(controller.get('post.slug')).to.equal('newtitle-slug');
});
});
describe('saveTitleTask', function () {
beforeEach(function () {
this.controller = this.owner.lookup('controller:lexical-editor');
this.controller.set('target', {send() {}});
defineProperty(this.controller, 'autosaveTask', task(function * () {
yield RSVP.resolve();
}));
});
it('should invoke generateSlug if the post is not published', async function () {
let {controller} = this;
controller.set('target', {send() {}});
defineProperty(controller, 'generateSlugTask', task(function * () {
this.set('post.slug', 'test-slug');
yield RSVP.resolve();
}));
controller.set('post', createPost({isDraft: true}));
expect(controller.get('post.isDraft')).to.be.true;
expect(controller.get('post.titleScratch')).to.not.be.ok;
controller.set('post.titleScratch', 'test');
await controller.saveTitleTask.perform();
expect(controller.get('post.titleScratch')).to.equal('test');
expect(controller.get('post.slug')).to.equal('test-slug');
});
it('should not invoke generateSlug if the post is published', async function () {
let {controller} = this;
controller.set('target', {send() {}});
controller.set('post', createPost({
title: 'a title',
isPublished: true
}));
expect(controller.get('post.isPublished')).to.be.true;
expect(controller.get('post.title')).to.equal('a title');
expect(controller.get('post.titleScratch')).to.not.be.ok;
controller.set('post.titleScratch', 'test');
await controller.saveTitleTask.perform();
expect(controller.get('post.titleScratch')).to.equal('test');
expect(controller.get('post.slug')).to.not.be.ok;
});
});
describe('TK count in title', function () {
it('should have count 0 for no TK', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('post', createPost({titleScratch: 'this is a title'}));
expect(controller.get('tkCount')).to.equal(0);
});
it('should count TK reminders in the title', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('post', createPost({titleScratch: 'this is a TK'}));
expect(controller.get('tkCount')).to.equal(1);
});
});
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');
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 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}}`;
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 = 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.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.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;
});
});
});