diff --git a/.babelrc b/.babelrc index cfcb01298..027c9bee3 100644 --- a/.babelrc +++ b/.babelrc @@ -1,10 +1,8 @@ { - "presets": [ - "react", - "env" - ], + "presets": ["react", "env"], "plugins": [ "lodash", + "styled-components", "transform-decorators-legacy", "transform-es2015-destructuring", "transform-object-rest-spread", @@ -13,9 +11,7 @@ ], "env": { "development": { - "presets": [ - "react-hmre" - ] + "presets": ["react-hmre"] } } -} \ No newline at end of file +} diff --git a/frontend/components/DropdownMenu/DropdownMenu.js b/frontend/components/DropdownMenu/DropdownMenu.js index 1b6e5c9f1..36c560041 100644 --- a/frontend/components/DropdownMenu/DropdownMenu.js +++ b/frontend/components/DropdownMenu/DropdownMenu.js @@ -1,66 +1,111 @@ // @flow import React from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import Flex from 'components/Flex'; +import { color } from 'styles/constants'; -import styles from './DropdownMenu.scss'; - -const MenuItem = ({ - onClick, - children, -}: { +type MenuItemProps = { onClick?: Function, children?: React.Element, -}) => { +}; + +const DropdownMenuItem = ({ onClick, children }: MenuItemProps) => { return ( -
+ {children} -
+ ); }; -// +type DropdownMenuProps = { + label: React.Element, + children?: React.Element, +}; -class DropdownMenu extends React.Component { - static propTypes = { - label: React.PropTypes.node.isRequired, - children: React.PropTypes.node.isRequired, - }; +@observer class DropdownMenu extends React.Component { + props: DropdownMenuProps; + @observable menuOpen: boolean = false; - state = { - menuVisible: false, - }; - - onMouseEnter = () => { - this.setState({ menuVisible: true }); - }; - - onMouseLeave = () => { - this.setState({ menuVisible: false }); - }; - - onClick = () => { - this.setState({ menuVisible: !this.state.menuVisible }); + handleClick = () => { + this.menuOpen = !this.menuOpen; }; render() { return ( -
-
- {this.props.label} -
+ + {this.menuOpen && } - {this.state.menuVisible - ?
- {this.props.children} -
- : null} -
+ + + {this.menuOpen && + + {this.props.children} + } + ); } } -export default DropdownMenu; -export { MenuItem }; +const Backdrop = styled.div` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 999; +`; + +const Label = styled(Flex).attrs({ + justify: 'center', + align: 'center', +})` + cursor: pointer; + z-index: 1000; + + min-height: 43px; + margin: 0 5px; +`; + +const MenuContainer = styled.div` + position: relative; +`; + +const Menu = styled.div` + position: absolute; + right: 0; + z-index: 1000; + border: 1px solid #eee; + background-color: #fff; + min-width: 160px; +`; + +const MenuItem = styled.div` + margin: 0; + padding: 5px 10px; + height: 32px; + + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + border-left: 2px solid transparent; + + span { + margin-top: 2px; + } + + a { + text-decoration: none; + width: 100%; + } + + &:hover { + border-left: 2px solid ${color.primary}; + } +`; + +export { DropdownMenu, DropdownMenuItem }; diff --git a/frontend/components/DropdownMenu/DropdownMenu.scss b/frontend/components/DropdownMenu/DropdownMenu.scss deleted file mode 100644 index b0c9e42d8..000000000 --- a/frontend/components/DropdownMenu/DropdownMenu.scss +++ /dev/null @@ -1,52 +0,0 @@ -@import '~styles/constants.scss'; - -.label { - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - - min-height: 43px; - margin: 0 5px; - color: $actionColor; -} - -.menuContainer { - position: relative; - - .menu { - position: absolute; - top: $headerHeight; - right: 0; - z-index: 1000; - border: 1px solid #eee; - background-color: #fff; - min-width: 160px; - } -} - -.menuItem { - margin: 0; - padding: 5px 10px; - height: 32px; - - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - border-left: 2px solid transparent; - - span { - margin-top: 2px; - } - - a { - color: $textColor; - text-decoration: none; - width: 100%; - } - - &:hover { - border-left: 2px solid $actionColor; - } -} diff --git a/frontend/components/DropdownMenu/index.js b/frontend/components/DropdownMenu/index.js index 8197d76c6..fd1562125 100644 --- a/frontend/components/DropdownMenu/index.js +++ b/frontend/components/DropdownMenu/index.js @@ -1,5 +1,4 @@ // @flow -import DropdownMenu, { MenuItem } from './DropdownMenu'; +import { DropdownMenu, DropdownMenuItem } from './DropdownMenu'; import MoreIcon from './components/MoreIcon'; -export default DropdownMenu; -export { MenuItem, MoreIcon }; +export { DropdownMenu, DropdownMenuItem, MoreIcon }; diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 0ba3055f7..ad73209e1 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -89,11 +89,12 @@ type KeyData = { case 's': ev.preventDefault(); ev.stopPropagation(); - return this.props.onSave({ redirect: false }); + this.props.onSave(); + return state; case 'enter': ev.preventDefault(); ev.stopPropagation(); - this.props.onSave(); + this.props.onSave({ redirect: false }); return state; case 'escape': return this.props.onCancel(); diff --git a/frontend/components/Icon/CheckIcon.js b/frontend/components/Icon/CheckIcon.js new file mode 100644 index 000000000..ab6ea35e3 --- /dev/null +++ b/frontend/components/Icon/CheckIcon.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function CheckIcon(props: Props) { + return ( + + + + + + + ); +} diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 0b6c00286..919891943 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -9,7 +9,7 @@ import keydown from 'react-keydown'; import Flex from 'components/Flex'; import { color, layout } from 'styles/constants'; -import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; import { LoadingIndicatorBar } from 'components/LoadingIndicator'; import Scrollable from 'components/Scrollable'; import KeyboardShortcuts from 'components/KeyboardShortcuts'; @@ -107,15 +107,17 @@ type Props = { }> - Settings + Settings Keyboard shortcuts - API + API - Logout + + Logout + diff --git a/frontend/components/Layout/components/SaveAction/SaveAction.js b/frontend/components/Layout/components/SaveAction/SaveAction.js index b37cce08a..ff307acb9 100644 --- a/frontend/components/Layout/components/SaveAction/SaveAction.js +++ b/frontend/components/Layout/components/SaveAction/SaveAction.js @@ -1,14 +1,17 @@ // @flow import React from 'react'; -import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import CheckIcon from 'components/Icon/CheckIcon'; +import { fadeAndScaleIn } from 'styles/animations'; type Props = { onClick: Function, + showCheckmark: boolean, disabled?: boolean, isNew?: boolean, }; -@observer class SaveAction extends React.Component { +class SaveAction extends React.Component { props: Props; onClick = (event: MouseEvent) => { @@ -19,21 +22,38 @@ type Props = { }; render() { - const { disabled, isNew } = this.props; + const { showCheckmark, disabled, isNew } = this.props; return ( -
- - {isNew ? 'Publish' : 'Save'} - -
+ + {showCheckmark && } + {isNew ? 'Publish' : 'Save'} + ); } } +const Link = styled.a` + display: flex; + align-items: center; +`; + +const SavedIcon = styled(CheckIcon)` + animation: ${fadeAndScaleIn} 250ms ease; + display: inline-block; + margin-right: 4px; + width: 18px; + height: 18px; + + svg { + width: 18px; + height: 18px; + } +`; + export default SaveAction; diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js index a799a43d0..66110f0ec 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -2,7 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import ReactModal from 'react-modal'; -import { modalFadeIn } from 'styles/animations'; +import { fadeAndScaleIn } from 'styles/animations'; import CloseIcon from 'components/Icon/CloseIcon'; import Flex from 'components/Flex'; @@ -46,7 +46,7 @@ const Content = styled(Flex)` `; const StyledModal = styled(ReactModal)` - animation: ${modalFadeIn} 250ms ease; + animation: ${fadeAndScaleIn} 250ms ease; position: absolute; top: 0; diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index c82315c5b..7f6d95107 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -37,6 +37,7 @@ type Props = { @observer class DocumentScene extends Component { props: Props; + savedTimeout: number; state: { newDocument?: Document, }; @@ -44,6 +45,7 @@ type Props = { isDragging: false, isLoading: false, newDocument: undefined, + showAsSaved: false, }; componentDidMount() { @@ -60,6 +62,7 @@ type Props = { } componentWillUnmount() { + clearTimeout(this.savedTimeout); this.props.ui.clearActiveDocument(); } @@ -108,9 +111,19 @@ type Props = { if (redirect || this.props.newDocument) { this.props.history.push(document.url); + } else { + this.showAsSaved(); } }; + showAsSaved() { + this.setState({ showAsSaved: true }); + this.savedTimeout = setTimeout( + () => this.setState({ showAsSaved: false }), + 2000 + ); + } + onImageUploadStart() { this.setState({ isLoading: true }); } @@ -204,6 +217,7 @@ type Props = { {isEditing ? }> {collection &&
- + New document - +
} - Export - {allowDelete && Delete} + Export + {allowDelete && + Delete} ); } diff --git a/frontend/scenes/Search/Search.js b/frontend/scenes/Search/Search.js index 6a9f184c9..59e8a3f90 100644 --- a/frontend/scenes/Search/Search.js +++ b/frontend/scenes/Search/Search.js @@ -9,10 +9,10 @@ import { searchUrl } from 'utils/routeHelpers'; import styled from 'styled-components'; import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; +import CenteredContent from 'components/CenteredContent'; import SearchField from './components/SearchField'; import SearchStore from './SearchStore'; -import CenteredContent from 'components/CenteredContent'; import DocumentPreview from 'components/DocumentPreview'; import PageTitle from 'components/PageTitle'; @@ -23,7 +23,9 @@ type Props = { }; const Container = styled(CenteredContent)` - position: relative; + > div { + position: relative; + } `; const ResultsWrapper = styled(Flex)` @@ -39,6 +41,12 @@ const ResultList = styled(Flex)` transition: all 400ms cubic-bezier(0.65, 0.05, 0.36, 1); `; +const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)` + display: flex; + flex-direction: column; + flex: 1; +`; + @observer class Search extends React.Component { firstDocument: HTMLElement; props: Props; @@ -106,7 +114,7 @@ const ResultList = styled(Flex)` value={query || ''} /> - @@ -118,7 +126,7 @@ const ResultList = styled(Flex)` highlight={this.store.searchTerm} /> ))} - + diff --git a/frontend/styles/animations.js b/frontend/styles/animations.js index a042aaf72..bdd611fa8 100644 --- a/frontend/styles/animations.js +++ b/frontend/styles/animations.js @@ -1,7 +1,7 @@ // @flow import { keyframes } from 'styled-components'; -export const modalFadeIn = keyframes` +export const fadeAndScaleIn = keyframes` from { opacity: 0; transform: scale(.98); diff --git a/package.json b/package.json index 777f0e681..1cc41cb33 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "babel-eslint": "^7.2.3", "babel-loader": "6.2.5", "babel-plugin-lodash": "^3.2.11", + "babel-plugin-styled-components": "^1.1.7", "babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-decorators-legacy": "1.3.4", "babel-plugin-transform-es2015-destructuring": "^6.23.0", diff --git a/server/api/__snapshots__/documents.test.js.snap b/server/api/__snapshots__/documents.test.js.snap index 9bbde3ba4..3400de1c4 100644 --- a/server/api/__snapshots__/documents.test.js.snap +++ b/server/api/__snapshots__/documents.test.js.snap @@ -9,6 +9,15 @@ Object { } `; +exports[`#documents.search should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#documents.star should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/api/documents.test.js b/server/api/documents.test.js index ec477e6cb..54f1728fd 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -41,6 +41,28 @@ describe('#documents.list', async () => { }); }); +describe('#documents.search', async () => { + it('should return results', async () => { + const { user } = await seed(); + const res = await server.post('/api/documents.search', { + body: { token: user.getJwtToken(), query: 'much' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].text).toEqual('# Much guidance'); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.search'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + describe('#documents.viewed', async () => { it('should return empty result if no views', async () => { const { user } = await seed(); diff --git a/server/models/Document.js b/server/models/Document.js index 104275d70..d49177565 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -156,7 +156,7 @@ const Document = sequelize.define( }); } }, - searchForUser: (user, query, options = {}) => { + searchForUser: async (user, query, options = {}) => { const limit = options.limit || 15; const offset = options.offset || 0; @@ -169,13 +169,18 @@ const Document = sequelize.define( LIMIT :limit OFFSET :offset; `; - return sequelize.query(sql, { - replacements: { - query, - limit, - offset, - }, - model: Document, + const ids = await sequelize + .query(sql, { + replacements: { + query, + limit, + offset, + }, + model: Document, + }) + .map(document => document.id); + return Document.findAll({ + where: { id: ids }, }); }, }, diff --git a/yarn.lock b/yarn.lock index a3d68f89c..b58e8cbc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -534,6 +534,12 @@ babel-plugin-react-transform@^2.0.2: dependencies: lodash "^4.6.1" +babel-plugin-styled-components@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/babel-plugin-styled-components/-/babel-plugin-styled-components-1.1.7.tgz#a92c239779cc80e7838b645c12865c61c4ca71ce" + dependencies: + stylis "^3.2.1" + babel-plugin-syntax-async-functions@^6.8.0: version "6.13.0" resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" @@ -8259,6 +8265,10 @@ stylis@^2.0.0: version "2.0.12" resolved "https://registry.yarnpkg.com/stylis/-/stylis-2.0.12.tgz#547253055d170f2a7ac2f6d09365d70635f2bec6" +stylis@^3.2.1: + version "3.2.3" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.2.3.tgz#fed751d792af3f48a247769f55aca05c1a100a09" + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -8913,7 +8923,7 @@ whatwg-encoding@^1.0.1: dependencies: iconv-lite "0.4.13" -whatwg-fetch@1.0.0, whatwg-fetch@>=0.10.0: +whatwg-fetch@>=0.10.0: version "1.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e"