Move document improvements (#927)

* Show all collections in UI

* Introduce command pattern

* Actually remove from previous collection

* Stash

* Fixes: Promises resolved outside of response lifecycle

* 💚

* 💚

* documentMover tests

* Transaction

* Perf. More in transactions
This commit is contained in:
Tom Moor
2019-04-08 21:25:13 -07:00
committed by GitHub
parent 16066c0b24
commit 763f57a3dc
16 changed files with 313 additions and 146 deletions

View File

@@ -3,7 +3,12 @@ import Router from 'koa-router';
import Sequelize from 'sequelize';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentDocument, presentRevision } from '../presenters';
import documentMover from '../commands/documentMover';
import {
presentDocument,
presentCollection,
presentRevision,
} from '../presenters';
import { Document, Collection, Share, Star, View, Revision } from '../models';
import { InvalidRequestError } from '../errors';
import events from '../events';
@@ -537,39 +542,56 @@ router.post('documents.update', auth(), async ctx => {
});
router.post('documents.move', auth(), async ctx => {
const { id, parentDocument, index } = ctx.body;
ctx.assertPresent(id, 'id is required');
if (parentDocument)
ctx.assertUuid(parentDocument, 'parentDocument must be a uuid');
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
const { id, collectionId, parentDocumentId, index } = ctx.body;
ctx.assertUuid(id, 'id must be a uuid');
ctx.assertUuid(collectionId, 'collectionId must be a uuid');
if (parentDocumentId) {
ctx.assertUuid(parentDocumentId, 'parentDocumentId must be a uuid');
}
if (index) {
ctx.assertPositiveInteger(index, 'index must be a positive integer');
}
if (parentDocumentId === id) {
throw new InvalidRequestError(
'Infinite loop detected, cannot nest a document inside itself'
);
}
const user = ctx.state.user;
const document = await Document.findById(id);
authorize(user, 'update', document);
authorize(user, 'move', document);
const collection = document.collection;
if (collection.type !== 'atlas')
throw new InvalidRequestError('This document cant be moved');
const collection = await Collection.findById(collectionId);
authorize(user, 'update', collection);
// Set parent document
if (parentDocument) {
const parent = await Document.findById(parentDocument);
if (collection.type !== 'atlas' && parentDocumentId) {
throw new InvalidRequestError(
'Document cannot be nested in this collection type'
);
}
if (parentDocumentId) {
const parent = await Document.findById(parentDocumentId);
authorize(user, 'update', parent);
}
if (parentDocument === id)
throw new InvalidRequestError('Infinite loop detected and prevented!');
// If no parent document is provided, set it as null (move to root level)
document.parentDocumentId = parentDocument;
await document.save();
await collection.moveDocument(document, index);
// Update collection
document.collection = collection;
const { documents, collections } = await documentMover({
document,
collectionId,
parentDocumentId,
index,
});
ctx.body = {
data: await presentDocument(ctx, document),
data: {
documents: await Promise.all(
documents.map(document => presentDocument(ctx, document))
),
collections: await Promise.all(
collections.map(collection => presentCollection(ctx, collection))
),
},
};
});

View File

@@ -0,0 +1,83 @@
// @flow
import { Document, Collection } from '../models';
import { sequelize } from '../sequelize';
export default async function documentMover({
document,
collectionId,
parentDocumentId,
index,
}: {
document: Document,
collectionId: string,
parentDocumentId: string,
index?: number,
}) {
let transaction;
const result = { collections: [], documents: [] };
const collectionChanged = collectionId !== document.collectionId;
try {
transaction = await sequelize.transaction();
// remove from original collection
const collection = await document.getCollection({ transaction });
const documentJson = await collection.removeDocumentInStructure(document, {
save: false,
});
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
if (collectionChanged) await collection.save({ transaction });
// add to new collection (may be the same)
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
const newCollection: Collection = collectionChanged
? await Collection.findById(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, index, {
documentJson,
});
result.collections.push(collection);
// if collection does not remain the same loop through children and change their
// collectionId too. This includes archived children, otherwise their collection
// would be wrong once restored.
if (collectionChanged) {
result.collections.push(newCollection);
const loopChildren = async documentId => {
const childDocuments = await Document.findAll({
where: { parentDocumentId: documentId },
});
await Promise.all(
childDocuments.map(async child => {
await loopChildren(child.id);
await child.update({ collectionId }, { transaction });
child.collection = newCollection;
result.documents.push(child);
})
);
};
await loopChildren(document.id);
}
await document.save({ transaction });
document.collection = newCollection;
result.documents.push(document);
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
// we need to send all updated models back to the client
return result;
}

View File

@@ -0,0 +1,85 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import documentMover from '../commands/documentMover';
import { flushdb, seed } from '../test/support';
import { buildDocument, buildCollection } from '../test/factories';
beforeEach(flushdb);
describe('documentMover', async () => {
it('should move within a collection', async () => {
const { document, collection } = await seed();
const response = await documentMover({
document,
collectionId: collection.id,
});
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(1);
});
it('should move with children', async () => {
const { document, collection } = await seed();
const newDocument = await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
title: 'Child document',
text: 'content',
});
await collection.addDocumentToStructure(newDocument);
const response = await documentMover({
document,
collectionId: collection.id,
parentDocumentId: undefined,
index: 0,
});
expect(response.collections[0].documentStructure[0].children[0].id).toBe(
newDocument.id
);
expect(response.collections.length).toEqual(1);
expect(response.documents.length).toEqual(1);
});
it('should move with children to another collection', async () => {
const { document, collection } = await seed();
const newCollection = await buildCollection({
teamId: collection.teamId,
});
const newDocument = await buildDocument({
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
title: 'Child document',
text: 'content',
});
await collection.addDocumentToStructure(newDocument);
const response = await documentMover({
document,
collectionId: newCollection.id,
parentDocumentId: undefined,
index: 0,
});
// check document ids where updated
await newDocument.reload();
expect(newDocument.collectionId).toBe(newCollection.id);
await document.reload();
expect(document.collectionId).toBe(newCollection.id);
// check collection structure updated
expect(response.collections[0].id).toBe(collection.id);
expect(response.collections[1].id).toBe(newCollection.id);
expect(response.collections[1].documentStructure[0].children[0].id).toBe(
newDocument.id
);
expect(response.collections.length).toEqual(2);
expect(response.documents.length).toEqual(2);
});
});

