Merge pull request #747 from outline/dashboard-tabs

Dashboard Tabs
This commit is contained in:
Tom Moor
2018-08-10 23:10:12 -07:00
committed by GitHub
15 changed files with 329 additions and 244 deletions

View File

@@ -0,0 +1,34 @@
// @flow
import * as React from 'react';
import Document from 'models/Document';
import DocumentPreview from 'components/DocumentPreview';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
type Props = {
documents: Document[],
showCollection?: boolean,
limit?: number,
};
export default function DocumentList({
limit,
showCollection,
documents,
}: Props) {
const items = limit ? documents.splice(0, limit) : documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.map(document => (
<DocumentPreview
key={document.id}
document={document}
showCollection={showCollection}
/>
))}
</ArrowKeyNavigation>
);
}

View File

@@ -1,37 +0,0 @@
// @flow
import * as React from 'react';
import Document from 'models/Document';
import DocumentPreview from 'components/DocumentPreview';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
type Props = {
documents: Document[],
showCollection?: boolean,
limit?: number,
};
class DocumentList extends React.Component<Props> {
render() {
const { limit, showCollection } = this.props;
const documents = limit
? this.props.documents.splice(0, limit)
: this.props.documents;
return (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{documents.map(document => (
<DocumentPreview
key={document.id}
document={document}
showCollection={showCollection}
/>
))}
</ArrowKeyNavigation>
);
}
}
export default DocumentList;

View File

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

View File

