From 483bf29cc46443adba3c6d1739ed507da06b74da Mon Sep 17 00:00:00 2001 From: Jori Lallo Date: Mon, 4 Sep 2017 14:48:56 -0700 Subject: [PATCH] Workable document moving --- frontend/components/Labeled/Labeled.js | 29 ++ frontend/components/Labeled/index.js | 3 + frontend/components/Modal/Modal.js | 3 +- frontend/index.js | 11 +- frontend/models/Collection.js | 3 + frontend/models/Document.js | 27 +- frontend/scenes/Document/Document.js | 17 +- .../components/DocumentMove/DocumentMove.js | 252 ++++++++++++++++++ .../Document/components/DocumentMove/index.js | 3 + frontend/scenes/Document/components/Menu.js | 5 + frontend/scenes/Search/Search.js | 82 ++++-- frontend/scenes/Search/SearchStore.js | 37 --- frontend/utils/routeHelpers.js | 6 + server/api/documents.js | 2 + 14 files changed, 414 insertions(+), 66 deletions(-) create mode 100644 frontend/components/Labeled/Labeled.js create mode 100644 frontend/components/Labeled/index.js create mode 100644 frontend/scenes/Document/components/DocumentMove/DocumentMove.js create mode 100644 frontend/scenes/Document/components/DocumentMove/index.js delete mode 100644 frontend/scenes/Search/SearchStore.js diff --git a/frontend/components/Labeled/Labeled.js b/frontend/components/Labeled/Labeled.js new file mode 100644 index 00000000..bfff39c9 --- /dev/null +++ b/frontend/components/Labeled/Labeled.js @@ -0,0 +1,29 @@ +// @flow +import React from 'react'; +import { observer } from 'mobx-react'; +import Flex from 'components/Flex'; +import styled from 'styled-components'; +import { size } from 'styles/constants'; + +type Props = { + label: React.Element<*> | string, + children: React.Element<*>, +}; + +const Labeled = ({ label, children, ...props }: Props) => ( + +
{label}
+ {children} +
+); + +const Header = styled(Flex)` + margin-bottom: ${size.medium}; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + color: #9FA6AB; + letter-spacing: 0.04em; +`; + +export default observer(Labeled); diff --git a/frontend/components/Labeled/index.js b/frontend/components/Labeled/index.js new file mode 100644 index 00000000..544f6a7b --- /dev/null +++ b/frontend/components/Labeled/index.js @@ -0,0 +1,3 @@ +// @flow +import Labeled from './Labeled'; +export default Labeled; diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js index cde11308..d0b208e9 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -1,5 +1,6 @@ // @flow import React from 'react'; +import { observer } from 'mobx-react'; import styled from 'styled-components'; import ReactModal from 'react-modal'; import { color } from 'styles/constants'; @@ -75,4 +76,4 @@ const Close = styled.a` } `; -export default Modal; +export default observer(Modal); diff --git a/frontend/index.js b/frontend/index.js index 9754daaa..e6784ea7 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -39,6 +39,8 @@ import RouteSidebarHidden from 'components/RouteSidebarHidden'; import flatpages from 'static/flatpages'; +import { matchDocumentSlug } from 'utils/routeHelpers'; + let DevTools; if (__DEV__) { DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require @@ -93,8 +95,6 @@ const RedirectDocument = ({ match }: { match: Object }) => ( ); -const matchDocumentSlug = ':documentSlug([0-9a-zA-Z-]*-[a-zA-z0-9]{10,15})'; - render(
@@ -123,6 +123,11 @@ render( path={`/doc/${matchDocumentSlug}`} component={Document} /> + @@ -132,7 +137,7 @@ render( { + if (data.collectionId === this.id) this.fetch(); + }); } } diff --git a/frontend/models/Document.js b/frontend/models/Document.js index eade3779..39cfdb0a 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -47,11 +47,17 @@ class Document extends BaseModel { return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt; } - @computed get pathToDocument(): Array { + @computed get pathToDocument(): Array<{ id: string, title: string }> { let path; const traveler = (nodes, previousPath) => { nodes.forEach(childNode => { - const newPath = [...previousPath, childNode.id]; + const newPath = [ + ...previousPath, + { + id: childNode.id, + title: childNode.title, + }, + ]; if (childNode.id === this.id) { path = newPath; return; @@ -174,6 +180,23 @@ class Document extends BaseModel { return this; }; + @action move = async (parentDocumentId: ?string) => { + try { + const res = await client.post('/documents.move', { + id: this.id, + parentDocument: parentDocumentId, + }); + this.updateData(res.data); + this.emit('documents.move', { + id: this.id, + collectionId: this.collection.id, + }); + } catch (e) { + this.errors.add('Error while moving the document'); + } + return; + }; + @action delete = async () => { try { await client.post('/documents.delete', { id: this.id }); diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index b64c0023..78a015c6 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -2,13 +2,16 @@ import React, { Component } from 'react'; import get from 'lodash/get'; import styled from 'styled-components'; +import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; -import { withRouter, Prompt } from 'react-router'; +import { withRouter, Prompt, Route } from 'react-router'; import Flex from 'components/Flex'; import { color, layout } from 'styles/constants'; import { collectionUrl, updateDocumentUrl } from 'utils/routeHelpers'; import Document from 'models/Document'; +import Modal from 'components/Modal'; +import DocumentMove from './components/DocumentMove'; import UiStore from 'stores/UiStore'; import DocumentsStore from 'stores/DocumentsStore'; import Menu from './components/Menu'; @@ -22,6 +25,8 @@ import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import Search from 'scenes/Search'; +import { matchDocumentEdit, matchDocumentMove } from 'utils/routeHelpers'; + const DISCARD_CHANGES = ` You have unsaved changes. Are you sure you want to discard them? @@ -51,6 +56,8 @@ type Props = { notFound: false, }; + @observable moveModalOpen: boolean = false; + componentDidMount() { this.loadDocument(this.props); } @@ -120,6 +127,9 @@ type Props = { this.props.history.push(`${this.document.collection.url}/new`); }; + handleCloseMoveModal = () => (this.moveModalOpen = false); + handleOpenMoveModal = () => (this.moveModalOpen = true); + onSave = async (redirect: boolean = false) => { if (this.document && !this.document.allowSave) return; let document = this.document; @@ -181,7 +191,8 @@ type Props = { render() { const isNew = this.props.newDocument; - const isEditing = !!this.props.match.params.edit || isNew; + const isMoving = this.props.match.path === matchDocumentMove; + const isEditing = this.props.match.path === matchDocumentEdit || isNew; const isFetching = !this.document; const titleText = get(this.document, 'title', ''); const document = this.document; @@ -192,6 +203,8 @@ type Props = { return ( + {isMoving && document && } + {this.state.isDragging && Drop files here to import into Atlas. diff --git a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js new file mode 100644 index 00000000..9999ab4c --- /dev/null +++ b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js @@ -0,0 +1,252 @@ +// @flow +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { observable, runInAction, action } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import { withRouter } from 'react-router'; +import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; +import _ from 'lodash'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import styled from 'styled-components'; +import { size, color } from 'styles/constants'; + +import Modal from 'components/Modal'; +import Button from 'components/Button'; +import Input from 'components/Input'; +import HelpText from 'components/HelpText'; +import Labeled from 'components/Labeled'; +import Flex from 'components/Flex'; +import ChevronIcon from 'components/Icon/ChevronIcon'; + +import Document from 'models/Document'; +import DocumentsStore from 'stores/DocumentsStore'; + +type Props = { + match: Object, + history: Object, + document: Document, + documents: DocumentsStore, +}; + +@observer class DocumentMove extends Component { + props: Props; + store: DocumentMoveStore; + firstDocument: HTMLElement; + + @observable isSaving: boolean; + @observable resultIds: Array = []; // Document IDs + @observable searchTerm: ?string = null; + @observable isFetching = false; + + handleKeyDown = ev => { + // Down + if (ev.which === 40) { + ev.preventDefault(); + if (this.firstDocument) { + const element = ReactDOM.findDOMNode(this.firstDocument); + // $FlowFixMe + if (element && element.focus) element.focus(); + } + } + }; + + handleClose = () => { + this.props.history.push(this.props.document.url); + }; + + handleFilter = (e: SyntheticEvent) => { + const value = e.target.value; + this.searchTerm = value; + this.updateSearchResults(); + }; + + updateSearchResults = _.debounce(() => { + this.search(); + }, 250); + + setFirstDocumentRef = ref => { + this.firstDocument = ref; + }; + + @action search = async () => { + this.isFetching = true; + + if (this.searchTerm) { + try { + const res = await client.get('/documents.search', { + query: this.searchTerm, + }); + invariant(res && res.data, 'res or res.data missing'); + const { data } = res; + runInAction('search document', () => { + // Fill documents store + data.forEach(documentData => + this.props.documents.add(new Document(documentData)) + ); + this.resultIds = data.map(documentData => documentData.id); + }); + } catch (e) { + console.error('Something went wrong'); + } + } else { + this.resultIds = []; + } + + this.isFetching = false; + }; + + render() { + const { document, documents } = this.props; + + return ( + + +
+ + + +
+ +
+ + + + + + this.setFirstDocumentRef(ref)} + onSuccess={this.handleClose} + /> + {this.resultIds.map((documentId, index) => ( + + ))} + + +
+ + {false && + } +
+ ); + } +} + +const Section = styled(Flex)` + margin-bottom: ${size.huge}; +`; + +const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` + display: flex; + flex-direction: column; + flex: 1; +`; + +type PathToDocumentProps = { + documentId: string, + onSuccess?: Function, + documents: DocumentsStore, + document?: Document, + ref?: Function, + selectable?: boolean, +}; + +class PathToDocument extends React.Component { + props: PathToDocumentProps; + + get resultDocument(): ?Document { + return this.props.documents.getById(this.props.documentId); + } + + handleSelect = async event => { + const { document } = this.props; + invariant(this.props.onSuccess, 'onSuccess unavailable'); + event.preventDefault(); + await document.move(this.resultDocument ? this.resultDocument.id : null); + this.props.onSuccess(); + }; + + render() { + const { document, onSuccess, ref } = this.props; + const { collection } = document || this.resultDocument; + const Component = onSuccess ? ResultWrapperLink : ResultWrapper; + + return ( + + {collection.name} + {this.resultDocument && + + {' '} + + {' '} + {this.resultDocument.pathToDocument + .map(doc => {doc.title}) + .reduce((prev, curr) => [prev, , curr])} + } + {document && + + {' '} + + {' '}{document.title} + } + + ); + } +} + +const ResultWrapper = styled.div` + display: flex; + margin-bottom: 10px; + + color: ${color.text}; + cursor: default; +`; + +const ResultWrapperLink = ResultWrapper.withComponent('a').extend` + padding-top: 3px; + + &:hover, + &:active, + &:focus { + margin-left: -8px; + padding-left: 6px; + background: ${color.smokeLight}; + border-left: 2px solid ${color.primary}; + outline: none; + cursor: pointer; + } +`; + +export default withRouter(inject('documents')(DocumentMove)); diff --git a/frontend/scenes/Document/components/DocumentMove/index.js b/frontend/scenes/Document/components/DocumentMove/index.js new file mode 100644 index 00000000..3f3eb8bf --- /dev/null +++ b/frontend/scenes/Document/components/DocumentMove/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentMove from './DocumentMove'; +export default DocumentMove; diff --git a/frontend/scenes/Document/components/Menu.js b/frontend/scenes/Document/components/Menu.js index 9bae6f0b..7eca8f58 100644 --- a/frontend/scenes/Document/components/Menu.js +++ b/frontend/scenes/Document/components/Menu.js @@ -51,6 +51,10 @@ type Props = { } }; + onMove = () => { + this.props.history.push(`${this.props.document.url}/move`); + }; + render() { const document = get(this.props, 'document'); if (document) { @@ -69,6 +73,7 @@ type Props = { New document
} + Move Export {allowDelete && Delete} diff --git a/frontend/scenes/Search/Search.js b/frontend/scenes/Search/Search.js index 2b6e0acc..7665ff79 100644 --- a/frontend/scenes/Search/Search.js +++ b/frontend/scenes/Search/Search.js @@ -2,18 +2,23 @@ import React from 'react'; import ReactDOM from 'react-dom'; import keydown from 'react-keydown'; -import { observer } from 'mobx-react'; +import { observable, action, runInAction } from 'mobx'; +import { observer, inject } from 'mobx-react'; import _ from 'lodash'; -import Flex from 'components/Flex'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import Document from 'models/Document'; +import DocumentsStore from 'stores/DocumentsStore'; + import { withRouter } from 'react-router'; import { searchUrl } from 'utils/routeHelpers'; import styled from 'styled-components'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; +import Flex from 'components/Flex'; import CenteredContent from 'components/CenteredContent'; import LoadingIndicator from 'components/LoadingIndicator'; import SearchField from './components/SearchField'; -import SearchStore from './SearchStore'; import DocumentPreview from 'components/DocumentPreview'; import PageTitle from 'components/PageTitle'; @@ -21,6 +26,7 @@ import PageTitle from 'components/PageTitle'; type Props = { history: Object, match: Object, + documents: DocumentsStore, notFound: ?boolean, }; @@ -55,9 +61,11 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` props: Props; store: SearchStore; - constructor(props: Props) { - super(props); - this.store = new SearchStore(); + @observable resultIds: Array = []; // Document IDs + @observable searchTerm: ?string = null; + @observable isFetching = false; + + componentDidMount() { this.updateSearchResults(); } @@ -91,9 +99,35 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` }; updateSearchResults = _.debounce(() => { - this.store.search(this.props.match.params.query); + this.search(this.props.match.params.query); }, 250); + @action search = async (query: string) => { + this.searchTerm = query; + this.isFetching = true; + + if (query) { + try { + const res = await client.get('/documents.search', { query }); + invariant(res && res.data, 'res or res.data missing'); + const { data } = res; + runInAction('search document', () => { + // Fill documents store + data.forEach(documentData => + this.props.documents.add(new Document(documentData)) + ); + this.resultIds = data.map(documentData => documentData.id); + }); + } catch (e) { + console.error('Something went wrong'); + } + } else { + this.resultIds = []; + } + + this.isFetching = false; + }; + updateQuery = query => { this.props.history.replace(searchUrl(query)); }; @@ -103,20 +137,21 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` }; get title() { - const query = this.store.searchTerm; + const query = this.searchTerm; const title = 'Search'; if (query) return `${query} - ${title}`; return title; } render() { + const { documents } = this.props; const query = this.props.match.params.query; - const hasResults = this.store.documents.length > 0; + const hasResults = this.resultIds.length > 0; return ( - {this.store.isFetching && } + {this.isFetching && } {this.props.notFound &&

Not Found

@@ -125,7 +160,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
} - {this.store.documents.map((document, index) => ( - index === 0 && this.setFirstDocumentRef(ref)} - key={document.id} - document={document} - highlight={this.store.searchTerm} - showCollection - /> - ))} + {this.resultIds.map((documentId, index) => { + const document = documents.getById(documentId); + if (document) + return ( + + index === 0 && this.setFirstDocumentRef(ref)} + key={documentId} + document={document} + highlight={this.searchTerm} + showCollection + /> + ); + })} @@ -152,4 +192,4 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` } } -export default withRouter(Search); +export default withRouter(inject('documents')(Search)); diff --git a/frontend/scenes/Search/SearchStore.js b/frontend/scenes/Search/SearchStore.js deleted file mode 100644 index 68088ce8..00000000 --- a/frontend/scenes/Search/SearchStore.js +++ /dev/null @@ -1,37 +0,0 @@ -// @flow -import { observable, action, runInAction } from 'mobx'; -import invariant from 'invariant'; -import { client } from 'utils/ApiClient'; -import Document from 'models/Document'; - -class SearchStore { - @observable documents: Array = []; - @observable searchTerm: ?string = null; - @observable isFetching = false; - - /* Actions */ - - @action search = async (query: string) => { - this.searchTerm = query; - this.isFetching = true; - - if (query) { - try { - const res = await client.get('/documents.search', { query }); - invariant(res && res.data, 'res or res.data missing'); - const { data } = res; - runInAction('search document', () => { - this.documents = data.map(documentData => new Document(documentData)); - }); - } catch (e) { - console.error('Something went wrong'); - } - } else { - this.documents = []; - } - - this.isFetching = false; - }; -} - -export default SearchStore; diff --git a/frontend/utils/routeHelpers.js b/frontend/utils/routeHelpers.js index 2a273bd5..e501f2a9 100644 --- a/frontend/utils/routeHelpers.js +++ b/frontend/utils/routeHelpers.js @@ -39,6 +39,12 @@ export function notFoundUrl(): string { return '/404'; } +export const matchDocumentSlug = + ':documentSlug([0-9a-zA-Z-]*-[a-zA-z0-9]{10,15})'; + +export const matchDocumentEdit = `/doc/${matchDocumentSlug}/edit`; +export const matchDocumentMove = `/doc/${matchDocumentSlug}/move`; + /** * Replace full url's document part with the new one in case * the document slug has been updated diff --git a/server/api/documents.js b/server/api/documents.js index a179f983..2eb51b4f 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -281,6 +281,8 @@ router.post('documents.move', auth(), async ctx => { await collection.deleteDocument(document); await collection.addDocumentToStructure(document, index); } + // Update collection + document.collection = collection; document.collection = collection;