Refactored collections store and layout components
This commit is contained in:
@ -11,6 +11,7 @@
|
|||||||
"flowtype"
|
"flowtype"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"no-unused-vars": 2,
|
||||||
// // Bring back after we remove CSS Modules 100%
|
// // Bring back after we remove CSS Modules 100%
|
||||||
// "import/order": "warn",
|
// "import/order": "warn",
|
||||||
// Prettier automatically uses the least amount of parens possible, so this
|
// Prettier automatically uses the least amount of parens possible, so this
|
||||||
|
@ -2,12 +2,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import moment from 'moment';
|
||||||
import DocumentLink from './components/DocumentLink';
|
|
||||||
|
|
||||||
import styles from './Collection.scss';
|
import styles from './Collection.scss';
|
||||||
// import classNames from 'classnames/bind';
|
|
||||||
// const cx = classNames.bind(styles);
|
|
||||||
|
|
||||||
@observer class Collection extends React.Component {
|
@observer class Collection extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -24,7 +21,18 @@ import styles from './Collection.scss';
|
|||||||
</h2>
|
</h2>
|
||||||
{data.recentDocuments.length > 0
|
{data.recentDocuments.length > 0
|
||||||
? data.recentDocuments.map(document => {
|
? 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}>
|
: <div className={styles.description}>
|
||||||
No documents. Why not
|
No documents. Why not
|
||||||
|
@ -17,3 +17,25 @@
|
|||||||
.description {
|
.description {
|
||||||
color: #aaa;
|
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;
|
||||||
|
}
|
||||||
|
@ -12,13 +12,15 @@ import MarkdownShortcuts from './plugins/MarkdownShortcuts';
|
|||||||
|
|
||||||
const onlyInCode = node => node.type === 'code';
|
const onlyInCode = node => node.type === 'code';
|
||||||
|
|
||||||
|
type CreatePluginsOptions = {
|
||||||
|
onImageUploadStart: Function,
|
||||||
|
onImageUploadStop: Function,
|
||||||
|
};
|
||||||
|
|
||||||
const createPlugins = ({
|
const createPlugins = ({
|
||||||
onImageUploadStart,
|
onImageUploadStart,
|
||||||
onImageUploadStop,
|
onImageUploadStop,
|
||||||
}: {
|
}: CreatePluginsOptions) => {
|
||||||
onImageUploadStart: Function,
|
|
||||||
onImageUploadStop: Function,
|
|
||||||
}) => {
|
|
||||||
return [
|
return [
|
||||||
PasteLinkify({
|
PasteLinkify({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
@ -6,24 +6,19 @@ import styled from 'styled-components';
|
|||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import keydown from 'react-keydown';
|
import keydown from 'react-keydown';
|
||||||
import classNames from 'classnames/bind';
|
|
||||||
import searchIcon from 'assets/icons/search.svg';
|
import searchIcon from 'assets/icons/search.svg';
|
||||||
import { Flex } from 'reflexbox';
|
import { Flex } from 'reflexbox';
|
||||||
import { textColor } from 'styles/constants.scss';
|
import { textColor, headerHeight } from 'styles/constants.scss';
|
||||||
import styles from './Layout.scss';
|
|
||||||
|
|
||||||
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
|
import DropdownMenu, { MenuItem } from 'components/DropdownMenu';
|
||||||
import LoadingIndicator from 'components/LoadingIndicator';
|
import LoadingIndicator from 'components/LoadingIndicator';
|
||||||
import UserStore from 'stores/UserStore';
|
import UserStore from 'stores/UserStore';
|
||||||
|
|
||||||
const cx = classNames.bind(styles);
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: Object,
|
history: Object,
|
||||||
children?: ?React.Element<any>,
|
children?: ?React.Element<any>,
|
||||||
actions?: ?React.Element<any>,
|
actions?: ?React.Element<any>,
|
||||||
title?: ?React.Element<any>,
|
title?: ?React.Element<any>,
|
||||||
titleText?: string,
|
|
||||||
loading?: boolean,
|
loading?: boolean,
|
||||||
user: UserStore,
|
user: UserStore,
|
||||||
search: ?boolean,
|
search: ?boolean,
|
||||||
@ -57,11 +52,9 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<Container column auto>
|
||||||
<Helmet
|
<Helmet
|
||||||
title={
|
title="Atlas"
|
||||||
this.props.titleText ? `${this.props.titleText} - Atlas` : 'Atlas'
|
|
||||||
}
|
|
||||||
meta={[
|
meta={[
|
||||||
{
|
{
|
||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
@ -74,16 +67,16 @@ type Props = {
|
|||||||
|
|
||||||
{this.props.notifications}
|
{this.props.notifications}
|
||||||
|
|
||||||
<div className={cx(styles.header)}>
|
<Header>
|
||||||
<div className={styles.headerLeft}>
|
<Flex align="center">
|
||||||
<Link to="/" className={styles.team}>Atlas</Link>
|
<LogoLink to="/">Atlas</LogoLink>
|
||||||
<span className={styles.title}>
|
<Title>
|
||||||
{this.props.title}
|
{this.props.title}
|
||||||
</span>
|
</Title>
|
||||||
</div>
|
</Flex>
|
||||||
<Flex className={styles.headerRight}>
|
<Flex>
|
||||||
<Flex>
|
<Flex>
|
||||||
<Flex align="center" className={styles.actions}>
|
<Flex align="center">
|
||||||
{this.props.actions}
|
{this.props.actions}
|
||||||
</Flex>
|
</Flex>
|
||||||
{user.user &&
|
{user.user &&
|
||||||
@ -91,9 +84,9 @@ type Props = {
|
|||||||
{this.props.search &&
|
{this.props.search &&
|
||||||
<Flex>
|
<Flex>
|
||||||
<Link to="/search">
|
<Link to="/search">
|
||||||
<div className={styles.search} title="Search (/)">
|
<Search title="Search (/)">
|
||||||
<img src={searchIcon} alt="Search" />
|
<SearchIcon src={searchIcon} alt="Search" />
|
||||||
</div>
|
</Search>
|
||||||
</Link>
|
</Link>
|
||||||
</Flex>}
|
</Flex>}
|
||||||
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>
|
<DropdownMenu label={<Avatar src={user.user.avatarUrl} />}>
|
||||||
@ -113,16 +106,67 @@ type Props = {
|
|||||||
</Flex>}
|
</Flex>}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</Header>
|
||||||
|
|
||||||
<div className={cx(styles.content)}>
|
<Content auto justify="center">
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</Content>
|
||||||
</div>
|
</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`
|
const Avatar = styled.img`
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@ -133,4 +177,9 @@ const MenuLink = styled(Link)`
|
|||||||
color: ${textColor};
|
color: ${textColor};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Content = styled(Flex)`
|
||||||
|
height: 100%;
|
||||||
|
overflow: scroll;
|
||||||
|
`;
|
||||||
|
|
||||||
export default withRouter(inject('user')(Layout));
|
export default withRouter(inject('user')(Layout));
|
||||||
|
@ -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;
|
|
||||||
}
|
|
11
frontend/components/PageTitle/PageTitle.js
Normal file
11
frontend/components/PageTitle/PageTitle.js
Normal 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;
|
3
frontend/components/PageTitle/index.js
Normal file
3
frontend/components/PageTitle/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import PageTitle from './PageTitle';
|
||||||
|
export default PageTitle;
|
@ -11,6 +11,7 @@ import {
|
|||||||
import { Flex } from 'reflexbox';
|
import { Flex } from 'reflexbox';
|
||||||
|
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
|
||||||
import 'normalize.css/normalize.css';
|
import 'normalize.css/normalize.css';
|
||||||
import 'styles/base.scss';
|
import 'styles/base.scss';
|
||||||
@ -21,7 +22,7 @@ import 'styles/hljs-github-gist.scss';
|
|||||||
|
|
||||||
import Home from 'scenes/Home';
|
import Home from 'scenes/Home';
|
||||||
import Dashboard from 'scenes/Dashboard';
|
import Dashboard from 'scenes/Dashboard';
|
||||||
import Atlas from 'scenes/Atlas';
|
import Collection from 'scenes/Collection';
|
||||||
import Document from 'scenes/Document';
|
import Document from 'scenes/Document';
|
||||||
import Search from 'scenes/Search';
|
import Search from 'scenes/Search';
|
||||||
import Settings from 'scenes/Settings';
|
import Settings from 'scenes/Settings';
|
||||||
@ -37,13 +38,35 @@ if (__DEV__) {
|
|||||||
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
|
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authenticatedStores;
|
||||||
|
|
||||||
type AuthProps = {
|
type AuthProps = {
|
||||||
children?: React.Element<any>,
|
children?: React.Element<any>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Auth = ({ children }: AuthProps) => {
|
const Auth = ({ children }: AuthProps) => {
|
||||||
if (stores.user.authenticated) {
|
if (stores.user.authenticated && stores.user.team) {
|
||||||
return <Flex auto>{children}</Flex>;
|
// 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 {
|
} else {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
}
|
}
|
||||||
@ -64,10 +87,14 @@ render(
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Home} />
|
<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>
|
<Auth>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/dashboard" component={Dashboard} />
|
<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" component={Document} />
|
||||||
<Route exact path="/d/:id/:edit" component={Document} />
|
<Route exact path="/d/:id/:edit" component={Document} />
|
||||||
<Route
|
<Route
|
||||||
@ -81,10 +108,6 @@ render(
|
|||||||
<Route exact path="/search/:query" component={Search} />
|
<Route exact path="/search/:query" component={Search} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<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
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/keyboard-shortcuts"
|
path="/keyboard-shortcuts"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { extendObservable, action, computed, runInAction } from 'mobx';
|
import { extendObservable, action, runInAction } from 'mobx';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
@ -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;
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
// @flow
|
|
||||||
import Atlas from './Atlas';
|
|
||||||
export default Atlas;
|
|
65
frontend/scenes/Collection/Collection.js
Normal file
65
frontend/scenes/Collection/Collection.js
Normal 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);
|
3
frontend/scenes/Collection/index.js
Normal file
3
frontend/scenes/Collection/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import Collection from './Collection';
|
||||||
|
export default Collection;
|
@ -1,10 +1,9 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { Flex } from 'reflexbox';
|
import { Flex } from 'reflexbox';
|
||||||
|
|
||||||
import DashboardStore from './DashboardStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
|
||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
import Collection from 'components/Collection';
|
import Collection from 'components/Collection';
|
||||||
@ -12,37 +11,24 @@ import PreviewLoading from 'components/PreviewLoading';
|
|||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: Object,
|
collections: CollectionsStore,
|
||||||
router: Object,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@withRouter
|
@observer class Dashboard extends React.Component {
|
||||||
@inject('user')
|
|
||||||
@observer
|
|
||||||
class Dashboard extends React.Component {
|
|
||||||
props: Props;
|
props: Props;
|
||||||
store: DashboardStore;
|
|
||||||
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.store = new DashboardStore({
|
|
||||||
team: props.user.team,
|
|
||||||
router: props.router,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { collections } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<Flex column auto>
|
<Flex column auto>
|
||||||
{this.store.isFetching
|
{!collections.isLoaded
|
||||||
? <PreviewLoading />
|
? <PreviewLoading />
|
||||||
: this.store.collections &&
|
: collections.data.map(collection => (
|
||||||
this.store.collections.map(collection => (
|
<Collection key={collection.id} data={collection} />
|
||||||
<Collection key={collection.id} data={collection} />
|
))}
|
||||||
))}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
</Layout>
|
</Layout>
|
||||||
@ -50,4 +36,4 @@ class Dashboard extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
export default inject('collections')(Dashboard);
|
||||||
|
@ -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;
|
|
@ -14,6 +14,7 @@ import Layout, { HeaderAction, SaveAction } from 'components/Layout';
|
|||||||
import PublishingInfo from 'components/PublishingInfo';
|
import PublishingInfo from 'components/PublishingInfo';
|
||||||
import PreviewLoading from 'components/PreviewLoading';
|
import PreviewLoading from 'components/PreviewLoading';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
const DISCARD_CHANGES = `
|
const DISCARD_CHANGES = `
|
||||||
You have unsaved changes.
|
You have unsaved changes.
|
||||||
@ -103,9 +104,7 @@ type Props = {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const titleText =
|
const titleText = this.store.document && get(this.store, 'document.title');
|
||||||
this.store.document &&
|
|
||||||
`${get(this.store, 'document.collection.name')} - ${get(this.store, 'document.title')}`;
|
|
||||||
|
|
||||||
const actions = (
|
const actions = (
|
||||||
<Flex>
|
<Flex>
|
||||||
@ -130,11 +129,11 @@ type Props = {
|
|||||||
<Layout
|
<Layout
|
||||||
actions={actions}
|
actions={actions}
|
||||||
title={title}
|
title={title}
|
||||||
titleText={titleText}
|
|
||||||
loading={this.store.isSaving || this.store.isUploading}
|
loading={this.store.isSaving || this.store.isUploading}
|
||||||
search={false}
|
search={false}
|
||||||
fixed
|
fixed
|
||||||
>
|
>
|
||||||
|
<PageTitle title={titleText} />
|
||||||
<Prompt when={this.store.hasPendingChanges} message={DISCARD_CHANGES} />
|
<Prompt when={this.store.hasPendingChanges} message={DISCARD_CHANGES} />
|
||||||
{this.store.isFetching &&
|
{this.store.isFetching &&
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
|
@ -4,11 +4,13 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
class Error404 extends React.Component {
|
class Error404 extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Layout titleText="Not Found">
|
<Layout>
|
||||||
|
<PageTitle title="Not found" />
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<h1>Not Found</h1>
|
<h1>Not Found</h1>
|
||||||
|
|
||||||
|
@ -4,11 +4,13 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
class ErrorAuth extends React.Component {
|
class ErrorAuth extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Layout titleText="Not Found">
|
<Layout>
|
||||||
|
<PageTitle title="Authentication error" />
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<h1>Authentication failed</h1>
|
<h1>Authentication failed</h1>
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { observer } from 'mobx-react';
|
|||||||
import Layout, { Title } from 'components/Layout';
|
import Layout, { Title } from 'components/Layout';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import { DocumentHtml } from 'components/Document';
|
import { DocumentHtml } from 'components/Document';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
import { convertToMarkdown } from 'utils/markdown';
|
import { convertToMarkdown } from 'utils/markdown';
|
||||||
|
|
||||||
@ -20,11 +21,8 @@ type Props = {
|
|||||||
const { title, content } = this.props;
|
const { title, content } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout title={<Title content={title} />} search={false}>
|
||||||
title={<Title content={title} />}
|
<PageTitle title={title} />
|
||||||
titleText={title}
|
|
||||||
search={false}
|
|
||||||
>
|
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
<DocumentHtml html={convertToMarkdown(content)} />
|
<DocumentHtml html={convertToMarkdown(content)} />
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
|
@ -13,6 +13,7 @@ import SearchStore from './SearchStore';
|
|||||||
import Layout, { Title } from 'components/Layout';
|
import Layout, { Title } from 'components/Layout';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import DocumentPreview from 'components/DocumentPreview';
|
import DocumentPreview from 'components/DocumentPreview';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: Object,
|
history: Object,
|
||||||
@ -56,12 +57,8 @@ type Props = {
|
|||||||
const title = <Title content="Search" />;
|
const title = <Title content="Search" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout title={title} search={false} loading={this.store.isFetching}>
|
||||||
title={title}
|
<PageTitle title="Search" />
|
||||||
titleText="Search"
|
|
||||||
search={false}
|
|
||||||
loading={this.store.isFetching}
|
|
||||||
>
|
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
{this.props.notFound &&
|
{this.props.notFound &&
|
||||||
<div>
|
<div>
|
||||||
|
@ -11,6 +11,7 @@ import SettingsStore from './SettingsStore';
|
|||||||
import Layout, { Title } from 'components/Layout';
|
import Layout, { Title } from 'components/Layout';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import SlackAuthLink from 'components/SlackAuthLink';
|
import SlackAuthLink from 'components/SlackAuthLink';
|
||||||
|
import PageTitle from 'components/PageTitle';
|
||||||
|
|
||||||
@observer class Settings extends React.Component {
|
@observer class Settings extends React.Component {
|
||||||
store: SettingsStore;
|
store: SettingsStore;
|
||||||
@ -25,12 +26,8 @@ import SlackAuthLink from 'components/SlackAuthLink';
|
|||||||
const showSlackSettings = DEPLOYMENT === 'hosted';
|
const showSlackSettings = DEPLOYMENT === 'hosted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout title={title} search={false} loading={this.store.isFetching}>
|
||||||
title={title}
|
<PageTitle title="Settings" />
|
||||||
titleText="Settings"
|
|
||||||
search={false}
|
|
||||||
loading={this.store.isFetching}
|
|
||||||
>
|
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
{showSlackSettings &&
|
{showSlackSettings &&
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
|
@ -17,7 +17,8 @@ type Props = {
|
|||||||
class SlackAuth extends React.Component {
|
class SlackAuth extends React.Component {
|
||||||
props: Props;
|
props: Props;
|
||||||
|
|
||||||
state: { redirectTo: string };
|
state: { redirectTo?: string };
|
||||||
|
state = {};
|
||||||
|
|
||||||
// $FlowFixMe not sure why this breaks
|
// $FlowFixMe not sure why this breaks
|
||||||
componentDidMount = async () => {
|
componentDidMount = async () => {
|
||||||
|
59
frontend/stores/CollectionsStore.js
Normal file
59
frontend/stores/CollectionsStore.js
Normal 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;
|
54
frontend/stores/CollectionsStore.test.js
Normal file
54
frontend/stores/CollectionsStore.test.js
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -10,4 +10,6 @@ $headerHeight: 42px;
|
|||||||
:export {
|
:export {
|
||||||
textColor: $textColor;
|
textColor: $textColor;
|
||||||
actionColor: $actionColor;
|
actionColor: $actionColor;
|
||||||
|
|
||||||
|
headerHeight: $headerHeight;
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
export function homeUrl() {
|
export function homeUrl(): string {
|
||||||
return '/dashboard';
|
return '/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newCollectionUrl() {
|
export function newCollectionUrl(): string {
|
||||||
return '/collections/new';
|
return '/collections/new';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchUrl(query: string) {
|
export function searchUrl(query: string): string {
|
||||||
if (query) return `/search/${query}`;
|
if (query) return `/search/${query}`;
|
||||||
return `/search`;
|
return `/search`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notFoundUrl(): string {
|
||||||
|
return '/404';
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user