Edit collection (#173)

* Collection edit modal

* Add icon

* 💚

* Oh look, some specs

* Delete collection

* Remove from collection

* Handle error responses
Protect against deleting last collection

* Fix key

* 💚

* Keyboard navigate documents list

* Add missing database constraints
This commit is contained in:
Tom Moor
2017-08-29 08:37:17 -07:00
committed by GitHub
parent e0b1c259e8
commit 8558b92cae
22 changed files with 515 additions and 53 deletions

View File

@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#collections.create should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.delete should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.info should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -1,3 +1,4 @@
// @flow
import Router from 'koa-router';
import httpErrors from 'http-errors';
import _ from 'lodash';
@@ -15,7 +16,7 @@ router.post('collections.create', auth(), async ctx => {
const user = ctx.state.user;
const atlas = await Collection.create({
const collection = await Collection.create({
name,
description,
type: type || 'atlas',
@@ -24,7 +25,20 @@ router.post('collections.create', auth(), async ctx => {
});
ctx.body = {
data: await presentCollection(ctx, atlas),
data: await presentCollection(ctx, collection),
};
});
router.post('collections.update', auth(), async ctx => {
const { id, name } = ctx.body;
ctx.assertPresent(name, 'name is required');
const collection = await Collection.findById(id);
collection.name = name;
await collection.save();
ctx.body = {
data: await presentCollection(ctx, collection),
};
});
@@ -33,17 +47,17 @@ router.post('collections.info', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const atlas = await Collection.scope('withRecentDocuments').findOne({
const collection = await Collection.scope('withRecentDocuments').findOne({
where: {
id,
teamId: user.teamId,
},
});
if (!atlas) throw httpErrors.NotFound();
if (!collection) throw httpErrors.NotFound();
ctx.body = {
data: await presentCollection(ctx, atlas),
data: await presentCollection(ctx, collection),
};
});
@@ -59,7 +73,9 @@ router.post('collections.list', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
collections.map(async atlas => await presentCollection(ctx, atlas))
collections.map(
async collection => await presentCollection(ctx, collection)
)
);
ctx.body = {
@@ -68,4 +84,28 @@ router.post('collections.list', auth(), pagination(), async ctx => {
};
});
router.post('collections.delete', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.findById(id);
const total = await Collection.count();
if (total === 1) throw httpErrors.BadRequest('Cannot delete last collection');
if (!collection || collection.teamId !== user.teamId)
throw httpErrors.BadRequest();
try {
await collection.destroy();
} catch (e) {
throw httpErrors.BadRequest('Error while deleting collection');
}
ctx.body = {
success: true,
};
});
export default router;

View File

@@ -0,0 +1,111 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { flushdb, seed } from '../test/support';
import Collection from '../models/Collection';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe('#collections.list', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.list');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should return collections', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.list', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(collection.id);
});
});
describe('#collections.info', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.info');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should return collection', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.info', {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(collection.id);
});
});
describe('#collections.create', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.create');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should create collection', async () => {
const { user } = await seed();
const res = await server.post('/api/collections.create', {
body: { token: user.getJwtToken(), name: 'Test', type: 'atlas' },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.name).toBe('Test');
});
});
describe('#collections.delete', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.delete');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should not delete last collection', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.delete', {
body: { token: user.getJwtToken(), id: collection.id },
});
expect(res.status).toEqual(400);
});
it('should delete collection', async () => {
const { user, collection } = await seed();
await Collection.create({
name: 'Blah',
urlId: 'blah',
teamId: user.teamId,
creatorId: user.id,
type: 'atlas',
});
const res = await server.post('/api/collections.delete', {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { View, Star } from '../models';

View File

@@ -0,0 +1,56 @@
module.exports = {
up: async function(queryInterface, Sequelize) {
await queryInterface.changeColumn('documents', 'atlasId', {
type: Sequelize.UUID,
allowNull: true,
onDelete: 'cascade',
references: {
model: 'collections',
},
});
await queryInterface.changeColumn('documents', 'userId', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
},
});
await queryInterface.changeColumn('documents', 'parentDocumentId', {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'documents',
},
});
await queryInterface.changeColumn('documents', 'teamId', {
type: Sequelize.UUID,
allowNull: true,
onDelete: 'cascade',
references: {
model: 'teams',
},
});
},
down: async function(queryInterface, Sequelize) {
await queryInterface.sequelize.query(
'ALTER TABLE documents DROP CONSTRAINT "atlasId_foreign_idx";'
);
await queryInterface.removeIndex('documents', 'atlasId_foreign_idx');
await queryInterface.sequelize.query(
'ALTER TABLE documents DROP CONSTRAINT "userId_foreign_idx";'
);
await queryInterface.removeIndex('documents', 'userId_foreign_idx');
await queryInterface.sequelize.query(
'ALTER TABLE documents DROP CONSTRAINT "parentDocumentId_foreign_idx";'
);
await queryInterface.removeIndex(
'documents',
'parentDocumentId_foreign_idx'
);
await queryInterface.sequelize.query(
'ALTER TABLE documents DROP CONSTRAINT "teamId_foreign_idx";'
);
await queryInterface.removeIndex('documents', 'teamId_foreign_idx');
},
};

View File

@@ -74,6 +74,7 @@ Collection.associate = models => {
Collection.hasMany(models.Document, {
as: 'documents',
foreignKey: 'atlasId',
onDelete: 'cascade',
});
Collection.belongsTo(models.Team, {
as: 'team',

View File

@@ -112,6 +112,7 @@ Document.associate = models => {
Document.belongsTo(models.Collection, {
as: 'collection',
foreignKey: 'atlasId',
onDelete: 'cascade',
});
Document.belongsTo(models.User, {
as: 'createdBy',
@@ -121,6 +122,10 @@ Document.associate = models => {
as: 'updatedBy',
foreignKey: 'lastModifiedById',
});
Document.hasMany(models.Revision, {
as: 'revisions',
onDelete: 'cascade',
});
Document.hasMany(models.Star, {
as: 'starred',
});

View File

@@ -1,3 +1,4 @@
// @flow
import { DataTypes, sequelize } from '../sequelize';
const Revision = sequelize.define('revision', {