diff --git a/__mocks__/localStorage.js b/__mocks__/localStorage.js new file mode 100644 index 000000000..3d8394b99 --- /dev/null +++ b/__mocks__/localStorage.js @@ -0,0 +1,20 @@ +const storage = {}; + +export default { + setItem: function(key, value) { + storage[key] = value || ''; + }, + getItem: function(key) { + return key in storage ? storage[key] : null; + }, + removeItem: function(key) { + delete storage[key]; + }, + get length() { + return Object.keys(storage).length; + }, + key: function(i) { + var keys = Object.keys(storage); + return keys[i] || null; + }, +}; diff --git a/frontend/components/Button/Button.js b/frontend/components/Button/Button.js new file mode 100644 index 000000000..31b826dd0 --- /dev/null +++ b/frontend/components/Button/Button.js @@ -0,0 +1,69 @@ +// @flow +import React from 'react'; +import styled from 'styled-components'; +import { size, color } from 'styles/constants'; +import { darken } from 'polished'; + +const RealButton = styled.button` + display: inline-block; + margin: 0 0 ${size.large}; + padding: 0; + border: 0; + background: ${color.primary}; + color: ${color.white}; + border-radius: 4px; + min-width: 32px; + min-height: 32px; + text-decoration: none; + flex-shrink: 0; + outline: none; + + &::-moz-focus-inner { + padding: 0; + border: 0; + } + &:hover { + background: ${darken(0.05, color.primary)}; + } +`; + +const Label = styled.span` + padding: 2px 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + +const Inner = styled.span` + display: flex; + line-height: 28px; + justify-content: center; +`; + +export type Props = { + type?: string, + value?: string, + icon?: React$Element, + className?: string, + children?: React$Element, +}; + +export default function Button({ + type = 'text', + icon, + children, + value, + ...rest +}: Props) { + const hasText = children !== undefined || value !== undefined; + const hasIcon = icon !== undefined; + + return ( + + + {hasIcon && icon} + {hasText && } + + + ); +} diff --git a/frontend/components/Button/index.js b/frontend/components/Button/index.js new file mode 100644 index 000000000..70118ffe8 --- /dev/null +++ b/frontend/components/Button/index.js @@ -0,0 +1,3 @@ +// @flow +import Button from './Button'; +export default Button; diff --git a/frontend/components/DocumentList/DocumentList.js b/frontend/components/DocumentList/DocumentList.js index 583c0c662..4ca8b63ab 100644 --- a/frontend/components/DocumentList/DocumentList.js +++ b/frontend/components/DocumentList/DocumentList.js @@ -1,8 +1,7 @@ // @flow import React from 'react'; -import type { Document } from 'types'; +import Document from 'models/Document'; import DocumentPreview from 'components/DocumentPreview'; -import Divider from 'components/Divider'; class DocumentList extends React.Component { props: { @@ -14,10 +13,7 @@ class DocumentList extends React.Component {
{this.props.documents && this.props.documents.map(document => ( -
- - -
+ ))}
); diff --git a/frontend/components/DocumentPreview/DocumentPreview.js b/frontend/components/DocumentPreview/DocumentPreview.js index aef5e09b3..c6d34b895 100644 --- a/frontend/components/DocumentPreview/DocumentPreview.js +++ b/frontend/components/DocumentPreview/DocumentPreview.js @@ -1,21 +1,20 @@ // @flow import React, { Component } from 'react'; -import { toJS } from 'mobx'; import { Link } from 'react-router-dom'; -import type { Document } from 'types'; +import Document from 'models/Document'; import styled from 'styled-components'; import { color } from 'styles/constants'; -import Markdown from 'components/Markdown'; import PublishingInfo from 'components/PublishingInfo'; type Props = { document: Document, + highlight?: string, innerRef?: Function, }; const DocumentLink = styled(Link)` display: block; - margin: 16px -16px; + margin: 0 -16px; padding: 16px; border-radius: 8px; border: 2px solid transparent; @@ -35,16 +34,11 @@ const DocumentLink = styled(Link)` border: 2px solid ${color.slateDark}; } - h1 { + h3 { margin-top: 0; } `; -// $FlowIssue -const TruncatedMarkdown = styled(Markdown)` - pointer-events: none; -`; - class DocumentPreview extends Component { props: Props; @@ -53,14 +47,13 @@ class DocumentPreview extends Component { return ( +

