887f4d3ac2
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>
428 lines
21 KiB
JavaScript
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;
|
|
});
|
|
});
|
|
});
|