Merge documents
This commit is contained in:
20
__mocks__/localStorage.js
Normal file
20
__mocks__/localStorage.js
Normal file
@ -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;
|
||||||
|
},
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Document } from 'types';
|
import Document from 'models/Document';
|
||||||
import DocumentPreview from 'components/DocumentPreview';
|
import DocumentPreview from 'components/DocumentPreview';
|
||||||
|
|
||||||
class DocumentList extends React.Component {
|
class DocumentList extends React.Component {
|
||||||
@ -13,7 +13,7 @@ class DocumentList extends React.Component {
|
|||||||
<div>
|
<div>
|
||||||
{this.props.documents &&
|
{this.props.documents &&
|
||||||
this.props.documents.map(document => (
|
this.props.documents.map(document => (
|
||||||
<DocumentPreview document={document} />
|
<DocumentPreview key={document.id} document={document} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { Document } from 'types';
|
import Document from 'models/Document';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { color } from 'styles/constants';
|
import { color } from 'styles/constants';
|
||||||
import PublishingInfo from 'components/PublishingInfo';
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
import { Flex } from 'reflexbox';
|
import { Flex } from 'reflexbox';
|
||||||
|
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
|
||||||
import 'normalize.css/normalize.css';
|
import 'normalize.css/normalize.css';
|
||||||
@ -57,12 +58,13 @@ const Auth = ({ children }: AuthProps) => {
|
|||||||
const user = stores.auth.getUserStore();
|
const user = stores.auth.getUserStore();
|
||||||
authenticatedStores = {
|
authenticatedStores = {
|
||||||
user,
|
user,
|
||||||
|
documents: new DocumentsStore(),
|
||||||
collections: new CollectionsStore({
|
collections: new CollectionsStore({
|
||||||
teamId: user.team.id,
|
teamId: user.team.id,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
authenticatedStores.collections.fetch();
|
authenticatedStores.collections.fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -135,3 +137,5 @@ render(
|
|||||||
</div>,
|
</div>,
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
window.authenticatedStores = authenticatedStores;
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { extendObservable, action, runInAction, computed } from 'mobx';
|
import { extendObservable, action, runInAction, computed } from 'mobx';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
|
|
||||||
import ApiClient, { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
import ErrorsStore from 'stores/ErrorsStore';
|
import ErrorsStore from 'stores/ErrorsStore';
|
||||||
|
|
||||||
@ -25,8 +25,6 @@ class Document {
|
|||||||
updatedBy: User;
|
updatedBy: User;
|
||||||
url: string;
|
url: string;
|
||||||
views: number;
|
views: number;
|
||||||
|
|
||||||
client: ApiClient;
|
|
||||||
errors: ErrorsStore;
|
errors: ErrorsStore;
|
||||||
|
|
||||||
/* Computed */
|
/* Computed */
|
||||||
@ -58,7 +56,7 @@ class Document {
|
|||||||
|
|
||||||
@action update = async () => {
|
@action update = async () => {
|
||||||
try {
|
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');
|
invariant(res && res.data, 'Document API response should be available');
|
||||||
const { data } = res;
|
const { data } = res;
|
||||||
runInAction('Document#update', () => {
|
runInAction('Document#update', () => {
|
||||||
@ -75,7 +73,6 @@ class Document {
|
|||||||
|
|
||||||
constructor(document: Document) {
|
constructor(document: Document) {
|
||||||
this.updateData(document);
|
this.updateData(document);
|
||||||
this.client = client;
|
|
||||||
this.errors = stores.errors;
|
this.errors = stores.errors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
13
frontend/models/Document.test.js
Normal file
13
frontend/models/Document.test.js
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -1,13 +1,12 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import DocumentList from 'components/DocumentList';
|
import DocumentList from 'components/DocumentList';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import ViewedDocumentsStore from './ViewedDocumentsStore';
|
|
||||||
import EditedDocumentsStore from './EditedDocumentsStore';
|
|
||||||
|
|
||||||
const Subheading = styled.h3`
|
const Subheading = styled.h3`
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -20,22 +19,16 @@ const Subheading = styled.h3`
|
|||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = {};
|
type Props = {
|
||||||
|
documents: DocumentsStore,
|
||||||
|
};
|
||||||
|
|
||||||
@observer class Dashboard extends React.Component {
|
@observer class Dashboard extends React.Component {
|
||||||
props: Props;
|
props: Props;
|
||||||
viewedStore: ViewedDocumentsStore;
|
|
||||||
editedStore: EditedDocumentsStore;
|
|
||||||
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.viewedStore = new ViewedDocumentsStore();
|
|
||||||
this.editedStore = new EditedDocumentsStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.viewedStore.fetchDocuments();
|
this.props.documents.fetchAll();
|
||||||
this.editedStore.fetchDocuments();
|
this.props.documents.fetchRecentlyViewed();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -44,13 +37,13 @@ type Props = {};
|
|||||||
<PageTitle title="Home" />
|
<PageTitle title="Home" />
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
<Subheading>Recently viewed</Subheading>
|
<Subheading>Recently viewed</Subheading>
|
||||||
<DocumentList documents={this.viewedStore.documents} />
|
<DocumentList documents={this.props.documents.getRecentlyViewed()} />
|
||||||
|
|
||||||
<Subheading>Recently edited</Subheading>
|
<Subheading>Recently edited</Subheading>
|
||||||
<DocumentList documents={this.editedStore.documents} />
|
<DocumentList documents={this.props.documents.data.values()} />
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
export default inject('documents')(Dashboard);
|
||||||
|
@ -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<Document> = [];
|
|
||||||
@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;
|
|
@ -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<Document> = [];
|
|
||||||
@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;
|
|
@ -1,21 +1,18 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import PageTitle from 'components/PageTitle';
|
import PageTitle from 'components/PageTitle';
|
||||||
import DocumentList from 'components/DocumentList';
|
import DocumentList from 'components/DocumentList';
|
||||||
import StarredStore from './StarredStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
|
||||||
@observer class Starred extends Component {
|
@observer class Starred extends Component {
|
||||||
store: StarredStore;
|
props: {
|
||||||
|
documents: DocumentsStore,
|
||||||
constructor() {
|
};
|
||||||
super();
|
|
||||||
this.store = new StarredStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.store.fetchDocuments();
|
this.props.documents.fetchStarred();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -23,10 +20,10 @@ import StarredStore from './StarredStore';
|
|||||||
<CenteredContent column auto>
|
<CenteredContent column auto>
|
||||||
<PageTitle title="Starred" />
|
<PageTitle title="Starred" />
|
||||||
<h1>Starred</h1>
|
<h1>Starred</h1>
|
||||||
<DocumentList documents={this.store.documents} />
|
<DocumentList documents={this.props.documents.getStarred()} />
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Starred;
|
export default inject('documents')(Starred);
|
||||||
|
@ -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<Document> = [];
|
|
||||||
@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;
|
|
@ -22,7 +22,7 @@ class CollectionsStore {
|
|||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
|
|
||||||
@action fetch = async (): Promise<*> => {
|
@action fetchAll = async (): Promise<*> => {
|
||||||
try {
|
try {
|
||||||
const res = await this.client.post('/collections.list', {
|
const res = await this.client.post('/collections.list', {
|
||||||
id: this.teamId,
|
id: this.teamId,
|
||||||
|
@ -27,7 +27,7 @@ describe('CollectionsStore', () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
await store.fetch();
|
await store.fetchAll();
|
||||||
|
|
||||||
expect(store.client.post).toHaveBeenCalledWith('/collections.list', {
|
expect(store.client.post).toHaveBeenCalledWith('/collections.list', {
|
||||||
id: 123,
|
id: 123,
|
||||||
@ -44,7 +44,7 @@ describe('CollectionsStore', () => {
|
|||||||
add: jest.fn(),
|
add: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await store.fetch();
|
await store.fetchAll();
|
||||||
|
|
||||||
expect(store.errors.add).toHaveBeenCalledWith(
|
expect(store.errors.add).toHaveBeenCalledWith(
|
||||||
'Failed to load collections'
|
'Failed to load collections'
|
||||||
|
89
frontend/stores/DocumentsStore.js
Normal file
89
frontend/stores/DocumentsStore.js
Normal file
@ -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<string> = [];
|
||||||
|
@observable data: Map<string, Document> = 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;
|
@ -1,6 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { observable, action, computed } from 'mobx';
|
import { observable, action, computed } from 'mobx';
|
||||||
import type { Document } from 'types';
|
import Document from 'models/Document';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
|
|
||||||
class UiStore {
|
class UiStore {
|
||||||
|
@ -2,10 +2,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import toJson from 'enzyme-to-json';
|
import toJson from 'enzyme-to-json';
|
||||||
|
import localStorage from '../../__mocks__/localStorage';
|
||||||
|
|
||||||
const snap = children => {
|
const snap = children => {
|
||||||
const wrapper = shallow(children);
|
const wrapper = shallow(children);
|
||||||
expect(toJson(wrapper)).toMatchSnapshot();
|
expect(toJson(wrapper)).toMatchSnapshot();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
global.localStorage = localStorage;
|
||||||
global.snap = snap;
|
global.snap = snap;
|
||||||
|
Reference in New Issue
Block a user