diff --git a/app/components/Auth.js b/app/components/Auth.js index e677673a..8952f87c 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -31,7 +31,9 @@ const Auth = observer(({ auth, children }: Props) => { // Stores for authenticated user const cache = new CacheStore(user.id); authenticatedStores = { - integrations: new IntegrationsStore(), + integrations: new IntegrationsStore({ + ui: stores.ui, + }), apiKeys: new ApiKeysStore(), users: new UsersStore(), collections: new CollectionsStore({ diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts/Toasts.js index 3d7656aa..2e5ff333 100644 --- a/app/components/Toasts/Toasts.js +++ b/app/components/Toasts/Toasts.js @@ -8,15 +8,15 @@ import Toast from './components/Toast'; @observer class Toasts extends React.Component<*> { handleClose = index => { - this.props.errors.remove(index); + this.props.ui.remove(index); }; render() { - const { errors } = this.props; + const { ui } = this.props; return ( - {errors.data.map((error, index) => ( + {ui.toasts.map((error, index) => ( { if (data.collectionId === this.id) this.fetch(); diff --git a/app/models/Collection.test.js b/app/models/Collection.test.js index 22d0cdf4..5af3f673 100644 --- a/app/models/Collection.test.js +++ b/app/models/Collection.test.js @@ -28,22 +28,5 @@ describe('Collection model', () => { expect(client.post).toHaveBeenCalledWith('/collections.info', { id: 123 }); expect(collection.name).toBe('New collection'); }); - - test('should report errors', async () => { - client.post = jest.fn(() => Promise.reject()) - - const collection = new Collection({ - id: 123, - }); - collection.errors = { - add: jest.fn(), - }; - - await collection.fetch(); - - expect(collection.errors.add).toHaveBeenCalledWith( - 'Collection failed loading' - ); - }); }); }); diff --git a/app/models/Document.js b/app/models/Document.js index ba17afbe..d80ddd3d 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -4,7 +4,7 @@ import invariant from 'invariant'; import { client } from 'utils/ApiClient'; import stores from 'stores'; -import ErrorsStore from 'stores/ErrorsStore'; +import UiStore from 'stores/UiStore'; import parseTitle from '../../shared/utils/parseTitle'; import type { User } from 'types'; @@ -16,7 +16,7 @@ type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean }; class Document extends BaseModel { isSaving: boolean = false; hasPendingChanges: boolean = false; - errors: ErrorsStore; + ui: UiStore; collaborators: User[]; collection: $Shape; @@ -107,7 +107,7 @@ class Document extends BaseModel { this.shareUrl = res.data.url; } catch (e) { - this.errors.add('Document failed to share'); + this.ui.showToast('Document failed to share'); } }; @@ -118,7 +118,7 @@ class Document extends BaseModel { await client.post('/documents.pin', { id: this.id }); } catch (e) { this.pinned = false; - this.errors.add('Document failed to pin'); + this.ui.showToast('Document failed to pin'); } }; @@ -129,7 +129,7 @@ class Document extends BaseModel { await client.post('/documents.unpin', { id: this.id }); } catch (e) { this.pinned = true; - this.errors.add('Document failed to unpin'); + this.ui.showToast('Document failed to unpin'); } }; @@ -140,7 +140,7 @@ class Document extends BaseModel { await client.post('/documents.star', { id: this.id }); } catch (e) { this.starred = false; - this.errors.add('Document failed star'); + this.ui.showToast('Document failed star'); } }; @@ -151,7 +151,7 @@ class Document extends BaseModel { await client.post('/documents.unstar', { id: this.id }); } catch (e) { this.starred = false; - this.errors.add('Document failed unstar'); + this.ui.showToast('Document failed unstar'); } }; @@ -161,7 +161,7 @@ class Document extends BaseModel { try { await client.post('/views.create', { id: this.id }); } catch (e) { - this.errors.add('Document failed to record view'); + this.ui.showToast('Document failed to record view'); } }; @@ -175,7 +175,7 @@ class Document extends BaseModel { this.updateData(data); }); } catch (e) { - this.errors.add('Document failed loading'); + this.ui.showToast('Document failed loading'); } }; @@ -228,7 +228,7 @@ class Document extends BaseModel { }); } } catch (e) { - this.errors.add('Document failed to save'); + this.ui.showToast('Document failed to save'); } finally { this.isSaving = false; } @@ -250,7 +250,7 @@ class Document extends BaseModel { collectionId: this.collection.id, }); } catch (e) { - this.errors.add('Error while moving the document'); + this.ui.showToast('Error while moving the document'); } return; }; @@ -265,7 +265,7 @@ class Document extends BaseModel { }); return true; } catch (e) { - this.errors.add('Error while deleting the document'); + this.ui.showToast('Error while deleting the document'); } return false; }; @@ -294,7 +294,7 @@ class Document extends BaseModel { super(); this.updateData(data); - this.errors = stores.errors; + this.ui = stores.ui; } } diff --git a/app/models/Integration.js b/app/models/Integration.js index 415e2f62..b1f67b34 100644 --- a/app/models/Integration.js +++ b/app/models/Integration.js @@ -4,7 +4,7 @@ import { extendObservable, action } from 'mobx'; import BaseModel from 'models/BaseModel'; import { client } from 'utils/ApiClient'; import stores from 'stores'; -import ErrorsStore from 'stores/ErrorsStore'; +import UiStore from 'stores/UiStore'; type Settings = { url: string, @@ -15,7 +15,7 @@ type Settings = { type Events = 'documents.create' | 'collections.create'; class Integration extends BaseModel { - errors: ErrorsStore; + ui: UiStore; id: string; serviceId: string; @@ -29,7 +29,7 @@ class Integration extends BaseModel { await client.post('/integrations.update', { id: this.id, ...data }); extendObservable(this, data); } catch (e) { - this.errors.add('Integration failed to update'); + this.ui.showToast('Integration failed to update'); } return false; }; @@ -41,7 +41,7 @@ class Integration extends BaseModel { this.emit('integrations.delete', { id: this.id }); return true; } catch (e) { - this.errors.add('Integration failed to delete'); + this.ui.showToast('Integration failed to delete'); } return false; }; @@ -50,7 +50,7 @@ class Integration extends BaseModel { super(); extendObservable(this, data); - this.errors = stores.errors; + this.ui = stores.ui; } } diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js index 80c3b81e..86a8adbe 100644 --- a/app/scenes/Settings/Profile.js +++ b/app/scenes/Settings/Profile.js @@ -1,14 +1,11 @@ // @flow import * as React from 'react'; -import { observable, runInAction } from 'mobx'; +import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; -import invariant from 'invariant'; import styled from 'styled-components'; import { color, size } from 'shared/styles/constants'; -import { client } from 'utils/ApiClient'; import AuthStore from 'stores/AuthStore'; -import ErrorsStore from 'stores/ErrorsStore'; import ImageUpload from './components/ImageUpload'; import Input, { LabelText } from 'components/Input'; import Button from 'components/Button'; @@ -18,7 +15,6 @@ import Flex from 'shared/components/Flex'; type Props = { auth: AuthStore, - errors: ErrorsStore, }; @observer @@ -27,8 +23,6 @@ class Profile extends React.Component { @observable name: string; @observable avatarUrl: ?string; - @observable isUpdated: boolean; - @observable isSaving: boolean; componentDidMount() { if (this.props.auth.user) { @@ -42,25 +36,11 @@ class Profile extends React.Component { handleSubmit = async (ev: SyntheticEvent<*>) => { ev.preventDefault(); - this.isSaving = true; - try { - const res = await client.post(`/user.update`, { - name: this.name, - avatarUrl: this.avatarUrl, - }); - invariant(res && res.data, 'User response not available'); - const { data } = res; - runInAction('Settings#handleSubmit', () => { - this.props.auth.user = data; - this.isUpdated = true; - this.timeout = setTimeout(() => (this.isUpdated = false), 2500); - }); - } catch (e) { - this.props.errors.add('Failed to update user'); - } finally { - this.isSaving = false; - } + await this.props.auth.updateUser({ + name: this.name, + avatarUrl: this.avatarUrl, + }); }; handleNameChange = (ev: SyntheticInputEvent<*>) => { @@ -72,7 +52,7 @@ class Profile extends React.Component { }; handleAvatarError = (error: ?string) => { - this.props.errors.add(error || 'Unable to upload new avatar'); + this.props.ui.showToast(error || 'Unable to upload new avatar'); }; render() { @@ -85,7 +65,7 @@ class Profile extends React.Component {

Profile

- Profile picture + Picture { - - Profile updated! - ); } } -const SuccessMessage = styled.span` - margin-left: ${size.large}; - color: ${color.slate}; - opacity: ${props => (props.visible ? 1 : 0)}; - - transition: opacity 0.25s; -`; - const ProfilePicture = styled(Flex)` margin-bottom: ${size.huge}; `; const avatarStyles = ` - width: 150px; - height: 150px; - border-radius: 50%; + width: 80px; + height: 80px; + border-radius: 10px; `; const AvatarContainer = styled(Flex)` @@ -166,4 +135,4 @@ const StyledInput = styled(Input)` max-width: 350px; `; -export default inject('auth', 'errors')(Profile); +export default inject('auth', 'ui')(Profile); diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index c764e482..b2155069 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -49,6 +49,16 @@ class AuthStore { } }; + @action + updateUser = async (params: { name: string, avatarUrl?: string }) => { + const res = await client.post(`/user.update`, params); + invariant(res && res.data, 'User response not available'); + + runInAction('AuthStore#updateUser', () => { + this.user = res.data.user; + }); + }; + @action logout = async () => { this.user = null; diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index 50b95445..bc2e84ea 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -6,7 +6,6 @@ import invariant from 'invariant'; import stores from 'stores'; import BaseStore from './BaseStore'; -import ErrorsStore from './ErrorsStore'; import UiStore from './UiStore'; import Collection from 'models/Collection'; import naturalSort from 'shared/utils/naturalSort'; @@ -32,7 +31,6 @@ class CollectionsStore extends BaseStore { @observable isLoaded: boolean = false; @observable isFetching: boolean = false; - errors: ErrorsStore; ui: UiStore; @computed @@ -106,7 +104,7 @@ class CollectionsStore extends BaseStore { }); return res; } catch (e) { - this.errors.add('Failed to load collections'); + this.ui.showToast('Failed to load collections'); } finally { this.isFetching = false; } @@ -134,7 +132,7 @@ class CollectionsStore extends BaseStore { return collection; } catch (e) { - this.errors.add('Something went wrong'); + this.ui.showToast('Something went wrong'); } finally { this.isFetching = false; } @@ -156,7 +154,6 @@ class CollectionsStore extends BaseStore { constructor(options: Options) { super(); - this.errors = stores.errors; this.ui = options.ui; this.on('collections.delete', (data: { id: string }) => { diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index 08ce8004..63452b5a 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -6,7 +6,6 @@ import invariant from 'invariant'; import BaseStore from 'stores/BaseStore'; import Document from 'models/Document'; -import ErrorsStore from 'stores/ErrorsStore'; import UiStore from 'stores/UiStore'; import type { PaginationParams } from 'types'; @@ -14,7 +13,6 @@ export const DEFAULT_PAGINATION_LIMIT = 25; type Options = { ui: UiStore, - errors: ErrorsStore, }; type FetchOptions = { @@ -29,7 +27,6 @@ class DocumentsStore extends BaseStore { @observable isLoaded: boolean = false; @observable isFetching: boolean = false; - errors: ErrorsStore; ui: UiStore; /* Computed */ @@ -114,7 +111,7 @@ class DocumentsStore extends BaseStore { }); return data; } catch (e) { - this.errors.add('Failed to load documents'); + this.ui.showToast('Failed to load documents'); } finally { this.isFetching = false; } @@ -200,7 +197,7 @@ class DocumentsStore extends BaseStore { return document; } catch (e) { - this.errors.add('Failed to load document'); + this.ui.showToast('Failed to load document'); } finally { this.isFetching = false; } @@ -230,7 +227,6 @@ class DocumentsStore extends BaseStore { constructor(options: Options) { super(); - this.errors = options.errors; this.ui = options.ui; this.on('documents.delete', (data: { id: string }) => { diff --git a/app/stores/ErrorsStore.js b/app/stores/ErrorsStore.js deleted file mode 100644 index eac503e7..00000000 --- a/app/stores/ErrorsStore.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow -import { observable, action } from 'mobx'; - -class ErrorsStore { - @observable data = observable.array([]); - - /* Actions */ - - @action - add = (message: string): void => { - this.data.push(message); - }; - - @action - remove = (index: number): void => { - this.data.splice(index, 1); - }; -} - -export default ErrorsStore; diff --git a/app/stores/ErrorsStore.test.js b/app/stores/ErrorsStore.test.js deleted file mode 100644 index daa72a01..00000000 --- a/app/stores/ErrorsStore.test.js +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable */ -import ErrorsStore from './ErrorsStore'; - -// Actions -describe('ErrorsStore', () => { - let store; - - beforeEach(() => { - store = new ErrorsStore(); - }); - - test('#add should add errors', () => { - expect(store.data.length).toBe(0); - store.add('first error'); - store.add('second error'); - expect(store.data.length).toBe(2); - }); - - test('#remove should remove errors', () => { - store.add('first error'); - store.add('second error'); - expect(store.data.length).toBe(2); - store.remove(0); - expect(store.data.length).toBe(1); - expect(store.data[0]).toBe('second error'); - }); -}); diff --git a/app/stores/IntegrationsStore.js b/app/stores/IntegrationsStore.js index 02a1fd22..5bc4a391 100644 --- a/app/stores/IntegrationsStore.js +++ b/app/stores/IntegrationsStore.js @@ -3,8 +3,7 @@ import { observable, computed, action, runInAction, ObservableMap } from 'mobx'; import { client } from 'utils/ApiClient'; import _ from 'lodash'; import invariant from 'invariant'; -import stores from './'; -import ErrorsStore from './ErrorsStore'; +import UiStore from './UiStore'; import BaseStore from './BaseStore'; import Integration from 'models/Integration'; @@ -15,7 +14,7 @@ class IntegrationsStore extends BaseStore { @observable isLoaded: boolean = false; @observable isFetching: boolean = false; - errors: ErrorsStore; + ui: UiStore; @computed get orderedData(): Integration[] { @@ -43,7 +42,7 @@ class IntegrationsStore extends BaseStore { }); return res; } catch (e) { - this.errors.add('Failed to load integrations'); + this.ui.showToast('Failed to load integrations'); } finally { this.isFetching = false; } @@ -63,9 +62,9 @@ class IntegrationsStore extends BaseStore { return this.data.get(id); }; - constructor() { + constructor(options: { ui: UiStore }) { super(); - this.errors = stores.errors; + this.ui = options.ui; this.on('integrations.delete', (data: { id: string }) => { this.remove(data.id); diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index 109ee725..7caff983 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -11,6 +11,7 @@ class UiStore { @observable progressBarVisible: boolean = false; @observable editMode: boolean = false; @observable mobileSidebarVisible: boolean = false; + @observable toasts: string[] = observable.array([]); /* Actions */ @action @@ -79,6 +80,16 @@ class UiStore { hideMobileSidebar() { this.mobileSidebarVisible = false; } + + @action + showToast = (message: string): void => { + this.toasts.push(message); + }; + + @action + removeToast = (index: number): void => { + this.toasts.splice(index, 1); + }; } export default UiStore; diff --git a/app/stores/UiStore.test.js b/app/stores/UiStore.test.js new file mode 100644 index 00000000..899e6670 --- /dev/null +++ b/app/stores/UiStore.test.js @@ -0,0 +1,27 @@ +/* eslint-disable */ +import UiStore from './UiStore'; + +// Actions +describe('UiStore', () => { + let store; + + beforeEach(() => { + store = new UiStore(); + }); + + test('#add should add errors', () => { + expect(store.data.length).toBe(0); + store.showToast('first error'); + store.showToast('second error'); + expect(store.toasts.length).toBe(2); + }); + + test('#remove should remove errors', () => { + store.showToast('first error'); + store.showToast('second error'); + expect(store.toasts.length).toBe(2); + store.removeToast(0); + expect(store.toasts.length).toBe(1); + expect(store.toasts[0]).toBe('second error'); + }); +}); diff --git a/app/stores/index.js b/app/stores/index.js index 68d49ca9..7065b456 100644 --- a/app/stores/index.js +++ b/app/stores/index.js @@ -1,18 +1,15 @@ // @flow import AuthStore from './AuthStore'; import UiStore from './UiStore'; -import ErrorsStore from './ErrorsStore'; import DocumentsStore from './DocumentsStore'; import SharesStore from './SharesStore'; const ui = new UiStore(); -const errors = new ErrorsStore(); const stores = { user: null, // Including for Layout auth: new AuthStore(), ui, - errors, - documents: new DocumentsStore({ ui, errors }), + documents: new DocumentsStore({ ui }), shares: new SharesStore(), }; diff --git a/package.json b/package.json index 40ef2e46..7e4dda92 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json > stats.json", "build": "npm run clean && npm run build:webpack", "start": "NODE_ENV=production node index.js", - "dev": "NODE_ENV=development nodemon --watch server index.js", + "dev": "NODE_ENV=development node index.js", "lint": "npm run lint:flow && npm run lint:js", "lint:js": "eslint app server", "lint:flow": "flow", diff --git a/webpack.config.js b/webpack.config.js index 0c7bc699..cc67d1a7 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -34,7 +34,10 @@ module.exports = { include: [ path.join(__dirname, 'app'), path.join(__dirname, 'shared'), - ] + ], + options: { + cacheDirectory: true + } }, { test: /\.json$/, loader: 'json-loader' }, // inline base64 URLs for <=8k images, direct URLs for the rest