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