From 9142d975df06203f8a13b3c89efb74af88d60347 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 27 Feb 2018 22:41:12 -0800 Subject: [PATCH] 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 --- app/components/Actions/Actions.js | 2 +- app/components/DocumentList/DocumentList.js | 15 +- .../DocumentPreview/DocumentPreview.js | 19 +-- .../components/PublishingInfo.js | 22 +-- app/components/Editor/Editor.js | 6 +- app/components/Sidebar/Main.js | 18 ++- app/components/Sidebar/Sidebar.js | 3 + .../Sidebar/components/SidebarLink.js | 3 + app/index.js | 2 + app/menus/DocumentMenu.js | 33 +++-- app/models/Document.js | 8 +- app/scenes/Document/Document.js | 96 ++++--------- app/scenes/Document/components/Actions.js | 133 ++++++++++++++++++ .../LoadingPlaceholder.js | 0 .../components/LoadingPlaceholder/index.js | 3 - .../components/SaveAction/SaveAction.js | 47 ------- .../Document/components/SaveAction/index.js | 3 - app/scenes/Drafts/Drafts.js | 38 +++++ app/scenes/Drafts/index.js | 3 + app/stores/DocumentsStore.js | 21 ++- app/stores/UiStore.js | 5 +- server/api/documents.js | 70 ++++++--- server/api/documents.test.js | 80 ++++++++++- .../migrations/20180115021837-add-drafts.js | 27 ++++ server/models/Collection.js | 1 + server/models/Collection.test.js | 1 + server/models/Document.js | 29 +++- server/pages/Api.js | 23 ++- server/presenters/document.js | 1 + server/test/support.js | 1 + 30 files changed, 519 insertions(+), 194 deletions(-) create mode 100644 app/scenes/Document/components/Actions.js rename app/scenes/Document/components/{LoadingPlaceholder => }/LoadingPlaceholder.js (100%) delete mode 100644 app/scenes/Document/components/LoadingPlaceholder/index.js delete mode 100644 app/scenes/Document/components/SaveAction/SaveAction.js delete mode 100644 app/scenes/Document/components/SaveAction/index.js create mode 100644 app/scenes/Drafts/Drafts.js create mode 100644 app/scenes/Drafts/index.js create mode 100644 server/migrations/20180115021837-add-drafts.js diff --git a/app/components/Actions/Actions.js b/app/components/Actions/Actions.js index 66b9cff23..4ef55010d 100644 --- a/app/components/Actions/Actions.js +++ b/app/components/Actions/Actions.js @@ -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}; diff --git a/app/components/DocumentList/DocumentList.js b/app/components/DocumentList/DocumentList.js index a53a2de4a..db69417df 100644 --- a/app/components/DocumentList/DocumentList.js +++ b/app/components/DocumentList/DocumentList.js @@ -6,18 +6,25 @@ import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; class DocumentList extends React.Component { props: { - documents: Array, + documents: Document[], + showCollection?: boolean, }; render() { + const { documents, showCollection } = this.props; + return ( - {this.props.documents && - this.props.documents.map(document => ( - + {documents && + documents.map(document => ( + ))} ); diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index ae7e089c7..40f2d0a6d 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -97,15 +97,16 @@ class DocumentPreview extends Component {

- {document.starred ? ( - - - - ) : ( - - - - )} + {document.publishedAt && + (document.starred ? ( + + + + ) : ( + + + + ))}

- {createdAt === updatedAt ? ( + {publishedAt === updatedAt ? ( - {createdBy.name} published {moment(createdAt).fromNow()} + {createdBy.name} published {timeAgo} ) : ( - + {updatedBy.name} - - {' '} - modified {moment(updatedAt).fromNow()} - - + {publishedAt ? ( + +  modified {timeAgo} + + ) : ( +  saved {timeAgo} + )} + )} {collection && ( diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index be58b3ed9..353fb9432 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -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') diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index 50f80d288..fd1e2f146 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -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 { }> Starred + } + active={ + documents.active ? !documents.active.publishedAt : undefined + } + > + Drafts +
, 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 { + diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index c66473719..2e4f5607c 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -47,27 +47,34 @@ class DocumentMenu extends Component { render() { const { document, label } = this.props; + const isDraft = !document.publishedAt; return ( }> - {document.starred ? ( - - Unstar - - ) : ( - Star + {!isDraft && ( + + {document.starred ? ( + + Unstar + + ) : ( + + Star + + )} + + New child + + Move… + )} - - New child - Download Print - Move… Delete… ); diff --git a/app/models/Document.js b/app/models/Document.js index 1771600d1..80da8eab0 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -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; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 8539561a1..b0ff12d40 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -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} /> - - {!isNew && - !this.isEditing && } - - {this.isEditing ? ( - - ) : ( - Edit - )} - - {this.isEditing && ( - - Discard - - )} - {!this.isEditing && ( - - - - )} - {!this.isEditing && } - - {!this.isEditing && ( - - - - )} - - + {document && ( + + )} )} diff --git a/app/scenes/Document/components/Actions.js b/app/scenes/Document/components/Actions.js new file mode 100644 index 000000000..7e30c8478 --- /dev/null +++ b/app/scenes/Document/components/Actions.js @@ -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 ( + + {!isDraft && !isEditing && } + {isDraft && ( + + + {isPublishing ? 'Publishing…' : 'Publish'} + + + )} + {isEditing && ( + + + + {isSaving && !isPublishing ? 'Saving…' : 'Save'} + + + {isDraft && } + + )} + {!isEditing && ( + + Edit + + )} + {isEditing && ( + + + {document.hasPendingChanges ? 'Discard' : 'Done'} + + + )} + {!isEditing && ( + + + + )} + {!isEditing && + !isDraft && ( + + + + + + + + + )} + + ); + } +} + +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; diff --git a/app/scenes/Document/components/LoadingPlaceholder/LoadingPlaceholder.js b/app/scenes/Document/components/LoadingPlaceholder.js similarity index 100% rename from app/scenes/Document/components/LoadingPlaceholder/LoadingPlaceholder.js rename to app/scenes/Document/components/LoadingPlaceholder.js diff --git a/app/scenes/Document/components/LoadingPlaceholder/index.js b/app/scenes/Document/components/LoadingPlaceholder/index.js deleted file mode 100644 index fd22eb812..000000000 --- a/app/scenes/Document/components/LoadingPlaceholder/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import LoadingPlaceholder from './LoadingPlaceholder'; -export default LoadingPlaceholder; diff --git a/app/scenes/Document/components/SaveAction/SaveAction.js b/app/scenes/Document/components/SaveAction/SaveAction.js deleted file mode 100644 index 27fcefbbc..000000000 --- a/app/scenes/Document/components/SaveAction/SaveAction.js +++ /dev/null @@ -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 ( - - {isNew - ? isSaving ? 'Publishing…' : 'Publish' - : isSaving ? 'Saving…' : 'Save'} - - ); - } -} - -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; diff --git a/app/scenes/Document/components/SaveAction/index.js b/app/scenes/Document/components/SaveAction/index.js deleted file mode 100644 index 524f5da74..000000000 --- a/app/scenes/Document/components/SaveAction/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import SaveAction from './SaveAction'; -export default SaveAction; diff --git a/app/scenes/Drafts/Drafts.js b/app/scenes/Drafts/Drafts.js new file mode 100644 index 000000000..ada616c27 --- /dev/null +++ b/app/scenes/Drafts/Drafts.js @@ -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 ( + + +

Drafts

+ {showLoading && } + {showEmpty && No drafts yet.} + +
+ ); + } +} + +export default inject('documents')(Drafts); diff --git a/app/scenes/Drafts/index.js b/app/scenes/Drafts/index.js new file mode 100644 index 000000000..708627878 --- /dev/null +++ b/app/scenes/Drafts/index.js @@ -0,0 +1,3 @@ +// @flow +import Drafts from './Drafts'; +export default Drafts; diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index dc59f02dc..4367b2d39 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -71,10 +71,18 @@ class DocumentsStore extends BaseStore { } @computed - get starred(): Array { + 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 => { const res = await client.get('/documents.search', { ...options, diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index 91aca089f..dda930c1e 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -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 diff --git a/server/api/documents.js b/server/api/documents.js index 34598a65e..accfede38 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -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; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index a86d910d5..dcd4a4f0f 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -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 () => { diff --git a/server/migrations/20180115021837-add-drafts.js b/server/migrations/20180115021837-add-drafts.js new file mode 100644 index 000000000..489886f94 --- /dev/null +++ b/server/migrations/20180115021837-add-drafts.js @@ -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']); + } +}; diff --git a/server/models/Collection.js b/server/models/Collection.js index 001dd2125..10b32dd6f 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -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), }); diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index d728f9f86..715c60145 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -234,6 +234,7 @@ describe('#removeDocument', () => { userId: collection.creatorId, lastModifiedById: collection.creatorId, createdById: collection.creatorId, + publishedAt: new Date(), title: 'Child document', text: 'content', }); diff --git a/server/models/Document.js b/server/models/Document.js index a15073261..e58026010 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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 diff --git a/server/pages/Api.js b/server/pages/Api.js index e6a996bba..f640b4dbd 100644 --- a/server/pages/Api.js +++ b/server/pages/Api.js @@ -258,7 +258,7 @@ export default function Pricing() { - List all your documents. + List all published documents. + + List all your draft documents. + +

@@ -338,6 +342,15 @@ export default function Pricing() { } /> + + true by default. Pass false to + create a draft. + + } + /> @@ -356,6 +369,14 @@ export default function Pricing() { id="text" description="Content of the document in Markdown" /> + + Pass true to publish a draft + + } + /> diff --git a/server/presenters/document.js b/server/presenters/document.js index 2120cf9d4..ac045a4e6 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -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, diff --git a/server/test/support.js b/server/test/support.js index 7b1564e38..4d6a5ee4f 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -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', });