Move document improvements (#927)
* Show all collections in UI * Introduce command pattern * Actually remove from previous collection * Stash * Fixes: Promises resolved outside of response lifecycle * 💚 * 💚 * documentMover tests * Transaction * Perf. More in transactions
This commit is contained in:
@@ -2,16 +2,18 @@
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import { GoToIcon } from 'outline-icons';
|
||||
import { GoToIcon, CollectionIcon, PrivateCollectionIcon } from 'outline-icons';
|
||||
import Flex from 'shared/components/Flex';
|
||||
|
||||
import Document from 'models/Document';
|
||||
import Collection from 'models/Collection';
|
||||
import type { DocumentPath } from 'stores/CollectionsStore';
|
||||
|
||||
type Props = {
|
||||
result: DocumentPath,
|
||||
document?: Document,
|
||||
onSuccess?: *,
|
||||
document?: ?Document,
|
||||
collection: ?Collection,
|
||||
onSuccess?: () => void,
|
||||
ref?: *,
|
||||
};
|
||||
|
||||
@@ -23,27 +25,28 @@ class PathToDocument extends React.Component<Props> {
|
||||
if (!document) return;
|
||||
|
||||
if (result.type === 'document') {
|
||||
await document.move(result.id);
|
||||
} else if (
|
||||
result.type === 'collection' &&
|
||||
result.id === document.collection.id
|
||||
) {
|
||||
await document.move(null);
|
||||
await document.move(result.collectionId, result.id);
|
||||
} else {
|
||||
throw new Error('Not implemented yet');
|
||||
await document.move(result.collectionId, null);
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { result, document, ref } = this.props;
|
||||
const { result, collection, document, ref } = this.props;
|
||||
const Component = document ? ResultWrapperLink : ResultWrapper;
|
||||
|
||||
if (!result) return <div />;
|
||||
|
||||
return (
|
||||
<Component ref={ref} onClick={this.handleClick} href="" selectable>
|
||||
{collection &&
|
||||
(collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
) : (
|
||||
<CollectionIcon color={collection.color} />
|
||||
))}
|
||||
{result.path
|
||||
.map(doc => <Title key={doc.id}>{doc.title}</Title>)
|
||||
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
|
||||
@@ -64,7 +67,9 @@ const Title = styled.span`
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)``;
|
||||
const StyledGoToIcon = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
display: flex;
|
||||
|
||||
@@ -24,6 +24,11 @@ export default class Collection extends BaseModel {
|
||||
updatedAt: ?string;
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
get isPrivate(): boolean {
|
||||
return this.private;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
return this.documents.length === 0;
|
||||
|
||||
@@ -234,8 +234,8 @@ export default class Document extends BaseModel {
|
||||
}
|
||||
};
|
||||
|
||||
move = (parentDocumentId: ?string) => {
|
||||
return this.store.move(this, parentDocumentId);
|
||||
move = (collectionId: string, parentDocumentId: ?string) => {
|
||||
return this.store.move(this, collectionId, parentDocumentId);
|
||||
};
|
||||
|
||||
duplicate = () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReactDOM from 'react-dom';
|
||||
import { observable, computed } from 'mobx';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { Search } from 'js-search';
|
||||
import { first, last } from 'lodash';
|
||||
import { last } from 'lodash';
|
||||
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -33,19 +33,14 @@ class DocumentMove extends React.Component<Props> {
|
||||
|
||||
@computed
|
||||
get searchIndex() {
|
||||
const { document, collections } = this.props;
|
||||
const { collections } = this.props;
|
||||
const paths = collections.pathsToDocuments;
|
||||
const index = new Search('id');
|
||||
index.addIndex('title');
|
||||
|
||||
// Build index
|
||||
const indexeableDocuments = [];
|
||||
paths.forEach(path => {
|
||||
// TMP: For now, exclude paths to other collections
|
||||
if (first(path.path).id !== document.collection.id) return;
|
||||
|
||||
indexeableDocuments.push(path);
|
||||
});
|
||||
paths.forEach(path => indexeableDocuments.push(path));
|
||||
index.addDocuments(indexeableDocuments);
|
||||
|
||||
return index;
|
||||
@@ -63,23 +58,22 @@ class DocumentMove extends React.Component<Props> {
|
||||
} else {
|
||||
// Default results, root of the current collection
|
||||
results = [];
|
||||
document.collection.documents.forEach(doc => {
|
||||
const path = collections.getPathForDocument(doc.id);
|
||||
if (doc && path) {
|
||||
results.push(path);
|
||||
collections.orderedData.forEach(collection => {
|
||||
collection.documents.forEach(doc => {
|
||||
const path = collections.getPathForDocument(doc.id);
|
||||
if (doc && path) {
|
||||
results.push(path);
|
||||
}
|
||||
});
|
||||
|
||||
const rootPath = collections.getPathForDocument(collection.id);
|
||||
if (rootPath) {
|
||||
results = [rootPath, ...results];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document && document.parentDocumentId) {
|
||||
// Add root if document does have a parent document
|
||||
const rootPath = collections.getPathForDocument(document.collection.id);
|
||||
if (rootPath) {
|
||||
results = [rootPath, ...results];
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter(result => result.id !== document.collection.id);
|
||||
@@ -119,7 +113,12 @@ class DocumentMove extends React.Component<Props> {
|
||||
const result = collections.getPathForDocument(document.id);
|
||||
|
||||
if (result) {
|
||||
return <PathToDocument result={result} />;
|
||||
return (
|
||||
<PathToDocument
|
||||
result={result}
|
||||
collection={collections.get(result.collectionId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +140,7 @@ class DocumentMove extends React.Component<Props> {
|
||||
<Labeled label="Choose a new location">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter by document name…"
|
||||
placeholder="Filter…"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
@@ -158,6 +157,7 @@ class DocumentMove extends React.Component<Props> {
|
||||
key={result.id}
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
ref={ref =>
|
||||
index === 0 && this.setFirstDocumentRef(ref)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ export default class BaseStore<T: BaseModel> {
|
||||
return this.create(params);
|
||||
}
|
||||
|
||||
get(id: string): ?T {
|
||||
return this.data.get(id);
|
||||
}
|
||||
|
||||
@action
|
||||
async create(params: Object) {
|
||||
if (!this.actions.includes('create')) {
|
||||
|
||||
@@ -10,9 +10,10 @@ import naturalSort from 'shared/utils/naturalSort';
|
||||
|
||||
export type DocumentPathItem = {
|
||||
id: string,
|
||||
collectionId: string,
|
||||
title: string,
|
||||
url: string,
|
||||
type: 'document' | 'collection',
|
||||
type: 'collection' | 'document',
|
||||
};
|
||||
|
||||
export type DocumentPath = DocumentPathItem & {
|
||||
@@ -52,20 +53,26 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
@computed
|
||||
get pathsToDocuments(): DocumentPath[] {
|
||||
let results = [];
|
||||
const travelDocuments = (documentList, path) =>
|
||||
const travelDocuments = (documentList, collectionId, path) =>
|
||||
documentList.forEach(document => {
|
||||
const { id, title, url } = document;
|
||||
const node = { id, title, url, type: 'document' };
|
||||
const node = { id, collectionId, title, url, type: 'document' };
|
||||
results.push(concat(path, node));
|
||||
travelDocuments(document.children, concat(path, [node]));
|
||||
travelDocuments(document.children, collectionId, concat(path, [node]));
|
||||
});
|
||||
|
||||
if (this.isLoaded) {
|
||||
this.data.forEach(collection => {
|
||||
const { id, name, url } = collection;
|
||||
const node = { id, title: name, url, type: 'collection' };
|
||||
const node = {
|
||||
id,
|
||||
collectionId: id,
|
||||
title: name,
|
||||
url,
|
||||
type: 'collection',
|
||||
};
|
||||
results.push([node]);
|
||||
travelDocuments(collection.documents, [node]);
|
||||
travelDocuments(collection.documents, id, [node]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -294,17 +294,20 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
move = async (document: Document, parentDocumentId: ?string) => {
|
||||
move = async (
|
||||
document: Document,
|
||||
collectionId: string,
|
||||
parentDocumentId: ?string
|
||||
) => {
|
||||
const res = await client.post('/documents.move', {
|
||||
id: document.id,
|
||||
parentDocument: parentDocumentId,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
});
|
||||
invariant(res && res.data, 'Data not available');
|
||||
|
||||
const collection = this.getCollectionForDocument(document);
|
||||
if (collection) collection.refresh();
|
||||
|
||||
return this.add(res.data);
|
||||
res.data.documents.forEach(this.add);
|
||||
res.data.collections.forEach(this.rootStore.collections.add);
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
Reference in New Issue
Block a user