{document.title}

-
); } diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 125b1f43c..025e5a034 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -111,8 +111,6 @@ type KeyData = { render = () => { return ( - {!this.props.readOnly && - } (props.hasError ? 'red' : 'rgba(0, 0, 0, .15)')}; + border-radius: ${size.small}; + + &:focus, + &:active { + border-color: rgba(0, 0, 0, .25); + } +`; + +export type Props = { + type: string, + value: string, + className?: string, +}; + +export default function Input({ type, ...rest }: Props) { + const Component = type === 'textarea' ? RealTextarea : RealInput; + + return ( + + + + ); +} diff --git a/frontend/components/Input/index.js b/frontend/components/Input/index.js new file mode 100644 index 000000000..e005a8af8 --- /dev/null +++ b/frontend/components/Input/index.js @@ -0,0 +1,3 @@ +// @flow +import Input from './Input'; +export default Input; diff --git a/frontend/components/Layout/Layout.js b/frontend/components/Layout/Layout.js index 2af7ef187..af07a7e1c 100644 --- a/frontend/components/Layout/Layout.js +++ b/frontend/components/Layout/Layout.js @@ -106,7 +106,7 @@ type Props = { Search - Dashboard + Home Starred diff --git a/frontend/components/PublishingInfo/PublishingInfo.js b/frontend/components/PublishingInfo/PublishingInfo.js index 1cd946bec..166c23fc4 100644 --- a/frontend/components/PublishingInfo/PublishingInfo.js +++ b/frontend/components/PublishingInfo/PublishingInfo.js @@ -7,7 +7,7 @@ import { Flex } from 'reflexbox'; const Container = styled(Flex)` justify-content: space-between; - color: #ccc; + color: #bbb; font-size: 13px; `; @@ -26,7 +26,7 @@ const Avatar = styled.img` class PublishingInfo extends Component { props: { - collaborators: Array, + collaborators?: Array, createdAt: string, createdBy: User, updatedAt: string, @@ -35,13 +35,16 @@ class PublishingInfo extends Component { }; render() { + const { collaborators } = this.props; + return ( - - {this.props.collaborators.map(user => ( - - ))} - + {collaborators && + + {collaborators.map(user => ( + + ))} + } {this.props.createdBy.name} {' '} diff --git a/frontend/index.js b/frontend/index.js index c09fac025..0f050e1ab 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -11,6 +11,7 @@ import { import { Flex } from 'reflexbox'; import stores from 'stores'; +import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; import 'normalize.css/normalize.css'; @@ -57,12 +58,13 @@ const Auth = ({ children }: AuthProps) => { const user = stores.auth.getUserStore(); authenticatedStores = { user, + documents: new DocumentsStore(), collections: new CollectionsStore({ teamId: user.team.id, }), }; - authenticatedStores.collections.fetch(); + authenticatedStores.collections.fetchAll(); } return ( @@ -135,3 +137,5 @@ render( , document.getElementById('root') ); + +window.authenticatedStores = authenticatedStores; diff --git a/frontend/models/Document.js b/frontend/models/Document.js index 1c69baee3..e19f79711 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -2,14 +2,23 @@ import { extendObservable, action, runInAction, computed } from 'mobx'; import invariant from 'invariant'; -import ApiClient, { client } from 'utils/ApiClient'; +import { client } from 'utils/ApiClient'; import stores from 'stores'; import ErrorsStore from 'stores/ErrorsStore'; import type { User } from 'types'; import Collection from './Collection'; +const parseHeader = text => { + const firstLine = text.split(/\r?\n/)[0]; + return firstLine.replace(/^#/, '').trim(); +}; + class Document { + isSaving: boolean; + hasPendingChanges: boolean = false; + errors: ErrorsStore; + collaborators: Array; collection: Collection; createdAt: string; @@ -20,15 +29,12 @@ class Document { starred: boolean; team: string; text: string; - title: string; + title: string = 'Untitled document'; updatedAt: string; updatedBy: User; url: string; views: number; - client: ApiClient; - errors: ErrorsStore; - /* Computed */ @computed get pathToDocument(): Array { @@ -56,9 +62,46 @@ class Document { /* Actions */ - @action update = async () => { + @action star = async () => { + this.starred = true; try { - const res = await this.client.post('/documents.info', { id: this.id }); + await client.post('/documents.star', { id: this.id }); + } catch (e) { + this.starred = false; + this.errors.add('Document failed star'); + } + }; + + @action unstar = async () => { + this.starred = false; + try { + await client.post('/documents.unstar', { id: this.id }); + } catch (e) { + this.starred = false; + this.errors.add('Document failed unstar'); + } + }; + + @action view = async () => { + try { + await client.post('/views.create', { id: this.id }); + this.views++; + } catch (e) { + this.errors.add('Document failed to record view'); + } + }; + + @action delete = async () => { + try { + await client.post('/documents.delete', { id: this.id }); + } catch (e) { + this.errors.add('Document failed to delete'); + } + }; + + @action fetch = async () => { + try { + const res = await client.post('/documents.info', { id: this.id }); invariant(res && res.data, 'Document API response should be available'); const { data } = res; runInAction('Document#update', () => { @@ -69,13 +112,42 @@ class Document { } }; - updateData(data: Document) { + @action save = async () => { + if (this.isSaving) return; + this.isSaving = true; + + try { + let res; + if (this.id) { + res = await client.post('/documents.update', { + id: this.id, + title: this.title, + text: this.text, + }); + } else { + res = await client.post('/documents.create', { + collection: this.collection.id, + title: this.title, + text: this.text, + }); + } + + invariant(res && res.data, 'Data should be available'); + this.hasPendingChanges = false; + } catch (e) { + this.errors.add('Document failed saving'); + } finally { + this.isSaving = false; + } + }; + + updateData(data: Object | Document) { + data.title = parseHeader(data.text); extendObservable(this, data); } constructor(document: Document) { this.updateData(document); - this.client = client; this.errors = stores.errors; } } diff --git a/frontend/models/Document.test.js b/frontend/models/Document.test.js new file mode 100644 index 000000000..e7db05b7d --- /dev/null +++ b/frontend/models/Document.test.js @@ -0,0 +1,13 @@ +/* eslint-disable */ +import Document from './Document'; + +describe('Document model', () => { + test('should initialize with data', () => { + const document = new Document({ + id: 123, + title: 'Onboarding', + text: 'Some body text' + }); + expect(document.title).toBe('Onboarding'); + }); +}); diff --git a/frontend/scenes/Dashboard/Dashboard.js b/frontend/scenes/Dashboard/Dashboard.js index af6ff2179..54410b2fa 100644 --- a/frontend/scenes/Dashboard/Dashboard.js +++ b/frontend/scenes/Dashboard/Dashboard.js @@ -1,36 +1,49 @@ // @flow import React from 'react'; import { observer, inject } from 'mobx-react'; -import { Flex } from 'reflexbox'; +import styled from 'styled-components'; -import CollectionsStore from 'stores/CollectionsStore'; - -import Collection from 'components/Collection'; -import PreviewLoading from 'components/PreviewLoading'; +import DocumentsStore from 'stores/DocumentsStore'; +import DocumentList from 'components/DocumentList'; +import PageTitle from 'components/PageTitle'; import CenteredContent from 'components/CenteredContent'; +const Subheading = styled.h3` + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + color: #9FA6AB; + letter-spacing: 0.04em; + border-bottom: 1px solid #ddd; + padding-bottom: 10px; + margin-top: 30px; +`; + type Props = { - collections: CollectionsStore, + documents: DocumentsStore, }; @observer class Dashboard extends React.Component { props: Props; - render() { - const { collections } = this.props; + componentDidMount() { + this.props.documents.fetchAll(); + this.props.documents.fetchRecentlyViewed(); + } + render() { return ( - - {!collections.isLoaded - ? - : collections.data.map(collection => ( - - ))} - + +

Home

+ Recently viewed + + + Recently edited +
); } } -export default inject('collections')(Dashboard); +export default inject('documents')(Dashboard); diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index a535432fe..1cbdc9a56 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -7,8 +7,7 @@ import { withRouter, Prompt } from 'react-router'; import { Flex } from 'reflexbox'; import UiStore from 'stores/UiStore'; - -import DocumentStore from './DocumentStore'; +import DocumentsStore from 'stores/DocumentsStore'; import Menu from './components/Menu'; import Editor from 'components/Editor'; import { HeaderAction, SaveAction } from 'components/Layout'; @@ -27,76 +26,78 @@ type Props = { match: Object, history: Object, keydown: Object, + documents: DocumentsStore, newChildDocument?: boolean, ui: UiStore, }; @observer class Document extends Component { - store: DocumentStore; props: Props; - constructor(props: Props) { - super(props); - this.store = new DocumentStore({ - history: this.props.history, - ui: props.ui, - }); - } - componentDidMount() { this.loadDocument(this.props); } componentWillReceiveProps(nextProps) { - if (nextProps.match.params.id !== this.props.match.params.id) + if (nextProps.match.params.id !== this.props.match.params.id) { this.loadDocument(nextProps); - } - - loadDocument(props) { - if (props.newDocument) { - this.store.collectionId = props.match.params.id; - this.store.newDocument = true; - } else if (props.match.params.edit) { - this.store.documentId = props.match.params.id; - this.store.fetchDocument(); - } else if (props.newChildDocument) { - this.store.documentId = props.match.params.id; - this.store.newChildDocument = true; - this.store.fetchDocument(); - } else { - this.store.documentId = props.match.params.id; - this.store.newDocument = false; - this.store.fetchDocument(); } - - this.store.viewDocument(); } componentWillUnmount() { this.props.ui.clearActiveDocument(); } - onEdit = () => { - const url = `${this.store.document.url}/edit`; + loadDocument = async props => { + await this.props.documents.fetch(props.match.params.id); + const document = this.document; + + if (document) { + this.props.ui.setActiveDocument(document); + document.view(); + } + + if (this.props.match.params.edit) { + this.props.ui.enableEditMode(); + } else { + this.props.ui.disableEditMode(); + } + }; + + get document() { + return this.props.documents.getByUrl(`/d/${this.props.match.params.id}`); + } + + onClickEdit = () => { + if (!this.document) return; + const url = `${this.document.url}/edit`; this.props.history.push(url); this.props.ui.enableEditMode(); }; - onSave = async (options: { redirect?: boolean } = {}) => { - if (this.store.newDocument || this.store.newChildDocument) { - await this.store.saveDocument(options); - } else { - await this.store.updateDocument(options); - } + onSave = async (redirect: boolean = false) => { + const document = this.document; + + if (!document) return; + await document.save(); this.props.ui.disableEditMode(); + + if (redirect) { + this.props.history.push(document.url); + } }; - onImageUploadStart = () => { - this.store.updateUploading(true); - }; + onImageUploadStart() { + // TODO: How to set loading bar on layout? + } - onImageUploadStop = () => { - this.store.updateUploading(false); + onImageUploadStop() { + // TODO: How to set loading bar on layout? + } + + onChange = text => { + if (!this.document) return; + this.document.updateData({ text, hasPendingChanges: true }); }; onCancel = () => { @@ -106,69 +107,66 @@ type Props = { render() { const isNew = this.props.newDocument || this.props.newChildDocument; const isEditing = this.props.match.params.edit; - const titleText = this.store.document && get(this.store, 'document.title'); - - const actions = ( - - - {isEditing - ? - : Edit} - - - {!isEditing && - } - - ); + const isFetching = !this.document && get(this.document, 'isFetching'); + const titleText = get(this.document, 'title', 'Loading'); return ( {titleText && } - - - - {this.store.isFetching - ? - - - : this.store.document && - - - } - - {this.store.document && - - {!isEditing && - } - {!isEditing && - } - {actions} - } + {isFetching && + + + } + {!isFetching && + this.document && + + + + + + + {!isEditing && + } + {!isEditing && + } + + + {isEditing + ? + : Edit} + + {!isEditing && } + + + } ); } @@ -201,4 +199,4 @@ const DocumentContainer = styled.div` width: 50em; `; -export default withRouter(inject('ui')(Document)); +export default withRouter(inject('ui', 'documents')(Document)); diff --git a/frontend/scenes/Document/DocumentStore.js b/frontend/scenes/Document/DocumentStore.js deleted file mode 100644 index bcc595001..000000000 --- a/frontend/scenes/Document/DocumentStore.js +++ /dev/null @@ -1,186 +0,0 @@ -// @flow -import { observable, action, computed } from 'mobx'; -import get from 'lodash/get'; -import invariant from 'invariant'; -import { client } from 'utils/ApiClient'; -import emojify from 'utils/emojify'; -import Document from 'models/Document'; -import UiStore from 'stores/UiStore'; - -type SaveProps = { redirect?: boolean }; - -const parseHeader = text => { - const firstLine = text.split(/\r?\n/)[0]; - if (firstLine) { - const match = firstLine.match(/^#+ +(.*)$/); - - if (match) { - return emojify(match[1]); - } else { - return ''; - } - } - return ''; -}; - -type Options = { - history: Object, - ui: UiStore, -}; - -class DocumentStore { - document: Document; - @observable collapsedNodes: string[] = []; - @observable documentId = null; - @observable collectionId = null; - @observable parentDocument: Document; - @observable hasPendingChanges = false; - @observable newDocument: ?boolean; - @observable newChildDocument: ?boolean; - - @observable isEditing: boolean = false; - @observable isFetching: boolean = false; - @observable isSaving: boolean = false; - @observable isUploading: boolean = false; - - history: Object; - ui: UiStore; - - /* Computed */ - - @computed get isCollection(): boolean { - return !!this.document && this.document.collection.type === 'atlas'; - } - - /* Actions */ - - @action starDocument = async () => { - this.document.starred = true; - try { - await client.post('/documents.star', { - id: this.documentId, - }); - } catch (e) { - this.document.starred = false; - console.error('Something went wrong'); - } - }; - - @action unstarDocument = async () => { - this.document.starred = false; - try { - await client.post('/documents.unstar', { - id: this.documentId, - }); - } catch (e) { - this.document.starred = true; - console.error('Something went wrong'); - } - }; - - @action viewDocument = async () => { - await client.post('/views.create', { - id: this.documentId, - }); - }; - - @action fetchDocument = async () => { - this.isFetching = true; - - try { - const res = await client.get('/documents.info', { - id: this.documentId, - }); - invariant(res && res.data, 'Data should be available'); - if (this.newChildDocument) { - this.parentDocument = res.data; - } else { - this.document = new Document(res.data); - this.ui.setActiveDocument(this.document); - } - } catch (e) { - console.error('Something went wrong'); - } - this.isFetching = false; - }; - - @action saveDocument = async ({ redirect = true }: SaveProps) => { - if (this.isSaving) return; - - this.isSaving = true; - - try { - const res = await client.post('/documents.create', { - parentDocument: get(this.parentDocument, 'id'), - collection: get( - this.parentDocument, - 'collection.id', - this.collectionId - ), - title: get(this.document, 'title', 'Untitled document'), - text: get(this.document, 'text'), - }); - invariant(res && res.data, 'Data should be available'); - const { url } = res.data; - - this.hasPendingChanges = false; - if (redirect) this.history.push(url); - } catch (e) { - console.error('Something went wrong'); - } - this.isSaving = false; - }; - - @action updateDocument = async ({ redirect = true }: SaveProps) => { - if (this.isSaving) return; - - this.isSaving = true; - - try { - const res = await client.post('/documents.update', { - id: this.documentId, - title: get(this.document, 'title', 'Untitled document'), - text: get(this.document, 'text'), - }); - invariant(res && res.data, 'Data should be available'); - const { url } = res.data; - - this.hasPendingChanges = false; - if (redirect) this.history.push(url); - } catch (e) { - console.error('Something went wrong'); - } - this.isSaving = false; - }; - - @action deleteDocument = async () => { - this.isFetching = true; - - try { - await client.post('/documents.delete', { id: this.documentId }); - this.history.push(this.document.collection.url); - } catch (e) { - console.error('Something went wrong'); - } - this.isFetching = false; - }; - - @action updateText = (text: string) => { - if (!this.document) return; - - this.document.text = text; - this.document.title = parseHeader(text); - this.hasPendingChanges = true; - }; - - @action updateUploading = (uploading: boolean) => { - this.isUploading = uploading; - }; - - constructor(options: Options) { - this.history = options.history; - this.ui = options.ui; - } -} - -export default DocumentStore; diff --git a/frontend/scenes/Document/components/Menu.js b/frontend/scenes/Document/components/Menu.js index 6e044b59b..76ac51a91 100644 --- a/frontend/scenes/Document/components/Menu.js +++ b/frontend/scenes/Document/components/Menu.js @@ -4,14 +4,12 @@ import invariant from 'invariant'; import get from 'lodash/get'; import { withRouter } from 'react-router-dom'; import { observer } from 'mobx-react'; -import type { Document as DocumentType } from 'types'; +import Document from 'models/Document'; import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu'; -import DocumentStore from '../DocumentStore'; type Props = { history: Object, - document: DocumentType, - store: DocumentStore, + document: Document, }; @observer class Menu extends Component { @@ -38,7 +36,7 @@ type Props = { } if (confirm(msg)) { - this.props.store.deleteDocument(); + this.props.document.delete(); } }; diff --git a/frontend/scenes/Search/Search.js b/frontend/scenes/Search/Search.js index 543992e68..34c35b22e 100644 --- a/frontend/scenes/Search/Search.js +++ b/frontend/scenes/Search/Search.js @@ -109,6 +109,7 @@ const ResultsWrapper = styled(Flex)` innerRef={ref => index === 0 && this.setFirstDocumentRef(ref)} key={document.id} document={document} + highlight={this.store.searchTerm} /> ))} diff --git a/frontend/scenes/Settings/Settings.js b/frontend/scenes/Settings/Settings.js index 709a7dfd8..9c27d69e6 100644 --- a/frontend/scenes/Settings/Settings.js +++ b/frontend/scenes/Settings/Settings.js @@ -1,13 +1,14 @@ // @flow import React from 'react'; import { observer } from 'mobx-react'; -import styled from 'styled-components'; import { Flex } from 'reflexbox'; import ApiKeyRow from './components/ApiKeyRow'; import styles from './Settings.scss'; import SettingsStore from './SettingsStore'; +import Button from 'components/Button'; +import Input from 'components/Input'; import CenteredContent from 'components/CenteredContent'; import SlackAuthLink from 'components/SlackAuthLink'; import PageTitle from 'components/PageTitle'; @@ -133,7 +134,7 @@ class InlineForm extends React.Component { return (
- (props.validationError ? 'red' : 'rgba(0, 0, 0, .25)')}; - border-radius:2px 0 0 2px; -`; - -const Button = styled.input` - box-shadow:inset 0 0 0 1px; - font-family:inherit; - font-size:14px; - line-height:16px; - min-height:32px; - text-decoration:none; - display:inline-block; - margin:0; - padding-top:8px; - padding-bottom:8px; - padding-left:16px; - padding-right:16px; - cursor:pointer; - border:0; - color:black; - background-color:transparent; - border-radius:0 2px 2px 0; - margin-left:-1px; -`; - export default Settings; diff --git a/frontend/scenes/Starred/Starred.js b/frontend/scenes/Starred/Starred.js index 71cf53d88..a0cbd919c 100644 --- a/frontend/scenes/Starred/Starred.js +++ b/frontend/scenes/Starred/Starred.js @@ -1,38 +1,29 @@ // @flow import React, { Component } from 'react'; -import { observer } from 'mobx-react'; -import styled from 'styled-components'; +import { observer, inject } from 'mobx-react'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; import DocumentList from 'components/DocumentList'; -import StarredStore from './StarredStore'; - -const Container = styled(CenteredContent)` - width: 100%; - padding: 16px; -`; +import DocumentsStore from 'stores/DocumentsStore'; @observer class Starred extends Component { - store: StarredStore; - - constructor() { - super(); - this.store = new StarredStore(); - } + props: { + documents: DocumentsStore, + }; componentDidMount() { - this.store.fetchDocuments(); + this.props.documents.fetchStarred(); } render() { return ( - +

Starred

- -
+ + ); } } -export default Starred; +export default inject('documents')(Starred); diff --git a/frontend/scenes/Starred/StarredStore.js b/frontend/scenes/Starred/StarredStore.js deleted file mode 100644 index 8fe6bd02a..000000000 --- a/frontend/scenes/Starred/StarredStore.js +++ /dev/null @@ -1,29 +0,0 @@ -// @flow -import { observable, action, runInAction } from 'mobx'; -import invariant from 'invariant'; -import { client } from 'utils/ApiClient'; -import type { Document } from 'types'; - -class StarredDocumentsStore { - @observable documents: Array = []; - @observable isFetching = false; - - @action fetchDocuments = async () => { - this.isFetching = true; - - try { - const res = await client.get('/documents.starred'); - invariant(res && res.data, 'res or res.data missing'); - const { data } = res; - runInAction('update state after fetching data', () => { - this.documents = data; - }); - } catch (e) { - console.error('Something went wrong'); - } - - this.isFetching = false; - }; -} - -export default StarredDocumentsStore; diff --git a/frontend/stores/CollectionsStore.js b/frontend/stores/CollectionsStore.js index 2f9d2dafe..ae8e26446 100644 --- a/frontend/stores/CollectionsStore.js +++ b/frontend/stores/CollectionsStore.js @@ -22,7 +22,7 @@ class CollectionsStore { /* Actions */ - @action fetch = async (): Promise<*> => { + @action fetchAll = async (): Promise<*> => { try { const res = await this.client.post('/collections.list', { id: this.teamId, diff --git a/frontend/stores/CollectionsStore.test.js b/frontend/stores/CollectionsStore.test.js index 2694a9dc5..ef39ccbe0 100644 --- a/frontend/stores/CollectionsStore.test.js +++ b/frontend/stores/CollectionsStore.test.js @@ -27,7 +27,7 @@ describe('CollectionsStore', () => { })), }; - await store.fetch(); + await store.fetchAll(); expect(store.client.post).toHaveBeenCalledWith('/collections.list', { id: 123, @@ -44,7 +44,7 @@ describe('CollectionsStore', () => { add: jest.fn(), }; - await store.fetch(); + await store.fetchAll(); expect(store.errors.add).toHaveBeenCalledWith( 'Failed to load collections' diff --git a/frontend/stores/DocumentsStore.js b/frontend/stores/DocumentsStore.js new file mode 100644 index 000000000..c33195a3c --- /dev/null +++ b/frontend/stores/DocumentsStore.js @@ -0,0 +1,93 @@ +// @flow +import { observable, action, ObservableMap, runInAction } from 'mobx'; +import { client } from 'utils/ApiClient'; +import _ from 'lodash'; +import invariant from 'invariant'; + +import stores from 'stores'; +import Document from 'models/Document'; +import ErrorsStore from 'stores/ErrorsStore'; + +class DocumentsStore { + @observable recentlyViewedIds: Array = []; + @observable data: Map = new ObservableMap([]); + @observable isLoaded: boolean = false; + errors: ErrorsStore; + + /* Actions */ + + @action fetchAll = async (request: string = 'list'): Promise<*> => { + try { + const res = await client.post(`/documents.${request}`); + invariant(res && res.data, 'Document list not available'); + const { data } = res; + runInAction('DocumentsStore#fetchAll', () => { + data.forEach(document => { + this.data.set(document.id, new Document(document)); + }); + this.isLoaded = true; + }); + return data; + } catch (e) { + this.errors.add('Failed to load documents'); + } + }; + + @action fetchRecentlyViewed = async (): Promise<*> => { + const data = await this.fetchAll('viewed'); + + runInAction('DocumentsStore#fetchRecentlyViewed', () => { + this.recentlyViewedIds = _.map(data, 'id'); + }); + }; + + @action fetchStarred = async (): Promise<*> => { + await this.fetchAll('starred'); + }; + + @action fetch = async (id: string): Promise<*> => { + try { + const res = await client.post('/documents.info', { id }); + invariant(res && res.data, 'Document not available'); + const { data } = res; + runInAction('DocumentsStore#fetch', () => { + this.data.set(data.id, new Document(data)); + this.isLoaded = true; + }); + } catch (e) { + this.errors.add('Failed to load documents'); + } + }; + + @action add = (document: Document): void => { + this.data.set(document.id, document); + }; + + @action remove = (id: string): void => { + this.data.delete(id); + }; + + getStarred = () => { + return _.filter(this.data.values(), 'starred'); + }; + + getRecentlyViewed = () => { + return _.filter(this.data.values(), ({ id }) => + this.recentlyViewedIds.includes(id) + ); + }; + + getById = (id: string): ?Document => { + return this.data.get(id); + }; + + getByUrl = (url: string): ?Document => { + return _.find(this.data.values(), { url }); + }; + + constructor() { + this.errors = stores.errors; + } +} + +export default DocumentsStore; diff --git a/frontend/stores/UiStore.js b/frontend/stores/UiStore.js index 74068d0dd..48327a7bd 100644 --- a/frontend/stores/UiStore.js +++ b/frontend/stores/UiStore.js @@ -1,6 +1,6 @@ // @flow import { observable, action, computed } from 'mobx'; -import type { Document } from 'types'; +import Document from 'models/Document'; import Collection from 'models/Collection'; class UiStore { diff --git a/frontend/styles/base.scss b/frontend/styles/base.scss index c7bd81533..3fcc4acd2 100644 --- a/frontend/styles/base.scss +++ b/frontend/styles/base.scss @@ -55,6 +55,7 @@ h4, h5, h6 { line-height: 1.25; margin-top: 1em; margin-bottom: .5em; + color: #1f2429; } h1 { font-size: 2em } h2 { font-size: 1.5em } diff --git a/frontend/utils/setupJest.js b/frontend/utils/setupJest.js index dcd1f0750..61b717c2f 100644 --- a/frontend/utils/setupJest.js +++ b/frontend/utils/setupJest.js @@ -2,10 +2,12 @@ import React from 'react'; import { shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; +import localStorage from '../../__mocks__/localStorage'; const snap = children => { const wrapper = shallow(children); expect(toJson(wrapper)).toMatchSnapshot(); }; +global.localStorage = localStorage; global.snap = snap; diff --git a/package.json b/package.json index 404c6f5cb..3ce6affae 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "normalizr": "2.0.1", "pg": "^6.1.5", "pg-hstore": "2.3.2", + "polished": "^1.2.1", "query-string": "^4.3.4", "randomstring": "1.1.5", "raw-loader": "^0.5.1", diff --git a/yarn.lock b/yarn.lock index aa035e59b..27043076b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6663,6 +6663,10 @@ pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" +polished@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/polished/-/polished-1.2.1.tgz#83c18a85bf9d7023477cfc7049763b657d50f0f7" + postcss-calc@^5.2.0: version "5.3.1" resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"