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)`
justify-content: center;
align-items: center;
padding: 0 0 0 10px;
padding: 0 0 0 12px;
a {
color: ${color.text};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ 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';
@ -65,6 +66,7 @@ render(
<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/members" component={Members} />
<Route exact path="/settings/tokens" component={Tokens} />

View File

@ -47,27 +47,34 @@ class DocumentMenu extends Component {
render() {
const { document, label } = this.props;
const isDraft = !document.publishedAt;
return (
<DropdownMenu label={label || <MoreIcon />}>
{document.starred ? (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleStar}>Star</DropdownMenuItem>
{!isDraft && (
<React.Fragment>
{document.starred ? (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</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}>
Download
</DropdownMenuItem>
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleDelete}>Delete</DropdownMenuItem>
</DropdownMenu>
);

View File

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

View File

@ -1,5 +1,5 @@
// @flow
import React, { Component } from 'react';
import * as React from 'react';
import get from 'lodash/get';
import styled from 'styled-components';
import { observable } from 'mobx';
@ -13,25 +13,20 @@ import {
updateDocumentUrl,
documentMoveUrl,
documentEditUrl,
documentNewUrl,
matchDocumentEdit,
matchDocumentMove,
} from 'utils/routeHelpers';
import Document from 'models/Document';
import Actions from './components/Actions';
import DocumentMove from './components/DocumentMove';
import UiStore from 'stores/UiStore';
import DocumentsStore from 'stores/DocumentsStore';
import CollectionsStore from 'stores/CollectionsStore';
import DocumentMenu from 'menus/DocumentMenu';
import SaveAction from './components/SaveAction';
import LoadingPlaceholder from 'components/LoadingPlaceholder';
import LoadingIndicator from 'components/LoadingIndicator';
import Collaborators from 'components/Collaborators';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import NewDocumentIcon from 'components/Icon/NewDocumentIcon';
import Actions, { Action, Separator } from 'components/Actions';
import Search from 'scenes/Search';
const DISCARD_CHANGES = `
@ -50,7 +45,7 @@ type Props = {
};
@observer
class DocumentScene extends Component {
class DocumentScene extends React.Component {
props: Props;
savedTimeout: number;
@ -59,6 +54,7 @@ class DocumentScene extends Component {
@observable newDocument: ?Document;
@observable isLoading = false;
@observable isSaving = false;
@observable isPublishing = false;
@observable notFound = false;
@observable moveModalOpen: boolean = false;
@ -116,7 +112,9 @@ class DocumentScene extends Component {
// Cache data if user enters edit mode and cancels
this.editCache = document.text;
if (!this.isEditing) document.view();
if (!this.isEditing && document.publishedAt) {
document.view();
}
// Update url to match the current one
this.props.history.replace(
@ -151,28 +149,21 @@ class DocumentScene extends Component {
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);
handleOpenMoveModal = () => (this.moveModalOpen = true);
onSave = async (redirect: boolean = false) => {
if (this.document && !this.document.allowSave) return;
this.editCache = null;
let document = this.document;
onSave = async (options: { redirect?: boolean, publish?: boolean } = {}) => {
const { redirect, publish } = options;
if (!document) return;
let document = this.document;
if (!document || !document.allowSave) return;
this.editCache = null;
this.isSaving = true;
document = await document.save();
this.isPublishing = publish;
document = await document.save(publish);
this.isSaving = false;
this.isPublishing = false;
if (redirect) {
this.props.history.push(document.url);
@ -215,7 +206,6 @@ class DocumentScene extends Component {
render() {
const Editor = this.editorComponent;
const isNew = this.props.newDocument;
const isMoving = this.props.match.path === matchDocumentMove;
const document = this.document;
const titleText =
@ -253,47 +243,19 @@ class DocumentScene extends Component {
onCancel={this.onDiscard}
readOnly={!this.isEditing}
/>
<Actions
align="center"
justify="flex-end"
readOnly={!this.isEditing}
>
{!isNew &&
!this.isEditing && <Collaborators document={document} />}
<Action>
{this.isEditing ? (
<SaveAction
isSaving={this.isSaving}
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>
{document && (
<Actions
document={document}
isDraft={!document.publishedAt}
isEditing={this.isEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
savingIsDisabled={!document.allowSave}
history={this.props.history}
onDiscard={this.onDiscard}
onSave={this.onSave}
/>
)}
</Flex>
)}
</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
get starred(): Array<Document> {
get starred(): Document[] {
return _.filter(this.data.values(), 'starred');
}
@computed
get drafts(): Document[] {
return _.filter(
_.orderBy(this.data.values(), 'updatedAt', 'desc'),
doc => !doc.publishedAt
);
}
@computed
get active(): ?Document {
return this.ui.activeDocumentId
@ -130,14 +138,19 @@ class DocumentsStore extends BaseStore {
};
@action
fetchStarred = async (): Promise<*> => {
await this.fetchPage('starred');
fetchStarred = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('starred', options);
};
@action
fetchDrafts = async (options: ?PaginationParams): Promise<*> => {
await this.fetchPage('drafts', options);
};
@action
search = async (
query: string,
options?: PaginationParams
options: ?PaginationParams
): Promise<string[]> => {
const res = await client.get('/documents.search', {
...options,

View File

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

View File

@ -1,6 +1,6 @@
// @flow
import Router from 'koa-router';
import { Op } from 'sequelize';
import auth from './middlewares/authentication';
import pagination from './middlewares/pagination';
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 => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
@ -188,9 +210,10 @@ router.post('documents.unstar', auth(), async ctx => {
});
router.post('documents.create', auth(), async ctx => {
const { collection, title, text, parentDocument, index } = ctx.body;
ctx.assertPresent(collection, 'collection is required');
ctx.assertUuid(collection, 'collection must be an uuid');
const { title, text, publish, parentDocument, index } = ctx.body;
const collectionId = ctx.body.collection;
ctx.assertPresent(collectionId, 'collection is required');
ctx.assertUuid(collectionId, 'collection must be an uuid');
ctx.assertPresent(title, 'title is required');
ctx.assertPresent(text, 'text is required');
if (parentDocument)
@ -200,44 +223,48 @@ router.post('documents.create', auth(), async ctx => {
const user = ctx.state.user;
authorize(user, 'create', Document);
const ownerCollection = await Collection.findOne({
const collection = await Collection.findOne({
where: {
id: collection,
id: collectionId,
teamId: user.teamId,
},
});
authorize(user, 'publish', ownerCollection);
authorize(user, 'publish', collection);
let parentDocumentObj = {};
if (parentDocument && ownerCollection.type === 'atlas') {
if (parentDocument && collection.type === 'atlas') {
parentDocumentObj = await Document.findOne({
where: {
id: parentDocument,
atlasId: ownerCollection.id,
atlasId: collection.id,
},
});
authorize(user, 'read', parentDocumentObj);
}
const newDocument = await Document.create({
const publishedAt = publish === false ? null : new Date();
let document = await Document.create({
parentDocumentId: parentDocumentObj.id,
atlasId: ownerCollection.id,
atlasId: collection.id,
teamId: user.teamId,
userId: user.id,
lastModifiedById: user.id,
createdById: user.id,
publishedAt,
title,
text,
});
// reload to get all of the data needed to present (user, collection etc)
const document = await Document.findById(newDocument.id);
if (ownerCollection.type === 'atlas') {
await ownerCollection.addDocumentToStructure(document, index);
if (publishedAt && collection.type === 'atlas') {
await collection.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 = {
data: await presentDocument(ctx, document),
@ -245,7 +272,7 @@ router.post('documents.create', 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(title || text, 'title or text is required');
@ -259,6 +286,7 @@ router.post('documents.update', auth(), async ctx => {
}
// Update document
if (publish) document.publishedAt = new Date();
if (title) document.title = title;
if (text) document.text = text;
document.lastModifiedById = user.id;
@ -266,7 +294,11 @@ router.post('documents.update', auth(), async ctx => {
await document.save();
const collection = document.collection;
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;

View File

@ -10,9 +10,37 @@ const server = new TestServer(app.callback());
beforeEach(flushdb);
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 () => {
it('should return documents', async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.list', {
body: { token: user.getJwtToken() },
});
@ -23,6 +51,20 @@ describe('#documents.list', async () => {
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 () => {
const { user, document } = await seed();
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 () => {
it("should return a document's revisions", async () => {
const { user, document } = await seed();
@ -263,6 +321,7 @@ describe('#documents.create', async () => {
collection: collection.id,
title: 'new document',
text: 'hello',
publish: true,
},
});
const body = await res.json();
@ -293,7 +352,7 @@ describe('#documents.create', async () => {
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 res = await server.post('/api/documents.create', {
body: {
@ -302,6 +361,7 @@ describe('#documents.create', async () => {
title: 'new document',
text: 'hello',
parentDocument: document.id,
publish: true,
},
});
const body = await res.json();
@ -328,6 +388,24 @@ describe('#documents.create', async () => {
expect(res.status).toEqual(403);
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 () => {

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,
lastModifiedById: collection.creatorId,
createdById: collection.creatorId,
publishedAt: new Date(),
title: 'Welcome to Outline',
text: welcomeMessage(collection.id),
});

View File

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

View File

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

View File

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

View File

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

View File

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