Refactored collections store and layout components

This commit is contained in:
Jori Lallo 2017-05-26 12:56:10 -07:00
parent 1247fd1cd2
commit 201f90030c
29 changed files with 382 additions and 348 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

@ -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,24 +6,19 @@ 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);
type Props = {
history: Object,
children?: ?React.Element<any>,
actions?: ?React.Element<any>,
title?: ?React.Element<any>,
titleText?: string,
loading?: boolean,
user: UserStore,
search: ?boolean,
@ -57,11 +52,9 @@ type 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,16 +67,16 @@ 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 &&
@ -91,9 +84,9 @@ type Props = {
{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} />}>
@ -113,16 +106,67 @@ type Props = {
</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 +177,9 @@ const MenuLink = styled(Link)`
color: ${textColor};
`;
const Content = styled(Flex)`
height: 100%;
overflow: scroll;
`;
export default withRouter(inject('user')(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

@ -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,35 @@ 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.user.authenticated && stores.user.team) {
// Only initialize stores once. Kept in global scope
// because otherwise they will get overriden on route
// change
if (!authenticatedStores) {
// Stores for authenticated user
authenticatedStores = {
collections: new CollectionsStore({
teamId: stores.user.team.id,
}),
};
authenticatedStores.collections.fetch();
}
return (
<Flex auto>
<Provider {...authenticatedStores}>
{children}
</Provider>
</Flex>
);
} else {
return <Redirect to="/" />;
}
@ -64,10 +87,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 +108,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 AtlasPreviewLoading from 'components/AtlasPreviewLoading';
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>
<AtlasPreviewLoading />
</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

@ -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

@ -17,7 +17,8 @@ type Props = {
class SlackAuth extends React.Component {
props: Props;
state: { redirectTo: string };
state: { redirectTo?: string };
state = {};
// $FlowFixMe not sure why this breaks
componentDidMount = async () => {

View File

@ -0,0 +1,59 @@
// @flow
import { observable, action, runInAction, ObservableArray } from 'mobx';
import ApiClient, { client } from 'utils/ApiClient';
import _ from 'lodash';
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,
});
const { data } = res;
runInAction('CollectionsStore#fetch', () => {
this.data.replace(
data.map(
collection =>
new Collection({
...collection,
errors: this.errors,
})
)
);
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

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

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';
}