Merge master

This commit is contained in:
Tom Moor
2017-09-13 19:18:49 -07:00
27 changed files with 581 additions and 144 deletions

View File

@@ -2,13 +2,21 @@
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 keydown from 'react-keydown';
import Flex from 'components/Flex';
import { color, layout } from 'styles/constants';
import { collectionUrl } from 'utils/routeHelpers';
import {
collectionUrl,
updateDocumentUrl,
matchDocumentEdit,
matchDocumentMove,
} from 'utils/routeHelpers';
import Document from 'models/Document';
import DocumentMove from './components/DocumentMove';
import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore';
import DocumentMenu from 'menus/DocumentMenu';
@@ -39,17 +47,16 @@ type Props = {
@observer class DocumentScene extends Component {
props: Props;
savedTimeout: number;
state: {
newDocument?: Document,
};
state = {
isDragging: false,
isLoading: false,
isSaving: false,
newDocument: undefined,
showAsSaved: false,
notFound: false,
};
@observable editCache: ?string;
@observable newDocument: ?Document;
@observable isDragging = false;
@observable isLoading = false;
@observable isSaving = false;
@observable showAsSaved = false;
@observable notFound = false;
@observable moveModalOpen: boolean = false;
componentDidMount() {
this.loadDocument(this.props);
@@ -60,7 +67,7 @@ type Props = {
nextProps.match.params.documentSlug !==
this.props.match.params.documentSlug
) {
this.setState({ notFound: false });
this.notFound = false;
this.loadDocument(nextProps);
}
}
@@ -70,6 +77,12 @@ type Props = {
this.props.ui.clearActiveDocument();
}
@keydown('m')
goToMove(event) {
event.preventDefault();
if (this.document) this.props.history.push(`${this.document.url}/move`);
}
loadDocument = async props => {
if (props.newDocument) {
const newDocument = new Document({
@@ -77,7 +90,7 @@ type Props = {
title: '',
text: '',
});
this.setState({ newDocument });
this.newDocument = newDocument;
} else {
let document = this.document;
if (document) {
@@ -89,16 +102,23 @@ type Props = {
if (document) {
this.props.ui.setActiveDocument(document);
// Cache data if user enters edit mode and cancels
this.editCache = document.text;
document.view();
// Update url to match the current one
this.props.history.replace(
updateDocumentUrl(this.props.match.url, document.url)
);
} else {
// Render 404 with search
this.setState({ notFound: true });
this.notFound = true;
}
}
};
get document() {
if (this.state.newDocument) return this.state.newDocument;
if (this.newDocument) return this.newDocument;
return this.props.documents.getByUrl(
`/doc/${this.props.match.params.documentSlug}`
);
@@ -115,36 +135,38 @@ 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;
if (!document) return;
this.setState({ isLoading: true, isSaving: true });
this.isLoading = true;
this.isSaving = true;
document = await document.save();
this.setState({ isLoading: false });
this.isLoading = false;
if (redirect || this.props.newDocument) {
this.props.history.push(document.url);
} else {
this.showAsSaved();
this.toggleShowAsSaved();
}
};
showAsSaved() {
this.setState({ showAsSaved: true, isSaving: false });
this.savedTimeout = setTimeout(
() => this.setState({ showAsSaved: false }),
2000
);
toggleShowAsSaved() {
this.showAsSaved = true;
this.isSaving = false;
this.savedTimeout = setTimeout(() => (this.showAsSaved = false), 2000);
}
onImageUploadStart = () => {
this.setState({ isLoading: true });
this.isLoading = true;
};
onImageUploadStop = () => {
this.setState({ isLoading: false });
this.isLoading = false;
};
onChange = text => {
@@ -156,6 +178,7 @@ type Props = {
let url;
if (this.document && this.document.url) {
url = this.document.url;
if (this.editCache) this.document.updateData({ text: this.editCache });
} else {
url = collectionUrl(this.props.match.params.id);
}
@@ -163,11 +186,11 @@ type Props = {
};
onStartDragging = () => {
this.setState({ isDragging: true });
this.isDragging = true;
};
onStopDragging = () => {
this.setState({ isDragging: false });
this.isDragging = false;
};
renderNotFound() {
@@ -176,23 +199,26 @@ 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;
if (this.state.notFound) {
if (this.notFound) {
return this.renderNotFound();
}
return (
<Container column auto>
{this.state.isDragging &&
{isMoving && document && <DocumentMove document={document} />}
{this.isDragging &&
<DropHere align="center" justify="center">
Drop files here to import into Atlas.
</DropHere>}
{titleText && <PageTitle title={titleText} />}
{this.state.isLoading && <LoadingIndicator />}
{this.isLoading && <LoadingIndicator />}
{isFetching &&
<CenteredContent>
<LoadingState />
@@ -231,11 +257,11 @@ type Props = {
<HeaderAction>
{isEditing
? <SaveAction
isSaving={this.state.isSaving}
isSaving={this.isSaving}
onClick={this.onSave.bind(this, true)}
disabled={
!(this.document && this.document.allowSave) ||
this.state.isSaving
this.isSaving
}
isNew={!!isNew}
/>

View File

@@ -0,0 +1,153 @@
// @flow
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { observable, 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 styled from 'styled-components';
import { size } from 'styles/constants';
import Modal from 'components/Modal';
import Input from 'components/Input';
import Labeled from 'components/Labeled';
import Flex from 'components/Flex';
import PathToDocument from './components/PathToDocument';
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;
firstDocument: HTMLElement;
@observable isSaving: boolean;
@observable resultIds: Array<string> = []; // Document IDs
@observable searchTerm: ?string = null;
@observable isFetching = false;
componentDidMount() {
this.setDefaultResult();
}
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: SyntheticInputEvent) => {
const value = e.target.value;
this.searchTerm = value;
this.updateSearchResults();
};
updateSearchResults = _.debounce(() => {
this.search();
}, 250);
setFirstDocumentRef = ref => {
this.firstDocument = ref;
};
@action setDefaultResult() {
this.resultIds = this.props.document.collection.documents.map(
doc => doc.id
);
}
@action search = async () => {
this.isFetching = true;
if (this.searchTerm) {
try {
this.resultIds = await this.props.documents.search(this.searchTerm);
} catch (e) {
console.error('Something went wrong');
}
} else {
this.setDefaultResult();
}
this.isFetching = false;
};
render() {
const { document, documents } = this.props;
return (
<Modal isOpen onRequestClose={this.handleClose} title="Move document">
<Section>
<Labeled label="Current location">
<PathToDocument documentId={document.id} documents={documents} />
</Labeled>
</Section>
<Section column>
<Labeled label="Choose a new location">
<Input
type="text"
placeholder="Filter by document name"
onKeyDown={this.handleKeyDown}
onChange={this.handleFilter}
required
autoFocus
/>
</Labeled>
<Flex column>
<StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
<PathToDocument
document={document}
documents={documents}
ref={ref => this.setFirstDocumentRef(ref)}
onSuccess={this.handleClose}
/>
{this.resultIds.map((documentId, index) => (
<PathToDocument
key={documentId}
documentId={documentId}
documents={documents}
document={document}
onSuccess={this.handleClose}
/>
))}
</StyledArrowKeyNavigation>
</Flex>
</Section>
</Modal>
);
}
}
const Section = styled(Flex)`
margin-bottom: ${size.huge};
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
display: flex;
flex-direction: column;
flex: 1;
`;
export default withRouter(inject('documents')(DocumentMove));

View File

@@ -0,0 +1,99 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import _ from 'lodash';
import invariant from 'invariant';
import styled from 'styled-components';
import { color } from 'styles/constants';
import Flex from 'components/Flex';
import ChevronIcon from 'components/Icon/ChevronIcon';
import Document from 'models/Document';
import DocumentsStore from 'stores/DocumentsStore';
const ResultWrapper = styled.div`
display: flex;
margin-bottom: 10px;
color: ${color.text};
cursor: default;
`;
const ResultWrapperLink = ResultWrapper.withComponent('a').extend`
padding-top: 3px;
padding-left: 5px;
&:hover,
&:active,
&:focus {
margin-left: 0px;
border-radius: 2px;
background: ${color.black};
color: ${color.smokeLight};
outline: none;
cursor: pointer;
}
`;
type Props = {
documentId?: string,
onSuccess?: Function,
documents: DocumentsStore,
document?: Document,
ref?: Function,
selectable?: boolean,
};
@observer class PathToDocument extends React.Component {
props: Props;
get resultDocument(): ?Document {
const { documentId } = this.props;
if (documentId) return this.props.documents.getById(documentId);
}
handleSelect = async (event: SyntheticEvent) => {
const { document, onSuccess } = this.props;
invariant(onSuccess && document, 'onSuccess unavailable');
event.preventDefault();
await document.move(this.resultDocument ? this.resultDocument.id : null);
onSuccess();
};
render() {
const { document, onSuccess, ref } = this.props;
// $FlowIssue we'll always have a document
const { collection } = document || this.resultDocument;
const Component = onSuccess ? ResultWrapperLink : ResultWrapper;
return (
<Component
innerRef={ref}
selectable
href={!!onSuccess}
onClick={onSuccess && this.handleSelect}
>
{collection.name}
{this.resultDocument &&
<Flex>
{' '}
<ChevronIcon />
{' '}
{this.resultDocument.pathToDocument
.map(doc => <span key={doc.id}>{doc.title}</span>)
.reduce((prev, curr) => [prev, <ChevronIcon />, curr])}
</Flex>}
{document &&
<Flex>
{' '}
<ChevronIcon />
{' '}{document.title}
</Flex>}
</Component>
);
}
}
export default PathToDocument;

View File

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

View File

@@ -2,18 +2,20 @@
import React from 'react';
import ReactDOM from 'react-dom';
import keydown from 'react-keydown';
import { observer } from 'mobx-react';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import _ from 'lodash';
import Flex from 'components/Flex';
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 +23,7 @@ import PageTitle from 'components/PageTitle';
type Props = {
history: Object,
match: Object,
documents: DocumentsStore,
notFound: ?boolean,
};
@@ -53,11 +56,12 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
@observer class Search extends React.Component {
firstDocument: HTMLElement;
props: Props;
store: SearchStore;
constructor(props: Props) {
super(props);
this.store = new SearchStore();
@observable resultIds: Array<string> = []; // Document IDs
@observable searchTerm: ?string = null;
@observable isFetching = false;
componentDidMount() {
this.updateSearchResults();
}
@@ -91,9 +95,26 @@ 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 {
this.resultIds = await this.props.documents.search(query);
} catch (e) {
console.error('Something went wrong');
}
} else {
this.resultIds = [];
}
this.isFetching = false;
};
updateQuery = query => {
this.props.history.replace(searchUrl(query));
};
@@ -103,20 +124,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 (
<Container auto>
<PageTitle title={this.title} />
{this.store.isFetching && <LoadingIndicator />}
{this.isFetching && <LoadingIndicator />}
{this.props.notFound &&
<div>
<h1>Not Found</h1>
@@ -125,7 +147,7 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
</div>}
<ResultsWrapper pinToTop={hasResults} column auto>
<SearchField
searchTerm={this.store.searchTerm}
searchTerm={this.searchTerm}
onKeyDown={this.handleKeyDown}
onChange={this.updateQuery}
value={query || ''}
@@ -135,15 +157,20 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.store.documents.map((document, index) => (
<DocumentPreview
innerRef={ref => 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 (
<DocumentPreview
innerRef={ref =>
index === 0 && this.setFirstDocumentRef(ref)}
key={documentId}
document={document}
highlight={this.searchTerm}
showCollection
/>
);
})}
</StyledArrowKeyNavigation>
</ResultList>
</ResultsWrapper>
@@ -152,4 +179,4 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
}
}
export default withRouter(Search);
export default withRouter(inject('documents')(Search));

View File

@@ -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<Document> = [];
@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;