Draft Documents (#518)

* Mostly there

* Fix up specs

* Working scope, updated tests

* Don't record view on draft

* PR feedback

* Highlight drafts nav item

* Bugaboos

* Styling

* Refactoring, gradually addressing Jori feedback

* Show collection in drafts list
Flow fixes

* Ensure menu actions are hidden when draft
This commit is contained in:
Tom Moor 2018-02-27 22:41:12 -08:00 committed by GitHub
parent 79a0272230
commit 9142d975df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 519 additions and 194 deletions

View File

@ -7,7 +7,7 @@ import { layout, color } from 'shared/styles/constants';
export const Action = styled(Flex)` export const Action = styled(Flex)`
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0 0 0 10px; padding: 0 0 0 12px;
a { a {
color: ${color.text}; color: ${color.text};

View File

@ -6,18 +6,25 @@ import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
class DocumentList extends React.Component { class DocumentList extends React.Component {
props: { props: {
documents: Array<Document>, documents: Document[],
showCollection?: boolean,
}; };
render() { render() {
const { documents, showCollection } = this.props;
return ( return (
<ArrowKeyNavigation <ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL} mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0} defaultActiveChildIndex={0}
> >
{this.props.documents && {documents &&
this.props.documents.map(document => ( documents.map(document => (
<DocumentPreview key={document.id} document={document} /> <DocumentPreview
key={document.id}
document={document}
showCollection={showCollection}
/>
))} ))}
</ArrowKeyNavigation> </ArrowKeyNavigation>
); );

View File

@ -97,15 +97,16 @@ class DocumentPreview extends Component {
<DocumentLink to={document.url} innerRef={innerRef} {...rest}> <DocumentLink to={document.url} innerRef={innerRef} {...rest}>
<h3> <h3>
<Highlight text={document.title} highlight={highlight} /> <Highlight text={document.title} highlight={highlight} />
{document.starred ? ( {document.publishedAt &&
<span onClick={this.unstar}> (document.starred ? (
<StyledStar solid /> <span onClick={this.unstar}>
</span> <StyledStar solid />
) : ( </span>
<span onClick={this.star}> ) : (
<StyledStar /> <span onClick={this.star}>
</span> <StyledStar />
)} </span>
))}
</h3> </h3>
<PublishingInfo <PublishingInfo
document={document} document={document}

View File

@ -32,22 +32,28 @@ class PublishingInfo extends Component {
updatedAt, updatedAt,
createdBy, createdBy,
updatedBy, updatedBy,
publishedAt,
} = document; } = document;
const timeAgo = moment(createdAt).fromNow();
return ( return (
<Container align="center"> <Container align="center">
{createdAt === updatedAt ? ( {publishedAt === updatedAt ? (
<span> <span>
{createdBy.name} published {moment(createdAt).fromNow()} {createdBy.name} published {timeAgo}
</span> </span>
) : ( ) : (
<span> <React.Fragment>
{updatedBy.name} {updatedBy.name}
<Modified highlight={modifiedSinceViewed}> {publishedAt ? (
{' '} <Modified highlight={modifiedSinceViewed}>
modified {moment(updatedAt).fromNow()} &nbsp;modified {timeAgo}
</Modified> </Modified>
</span> ) : (
<span>&nbsp;saved {timeAgo}</span>
)}
</React.Fragment>
)} )}
{collection && ( {collection && (
<span> <span>

View File

@ -26,7 +26,7 @@ import { isModKey } from './utils';
type Props = { type Props = {
text: string, text: string,
onChange: Change => *, onChange: Change => *,
onSave: (redirect?: boolean) => *, onSave: ({ redirect?: boolean, publish?: boolean }) => *,
onCancel: () => void, onCancel: () => void,
onImageUploadStart: () => void, onImageUploadStart: () => void,
onImageUploadStop: () => void, onImageUploadStop: () => void,
@ -125,7 +125,7 @@ class MarkdownEditor extends Component {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.props.onSave(); this.props.onSave({ redirect: false });
} }
@keydown('meta+enter') @keydown('meta+enter')
@ -134,7 +134,7 @@ class MarkdownEditor extends Component {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.props.onSave(true); this.props.onSave({ redirect: true });
} }
@keydown('esc') @keydown('esc')

View File

@ -9,6 +9,7 @@ import AccountMenu from 'menus/AccountMenu';
import Sidebar, { Section } from './Sidebar'; import Sidebar, { Section } from './Sidebar';
import Scrollable from 'components/Scrollable'; import Scrollable from 'components/Scrollable';
import HomeIcon from 'components/Icon/HomeIcon'; import HomeIcon from 'components/Icon/HomeIcon';
import EditIcon from 'components/Icon/EditIcon';
import SearchIcon from 'components/Icon/SearchIcon'; import SearchIcon from 'components/Icon/SearchIcon';
import StarredIcon from 'components/Icon/StarredIcon'; import StarredIcon from 'components/Icon/StarredIcon';
import Collections from './components/Collections'; import Collections from './components/Collections';
@ -16,12 +17,14 @@ import SidebarLink from './components/SidebarLink';
import HeaderBlock from './components/HeaderBlock'; import HeaderBlock from './components/HeaderBlock';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
type Props = { type Props = {
history: Object, history: Object,
location: Location, location: Location,
auth: AuthStore, auth: AuthStore,
documents: DocumentsStore,
ui: UiStore, ui: UiStore,
}; };
@ -38,7 +41,7 @@ class MainSidebar extends Component {
}; };
render() { render() {
const { auth } = this.props; const { auth, documents } = this.props;
const { user, team } = auth; const { user, team } = auth;
if (!user || !team) return; if (!user || !team) return;
@ -65,6 +68,15 @@ class MainSidebar extends Component {
<SidebarLink to="/starred" icon={<StarredIcon />}> <SidebarLink to="/starred" icon={<StarredIcon />}>
Starred Starred
</SidebarLink> </SidebarLink>
<SidebarLink
to="/drafts"
icon={<EditIcon />}
active={
documents.active ? !documents.active.publishedAt : undefined
}
>
Drafts
</SidebarLink>
</Section> </Section>
<Section> <Section>
<Collections <Collections
@ -80,4 +92,6 @@ class MainSidebar extends Component {
} }
} }
export default withRouter(inject('user', 'auth', 'ui')(MainSidebar)); export default withRouter(
inject('user', 'documents', 'auth', 'ui')(MainSidebar)
);

View File

@ -10,7 +10,9 @@ import { color, layout } from 'shared/styles/constants';
import CloseIcon from 'components/Icon/CloseIcon'; import CloseIcon from 'components/Icon/CloseIcon';
import MenuIcon from 'components/Icon/MenuIcon'; import MenuIcon from 'components/Icon/MenuIcon';
import AuthStore from 'stores/AuthStore'; import AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
type Props = { type Props = {
@ -18,6 +20,7 @@ type Props = {
history: Object, history: Object,
location: Location, location: Location,
auth: AuthStore, auth: AuthStore,
documents: DocumentsStore,
ui: UiStore, ui: UiStore,
}; };

View File

@ -54,6 +54,7 @@ type Props = {
expandedContent?: React$Element<*>, expandedContent?: React$Element<*>,
hideExpandToggle?: boolean, hideExpandToggle?: boolean,
iconColor?: string, iconColor?: string,
active?: boolean,
}; };
@observer @observer
@ -89,6 +90,7 @@ class SidebarLink extends Component {
to, to,
expandedContent, expandedContent,
expand, expand,
active,
hideExpandToggle, hideExpandToggle,
} = this.props; } = this.props;
const Component = to ? StyledNavLink : StyledDiv; const Component = to ? StyledNavLink : StyledDiv;
@ -99,6 +101,7 @@ class SidebarLink extends Component {
<Component <Component
iconVisible={showExpandIcon} iconVisible={showExpandIcon}
activeStyle={activeStyle} activeStyle={activeStyle}
style={active ? activeStyle : undefined}
onClick={onClick} onClick={onClick}
to={to} to={to}
exact exact

View File

@ -16,6 +16,7 @@ import 'shared/styles/prism.css';
import Home from 'scenes/Home'; import Home from 'scenes/Home';
import Dashboard from 'scenes/Dashboard'; import Dashboard from 'scenes/Dashboard';
import Starred from 'scenes/Starred'; import Starred from 'scenes/Starred';
import Drafts from 'scenes/Drafts';
import Collection from 'scenes/Collection'; 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';
@ -65,6 +66,7 @@ render(
<Switch> <Switch>
<Route exact path="/dashboard" component={Dashboard} /> <Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} /> <Route exact path="/starred" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/settings" component={Settings} /> <Route exact path="/settings" component={Settings} />
<Route exact path="/settings/members" component={Members} /> <Route exact path="/settings/members" component={Members} />
<Route exact path="/settings/tokens" component={Tokens} /> <Route exact path="/settings/tokens" component={Tokens} />

View File

@ -47,27 +47,34 @@ class DocumentMenu extends Component {
render() { render() {
const { document, label } = this.props; const { document, label } = this.props;
const isDraft = !document.publishedAt;
return ( return (
<DropdownMenu label={label || <MoreIcon />}> <DropdownMenu label={label || <MoreIcon />}>
{document.starred ? ( {!isDraft && (
<DropdownMenuItem onClick={this.handleUnstar}> <React.Fragment>
Unstar {document.starred ? (
</DropdownMenuItem> <DropdownMenuItem onClick={this.handleUnstar}>
) : ( Unstar
<DropdownMenuItem onClick={this.handleStar}>Star</DropdownMenuItem> </DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleStar}>
Star
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a new child document for the current document"
>
New child
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
</React.Fragment>
)} )}
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a new child document for the current document"
>
New child
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleExport}> <DropdownMenuItem onClick={this.handleExport}>
Download Download
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem> <DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleDelete}>Delete</DropdownMenuItem> <DropdownMenuItem onClick={this.handleDelete}>Delete</DropdownMenuItem>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -33,6 +33,7 @@ class Document extends BaseModel {
text: string = ''; text: string = '';
title: string = ''; title: string = '';
parentDocument: ?string; parentDocument: ?string;
publishedAt: ?string;
updatedAt: string; updatedAt: string;
updatedBy: User; updatedBy: User;
url: string; url: string;
@ -71,8 +72,7 @@ class Document extends BaseModel {
if (this.collection.documents) { if (this.collection.documents) {
traveler(this.collection.documents, []); traveler(this.collection.documents, []);
invariant(path, 'Path is not available for collection, abort'); if (path) return path;
return path;
} }
return []; return [];
@ -145,7 +145,7 @@ class Document extends BaseModel {
}; };
@action @action
save = async () => { save = async (publish: boolean = false) => {
if (this.isSaving) return this; if (this.isSaving) return this;
this.isSaving = true; this.isSaving = true;
@ -157,6 +157,7 @@ class Document extends BaseModel {
title: this.title, title: this.title,
text: this.text, text: this.text,
lastRevision: this.revision, lastRevision: this.revision,
publish,
}); });
} else { } else {
const data = { const data = {
@ -164,6 +165,7 @@ class Document extends BaseModel {
collection: this.collection.id, collection: this.collection.id,
title: this.title, title: this.title,
text: this.text, text: this.text,
publish,
}; };
if (this.parentDocument) { if (this.parentDocument) {
data.parentDocument = this.parentDocument; data.parentDocument = this.parentDocument;

View File

@ -1,5 +1,5 @@
// @flow // @flow
import React, { Component } from 'react'; import * as React from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import styled from 'styled-components'; import styled from 'styled-components';
import { observable } from 'mobx'; import { observable } from 'mobx';
@ -13,25 +13,20 @@ import {
updateDocumentUrl, updateDocumentUrl,
documentMoveUrl, documentMoveUrl,
documentEditUrl, documentEditUrl,
documentNewUrl,
matchDocumentEdit, matchDocumentEdit,
matchDocumentMove, matchDocumentMove,
} from 'utils/routeHelpers'; } from 'utils/routeHelpers';
import Document from 'models/Document'; import Document from 'models/Document';
import Actions from './components/Actions';
import DocumentMove from './components/DocumentMove'; import DocumentMove from './components/DocumentMove';
import UiStore from 'stores/UiStore'; import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore'; import DocumentsStore from 'stores/DocumentsStore';
import CollectionsStore from 'stores/CollectionsStore'; import CollectionsStore from 'stores/CollectionsStore';
import DocumentMenu from 'menus/DocumentMenu';
import SaveAction from './components/SaveAction';
import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingPlaceholder from 'components/LoadingPlaceholder';
import LoadingIndicator from 'components/LoadingIndicator'; import LoadingIndicator from 'components/LoadingIndicator';
import Collaborators from 'components/Collaborators';
import CenteredContent from 'components/CenteredContent'; import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
import Actions, { Action, Separator } from 'components/Actions';
import Search from 'scenes/Search'; import Search from 'scenes/Search';
const DISCARD_CHANGES = ` const DISCARD_CHANGES = `
@ -50,7 +45,7 @@ type Props = {
}; };
@observer @observer
class DocumentScene extends Component { class DocumentScene extends React.Component {
props: Props; props: Props;
savedTimeout: number; savedTimeout: number;
@ -59,6 +54,7 @@ class DocumentScene extends Component {
@observable newDocument: ?Document; @observable newDocument: ?Document;
@observable isLoading = false; @observable isLoading = false;
@observable isSaving = false; @observable isSaving = false;
@observable isPublishing = false;
@observable notFound = false; @observable notFound = false;
@observable moveModalOpen: boolean = false; @observable moveModalOpen: boolean = false;
@ -116,7 +112,9 @@ class DocumentScene extends Component {
// Cache data if user enters edit mode and cancels // Cache data if user enters edit mode and cancels
this.editCache = document.text; this.editCache = document.text;
if (!this.isEditing) document.view(); if (!this.isEditing && document.publishedAt) {
document.view();
}
// Update url to match the current one // Update url to match the current one
this.props.history.replace( this.props.history.replace(
@ -151,28 +149,21 @@ class DocumentScene extends Component {
return this.getDocument(); return this.getDocument();
} }
onClickEdit = () => {
if (!this.document) return;
this.props.history.push(documentEditUrl(this.document));
};
onClickNew = () => {
if (!this.document) return;
this.props.history.push(documentNewUrl(this.document));
};
handleCloseMoveModal = () => (this.moveModalOpen = false); handleCloseMoveModal = () => (this.moveModalOpen = false);
handleOpenMoveModal = () => (this.moveModalOpen = true); handleOpenMoveModal = () => (this.moveModalOpen = true);
onSave = async (redirect: boolean = false) => { onSave = async (options: { redirect?: boolean, publish?: boolean } = {}) => {
if (this.document && !this.document.allowSave) return; const { redirect, publish } = options;
this.editCache = null;
let document = this.document;
if (!document) return; let document = this.document;
if (!document || !document.allowSave) return;
this.editCache = null;
this.isSaving = true; this.isSaving = true;
document = await document.save(); this.isPublishing = publish;
document = await document.save(publish);
this.isSaving = false; this.isSaving = false;
this.isPublishing = false;
if (redirect) { if (redirect) {
this.props.history.push(document.url); this.props.history.push(document.url);
@ -215,7 +206,6 @@ class DocumentScene extends Component {
render() { render() {
const Editor = this.editorComponent; const Editor = this.editorComponent;
const isNew = this.props.newDocument;
const isMoving = this.props.match.path === matchDocumentMove; const isMoving = this.props.match.path === matchDocumentMove;
const document = this.document; const document = this.document;
const titleText = const titleText =
@ -253,47 +243,19 @@ class DocumentScene extends Component {
onCancel={this.onDiscard} onCancel={this.onDiscard}
readOnly={!this.isEditing} readOnly={!this.isEditing}
/> />
<Actions {document && (
align="center" <Actions
justify="flex-end" document={document}
readOnly={!this.isEditing} isDraft={!document.publishedAt}
> isEditing={this.isEditing}
{!isNew && isSaving={this.isSaving}
!this.isEditing && <Collaborators document={document} />} isPublishing={this.isPublishing}
<Action> savingIsDisabled={!document.allowSave}
{this.isEditing ? ( history={this.props.history}
<SaveAction onDiscard={this.onDiscard}
isSaving={this.isSaving} onSave={this.onSave}
onClick={this.onSave.bind(this, true)} />
disabled={ )}
!(this.document && this.document.allowSave) ||
this.isSaving
}
isNew={!!isNew}
/>
) : (
<a onClick={this.onClickEdit}>Edit</a>
)}
</Action>
{this.isEditing && (
<Action>
<a onClick={this.onDiscard}>Discard</a>
</Action>
)}
{!this.isEditing && (
<Action>
<DocumentMenu document={document} />
</Action>
)}
{!this.isEditing && <Separator />}
<Action>
{!this.isEditing && (
<a onClick={this.onClickNew}>
<NewDocumentIcon />
</a>
)}
</Action>
</Actions>
</Flex> </Flex>
)} )}
</Container> </Container>

View File

@ -0,0 +1,133 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
import Document from 'models/Document';
import { documentEditUrl, documentNewUrl } from 'utils/routeHelpers';
import DocumentMenu from 'menus/DocumentMenu';
import Collaborators from 'components/Collaborators';
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
import Actions, { Action, Separator } from 'components/Actions';
type Props = {
document: Document,
isDraft: boolean,
isEditing: boolean,
isSaving: boolean,
isPublishing: boolean,
savingIsDisabled: boolean,
onDiscard: () => *,
onSave: ({
redirect?: boolean,
publish?: boolean,
}) => *,
history: Object,
};
class DocumentActions extends React.Component {
props: Props;
handleNewDocument = () => {
this.props.history.push(documentNewUrl(this.props.document));
};
handleEdit = () => {
this.props.history.push(documentEditUrl(this.props.document));
};
handleSave = () => {
this.props.onSave({ redirect: true });
};
handlePublish = () => {
this.props.onSave({ redirect: true, publish: true });
};
render() {
const {
document,
isEditing,
isDraft,
isPublishing,
isSaving,
savingIsDisabled,
} = this.props;
return (
<Actions align="center" justify="flex-end" readOnly={!isEditing}>
{!isDraft && !isEditing && <Collaborators document={document} />}
{isDraft && (
<Action>
<Link
onClick={this.handlePublish}
title="Publish document (Cmd+Enter)"
disabled={savingIsDisabled}
highlight
>
{isPublishing ? 'Publishing…' : 'Publish'}
</Link>
</Action>
)}
{isEditing && (
<React.Fragment>
<Action>
<Link
onClick={this.handleSave}
title="Save changes (Cmd+Enter)"
disabled={savingIsDisabled}
isSaving={isSaving}
highlight={!isDraft}
>
{isSaving && !isPublishing ? 'Saving…' : 'Save'}
</Link>
</Action>
{isDraft && <Separator />}
</React.Fragment>
)}
{!isEditing && (
<Action>
<a onClick={this.handleEdit}>Edit</a>
</Action>
)}
{isEditing && (
<Action>
<a onClick={this.props.onDiscard}>
{document.hasPendingChanges ? 'Discard' : 'Done'}
</a>
</Action>
)}
{!isEditing && (
<Action>
<DocumentMenu document={document} />
</Action>
)}
{!isEditing &&
!isDraft && (
<React.Fragment>
<Separator />
<Action>
<a onClick={this.handleNewDocument}>
<NewDocumentIcon />
</a>
</Action>
</React.Fragment>
)}
</Actions>
);
}
}
const Link = styled.a`
display: flex;
align-items: center;
font-weight: ${props => (props.highlight ? 500 : 'inherit')};
color: ${props =>
props.highlight ? `${color.primary} !important` : 'inherit'};
opacity: ${props => (props.disabled ? 0.5 : 1)};
pointer-events: ${props => (props.disabled ? 'none' : 'auto')};
cursor: ${props => (props.disabled ? 'default' : 'pointer')};
`;
export default DocumentActions;

View File

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

View File

@ -1,47 +0,0 @@
// @flow
import React from 'react';
import styled from 'styled-components';
type Props = {
onClick: (redirect: ?boolean) => *,
disabled?: boolean,
isNew?: boolean,
isSaving?: boolean,
};
class SaveAction extends React.Component {
props: Props;
onClick = (ev: MouseEvent) => {
if (this.props.disabled) return;
ev.preventDefault();
this.props.onClick();
};
render() {
const { isSaving, isNew, disabled } = this.props;
return (
<Link
onClick={this.onClick}
title="Save changes (Cmd+Enter)"
disabled={disabled}
>
{isNew
? isSaving ? 'Publishing…' : 'Publish'
: isSaving ? 'Saving…' : 'Save'}
</Link>
);
}
}
const Link = styled.a`
display: flex;
align-items: center;
opacity: ${props => (props.disabled ? 0.5 : 1)};
pointer-events: ${props => (props.disabled ? 'none' : 'auto')};
cursor: ${props => (props.disabled ? 'default' : 'pointer')};
`;
export default SaveAction;

View File

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

View File

@ -0,0 +1,38 @@
// @flow
import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import CenteredContent from 'components/CenteredContent';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
import Empty from 'components/Empty';
import PageTitle from 'components/PageTitle';
import DocumentList from 'components/DocumentList';
import DocumentsStore from 'stores/DocumentsStore';
@observer
class Drafts extends Component {
props: {
documents: DocumentsStore,
};
componentDidMount() {
this.props.documents.fetchDrafts();
}
render() {
const { isLoaded, isFetching, drafts } = this.props.documents;
const showLoading = !isLoaded && isFetching;
const showEmpty = isLoaded && !drafts.length;
return (
<CenteredContent column auto>
<PageTitle title="Drafts" />
<h1>Drafts</h1>
{showLoading && <ListPlaceholder />}
{showEmpty && <Empty>No drafts yet.</Empty>}
<DocumentList documents={drafts} showCollection />
</CenteredContent>
);
}
}
export default inject('documents')(Drafts);

View File

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

View File

@ -71,10 +71,18 @@ class DocumentsStore extends BaseStore {
} }
@computed @computed
get starred(): Array<Document> { get starred(): Document[] {
return _.filter(this.data.values(), 'starred'); return _.filter(this.data.values(), 'starred');
} }
@computed
get drafts(): Document[] {
return _.filter(
_.orderBy(this.data.values(), 'updatedAt', 'desc'),
doc => !doc.publishedAt
);
}
@computed @computed
get active(): ?Document { get active(): ?Document {
return this.ui.activeDocumentId return this.ui.activeDocumentId
@ -130,14 +138,19 @@ class DocumentsStore extends BaseStore {
}; };
@action @action
fetchStarred = async (): Promise<*> => { fetchStarred = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('starred'); await this.fetchPage('starred', options);
};
@action
fetchDrafts = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('drafts', options);
}; };
@action @action
search = async ( search = async (
query: string, query: string,
options?: PaginationParams options: ?PaginationParams
): Promise<string[]> => { ): Promise<string[]> => {
const res = await client.get('/documents.search', { const res = await client.get('/documents.search', {
...options, ...options,

View File

@ -28,7 +28,10 @@ class UiStore {
@action @action
setActiveDocument = (document: Document): void => { setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id; this.activeDocumentId = document.id;
this.activeCollectionId = document.collection.id;
if (document.publishedAt) {
this.activeCollectionId = document.collection.id;
}
}; };
@action @action

View File

@ -1,6 +1,6 @@
// @flow // @flow
import Router from 'koa-router'; import Router from 'koa-router';
import { Op } from 'sequelize';
import auth from './middlewares/authentication'; import auth from './middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import { presentDocument, presentRevision } from '../presenters'; import { presentDocument, presentRevision } from '../presenters';
@ -102,6 +102,28 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
}; };
}); });
router.post('documents.drafts', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
const user = ctx.state.user;
const documents = await Document.findAll({
where: { userId: user.id, publishedAt: { [Op.eq]: null } },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
);
ctx.body = {
pagination: ctx.state.pagination,
data,
};
});
router.post('documents.info', auth(), async ctx => { router.post('documents.info', auth(), async ctx => {
const { id } = ctx.body; const { id } = ctx.body;
ctx.assertPresent(id, 'id is required'); ctx.assertPresent(id, 'id is required');
@ -188,9 +210,10 @@ router.post('documents.unstar', auth(), async ctx => {
}); });
router.post('documents.create', auth(), async ctx => { router.post('documents.create', auth(), async ctx => {
const { collection, title, text, parentDocument, index } = ctx.body; const { title, text, publish, parentDocument, index } = ctx.body;
ctx.assertPresent(collection, 'collection is required'); const collectionId = ctx.body.collection;
ctx.assertUuid(collection, 'collection must be an uuid'); ctx.assertPresent(collectionId, 'collection is required');
ctx.assertUuid(collectionId, 'collection must be an uuid');
ctx.assertPresent(title, 'title is required'); ctx.assertPresent(title, 'title is required');
ctx.assertPresent(text, 'text is required'); ctx.assertPresent(text, 'text is required');
if (parentDocument) if (parentDocument)
@ -200,44 +223,48 @@ router.post('documents.create', auth(), async ctx => {
const user = ctx.state.user; const user = ctx.state.user;
authorize(user, 'create', Document); authorize(user, 'create', Document);
const ownerCollection = await Collection.findOne({ const collection = await Collection.findOne({
where: { where: {
id: collection, id: collectionId,
teamId: user.teamId, teamId: user.teamId,
}, },
}); });
authorize(user, 'publish', ownerCollection); authorize(user, 'publish', collection);
let parentDocumentObj = {}; let parentDocumentObj = {};
if (parentDocument && ownerCollection.type === 'atlas') { if (parentDocument && collection.type === 'atlas') {
parentDocumentObj = await Document.findOne({ parentDocumentObj = await Document.findOne({
where: { where: {
id: parentDocument, id: parentDocument,
atlasId: ownerCollection.id, atlasId: collection.id,
}, },
}); });
authorize(user, 'read', parentDocumentObj); authorize(user, 'read', parentDocumentObj);
} }
const newDocument = await Document.create({ const publishedAt = publish === false ? null : new Date();
let document = await Document.create({
parentDocumentId: parentDocumentObj.id, parentDocumentId: parentDocumentObj.id,
atlasId: ownerCollection.id, atlasId: collection.id,
teamId: user.teamId, teamId: user.teamId,
userId: user.id, userId: user.id,
lastModifiedById: user.id, lastModifiedById: user.id,
createdById: user.id, createdById: user.id,
publishedAt,
title, title,
text, text,
}); });
// reload to get all of the data needed to present (user, collection etc) if (publishedAt && collection.type === 'atlas') {
const document = await Document.findById(newDocument.id); await collection.addDocumentToStructure(document, index);
if (ownerCollection.type === 'atlas') {
await ownerCollection.addDocumentToStructure(document, index);
} }
document.collection = ownerCollection; // reload to get all of the data needed to present (user, collection etc)
// we need to specify publishedAt to bypass default scope that only returns
// published documents
document = await Document.find({
where: { id: document.id, publishedAt },
});
ctx.body = { ctx.body = {
data: await presentDocument(ctx, document), data: await presentDocument(ctx, document),
@ -245,7 +272,7 @@ router.post('documents.create', auth(), async ctx => {
}); });
router.post('documents.update', auth(), async ctx => { router.post('documents.update', auth(), async ctx => {
const { id, title, text, lastRevision } = ctx.body; const { id, title, text, publish, lastRevision } = ctx.body;
ctx.assertPresent(id, 'id is required'); ctx.assertPresent(id, 'id is required');
ctx.assertPresent(title || text, 'title or text is required'); ctx.assertPresent(title || text, 'title or text is required');
@ -259,6 +286,7 @@ router.post('documents.update', auth(), async ctx => {
} }
// Update document // Update document
if (publish) document.publishedAt = new Date();
if (title) document.title = title; if (title) document.title = title;
if (text) document.text = text; if (text) document.text = text;
document.lastModifiedById = user.id; document.lastModifiedById = user.id;
@ -266,7 +294,11 @@ router.post('documents.update', auth(), async ctx => {
await document.save(); await document.save();
const collection = document.collection; const collection = document.collection;
if (collection.type === 'atlas') { if (collection.type === 'atlas') {
await collection.updateDocument(document); if (document.publishedAt) {
await collection.updateDocument(document);
} else if (publish) {
await collection.addDocumentToStructure(document);
}
} }
document.collection = collection; document.collection = collection;

View File

@ -10,9 +10,37 @@ const server = new TestServer(app.callback());
beforeEach(flushdb); beforeEach(flushdb);
afterAll(server.close); afterAll(server.close);
describe('#documents.info', async () => {
it('should return published document', async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.info', {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
});
it('should return drafts', async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post('/api/documents.info', {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
});
});
describe('#documents.list', async () => { describe('#documents.list', async () => {
it('should return documents', async () => { it('should return documents', async () => {
const { user, document } = await seed(); const { user, document } = await seed();
const res = await server.post('/api/documents.list', { const res = await server.post('/api/documents.list', {
body: { token: user.getJwtToken() }, body: { token: user.getJwtToken() },
}); });
@ -23,6 +51,20 @@ describe('#documents.list', async () => {
expect(body.data[0].id).toEqual(document.id); expect(body.data[0].id).toEqual(document.id);
}); });
it('should not return unpublished documents', async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post('/api/documents.list', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
it('should allow changing sort direction', async () => { it('should allow changing sort direction', async () => {
const { user, document } = await seed(); const { user, document } = await seed();
const res = await server.post('/api/documents.list', { const res = await server.post('/api/documents.list', {
@ -54,6 +96,22 @@ describe('#documents.list', async () => {
}); });
}); });
describe('#documents.drafts', async () => {
it('should return unpublished documents', async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post('/api/documents.drafts', {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
});
});
describe('#documents.revision', async () => { describe('#documents.revision', async () => {
it("should return a document's revisions", async () => { it("should return a document's revisions", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
@ -263,6 +321,7 @@ describe('#documents.create', async () => {
collection: collection.id, collection: collection.id,
title: 'new document', title: 'new document',
text: 'hello', text: 'hello',
publish: true,
}, },
}); });
const body = await res.json(); const body = await res.json();
@ -293,7 +352,7 @@ describe('#documents.create', async () => {
expect(body.data.text).toBe('# Untitled document'); expect(body.data.text).toBe('# Untitled document');
}); });
it('should create as a child', async () => { it('should create as a child and add to collection if published', async () => {
const { user, document, collection } = await seed(); const { user, document, collection } = await seed();
const res = await server.post('/api/documents.create', { const res = await server.post('/api/documents.create', {
body: { body: {
@ -302,6 +361,7 @@ describe('#documents.create', async () => {
title: 'new document', title: 'new document',
text: 'hello', text: 'hello',
parentDocument: document.id, parentDocument: document.id,
publish: true,
}, },
}); });
const body = await res.json(); const body = await res.json();
@ -328,6 +388,24 @@ describe('#documents.create', async () => {
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
it('should create as a child and not add to collection', async () => {
const { user, document, collection } = await seed();
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
title: 'new document',
text: 'hello',
parentDocument: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe('new document');
expect(body.data.collection.documents.length).toBe(2);
});
}); });
describe('#documents.update', async () => { describe('#documents.update', async () => {

View File

@ -0,0 +1,27 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'publishedAt', {
type: Sequelize.DATE,
allowNull: true,
});
const [documents, metaData] = await queryInterface.sequelize.query(`SELECT * FROM documents`);
for (const document of documents) {
await queryInterface.sequelize.query(`
update documents
set "publishedAt" = '${new Date(document.createdAt).toISOString()}'
where id = '${document.id}'
`)
}
await queryInterface.removeIndex('documents', ['id', 'atlasId']);
await queryInterface.addIndex('documents', ['id', 'atlasId', 'publishedAt']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'publishedAt');
await queryInterface.removeIndex('documents', ['id', 'atlasId', 'publishedAt']);
await queryInterface.addIndex('documents', ['id', 'atlasId']);
}
};

View File

@ -58,6 +58,7 @@ const Collection = sequelize.define(
userId: collection.creatorId, userId: collection.creatorId,
lastModifiedById: collection.creatorId, lastModifiedById: collection.creatorId,
createdById: collection.creatorId, createdById: collection.creatorId,
publishedAt: new Date(),
title: 'Welcome to Outline', title: 'Welcome to Outline',
text: welcomeMessage(collection.id), text: welcomeMessage(collection.id),
}); });

View File

@ -234,6 +234,7 @@ describe('#removeDocument', () => {
userId: collection.creatorId, userId: collection.creatorId,
lastModifiedById: collection.creatorId, lastModifiedById: collection.creatorId,
createdById: collection.creatorId, createdById: collection.creatorId,
publishedAt: new Date(),
title: 'Child document', title: 'Child document',
text: 'content', text: 'content',
}); });

View File

@ -4,6 +4,7 @@ import _ from 'lodash';
import randomstring from 'randomstring'; import randomstring from 'randomstring';
import MarkdownSerializer from 'slate-md-serializer'; import MarkdownSerializer from 'slate-md-serializer';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import { Op } from 'sequelize';
import isUUID from 'validator/lib/isUUID'; import isUUID from 'validator/lib/isUUID';
import { DataTypes, sequelize } from '../sequelize'; import { DataTypes, sequelize } from '../sequelize';
@ -82,6 +83,7 @@ const Document = sequelize.define(
title: DataTypes.STRING, title: DataTypes.STRING,
text: DataTypes.TEXT, text: DataTypes.TEXT,
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 }, revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
publishedAt: DataTypes.DATE,
parentDocumentId: DataTypes.UUID, parentDocumentId: DataTypes.UUID,
createdById: { createdById: {
type: DataTypes.UUID, type: DataTypes.UUID,
@ -145,9 +147,21 @@ Document.associate = models => {
{ model: models.User, as: 'createdBy' }, { model: models.User, as: 'createdBy' },
{ model: models.User, as: 'updatedBy' }, { model: models.User, as: 'updatedBy' },
], ],
where: {
publishedAt: {
[Op.ne]: null,
},
},
}, },
{ override: true } { override: true }
); );
Document.addScope('withUnpublished', {
include: [
{ model: models.Collection, as: 'collection' },
{ model: models.User, as: 'createdBy' },
{ model: models.User, as: 'updatedBy' },
],
});
Document.addScope('withViews', userId => ({ Document.addScope('withViews', userId => ({
include: [ include: [
{ model: models.View, as: 'views', where: { userId }, required: false }, { model: models.View, as: 'views', where: { userId }, required: false },
@ -161,12 +175,14 @@ Document.associate = models => {
}; };
Document.findById = async id => { Document.findById = async id => {
const scope = Document.scope('withUnpublished');
if (isUUID(id)) { if (isUUID(id)) {
return Document.findOne({ return scope.findOne({
where: { id }, where: { id },
}); });
} else if (id.match(URL_REGEX)) { } else if (id.match(URL_REGEX)) {
return Document.findOne({ return scope.findOne({
where: { where: {
urlId: id.match(URL_REGEX)[1], urlId: id.match(URL_REGEX)[1],
}, },
@ -225,9 +241,12 @@ Document.addHook('afterDestroy', model =>
events.add({ name: 'documents.delete', model }) events.add({ name: 'documents.delete', model })
); );
Document.addHook('afterUpdate', model => Document.addHook('afterUpdate', model => {
events.add({ name: 'documents.update', model }) if (!model.previous('publishedAt') && model.publishedAt) {
); events.add({ name: 'documents.publish', model });
}
events.add({ name: 'documents.update', model });
});
// Instance methods // Instance methods

View File

@ -258,7 +258,7 @@ export default function Pricing() {
</Method> </Method>
<Method method="documents.list" label="List your documents"> <Method method="documents.list" label="List your documents">
<Description>List all your documents.</Description> <Description>List all published documents.</Description>
<Arguments pagination> <Arguments pagination>
<Argument <Argument
id="collection" id="collection"
@ -267,6 +267,10 @@ export default function Pricing() {
</Arguments> </Arguments>
</Method> </Method>
<Method method="documents.drafts" label="List your draft documents">
<Description>List all your draft documents.</Description>
</Method>
<Method method="documents.info" label="Get a document"> <Method method="documents.info" label="Get a document">
<Description> <Description>
<p> <p>
@ -338,6 +342,15 @@ export default function Pricing() {
</span> </span>
} }
/> />
<Argument
id="publish"
description={
<span>
<code>true</code> by default. Pass <code>false</code> to
create a draft.
</span>
}
/>
</Arguments> </Arguments>
</Method> </Method>
@ -356,6 +369,14 @@ export default function Pricing() {
id="text" id="text"
description="Content of the document in Markdown" description="Content of the document in Markdown"
/> />
<Argument
id="publish"
description={
<span>
Pass <code>true</code> to publish a draft
</span>
}
/>
</Arguments> </Arguments>
</Method> </Method>

View File

@ -33,6 +33,7 @@ async function present(ctx: Object, document: Document, options: ?Options) {
createdBy: presentUser(ctx, document.createdBy), createdBy: presentUser(ctx, document.createdBy),
updatedAt: document.updatedAt, updatedAt: document.updatedAt,
updatedBy: presentUser(ctx, document.updatedBy), updatedBy: presentUser(ctx, document.updatedBy),
publishedAt: document.publishedAt,
firstViewedAt: undefined, firstViewedAt: undefined,
lastViewedAt: undefined, lastViewedAt: undefined,
team: document.teamId, team: document.teamId,

View File

@ -67,6 +67,7 @@ const seed = async () => {
userId: collection.creatorId, userId: collection.creatorId,
lastModifiedById: collection.creatorId, lastModifiedById: collection.creatorId,
createdById: collection.creatorId, createdById: collection.creatorId,
publishedAt: new Date(),
title: 'Second document', title: 'Second document',
text: '# Much guidance', text: '# Much guidance',
}); });