@@ -24,31 +24,38 @@ type Props = {
};
function PublishingInfo({ collection, document }: Props) {
const { modifiedSinceViewed, updatedAt, updatedBy, publishedAt } = document;
const {
modifiedSinceViewed,
updatedAt,
updatedBy,
publishedAt,
isDraft,
} = document;
const neverUpdated = publishedAt === updatedAt;
return (
<Container align="center">
{publishedAt && publishedAt === updatedAt ? (
{publishedAt && neverUpdated ? (
<span>
{updatedBy.name} published <Time dateTime={publishedAt} /> ago
</span>
) : (
<React.Fragment>
{updatedBy.name}
{publishedAt ? (
<Modified highlight={modifiedSinceViewed}>
&nbsp;modified <Time dateTime={updatedAt} /> ago
</Modified>
) : (
{isDraft ? (
<span>
&nbsp;saved <Time dateTime={updatedAt} /> ago
</span>
) : (
<Modified highlight={modifiedSinceViewed}>
&nbsp;modified <Time dateTime={updatedAt} /> ago
</Modified>
)}
</React.Fragment>
)}
{collection && (
<span>
&nbsp;in <strong>{collection.name}</strong>
&nbsp;in <strong>{isDraft ? 'Drafts' : collection.name}</strong>
</span>
)}
</Container>

View File

@@ -0,0 +1,78 @@
// @flow
import * as React from 'react';
import { observable, action } from 'mobx';
import { observer } from 'mobx-react';
import Waypoint from 'react-waypoint';
import { DEFAULT_PAGINATION_LIMIT } from 'stores/DocumentsStore';
import Document from 'models/Document';
import DocumentList from 'components/DocumentList';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
type Props = {
showCollection?: boolean,
documents: Document[],
fetch: (options: ?Object) => Promise<*>,
options?: Object,
};
@observer
class PaginatedDocumentList extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
componentDidMount() {
this.fetchResults();
}
componentDidUpdate(prevProps: Props) {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.fetch({ limit, ...this.props.options });
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
render() {
const { showCollection, documents } = this.props;
return this.isLoaded || documents.length ? (
<React.Fragment>
<DocumentList documents={documents} showCollection={showCollection} />
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</React.Fragment>
) : (
<ListPlaceholder count={5} />
);
}
}
export default PaginatedDocumentList;

View File

@@ -56,7 +56,7 @@ class MainSidebar extends React.Component<Props> {
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink to="/dashboard" icon={<HomeIcon />}>
<SidebarLink to="/dashboard" icon={<HomeIcon />} exact={false}>
Home
</SidebarLink>
<SidebarLink to="/search" icon={<SearchIcon />}>

View File

@@ -52,6 +52,7 @@ type Props = {
iconColor?: string,
active?: boolean,
theme: Object,
exact?: boolean,
};
@observer
@@ -100,6 +101,7 @@ class SidebarLink extends React.Component<Props> {
menu,
menuOpen,
hideExpandToggle,
exact,
} = this.props;
const Component = to ? StyledNavLink : StyledDiv;
const showExpandIcon =
@@ -113,7 +115,7 @@ class SidebarLink extends React.Component<Props> {
style={active ? this.activeStyle : undefined}
onClick={onClick}
to={to}
exact
exact={exact !== false}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
{showExpandIcon && (

26
app/components/Tab.js Normal file
View File

@@ -0,0 +1,26 @@
// @flow
import * as React from 'react';
import styled, { withTheme } from 'styled-components';
import { NavLink } from 'react-router-dom';
const NavItem = styled(NavLink)`
display: inline-block;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: ${props => props.theme.slate};
letter-spacing: 0.04em;
margin-right: 20px;
padding-bottom: 8px;
`;
function Tab(props: *) {
const activeStyle = {
paddingBottom: '5px',
borderBottom: `3px solid ${props.theme.slateLight}`,
};
return <NavItem {...props} activeStyle={activeStyle} />;
}
export default withTheme(Tab);

10
app/components/Tabs.js Normal file
View File

@@ -0,0 +1,10 @@
// @flow
import styled from 'styled-components';
const Tabs = styled.nav`
border-bottom: 1px solid ${props => props.theme.slateLight};
margin-top: 22px;
margin-bottom: 10px;
`;
export default Tabs;

View File

@@ -3,54 +3,23 @@ import * as React from 'react';
import { render } from 'react-dom';
import { Provider } from 'mobx-react';
import { ThemeProvider } from 'styled-components';
import {
BrowserRouter as Router,
Switch,
Route,
Redirect,
} from 'react-router-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import stores from 'stores';
import theme from 'shared/styles/theme';
import globalStyles from 'shared/styles/globals';
import 'shared/styles/prism.css';
import Home from 'scenes/Home';
import Dashboard from 'scenes/Dashboard';
import Starred from 'scenes/Starred';
import Drafts from 'scenes/Drafts';
import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import Details from 'scenes/Settings/Details';
import People from 'scenes/Settings/People';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens';
import Export from 'scenes/Settings/Export';
import Error404 from 'scenes/Error404';
import ErrorBoundary from 'components/ErrorBoundary';
import ScrollToTop from 'components/ScrollToTop';
import ScrollToAnchor from 'components/ScrollToAnchor';
import Layout from 'components/Layout';
import Auth from 'components/Auth';
import RouteSidebarHidden from 'components/RouteSidebarHidden';
import { matchDocumentSlug } from 'utils/routeHelpers';
import Routes from './routes';
let DevTools;
if (__DEV__) {
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
}
const notFoundSearch = () => <Search notFound />;
const DocumentNew = () => <Document newDocument />;
const RedirectDocument = ({ match }: { match: Object }) => (
<Redirect to={`/doc/${match.params.documentSlug}`} />
);
globalStyles();
const element = document.getElementById('root');
@@ -64,92 +33,7 @@ if (element) {
<Router>
<ScrollToTop>
<ScrollToAnchor>
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/share/:shareId" component={Document} />
<Auth>
<Layout>
<Switch>
<Route
exact
path="/dashboard"
component={Dashboard}
/>
<Route exact path="/starred" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/settings" component={Settings} />
<Route
exact
path="/settings/details"
component={Details}
/>
<Route
exact
path="/settings/people"
component={People}
/>
<Route
exact
path="/settings/shares"
component={Shares}
/>
<Route
exact
path="/settings/tokens"
component={Tokens}
/>
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/export"
component={Export}
/>
<Route
exact
path="/collections/:id"
component={Collection}
/>
<Route
exact
path={`/d/${matchDocumentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}`}
component={Document}
/>
<Route
exact
path={`/doc/${matchDocumentSlug}/move`}
component={Document}
/>
<Route exact path="/search" component={Search} />
<Route
exact
path="/search/:query"
component={Search}
/>
<Route path="/404" component={Error404} />
<RouteSidebarHidden
exact
path={`/doc/${matchDocumentSlug}/edit`}
component={Document}
/>
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={DocumentNew}
/>
<Route component={notFoundSearch} />
</Switch>
</Layout>
</Auth>
</Switch>
<Routes />
</ScrollToAnchor>
</ScrollToTop>
</Router>

78
app/routes.js Normal file
View File

@@ -0,0 +1,78 @@
// @flow
import * as React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';
import Home from 'scenes/Home';
import Dashboard from 'scenes/Dashboard';
import Starred from 'scenes/Starred';
import Drafts from 'scenes/Drafts';
import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import Details from 'scenes/Settings/Details';
import People from 'scenes/Settings/People';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens';
import Export from 'scenes/Settings/Export';
import Error404 from 'scenes/Error404';
import Layout from 'components/Layout';
import Auth from 'components/Auth';
import RouteSidebarHidden from 'components/RouteSidebarHidden';
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
const NotFound = () => <Search notFound />;
const DocumentNew = () => <Document newDocument />;
const RedirectDocument = ({ match }: { match: Object }) => (
<Redirect to={`/doc/${match.params.documentSlug}`} />
);
export default function Routes() {
return (
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/share/:shareId" component={Document} />
<Auth>
<Layout>
<Switch>
<Route path="/dashboard/:tab" component={Dashboard} />
<Route path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route exact path="/settings/export" component={Export} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route exact path={`/doc/${slug}`} component={Document} />
<Route exact path={`/doc/${slug}/move`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />
<RouteSidebarHidden
exact
path={`/doc/${slug}/edit`}
component={Document}
/>
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={DocumentNew}
/>
<Route component={NotFound} />
</Switch>
</Layout>
</Auth>
</Switch>
);
}

View File

@@ -61,7 +61,7 @@ class CollectionScene extends React.Component<Props> {
this.collection = collection;
await Promise.all([
this.props.documents.fetchRecentlyModified({
this.props.documents.fetchRecentlyEdited({
limit: 10,
collection: id,
}),

View File

@@ -1,83 +1,75 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { Switch, Route } from 'react-router-dom';
import { observer, inject } from 'mobx-react';
import { NewDocumentIcon } from 'outline-icons';
import DocumentsStore from 'stores/DocumentsStore';
import AuthStore from 'stores/AuthStore';
import NewDocumentMenu from 'menus/NewDocumentMenu';
import Actions, { Action } from 'components/Actions';
import CenteredContent from 'components/CenteredContent';
import DocumentList from 'components/DocumentList';
import PageTitle from 'components/PageTitle';
import Subheading from 'components/Subheading';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
import Tabs from 'components/Tabs';
import Tab from 'components/Tab';
import PaginatedDocumentList from '../components/PaginatedDocumentList';
type Props = {
documents: DocumentsStore,
auth: AuthStore,
};
@observer
class Dashboard extends React.Component<Props> {
@observable isLoaded: boolean = false;
componentDidMount() {
this.loadContent();
}
loadContent = async () => {
await Promise.all([
this.props.documents.fetchRecentlyModified({ limit: 5 }),
this.props.documents.fetchRecentlyViewed({ limit: 5 }),
]);
this.isLoaded = true;
};
render() {
const { documents } = this.props;
const hasRecentlyViewed = documents.recentlyViewed.length > 0;
const hasRecentlyEdited = documents.recentlyEdited.length > 0;
const showContent =
this.isLoaded || (hasRecentlyViewed && hasRecentlyEdited);
const { documents, auth } = this.props;
if (!auth.user) return;
const user = auth.user.id;
return (
<CenteredContent>
<PageTitle title="Home" />
<h1>Home</h1>
{showContent ? (
<React.Fragment>
{hasRecentlyViewed && (
<React.Fragment>
<Subheading key="viewed">Recently viewed</Subheading>
<DocumentList
key="viewedDocuments"
documents={documents.recentlyViewed}
showCollection
/>
</React.Fragment>
)}
{hasRecentlyEdited && (
<React.Fragment>
<Subheading key="edited">Recently edited</Subheading>
<DocumentList
key="editedDocuments"
documents={documents.recentlyEdited}
showCollection
/>
</React.Fragment>
)}
<Actions align="center" justify="flex-end">
<Action>
<NewDocumentMenu label={<NewDocumentIcon />} />
</Action>
</Actions>
</React.Fragment>
) : (
<ListPlaceholder count={5} />
)}
<Tabs>
<Tab to="/dashboard" exact>
Recently updated
</Tab>
<Tab to="/dashboard/recent" exact>
Recently viewed
</Tab>
<Tab to="/dashboard/created">Created by me</Tab>
</Tabs>
<Switch>
<Route path="/dashboard/recent">
<PaginatedDocumentList
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
/>
</Route>
<Route path="/dashboard/created">
<PaginatedDocumentList
key="created"
documents={documents.createdByUser(user)}
fetch={documents.fetchOwned}
options={{ user }}
/>
</Route>
<Route path="/dashboard">
<PaginatedDocumentList
documents={documents.recentlyEdited}
fetch={documents.fetchRecentlyEdited}
/>
</Route>
</Switch>
<Actions align="center" justify="flex-end">
<Action>
<NewDocumentMenu label={<NewDocumentIcon />} />
</Action>
</Actions>
</CenteredContent>
);
}
}
export default inject('documents')(Dashboard);
export default inject('documents', 'auth')(Dashboard);

View File

@@ -21,18 +21,16 @@ type FetchOptions = {
};
class DocumentsStore extends BaseStore {
@observable recentlyViewedIds: Array<string> = [];
@observable recentlyEditedIds: Array<string> = [];
@observable recentlyViewedIds: string[] = [];
@observable recentlyEditedIds: string[] = [];
@observable data: Map<string, Document> = new ObservableMap([]);
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
ui: UiStore;
/* Computed */
@computed
get recentlyViewed(): Array<Document> {
get recentlyViewed(): Document[] {
const docs = [];
this.recentlyViewedIds.forEach(id => {
const doc = this.getById(id);
@@ -51,6 +49,17 @@ class DocumentsStore extends BaseStore {
return docs;
}
createdByUser(userId: string): Document[] {
return _.orderBy(
_.filter(
this.data.values(),
document => document.createdBy.id === userId
),
'updatedAt',
'desc'
);
}
pinnedInCollection(collectionId: string): Document[] {
return _.filter(
this.recentlyEditedInCollection(collectionId),
@@ -118,10 +127,10 @@ class DocumentsStore extends BaseStore {
};
@action
fetchRecentlyModified = async (options: ?PaginationParams): Promise<*> => {
fetchRecentlyEdited = async (options: ?PaginationParams): Promise<*> => {
const data = await this.fetchPage('list', options);
runInAction('DocumentsStore#fetchRecentlyModified', () => {
runInAction('DocumentsStore#fetchRecentlyEdited', () => {
this.recentlyEditedIds = _.map(data, 'id');
});
return data;
@@ -138,18 +147,23 @@ class DocumentsStore extends BaseStore {
};
@action
fetchStarred = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('starred', options);
fetchStarred = (options: ?PaginationParams): Promise<*> => {
return this.fetchPage('starred', options);
};
@action
fetchDrafts = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('drafts', options);
fetchDrafts = (options: ?PaginationParams): Promise<*> => {
return this.fetchPage('drafts', options);
};
@action
fetchPinned = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('pinned', options);
fetchPinned = (options: ?PaginationParams): Promise<*> => {
return this.fetchPage('pinned', options);
};
@action
fetchOwned = (options: ?PaginationParams): Promise<*> => {
return this.fetchPage('list', options);
};
@action
@@ -261,11 +275,11 @@ class DocumentsStore extends BaseStore {
// Re-fetch dashboard content so that we don't show deleted documents
this.on('collections.delete', () => {
this.fetchRecentlyModified();
this.fetchRecentlyEdited();
this.fetchRecentlyViewed();
});
this.on('documents.delete', () => {
this.fetchRecentlyModified();
this.fetchRecentlyEdited();
this.fetchRecentlyViewed();
});
}

View File

@@ -14,14 +14,14 @@ const { authorize, cannot } = policy;
const router = new Router();
router.post('documents.list', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction, collection } = ctx.body;
let { sort = 'updatedAt', direction, collection, user } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
const user = ctx.state.user;
let where = { teamId: user.teamId };
let where = { teamId: ctx.state.user.teamId };
if (collection) where = { ...where, collectionId: collection };
if (user) where = { ...where, createdById: user };
const starredScope = { method: ['withStarred', user.id] };
const starredScope = { method: ['withStarred', ctx.state.user.id] };
const documents = await Document.scope('defaultScope', starredScope).findAll({
where,
order: [[sort, direction]],