From 98da54d82a0d0e51cb5c3e34d4b4141a696118a4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 9 Jul 2017 09:02:44 -0700 Subject: [PATCH 1/6] Modals architecture --- frontend/components/Layout/Layout.js | 14 +++++ .../components/Layout/components/Modals.js | 24 +++++++++ .../components/NewCollection/NewCollection.js | 6 +++ frontend/components/NewCollection/index.js | 3 ++ frontend/components/modals.js | 5 ++ frontend/stores/UiStore.js | 17 +++++++ package.json | 1 + yarn.lock | 51 ++++++++++--------- 8 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 frontend/components/Layout/components/Modals.js create mode 100644 frontend/components/NewCollection/NewCollection.js create mode 100644 frontend/components/NewCollection/index.js create mode 100644 frontend/components/modals.js diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index c022c6301..a92b587a0 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -17,6 +17,7 @@ import Avatar from 'components/Avatar'; import SidebarCollection from './components/SidebarCollection'; import SidebarCollectionList from './components/SidebarCollectionList'; import SidebarLink from './components/SidebarLink'; +import Modals from './components/Modals'; import UserStore from 'stores/UserStore'; import AuthStore from 'stores/AuthStore'; @@ -59,6 +60,10 @@ type Props = { this.props.auth.logout(() => this.props.history.push('/')); }; + createNewCollection = () => { + this.props.ui.openModal('NewCollection'); + }; + render() { const { user, auth, ui } = this.props; @@ -73,6 +78,12 @@ type Props = { }, ]} /> + {this.props.ui.progressBarVisible && } @@ -111,6 +122,9 @@ type Props = { Home Starred + + Create new collection + {ui.activeCollection ? + + {isOpen && } + + ); + } +} + +export default Modals; diff --git a/frontend/components/NewCollection/NewCollection.js b/frontend/components/NewCollection/NewCollection.js new file mode 100644 index 000000000..a4a008b2b --- /dev/null +++ b/frontend/components/NewCollection/NewCollection.js @@ -0,0 +1,6 @@ +// @flow +import React from 'react'; + +const NewCollection = () => NEW COLLECTION; + +export default NewCollection; diff --git a/frontend/components/NewCollection/index.js b/frontend/components/NewCollection/index.js new file mode 100644 index 000000000..d65518ed9 --- /dev/null +++ b/frontend/components/NewCollection/index.js @@ -0,0 +1,3 @@ +// @flow +import NewCollection from './NewCollection'; +export default NewCollection; diff --git a/frontend/components/modals.js b/frontend/components/modals.js new file mode 100644 index 000000000..c409daaec --- /dev/null +++ b/frontend/components/modals.js @@ -0,0 +1,5 @@ +// @flow +// All components wishing to be used as modals must be defined below +import NewCollection from './NewCollection'; + +export default { NewCollection }; diff --git a/frontend/stores/UiStore.js b/frontend/stores/UiStore.js index 6345af8b0..59ce9fdff 100644 --- a/frontend/stores/UiStore.js +++ b/frontend/stores/UiStore.js @@ -2,11 +2,14 @@ import { observable, action, computed } from 'mobx'; import Document from 'models/Document'; import Collection from 'models/Collection'; +import modals from 'components/modals'; class UiStore { @observable activeDocument: ?Document; @observable progressBarVisible: boolean = false; @observable editMode: boolean = false; + @observable modalName: ?string; + @observable modalProps: ?Object; /* Computed */ @@ -14,6 +17,10 @@ class UiStore { return this.activeDocument ? this.activeDocument.collection : undefined; } + @computed get modalComponent(): ?ReactClass { + if (this.modalName) return modals[this.modalName]; + } + /* Actions */ @action setActiveDocument = (document: Document): void => { @@ -24,6 +31,16 @@ class UiStore { this.activeDocument = undefined; }; + @action openModal = (name: string, props?: Object) => { + this.modalName = name; + this.modalProps = props; + }; + + @action closeModal = () => { + this.modalName = undefined; + this.modalProps = undefined; + }; + @action enableEditMode() { this.editMode = true; } diff --git a/package.json b/package.json index d068d9d5b..ba8d2ae72 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,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", diff --git a/yarn.lock b/yarn.lock index 28fe5d82c..3d3a545a9 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" @@ -6973,6 +6981,13 @@ promise@7.x, promise@^7.0.3, promise@^7.1.1: dependencies: asap "~2.0.3" +prop-types@^15.5.10: + 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" + 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" @@ -7121,6 +7136,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" @@ -7145,6 +7164,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" @@ -7369,13 +7397,6 @@ referrer-policy@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79" -reflexbox@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/reflexbox/-/reflexbox-2.2.3.tgz#9b9ce983dbe677cebf3a94cf2c50b8157f50c0d1" - dependencies: - robox "^1.0.0-beta.8" - ruled "^1.0.1" - regenerate@^1.2.1: version "1.3.1" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.1.tgz#0300203a5d2fdcf89116dce84275d011f5903f33" @@ -7629,16 +7650,6 @@ rndm@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c" -robox@^1.0.0-beta.8: - version "1.0.0-beta.8" - resolved "https://registry.yarnpkg.com/robox/-/robox-1.0.0-beta.8.tgz#9aaee1dacf38a8c4ca4584a80012aebab5711c73" - dependencies: - understyle "^1.2.0" - -ruled@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ruled/-/ruled-1.0.1.tgz#8301a1accc9d2e14b6502fca7033582335c2c0f4" - run-async@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389" @@ -8613,12 +8624,6 @@ underscore@^1.7.0: version "1.8.3" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" -understyle@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/understyle/-/understyle-1.3.0.tgz#df3f9a9be96779d718c3da9598fad1c2f90f24ee" - dependencies: - object-assign "^4.1.0" - "unicode@>= 0.3.1": version "0.6.1" resolved "https://registry.yarnpkg.com/unicode/-/unicode-0.6.1.tgz#ec69e3c4537e2b9650b826133bcb068f0445d0bc" From f456dc6b6ae99fc46c15348229f3e2330d8aa482 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 9 Jul 2017 10:27:29 -0700 Subject: [PATCH 2/6] CollectionNew scene --- frontend/components/Layout/Layout.js | 27 ++++++---- .../components/Layout/components/Modals.js | 24 --------- frontend/components/Modal/Modal.js | 36 +++++++++++++ frontend/components/Modal/index.js | 3 ++ frontend/components/modals.js | 5 -- frontend/models/Collection.js | 54 +++++++++++++++---- frontend/models/Document.js | 8 +-- .../scenes/CollectionNew/CollectionNew.js | 53 ++++++++++++++++++ frontend/scenes/CollectionNew/index.js | 3 ++ frontend/scenes/Document/Document.js | 2 +- frontend/stores/ErrorsStore.js | 4 +- frontend/stores/UiStore.js | 17 ------ 12 files changed, 164 insertions(+), 72 deletions(-) delete mode 100644 frontend/components/Layout/components/Modals.js create mode 100644 frontend/components/Modal/Modal.js create mode 100644 frontend/components/Modal/index.js delete mode 100644 frontend/components/modals.js create mode 100644 frontend/scenes/CollectionNew/CollectionNew.js create mode 100644 frontend/scenes/CollectionNew/index.js diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index a92b587a0..0fdf3d977 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -13,11 +13,12 @@ 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 CollectionNew from 'scenes/CollectionNew'; import SidebarCollection from './components/SidebarCollection'; import SidebarCollectionList from './components/SidebarCollectionList'; import SidebarLink from './components/SidebarLink'; -import Modals from './components/Modals'; import UserStore from 'stores/UserStore'; import AuthStore from 'stores/AuthStore'; @@ -39,6 +40,8 @@ type Props = { @observer class Layout extends React.Component { props: Props; + state: { createCollectionModalOpen: boolean }; + state = { createCollectionModalOpen: false }; static defaultProps = { search: true, @@ -60,8 +63,12 @@ type Props = { this.props.auth.logout(() => this.props.history.push('/')); }; - createNewCollection = () => { - this.props.ui.openModal('NewCollection'); + handleCreateCollection = () => { + this.setState({ createCollectionModalOpen: true }); + }; + + handleCloseModal = () => { + this.setState({ createCollectionModalOpen: false }); }; render() { @@ -78,12 +85,6 @@ type Props = { }, ]} /> - {this.props.ui.progressBarVisible && } @@ -122,7 +123,7 @@ type Props = { Home Starred - + Create new collection @@ -142,6 +143,12 @@ type Props = { {this.props.children} + + + ); } diff --git a/frontend/components/Layout/components/Modals.js b/frontend/components/Layout/components/Modals.js deleted file mode 100644 index 891aa5cae..000000000 --- a/frontend/components/Layout/components/Modals.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow -import React, { Component } from 'react'; -import Modal from 'react-modal'; - -class Modals extends Component { - render() { - const { name, component, onRequestClose, ...rest } = this.props; - const isOpen = !!component; - const ModalComponent = component; - - return ( - - - {isOpen && } - - ); - } -} - -export default Modals; diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js new file mode 100644 index 000000000..3735386d2 --- /dev/null +++ b/frontend/components/Modal/Modal.js @@ -0,0 +1,36 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; +import ReactModal from 'react-modal'; + +class Modal extends Component { + render() { + const { + children, + title = 'Untitled Modal', + onRequestClose, + ...rest + } = this.props; + + return ( + +
+ + {title} +
+ {children} +
+ ); + } +} + +const Header = styled.div` + text-align: center; + font-weight: semibold; +`; + +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/components/modals.js b/frontend/components/modals.js deleted file mode 100644 index c409daaec..000000000 --- a/frontend/components/modals.js +++ /dev/null @@ -1,5 +0,0 @@ -// @flow -// All components wishing to be used as modals must be defined below -import NewCollection from './NewCollection'; - -export default { NewCollection }; diff --git a/frontend/models/Collection.js b/frontend/models/Collection.js index adc663c4d..bc1755cd6 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,59 @@ 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'); + } finally { + this.isSaving = false; + } + + return this; + }; + + 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/Document.js b/frontend/models/Document.js index db35d7423..37cca9905 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 = {}) { if (data.text) data.title = parseHeader(data.text); + 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..e49ff931a --- /dev/null +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -0,0 +1,53 @@ +// @flow +import React, { Component } from 'react'; +import { observer } from 'mobx-react'; +import Button from 'components/Button'; +import Input from 'components/Input'; +import Collection from 'models/Collection'; + +@observer class CollectionNew extends Component { + static defaultProps = { + collection: new Collection(), + }; + + handleSubmit = async (ev: SyntheticEvent) => { + ev.preventDefault(); + await this.props.collection.save(); + }; + + handleNameChange = (ev: SyntheticInputEvent) => { + this.props.collection.updateData({ name: ev.target.value }); + }; + + handleDescriptionChange = (ev: SyntheticInputEvent) => { + this.props.collection.updateData({ description: ev.target.value }); + }; + + render() { + const { collection } = this.props; + + return ( +
+ {collection.errors.errors.map(error => {error})} + + + +
+ ); + } +} + +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..bc104f2e6 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 }); }; onCancel = () => { 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/stores/UiStore.js b/frontend/stores/UiStore.js index 59ce9fdff..6345af8b0 100644 --- a/frontend/stores/UiStore.js +++ b/frontend/stores/UiStore.js @@ -2,14 +2,11 @@ import { observable, action, computed } from 'mobx'; import Document from 'models/Document'; import Collection from 'models/Collection'; -import modals from 'components/modals'; class UiStore { @observable activeDocument: ?Document; @observable progressBarVisible: boolean = false; @observable editMode: boolean = false; - @observable modalName: ?string; - @observable modalProps: ?Object; /* Computed */ @@ -17,10 +14,6 @@ class UiStore { return this.activeDocument ? this.activeDocument.collection : undefined; } - @computed get modalComponent(): ?ReactClass { - if (this.modalName) return modals[this.modalName]; - } - /* Actions */ @action setActiveDocument = (document: Document): void => { @@ -31,16 +24,6 @@ class UiStore { this.activeDocument = undefined; }; - @action openModal = (name: string, props?: Object) => { - this.modalName = name; - this.modalProps = props; - }; - - @action closeModal = () => { - this.modalName = undefined; - this.modalProps = undefined; - }; - @action enableEditMode() { this.editMode = true; } From a04af080646112dc0054d3c043dd4f599d689264 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 9 Jul 2017 20:02:10 -0700 Subject: [PATCH 3/6] Polish --- frontend/components/Button/Button.js | 6 ++- frontend/components/HelpText/HelpText.js | 10 ++++ frontend/components/HelpText/index.js | 3 ++ frontend/components/Icon/AddIcon.js | 21 ++++++++ frontend/components/Input/Input.js | 47 ++++++++++++---- frontend/components/Layout/Layout.js | 35 ++++++++++-- .../components/LoadingIndicator/style.scss | 0 frontend/components/Modal/Modal.js | 53 +++++++++++++++---- .../components/NewCollection/NewCollection.js | 6 --- frontend/components/NewCollection/index.js | 3 -- frontend/models/Document.js | 4 +- .../scenes/CollectionNew/CollectionNew.js | 51 ++++++++++++------ frontend/scenes/Document/Document.js | 2 +- frontend/stores/CollectionsStore.js | 8 +++ frontend/styles/animations.js | 14 +++++ frontend/styles/constants.js | 2 +- 16 files changed, 209 insertions(+), 56 deletions(-) create mode 100644 frontend/components/HelpText/HelpText.js create mode 100644 frontend/components/HelpText/index.js create mode 100644 frontend/components/Icon/AddIcon.js create mode 100644 frontend/components/LoadingIndicator/style.scss delete mode 100644 frontend/components/NewCollection/NewCollection.js delete mode 100644 frontend/components/NewCollection/index.js create mode 100644 frontend/styles/animations.js 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 0fdf3d977..aead835ca 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -14,6 +14,7 @@ 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'; @@ -72,7 +73,7 @@ type Props = { }; render() { - const { user, auth, ui } = this.props; + const { user, auth, collections, history, ui } = this.props; return ( @@ -123,10 +124,10 @@ type Props = { Home Starred
- - Create new collection - + + + {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%; @@ -200,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 index 3735386d2..21f734182 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -2,6 +2,10 @@ import React, { Component } from 'react'; import styled from 'styled-components'; import ReactModal from 'react-modal'; +import { modalFadeIn } from 'styles/animations'; + +import CloseIcon from '../Icon/CloseIcon'; +import Flex from '../Flex'; class Modal extends Component { render() { @@ -13,24 +17,53 @@ class Modal extends Component { } = this.props; return ( - -
- - {title} -
- {children} -
+ +

{title}

+ + {children} +
+ ); } } -const Header = styled.div` - text-align: center; - font-weight: semibold; +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/NewCollection/NewCollection.js b/frontend/components/NewCollection/NewCollection.js deleted file mode 100644 index a4a008b2b..000000000 --- a/frontend/components/NewCollection/NewCollection.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow -import React from 'react'; - -const NewCollection = () => NEW COLLECTION; - -export default NewCollection; diff --git a/frontend/components/NewCollection/index.js b/frontend/components/NewCollection/index.js deleted file mode 100644 index d65518ed9..000000000 --- a/frontend/components/NewCollection/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import NewCollection from './NewCollection'; -export default NewCollection; diff --git a/frontend/models/Document.js b/frontend/models/Document.js index 37cca9905..e106a722b 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -152,9 +152,9 @@ class Document { return this; }; - updateData(data: Object = {}) { + updateData(data: Object = {}, dirty: boolean = false) { if (data.text) data.title = parseHeader(data.text); - data.hasPendingChanges = true; + if (dirty) data.hasPendingChanges = true; extendObservable(this, data); } diff --git a/frontend/scenes/CollectionNew/CollectionNew.js b/frontend/scenes/CollectionNew/CollectionNew.js index e49ff931a..113d449d2 100644 --- a/frontend/scenes/CollectionNew/CollectionNew.js +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -3,24 +3,41 @@ import React, { Component } from 'react'; 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'; @observer class CollectionNew extends Component { + props: { + history: Object, + collection: Collection, + collections: CollectionsStore, + onCollectionCreated: () => void, + }; + state: { name: string, isSaving: boolean }; + state = { name: '', isSaving: false }; + static defaultProps = { collection: new Collection(), }; handleSubmit = async (ev: SyntheticEvent) => { ev.preventDefault(); - await this.props.collection.save(); + this.setState({ isSaving: true }); + const { collection, collections } = this.props; + + collection.updateData(this.state); + await collection.save(); + collections.add(collection); + + this.setState({ isSaving: false }); + this.props.onCollectionCreated(); + this.props.history.push(collection.url); }; handleNameChange = (ev: SyntheticInputEvent) => { - this.props.collection.updateData({ name: ev.target.value }); - }; - - handleDescriptionChange = (ev: SyntheticInputEvent) => { - this.props.collection.updateData({ description: ev.target.value }); + this.setState({ name: ev.target.value }); }; render() { @@ -29,21 +46,23 @@ import Collection from 'models/Collection'; return (
{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. + - -
); diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index bc104f2e6..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 }); + this.document.updateData({ text }, true); }; onCancel = () => { diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index ae8e26446..39f62fe3d 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/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', From 314ac75850571759540f323d107224a596a30af9 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 9 Jul 2017 20:56:36 -0700 Subject: [PATCH 4/6] Fixed: Preserved state bug --- frontend/components/Modal/Modal.js | 3 ++ frontend/models/Collection.js | 3 +- .../scenes/CollectionNew/CollectionNew.js | 47 +++++++++++-------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js index 21f734182..343e527bb 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -11,15 +11,18 @@ class Modal extends Component { render() { const { children, + isOpen, title = 'Untitled Modal', onRequestClose, ...rest } = this.props; + if (!isOpen) return null; return ( diff --git a/frontend/models/Collection.js b/frontend/models/Collection.js index bc1755cd6..3215b2253 100644 --- a/frontend/models/Collection.js +++ b/frontend/models/Collection.js @@ -70,11 +70,12 @@ class Collection { }); } catch (e) { this.errors.add('Collection failed saving'); + return false; } finally { this.isSaving = false; } - return this; + return true; }; updateData(data: Object = {}) { diff --git a/frontend/scenes/CollectionNew/CollectionNew.js b/frontend/scenes/CollectionNew/CollectionNew.js index 113d449d2..ad6095d97 100644 --- a/frontend/scenes/CollectionNew/CollectionNew.js +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -1,6 +1,5 @@ // @flow import React, { Component } from 'react'; -import { observer } from 'mobx-react'; import Button from 'components/Button'; import Input from 'components/Input'; import HelpText from 'components/HelpText'; @@ -8,32 +7,42 @@ import HelpText from 'components/HelpText'; import Collection from 'models/Collection'; import CollectionsStore from 'stores/CollectionsStore'; -@observer class CollectionNew extends Component { - props: { - history: Object, - collection: Collection, - collections: CollectionsStore, - onCollectionCreated: () => void, - }; - state: { name: string, isSaving: boolean }; - state = { name: '', isSaving: false }; +type Props = { + history: Object, + collections: CollectionsStore, + onCollectionCreated: () => void, +}; - static defaultProps = { - collection: new Collection(), - }; +class CollectionNew extends Component { + props: Props; + state: { collection: Collection, name: string, isSaving: boolean }; + + constructor(props: Props) { + super(props); + + this.state = { + name: '', + isSaving: false, + collection: new Collection(), + }; + } handleSubmit = async (ev: SyntheticEvent) => { ev.preventDefault(); this.setState({ isSaving: true }); - const { collection, collections } = this.props; + const { collection } = this.state; + const { collections } = this.props; collection.updateData(this.state); - await collection.save(); - collections.add(collection); + const success = await collection.save(); + + if (success) { + collections.add(collection); + this.props.onCollectionCreated(); + this.props.history.push(collection.url); + } this.setState({ isSaving: false }); - this.props.onCollectionCreated(); - this.props.history.push(collection.url); }; handleNameChange = (ev: SyntheticInputEvent) => { @@ -41,7 +50,7 @@ import CollectionsStore from 'stores/CollectionsStore'; }; render() { - const { collection } = this.props; + const { collection } = this.state; return (
From 6e152d7c3456da8f6c532ffc8b0e3e5ff53f2f15 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 9 Jul 2017 23:53:35 -0700 Subject: [PATCH 5/6] PR feedback --- frontend/components/Modal/Modal.js | 62 ++++++++++--------- .../scenes/CollectionNew/CollectionNew.js | 45 ++++++-------- 2 files changed, 51 insertions(+), 56 deletions(-) diff --git a/frontend/components/Modal/Modal.js b/frontend/components/Modal/Modal.js index 343e527bb..ee988528a 100644 --- a/frontend/components/Modal/Modal.js +++ b/frontend/components/Modal/Modal.js @@ -1,39 +1,43 @@ // @flow -import React, { Component } from 'react'; +import React from 'react'; import styled from 'styled-components'; import ReactModal from 'react-modal'; import { modalFadeIn } from 'styles/animations'; -import CloseIcon from '../Icon/CloseIcon'; -import Flex from '../Flex'; +import CloseIcon from 'components/Icon/CloseIcon'; +import Flex from 'components/Flex'; -class Modal extends Component { - render() { - const { - children, - isOpen, - title = 'Untitled Modal', - onRequestClose, - ...rest - } = this.props; - if (!isOpen) return null; +type Props = { + children?: React$Element, + isOpen: boolean, + title?: string, + onRequestClose: () => void, +}; - return ( - - -

{title}

- - {children} -
-
- ); - } -} +const Modal = ({ + children, + isOpen, + title = 'Untitled Modal', + onRequestClose, + ...rest +}: Props) => { + if (!isOpen) return null; + + return ( + + + {title &&

{title}

} + + {children} +
+
+ ); +}; const Content = styled(Flex)` width: 640px; diff --git a/frontend/scenes/CollectionNew/CollectionNew.js b/frontend/scenes/CollectionNew/CollectionNew.js index ad6095d97..186e8fa67 100644 --- a/frontend/scenes/CollectionNew/CollectionNew.js +++ b/frontend/scenes/CollectionNew/CollectionNew.js @@ -1,5 +1,7 @@ // @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'; @@ -13,48 +15,40 @@ type Props = { onCollectionCreated: () => void, }; -class CollectionNew extends Component { +@observer class CollectionNew extends Component { props: Props; - state: { collection: Collection, name: string, isSaving: boolean }; + @observable collection: Collection; + @observable name: string = ''; + @observable isSaving: boolean; constructor(props: Props) { super(props); - - this.state = { - name: '', - isSaving: false, - collection: new Collection(), - }; + this.collection = new Collection(); } handleSubmit = async (ev: SyntheticEvent) => { ev.preventDefault(); - this.setState({ isSaving: true }); - const { collection } = this.state; - const { collections } = this.props; - - collection.updateData(this.state); - const success = await collection.save(); + this.isSaving = true; + this.collection.updateData({ name: this.name }); + const success = await this.collection.save(); if (success) { - collections.add(collection); + this.props.collections.add(this.collection); this.props.onCollectionCreated(); - this.props.history.push(collection.url); + this.props.history.push(this.collection.url); } - this.setState({ isSaving: false }); + this.isSaving = false; }; handleNameChange = (ev: SyntheticInputEvent) => { - this.setState({ name: ev.target.value }); + this.name = ev.target.value; }; render() { - const { collection } = this.state; - return ( - {collection.errors.errors.map(error => {error})} + {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. @@ -63,15 +57,12 @@ class CollectionNew extends Component { type="text" label="Name" onChange={this.handleNameChange} - value={this.state.name} + value={this.name} required autoFocus /> - ); From 205590ca6a27d1d9877c612950f6998a84fe6c51 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 10 Jul 2017 00:38:51 -0700 Subject: [PATCH 6/6] :green_heart: --- frontend/models/Collection.test.js | 36 ++++++++++----------------- frontend/utils/__mocks__/ApiClient.js | 5 ++++ frontend/utils/setupJest.js | 1 + package.json | 1 + yarn.lock | 16 ++++++------ 5 files changed, 28 insertions(+), 31 deletions(-) create mode 100644 frontend/utils/__mocks__/ApiClient.js 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/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 ba8d2ae72..cf1677423 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "flow-bin": "^0.45.0", "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 3d3a545a9..d7fe0cb8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4543,6 +4543,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" @@ -6981,19 +6987,13 @@ promise@7.x, promise@^7.0.3, promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.5.10: +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" -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" - dependencies: - fbjs "^0.8.9" - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -8923,7 +8923,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"