frontend > app
This commit is contained in:
89
app/stores/AuthStore.js
Normal file
89
app/stores/AuthStore.js
Normal 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
19
app/stores/BaseStore.js
Normal 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
28
app/stores/CacheStore.js
Normal 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;
|
||||
157
app/stores/CollectionsStore.js
Normal file
157
app/stores/CollectionsStore.js
Normal 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;
|
||||
60
app/stores/CollectionsStore.test.js
Normal file
60
app/stores/CollectionsStore.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
187
app/stores/DocumentsStore.js
Normal file
187
app/stores/DocumentsStore.js
Normal 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
18
app/stores/ErrorsStore.js
Normal 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;
|
||||
27
app/stores/ErrorsStore.test.js
Normal file
27
app/stores/ErrorsStore.test.js
Normal 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
51
app/stores/UiStore.js
Normal 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
14
app/stores/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user