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

@ -6,10 +6,10 @@ import { darken } from 'polished';
const RealButton = styled.button`
display: inline-block;
margin: 0 0 ${size.large};
margin: 0 ${size.medium} ${size.large} 0;
padding: 0;
border: 0;
background: ${color.primary};
background: ${props => (props.neutral ? color.slate : props.danger ? color.danger : color.primary)};
color: ${color.white};
border-radius: 4px;
min-width: 32px;
@ -23,7 +23,7 @@ const RealButton = styled.button`
border: 0;
}
&:hover {
background: ${darken(0.05, color.primary)};
background: ${props => darken(0.05, props.neutral ? color.slate : props.danger ? color.danger : color.primary)};
}
&:disabled {
background: ${color.slateLight};

View File

@ -2,6 +2,7 @@
import React from 'react';
import Document from 'models/Document';
import DocumentPreview from 'components/DocumentPreview';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
class DocumentList extends React.Component {
props: {
@ -10,12 +11,15 @@ class DocumentList extends React.Component {
render() {
return (
<div>
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.props.documents &&
this.props.documents.map(document => (
<DocumentPreview key={document.id} document={document} />
))}
</div>
</ArrowKeyNavigation>
);
}
}

View File

@ -0,0 +1,21 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function MoreIcon(props: Props) {
return (
<Icon {...props}>
<svg
fill="#000000"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</Icon>
);
}

View File

@ -17,7 +17,9 @@ import Scrollable from 'components/Scrollable';
import Avatar from 'components/Avatar';
import Modal from 'components/Modal';
import AddIcon from 'components/Icon/AddIcon';
import MoreIcon from 'components/Icon/MoreIcon';
import CollectionNew from 'scenes/CollectionNew';
import CollectionEdit from 'scenes/CollectionEdit';
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
import Settings from 'scenes/Settings';
@ -29,10 +31,12 @@ import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
import CollectionsStore from 'stores/CollectionsStore';
import DocumentsStore from 'stores/DocumentsStore';
type Props = {
history: Object,
collections: CollectionsStore,
documents: DocumentsStore,
children?: ?React.Element<any>,
actions?: ?React.Element<any>,
title?: ?React.Element<any>,
@ -66,8 +70,8 @@ type Props = {
@keydown('e')
goToEdit() {
if (!this.props.ui.activeDocument) return;
this.props.history.push(documentEditUrl(this.props.ui.activeDocument));
if (!this.props.documents.active) return;
this.props.history.push(documentEditUrl(this.props.documents.active));
}
handleLogout = () => {
@ -87,12 +91,16 @@ type Props = {
this.modal = 'create-collection';
};
handleEditCollection = () => {
this.modal = 'edit-collection';
};
handleCloseModal = () => {
this.modal = null;
};
render() {
const { user, auth, collections, history, ui } = this.props;
const { user, auth, documents, collections, history, ui } = this.props;
return (
<Container column auto>
@ -144,13 +152,17 @@ type Props = {
<SidebarLink to="/starred">Starred</SidebarLink>
</LinkSection>
<LinkSection>
<CreateCollection onClick={this.handleCreateCollection}>
<AddIcon />
</CreateCollection>
{ui.activeCollection
{collections.active
? <CollectionAction onClick={this.handleEditCollection}>
<MoreIcon />
</CollectionAction>
: <CollectionAction onClick={this.handleCreateCollection}>
<AddIcon />
</CollectionAction>}
{collections.active
? <SidebarCollection
document={ui.activeDocument}
collection={ui.activeCollection}
document={documents.active}
collection={collections.active}
history={this.props.history}
/>
: <SidebarCollectionList history={this.props.history} />}
@ -171,9 +183,22 @@ type Props = {
<CollectionNew
collections={collections}
history={history}
onCollectionCreated={this.handleCloseModal}
onSubmit={this.handleCloseModal}
/>
</Modal>
<Modal
isOpen={this.modal === 'edit-collection'}
onRequestClose={this.handleCloseModal}
title="Edit collection"
>
{collections.active &&
<CollectionEdit
collection={collections.active}
collections={collections}
history={history}
onSubmit={this.handleCloseModal}
/>}
</Modal>
<Modal
isOpen={this.modal === 'keyboard-shortcuts'}
onRequestClose={this.handleCloseModal}
@ -193,7 +218,7 @@ type Props = {
}
}
const CreateCollection = styled.a`
const CollectionAction = styled.a`
position: absolute;
top: 8px;
right: ${layout.hpadding};
@ -260,4 +285,6 @@ const LinkSection = styled(Flex)`
position: relative;
`;
export default withRouter(inject('user', 'auth', 'ui', 'collections')(Layout));
export default withRouter(
inject('user', 'auth', 'ui', 'documents', 'collections')(Layout)
);

View File

@ -62,9 +62,11 @@ const Auth = ({ children }: AuthProps) => {
authenticatedStores = {
user,
documents: new DocumentsStore({
ui: stores.ui,
cache,
}),
collections: new CollectionsStore({
ui: stores.ui,
teamId: user.team.id,
cache,
}),

View File

@ -67,9 +67,11 @@ class Collection extends BaseModel {
description: this.description,
});
}
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
this.hasPendingChanges = false;
runInAction('Collection#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
this.hasPendingChanges = false;
});
} catch (e) {
this.errors.add('Collection failed saving');
return false;
@ -80,6 +82,17 @@ class Collection extends BaseModel {
return true;
};
@action delete = async () => {
try {
const res = await client.post('/collections.delete', { id: this.id });
invariant(res && res.data, 'Data should be available');
const { data } = res;
return data.success;
} catch (e) {
this.errors.add('Collection failed to delete');
}
};
updateData(data: Object = {}) {
this.data = data;
extendObservable(this, data);

View File

@ -155,12 +155,11 @@ class Document extends BaseModel {
// }
res = await client.post('/documents.create', data);
}
invariant(res && res.data, 'Data should be available');
this.updateData({
...res.data,
runInAction('Document#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
this.hasPendingChanges = false;
});
this.hasPendingChanges = false;
} catch (e) {
this.errors.add('Document failed saving');
} finally {

View File

@ -0,0 +1,119 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { homeUrl } from 'utils/routeHelpers';
import Button from 'components/Button';
import Input from 'components/Input';
import Flex from 'components/Flex';
import HelpText from 'components/HelpText';
import Collection from 'models/Collection';
import CollectionsStore from 'stores/CollectionsStore';
type Props = {
history: Object,
collection: Collection,
collections: CollectionsStore,
onSubmit: () => void,
};
@observer class CollectionEdit extends Component {
props: Props;
@observable name: string;
@observable isConfirming: boolean;
@observable isDeleting: boolean;
@observable isSaving: boolean;
componentWillMount() {
this.name = this.props.collection.name;
}
handleSubmit = async (ev: SyntheticEvent) => {
ev.preventDefault();
this.isSaving = true;
this.props.collection.updateData({ name: this.name });
const success = await this.props.collection.save();
if (success) {
this.props.onSubmit();
}
this.isSaving = false;
};
handleNameChange = (ev: SyntheticInputEvent) => {
this.name = ev.target.value;
};
confirmDelete = () => {
this.isConfirming = true;
};
cancelDelete = () => {
this.isConfirming = false;
};
confirmedDelete = async (ev: SyntheticEvent) => {
ev.preventDefault();
this.isDeleting = true;
const success = await this.props.collection.delete();
if (success) {
this.props.collections.remove(this.props.collection.id);
this.props.history.push(homeUrl());
this.props.onSubmit();
}
this.isDeleting = false;
};
render() {
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
You can edit a collection name at any time, but doing so might
confuse your team mates.
</HelpText>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
/>
<Button
type="submit"
disabled={this.isSaving || !this.props.collection.name}
>
{this.isSaving ? 'Saving…' : 'Save'}
</Button>
</form>
<hr />
<form>
<HelpText>
Deleting a collection will also delete all of the documents within
it, so be careful with that.
</HelpText>
{!this.isConfirming &&
<Button type="submit" onClick={this.confirmDelete} neutral>
Delete
</Button>}
{this.isConfirming &&
<span>
<Button type="submit" onClick={this.cancelDelete} neutral>
Cancel
</Button>
<Button type="submit" onClick={this.confirmedDelete} danger>
{this.isDeleting ? 'Deleting…' : 'Confirm Delete'}
</Button>
</span>}
</form>
</Flex>
);
}
}
export default CollectionEdit;

View File

@ -0,0 +1,3 @@
// @flow
import CollectionEdit from './CollectionEdit';
export default CollectionEdit;

View File

@ -12,7 +12,7 @@ import CollectionsStore from 'stores/CollectionsStore';
type Props = {
history: Object,
collections: CollectionsStore,
onCollectionCreated: () => void,
onSubmit: () => void,
};
@observer class CollectionNew extends Component {
@ -34,7 +34,7 @@ type Props = {
if (success) {
this.props.collections.add(this.collection);
this.props.onCollectionCreated();
this.props.onSubmit();
this.props.history.push(this.collection.url);
}

View File

@ -1,6 +1,7 @@
// @flow
import {
observable,
computed,
action,
runInAction,
ObservableArray,
@ -14,12 +15,14 @@ import stores from 'stores';
import Collection from 'models/Collection';
import ErrorsStore from 'stores/ErrorsStore';
import CacheStore from 'stores/CacheStore';
import UiStore from 'stores/UiStore';
const COLLECTION_CACHE_KEY = 'COLLECTION_CACHE_KEY';
type Options = {
teamId: string,
cache: CacheStore,
ui: UiStore,
};
class CollectionsStore {
@ -30,6 +33,13 @@ class CollectionsStore {
teamId: string;
errors: ErrorsStore;
cache: CacheStore;
ui: UiStore;
@computed get active(): ?Collection {
return this.ui.activeCollectionId
? this.getById(this.ui.activeCollectionId)
: undefined;
}
/* Actions */
@ -49,8 +59,8 @@ class CollectionsStore {
}
};
@action getById = async (id: string): Promise<?Collection> => {
let collection = _.find(this.data, { id });
@action fetchById = async (id: string): Promise<?Collection> => {
let collection = this.getById(id);
if (!collection) {
try {
const res = await this.client.post('/collections.info', {
@ -79,18 +89,23 @@ class CollectionsStore {
this.data.splice(this.data.indexOf(id), 1);
};
getById = (id: string): ?Collection => {
return _.find(this.data, { id });
};
constructor(options: Options) {
this.client = client;
this.errors = stores.errors;
this.teamId = options.teamId;
this.cache = options.cache;
this.cache.getItem(COLLECTION_CACHE_KEY).then(data => {
if (data) {
this.data.replace(data.map(collection => new Collection(collection)));
this.isLoaded = true;
}
});
this.ui = options.ui;
//
// this.cache.getItem(COLLECTION_CACHE_KEY).then(data => {
// if (data) {
// this.data.replace(data.map(collection => new Collection(collection)));
// this.isLoaded = true;
// }
// });
autorunAsync('CollectionsStore.persists', () => {
if (this.data.length > 0)

View File

@ -16,11 +16,13 @@ import stores from 'stores';
import Document from 'models/Document';
import ErrorsStore from 'stores/ErrorsStore';
import CacheStore from 'stores/CacheStore';
import UiStore from 'stores/UiStore';
const DOCUMENTS_CACHE_KEY = 'DOCUMENTS_CACHE_KEY';
type Options = {
cache: CacheStore,
ui: UiStore,
};
class DocumentsStore extends BaseStore {
@ -31,6 +33,7 @@ class DocumentsStore extends BaseStore {
errors: ErrorsStore;
cache: CacheStore;
ui: UiStore;
/* Computed */
@ -49,6 +52,12 @@ class DocumentsStore extends BaseStore {
return _.filter(this.data.values(), 'starred');
}
@computed get active(): ?Document {
return this.ui.activeDocumentId
? this.getById(this.ui.activeDocumentId)
: undefined;
}
/* Actions */
@action fetchAll = async (request: string = 'list'): Promise<*> => {
@ -127,6 +136,7 @@ class DocumentsStore extends BaseStore {
this.errors = stores.errors;
this.cache = options.cache;
this.ui = options.ui;
this.cache.getItem(DOCUMENTS_CACHE_KEY).then(data => {
if (data) {

View File

@ -1,27 +1,23 @@
// @flow
import { observable, action, computed } from 'mobx';
import { observable, action } from 'mobx';
import Document from 'models/Document';
import Collection from 'models/Collection';
class UiStore {
@observable activeDocument: ?Document;
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
/* Computed */
@computed get activeCollection(): ?Collection {
return this.activeDocument ? this.activeDocument.collection : undefined;
}
/* Actions */
@action setActiveDocument = (document: Document): void => {
this.activeDocument = document;
this.activeDocumentId = document.id;
this.activeCollectionId = document.collection.id;
};
@action clearActiveDocument = (): void => {
this.activeDocument = undefined;
this.activeDocumentId = undefined;
this.activeCollectionId = undefined;
};
@action enableEditMode() {

View File

@ -41,6 +41,7 @@ export const color = {
/* Brand */
primary: '#2B8FBF',
danger: '#D0021B',
/* Dark Grays */
slate: '#9BA6B2',

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', {