Version History (#768)
* Stash. Super rough progress * Stash * 'h' how toggles history panel Add documents.restore endpoint * Add tests for documents.restore endpoint * Document restore endpoint * Tiding, RevisionMenu, remove scroll dep * Add history menu item * Paginate loading * Fixed: Error boundary styling Select first revision faster * Diff summary, styling * Add history loading placeholder Fix move modal not opening * Fixes: Refreshing page on specific revision * documentation for document.revision * Better handle versions with no text changes (will no longer be created)
This commit is contained in:
@@ -35,6 +35,15 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.restore should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.search should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
|
||||
@@ -195,6 +195,26 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.revision', auth(), async ctx => {
|
||||
let { id, revisionId } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||
const document = await Document.findById(id);
|
||||
authorize(ctx.state.user, 'read', document);
|
||||
|
||||
const revision = await Revision.findOne({
|
||||
where: {
|
||||
id: revisionId,
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: presentRevision(ctx, revision),
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||
let { id, sort = 'updatedAt', direction } = ctx.body;
|
||||
if (direction !== 'ASC') direction = 'DESC';
|
||||
@@ -211,7 +231,9 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
revisions.map(revision => presentRevision(ctx, revision))
|
||||
revisions.map((revision, index) =>
|
||||
presentRevision(ctx, revision, revisions[index + 1])
|
||||
)
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
@@ -220,6 +242,27 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.restore', auth(), async ctx => {
|
||||
const { id, revisionId } = ctx.body;
|
||||
ctx.assertPresent(id, 'id is required');
|
||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findById(id);
|
||||
authorize(user, 'update', document);
|
||||
|
||||
const revision = await Revision.findById(revisionId);
|
||||
authorize(document, 'restore', revision);
|
||||
|
||||
document.text = revision.text;
|
||||
document.title = revision.title;
|
||||
await document.save();
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
};
|
||||
});
|
||||
|
||||
router.post('documents.search', auth(), pagination(), async ctx => {
|
||||
const { query } = ctx.body;
|
||||
const { offset, limit } = ctx.state.pagination;
|
||||
|
||||
@@ -418,6 +418,63 @@ describe('#documents.pin', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.restore', async () => {
|
||||
it('should restore the document to a previous version', async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.findOne({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
const previousText = revision.text;
|
||||
const revisionId = revision.id;
|
||||
|
||||
// update the document contents
|
||||
document.text = 'UPDATED';
|
||||
await document.save();
|
||||
|
||||
const res = await server.post('/api/documents.restore', {
|
||||
body: { token: user.getJwtToken(), id: document.id, revisionId },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.data.text).toEqual(previousText);
|
||||
});
|
||||
|
||||
it('should not allow restoring a revision in another document', async () => {
|
||||
const { user, document } = await seed();
|
||||
const anotherDoc = await buildDocument();
|
||||
const revision = await Revision.findOne({
|
||||
where: { documentId: anotherDoc.id },
|
||||
});
|
||||
const revisionId = revision.id;
|
||||
|
||||
const res = await server.post('/api/documents.restore', {
|
||||
body: { token: user.getJwtToken(), id: document.id, revisionId },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const res = await server.post('/api/documents.restore');
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should require authorization', async () => {
|
||||
const { document } = await seed();
|
||||
const revision = await Revision.findOne({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
const revisionId = revision.id;
|
||||
|
||||
const user = await buildUser();
|
||||
const res = await server.post('/api/documents.restore', {
|
||||
body: { token: user.getJwtToken(), id: document.id, revisionId },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#documents.unpin', async () => {
|
||||
it('should unpin the document', async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -28,8 +28,12 @@ const slugify = text =>
|
||||
});
|
||||
|
||||
const createRevision = (doc, options = {}) => {
|
||||
// we don't create revisions for autosaves
|
||||
if (options.autosave) return;
|
||||
|
||||
// we don't create revisions if identical to previous
|
||||
if (doc.text === doc.previous('text')) return;
|
||||
|
||||
return Revision.create({
|
||||
title: doc.title,
|
||||
text: doc.text,
|
||||
@@ -54,20 +58,9 @@ const beforeSave = async doc => {
|
||||
doc.text = doc.text.replace(/^.*$/m, `# ${DEFAULT_TITLE}`);
|
||||
}
|
||||
|
||||
// calculate collaborators
|
||||
let ids = [];
|
||||
if (doc.id) {
|
||||
ids = await Revision.findAll({
|
||||
attributes: [[DataTypes.literal('DISTINCT "userId"'), 'userId']],
|
||||
where: {
|
||||
documentId: doc.id,
|
||||
},
|
||||
}).map(rev => rev.userId);
|
||||
}
|
||||
|
||||
// add the current user as revision hasn't been generated yet
|
||||
ids.push(doc.lastModifiedById);
|
||||
doc.collaboratorIds = uniq(ids);
|
||||
// add the current user as a collaborator on this doc
|
||||
if (!doc.collaboratorIds) doc.collaboratorIds = [];
|
||||
doc.collaboratorIds = uniq(doc.collaboratorIds.concat(doc.lastModifiedById));
|
||||
|
||||
// increment revision
|
||||
doc.revisionCount += 1;
|
||||
|
||||
@@ -28,4 +28,22 @@ const Revision = sequelize.define('revision', {
|
||||
},
|
||||
});
|
||||
|
||||
Revision.associate = models => {
|
||||
Revision.belongsTo(models.Document, {
|
||||
as: 'document',
|
||||
foreignKey: 'documentId',
|
||||
});
|
||||
Revision.belongsTo(models.User, {
|
||||
as: 'user',
|
||||
foreignKey: 'userId',
|
||||
});
|
||||
Revision.addScope(
|
||||
'defaultScope',
|
||||
{
|
||||
include: [{ model: models.User, as: 'user', paranoid: false }],
|
||||
},
|
||||
{ override: true }
|
||||
);
|
||||
};
|
||||
|
||||
export default Revision;
|
||||
|
||||
@@ -494,6 +494,28 @@ export default function Pricing() {
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method
|
||||
method="documents.restore"
|
||||
label="Restore a previous revision"
|
||||
>
|
||||
<Description>
|
||||
Restores a document to a previous revision by creating a new
|
||||
revision with the contents of the given revisionId.
|
||||
</Description>
|
||||
<Arguments>
|
||||
<Argument
|
||||
id="id"
|
||||
description="Document ID or URI identifier"
|
||||
required
|
||||
/>
|
||||
<Argument
|
||||
id="revisionId"
|
||||
description="Revision ID to restore to"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="documents.pin" label="Pin a document">
|
||||
<Description>
|
||||
Pins a document to the collection home. The pinned document is
|
||||
@@ -576,6 +598,21 @@ export default function Pricing() {
|
||||
<Arguments pagination />
|
||||
</Method>
|
||||
|
||||
<Method
|
||||
method="documents.revision"
|
||||
label="Get revision for a document"
|
||||
>
|
||||
<Description>Return a specific revision of a document.</Description>
|
||||
<Arguments>
|
||||
<Argument
|
||||
id="id"
|
||||
description="Document ID or URI identifier"
|
||||
required
|
||||
/>
|
||||
<Argument id="revisionId" description="Revision ID" required />
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method
|
||||
method="documents.revisions"
|
||||
label="Get revisions for a document"
|
||||
@@ -584,7 +621,13 @@ export default function Pricing() {
|
||||
Return revisions for a document. Upon each edit, a new revision is
|
||||
stored.
|
||||
</Description>
|
||||
<Arguments pagination />
|
||||
<Arguments pagination>
|
||||
<Argument
|
||||
id="id"
|
||||
description="Document ID or URI identifier"
|
||||
required
|
||||
/>
|
||||
</Arguments>
|
||||
</Method>
|
||||
|
||||
<Method method="team.users" label="List team's users">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import policy from './policy';
|
||||
import { Document, User } from '../models';
|
||||
import { Document, Revision, User } from '../models';
|
||||
|
||||
const { allow } = policy;
|
||||
|
||||
@@ -12,3 +12,10 @@ allow(
|
||||
Document,
|
||||
(user, document) => user.teamId === document.teamId
|
||||
);
|
||||
|
||||
allow(
|
||||
Document,
|
||||
'restore',
|
||||
Revision,
|
||||
(document, revision) => document.id === revision.documentId
|
||||
);
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
// @flow
|
||||
import * as JSDiff from 'diff';
|
||||
import { Revision } from '../models';
|
||||
import presentUser from './user';
|
||||
|
||||
function counts(changes) {
|
||||
return changes.reduce(
|
||||
(acc, change) => {
|
||||
if (change.added) acc.added += change.value.length;
|
||||
if (change.removed) acc.removed += change.value.length;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
added: 0,
|
||||
removed: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function present(ctx: Object, revision: Revision, previous?: Revision) {
|
||||
const prev = previous ? previous.text : '';
|
||||
|
||||
function present(ctx: Object, revision: Revision) {
|
||||
return {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: revision.title,
|
||||
text: revision.text,
|
||||
createdAt: revision.createdAt,
|
||||
updatedAt: revision.updatedAt,
|
||||
createdBy: presentUser(ctx, revision.user),
|
||||
diff: counts(JSDiff.diffChars(prev, revision.text)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user