diff --git a/frontend/components/Button/Button.js b/frontend/components/Button/Button.js index 988d6705..d0b3afff 100644 --- a/frontend/components/Button/Button.js +++ b/frontend/components/Button/Button.js @@ -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}; diff --git a/frontend/components/DocumentList/DocumentList.js b/frontend/components/DocumentList/DocumentList.js index 4ca8b63a..a53a2de4 100644 --- a/frontend/components/DocumentList/DocumentList.js +++ b/frontend/components/DocumentList/DocumentList.js @@ -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 ( -
+ {this.props.documents && this.props.documents.map(document => ( ))} -
+ ); } } diff --git a/frontend/components/Icon/MoreIcon.js b/frontend/components/Icon/MoreIcon.js new file mode 100644 index 00000000..5e8126ff --- /dev/null +++ b/frontend/components/Icon/MoreIcon.js @@ -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 ( + + + + + + + ); +} diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 3aace8a7..2b8b4f2c 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -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, actions?: ?React.Element, title?: ?React.Element, @@ -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 ( @@ -144,13 +152,17 @@ type Props = { Starred - - - - {ui.activeCollection + {collections.active + ? + + + : + + } + {collections.active ? : } @@ -171,9 +183,22 @@ type Props = { + + {collections.active && + } + { authenticatedStores = { user, documents: new DocumentsStore({ + ui: stores.ui, cache, }), collections: new CollectionsStore({ + ui: stores.ui, teamId: user.team.id, cache, }), diff --git a/frontend/models/Collection.js b/frontend/models/Collection.js index 65ac1e1a..13d8e5e8 100644 --- a/frontend/models/Collection.js +++ b/frontend/models/Collection.js @@ -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); diff --git a/frontend/models/Document.js b/frontend/models/Document.js index 82acf88e..ea39c2f6 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -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 { diff --git a/frontend/scenes/CollectionEdit/CollectionEdit.js b/frontend/scenes/CollectionEdit/CollectionEdit.js new file mode 100644 index 00000000..e3718808 --- /dev/null +++ b/frontend/scenes/CollectionEdit/CollectionEdit.js @@ -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 ( + +
+ + You can edit a collection name at any time, but doing so might + confuse your team mates. + + + +
+
+
+ + Deleting a collection will also delete all of the documents within + it, so be careful with that. + + {!this.isConfirming && + } + {this.isConfirming && + + + + } +
+
+ ); + } +} + +export default CollectionEdit; diff --git a/frontend/scenes/CollectionEdit/index.js b/frontend/scenes/CollectionEdit/index.js new file mode 100644 index 00000000..205ea254 --- /dev/null +++ b/frontend/scenes/CollectionEdit/index.js @@ -0,0 +1,3 @@ +// @flow +import CollectionEdit from './CollectionEdit'; +export default CollectionEdit; diff --git a/frontend/scenes/CollectionNew/CollectionNew.js b/frontend/scenes/CollectionNew/CollectionNew.js index 186e8fa6..4b9f7819 100644 --- a/frontend/scenes/CollectionNew/CollectionNew.js +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -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); } diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index 98f4e2da..d90c0985 100644 --- a/frontend/stores/CollectionsStore.js +++ b/frontend/stores/CollectionsStore.js @@ -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 => { - let collection = _.find(this.data, { id }); + @action fetchById = async (id: string): Promise => { + 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) diff --git a/frontend/stores/DocumentsStore.js b/frontend/stores/DocumentsStore.js index 473d9b1d..3d839a1c 100644 --- a/frontend/stores/DocumentsStore.js +++ b/frontend/stores/DocumentsStore.js @@ -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) { diff --git a/frontend/stores/UiStore.js b/frontend/stores/UiStore.js index 6345af8b..e8db5c5e 100644 --- a/frontend/stores/UiStore.js +++ b/frontend/stores/UiStore.js @@ -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() { diff --git a/frontend/styles/constants.js b/frontend/styles/constants.js index e4f9a9a5..56bf05aa 100644 --- a/frontend/styles/constants.js +++ b/frontend/styles/constants.js @@ -41,6 +41,7 @@ export const color = { /* Brand */ primary: '#2B8FBF', + danger: '#D0021B', /* Dark Grays */ slate: '#9BA6B2', diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap new file mode 100644 index 00000000..5002b47e --- /dev/null +++ b/server/api/__snapshots__/collections.test.js.snap @@ -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, +} +`; diff --git a/server/api/collections.js b/server/api/collections.js index 2b48f40f..2e76179b 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -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; diff --git a/server/api/collections.test.js b/server/api/collections.test.js new file mode 100644 index 00000000..6f6b320c --- /dev/null +++ b/server/api/collections.test.js @@ -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); + }); +}); diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 000b45bb..97ea9b02 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -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'; diff --git a/server/migrations/20170827182423-improve-references.js b/server/migrations/20170827182423-improve-references.js new file mode 100644 index 00000000..788f3b60 --- /dev/null +++ b/server/migrations/20170827182423-improve-references.js @@ -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'); + }, +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index f124eb26..45a36a77 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -74,6 +74,7 @@ Collection.associate = models => { Collection.hasMany(models.Document, { as: 'documents', foreignKey: 'atlasId', + onDelete: 'cascade', }); Collection.belongsTo(models.Team, { as: 'team', diff --git a/server/models/Document.js b/server/models/Document.js index eef624d3..7ee9e425 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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', }); diff --git a/server/models/Revision.js b/server/models/Revision.js index 83107856..d69892ea 100644 --- a/server/models/Revision.js +++ b/server/models/Revision.js @@ -1,3 +1,4 @@ +// @flow import { DataTypes, sequelize } from '../sequelize'; const Revision = sequelize.define('revision', {