diff --git a/frontend/scenes/Document/components/DocumentMove/DocumentMove.js b/frontend/scenes/Document/components/DocumentMove/DocumentMove.js index a6c39a65..7c60ea6b 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, action } 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,89 @@ import PathToDocument from './components/PathToDocument'; import Document from 'models/Document'; import DocumentsStore from 'stores/DocumentsStore'; +import CollectionsStore from 'stores/CollectionsStore'; type Props = { match: Object, history: Object, document: Document, documents: DocumentsStore, + collections: CollectionsStore, }; +type DocumentResult = { + id: string, + title: string, + type: 'document' | 'collection', +} + +type SearchResult = DocumentResult & { + path: Array, +} + @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 + paths.forEach(path => { + // TMP: For now, exclude paths to other collections + if (_.first(path).id !== document.collection.id) return; + + const tail = _.last(path); + index.addDocuments([{ + ...tail, + path: path, + }]); + }); + + return index; + } + + @computed get results(): Array { + const { document, collections } = this.props; + + let results = []; + if (collections.isLoaded) { + if (this.searchTerm) { + // Search by + results = this.searchIndex.search(this.searchTerm); + } else { + // Default results, root of the current collection + results = document.collection.documents.map( + doc => collections.getPathForDocument(doc.id) + ); + } + } + + if (document.parentDocumentId) { + // Add root if document does have a parent document + results = [ + collections.getPathForDocument(document.collection.id), + ...results, + ] + } else { + // Exclude root from search results if document is already at the root + 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 => { + return !result.path.map(doc => doc.id).includes(document.parentDocumentId); + }); + + return results; } handleKeyDown = ev => { @@ -53,101 +118,57 @@ 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 - ); - } - - @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; - let resultSet; - - resultSet = this.resultIds.filter(docId => { - const resultDoc = documents.getById(docId); - - if (document && resultDoc) { - return ( - // Exclude the document if it's on the path to a potential new path - !resultDoc.pathToDocument.map(doc => doc.id).includes(document.id) && - // Exclude if the same path, e.g the last one before the current - _.last(resultDoc.pathToDocument).id !== document.parentDocumentId - ); - } - return true; - }); - - // Prepend root if document does have a parent document - resultSet = document.parentDocumentId - ? _.concat(null, resultSet) - : this.resultIds; + const { document, documents, collections } = this.props; return ( -
- - - -
- -
- - - + {collections.isLoaded ? ( - - {resultSet.map((documentId, index) => ( - index === 0 && this.setFirstDocumentRef(ref)} - onSuccess={this.handleClose} +
+ + + +
+ +
+ + - ))} - + + + + {this.results.map((result, index) => ( + index === 0 && this.setFirstDocumentRef(ref)} + onClick={ () => 'move here' } + /> + ))} + + +
-
+ ) :
loading
}
); } @@ -163,4 +184,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 12cfdaa5..69b717f2 100644 --- a/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js +++ b/frontend/scenes/Document/components/DocumentMove/components/PathToDocument.js @@ -9,9 +9,6 @@ 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; @@ -48,59 +45,36 @@ const ResultWrapperLink = ResultWrapper.withComponent('a').extend` `; type Props = { - documentId?: string, - onSuccess?: Function, - documents: DocumentsStore, - document?: Document, + result: Object, + document: Document, + onClick?: 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; - - invariant(onSuccess && document, 'onSuccess unavailable'); - event.preventDefault(); - await document.move(this.resultDocument ? this.resultDocument.id : null); - onSuccess(); - }; - render() { - const { document, documentId, onSuccess, ref } = this.props; + const { result, document, onClick, ref } = this.props; // $FlowIssue we'll always have a document - const { collection } = documentId ? this.resultDocument : document; - const Component = onSuccess ? ResultWrapperLink : ResultWrapper; + const Component = onClick ? ResultWrapperLink : ResultWrapper; + + if (!result) return
; - // Exclude document when it's part of the path and not the preview 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 && {' '} - + {' '}{document.title} } diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index d1db0f31..8540e29a 100644 --- a/frontend/stores/CollectionsStore.js +++ b/frontend/stores/CollectionsStore.js @@ -46,7 +46,7 @@ class CollectionsStore { const travelDocuments = (documentList, path) => documentList.forEach(document => { const { id, title } = document; - const node = { id, title }; + const node = { id, title, type: 'document' }; results.push(_.concat(path, node)); travelDocuments(document.children, _.concat(path, [node])); }); @@ -54,7 +54,8 @@ class CollectionsStore { if (this.isLoaded) { this.data.forEach(collection => { const { id, name } = collection; - const node = { id, title: name }; + const node = { id, title: name, type: 'collection' }; + results.push([node]); travelDocuments(collection.documents, [node]); }); } @@ -62,6 +63,16 @@ class CollectionsStore { return results; } + getPathForDocument(documentId: string): Object { + const result = this.pathsToDocuments.find(path => _.last(path).id === documentId); + + const tail = _.last(result); + return { + ...tail, + path: result, + } + } + /* Actions */ @action fetchAll = async (): Promise<*> => { diff --git a/package.json b/package.json index 5af1067d..11e75b1e 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/yarn.lock b/yarn.lock index eaabe224..8afd5499 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"