frontend > app

This commit is contained in:
Tom Moor
2017-10-25 22:49:04 -07:00
parent aa34db8318
commit 4863680d86
239 changed files with 11 additions and 11 deletions

89
app/stores/AuthStore.js Normal file
View File

@@ -0,0 +1,89 @@
// @flow
import { observable, action, computed, autorunAsync } from 'mobx';
import invariant from 'invariant';
import Cookie from 'js-cookie';
import { client } from 'utils/ApiClient';
import type { User, Team } from 'types';
const AUTH_STORE = 'AUTH_STORE';
class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable oauthState: string;
@observable isLoading: boolean = false;
/* Computed */
@computed get authenticated(): boolean {
return !!this.token;
}
@computed get asJson(): string {
return JSON.stringify({
user: this.user,
team: this.team,
token: this.token,
oauthState: this.oauthState,
});
}
/* Actions */
@action logout = () => {
this.user = null;
this.token = null;
Cookie.remove('loggedIn', { path: '/' });
};
@action getOauthState = () => {
const state = Math.random().toString(36).substring(7);
this.oauthState = state;
return this.oauthState;
};
@action authWithSlack = async (code: string, state: string) => {
if (state !== this.oauthState) {
return {
success: false,
};
}
let res;
try {
res = await client.post('/auth.slack', { code });
} catch (e) {
return {
success: false,
};
}
invariant(
res && res.data && res.data.user && res.data.team && res.data.accessToken,
'All values should be available'
);
this.user = res.data.user;
this.team = res.data.team;
this.token = res.data.accessToken;
return {
success: true,
};
};
constructor() {
// Rehydrate
const data = JSON.parse(localStorage.getItem(AUTH_STORE) || '{}');
this.user = data.user;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
autorunAsync(() => {
localStorage.setItem(AUTH_STORE, this.asJson);
});
}
}
export default AuthStore;

19
app/stores/BaseStore.js Normal file
View File

@@ -0,0 +1,19 @@
// @flow
import { EventEmitter } from 'fbemitter';
import _ from 'lodash';
const emitter = new EventEmitter();
window.__emitter = emitter;
class BaseStore {
emitter: EventEmitter;
on: (eventName: string, callback: Function) => void;
emit: (eventName: string, data: any) => void;
constructor() {
_.extend(this, emitter);
this.on = emitter.addListener;
}
}
export default BaseStore;

28
app/stores/CacheStore.js Normal file
View File

@@ -0,0 +1,28 @@
// @flow
import localForage from 'localforage';
class CacheStore {
key: string;
cacheKey = (key: string): string => {
return `CACHE_${this.key}_${key}`;
};
getItem = (key: string): any => {
return localForage.getItem(this.cacheKey(key));
};
setItem = (key: string, value: any): any => {
return localForage.setItem(this.cacheKey(key), value);
};
removeItem = (key: string) => {
return localForage.removeItem(this.cacheKey(key));
};
constructor(cacheKey: string) {
this.key = cacheKey;
}
}
export default CacheStore;

View File

