diff --git a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js index 1f7ebfbf7..53eeed8ec 100644 --- a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js +++ b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js @@ -1,9 +1,10 @@ // @flow import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import { observable, action } from 'mobx'; +import { observable, computed } from 'mobx'; import { observer, inject } from 'mobx-react'; import { withRouter } from 'react-router'; +import { Search } from 'js-search'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; import _ from 'lodash'; import styled from 'styled-components'; @@ -17,25 +18,83 @@ import PathToDocument from './components/PathToDocument'; import Document from 'models/Document'; import DocumentsStore from 'stores/DocumentsStore'; +import CollectionsStore, { type DocumentPath } from 'stores/CollectionsStore'; type Props = { match: Object, history: Object, document: Document, documents: DocumentsStore, + collections: CollectionsStore, }; @observer class DocumentMove extends Component { props: Props; firstDocument: HTMLElement; + @observable searchTerm: ?string; @observable isSaving: boolean; - @observable resultIds: Array = []; // Document IDs - @observable searchTerm: ?string = null; - @observable isFetching = false; - componentDidMount() { - this.setDefaultResult(); + @computed get searchIndex() { + const { document, 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); + }); + index.addDocuments(indexeableDocuments); + + return index; + } + + @computed get results(): DocumentPath[] { + const { document, collections } = this.props; + + let results = []; + if (collections.isLoaded) { + if (this.searchTerm) { + // Search by the keyword + results = this.searchIndex.search(this.searchTerm); + } 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); + } + }); + } + } + + 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); + } + + // Exclude document if on the path to result, or the same result + results = results.filter( + result => + !result.path.map(doc => doc.id).includes(document.id) && + !result.path.map(doc => doc.id).includes(document.parentDocumentId) + ); + + return results; } handleKeyDown = ev => { @@ -53,86 +112,65 @@ type Props = { this.props.history.push(this.props.document.url); }; - handleFilter = (ev: SyntheticInputEvent) => { - this.searchTerm = ev.target.value; - this.updateSearchResults(); + handleFilter = (e: SyntheticInputEvent) => { + this.searchTerm = e.target.value; }; - updateSearchResults = _.debounce(() => { - this.search(); - }, 250); - setFirstDocumentRef = ref => { this.firstDocument = ref; }; - @action setDefaultResult() { - this.resultIds = this.props.document.collection.documents.map( - doc => doc.id - ); + renderPathToCurrentDocument() { + const { collections, document } = this.props; + const result = collections.getPathForDocument(document.id); + if (result) { + return ; + } } - @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; + const { document, collections } = this.props; return ( -
- - - -
- -
- - - + {document && + collections.isLoaded && - - this.setFirstDocumentRef(ref)} - onSuccess={this.handleClose} - /> - {this.resultIds.map((documentId, index) => ( - + + {this.renderPathToCurrentDocument()} + +
+ +
+ + - ))} - - -
+ + + + {this.results.map((result, index) => ( + index === 0 && this.setFirstDocumentRef(ref)} + onSuccess={this.handleClose} + /> + ))} + + + + }
); } @@ -148,4 +186,4 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` flex: 1; `; -export default withRouter(inject('documents')(DocumentMove)); +export default withRouter(inject('documents', 'collections')(DocumentMove)); diff --git a/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js b/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js index 1c2ec5ff2..2e5bab449 100644 --- a/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js +++ b/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js @@ -1,8 +1,8 @@ // @flow import React from 'react'; import { observer } from 'mobx-react'; -import _ from 'lodash'; import invariant from 'invariant'; +import _ from 'lodash'; import styled from 'styled-components'; import { color } from 'styles/constants'; @@ -10,81 +10,82 @@ 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; + display: flex; + margin-bottom: 10px; -color: ${color.text}; -cursor: default; + color: ${color.text}; + cursor: default; +`; + +const StyledChevronIcon = styled(ChevronIcon)` + padding-top: 2px; + width: 24px; + height: 24px; `; const ResultWrapperLink = ResultWrapper.withComponent('a').extend` -padding-top: 3px; -padding-left: 5px; + height: 32px; + 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; -} + &:hover, + &:active, + &:focus { + margin-left: 0px; + border-radius: 2px; + background: ${color.black}; + color: ${color.smokeLight}; + outline: none; + cursor: pointer; + + ${StyledChevronIcon} svg { + fill: ${color.smokeLight}; + } + } `; type Props = { - documentId?: string, - onSuccess?: Function, - documents: DocumentsStore, + result: Object, document?: Document, + onSuccess?: Function, 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; + handleClick = async (ev: SyntheticEvent) => { + ev.preventDefault(); + const { document, result, onSuccess } = this.props; invariant(onSuccess && document, 'onSuccess unavailable'); - event.preventDefault(); - await document.move(this.resultDocument ? this.resultDocument.id : null); + + if (result.type === 'document') { + await document.move(result.id); + } else if ( + result.type === 'collection' && + result.id === document.collection.id + ) { + await document.move(null); + } else { + throw new Error('Not implemented yet'); + } 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; + const { result, document, ref } = this.props; + const Component = document ? ResultWrapperLink : ResultWrapper; + + if (!result) return
; return ( - - {collection.name} - {this.resultDocument && - - {' '} - - {' '} - {this.resultDocument.pathToDocument - .map(doc => {doc.title}) - .reduce((prev, curr) => [prev, , curr])} - } + + {result.path + .map(doc => {doc.title}) + .reduce((prev, curr) => [prev, , curr])} {document && {' '} diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index d90c0985a..7231560f7 100644 --- a/frontend/stores/CollectionsStore.js +++ b/frontend/stores/CollectionsStore.js @@ -5,7 +5,6 @@ import { action, runInAction, ObservableArray, - autorunAsync, } from 'mobx'; import ApiClient, { client } from 'utils/ApiClient'; import _ from 'lodash'; @@ -17,14 +16,22 @@ 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, }; +type DocumentPathItem = { + id: string, + title: string, + type: 'document' | 'collection', +}; + +export type DocumentPath = DocumentPathItem & { + path: Array, +}; + class CollectionsStore { @observable data: ObservableArray = observable.array([]); @observable isLoaded: boolean = false; @@ -41,6 +48,41 @@ class CollectionsStore { : undefined; } + /** + * List of paths to each of the documents, where paths are composed of id and title/name pairs + */ + @computed get pathsToDocuments(): Array { + let results = []; + const travelDocuments = (documentList, path) => + documentList.forEach(document => { + const { id, title } = document; + const node = { id, title, type: 'document' }; + results.push(_.concat(path, node)); + travelDocuments(document.children, _.concat(path, [node])); + }); + + if (this.isLoaded) { + this.data.forEach(collection => { + const { id, name } = collection; + const node = { id, title: name, type: 'collection' }; + results.push([node]); + travelDocuments(collection.documents, [node]); + }); + } + + return results.map(result => { + const tail = _.last(result); + return { + ...tail, + path: result, + }; + }); + } + + getPathForDocument(documentId: string): ?DocumentPath { + return this.pathsToDocuments.find(path => path.id === documentId); + } + /* Actions */ @action fetchAll = async (): Promise<*> => { @@ -99,21 +141,6 @@ class CollectionsStore { this.teamId = options.teamId; this.cache = options.cache; 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) - this.cache.setItem( - COLLECTION_CACHE_KEY, - this.data.map(collection => collection.data) - ); - }); } } diff --git a/package.json b/package.json index 5af1067db..11e75b1e5 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,11 @@ "main": "index.js", "scripts": { "clean": "rimraf dist", - "build:webpack": - "NODE_ENV=production webpack --config webpack.config.prod.js", - "build:analyze": - "NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer", + "build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js", + "build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json | webpack-bundle-size-analyzer", "build": "npm run clean && npm run build:webpack", "start": "node index.js", - "dev": - "NODE_ENV=development DEBUG=sql,cache,presenters ./node_modules/.bin/nodemon --inspect --watch server index.js", + "dev": "NODE_ENV=development DEBUG=sql,cache,presenters ./node_modules/.bin/nodemon --inspect --watch server index.js", "lint": "npm run lint:flow && npm run lint:js", "lint:js": "eslint frontend", "lint:flow": "flow", @@ -21,24 +18,39 @@ "sequelize:migrate": "sequelize db:migrate", "test": "npm run test:frontend && npm run test:server", "test:frontend": "jest", - "test:server": - "jest --config=server/.jestconfig.json --runInBand --forceExit", + "test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit", "precommit": "lint-staged" }, "lint-staged": { - "*.js": ["eslint --fix", "git add"] + "*.js": [ + "eslint --fix", + "git add" + ] }, "jest": { "verbose": false, - "roots": ["frontend"], + "roots": [ + "frontend" + ], "moduleNameMapper": { "^.*[.](s?css|css)$": "/__mocks__/styleMock.js", "^.*[.](gif|ttf|eot|svg)$": "/__test__/fileMock.js" }, - "moduleFileExtensions": ["js", "jsx", "json"], - "moduleDirectories": ["node_modules"], - "modulePaths": ["frontend"], - "setupFiles": ["/setupJest.js", "/__mocks__/window.js"] + "moduleFileExtensions": [ + "js", + "jsx", + "json" + ], + "moduleDirectories": [ + "node_modules" + ], + "modulePaths": [ + "frontend" + ], + "setupFiles": [ + "/setupJest.js", + "/__mocks__/window.js" + ] }, "engines": { "node": ">= 7.6" @@ -95,6 +107,7 @@ "imports-loader": "0.6.5", "invariant": "^2.2.2", "isomorphic-fetch": "2.2.1", + "js-search": "^1.4.2", "js-tree": "1.1.0", "json-loader": "0.5.4", "jsonwebtoken": "7.0.1", @@ -157,8 +170,7 @@ "string-hash": "^1.1.0", "style-loader": "^0.18.2", "styled-components": "^2.0.0", - "truncate-html": - "https://github.com/jorilallo/truncate-html/tarball/master", + "truncate-html": "https://github.com/jorilallo/truncate-html/tarball/master", "url-loader": "0.5.7", "uuid": "2.0.2", "validator": "5.2.0", diff --git a/server/api/documents.js b/server/api/documents.js index b617b29c5..9d9892550 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -264,7 +264,7 @@ router.post('documents.move', auth(), async ctx => { // Set parent document if (parentDocument) { const parent = await Document.findById(parentDocument); - if (parent.atlasId !== document.atlasId) + if (!parent || parent.atlasId !== document.atlasId) throw httpErrors.BadRequest( 'Invalid parentDocument (must be same collection)' ); diff --git a/yarn.lock b/yarn.lock index eaabe2248..8afd54995 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4858,6 +4858,10 @@ js-beautify@^1.6.11: mkdirp "~0.5.0" nopt "~3.0.1" +js-search@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.2.tgz#59a91e117d6badb20bf0d7643ba7577d5a81d7e2" + js-string-escape@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef"