diff --git a/__mocks__/localStorage.js b/__mocks__/localStorage.js new file mode 100644 index 00000000..3d8394b9 --- /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/DocumentList/DocumentList.js b/frontend/components/DocumentList/DocumentList.js index bfd0b2d4..4ca8b63a 100644 --- a/frontend/components/DocumentList/DocumentList.js +++ b/frontend/components/DocumentList/DocumentList.js @@ -1,6 +1,6 @@ // @flow import React from 'react'; -import type { Document } from 'types'; +import Document from 'models/Document'; import DocumentPreview from 'components/DocumentPreview'; class DocumentList extends React.Component { @@ -13,7 +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 6b974e20..c6d34b89 100644 --- a/frontend/components/DocumentPreview/DocumentPreview.js +++ b/frontend/components/DocumentPreview/DocumentPreview.js @@ -1,7 +1,7 @@ // @flow import React, { Component } from 'react'; 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 PublishingInfo from 'components/PublishingInfo'; diff --git a/frontend/index.js b/frontend/index.js index c09fac02..0f050e1a 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 1c69baee..40fbd91b 100644 --- a/frontend/models/Document.js +++ b/frontend/models/Document.js @@ -2,7 +2,7 @@ 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'; @@ -25,8 +25,6 @@ class Document { updatedBy: User; url: string; views: number; - - client: ApiClient; errors: ErrorsStore; /* Computed */ @@ -58,7 +56,7 @@ class Document { @action update = async () => { try { - const res = await this.client.post('/documents.info', { id: this.id }); + 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', () => { @@ -75,7 +73,6 @@ class Document { 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 00000000..e7db05b7 --- /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 c36f4239..54410b2f 100644 --- a/frontend/scenes/Dashboard/Dashboard.js +++ b/frontend/scenes/Dashboard/Dashboard.js @@ -1,13 +1,12 @@ // @flow import React from 'react'; -import { observer } from 'mobx-react'; +import { observer, inject } from 'mobx-react'; import styled from 'styled-components'; +import DocumentsStore from 'stores/DocumentsStore'; import DocumentList from 'components/DocumentList'; import PageTitle from 'components/PageTitle'; import CenteredContent from 'components/CenteredContent'; -import ViewedDocumentsStore from './ViewedDocumentsStore'; -import EditedDocumentsStore from './EditedDocumentsStore'; const Subheading = styled.h3` font-size: 11px; @@ -20,22 +19,16 @@ const Subheading = styled.h3` margin-top: 30px; `; -type Props = {}; +type Props = { + documents: DocumentsStore, +}; @observer class Dashboard extends React.Component { props: Props; - viewedStore: ViewedDocumentsStore; - editedStore: EditedDocumentsStore; - - constructor(props: Props) { - super(props); - this.viewedStore = new ViewedDocumentsStore(); - this.editedStore = new EditedDocumentsStore(); - } componentDidMount() { - this.viewedStore.fetchDocuments(); - this.editedStore.fetchDocuments(); + this.props.documents.fetchAll(); + this.props.documents.fetchRecentlyViewed(); } render() { @@ -44,13 +37,13 @@ type Props = {};

Home

Recently viewed - + Recently edited - + ); } } -export default Dashboard; +export default inject('documents')(Dashboard); diff --git a/frontend/scenes/Dashboard/EditedDocumentsStore.js b/frontend/scenes/Dashboard/EditedDocumentsStore.js deleted file mode 100644 index 477500e1..00000000 --- a/frontend/scenes/Dashboard/EditedDocumentsStore.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 EditedDocumentsStore { - @observable documents: Array = []; - @observable isFetching = false; - - @action fetchDocuments = async () => { - this.isFetching = true; - - try { - const res = await client.get('/documents.list'); - 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 EditedDocumentsStore; diff --git a/frontend/scenes/Dashboard/ViewedDocumentsStore.js b/frontend/scenes/Dashboard/ViewedDocumentsStore.js deleted file mode 100644 index 147c85a1..00000000 --- a/frontend/scenes/Dashboard/ViewedDocumentsStore.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 ViewedDocumentsStore { - @observable documents: Array = []; - @observable isFetching = false; - - @action fetchDocuments = async () => { - this.isFetching = true; - - try { - const res = await client.get('/documents.viewed'); - 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 ViewedDocumentsStore; diff --git a/frontend/scenes/Starred/Starred.js b/frontend/scenes/Starred/Starred.js index 5c51b244..a0cbd919 100644 --- a/frontend/scenes/Starred/Starred.js +++ b/frontend/scenes/Starred/Starred.js @@ -1,21 +1,18 @@ // @flow import React, { Component } from 'react'; -import { observer } from 'mobx-react'; +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'; +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() { @@ -23,10 +20,10 @@ import StarredStore from './StarredStore';

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 8fe6bd02..00000000 --- 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 2f9d2daf..ae8e2644 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 2694a9dc..ef39ccbe 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 00000000..d72ffa5d --- /dev/null +++ b/frontend/stores/DocumentsStore.js @@ -0,0 +1,89 @@ +// @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); + }; + + constructor() { + this.errors = stores.errors; + } +} + +export default DocumentsStore; diff --git a/frontend/stores/UiStore.js b/frontend/stores/UiStore.js index 74068d0d..48327a7b 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/utils/setupJest.js b/frontend/utils/setupJest.js index dcd1f075..61b717c2 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;