View File

@@ -156,8 +156,12 @@ Collection.prototype.addDocumentToStructure = async function(
) {
if (!this.documentStructure) return;
let unlock;
// documentStructure can only be updated by one request at a time
const unlock = await asyncLock(`collection-${this.id}`);
if (options.save !== false) {
unlock = await asyncLock(`collection-${this.id}`);
}
// If moving existing document with children, use existing structure
const documentJson = {
@@ -195,8 +199,11 @@ Collection.prototype.addDocumentToStructure = async function(
// Sequelize doesn't seem to set the value with splice on JSONB field
this.documentStructure = this.documentStructure;
await this.save(options);
unlock();
if (options.save !== false) {
await this.save(options);
if (unlock) unlock();
}
return this;
};
@@ -234,34 +241,21 @@ Collection.prototype.updateDocument = async function(
return this;
};
/**
* moveDocument is combination of removing the document from the structure
* and placing it back the the new location with the existing children.
*/
Collection.prototype.moveDocument = async function(document, index) {
if (!this.documentStructure) return;
const documentJson = await this.removeDocumentInStructure(document);
await this.addDocumentToStructure(document, index, { documentJson });
};
Collection.prototype.deleteDocument = async function(document) {
await this.removeDocumentInStructure(document, { save: true });
await this.removeDocumentInStructure(document);
await document.deleteWithChildren();
};
Collection.prototype.removeDocumentInStructure = async function(
document,
options?: { save?: boolean }
options
) {
if (!this.documentStructure) return;
let returnValue;
let unlock;
if (options && options.save) {
// documentStructure can only be updated by one request at the time
unlock = await asyncLock(`collection-${this.id}`);
}
// documentStructure can only be updated by one request at the time
unlock = await asyncLock(`collection-${this.id}`);
const removeFromChildren = async (children, id) => {
children = await Promise.all(
@@ -287,10 +281,8 @@ Collection.prototype.removeDocumentInStructure = async function(
document.id
);
if (options && options.save) {
await this.save(options);
if (unlock) await unlock();
}
await this.save(options);
if (unlock) await unlock();
return returnValue;
};

View File

@@ -145,37 +145,7 @@ describe('#updateDocument', () => {
});
});
describe('#moveDocument', () => {
test('should move a document without children', async () => {
const { collection, document } = await seed();
expect(collection.documentStructure[1].id).toBe(document.id);
await collection.moveDocument(document, 0);
expect(collection.documentStructure[0].id).toBe(document.id);
});
test('should move a document with children', async () => {
const { collection, document } = await seed();
const newDocument = await Document.create({
parentDocumentId: document.id,
collectionId: collection.id,
teamId: collection.teamId,
userId: collection.creatorId,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
title: 'Child document',
text: 'content',
});
await collection.addDocumentToStructure(newDocument);
await collection.moveDocument(document, 0);
expect(collection.documentStructure[0].children[0].id).toBe(newDocument.id);
});
});
describe('#removeDocument', () => {
const destroyMock = jest.fn();
test('should save if removing', async () => {
const { collection, document } = await seed();
jest.spyOn(collection, 'save');
@@ -260,18 +230,4 @@ describe('#removeDocument', () => {
});
expect(collectionDocuments.count).toBe(2);
});
describe('options: deleteDocument = false', () => {
test('should remove documents from the structure but not destroy them from the DB', async () => {
const { collection, document } = await seed();
jest.spyOn(collection, 'save');
const removedNode = await collection.removeDocumentInStructure(document);
expect(collection.documentStructure.length).toBe(1);
expect(destroyMock).not.toBeCalled();
expect(collection.save).not.toBeCalled();
expect(removedNode.id).toBe(document.id);
expect(removedNode.children).toEqual([]);
});
});
});

View File

@@ -362,7 +362,7 @@ Document.prototype.publish = async function() {
Document.prototype.archive = async function(userId) {
// archive any children and remove from the document structure
const collection = await this.getCollection();
await collection.removeDocumentInStructure(this, { save: true });
await collection.removeDocumentInStructure(this);
this.collection = collection;
await this.archiveWithChildren(userId);

View File

@@ -352,8 +352,7 @@ export default function Pricing() {
<Method method="documents.move" label="Move document in a collection">
<Description>
Move a document into a new location inside the collection. This is
easily done by defining the parent document ID. If no parent
Move a document to a new location or collection. If no parent
document is provided, the document will be moved to the collection
root.
</Description>
@@ -364,8 +363,13 @@ export default function Pricing() {
required
/>
<Argument
id="parentDocument"
description="ID of the new parent document (if any)"
id="collectionId"
description="ID of the collection"
required
/>
<Argument
id="parentDocumentId"
description="ID of the new parent document"
/>
</Arguments>
</Method>

View File

@@ -14,7 +14,7 @@ allow(User, ['read', 'delete'], Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, ['update', 'share'], Document, (user, document) => {
allow(User, ['update', 'move', 'share'], Document, (user, document) => {
if (document.collection) {
if (cannot(user, 'read', document.collection)) return false;
}