diff --git a/frontend/components/Button/Button.js b/frontend/components/Button/Button.js index 31b826dd0..988d67052 100644 --- a/frontend/components/Button/Button.js +++ b/frontend/components/Button/Button.js @@ -25,13 +25,17 @@ const RealButton = styled.button` &:hover { background: ${darken(0.05, color.primary)}; } + &:disabled { + background: ${color.slateLight}; + } `; const Label = styled.span` - padding: 2px 12px; + padding: 4px 16px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + font-weight: 500; `; const Inner = styled.span` diff --git a/frontend/components/HelpText/HelpText.js b/frontend/components/HelpText/HelpText.js new file mode 100644 index 000000000..ac4b96673 --- /dev/null +++ b/frontend/components/HelpText/HelpText.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; +import { color } from 'styles/constants'; + +const HelpText = styled.p` + user-select: none; + color: ${color.slateDark}; +`; + +export default HelpText; diff --git a/frontend/components/HelpText/index.js b/frontend/components/HelpText/index.js new file mode 100644 index 000000000..968c478b8 --- /dev/null +++ b/frontend/components/HelpText/index.js @@ -0,0 +1,3 @@ +// @flow +import HelpText from './HelpText'; +export default HelpText; diff --git a/frontend/components/Icon/AddIcon.js b/frontend/components/Icon/AddIcon.js new file mode 100644 index 000000000..688202cfc --- /dev/null +++ b/frontend/components/Icon/AddIcon.js @@ -0,0 +1,21 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function AddIcon(props: Props) { + return ( + + + + + + + ); +} diff --git a/frontend/components/Input/Input.js b/frontend/components/Input/Input.js index 9ac8641e6..b8420ed1a 100644 --- a/frontend/components/Input/Input.js +++ b/frontend/components/Input/Input.js @@ -2,13 +2,18 @@ import React from 'react'; import styled from 'styled-components'; import Flex from 'components/Flex'; -import { size } from 'styles/constants'; +import { size, color } from 'styles/constants'; const RealTextarea = styled.textarea` border: 0; flex: 1; padding: 8px 12px; outline: none; + background: none; + + &::placeholder { + color: ${color.slate}; + } `; const RealInput = styled.input` @@ -16,36 +21,56 @@ const RealInput = styled.input` flex: 1; padding: 8px 12px; outline: none; + background: none; + + &::placeholder { + color: ${color.slateLight}; + } `; -const Wrapper = styled(Flex)` +const Wrapper = styled.div` + +`; + +const Outline = styled(Flex)` display: flex; flex: 1; margin: 0 0 ${size.large}; color: inherit; - border-width: 2px; + border-width: 1px; border-style: solid; - border-color: ${props => (props.hasError ? 'red' : 'rgba(0, 0, 0, .15)')}; - border-radius: ${size.small}; + border-color: ${props => (props.hasError ? 'red' : color.slateLight)}; + border-radius: 4px; + font-weight: normal; - &:focus, - &:active { - border-color: rgba(0, 0, 0, .25); + &:focus { + border-color: ${color.slate} } `; +const LabelText = styled.div` + font-weight: 500; + padding-bottom: 4px; +`; + export type Props = { type: string, value: string, + label?: string, className?: string, }; -export default function Input({ type, ...rest }: Props) { - const Component = type === 'textarea' ? RealTextarea : RealInput; +export default function Input({ type, label, ...rest }: Props) { + const InputComponent = type === 'textarea' ? RealTextarea : RealInput; return ( - + ); } diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index c022c6301..aead835ca 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -13,6 +13,9 @@ import DropdownMenu, { MenuItem } from 'components/DropdownMenu'; import { LoadingIndicatorBar } from 'components/LoadingIndicator'; import Scrollable from 'components/Scrollable'; import Avatar from 'components/Avatar'; +import Modal from 'components/Modal'; +import AddIcon from 'components/Icon/AddIcon'; +import CollectionNew from 'scenes/CollectionNew'; import SidebarCollection from './components/SidebarCollection'; import SidebarCollectionList from './components/SidebarCollectionList'; @@ -38,6 +41,8 @@ type Props = { @observer class Layout extends React.Component { props: Props; + state: { createCollectionModalOpen: boolean }; + state = { createCollectionModalOpen: false }; static defaultProps = { search: true, @@ -59,8 +64,16 @@ type Props = { this.props.auth.logout(() => this.props.history.push('/')); }; + handleCreateCollection = () => { + this.setState({ createCollectionModalOpen: true }); + }; + + handleCloseModal = () => { + this.setState({ createCollectionModalOpen: false }); + }; + render() { - const { user, auth, ui } = this.props; + const { user, auth, collections, history, ui } = this.props; return ( @@ -112,6 +125,9 @@ type Props = { Starred + + + {ui.activeCollection ? + + + ); } } +const CreateCollection = styled.a` + position: absolute; + top: 8px; + right: ${layout.hpadding}; + + svg { + opacity: .35; + width: 16px; + height: 16px; + } + + &:hover { + svg { + opacity: 1; + } + } +`; + const Container = styled(Flex)` position: relative; width: 100%; @@ -179,6 +224,7 @@ const Header = styled(Flex)` const LinkSection = styled(Flex)` flex-direction: column; padding: 10px 0; + position: relative; `; export default withRouter(inject('user', 'auth', 'ui', 'collections')(Layout)); diff --git a/frontend/components/LoadingIndicator/style.scss b/frontend/components/LoadingIndicator/style.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js new file mode 100644 index 000000000..ee988528a --- /dev/null +++ b/frontend/components/Modal/Modal.js @@ -0,0 +1,76 @@ +// @flow +import React from 'react'; +import styled from 'styled-components'; +import ReactModal from 'react-modal'; +import { modalFadeIn } from 'styles/animations'; + +import CloseIcon from 'components/Icon/CloseIcon'; +import Flex from 'components/Flex'; + +type Props = { + children?: React$Element, + isOpen: boolean, + title?: string, + onRequestClose: () => void, +}; + +const Modal = ({ + children, + isOpen, + title = 'Untitled Modal', + onRequestClose, + ...rest +}: Props) => { + if (!isOpen) return null; + + return ( + + + {title &&

{title}

} + + {children} +
+
+ ); +}; + +const Content = styled(Flex)` + width: 640px; + max-width: 100%; + position: relative; +`; + +const StyledModal = styled(ReactModal)` + animation: ${modalFadeIn} 250ms ease; + + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; + justify-content: center; + align-items: flex-start; + overflow-x: hidden; + overflow-y: auto; + background: white; + padding: 15vh 2rem 2rem +`; + +const Close = styled.a` + position: fixed; + top: 3rem; + right: 3rem; + opacity: .5; + + &:hover { + opacity: 1; + } +`; + +export default Modal; diff --git a/frontend/components/Modal/index.js b/frontend/components/Modal/index.js new file mode 100644 index 000000000..907593356 --- /dev/null +++ b/frontend/components/Modal/index.js @@ -0,0 +1,3 @@ +// @flow +import Modal from './Modal'; +export default Modal; diff --git a/frontend/models/Collection.js b/frontend/models/Collection.js index adc663c4d..3215b2253 100644 --- a/frontend/models/Collection.js +++ b/frontend/models/Collection.js @@ -3,12 +3,16 @@ import { extendObservable, action, computed, runInAction } from 'mobx'; import invariant from 'invariant'; import _ from 'lodash'; -import ApiClient, { client } from 'utils/ApiClient'; +import { client } from 'utils/ApiClient'; import stores from 'stores'; import ErrorsStore from 'stores/ErrorsStore'; import type { NavigationNode } from 'types'; class Collection { + isSaving: boolean = false; + hasPendingChanges: boolean = false; + errors: ErrorsStore; + createdAt: string; description: ?string; id: string; @@ -18,9 +22,6 @@ class Collection { updatedAt: string; url: string; - client: ApiClient; - errors: ErrorsStore; - /* Computed */ @computed get entryUrl(): string { @@ -29,26 +30,60 @@ class Collection { /* Actions */ - @action update = async () => { + @action fetch = async () => { try { - const res = await this.client.post('/collections.info', { id: this.id }); + const res = await client.post('/collections.info', { id: this.id }); invariant(res && res.data, 'API response should be available'); const { data } = res; - runInAction('Collection#update', () => { + runInAction('Collection#fetch', () => { this.updateData(data); }); } catch (e) { this.errors.add('Collection failed loading'); } + + return this; }; - updateData(data: Collection) { + @action save = async () => { + if (this.isSaving) return this; + this.isSaving = true; + + try { + let res; + if (this.id) { + res = await client.post('/collections.update', { + id: this.id, + name: this.name, + description: this.description, + }); + } else { + res = await client.post('/collections.create', { + name: this.name, + description: this.description, + }); + } + invariant(res && res.data, 'Data should be available'); + this.updateData({ + ...res.data, + hasPendingChanges: false, + }); + } catch (e) { + this.errors.add('Collection failed saving'); + return false; + } finally { + this.isSaving = false; + } + + return true; + }; + + updateData(data: Object = {}) { extendObservable(this, data); } - constructor(collection: Collection) { + constructor(collection: Object = {}) { this.updateData(collection); - this.client = client; this.errors = stores.errors; } } diff --git a/frontend/models/Collection.test.js b/frontend/models/Collection.test.js index a9a1857d6..ca7868243 100644 --- a/frontend/models/Collection.test.js +++ b/frontend/models/Collection.test.js @@ -1,10 +1,6 @@ /* eslint-disable */ import Collection from './Collection'; - -jest.mock('utils/ApiClient', () => ({ - client: { post: {} }, -})); -jest.mock('stores', () => ({ errors: {} })); +const { client } = require('utils/ApiClient'); describe('Collection model', () => { test('should initialize with data', () => { @@ -15,40 +11,34 @@ describe('Collection model', () => { expect(collection.name).toBe('Engineering'); }); - describe('#update', () => { - test('should update', async () => { + describe('#fetch', () => { + test('should update data', async () => { + client.post = jest.fn(() => ({ + data: { + name: 'New collection', + }, + })) + const collection = new Collection({ id: 123, name: 'Engineering', }); - collection.client = { - post: jest.fn(() => ({ - data: { - name: 'New collection', - }, - })), - }; - await collection.update(); - - expect(collection.client.post).toHaveBeenCalledWith('/collections.info', { - id: 123, - }); + await collection.fetch(); expect(collection.name).toBe('New collection'); }); test('should report errors', async () => { + client.post = jest.fn(() => Promise.reject()) + const collection = new Collection({ id: 123, }); - collection.client = { - post: jest.fn(() => Promise.reject), - }; collection.errors = { add: jest.fn(), }; - await collection.update(); + await collection.fetch(); expect(collection.errors.add).toHaveBeenCalledWith( 'Collection failed loading' diff --git a/frontend/models/Document.js b/frontend/models/Document.js index bb117d6bc..5a9b2d31c 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -30,6 +30,7 @@ class Document { starred: boolean = false; text: string = ''; title: string = 'Untitled document'; + parentDocument: ?Document; updatedAt: string; updatedBy: User; url: string; @@ -151,13 +152,14 @@ class Document { return this; }; - updateData(data: Object | Document) { + updateData(data: Object = {}, dirty: boolean = false) { if (data.text) data.title = parseHeader(data.text); + if (dirty) data.hasPendingChanges = true; extendObservable(this, data); } - constructor(document?: Object = {}) { - this.updateData(document); + constructor(data?: Object = {}) { + this.updateData(data); this.errors = stores.errors; } } diff --git a/frontend/scenes/CollectionNew/CollectionNew.js b/frontend/scenes/CollectionNew/CollectionNew.js new file mode 100644 index 000000000..186e8fa67 --- /dev/null +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -0,0 +1,72 @@ +// @flow +import React, { Component } from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import Button from 'components/Button'; +import Input from 'components/Input'; +import HelpText from 'components/HelpText'; + +import Collection from 'models/Collection'; +import CollectionsStore from 'stores/CollectionsStore'; + +type Props = { + history: Object, + collections: CollectionsStore, + onCollectionCreated: () => void, +}; + +@observer class CollectionNew extends Component { + props: Props; + @observable collection: Collection; + @observable name: string = ''; + @observable isSaving: boolean; + + constructor(props: Props) { + super(props); + this.collection = new Collection(); + } + + handleSubmit = async (ev: SyntheticEvent) => { + ev.preventDefault(); + this.isSaving = true; + this.collection.updateData({ name: this.name }); + const success = await this.collection.save(); + + if (success) { + this.props.collections.add(this.collection); + this.props.onCollectionCreated(); + this.props.history.push(this.collection.url); + } + + this.isSaving = false; + }; + + handleNameChange = (ev: SyntheticInputEvent) => { + this.name = ev.target.value; + }; + + render() { + return ( +
+ {this.collection.errors.errors.map(error => {error})} + + Collections are for grouping your Atlas. They work best when organized + around a topic or internal team — Product or Engineering for example. + + + +
+ ); + } +} + +export default CollectionNew; diff --git a/frontend/scenes/CollectionNew/index.js b/frontend/scenes/CollectionNew/index.js new file mode 100644 index 000000000..651d8d5c1 --- /dev/null +++ b/frontend/scenes/CollectionNew/index.js @@ -0,0 +1,3 @@ +// @flow +import CollectionNew from './CollectionNew'; +export default CollectionNew; diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index d04c08c52..7b1ce0db0 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -120,7 +120,7 @@ type Props = { onChange = text => { if (!this.document) return; - this.document.updateData({ text, hasPendingChanges: true }); + this.document.updateData({ text }, true); }; onCancel = () => { diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index 05712347a..985bd8635 100644 --- a/frontend/stores/CollectionsStore.js +++ b/frontend/stores/CollectionsStore.js @@ -42,6 +42,14 @@ class CollectionsStore { return _.find(this.data, { id }); }; + @action add = (collection: Collection): void => { + this.data.push(collection); + }; + + @action remove = (id: string): void => { + this.data.splice(this.data.indexOf(id), 1); + }; + constructor(options: Options) { this.client = client; this.errors = stores.errors; diff --git a/frontend/stores/ErrorsStore.js b/frontend/stores/ErrorsStore.js index abdc8eb60..02dc9739c 100644 --- a/frontend/stores/ErrorsStore.js +++ b/frontend/stores/ErrorsStore.js @@ -1,7 +1,7 @@ // @flow import { observable, action } from 'mobx'; -class UiStore { +class ErrorsStore { @observable errors = observable.array([]); /* Actions */ @@ -15,4 +15,4 @@ class UiStore { }; } -export default UiStore; +export default ErrorsStore; diff --git a/frontend/styles/animations.js b/frontend/styles/animations.js new file mode 100644 index 000000000..a042aaf72 --- /dev/null +++ b/frontend/styles/animations.js @@ -0,0 +1,14 @@ +// @flow +import { keyframes } from 'styled-components'; + +export const modalFadeIn = keyframes` + from { + opacity: 0; + transform: scale(.98); + } + + to { + opacity: 1; + transform: scale(1); + } +`; diff --git a/frontend/styles/constants.js b/frontend/styles/constants.js index d3a09f953..18c368771 100644 --- a/frontend/styles/constants.js +++ b/frontend/styles/constants.js @@ -40,7 +40,7 @@ export const color = { text: '#171B35', /* Brand */ - primary: '#73DF7B', + primary: '#2B8FBF', /* Dark Grays */ slate: '#9BA6B2', diff --git a/frontend/utils/__mocks__/ApiClient.js b/frontend/utils/__mocks__/ApiClient.js new file mode 100644 index 000000000..d53d1d2dc --- /dev/null +++ b/frontend/utils/__mocks__/ApiClient.js @@ -0,0 +1,5 @@ +export default { + client: { + post: jest.fn(() => Promise.resolve), + }, +}; diff --git a/frontend/utils/setupJest.js b/frontend/utils/setupJest.js index 61b717c2f..62af3f25d 100644 --- a/frontend/utils/setupJest.js +++ b/frontend/utils/setupJest.js @@ -9,5 +9,6 @@ const snap = children => { expect(toJson(wrapper)).toMatchSnapshot(); }; +global.fetch = require('jest-fetch-mock'); global.localStorage = localStorage; global.snap = snap; diff --git a/package.json b/package.json index eb1596fd8..63023c46e 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "react-dropzone": "3.6.0", "react-helmet": "3.1.0", "react-keydown": "^1.7.3", + "react-modal": "^2.2.1", "react-portal": "^3.1.0", "react-router-dom": "^4.1.1", "redis": "^2.6.2", @@ -178,6 +179,7 @@ "flow-bin": "^0.49.1", "identity-obj-proxy": "^3.0.0", "jest-cli": "^20.0.0", + "jest-fetch-mock": "^1.2.0", "koa-webpack-dev-middleware": "1.4.5", "koa-webpack-hot-middleware": "1.0.3", "lint-staged": "^3.4.0", diff --git a/yarn.lock b/yarn.lock index d2ffab4e7..3d7d735ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2433,6 +2433,10 @@ elegant-spinner@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" +element-class@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e" + elliptic@^6.0.0: version "6.3.2" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.3.2.tgz#e4c81e0829cf0a65ab70e998b8232723b5c1bc48" @@ -2908,6 +2912,10 @@ execa@^0.6.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +exenv@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.0.tgz#3835f127abf075bfe082d0aed4484057c78e3c89" + exit-hook@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" @@ -4531,6 +4539,12 @@ jest-environment-node@^20.0.3: jest-mock "^20.0.3" jest-util "^20.0.3" +jest-fetch-mock@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-1.2.0.tgz#15ac5c2d32d2c888bd3d132b682a4deb17f3ab35" + dependencies: + whatwg-fetch "1.0.0" + jest-haste-map@^20.0.3: version "20.0.3" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-20.0.3.tgz#6377d537eaf34eb5f75121a691cae3fde82ba971" @@ -6969,11 +6983,12 @@ promise@7.x, promise@^7.0.3, promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.5.4, prop-types@^15.5.8: - version "15.5.8" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394" +prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" dependencies: fbjs "^0.8.9" + loose-envify "^1.3.1" proto-list@~1.2.1: version "1.2.4" @@ -7117,6 +7132,10 @@ react-deep-force-update@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7" +react-dom-factories@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-dom-factories/-/react-dom-factories-1.0.0.tgz#f43c05e5051b304f33251618d5bc859b29e46b6d" + react-dom@15.3.2, "react-dom@^0.14.0 || ^15.0.0": version "15.3.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f" @@ -7141,6 +7160,15 @@ react-keydown@^1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/react-keydown/-/react-keydown-1.7.3.tgz#51262d5e6e5ce5909e0279783e607bd5a6cc480c" +react-modal@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-2.2.1.tgz#22563782688bbb43441ac436156d6cb5dcb60c8b" + dependencies: + element-class "^0.2.0" + exenv "1.2.0" + prop-types "^15.5.10" + react-dom-factories "^1.0.0" + react-portal@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899" @@ -8891,7 +8919,7 @@ whatwg-encoding@^1.0.1: dependencies: iconv-lite "0.4.13" -whatwg-fetch@>=0.10.0: +whatwg-fetch@1.0.0, whatwg-fetch@>=0.10.0: version "1.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-1.0.0.tgz#01c2ac4df40e236aaa18480e3be74bd5c8eb798e"