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:
Tom Moor
2018-09-29 21:24:07 -07:00
committed by GitHub
parent 7973bfeca2
commit d0bee23432
28 changed files with 794 additions and 85 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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
);

View File

@@ -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)),
};
}