@@ -0,0 +1,157 @@
// @flow
import {
observable,
computed,
action,
runInAction,
ObservableArray,
} from 'mobx';
import ApiClient, { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores';
import Collection from 'models/Collection';
import ErrorsStore from 'stores/ErrorsStore';
import CacheStore from 'stores/CacheStore';
import UiStore from 'stores/UiStore';
type Options = {
teamId: string,
cache: CacheStore,
ui: UiStore,
};
type DocumentPathItem = {
id: string,
title: string,
url: string,
type: 'document' | 'collection',
};
export type DocumentPath = DocumentPathItem & {
path: Array<DocumentPathItem>,
};
class CollectionsStore {
@observable data: ObservableArray<Collection> = observable.array([]);
@observable isLoaded: boolean = false;
client: ApiClient;
teamId: string;
errors: ErrorsStore;
cache: CacheStore;
ui: UiStore;
@computed get active(): ?Collection {
return this.ui.activeCollectionId
? this.getById(this.ui.activeCollectionId)
: undefined;
}
@computed get orderedData(): Collection[] {
return _.sortBy(this.data, 'name');
}
/**
* List of paths to each of the documents, where paths are composed of id and title/name pairs
*/
@computed get pathsToDocuments(): Array<DocumentPath> {
let results = [];
const travelDocuments = (documentList, path) =>
documentList.forEach(document => {
const { id, title, url } = document;
const node = { id, title, url, type: 'document' };
results.push(_.concat(path, node));
travelDocuments(document.children, _.concat(path, [node]));
});
if (this.isLoaded) {
this.data.forEach(collection => {
const { id, name, url } = collection;
const node = { id, title: name, url, type: 'collection' };
results.push([node]);
travelDocuments(collection.documents, [node]);
});
}
return results.map(result => {
const tail = _.last(result);
return {
...tail,
path: result,
};
});
}
getPathForDocument(documentId: string): ?DocumentPath {
return this.pathsToDocuments.find(path => path.id === documentId);
}
titleForDocument(documentUrl: string): ?string {
const path = this.pathsToDocuments.find(path => path.url === documentUrl);
if (path) return path.title;
}
/* Actions */
@action fetchAll = async (): Promise<*> => {
try {
const res = await this.client.post('/collections.list', {
id: this.teamId,
});
invariant(res && res.data, 'Collection list not available');
const { data } = res;
runInAction('CollectionsStore#fetch', () => {
this.data.replace(data.map(collection => new Collection(collection)));
this.isLoaded = true;
});
} catch (e) {
this.errors.add('Failed to load collections');
}
};
@action fetchById = async (id: string): Promise<?Collection> => {
let collection = this.getById(id);
if (!collection) {
try {
const res = await this.client.post('/collections.info', {
id,
});
invariant(res && res.data, 'Collection not available');
const { data } = res;
runInAction('CollectionsStore#getById', () => {
collection = new Collection(data);
this.add(collection);
});
} catch (e) {
Bugsnag.notify(e);
this.errors.add('Something went wrong');
}
}
return collection;
};
@action add = (collection: Collection): void => {
this.data.push(collection);
};
@action remove = (id: string): void => {
this.data.splice(this.data.indexOf(id), 1);
};
getById = (id: string): ?Collection => {
return _.find(this.data, { id });
};
constructor(options: Options) {
this.client = client;
this.errors = stores.errors;
this.teamId = options.teamId;
this.cache = options.cache;
this.ui = options.ui;
}
}
export default CollectionsStore;

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
import CollectionsStore from './CollectionsStore';
jest.mock('utils/ApiClient', () => ({
client: { post: {} },
}));
jest.mock('stores', () => ({ errors: {} }));
describe('CollectionsStore', () => {
let store;
beforeEach(() => {
const cache = {
getItem: jest.fn(() => Promise.resolve()),
setItem: jest.fn(() => {}),
};
store = new CollectionsStore({
teamId: 123,
cache,
});
});
describe('#fetch', () => {
test('should load stores', async () => {
store.client = {
post: jest.fn(() => ({
data: [
{
name: 'New collection',
},
],
})),
};
await store.fetchAll();
expect(store.client.post).toHaveBeenCalledWith('/collections.list', {
id: 123,
});
expect(store.data.length).toBe(1);
expect(store.data[0].name).toBe('New collection');
});
test('should report errors', async () => {
store.client = {
post: jest.fn(() => Promise.reject),
};
store.errors = {
add: jest.fn(),
};
await store.fetchAll();
expect(store.errors.add).toHaveBeenCalledWith(
'Failed to load collections'
);
});
});
});

View File

@@ -0,0 +1,187 @@
// @flow
import {
observable,
action,
computed,
ObservableMap,
runInAction,
autorunAsync,
} from 'mobx';
import { client } from 'utils/ApiClient';
import _ from 'lodash';
import invariant from 'invariant';
import BaseStore from 'stores/BaseStore';
import stores from 'stores';
import Document from 'models/Document';
import ErrorsStore from 'stores/ErrorsStore';
import CacheStore from 'stores/CacheStore';
import UiStore from 'stores/UiStore';
const DOCUMENTS_CACHE_KEY = 'DOCUMENTS_CACHE_KEY';
type Options = {
cache: CacheStore,
ui: UiStore,
};
class DocumentsStore extends BaseStore {
@observable recentlyViewedIds: Array<string> = [];
@observable data: Map<string, Document> = new ObservableMap([]);
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
errors: ErrorsStore;
cache: CacheStore;
ui: UiStore;
/* Computed */
@computed get recentlyViewed(): Array<Document> {
return _.take(
_.filter(this.data.values(), ({ id }) =>
this.recentlyViewedIds.includes(id)
),
5
);
}
@computed get recentlyEdited(): Array<Document> {
return _.take(_.orderBy(this.data.values(), 'updatedAt', 'desc'), 5);
}
@computed get starred(): Array<Document> {
return _.filter(this.data.values(), 'starred');
}
@computed get active(): ?Document {
return this.ui.activeDocumentId
? this.getById(this.ui.activeDocumentId)
: undefined;
}
/* Actions */
@action fetchAll = async (
request: string = 'list',
options: ?Object
): Promise<*> => {
this.isFetching = true;
try {
const res = await client.post(`/documents.${request}`, options);
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');
} finally {
this.isFetching = false;
}
};
@action fetchRecentlyModified = async (options: ?Object): Promise<*> => {
return await this.fetchAll('list', options);
};
@action fetchRecentlyViewed = async (options: ?Object): Promise<*> => {
const data = await this.fetchAll('viewed', options);
runInAction('DocumentsStore#fetchRecentlyViewed', () => {
this.recentlyViewedIds = _.map(data, 'id');
});
return data;
};
@action fetchStarred = async (): Promise<*> => {
await this.fetchAll('starred');
};
@action search = async (query: string): Promise<*> => {
const res = await client.get('/documents.search', { query });
invariant(res && res.data, 'res or res.data missing');
const { data } = res;
data.forEach(documentData => this.add(new Document(documentData)));
return data.map(documentData => documentData.id);
};
@action prefetchDocument = async (id: string) => {
if (!this.getById(id)) this.fetch(id, true);
};
@action fetch = async (id: string, prefetch?: boolean): Promise<*> => {
if (!prefetch) this.isFetching = true;
try {
const res = await client.post('/documents.info', { id });
invariant(res && res.data, 'Document not available');
const { data } = res;
const document = new Document(data);
runInAction('DocumentsStore#fetch', () => {
this.data.set(data.id, document);
this.isLoaded = true;
});
return document;
} catch (e) {
this.errors.add('Failed to load documents');
} finally {
this.isFetching = false;
}
};
@action add = (document: Document): void => {
this.data.set(document.id, document);
};
@action remove = (id: string): void => {
this.data.delete(id);
};
getById = (id: string): ?Document => {
return this.data.get(id);
};
/**
* Match documents by the url ID as the title slug can change
*/
getByUrl = (url: string): ?Document => {
return _.find(this.data.values(), doc => url.endsWith(doc.urlId));
};
constructor(options: Options) {
super();
this.errors = stores.errors;
this.cache = options.cache;
this.ui = options.ui;
this.cache.getItem(DOCUMENTS_CACHE_KEY).then(data => {
if (data) {
data.forEach(document => this.add(new Document(document)));
}
});
this.on('documents.delete', (data: { id: string }) => {
this.remove(data.id);
});
autorunAsync('DocumentsStore.persists', () => {
if (this.data.size) {
this.cache.setItem(
DOCUMENTS_CACHE_KEY,
Array.from(this.data.values()).map(collection => collection.data)
);
}
});
}
}
export default DocumentsStore;

18
app/stores/ErrorsStore.js Normal file
View File

@@ -0,0 +1,18 @@
// @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;

View File

@@ -0,0 +1,27 @@
/* 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');
});
});

51
app/stores/UiStore.js Normal file
View File

@@ -0,0 +1,51 @@
// @flow
import { observable, action } from 'mobx';
import Document from 'models/Document';
class UiStore {
@observable activeModalName: ?string;
@observable activeModalProps: ?Object;
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@observable editMode: boolean = false;
/* Actions */
@action setActiveModal = (name: string, props: ?Object): void => {
this.activeModalName = name;
this.activeModalProps = props;
};
@action clearActiveModal = (): void => {
this.activeModalName = undefined;
this.activeModalProps = undefined;
};
@action setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id;
this.activeCollectionId = document.collection.id;
};
@action clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.activeCollectionId = undefined;
};
@action enableEditMode() {
this.editMode = true;
}
@action disableEditMode() {
this.editMode = false;
}
@action enableProgressBar() {
this.progressBarVisible = true;
}
@action disableProgressBar() {
this.progressBarVisible = false;
}
}
export default UiStore;

14
app/stores/index.js Normal file
View File

@@ -0,0 +1,14 @@
// @flow
import AuthStore from './AuthStore';
import UiStore from './UiStore';
import ErrorsStore from './ErrorsStore';
const stores = {
user: null, // Including for Layout
auth: new AuthStore(),
ui: new UiStore(),
errors: new ErrorsStore(),
};
window.stores = stores;
export default stores;