mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-06 22:40:14 -05:00
Added posts restore UI (#21096)
ref https://app.incident.io/ghost/incidents/107
ref cc88757e2a
- added new path in admin `/restore`
- added basic ui for restoring posts from local storage
- added limits for # of revisions for posts with an `id` (5 revisions)
This commit adds a simple UI for restoring posts in case of data loss.
This is a backstop for very rare situations in which it seems Ember gets
into a conflicted state. See ref'd commit for more info. Clicking
'Restore' will create a new post with the saved off content.
This commit is contained in:
parent
45211b2f4c
commit
c121149ca3
7 changed files with 214 additions and 8 deletions
22
ghost/admin/app/controllers/restore-posts.js
Normal file
22
ghost/admin/app/controllers/restore-posts.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Controller from '@ember/controller';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency';
|
||||
|
||||
export default class RestorePostsController extends Controller {
|
||||
@service localRevisions;
|
||||
@service notifications;
|
||||
|
||||
@task
|
||||
*restorePostTask(revision) {
|
||||
try {
|
||||
yield this.localRevisions.restore(revision.key);
|
||||
this.notifications.showNotification('Post restored successfully', {type: 'success'});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.notifications.showNotification('Failed to restore post', {type: 'error'});
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to restore post:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,6 +33,8 @@ Router.map(function () {
|
|||
this.route('posts.analytics', {path: '/posts/analytics/:post_id'});
|
||||
this.route('posts.mentions', {path: '/posts/analytics/:post_id/mentions'});
|
||||
this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'});
|
||||
|
||||
this.route('restore-posts', {path: '/restore'});
|
||||
|
||||
this.route('pages');
|
||||
|
||||
|
|
10
ghost/admin/app/routes/restore-posts.js
Normal file
10
ghost/admin/app/routes/restore-posts.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class RevisionsRoute extends AuthenticatedRoute {
|
||||
@service localRevisions;
|
||||
|
||||
model() {
|
||||
return this.localRevisions.findAll();
|
||||
}
|
||||
}
|
|
@ -72,6 +72,10 @@ export default class LocalRevisionsService extends Service {
|
|||
allKeys.push(key);
|
||||
localStorage.setItem(this._indexKey, JSON.stringify(allKeys));
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
// Apply the filter after saving
|
||||
this.filterRevisions(data.id);
|
||||
|
||||
return key;
|
||||
} catch (err) {
|
||||
if (err.name === 'QuotaExceededError') {
|
||||
|
@ -108,16 +112,23 @@ export default class LocalRevisionsService extends Service {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns all revisions from localStorage, optionally filtered by key prefix
|
||||
* Returns all revisions from localStorage as an array, optionally filtered by key prefix and ordered by timestamp
|
||||
* @param {string | undefined} prefix - optional prefix to filter revision keys
|
||||
* @returns
|
||||
* @returns {Array} - all revisions matching the prefix, ordered by timestamp (newest first)
|
||||
*/
|
||||
findAll(prefix = undefined) {
|
||||
findAll(prefix = this._prefix) {
|
||||
const keys = this.keys(prefix);
|
||||
const revisions = {};
|
||||
for (const key of keys) {
|
||||
revisions[key] = JSON.parse(localStorage.getItem(key));
|
||||
}
|
||||
const revisions = keys.map((key) => {
|
||||
const revision = JSON.parse(localStorage.getItem(key));
|
||||
return {
|
||||
key,
|
||||
...revision
|
||||
};
|
||||
});
|
||||
|
||||
// Sort revisions by timestamp, newest first
|
||||
revisions.sort((a, b) => b.revisionTimestamp - a.revisionTimestamp);
|
||||
|
||||
return revisions;
|
||||
}
|
||||
|
||||
|
@ -243,4 +254,22 @@ export default class LocalRevisionsService extends Service {
|
|||
console.warn(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters revisions to keep only the most recent 5 for a given post ID
|
||||
* @param {string} postId - ID of the post to filter revisions for
|
||||
*/
|
||||
filterRevisions(postId) {
|
||||
if (postId === 'draft') {
|
||||
return; // Ignore filter for drafts
|
||||
}
|
||||
|
||||
const allRevisions = this.findAll(`${this._prefix}-${postId}`);
|
||||
if (allRevisions.length > 5) {
|
||||
const revisionsToRemove = allRevisions.slice(5);
|
||||
revisionsToRemove.forEach((revision) => {
|
||||
this.remove(revision.key);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
50
ghost/admin/app/templates/restore-posts.hbs
Normal file
50
ghost/admin/app/templates/restore-posts.hbs
Normal file
|
@ -0,0 +1,50 @@
|
|||
<section class="gh-canvas gh-post-restore" {{did-insert this.loadData}}>
|
||||
<GhCanvasHeader class="gh-canvas-header gh-post-restore-header">
|
||||
<div class="flex flex-column flex-grow-1">
|
||||
<h2 class="gh-canvas-title">
|
||||
Restore Posts
|
||||
</h2>
|
||||
</div>
|
||||
</GhCanvasHeader>
|
||||
<section class="view-container content-list">
|
||||
<p>Posts are regularly saved locally on your device. If you've lost a post, you can restore it from here as long as too much time hasn't passed.</p>
|
||||
<ol class="revisions-list gh-list {{unless this.model.length "no-revisions"}}">
|
||||
{{#if this.model.length}}
|
||||
<li class="gh-list-row header">
|
||||
<div class="gh-list-header gh-list-cellwidth-70">Title</div>
|
||||
<div class="gh-list-header gh-list-cellwidth-20">Created</div>
|
||||
<div class="gh-list-header gh-list-cellwidth-10"></div>
|
||||
</li>
|
||||
<VerticalCollection @items={{this.model}} @key="key" @containerSelector=".gh-main" @estimateHeight={{60}} @bufferSize={{20}} as |revision|>
|
||||
<li class="gh-list-row">
|
||||
<div class="gh-list-data gh-list-cellwidth-70">
|
||||
<h3 class="gh-revision-title" data-test-id="restore-post-title">{{if revision.title revision.title "(no title)"}}</h3>
|
||||
<p class="gh-revision-excerpt">{{truncate revision.excerpt 100}}</p>
|
||||
</div>
|
||||
<div class="gh-list-data gh-list-cellwidth-20">
|
||||
{{moment-format revision.revisionTimestamp "MMM D, YYYY HH:mm"}}
|
||||
</div>
|
||||
<div class="gh-list-data gh-list-cellwidth-10">
|
||||
<GhTaskButton
|
||||
@task={{this.restorePostTask}}
|
||||
@taskArgs={{revision}}
|
||||
@buttonText="Restore"
|
||||
@showSuccess={{false}}
|
||||
@showIcon={{false}}
|
||||
@class="gh-btn gh-btn-icon gh-btn-sm"
|
||||
data-test-id="restore-post-button"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</VerticalCollection>
|
||||
{{else}}
|
||||
<li class="no-revisions-box">
|
||||
<div class="no-revisions">
|
||||
{{svg-jar "revision-placeholder" class="gh-revisions-placeholder"}}
|
||||
<h4>No local revisions found.</h4>
|
||||
</div>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
49
ghost/admin/tests/acceptance/restore-post-test.js
Normal file
49
ghost/admin/tests/acceptance/restore-post-test.js
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {authenticateSession} from 'ember-simple-auth/test-support';
|
||||
import {beforeEach, describe, it} from 'mocha';
|
||||
import {click} from '@ember/test-helpers';
|
||||
import {expect} from 'chai';
|
||||
import {find, visit} from '@ember/test-helpers';
|
||||
import {setupApplicationTest} from 'ember-mocha';
|
||||
import {setupMirage} from 'ember-cli-mirage/test-support';
|
||||
|
||||
describe('Acceptance: Restore', function () {
|
||||
let hooks = setupApplicationTest();
|
||||
setupMirage(hooks);
|
||||
|
||||
beforeEach(async function () {
|
||||
// Create a user and authenticate the session
|
||||
let role = this.server.create('role', {name: 'Owner'});
|
||||
this.server.create('user', {roles: [role], slug: 'owner'});
|
||||
await authenticateSession();
|
||||
});
|
||||
|
||||
it('restores a post from a revision', async function () {
|
||||
// Create a post revision in localStorage
|
||||
const revisionData = {
|
||||
id: 'test-id',
|
||||
title: 'Test Post',
|
||||
lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Test content","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}',
|
||||
revisionTimestamp: Date.now()
|
||||
};
|
||||
const revisionKey = `post-revision-${revisionData.id}-${revisionData.revisionTimestamp}`;
|
||||
localStorage.setItem(revisionKey, JSON.stringify(revisionData));
|
||||
localStorage.setItem('ghost-revisions', JSON.stringify([revisionKey]));
|
||||
|
||||
// Visit the restore route
|
||||
await visit(`/restore/`);
|
||||
|
||||
// Verify that the post title is displayed
|
||||
const postTitle = find('[data-test-id="restore-post-title"]').textContent.trim();
|
||||
expect(postTitle).to.equal('Test Post');
|
||||
|
||||
// Verify that the restore button is present
|
||||
const restoreButton = find('[data-test-id="restore-post-button"]');
|
||||
expect(restoreButton).to.exist;
|
||||
|
||||
// Click the restore button
|
||||
await click(restoreButton);
|
||||
|
||||
// Verify that the post is restored (notification will show)
|
||||
expect(find('.gh-notification-title').textContent.trim()).to.equal('Post restored successfully');
|
||||
});
|
||||
});
|
|
@ -108,6 +108,50 @@ describe('Unit: Service: local-revisions', function () {
|
|||
// Ensure the latest revision saved
|
||||
expect(this.service.find(keyToAdd)).to.not.be.null;
|
||||
});
|
||||
|
||||
it('keeps only the latest 5 revisions for a given post ID', async function () {
|
||||
const postId = 'test-id';
|
||||
const revisionCount = 7;
|
||||
|
||||
// Save 7 revisions for the same post ID
|
||||
for (let i = 0; i < revisionCount; i++) {
|
||||
await sleep(1); // Ensure unique timestamps
|
||||
this.service.performSave('post', {id: postId, lexical: `test-${i}`});
|
||||
}
|
||||
|
||||
// Get all revisions for the post ID
|
||||
const revisions = this.service.findAll(`post-revision-${postId}`);
|
||||
|
||||
// Check that only 5 revisions are kept
|
||||
expect(revisions).to.have.lengthOf(5);
|
||||
|
||||
// Check that the kept revisions are the latest ones
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(revisions[i].lexical).to.equal(`test-${revisionCount - 1 - i}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not limit revisions for drafts', async function () {
|
||||
const postId = 'draft';
|
||||
const revisionCount = 7;
|
||||
|
||||
// Save 7 revisions for a draft
|
||||
for (let i = 0; i < revisionCount; i++) {
|
||||
await sleep(1); // Ensure unique timestamps
|
||||
this.service.performSave('post', {id: postId, lexical: `test-${i}`});
|
||||
}
|
||||
|
||||
// Get all revisions for the draft
|
||||
const revisions = this.service.findAll(`post-revision-${postId}`);
|
||||
|
||||
// Check that all 7 revisions are kept for the draft
|
||||
expect(revisions).to.have.lengthOf(revisionCount);
|
||||
|
||||
// Check that all revisions are present
|
||||
for (let i = 0; i < revisionCount; i++) {
|
||||
expect(revisions[i].lexical).to.equal(`test-${revisionCount - 1 - i}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleSave', function () {
|
||||
|
@ -175,7 +219,7 @@ describe('Unit: Service: local-revisions', function () {
|
|||
|
||||
it('returns an empty object if there are no revisions', function () {
|
||||
const result = this.service.findAll();
|
||||
expect(result).to.deep.equal({});
|
||||
expect(result).to.deep.equal([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue