Merge pull request #78 from jorilallo/jori/stores-refactor

Collections store refactor and layout changes
This commit is contained in:
Jori Lallo 2017-05-29 20:04:56 -07:00 committed by GitHub
commit d853821186
39 changed files with 568 additions and 494 deletions

View File

@ -11,6 +11,7 @@
"flowtype"
],
"rules": {
"no-unused-vars": 2,
// // Bring back after we remove CSS Modules 100%
// "import/order": "warn",
// Prettier automatically uses the least amount of parens possible, so this

View File

@ -2,12 +2,9 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import DocumentLink from './components/DocumentLink';
import moment from 'moment';
import styles from './Collection.scss';
// import classNames from 'classnames/bind';
// const cx = classNames.bind(styles);
@observer class Collection extends React.Component {
static propTypes = {
@ -24,7 +21,18 @@ import styles from './Collection.scss';
</h2>
{data.recentDocuments.length > 0
? data.recentDocuments.map(document => {
return <DocumentLink document={document} key={document.id} />;
return (
<Link
key={document.id}
to={document.url}
className={styles.link}
>
<h3 className={styles.title}>{document.title}</h3>
<span className={styles.timestamp}>
{moment(document.updatedAt).fromNow()}
</span>
</Link>
);
})
: <div className={styles.description}>
No documents. Why not

View File

@ -17,3 +17,25 @@
.description {
color: #aaa;
}
.link {
display: flex;
flex: 1;
justify-content: space-between;
margin-bottom: 20px;
text-decoration: none;
}
.title {
font-weight: normal;
font-size: 15px;
color: $textColor;
margin: 0;
}
.timestamp {
font-size: 13px;
color: #ccc;
}

View File

@ -32,6 +32,7 @@ const DocumentLink = styled(Link)`
}
`;
// $FlowIssue
const TruncatedMarkdown = styled(Markdown)`
pointer-events: none;
`;

View File

@ -12,13 +12,15 @@ import MarkdownShortcuts from './plugins/MarkdownShortcuts';
const onlyInCode = node => node.type === 'code';
type CreatePluginsOptions = {
onImageUploadStart: Function,
onImageUploadStop: Function,
};
const createPlugins = ({
onImageUploadStart,
onImageUploadStop,
}: {
onImageUploadStart: Function,
onImageUploadStop: Function,
}) => {
}: CreatePluginsOptions) => {
return [
PasteLinkify({
type: 'link',

View File

@ -6,26 +6,23 @@ import styled from 'styled-components';
import { observer, inject } from 'mobx-react';
import _ from 'lodash';
import keydown from 'react-keydown';
import classNames from 'classnames/bind';
import searchIcon from 'assets/icons/search.svg';
import { Flex } from 'reflexbox';
import { textColor } from 'styles/constants.scss';
import styles from './Layout.scss';
import { textColor, headerHeight } from 'styles/constants.scss';
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
import LoadingIndicator from 'components/LoadingIndicator';
import UserStore from 'stores/UserStore';
const cx = classNames.bind(styles);
import AuthStore from 'stores/AuthStore';
type Props = {
history: Object,
children?: ?React.Element<any>,
actions?: ?React.Element<any>,
title?: ?React.Element<any>,
titleText?: string,
loading?: boolean,
user: UserStore,
auth: AuthStore,
search: ?boolean,
notifications?: React.Element<any>,
};
@ -39,29 +36,27 @@ type Props = {
@keydown(['/', 't'])
search() {
if (!this.props.user) return;
_.defer(() => this.props.history.push('/search'));
if (this.props.auth.isAuthenticated)
_.defer(() => this.props.history.push('/search'));
}
@keydown(['d'])
dashboard() {
if (!this.props.user) return;
_.defer(() => this.props.history.push('/'));
if (this.props.auth.isAuthenticated)
_.defer(() => this.props.history.push('/'));
}
render() {
const user = this.props.user;
handleLogout = () => {
this.props.auth.logout(() => this.props.history.push('/'));
};
const handleLogout = () => {
user.logout(() => this.props.history.push('/'));
};
render() {
const { auth, user } = this.props;
return (
<div className={styles.container}>
<Container column auto>
<Helmet
title={
this.props.titleText ? `${this.props.titleText} - Atlas` : 'Atlas'
}
title="Atlas"
meta={[
{
name: 'viewport',
@ -74,26 +69,27 @@ type Props = {
{this.props.notifications}
<div className={cx(styles.header)}>
<div className={styles.headerLeft}>
<Link to="/" className={styles.team}>Atlas</Link>
<span className={styles.title}>
<Header>
<Flex align="center">
<LogoLink to="/">Atlas</LogoLink>
<Title>
{this.props.title}
</span>
</div>
<Flex className={styles.headerRight}>
</Title>
</Flex>
<Flex>
<Flex>
<Flex align="center" className={styles.actions}>
<Flex align="center">
{this.props.actions}
</Flex>
{user.user &&
{auth.authenticated &&
user &&
<Flex>
{this.props.search &&
<Flex>
<Link to="/search">
<div className={styles.search} title="Search (/)">
<img src={searchIcon} alt="Search" />
</div>
<Search title="Search (/)">
<SearchIcon src={searchIcon} alt="Search" />
</Search>
</Link>
</Flex>}
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>
@ -108,21 +104,72 @@ type Props = {
<MenuLink to="/developers">
<MenuItem>API</MenuItem>
</MenuLink>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
<MenuItem onClick={this.handleLogout}>Logout</MenuItem>
</DropdownMenu>
</Flex>}
</Flex>
</Flex>
</div>
</Header>
<div className={cx(styles.content)}>
<Content auto justify="center">
{this.props.children}
</div>
</div>
</Content>
</Container>
);
}
}
const Container = styled(Flex)`
width: 100%;
height: 100%;
`;
const Header = styled(Flex)`
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
z-index: 1;
background: #fff;
height: ${headerHeight};
border-bottom: 1px solid #eee;
font-size: 14px;
line-height: 1;
`;
const LogoLink = styled(Link)`
font-family: 'Atlas Grotesk';
font-weight: bold;
color: ${textColor};
text-decoration: none;
font-size: 16px;
`;
const Title = styled.span`
color: #ccc;
a {
color: #ccc;
}
a:hover {
color: ${textColor};
}
`;
const Search = styled(Flex)`
margin: 0 5px;
padding: 15px 5px 0 5px;
cursor: pointer;
`;
const SearchIcon = styled.img`
height: 20px;
`;
const Avatar = styled.img`
width: 24px;
height: 24px;
@ -133,4 +180,9 @@ const MenuLink = styled(Link)`
color: ${textColor};
`;
export default withRouter(inject('user')(Layout));
const Content = styled(Flex)`
height: 100%;
overflow: scroll;
`;
export default withRouter(inject('user', 'auth')(Layout));

View File

@ -1,72 +0,0 @@
@import '~styles/constants.scss';
.container {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
height: 100%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
z-index: 1;
background: #fff;
height: $headerHeight;
border-bottom: 1px solid #eee;
font-size: 14px;
line-height: 1;
}
.headerLeft {
display: flex;
align-items: center;
.team {
font-family: 'Atlas Grotesk';
font-weight: bold;
color: $textColor;
text-decoration: none;
font-size: 16px;
}
.title {
color: #ccc;
a {
color: #ccc;
}
a:hover {
color: $textColor;
}
}
}
.headerRight {
}
.search {
margin: 0 5px;
padding: 15px 5px 0 5px;
cursor: pointer;
img {
height: 20px;
}
}
.content {
display: flex;
flex: 1;
justify-content: center;
height: 100%;
overflow: scroll;
}

View File

@ -0,0 +1,11 @@
// @flow
import React from 'react';
import Helmet from 'react-helmet';
type Props = {
title: string,
};
const PageTitle = ({ title }: Props) => <Helmet title={`${title} - Atlas`} />;
export default PageTitle;

View File

@ -0,0 +1,3 @@
// @flow
import PageTitle from './PageTitle';
export default PageTitle;

View File

@ -1,12 +1,12 @@
// @flow
import React from 'react';
import { observer, inject } from 'mobx-react';
import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
type Props = {
children: React.Element<any>,
scopes?: Array<string>,
user: UserStore,
auth: AuthStore,
redirectUri: string,
};
@ -28,7 +28,7 @@ type Props = {
client_id: SLACK_KEY,
scope: this.props.scopes ? this.props.scopes.join(' ') : '',
redirect_uri: this.props.redirectUri || SLACK_REDIRECT_URI,
state: this.props.user.getOauthState(),
state: this.props.auth.getOauthState(),
};
const urlParams = Object.keys(params)
@ -45,4 +45,4 @@ type Props = {
}
}
export default inject('user')(SlackAuthLink);
export default inject('auth')(SlackAuthLink);

View File

@ -11,6 +11,7 @@ import {
import { Flex } from 'reflexbox';
import stores from 'stores';
import CollectionsStore from 'stores/CollectionsStore';
import 'normalize.css/normalize.css';
import 'styles/base.scss';
@ -21,7 +22,7 @@ import 'styles/hljs-github-gist.scss';
import Home from 'scenes/Home';
import Dashboard from 'scenes/Dashboard';
import Atlas from 'scenes/Atlas';
import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
@ -37,13 +38,37 @@ if (__DEV__) {
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
}
let authenticatedStores;
type AuthProps = {
children?: React.Element<any>,
};
const Auth = ({ children }: AuthProps) => {
if (stores.user.authenticated) {
return <Flex auto>{children}</Flex>;
if (stores.auth.authenticated && stores.auth.team) {
// Only initialize stores once. Kept in global scope
// because otherwise they will get overriden on route
// change
if (!authenticatedStores) {
// Stores for authenticated user
const user = stores.auth.getUserStore();
authenticatedStores = {
user,
collections: new CollectionsStore({
teamId: user.team.id,
}),
};
authenticatedStores.collections.fetch();
}
return (
<Flex auto>
<Provider {...authenticatedStores}>
{children}
</Provider>
</Flex>
);
} else {
return <Redirect to="/" />;
}
@ -64,10 +89,14 @@ render(
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/auth/slack" component={SlackAuth} />
<Route exact path="/auth/slack/commands" component={SlackAuth} />
<Route exact path="/auth/error" component={ErrorAuth} />
<Auth>
<Switch>
<Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/collections/:id" component={Atlas} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path="/d/:id" component={Document} />
<Route exact path="/d/:id/:edit" component={Document} />
<Route
@ -81,10 +110,6 @@ render(
<Route exact path="/search/:query" component={Search} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/auth/slack" component={SlackAuth} />
<Route exact path="/auth/slack/commands" component={SlackAuth} />
<Route exact path="/auth/error" component={ErrorAuth} />
<Route
exact
path="/keyboard-shortcuts"

View File

@ -1,5 +1,5 @@
// @flow
import { extendObservable, action, computed, runInAction } from 'mobx';
import { extendObservable, action, runInAction } from 'mobx';
import invariant from 'invariant';
import _ from 'lodash';

View File

@ -1,112 +0,0 @@
// @flow
import React from 'react';
import { observer } from 'mobx-react';
import keydown from 'react-keydown';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import _ from 'lodash';
// TODO move here argh
import store from './AtlasStore';
import Layout, { Title } from 'components/Layout';
import PreviewLoading from 'components/PreviewLoading';
import CenteredContent from 'components/CenteredContent';
import DocumentList from 'components/DocumentList';
import Divider from 'components/Divider';
import DropdownMenu, { MenuItem, MoreIcon } from 'components/DropdownMenu';
import { Flex } from 'reflexbox';
import styles from './Atlas.scss';
type Props = {
params: Object,
history: Object,
match: Object,
keydown: Object,
};
@keydown(['c'])
@observer
class Atlas extends React.Component {
props: Props;
componentDidMount = () => {
const { id } = this.props.match.params;
store.fetchCollection(id, data => {
// Forward directly to root document
if (data.type === 'atlas') {
this.props.history.replace(data.navigationTree.url);
}
});
};
componentWillReceiveProps = (nextProps: Props) => {
const key = nextProps.keydown.event;
if (key) {
if (key.key === 'c') {
_.defer(this.onCreate);
}
}
};
onCreate = (event: Event) => {
if (event) event.preventDefault();
store.collection && this.props.history.push(`${store.collection.url}/new`);
};
render() {
const collection = store.collection;
let actions;
let title;
let titleText;
if (collection) {
actions = (
<Flex>
<DropdownMenu label={<MoreIcon />}>
<MenuItem onClick={this.onCreate}>
New document
</MenuItem>
</DropdownMenu>
</Flex>
);
title = <Title content={collection.name} />;
titleText = collection.name;
}
return (
<Layout actions={actions} title={title} titleText={titleText}>
<CenteredContent>
<ReactCSSTransitionGroup
transitionName="fadeIn"
transitionAppear
transitionAppearTimeout={0}
transitionEnterTimeout={0}
transitionLeaveTimeout={0}
>
{store.isFetching
? <PreviewLoading />
: collection &&
<div className={styles.container}>
<div className={styles.atlasDetails}>
<h2>{collection.name}</h2>
<blockquote>
{collection.description}
</blockquote>
</div>
<Divider />
<DocumentList
documents={collection.recentDocuments}
preview
/>
</div>}
</ReactCSSTransitionGroup>
</CenteredContent>
</Layout>
);
}
}
export default Atlas;

View File

@ -1,17 +0,0 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
}
.atlasDetails {
display: flex;
flex: 1;
flex-direction: column;
blockquote {
padding: 0;
margin: 0 0 20px 0;
font-style: italic;
}
}

View File

@ -1,3 +0,0 @@
// @flow
import Atlas from './Atlas';
export default Atlas;

View File

@ -0,0 +1,65 @@
// @flow
import React from 'react';
import { observer, inject } from 'mobx-react';
import { Redirect } from 'react-router';
import _ from 'lodash';
import { notFoundUrl } from 'utils/routeHelpers';
import CollectionsStore from 'stores/CollectionsStore';
import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import PreviewLoading from 'components/PreviewLoading';
type Props = {
collections: CollectionsStore,
match: Object,
};
type State = {
redirectUrl: ?string,
};
@observer class Collection extends React.Component {
props: Props;
state: State;
constructor(props) {
super(props);
this.state = {
redirectUrl: null,
};
}
componentDidMount = () => {
const { id } = this.props.match.params;
this.props.collections
.getById(id)
.then(collection => {
if (collection.type !== 'atlas')
throw new Error('TODO code up non-atlas collections');
this.setState({
redirectUrl: collection.navigationTree.url,
});
})
.catch(() => {
this.setState({
redirectUrl: notFoundUrl(),
});
});
};
render() {
return (
<Layout>
{this.state.redirectUrl && <Redirect to={this.state.redirectUrl} />}
<CenteredContent>
<PreviewLoading />
</CenteredContent>
</Layout>
);
}
}
export default inject('collections')(Collection);

View File

@ -0,0 +1,3 @@
// @flow
import Collection from './Collection';
export default Collection;

View File

@ -1,10 +1,9 @@
// @flow
import React from 'react';
import { observer, inject } from 'mobx-react';
import { withRouter } from 'react-router-dom';
import { Flex } from 'reflexbox';
import DashboardStore from './DashboardStore';
import CollectionsStore from 'stores/CollectionsStore';
import Layout from 'components/Layout';
import Collection from 'components/Collection';
@ -12,37 +11,24 @@ import PreviewLoading from 'components/PreviewLoading';
import CenteredContent from 'components/CenteredContent';
type Props = {
user: Object,
router: Object,
collections: CollectionsStore,
};
@withRouter
@inject('user')
@observer
class Dashboard extends React.Component {
@observer class Dashboard extends React.Component {
props: Props;
store: DashboardStore;
constructor(props: Props) {
super(props);
this.store = new DashboardStore({
team: props.user.team,
router: props.router,
});
}
render() {
const { collections } = this.props;
return (
<Layout>
<CenteredContent>
<Flex column auto>
{this.store.isFetching
{!collections.isLoaded
? <PreviewLoading />
: this.store.collections &&
this.store.collections.map(collection => (
<Collection key={collection.id} data={collection} />
))}
: collections.data.map(collection => (
<Collection key={collection.id} data={collection} />
))}
</Flex>
</CenteredContent>
</Layout>
@ -50,4 +36,4 @@ class Dashboard extends React.Component {
}
}
export default Dashboard;
export default inject('collections')(Dashboard);

View File

@ -1,50 +0,0 @@
// @flow
import { observable, action, runInAction } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import type { Pagination } from 'types';
import Collection from 'models/Collection';
type Options = {
team: Object,
router: Object,
};
class DashboardStore {
team: Object;
router: Object;
@observable collections: Array<Collection>;
@observable pagination: Pagination;
@observable isFetching: boolean = true;
/* Actions */
@action fetchCollections = async () => {
this.isFetching = true;
try {
const res = await client.post('/collections.list', { id: this.team.id });
invariant(
res && res.data && res.pagination,
'API response should be available'
);
const { data, pagination } = res;
runInAction('fetchCollections', () => {
this.collections = data.map(collection => new Collection(collection));
this.pagination = pagination;
});
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
constructor(options: Options) {
this.team = options.team;
this.router = options.router;
this.fetchCollections();
}
}
export default DashboardStore;

View File

@ -14,6 +14,7 @@ import Layout, { HeaderAction, SaveAction } from 'components/Layout';
import PublishingInfo from 'components/PublishingInfo';
import PreviewLoading from 'components/PreviewLoading';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
const DISCARD_CHANGES = `
You have unsaved changes.
@ -103,9 +104,7 @@ type Props = {
/>
);
const titleText =
this.store.document &&
`${get(this.store, 'document.collection.name')} - ${get(this.store, 'document.title')}`;
const titleText = this.store.document && get(this.store, 'document.title');
const actions = (
<Flex>
@ -130,11 +129,11 @@ type Props = {
<Layout
actions={actions}
title={title}
titleText={titleText}
loading={this.store.isSaving || this.store.isUploading}
search={false}
fixed
>
<PageTitle title={titleText} />
<Prompt when={this.store.hasPendingChanges} message={DISCARD_CHANGES} />
{this.store.isFetching &&
<CenteredContent>

View File

@ -4,11 +4,13 @@ import { Link } from 'react-router-dom';
import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
class Error404 extends React.Component {
render() {
return (
<Layout titleText="Not Found">
<Layout>
<PageTitle title="Not found" />
<CenteredContent>
<h1>Not Found</h1>

View File

@ -4,11 +4,13 @@ import { Link } from 'react-router-dom';
import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
class ErrorAuth extends React.Component {
render() {
return (
<Layout titleText="Not Found">
<Layout>
<PageTitle title="Authentication error" />
<CenteredContent>
<h1>Authentication failed</h1>

View File

@ -5,6 +5,7 @@ import { observer } from 'mobx-react';
import Layout, { Title } from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import { DocumentHtml } from 'components/Document';
import PageTitle from 'components/PageTitle';
import { convertToMarkdown } from 'utils/markdown';
@ -20,11 +21,8 @@ type Props = {
const { title, content } = this.props;
return (
<Layout
title={<Title content={title} />}
titleText={title}
search={false}
>
<Layout title={<Title content={title} />} search={false}>
<PageTitle title={title} />
<CenteredContent>
<DocumentHtml html={convertToMarkdown(content)} />
</CenteredContent>

View File

@ -2,22 +2,23 @@
import React from 'react';
import { observer, inject } from 'mobx-react';
import { Redirect } from 'react-router';
import { Flex } from 'reflexbox';
import styled from 'styled-components';
import AuthStore from 'stores/AuthStore';
import Layout from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import SlackAuthLink from 'components/SlackAuthLink';
import Alert from 'components/Alert';
import styles from './Home.scss';
type Props = {
auth: AuthStore,
location: Object,
};
@inject('user')
@observer
export default class Home extends React.Component {
static propTypes = {
user: React.PropTypes.object.isRequired,
location: React.PropTypes.object.isRequired,
};
@observer class Home extends React.Component {
props: Props;
get notifications(): React.Element<any>[] {
const notifications = [];
@ -40,17 +41,17 @@ export default class Home extends React.Component {
return (
<Flex auto>
<Layout notifications={this.notifications}>
{this.props.user.authenticated && <Redirect to="/dashboard" />}
{this.props.auth.authenticated && <Redirect to="/dashboard" />}
<CenteredContent>
{showLandingPageCopy &&
<div className={styles.intro}>
<h1 className={styles.title}>Simple, fast, markdown.</h1>
<p className={styles.copy}>
<div>
<Title>Simple, fast, markdown.</Title>
<Copy>
We're building a modern wiki for engineering teams.
</p>
</Copy>
</div>}
<div className={styles.action}>
<div>
<SlackAuthLink redirectUri={`${BASE_URL}/auth/slack`}>
<img
alt="Sign in with Slack"
@ -67,3 +68,15 @@ export default class Home extends React.Component {
);
}
}
const Title = styled.h1`
font-size: 36px;
margin-bottom: 24px;
}`;
const Copy = styled.p`
font-size: 20px;
margin-bottom: 36px;
}`;
export default inject('auth')(Home);

View File

@ -1,9 +0,0 @@
.title {
font-size: 36px;
margin-bottom: 24px;
}
.copy {
font-size: 20px;
margin-bottom: 36px;
}

View File

@ -13,6 +13,7 @@ import SearchStore from './SearchStore';
import Layout, { Title } from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import DocumentPreview from 'components/DocumentPreview';
import PageTitle from 'components/PageTitle';
type Props = {
history: Object,
@ -56,12 +57,8 @@ type Props = {
const title = <Title content="Search" />;
return (
<Layout
title={title}
titleText="Search"
search={false}
loading={this.store.isFetching}
>
<Layout title={title} search={false} loading={this.store.isFetching}>
<PageTitle title="Search" />
<CenteredContent>
{this.props.notFound &&
<div>

View File

@ -11,6 +11,7 @@ import SettingsStore from './SettingsStore';
import Layout, { Title } from 'components/Layout';
import CenteredContent from 'components/CenteredContent';
import SlackAuthLink from 'components/SlackAuthLink';
import PageTitle from 'components/PageTitle';
@observer class Settings extends React.Component {
store: SettingsStore;
@ -25,12 +26,8 @@ import SlackAuthLink from 'components/SlackAuthLink';
const showSlackSettings = DEPLOYMENT === 'hosted';
return (
<Layout
title={title}
titleText="Settings"
search={false}
loading={this.store.isFetching}
>
<Layout title={title} search={false} loading={this.store.isFetching}>
<PageTitle title="Settings" />
<CenteredContent>
{showSlackSettings &&
<div className={styles.section}>

View File

@ -5,22 +5,24 @@ import queryString from 'query-string';
import { observer, inject } from 'mobx-react';
import { client } from 'utils/ApiClient';
import UserStore from 'stores/UserStore';
import AuthStore from 'stores/AuthStore';
type Props = {
user: UserStore,
auth: AuthStore,
location: Object,
};
@inject('user')
@observer
class SlackAuth extends React.Component {
type State = {
redirectTo: string,
};
@observer class SlackAuth extends React.Component {
props: Props;
state: State;
state = {};
state: { redirectTo: string };
// $FlowFixMe not sure why this breaks
componentDidMount = async () => {
// $FlowIssue Flow doesn't like async lifecycle components https://github.com/facebook/flow/issues/1803
async componentDidMount(): void {
const { error, code, state } = queryString.parse(
this.props.location.search
);
@ -46,13 +48,13 @@ class SlackAuth extends React.Component {
const redirectTo = sessionStorage.getItem('redirectTo');
sessionStorage.removeItem('redirectTo');
const { success } = await this.props.user.authWithSlack(code, state);
const { success } = await this.props.auth.authWithSlack(code, state);
success
? this.setState({ redirectTo: redirectTo || '/dashboard' })
: this.setState({ redirectTo: '/auth/error' });
}
}
};
}
render() {
return (
@ -63,4 +65,4 @@ class SlackAuth extends React.Component {
}
}
export default SlackAuth;
export default inject('auth')(SlackAuth);

View File

@ -0,0 +1,100 @@
// @flow
import { observable, action, computed, autorunAsync } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import UserStore from 'stores/UserStore';
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 = (cb: Function) => {
this.user = null;
this.token = null;
cb();
};
@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,
};
};
getUserStore(): UserStore {
invariant(
this.user && this.team,
'Tried to create a user store without data'
);
return new UserStore({
user: this.user,
team: this.team,
});
}
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;

View File

@ -0,0 +1,53 @@
// @flow
import { observable, 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';
type Options = {
teamId: string,
};
class CollectionsStore {
@observable data: ObservableArray<Collection> = observable.array([]);
@observable isLoaded: boolean = false;
client: ApiClient;
teamId: string;
errors: ErrorsStore;
/* Actions */
@action fetch = 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 getById = async (id: string): Promise<Collection> => {
if (!this.isLoaded) await this.fetch();
return _.find(this.data, { id });
};
constructor(options: Options) {
this.client = client;
this.errors = stores.errors;
this.teamId = options.teamId;
}
}
export default CollectionsStore;

View File

@ -0,0 +1,54 @@
/* eslint-disable */
import CollectionsStore from './CollectionsStore';
jest.mock('utils/ApiClient', () => ({
client: { post: {} },
}));
jest.mock('stores', () => ({ errors: {} }));
describe('CollectionsStore', () => {
let store;
beforeEach(() => {
store = new CollectionsStore({
teamId: 123,
});
});
describe('#fetch', () => {
test('should load stores', async () => {
store.client = {
post: jest.fn(() => ({
data: [
{
name: 'New collection',
},
],
})),
};
await store.fetch();
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.fetch();
expect(store.errors.add).toHaveBeenCalledWith(
'Failed to load collections'
);
});
});
});

View File

@ -1,5 +1,5 @@
// @flow
import { observable, action, computed } from 'mobx';
import { observable, action, computed, autorunAsync } from 'mobx';
const UI_STORE = 'UI_STORE';
@ -24,8 +24,11 @@ class UiStore {
// Rehydrate
const data = JSON.parse(localStorage.getItem(UI_STORE) || '{}');
this.sidebar = data.sidebar;
autorunAsync(() => {
localStorage.setItem(UI_STORE, this.asJson);
});
}
}
export default UiStore;
export { UI_STORE };

View File

@ -1,87 +1,32 @@
// @flow
import { observable, action, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import { observable, computed } from 'mobx';
import type { User, Team } from 'types';
const USER_STORE = 'USER_STORE';
type Options = {
user: User,
team: Team,
};
class UserStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable oauthState: string;
@observable user: User;
@observable team: Team;
@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 = (cb: Function) => {
this.user = null;
this.token = null;
cb();
};
@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() {
constructor(options: Options) {
// Rehydrate
const data = JSON.parse(localStorage.getItem(USER_STORE) || '{}');
this.user = data.user;
this.team = data.team;
this.token = data.token;
this.oauthState = data.oauthState;
this.user = options.user;
this.team = options.team;
}
}
export default UserStore;
export { USER_STORE };

View File

@ -1,20 +1,13 @@
// @flow
import { autorunAsync } from 'mobx';
import UserStore, { USER_STORE } from './UserStore';
import UiStore, { UI_STORE } from './UiStore';
import AuthStore from './AuthStore';
import UiStore from './UiStore';
import ErrorsStore from './ErrorsStore';
const stores = {
user: new UserStore(),
user: null, // Including for Layout
auth: new AuthStore(),
ui: new UiStore(),
errors: new ErrorsStore(),
};
// Persist stores to localStorage
// TODO: move to store constructors
autorunAsync(() => {
localStorage.setItem(USER_STORE, stores.user.asJson);
localStorage.setItem(UI_STORE, stores.ui.asJson);
});
export default stores;

View File

@ -10,4 +10,6 @@ $headerHeight: 42px;
:export {
textColor: $textColor;
actionColor: $actionColor;
headerHeight: $headerHeight;
}

View File

@ -1,5 +1,6 @@
// @flow
import _ from 'lodash';
import invariant from 'invariant';
import stores from 'stores';
type Options = {
@ -39,9 +40,9 @@ class ApiClient {
Accept: 'application/json',
'Content-Type': 'application/json',
});
if (stores.user.authenticated) {
// $FlowFixMe this is not great, need to refactor
headers.set('Authorization', `Bearer ${stores.user.token}`);
if (stores.auth.authenticated) {
invariant(stores.auth.token, 'JWT token not set properly');
headers.set('Authorization', `Bearer ${stores.auth.token}`);
}
// Construct request
@ -62,15 +63,10 @@ class ApiClient {
return response;
}
// // Handle 404
// if (response.status === 404) {
// // return browserHistory.push('/404');
// }
// // Handle 401, log out user
// if (response.status === 401) {
// return stores.user.logout();
// }
// Handle 401, log out user
if (response.status === 401) {
return stores.auth.logout(() => (window.location = '/'));
}
// Handle failed responses
const error = {};
@ -112,5 +108,4 @@ class ApiClient {
export default ApiClient;
// In case you don't want to always initiate, just import with `import { client } ...`
const client = new ApiClient();
export { client };
export const client = new ApiClient();

View File

@ -1,14 +1,18 @@
// @flow
export function homeUrl() {
export function homeUrl(): string {
return '/dashboard';
}
export function newCollectionUrl() {
export function newCollectionUrl(): string {
return '/collections/new';
}
export function searchUrl(query: string) {
export function searchUrl(query: string): string {
if (query) return `/search/${query}`;
return `/search`;
}
export function notFoundUrl(): string {
return '/404';
}

View File

@ -41,8 +41,7 @@
"json"
],
"moduleDirectories": [
"node_modules",
"server"
"node_modules"
],
"modulePaths": [
"frontend"
@ -188,4 +187,4 @@
"react-addons-test-utils": "^15.3.1",
"react-test-renderer": "^15.3.1"
}
}
}