mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added local revisions to the editor as backstop against data loss (#21044)
ref https://app.incident.io/ghost/incidents/107 - We have a rare bug that causes the initial `POST` request to create a new post from the editor to be skipped or fail. Subsequent `PUT` requests then fail because there is no post ID, potentially resulting in data loss. The aim of this commit is to start saving revisions of posts in the editor to the browser's localStorage, as a last-ditch option to restore lost work. - Since we don't know where the bug is yet, and to protect against future bugs, we've deliberately avoided depending too heavily on the `lexical-editor` controller or the ember store. We've aimed to create a direct route to the state in the editor, by hooking into the `updateScratch` method (effectively the `onChange` handler for the editor). - The `scheduleSave` function on the new `local-revisions` service is called immediately upon any changes to the state of the lexical editor, which is effectively every keystroke. The service has some logic and timeouts, so it doesn't actually save a revision on every change to the editor. - The "schema" of the datastore is a simple key-value store, where the key is of the format: `post-revision-${postId}-${timestamp}` if the post has an ID, or `post-revision-draft-${timestamp}` for an unsaved draft. There is also an array of all the revisions' keys, which allows us to clear all the revisions without having to loop over every key in localStorage (along with some other conveniences, like filtering). - There is currently no UI for viewing/restoring revisions. In the event that you need to restore a revision, you can access the service in the browser console. You can access all the saved revisions using the `list()` method, which logs all the revisions to the console by title & timestamp. You can then choose a revision to restore, and call `restore(revision_key)`, which will `POST` the revision's data to the server to create a new post. - Since localStorage data is limited to a 5mb quota in most browsers, the service has a mechanism for evicting the oldest revisions once it meets the quota. If a save fails because it would exceed the quota, the `performSave` method will evict the oldest revision, then recursively try to save again. --------- Co-authored-by: Steve Larson <9larsons@gmail.com>
This commit is contained in:
parent
8ab7182bfe
commit
cc88757e2a
4 changed files with 597 additions and 1 deletions
|
@ -159,6 +159,7 @@ export default class LexicalEditorController extends Controller {
|
|||
@service session;
|
||||
@service settings;
|
||||
@service ui;
|
||||
@service localRevisions;
|
||||
|
||||
@inject config;
|
||||
|
||||
|
@ -306,8 +307,18 @@ export default class LexicalEditorController extends Controller {
|
|||
|
||||
@action
|
||||
updateScratch(lexical) {
|
||||
this.set('post.lexicalScratch', JSON.stringify(lexical));
|
||||
const lexicalString = JSON.stringify(lexical);
|
||||
this.set('post.lexicalScratch', lexicalString);
|
||||
|
||||
try {
|
||||
// schedule a local revision save
|
||||
if (this.post.status === 'draft') {
|
||||
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), lexical: lexicalString});
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
// save 3 seconds after last edit
|
||||
this._autosaveTask.perform();
|
||||
// force save at 60 seconds
|
||||
|
@ -322,6 +333,14 @@ export default class LexicalEditorController extends Controller {
|
|||
@action
|
||||
updateTitleScratch(title) {
|
||||
this.set('post.titleScratch', title);
|
||||
try {
|
||||
// schedule a local revision save
|
||||
if (this.post.status === 'draft') {
|
||||
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), title: title});
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
246
ghost/admin/app/services/local-revisions.js
Normal file
246
ghost/admin/app/services/local-revisions.js
Normal file
|
@ -0,0 +1,246 @@
|
|||
import Service, {inject as service} from '@ember/service';
|
||||
import config from 'ghost-admin/config/environment';
|
||||
import {task, timeout} from 'ember-concurrency';
|
||||
|
||||
/**
|
||||
* Service to manage local post revisions in localStorage
|
||||
*/
|
||||
export default class LocalRevisionsService extends Service {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
if (this.isTesting === undefined) {
|
||||
this.isTesting = config.environment === 'test';
|
||||
}
|
||||
this.MIN_REVISION_TIME = this.isTesting ? 50 : 60000; // 1 minute in ms
|
||||
this.performSave = this.performSave.bind(this);
|
||||
}
|
||||
|
||||
@service store;
|
||||
|
||||
// base key prefix to avoid collisions in localStorage
|
||||
_prefix = 'post-revision';
|
||||
latestRevisionTime = null;
|
||||
|
||||
// key to store a simple index of all revisions
|
||||
_indexKey = 'ghost-revisions';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} data - serialized post data, must include id and revisionTimestamp
|
||||
* @returns {string} - key to store the revision in localStorage
|
||||
*/
|
||||
generateKey(data) {
|
||||
return `${this._prefix}-${data.id}-${data.revisionTimestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the save operations, either immediately or after a delay
|
||||
*
|
||||
* leepLatest ensures the latest changes will be saved
|
||||
* @param {string} type - post or page
|
||||
* @param {object} data - serialized post data
|
||||
*/
|
||||
@task({keepLatest: true})
|
||||
*saveTask(type, data) {
|
||||
const currentTime = Date.now();
|
||||
if (!this.lastRevisionTime || currentTime - this.lastRevisionTime > this.MIN_REVISION_TIME) {
|
||||
yield this.performSave(type, data);
|
||||
this.lastRevisionTime = currentTime;
|
||||
} else {
|
||||
const waitTime = this.MIN_REVISION_TIME - (currentTime - this.lastRevisionTime);
|
||||
yield timeout(waitTime);
|
||||
yield this.performSave(type, data);
|
||||
this.lastRevisionTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the revision to localStorage
|
||||
*
|
||||
* If localStorage is full, the oldest revision will be removed
|
||||
* @param {string} type - post or page
|
||||
* @param {object} data - serialized post data
|
||||
* @returns {string | undefined} - key of the saved revision or undefined if it couldn't be saved
|
||||
*/
|
||||
performSave(type, data) {
|
||||
data.id = data.id || 'draft';
|
||||
data.type = type;
|
||||
data.revisionTimestamp = Date.now();
|
||||
const key = this.generateKey(data);
|
||||
try {
|
||||
const allKeys = this.keys();
|
||||
allKeys.push(key);
|
||||
localStorage.setItem(this._indexKey, JSON.stringify(allKeys));
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
return key;
|
||||
} catch (err) {
|
||||
if (err.name === 'QuotaExceededError') {
|
||||
// Remove the current key in case it's already in the index
|
||||
this.remove(key);
|
||||
|
||||
// If there are any revisions, remove the oldest one and try to save again
|
||||
if (this.keys().length) {
|
||||
this.removeOldest();
|
||||
return this.performSave(type, data);
|
||||
}
|
||||
// LocalStorage is full and there are no revisions to remove
|
||||
// We can't save the revision
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to trigger the save task
|
||||
* @param {string} type - post or page
|
||||
* @param {object} data - serialized post data
|
||||
*/
|
||||
scheduleSave(type, data) {
|
||||
this.saveTask.perform(type, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the specified revision from localStorage, or null if it doesn't exist
|
||||
* @param {string} key - key of the revision to find
|
||||
* @returns {string | null}
|
||||
*/
|
||||
find(key) {
|
||||
return JSON.parse(localStorage.getItem(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all revisions from localStorage, optionally filtered by key prefix
|
||||
* @param {string | undefined} prefix - optional prefix to filter revision keys
|
||||
* @returns
|
||||
*/
|
||||
findAll(prefix = undefined) {
|
||||
const keys = this.keys(prefix);
|
||||
const revisions = {};
|
||||
for (const key of keys) {
|
||||
revisions[key] = JSON.parse(localStorage.getItem(key));
|
||||
}
|
||||
return revisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified key from localStorage
|
||||
* @param {string} key
|
||||
*/
|
||||
remove(key) {
|
||||
localStorage.removeItem(key);
|
||||
const keys = this.keys();
|
||||
let index = keys.indexOf(key);
|
||||
if (index !== -1) {
|
||||
keys.splice(index, 1);
|
||||
}
|
||||
localStorage.setItem(this._indexKey, JSON.stringify(keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the oldest revision and removes it from localStorage to clear up space
|
||||
*/
|
||||
removeOldest() {
|
||||
const keys = this.keys();
|
||||
const keysByTimestamp = keys.map(key => ({key, timestamp: this.find(key).revisionTimestamp}));
|
||||
keysByTimestamp.sort((a, b) => a.timestamp - b.timestamp);
|
||||
this.remove(keysByTimestamp[0].key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all revisions from localStorage
|
||||
*/
|
||||
clear() {
|
||||
const keys = this.keys();
|
||||
for (const key of keys) {
|
||||
this.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all revision keys from localStorage, optionally filtered by key prefix
|
||||
* @param {string | undefined} prefix
|
||||
* @returns {string[]}
|
||||
*/
|
||||
keys(prefix = undefined) {
|
||||
let keys = JSON.parse(localStorage.getItem(this._indexKey) || '[]');
|
||||
if (prefix) {
|
||||
keys = keys.filter(key => key.startsWith(prefix));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs all revisions to the console
|
||||
*
|
||||
* Currently this is the only UI for local revisions
|
||||
*/
|
||||
list() {
|
||||
const revisions = this.findAll();
|
||||
const data = {};
|
||||
for (const [key, revision] of Object.entries(revisions)) {
|
||||
if (!data[revision.title]) {
|
||||
data[revision.title] = [];
|
||||
}
|
||||
data[revision.title].push({
|
||||
key,
|
||||
timestamp: revision.revisionTimestamp,
|
||||
time: new Date(revision.revisionTimestamp).toLocaleString(),
|
||||
title: revision.title,
|
||||
type: revision.type,
|
||||
id: revision.id
|
||||
});
|
||||
}
|
||||
/* eslint-disable no-console */
|
||||
console.groupCollapsed('Local revisions');
|
||||
for (const [title, row] of Object.entries(data)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupCollapsed(`${title}`);
|
||||
for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.groupCollapsed(`${item.time}`);
|
||||
console.log('Revision ID: ', item.key);
|
||||
console.groupEnd();
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
console.groupEnd();
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new post from the specified revision
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {Promise} - the new post model
|
||||
*/
|
||||
async restore(key) {
|
||||
try {
|
||||
const revision = this.find(key);
|
||||
let authors = [];
|
||||
if (revision.authors) {
|
||||
for (const author of revision.authors) {
|
||||
const authorModel = await this.store.queryRecord('user', {id: author.id});
|
||||
authors.push(authorModel);
|
||||
}
|
||||
}
|
||||
let post = this.store.createRecord('post', {
|
||||
title: `(Restored) ${revision.title}`,
|
||||
lexical: revision.lexical,
|
||||
authors,
|
||||
type: revision.type,
|
||||
slug: revision.slug || 'untitled',
|
||||
status: 'draft',
|
||||
tags: revision.tags || [],
|
||||
post_revisions: []
|
||||
});
|
||||
await post.save();
|
||||
const location = window.location;
|
||||
const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Post restored: ', url);
|
||||
return post;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import Pretender from 'pretender';
|
||||
import ghostPaths from 'ghost-admin/utils/ghost-paths';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupTest} from 'ember-mocha';
|
||||
|
||||
function stubCreatePostEndpoint(server) {
|
||||
server.post(`${ghostPaths().apiRoot}/posts/`, function () {
|
||||
return [
|
||||
201,
|
||||
{'Content-Type': 'application/json'},
|
||||
JSON.stringify({posts: [{
|
||||
id: 'test id',
|
||||
lexical: 'test lexical string',
|
||||
title: 'test title',
|
||||
post_revisions: []
|
||||
}]})
|
||||
];
|
||||
});
|
||||
|
||||
server.get(`${ghostPaths().apiRoot}/users/`, function () {
|
||||
return [
|
||||
200,
|
||||
{'Content-Type': 'application/json'},
|
||||
JSON.stringify({users: [{
|
||||
id: '1',
|
||||
name: 'test name',
|
||||
roles: ['owner']
|
||||
}]})
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
describe('Integration: Service: local-revisions', function () {
|
||||
setupTest();
|
||||
|
||||
let server;
|
||||
|
||||
beforeEach(function () {
|
||||
server = new Pretender();
|
||||
this.service = this.owner.lookup('service:local-revisions');
|
||||
this.service.clear();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
server.shutdown();
|
||||
});
|
||||
|
||||
it('restores a post from a revision', async function () {
|
||||
stubCreatePostEndpoint(server);
|
||||
// create a post to restore
|
||||
const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'});
|
||||
|
||||
// restore the post
|
||||
const post = await this.service.restore(key);
|
||||
|
||||
expect(post.get('lexical')).to.equal('test lexical string');
|
||||
});
|
||||
});
|
272
ghost/admin/tests/unit/services/local-revisions-test.js
Normal file
272
ghost/admin/tests/unit/services/local-revisions-test.js
Normal file
|
@ -0,0 +1,272 @@
|
|||
import Service from '@ember/service';
|
||||
import sinon from 'sinon';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupTest} from 'ember-mocha';
|
||||
|
||||
const sleep = ms => new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
describe('Unit: Service: local-revisions', function () {
|
||||
setupTest();
|
||||
|
||||
let localStore, setItemStub;
|
||||
|
||||
this.beforeEach(function () {
|
||||
// Mock localStorage
|
||||
sinon.restore();
|
||||
localStore = {};
|
||||
sinon.stub(localStorage, 'getItem').callsFake(key => localStore[key] || null);
|
||||
setItemStub = sinon.stub(localStorage, 'setItem').callsFake((key, value) => localStore[key] = value + '');
|
||||
sinon.stub(localStorage, 'removeItem').callsFake(key => delete localStore[key]);
|
||||
sinon.stub(localStorage, 'clear').callsFake(() => localStore = {});
|
||||
|
||||
// Create the service
|
||||
this.service = this.owner.lookup('service:local-revisions');
|
||||
this.service.clear();
|
||||
});
|
||||
|
||||
it('exists', function () {
|
||||
expect(this.service).to.be.ok;
|
||||
});
|
||||
|
||||
describe('generateKey', function () {
|
||||
it('generates a key for a post with an id', function () {
|
||||
const revisionTimestamp = Date.now();
|
||||
const key = this.service.generateKey({id: 'test', revisionTimestamp});
|
||||
expect(key).to.equal(`post-revision-test-${revisionTimestamp}`);
|
||||
});
|
||||
|
||||
it('generates a key for a post without a post id', function () {
|
||||
const revisionTimestamp = Date.now();
|
||||
const key = this.service.generateKey({id: 'draft', revisionTimestamp});
|
||||
expect(key).to.equal(`post-revision-draft-${revisionTimestamp}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performSave', function () {
|
||||
it('saves a revision without a post id', function () {
|
||||
// save a revision
|
||||
const key = this.service.performSave('post', {id: 'draft', lexical: 'test'});
|
||||
const revision = this.service.find(key);
|
||||
expect(key).to.match(/post-revision-draft-\d+/);
|
||||
expect(revision.id).to.equal('draft');
|
||||
expect(revision.lexical).to.equal('test');
|
||||
});
|
||||
|
||||
it('saves a revision with a post id', function () {
|
||||
// save a revision
|
||||
const key = this.service.performSave('post', {id: 'test-id', lexical: 'test'});
|
||||
const revision = this.service.find(key);
|
||||
expect(key).to.match(/post-revision-test-id-\d+/);
|
||||
expect(revision.id).to.equal('test-id');
|
||||
expect(revision.lexical).to.equal('test');
|
||||
});
|
||||
|
||||
it('evicts the oldest version if localStorage is full', async function () {
|
||||
// save a few revisions
|
||||
const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'test'});
|
||||
await sleep(1);
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'data-2'});
|
||||
await sleep(1);
|
||||
|
||||
// Simulate a quota exceeded error
|
||||
const quotaError = new Error('QuotaExceededError');
|
||||
quotaError.name = 'QuotaExceededError';
|
||||
const callCount = setItemStub.callCount;
|
||||
setItemStub.onCall(callCount).throws(quotaError);
|
||||
const keyToAdd = this.service.performSave('post', {id: 'test-id', lexical: 'data-3'});
|
||||
|
||||
// Ensure the oldest revision was removed
|
||||
expect(this.service.find(keyToRemove)).to.be.null;
|
||||
|
||||
// Ensure the latest revision saved
|
||||
expect(this.service.find(keyToAdd)).to.not.be.null;
|
||||
});
|
||||
|
||||
it('evicts multiple oldest versions if localStorage is full', async function () {
|
||||
// save a few revisions
|
||||
const keyToRemove = this.service.performSave('post', {id: 'test-id-1', lexical: 'test'});
|
||||
await sleep(1);
|
||||
const nextKeyToRemove = this.service.performSave('post', {id: 'test-id-2', lexical: 'data-2'});
|
||||
await sleep(1);
|
||||
// Simulate a quota exceeded error
|
||||
const quotaError = new Error('QuotaExceededError');
|
||||
quotaError.name = 'QuotaExceededError';
|
||||
|
||||
setItemStub.onCall(setItemStub.callCount).throws(quotaError);
|
||||
// remove calls setItem() to remove the key from the index
|
||||
// it's called twice for each quota error, hence the + 3
|
||||
setItemStub.onCall(setItemStub.callCount + 3).throws(quotaError);
|
||||
const keyToAdd = this.service.performSave('post', {id: 'test-id-3', lexical: 'data-3'});
|
||||
|
||||
// Ensure the oldest revision was removed
|
||||
expect(this.service.find(keyToRemove)).to.be.null;
|
||||
expect(this.service.find(nextKeyToRemove)).to.be.null;
|
||||
|
||||
// Ensure the latest revision saved
|
||||
expect(this.service.find(keyToAdd)).to.not.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleSave', function () {
|
||||
it('saves a revision', function () {
|
||||
// save a revision
|
||||
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
|
||||
const key = this.service.keys()[0];
|
||||
const revision = this.service.find(key);
|
||||
expect(key).to.match(/post-revision-draft-\d+/);
|
||||
expect(revision.id).to.equal('draft');
|
||||
expect(revision.lexical).to.equal('test');
|
||||
});
|
||||
|
||||
it('does not save a revision more than once if scheduled multiple times', async function () {
|
||||
// interval is set to 200 ms in testing
|
||||
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
|
||||
await sleep(40);
|
||||
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
|
||||
const keys = this.service.keys();
|
||||
expect(keys).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('saves another revision if it has been longer than the revision interval', async function () {
|
||||
// interval is set to 200 ms in testing
|
||||
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
|
||||
await sleep(100);
|
||||
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
|
||||
const keys = this.service.keys();
|
||||
expect(keys).to.have.lengthOf(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('find', function () {
|
||||
it('gets a revision by key', function () {
|
||||
// save a revision
|
||||
const key = this.service.performSave('post', {lexical: 'test'});
|
||||
const result = this.service.find(key);
|
||||
expect(result.id).to.equal('draft');
|
||||
expect(result.lexical).to.equal('test');
|
||||
expect(result.revisionTimestamp).to.match(/\d+/);
|
||||
});
|
||||
|
||||
it('returns null if the key does not exist', function () {
|
||||
const result = this.service.find('non-existent-key');
|
||||
expect(result).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', function () {
|
||||
it('gets all revisions if no prefix is provided', function () {
|
||||
// save a revision
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'test'});
|
||||
this.service.performSave('post', {lexical: 'data-2'});
|
||||
const result = this.service.findAll();
|
||||
expect(Object.keys(result)).to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
it('gets revisions filtered by prefix', function () {
|
||||
// save a revision
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'test'});
|
||||
this.service.performSave('post', {lexical: 'data-2'});
|
||||
const result = this.service.findAll('post-revision-test-id');
|
||||
expect(Object.keys(result)).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('returns an empty object if there are no revisions', function () {
|
||||
const result = this.service.findAll();
|
||||
expect(result).to.deep.equal({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys', function () {
|
||||
it('returns an empty array if there are no revisions', function () {
|
||||
const result = this.service.keys();
|
||||
expect(result).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it('returns the keys for all revisions if not prefix is provided', function () {
|
||||
// save revision
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
|
||||
const result = this.service.keys();
|
||||
expect(Object.keys(result)).to.have.lengthOf(1);
|
||||
expect(result[0]).to.match(/post-revision-test-id-\d+/);
|
||||
});
|
||||
|
||||
it('returns the keys filtered by prefix if provided', function () {
|
||||
// save revision
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
|
||||
this.service.performSave('post', {id: 'draft', lexical: 'data'});
|
||||
const result = this.service.keys('post-revision-test-id');
|
||||
expect(Object.keys(result)).to.have.lengthOf(1);
|
||||
expect(result[0]).to.match(/post-revision-test-id-\d+/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', function () {
|
||||
it('removes the specified key', function () {
|
||||
// save revision
|
||||
const key = this.service.performSave('post', {id: 'test-id', lexical: 'data'});
|
||||
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
|
||||
this.service.remove(key);
|
||||
const updatedKeys = this.service.keys();
|
||||
expect(updatedKeys).to.have.lengthOf(1);
|
||||
expect(this.service.find(key)).to.be.null;
|
||||
});
|
||||
|
||||
it('does nothing if the key does not exist', function () {
|
||||
// save revision
|
||||
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
|
||||
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
|
||||
this.service.remove('non-existent-key');
|
||||
const updatedKeys = this.service.keys();
|
||||
expect(updatedKeys).to.have.lengthOf(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeOldest', function () {
|
||||
it('removes the oldest revision', async function () {
|
||||
// save revision
|
||||
const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'data'});
|
||||
await sleep(1);
|
||||
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
|
||||
await sleep(1);
|
||||
this.service.performSave('post', {id: 'test-3', lexical: 'data'});
|
||||
this.service.removeOldest();
|
||||
const updatedKeys = this.service.keys();
|
||||
expect(updatedKeys).to.have.lengthOf(2);
|
||||
expect(this.service.find(keyToRemove)).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', function () {
|
||||
it('creates a new post based on the revision data', async function () {
|
||||
// stub out the store service
|
||||
let saveStub = sinon.stub().resolves({id: 'test-id'});
|
||||
let setStub = sinon.stub();
|
||||
let getStub = sinon.stub().returns('post');
|
||||
let queryRecordStub = sinon.stub().resolves({id: '1'});
|
||||
this.owner.register('service:store', Service.extend({
|
||||
createRecord: () => {
|
||||
return {
|
||||
id: 'new-id',
|
||||
save: saveStub,
|
||||
set: setStub,
|
||||
get: getStub
|
||||
};
|
||||
},
|
||||
queryRecord: queryRecordStub
|
||||
}));
|
||||
// create a post to restore
|
||||
const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'});
|
||||
// restore the post
|
||||
const post = await this.service.restore(key);
|
||||
|
||||
// Ensure the post is saved
|
||||
expect(saveStub.calledOnce).to.be.true;
|
||||
|
||||
// Restore should return the post object
|
||||
expect(post.id).to.equal('new-id');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue