From 642c11ff7d38346005d413631135f7538bb44177 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 6 Apr 2019 16:20:27 -0700 Subject: [PATCH] Document Archive (#921) * WIP: Archive * WIP * Finishing up archive endpoints * WIP * Update docs * Flow * Stash * Add toast message confirmations * Redirect handling, fixed publishhing info for archived docs * Redirect to collection instead of home, remove unused pub info * Account for deleted parent * Trash -> Archive Allow reading of archived docs * Dont overload deletedAt * Fixes * :green_heart: * ParentDocumentId wipe for unarchived sub docs * Fix: CMD+S exits editing Fix: Duplicate user name on published but unedited docs * Improve jank on paginated lists * Prevent editing when archived * :green_heart: Separate lint / flow steps --- .circleci/config.yml | 5 +- .githooks/pre-commit/flow.sh | 2 +- app/components/Button.js | 3 +- app/components/DocumentList.js | 16 +- .../DocumentPreview/DocumentPreview.js | 34 ++-- .../components/PublishingInfo.js | 55 ++++-- app/components/Layout.js | 11 -- app/components/PaginatedDocumentList.js | 31 ++-- app/components/Sidebar/Main.js | 19 +- app/menus/AccountMenu.js | 22 +-- app/menus/DocumentMenu.js | 39 +++- app/menus/NewChildDocumentMenu.js | 6 +- app/models/Document.js | 18 +- app/routes.js | 2 + app/scenes/Archive.js | 38 ++++ app/scenes/Document/Document.js | 38 +++- app/scenes/Document/components/Header.js | 14 +- app/scenes/DocumentDelete.js | 15 +- app/scenes/Drafts.js | 11 +- app/scenes/UserProfile.js | 2 +- app/stores/DocumentsStore.js | 58 ++++-- app/stores/UiStore.js | 2 +- docker-compose.yml | 4 +- flow-typed/npm/koa_v2.x.x.js | 23 +-- flow-typed/npm/slug_v0.9.x.js | 21 +-- package.json | 7 +- server/api/documents.js | 85 +++++++-- server/api/documents.test.js | 170 +++++++++++++++++- .../migrations/20190404035736-add-archive.js | 11 ++ server/models/Collection.js | 138 ++++---------- server/models/Collection.test.js | 14 +- server/models/Document.js | 122 ++++++++++++- server/pages/developers/Api.js | 20 ++- server/policies/document.js | 44 +++-- server/presenters/document.js | 2 + server/presenters/user.js | 2 +- shared/components/Notice.js | 8 +- shared/utils/routeHelpers.js | 4 + yarn.lock | 6 +- 39 files changed, 811 insertions(+), 311 deletions(-) create mode 100644 app/scenes/Archive.js create mode 100644 server/migrations/20190404035736-add-archive.js diff --git a/.circleci/config.yml b/.circleci/config.yml index cbc6f28e7..856014b43 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -34,4 +34,7 @@ jobs: command: yarn test - run: name: lint - command: yarn lint \ No newline at end of file + command: yarn lint + - run: + name: flow + command: yarn flow \ No newline at end of file diff --git a/.githooks/pre-commit/flow.sh b/.githooks/pre-commit/flow.sh index 1cb551e61..4d946a70d 100644 --- a/.githooks/pre-commit/flow.sh +++ b/.githooks/pre-commit/flow.sh @@ -1 +1 @@ -yarn lint:flow \ No newline at end of file +yarn flow \ No newline at end of file diff --git a/app/components/Button.js b/app/components/Button.js index 35d6f5625..bd546e2ff 100644 --- a/app/components/Button.js +++ b/app/components/Button.js @@ -52,7 +52,8 @@ const RealButton = styled.button` `} ${props => props.danger && ` - background: ${props.theme.danger}; + background: ${props.theme.danger}; + color: ${props.theme.white}; &:hover { background: ${darken(0.05, props.theme.danger)}; diff --git a/app/components/DocumentList.js b/app/components/DocumentList.js index d6e8861f3..98f9f41d0 100644 --- a/app/components/DocumentList.js +++ b/app/components/DocumentList.js @@ -6,17 +6,10 @@ import ArrowKeyNavigation from 'boundless-arrow-key-navigation'; type Props = { documents: Document[], - showCollection?: boolean, - showPublished?: boolean, limit?: number, }; -export default function DocumentList({ - limit, - showCollection, - showPublished, - documents, -}: Props) { +export default function DocumentList({ limit, documents, ...rest }: Props) { const items = limit ? documents.splice(0, limit) : documents; return ( @@ -25,12 +18,7 @@ export default function DocumentList({ defaultActiveChildIndex={0} > {items.map(document => ( - + ))} ); diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 95850e4f9..e41806fc3 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -17,6 +17,7 @@ type Props = { context?: ?string, showCollection?: boolean, showPublished?: boolean, + link?: boolean, ref?: *, }; @@ -138,6 +139,7 @@ class DocumentPreview extends React.Component { showPublished, highlight, context, + link, ...rest } = this.props; @@ -147,23 +149,29 @@ class DocumentPreview extends React.Component { return ( - {!document.isDraft && ( - <Actions> - {document.starred ? ( - <StyledStar onClick={this.unstar} solid /> - ) : ( - <StyledStar onClick={this.star} /> - )} - </Actions> - )} + {!document.isDraft && + !document.isArchived && ( + <Actions> + {document.starred ? ( + <StyledStar onClick={this.unstar} solid /> + ) : ( + <StyledStar onClick={this.star} /> + )} + </Actions> + )} <StyledDocumentMenu document={document} /> </Heading> {!queryIsInTitle && ( diff --git a/app/components/DocumentPreview/components/PublishingInfo.js b/app/components/DocumentPreview/components/PublishingInfo.js index 3425667dd..b8a553216 100644 --- a/app/components/DocumentPreview/components/PublishingInfo.js +++ b/app/components/DocumentPreview/components/PublishingInfo.js @@ -30,30 +30,49 @@ function PublishingInfo({ collection, showPublished, document }: Props) { updatedAt, updatedBy, publishedAt, + archivedAt, + deletedAt, isDraft, } = document; const neverUpdated = publishedAt === updatedAt; + let content; + + if (deletedAt) { + content = ( + <span> +  deleted <Time dateTime={deletedAt} /> ago + </span> + ); + } else if (archivedAt) { + content = ( + <span> +  archived <Time dateTime={archivedAt} /> ago + </span> + ); + } else if (publishedAt && (neverUpdated || showPublished)) { + content = ( + <span> +  published <Time dateTime={publishedAt} /> ago + </span> + ); + } else if (isDraft) { + content = ( + <span> +  saved <Time dateTime={updatedAt} /> ago + </span> + ); + } else { + content = ( + <Modified highlight={modifiedSinceViewed}> +  updated <Time dateTime={updatedAt} /> ago + </Modified> + ); + } return ( <Container align="center"> - {publishedAt && (neverUpdated || showPublished) ? ( - <span> - {updatedBy.name} published <Time dateTime={publishedAt} /> ago - </span> - ) : ( - <React.Fragment> - {updatedBy.name} - {isDraft ? ( - <span> -  saved <Time dateTime={updatedAt} /> ago - </span> - ) : ( - <Modified highlight={modifiedSinceViewed}> -  updated <Time dateTime={updatedAt} /> ago - </Modified> - )} - </React.Fragment> - )} + {updatedBy.name} + {content} {collection && ( <span>  in <strong>{isDraft ? 'Drafts' : collection.name}</strong> diff --git a/app/components/Layout.js b/app/components/Layout.js index e7ef2c1b2..68f5e44cb 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -10,7 +10,6 @@ import keydown from 'react-keydown'; import Analytics from 'components/Analytics'; import Flex from 'shared/components/Flex'; import { - documentEditUrl, homeUrl, searchUrl, matchDocumentSlug as slug, @@ -72,16 +71,6 @@ class Layout extends React.Component<Props> { this.redirectTo = homeUrl(); } - @keydown('e') - goToEdit(ev) { - const activeDocument = this.props.documents.active; - if (!activeDocument) return; - - ev.preventDefault(); - ev.stopPropagation(); - this.redirectTo = documentEditUrl(activeDocument); - } - @keydown('shift+/') openKeyboardShortcuts() { this.props.ui.setActiveModal('keyboard-shortcuts'); diff --git a/app/components/PaginatedDocumentList.js b/app/components/PaginatedDocumentList.js index 39e63694f..20f11b84d 100644 --- a/app/components/PaginatedDocumentList.js +++ b/app/components/PaginatedDocumentList.js @@ -10,11 +10,10 @@ import DocumentList from 'components/DocumentList'; import { ListPlaceholder } from 'components/LoadingPlaceholder'; type Props = { - showCollection?: boolean, - showPublished?: boolean, documents: Document[], fetch: (options: ?Object) => Promise<*>, options?: Object, + heading?: React.Node, empty?: React.Node, }; @@ -66,25 +65,25 @@ class PaginatedDocumentList extends React.Component<Props> { }; render() { - const { showCollection, showPublished, empty, documents } = this.props; + const { empty, heading, documents, fetch, options, ...rest } = this.props; + const showLoading = !this.isLoaded && this.isFetching && !documents.length; + const showEmpty = this.isLoaded && !documents.length; - return this.isLoaded || documents.length ? ( + return ( <React.Fragment> - {documents.length ? ( - <DocumentList - documents={documents} - showCollection={showCollection} - showPublished={showPublished} - /> - ) : ( + {showEmpty ? ( empty + ) : ( + <React.Fragment> + {heading} + <DocumentList documents={documents} {...rest} /> + {this.allowLoadMore && ( + <Waypoint key={this.offset} onEnter={this.loadMoreResults} /> + )} + </React.Fragment> )} - {this.allowLoadMore && ( - <Waypoint key={this.offset} onEnter={this.loadMoreResults} /> - )} + {showLoading && <ListPlaceholder count={5} />} </React.Fragment> - ) : ( - <ListPlaceholder count={5} /> ); } } diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index b695fed34..22384a8e1 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -1,7 +1,13 @@ // @flow import * as React from 'react'; import { observer, inject } from 'mobx-react'; -import { HomeIcon, EditIcon, SearchIcon, StarredIcon } from 'outline-icons'; +import { + ArchiveIcon, + HomeIcon, + EditIcon, + SearchIcon, + StarredIcon, +} from 'outline-icons'; import Flex from 'shared/components/Flex'; import AccountMenu from 'menus/AccountMenu'; @@ -94,6 +100,17 @@ class MainSidebar extends React.Component<Props> { <Section> <Collections onCreateCollection={this.handleCreateCollection} /> </Section> + <Section> + <SidebarLink + to="/archive" + icon={<ArchiveIcon />} + exact={false} + label="Archive" + active={ + documents.active ? documents.active.isArchived : undefined + } + /> + </Section> </Scrollable> </Flex> </Sidebar> diff --git a/app/menus/AccountMenu.js b/app/menus/AccountMenu.js index a2dec29f8..84d01bb9f 100644 --- a/app/menus/AccountMenu.js +++ b/app/menus/AccountMenu.js @@ -1,7 +1,5 @@ // @flow import * as React from 'react'; -import { Redirect } from 'react-router-dom'; -import { observable } from 'mobx'; import { inject, observer } from 'mobx-react'; import { MoonIcon } from 'outline-icons'; import styled, { withTheme } from 'styled-components'; @@ -15,6 +13,7 @@ import { githubIssuesUrl, mailToUrl, spectrumUrl, + settings, } from '../../shared/utils/routeHelpers'; type Props = { @@ -26,26 +25,15 @@ type Props = { @observer class AccountMenu extends React.Component<Props> { - @observable redirectTo: ?string; - - componentDidUpdate() { - this.redirectTo = undefined; - } - handleOpenKeyboardShortcuts = () => { this.props.ui.setActiveModal('keyboard-shortcuts'); }; - handleOpenSettings = () => { - this.redirectTo = '/settings'; - }; - handleLogout = () => { this.props.auth.logout(); }; render() { - if (this.redirectTo) return <Redirect to={this.redirectTo} push />; const { ui, theme } = this.props; const isLightTheme = ui.theme === 'light'; @@ -54,9 +42,7 @@ class AccountMenu extends React.Component<Props> { style={{ marginRight: 10, marginTop: -10 }} label={this.props.label} > - <DropdownMenuItem onClick={this.handleOpenSettings}> - Settings - </DropdownMenuItem> + <DropdownMenuItem href={settings()}>Settings</DropdownMenuItem> <DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}> Keyboard shortcuts </DropdownMenuItem> @@ -77,8 +63,6 @@ class AccountMenu extends React.Component<Props> { Report a bug </DropdownMenuItem> <hr /> - <DropdownMenuItem onClick={this.handleLogout}>Logout</DropdownMenuItem> - <hr /> <DropdownMenuItem onClick={ui.toggleDarkMode}> <NightMode justify="space-between"> Night Mode{' '} @@ -87,6 +71,8 @@ class AccountMenu extends React.Component<Props> { /> </NightMode> </DropdownMenuItem> + <hr /> + <DropdownMenuItem onClick={this.handleLogout}>Logout</DropdownMenuItem> </DropdownMenu> ); } diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 92f92c8bf..249e8ee97 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -51,7 +51,20 @@ class DocumentMenu extends React.Component<Props> { handleDuplicate = async (ev: SyntheticEvent<*>) => { const duped = await this.props.document.duplicate(); + + // when duplicating, go straight to the duplicated document content this.redirectTo = duped.url; + this.props.ui.showToast('Document duplicated'); + }; + + handleArchive = async (ev: SyntheticEvent<*>) => { + await this.props.document.archive(); + this.props.ui.showToast('Document archived'); + }; + + handleRestore = async (ev: SyntheticEvent<*>) => { + await this.props.document.restore(); + this.props.ui.showToast('Document restored'); }; handlePin = (ev: SyntheticEvent<*>) => { @@ -87,9 +100,22 @@ class DocumentMenu extends React.Component<Props> { const { document, label, className, showPrint, auth } = this.props; const canShareDocuments = auth.team && auth.team.sharing; + if (document.isArchived) { + return ( + <DropdownMenu label={label || <MoreIcon />} className={className}> + <DropdownMenuItem onClick={this.handleRestore}> + Restore + </DropdownMenuItem> + <DropdownMenuItem onClick={this.handleDelete}> + Delete… + </DropdownMenuItem> + </DropdownMenu> + ); + } + return ( <DropdownMenu label={label || <MoreIcon />} className={className}> - {!document.isDraft && ( + {!document.isDraft ? ( <React.Fragment> {document.pinned ? ( <DropdownMenuItem onClick={this.handleUnpin}> @@ -128,10 +154,19 @@ class DocumentMenu extends React.Component<Props> { <DropdownMenuItem onClick={this.handleDuplicate}> Duplicate </DropdownMenuItem> + <DropdownMenuItem onClick={this.handleArchive}> + Archive + </DropdownMenuItem> + <DropdownMenuItem onClick={this.handleDelete}> + Delete… + </DropdownMenuItem> <DropdownMenuItem onClick={this.handleMove}>Move…</DropdownMenuItem> </React.Fragment> + ) : ( + <DropdownMenuItem onClick={this.handleDelete}> + Delete… + </DropdownMenuItem> )} - <DropdownMenuItem onClick={this.handleDelete}>Delete…</DropdownMenuItem> <hr /> <DropdownMenuItem onClick={this.handleExport}> Download diff --git a/app/menus/NewChildDocumentMenu.js b/app/menus/NewChildDocumentMenu.js index f0966d512..5dda4ef09 100644 --- a/app/menus/NewChildDocumentMenu.js +++ b/app/menus/NewChildDocumentMenu.js @@ -41,14 +41,14 @@ class NewChildDocumentMenu extends React.Component<Props> { return ( <DropdownMenu label={label || <MoreIcon />} {...rest}> - <DropdownMenuItem onClick={this.handleNewChild}> - New child document - </DropdownMenuItem> <DropdownMenuItem onClick={this.handleNewDocument}> <span> New document in <strong>{collection.name}</strong> </span> </DropdownMenuItem> + <DropdownMenuItem onClick={this.handleNewChild}> + New child document + </DropdownMenuItem> </DropdownMenu> ); } diff --git a/app/models/Document.js b/app/models/Document.js index e6e593321..743958394 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -36,6 +36,8 @@ export default class Document extends BaseModel { emoji: string; parentDocument: ?string; publishedAt: ?string; + archivedAt: string; + deletedAt: ?string; url: string; urlId: string; shareUrl: ?string; @@ -78,6 +80,16 @@ export default class Document extends BaseModel { return []; } + @computed + get isArchived(): boolean { + return !!this.archivedAt; + } + + @computed + get isDeleted(): boolean { + return !!this.deletedAt; + } + @computed get isDraft(): boolean { return !this.publishedAt; @@ -115,7 +127,11 @@ export default class Document extends BaseModel { this.updateTitle(); }; - restore = (revision: Revision) => { + archive = () => { + return this.store.archive(this); + }; + + restore = (revision: ?Revision) => { return this.store.restore(this, revision); }; diff --git a/app/routes.js b/app/routes.js index 6c9002eb6..f713bbb54 100644 --- a/app/routes.js +++ b/app/routes.js @@ -5,6 +5,7 @@ import Home from 'scenes/Home'; import Dashboard from 'scenes/Dashboard'; import Starred from 'scenes/Starred'; import Drafts from 'scenes/Drafts'; +import Archive from 'scenes/Archive'; import Collection from 'scenes/Collection'; import Document from 'scenes/Document'; import KeyedDocument from 'scenes/Document/KeyedDocument'; @@ -45,6 +46,7 @@ export default function Routes() { <Route exact path="/starred" component={Starred} /> <Route exact path="/starred/:sort" component={Starred} /> <Route exact path="/drafts" component={Drafts} /> + <Route exact path="/archive" component={Archive} /> <Route exact path="/settings" component={Settings} /> <Route exact path="/settings/details" component={Details} /> <Route exact path="/settings/security" component={Security} /> diff --git a/app/scenes/Archive.js b/app/scenes/Archive.js new file mode 100644 index 000000000..68053bcd3 --- /dev/null +++ b/app/scenes/Archive.js @@ -0,0 +1,38 @@ +// @flow +import * as React from 'react'; +import { observer, inject } from 'mobx-react'; + +import CenteredContent from 'components/CenteredContent'; +import Empty from 'components/Empty'; +import PageTitle from 'components/PageTitle'; +import Heading from 'components/Heading'; +import PaginatedDocumentList from 'components/PaginatedDocumentList'; +import Subheading from 'components/Subheading'; +import DocumentsStore from 'stores/DocumentsStore'; + +type Props = { + documents: DocumentsStore, +}; + +@observer +class Archive extends React.Component<Props> { + render() { + const { documents } = this.props; + + return ( + <CenteredContent column auto> + <PageTitle title="Archive" /> + <Heading>Archive</Heading> + <PaginatedDocumentList + documents={documents.archived} + fetch={documents.fetchArchived} + heading={<Subheading>Documents</Subheading>} + empty={<Empty>The document archive is empty at the moment.</Empty>} + showCollection + /> + </CenteredContent> + ); + } +} + +export default inject('documents')(Archive); diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 713306e60..16163fafc 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -27,6 +27,8 @@ import LoadingPlaceholder from 'components/LoadingPlaceholder'; import LoadingIndicator from 'components/LoadingIndicator'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; +import Notice from 'shared/components/Notice'; +import Time from 'shared/components/Time'; import Search from 'scenes/Search'; import Error404 from 'scenes/Error404'; import ErrorOffline from 'scenes/ErrorOffline'; @@ -98,7 +100,19 @@ class DocumentScene extends React.Component<Props> { @keydown('m') goToMove(ev) { ev.preventDefault(); - if (this.document) this.props.history.push(documentMoveUrl(this.document)); + + if (this.document && !this.document.isArchived) { + this.props.history.push(documentMoveUrl(this.document)); + } + } + + @keydown('e') + goToEdit(ev) { + ev.preventDefault(); + + if (this.document && !this.document.isArchived) { + this.props.history.push(documentEditUrl(this.document)); + } } @keydown('esc') @@ -156,6 +170,10 @@ class DocumentScene extends React.Component<Props> { if (document) { this.props.ui.setActiveDocument(document); + if (document.isArchived && this.isEditing) { + return this.goToDocumentCanonical(); + } + if (this.props.auth.user && !shareId) { if (!this.isEditing && document.publishedAt) { this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER); @@ -200,10 +218,6 @@ class DocumentScene extends React.Component<Props> { handleCloseMoveModal = () => (this.moveModalOpen = false); handleOpenMoveModal = () => (this.moveModalOpen = true); - onSaveAndExit = () => { - this.onSave({ done: true }); - }; - onSave = async ( options: { done?: boolean, publish?: boolean, autosave?: boolean } = {} ) => { @@ -366,7 +380,13 @@ class DocumentScene extends React.Component<Props> { onSave={this.onSave} /> )} - <MaxWidth column auto> + <MaxWidth archived={document.isArchived} column auto> + {document.archivedAt && ( + <Notice muted> + Archived by {document.updatedBy.name}{' '} + <Time dateTime={document.archivedAt} /> ago + </Notice> + )} <Editor id={document.id} key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'} @@ -377,9 +397,9 @@ class DocumentScene extends React.Component<Props> { onImageUploadStop={this.onImageUploadStop} onSearchLink={this.onSearchLink} onChange={this.onChange} - onSave={this.onSaveAndExit} + onSave={this.onSave} onCancel={this.onDiscard} - readOnly={!this.isEditing} + readOnly={!this.isEditing || document.isArchived} toc={!revision} ui={this.props.ui} schema={schema} @@ -394,6 +414,8 @@ class DocumentScene extends React.Component<Props> { } const MaxWidth = styled(Flex)` + ${props => + props.archived && `* { color: ${props.theme.textSecondary} !important; } `}; padding: 0 16px; max-width: 100vw; width: 100%; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index a25272c6e..a81dc6cde 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -20,6 +20,7 @@ import NewChildDocumentMenu from 'menus/NewChildDocumentMenu'; import DocumentShare from 'scenes/DocumentShare'; import Button from 'components/Button'; import Modal from 'components/Modal'; +import Badge from 'components/Badge'; import Collaborators from 'components/Collaborators'; import { Action, Separator } from 'components/Actions'; @@ -100,8 +101,10 @@ class Header extends React.Component<Props> { savingIsDisabled, auth, } = this.props; - const canShareDocuments = auth.team && auth.team.sharing; + const canShareDocuments = + auth.team && auth.team.sharing && !document.isArchived; const canToggleEmbeds = auth.team && auth.team.documentEmbeds; + const canEdit = !document.isArchived && !isEditing; return ( <Actions @@ -123,7 +126,7 @@ class Header extends React.Component<Props> { </Modal> <Breadcrumb document={document} /> <Title isHidden={!this.isScrolled} onClick={this.handleClickTitle}> - {document.title} + {document.title} {document.isArchived && <Badge>Archived</Badge>} {!isDraft && !isEditing && } @@ -175,7 +178,7 @@ class Header extends React.Component { )} - {!isEditing && ( + {canEdit && ( diff --git a/app/scenes/Drafts.js b/app/scenes/Drafts.js index 2e471b0b7..de0f7f32a 100644 --- a/app/scenes/Drafts.js +++ b/app/scenes/Drafts.js @@ -9,6 +9,7 @@ import { ListPlaceholder } from 'components/LoadingPlaceholder'; import Empty from 'components/Empty'; import PageTitle from 'components/PageTitle'; import DocumentList from 'components/DocumentList'; +import Subheading from 'components/Subheading'; import NewDocumentMenu from 'menus/NewDocumentMenu'; import Actions, { Action } from 'components/Actions'; import DocumentsStore from 'stores/DocumentsStore'; @@ -33,8 +34,14 @@ class Drafts extends React.Component { Drafts {showLoading && } - {showEmpty && You’ve not got any drafts at the moment.} - + {showEmpty ? ( + You’ve not got any drafts at the moment. + ) : ( + + Documents + + + )} } /> diff --git a/app/scenes/UserProfile.js b/app/scenes/UserProfile.js index 4600f84fc..5bb8a248b 100644 --- a/app/scenes/UserProfile.js +++ b/app/scenes/UserProfile.js @@ -56,11 +56,11 @@ class UserProfile extends React.Component { )} - Recently updated Recently updated} empty={ {user.name} hasn’t updated any documents yet. } diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index e9ac95130..59ca18670 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -20,7 +20,12 @@ export default class DocumentsStore extends BaseStore { } @computed - get recentlyViewed(): * { + get all(): Document[] { + return filter(this.orderedData, d => !d.archivedAt && !d.deletedAt); + } + + @computed + get recentlyViewed(): Document[] { return orderBy( compact(this.recentlyViewedIds.map(id => this.data.get(id))), 'updatedAt', @@ -29,16 +34,13 @@ export default class DocumentsStore extends BaseStore { } @computed - get recentlyUpdated(): * { - return orderBy(Array.from(this.data.values()), 'updatedAt', 'desc'); + get recentlyUpdated(): Document[] { + return orderBy(this.all, 'updatedAt', 'desc'); } createdByUser(userId: string): * { return orderBy( - filter( - Array.from(this.data.values()), - document => document.createdBy.id === userId - ), + filter(this.all, d => d.createdBy.id === userId), 'updatedAt', 'desc' ); @@ -53,7 +55,7 @@ export default class DocumentsStore extends BaseStore { publishedInCollection(collectionId: string): Document[] { return filter( - Array.from(this.data.values()), + this.all, document => document.collectionId === collectionId && !!document.publishedAt ); @@ -93,7 +95,15 @@ export default class DocumentsStore extends BaseStore { @computed get starred(): Document[] { - return filter(this.orderedData, d => d.starred); + return filter(this.all, d => d.starred); + } + + @computed + get archived(): Document[] { + return filter( + orderBy(this.orderedData, 'archivedAt', 'desc'), + d => d.archivedAt + ); } @computed @@ -104,7 +114,7 @@ export default class DocumentsStore extends BaseStore { @computed get drafts(): Document[] { return filter( - orderBy(Array.from(this.data.values()), 'updatedAt', 'desc'), + orderBy(this.all, 'updatedAt', 'desc'), doc => !doc.publishedAt ); } @@ -137,6 +147,11 @@ export default class DocumentsStore extends BaseStore { } }; + @action + fetchArchived = async (options: ?PaginationParams): Promise<*> => { + return this.fetchNamedPage('archived', options); + }; + @action fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => { return this.fetchNamedPage('list', options); @@ -331,15 +346,32 @@ export default class DocumentsStore extends BaseStore { } @action - restore = async (document: Document, revision: Revision) => { + archive = async (document: Document) => { + const res = await client.post('/documents.archive', { + id: document.id, + }); + runInAction('Document#archive', () => { + invariant(res && res.data, 'Data should be available'); + document.updateFromJson(res.data); + }); + + const collection = this.getCollectionForDocument(document); + if (collection) collection.refresh(); + }; + + @action + restore = async (document: Document, revision?: Revision) => { const res = await client.post('/documents.restore', { id: document.id, - revisionId: revision.id, + revisionId: revision ? revision.id : undefined, }); runInAction('Document#restore', () => { invariant(res && res.data, 'Data should be available'); document.updateFromJson(res.data); }); + + const collection = this.getCollectionForDocument(document); + if (collection) collection.refresh(); }; pin = (document: Document) => { @@ -359,7 +391,7 @@ export default class DocumentsStore extends BaseStore { }; getByUrl = (url: string = ''): ?Document => { - return find(Array.from(this.data.values()), doc => url.endsWith(doc.urlId)); + return find(this.orderedData, doc => url.endsWith(doc.urlId)); }; getCollectionForDocument(document: Document) { diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index 5e2c36477..0b1214ef1 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -40,7 +40,7 @@ class UiStore { setActiveDocument = (document: Document): void => { this.activeDocumentId = document.id; - if (document.publishedAt) { + if (document.publishedAt && !document.isArchived && !document.isDeleted) { this.activeCollectionId = document.collectionId; } }; diff --git a/docker-compose.yml b/docker-compose.yml index ce4cce7a3..6273ec5b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,11 @@ services: redis: image: redis ports: - - "6389:6379" + - "6380:6379" postgres: image: postgres ports: - - "5433:5432" + - "5434:5432" environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass diff --git a/flow-typed/npm/koa_v2.x.x.js b/flow-typed/npm/koa_v2.x.x.js index 30ef20994..72b2ed2b6 100644 --- a/flow-typed/npm/koa_v2.x.x.js +++ b/flow-typed/npm/koa_v2.x.x.js @@ -1,3 +1,4 @@ +// @flow // flow-typed signature: 225656ba2479b8c1dd8b10776913e73f // flow-typed version: b7d0245d00/koa_v2.x.x/flow_>=v0.47.x @@ -43,7 +44,7 @@ declare module 'koa' { url: string, header: SimpleHeader, }; - declare type RequestInspect = void | RequestJSON; + declare type RequestInspect = void | RequestJSON; declare type Request = { app: Application, req: http$IncomingMessage, @@ -75,13 +76,13 @@ declare module 'koa' { type: string, url: string, - charset: string | void, - length: number | void, + charset: string | void, + length: number | void, // Those functions comes from https://github.com/jshttp/accepts/blob/master/index.js // request.js$L445 // https://github.com/jshttp/accepts/blob/master/test/type.js - accepts: ((args: string[]) => string | false) & + accepts: ((args: string[]) => string | false) & // ToDo: There is an issue https://github.com/facebook/flow/issues/3009 // if you meet some error here, temporarily add an additional annotation // like: `request.accepts((['json', 'text']:Array))` to fix it. @@ -90,7 +91,7 @@ declare module 'koa' { // https://github.com/jshttp/accepts/blob/master/index.js#L153 // https://github.com/jshttp/accepts/blob/master/test/charset.js - acceptsCharsets: ((args: string[]) => buffer$Encoding | false) & + acceptsCharsets: ((args: string[]) => buffer$Encoding | false) & // ToDo: https://github.com/facebook/flow/issues/3009 // if you meet some error here, see L70. ((arg: string, ...args: string[]) => buffer$Encoding | false) & @@ -98,7 +99,7 @@ declare module 'koa' { // https://github.com/jshttp/accepts/blob/master/index.js#L119 // https://github.com/jshttp/accepts/blob/master/test/encoding.js - acceptsEncodings: ((args: string[]) => string | false) & + acceptsEncodings: ((args: string[]) => string | false) & // ToDo: https://github.com/facebook/flow/issues/3009 // if you meet some error here, see L70. ((arg: string, ...args: string[]) => string | false) & @@ -121,7 +122,7 @@ declare module 'koa' { * If there is no content type, `false` is returned. * Otherwise, it returns the first `type` that matches. */ - is: ((args: string[]) => null | false | string) & + is: ((args: string[]) => null | false | string) & ((arg: string, ...args: string[]) => null | false | string) & (() => string), // should return the mime type @@ -165,7 +166,7 @@ declare module 'koa' { writable: boolean, // charset: string, // doesn't find in response.js - length: number | void, + length: number | void, append: (field: string, val: string | string[]) => void, attachment: (filename?: string) => void, @@ -178,7 +179,7 @@ declare module 'koa' { redirect: (url: string, alt?: string) => void, remove: (field: string) => void, // https://github.com/koajs/koa/blob/v2.x/lib/response.js#L418 - set: ((field: string, val: string | string[]) => void) & + set: ((field: string, val: string | string[]) => void) & ((field: { [key: string]: string | string[] }) => void), vary: (field: string) => void, @@ -217,7 +218,7 @@ declare module 'koa' { name: string, value: string, options?: CookiesSetOptions - ) => Context) & + ) => Context) & // delete cookie (an outbound header with an expired date is used.) ((name: string) => Context), }; @@ -320,7 +321,7 @@ declare module 'koa' { res: http$ServerResponse ) => void; env: string; - keys?: Array | Object; // https://github.com/crypto-utils/keygrip + keys?: Array | Object; // https://github.com/crypto-utils/keygrip middleware: Array; proxy: boolean; // when true proxy header fields will be trusted request: Request; diff --git a/flow-typed/npm/slug_v0.9.x.js b/flow-typed/npm/slug_v0.9.x.js index 52ad3914f..be0fdd050 100644 --- a/flow-typed/npm/slug_v0.9.x.js +++ b/flow-typed/npm/slug_v0.9.x.js @@ -1,7 +1,8 @@ +// @flow // flow-typed signature: c499686d8ed4b3da5bd13133389c6088 // flow-typed version: b43dff3e0e/slug_v0.9.x/flow_>=v0.25.x -type SlugMode = 'rfc3986' | 'pretty' +type SlugMode = 'rfc3986' | 'pretty'; declare module 'slug' { declare type SlugOptions = { @@ -12,14 +13,14 @@ declare module 'slug' { remove?: ?RegExp, lower?: boolean, symbols?: boolean, - } + }; declare module.exports: { - (input: string, optionOrReplacement?: string | SlugOptions): string, - defaults: { - mode: 'pretty', - charmap: { [key: string]: string }, - multicharmap: { [key: string]: string }, - modes: { [key: SlugMode]: SlugOptions } - } - } + (input: string, optionOrReplacement?: string | SlugOptions): string, + defaults: { + mode: 'pretty' | 'rfc3986', + charmap: { [key: string]: string }, + multicharmap: { [key: string]: string }, + modes: { [key: SlugMode]: SlugOptions }, + }, + }; } diff --git a/package.json b/package.json index a3caa29d3..5a8c8e285 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,8 @@ "build": "npm run clean && npm run build:webpack", "start": "NODE_ENV=production node index.js", "dev": "NODE_ENV=development nodemon --watch server index.js", - "lint": "npm run lint:flow && npm run lint:js", - "lint:js": "eslint app server", - "lint:flow": "flow", + "lint": "eslint app server", + "flow": "flow", "deploy": "git push heroku master", "heroku-postbuild": "npm run build && npm run sequelize:migrate", "sequelize:create-migration": "sequelize migration:create", @@ -121,7 +120,7 @@ "mobx-react": "^5.4.2", "natural-sort": "^1.0.0", "nodemailer": "^4.4.0", - "outline-icons": "^1.7.0", + "outline-icons": "^1.8.0-0", "oy-vey": "^0.10.0", "pg": "^6.1.5", "pg-hstore": "2.3.2", diff --git a/server/api/documents.js b/server/api/documents.js index 8dd470108..40863e81e 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -100,6 +100,38 @@ router.post('documents.pinned', auth(), pagination(), async ctx => { }; }); +router.post('documents.archived', auth(), pagination(), async ctx => { + const { sort = 'updatedAt' } = ctx.body; + let direction = ctx.body.direction; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const collectionIds = await user.collectionIds(); + + const documents = await Document.findAll({ + where: { + teamId: user.teamId, + collectionId: collectionIds, + archivedAt: { + // $FlowFixMe + [Op.ne]: 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.viewed', auth(), pagination(), async ctx => { let { sort = 'updatedAt', direction } = ctx.body; if (direction !== 'ASC') direction = 'DESC'; @@ -235,7 +267,7 @@ router.post('documents.info', auth({ required: false }), async ctx => { }, ], }); - if (!share) { + if (!share || share.document.archivedAt) { throw new InvalidRequestError('Document could not be found for shareId'); } document = share.document; @@ -300,18 +332,29 @@ router.post('documents.revisions', auth(), pagination(), async ctx => { router.post('documents.restore', auth(), async ctx => { const { id, revisionId } = ctx.body; ctx.assertPresent(id, 'id is required'); - ctx.assertPresent(revisionId, 'revisionId is required'); const user = ctx.state.user; const document = await Document.findById(id); - authorize(user, 'update', document); - const revision = await Revision.findById(revisionId); - authorize(document, 'restore', revision); + if (document.archivedAt) { + authorize(user, 'unarchive', document); - document.text = revision.text; - document.title = revision.title; - await document.save(); + // restore a previously archived document + await document.unarchive(user.id); + + // restore a document to a specific revision + } else if (revisionId) { + authorize(user, 'update', document); + + const revision = await Revision.findById(revisionId); + authorize(document, 'restore', revision); + + document.text = revision.text; + document.title = revision.title; + await document.save(); + } else { + ctx.assertPresent(revisionId, 'revisionId is required'); + } ctx.body = { data: await presentDocument(ctx, document), @@ -530,20 +573,30 @@ router.post('documents.move', auth(), async ctx => { }; }); +router.post('documents.archive', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const document = await Document.findById(id); + authorize(user, 'archive', document); + + await document.archive(user.id); + + ctx.body = { + data: await presentDocument(ctx, document), + }; +}); + router.post('documents.delete', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); + const user = ctx.state.user; const document = await Document.findById(id); - authorize(ctx.state.user, 'delete', document); + authorize(user, 'delete', document); - const collection = document.collection; - if (collection && collection.type === 'atlas') { - // Delete document and all of its children - await collection.removeDocument(document); - } - - await document.destroy(); + await document.delete(); ctx.body = { success: true, diff --git a/server/api/documents.test.js b/server/api/documents.test.js index d54bae93f..1feac2f43 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -27,6 +27,18 @@ describe('#documents.info', async () => { expect(body.data.id).toEqual(document.id); }); + it('should return archived document', async () => { + const { user, document } = await seed(); + await document.archive(user.id); + 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 not return published document in collection not a member of', async () => { const { user, document, collection } = await seed(); collection.private = true; @@ -86,6 +98,20 @@ describe('#documents.info', async () => { expect(res.status).toEqual(400); }); + it('should not return document from archived shareId', async () => { + const { document, user } = await seed(); + const share = await buildShare({ + documentId: document.id, + teamId: document.teamId, + }); + await document.archive(user.id); + + const res = await server.post('/api/documents.info', { + body: { shareId: share.id }, + }); + expect(res.status).toEqual(400); + }); + it('should return document from shareId with token', async () => { const { user, document, collection } = await seed(); const share = await buildShare({ @@ -420,6 +446,24 @@ describe('#documents.search', async () => { expect(body.data.length).toEqual(0); }); + it('should not return archived documents', async () => { + const { user } = await seed(); + const document = await buildDocument({ + title: 'search term', + text: 'search term', + teamId: user.teamId, + }); + await document.archive(user.id); + + const res = await server.post('/api/documents.search', { + body: { token: user.getJwtToken(), query: 'search term' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + it('should not return documents in private collections not a member of', async () => { const { user } = await seed(); const collection = await buildCollection({ private: true }); @@ -449,6 +493,66 @@ describe('#documents.search', async () => { }); }); +describe('#documents.archived', async () => { + it('should return archived documents', async () => { + const { user } = await seed(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + await document.archive(user.id); + + const res = await server.post('/api/documents.archived', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + }); + + it('should not return deleted documents', async () => { + const { user } = await seed(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + await document.delete(); + + const res = await server.post('/api/documents.archived', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it('should not return documents in private collections not a member of', async () => { + const { user } = await seed(); + const collection = await buildCollection({ private: true }); + + const document = await buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + }); + await document.archive(user.id); + + const res = await server.post('/api/documents.archived', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.archived'); + expect(res.status).toEqual(401); + }); +}); + describe('#documents.viewed', async () => { it('should return empty result if no views', async () => { const { user } = await seed(); @@ -577,7 +681,37 @@ describe('#documents.pin', async () => { }); }); -describe('#documents.restore', async () => { +describe('#documents.restore', () => { + it('should allow restore of archived documents', async () => { + const { user, document } = await seed(); + await document.archive(user.id); + + const res = await server.post('/api/documents.restore', { + body: { token: user.getJwtToken(), id: document.id }, + }); + const body = await res.json(); + expect(body.data.archivedAt).toEqual(null); + }); + + it('should restore archived when previous parent is archived', async () => { + const { user, document } = await seed(); + const childDocument = await buildDocument({ + userId: user.id, + teamId: user.teamId, + collectionId: document.collectionId, + parentDocumentId: document.id, + }); + await childDocument.archive(user.id); + await document.archive(user.id); + + const res = await server.post('/api/documents.restore', { + body: { token: user.getJwtToken(), id: childDocument.id }, + }); + const body = await res.json(); + expect(body.data.parentDocumentId).toEqual(undefined); + expect(body.data.archivedAt).toEqual(null); + }); + it('should restore the document to a previous version', async () => { const { user, document } = await seed(); const revision = await Revision.findOne({ @@ -855,6 +989,22 @@ describe('#documents.update', async () => { expect(body.data.collection.documents[0].title).toBe('Updated title'); }); + it('should not edit archived document', async () => { + const { user, document } = await seed(); + await document.archive(); + + const res = await server.post('/api/documents.update', { + body: { + token: user.getJwtToken(), + id: document.id, + title: 'Updated title', + text: 'Updated text', + lastRevision: document.revision, + }, + }); + expect(res.status).toEqual(403); + }); + it('should not create new version when autosave=true', async () => { const { user, document } = await seed(); @@ -974,6 +1124,24 @@ describe('#documents.update', async () => { }); }); +describe('#documents.archive', async () => { + it('should allow archiving document', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/documents.archive', { + body: { token: user.getJwtToken(), id: document.id }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const { document } = await seed(); + const res = await server.post('/api/documents.archive', { + body: { id: document.id }, + }); + expect(res.status).toEqual(401); + }); +}); + describe('#documents.delete', async () => { it('should allow deleting document', async () => { const { user, document } = await seed(); diff --git a/server/migrations/20190404035736-add-archive.js b/server/migrations/20190404035736-add-archive.js new file mode 100644 index 000000000..09c3636b4 --- /dev/null +++ b/server/migrations/20190404035736-add-archive.js @@ -0,0 +1,11 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('documents', 'archivedAt', { + type: Sequelize.DATE, + allowNull: true + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('documents', 'archivedAt'); + } +} \ No newline at end of file diff --git a/server/models/Collection.js b/server/models/Collection.js index f73d9a574..0cbef17fb 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,5 +1,5 @@ // @flow -import _ from 'lodash'; +import { find, remove } from 'lodash'; import slug from 'slug'; import randomstring from 'randomstring'; import { DataTypes, sequelize } from '../sequelize'; @@ -7,14 +7,10 @@ import { asyncLock } from '../redis'; import events from '../events'; import Document from './Document'; import CollectionUser from './CollectionUser'; -import Event from './Event'; import { welcomeMessage } from '../utils/onboarding'; -// $FlowIssue invalid flow-typed slug.defaults.mode = 'rfc3986'; -const allowedCollectionTypes = [['atlas', 'journal']]; - const Collection = sequelize.define( 'collection', { @@ -30,7 +26,7 @@ const Collection = sequelize.define( private: DataTypes.BOOLEAN, type: { type: DataTypes.STRING, - validate: { isIn: allowedCollectionTypes }, + validate: { isIn: [['atlas', 'journal']] }, }, /* type: atlas */ @@ -40,10 +36,10 @@ const Collection = sequelize.define( tableName: 'collections', paranoid: true, hooks: { - beforeValidate: collection => { + beforeValidate: (collection: Collection) => { collection.urlId = collection.urlId || randomstring.generate(10); }, - afterCreate: async collection => { + afterCreate: async (collection: Collection) => { const team = await collection.getTeam(); const collections = await team.getCollections(); @@ -115,7 +111,7 @@ Collection.associate = models => { ); }; -Collection.addHook('afterDestroy', async model => { +Collection.addHook('afterDestroy', async (model: Collection) => { await Document.destroy({ where: { collectionId: model.id, @@ -123,19 +119,19 @@ Collection.addHook('afterDestroy', async model => { }); }); -Collection.addHook('afterCreate', model => +Collection.addHook('afterCreate', (model: Collection) => events.add({ name: 'collections.create', model }) ); -Collection.addHook('afterDestroy', model => +Collection.addHook('afterDestroy', (model: Collection) => events.add({ name: 'collections.delete', model }) ); -Collection.addHook('afterUpdate', model => +Collection.addHook('afterUpdate', (model: Collection) => events.add({ name: 'collections.update', model }) ); -Collection.addHook('afterCreate', (model, options) => { +Collection.addHook('afterCreate', (model: Collection, options) => { if (model.private) { return CollectionUser.findOrCreate({ where: { @@ -154,23 +150,16 @@ Collection.addHook('afterCreate', (model, options) => { // Instance methods Collection.prototype.addDocumentToStructure = async function( - document, - index, + document: Document, + index: number, options = {} ) { if (!this.documentStructure) return; - const existingData = { - old: this.documentStructure, - documentId: document, - parentDocumentId: document.parentDocumentId, - index, - }; - // documentStructure can only be updated by one request at the time + // documentStructure can only be updated by one request at a time const unlock = await asyncLock(`collection-${this.id}`); - // If moving existing document with children, use existing structure to - // keep everything in shape and not loose documents + // If moving existing document with children, use existing structure const documentJson = { ...document.toJSON(), ...options.documentJson, @@ -206,18 +195,7 @@ Collection.prototype.addDocumentToStructure = async function( // Sequelize doesn't seem to set the value with splice on JSONB field this.documentStructure = this.documentStructure; - await this.save(); - - await Event.create({ - name: 'Collection#addDocumentToStructure', - data: { - ...existingData, - new: this.documentStructure, - }, - collectionId: this.id, - teamId: this.teamId, - }); - + await this.save(options); unlock(); return this; @@ -226,7 +204,9 @@ Collection.prototype.addDocumentToStructure = async function( /** * Update document's title and url in the documentStructure */ -Collection.prototype.updateDocument = async function(updatedDocument) { +Collection.prototype.updateDocument = async function( + updatedDocument: Document +) { if (!this.documentStructure) return; // documentStructure can only be updated by one request at the time @@ -261,98 +241,56 @@ Collection.prototype.updateDocument = async function(updatedDocument) { Collection.prototype.moveDocument = async function(document, index) { if (!this.documentStructure) return; - const documentJson = await this.removeDocument(document, { - deleteDocument: false, - }); + const documentJson = await this.removeDocumentInStructure(document); await this.addDocumentToStructure(document, index, { documentJson }); - - return this; }; -type DeleteDocumentOptions = { - deleteDocument: boolean, +Collection.prototype.deleteDocument = async function(document) { + await this.removeDocumentInStructure(document, { save: true }); + await document.deleteWithChildren(); }; -/** - * removeDocument is used for both deleting documents (deleteDocument: true) - * and removing them temporarily from the structure while they are being moved - * (deleteDocument: false). - */ -Collection.prototype.removeDocument = async function( +Collection.prototype.removeDocumentInStructure = async function( document, - options: DeleteDocumentOptions = { deleteDocument: true } + options?: { save?: boolean } ) { if (!this.documentStructure) return; - let returnValue; + let unlock; - // documentStructure can only be updated by one request at the time - const unlock = await asyncLock('testLock'); + if (options && options.save) { + // documentStructure can only be updated by one request at the time + unlock = await asyncLock(`collection-${this.id}`); + } - const existingData = { - old: this.documentStructure, - documentId: document, - parentDocumentId: document.parentDocumentId, - options, - }; - - // Helper to destroy all child documents for a document - const deleteChildren = async documentId => { - const childDocuments = await Document.findAll({ - where: { parentDocumentId: documentId }, - }); - childDocuments.forEach(async child => { - await deleteChildren(child.id); - await child.destroy(); - }); - }; - - // Prune, and destroy if needed, from the document structure - const deleteFromChildren = async (children, id) => { + const removeFromChildren = async (children, id) => { children = await Promise.all( children.map(async childDocument => { return { ...childDocument, - children: await deleteFromChildren(childDocument.children, id), + children: await removeFromChildren(childDocument.children, id), }; }) ); - const match = _.find(children, { id }); + const match = find(children, { id }); if (match) { - if (!options.deleteDocument && !returnValue) returnValue = match; - _.remove(children, { id }); - - if (options.deleteDocument) { - const childDocument = await Document.findById(id); - // Delete the actual document - if (childDocument) await childDocument.destroy(); - // Delete all child documents - await deleteChildren(id); - } + if (!returnValue) returnValue = match; + remove(children, { id }); } return children; }; - this.documentStructure = await deleteFromChildren( + this.documentStructure = await removeFromChildren( this.documentStructure, document.id ); - if (options.deleteDocument) await this.save(); - - await Event.create({ - name: 'Collection#removeDocument', - data: { - ...existingData, - new: this.documentStructure, - }, - collectionId: this.id, - teamId: this.teamId, - }); - - await unlock(); + if (options && options.save) { + await this.save(options); + if (unlock) await unlock(); + } return returnValue; }; diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index 65d8e68ce..da81c4808 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -156,8 +156,6 @@ describe('#moveDocument', () => { test('should move a document with children', async () => { const { collection, document } = await seed(); - - // Add a child for testing const newDocument = await Document.create({ parentDocumentId: document.id, collectionId: collection.id, @@ -182,14 +180,14 @@ describe('#removeDocument', () => { const { collection, document } = await seed(); jest.spyOn(collection, 'save'); - await collection.removeDocument(document); + await collection.deleteDocument(document); expect(collection.save).toBeCalled(); }); test('should remove documents from root', async () => { const { collection, document } = await seed(); - await collection.removeDocument(document); + await collection.deleteDocument(document); expect(collection.documentStructure.length).toBe(1); // Verify that the document was removed @@ -219,7 +217,7 @@ describe('#removeDocument', () => { expect(collection.documentStructure[1].children.length).toBe(1); // Remove the document - await collection.removeDocument(document); + await collection.deleteDocument(document); expect(collection.documentStructure.length).toBe(1); const collectionDocuments = await Document.findAndCountAll({ where: { @@ -249,7 +247,7 @@ describe('#removeDocument', () => { expect(collection.documentStructure[1].children.length).toBe(1); // Remove the document - await collection.removeDocument(newDocument); + await collection.deleteDocument(newDocument); expect(collection.documentStructure.length).toBe(2); expect(collection.documentStructure[0].children.length).toBe(0); @@ -268,9 +266,7 @@ describe('#removeDocument', () => { const { collection, document } = await seed(); jest.spyOn(collection, 'save'); - const removedNode = await collection.removeDocument(document, { - deleteDocument: false, - }); + const removedNode = await collection.removeDocumentInStructure(document); expect(collection.documentStructure.length).toBe(1); expect(destroyMock).not.toBeCalled(); expect(collection.save).not.toBeCalled(); diff --git a/server/models/Document.js b/server/models/Document.js index d1f723276..dfba75a06 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -4,7 +4,7 @@ import { map, find, compact, uniq } from 'lodash'; import randomstring from 'randomstring'; import MarkdownSerializer from 'slate-md-serializer'; import Plain from 'slate-plain-serializer'; -import Sequelize from 'sequelize'; +import Sequelize, { type Transaction } from 'sequelize'; import removeMarkdown from '@tommoor/remove-markdown'; import isUUID from 'validator/lib/isUUID'; @@ -91,6 +91,7 @@ const Document = sequelize.define( }, text: DataTypes.TEXT, revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 }, + archivedAt: DataTypes.DATE, publishedAt: DataTypes.DATE, parentDocumentId: DataTypes.UUID, collaboratorIds: DataTypes.ARRAY(DataTypes.UUID), @@ -183,18 +184,20 @@ Document.associate = models => { })); }; -Document.findById = async id => { +Document.findById = async (id, options) => { const scope = Document.scope('withUnpublished'); if (isUUID(id)) { return scope.findOne({ where: { id }, + ...options, }); } else if (id.match(URL_REGEX)) { return scope.findOne({ where: { urlId: id.match(URL_REGEX)[1], }, + ...options, }); } }; @@ -222,6 +225,7 @@ Document.searchForUser = async ( FROM documents WHERE "searchVector" @@ to_tsquery('english', :query) AND "collectionId" IN(:collectionIds) AND + "archivedAt" IS NULL AND "deletedAt" IS NULL AND ("publishedAt" IS NOT NULL OR "createdById" = '${user.id}') ORDER BY @@ -271,7 +275,7 @@ Document.addHook('beforeSave', async model => { if (!model.publishedAt) return; const collection = await Collection.findById(model.collectionId); - if (collection.type !== 'atlas') return; + if (!collection || collection.type !== 'atlas') return; await collection.updateDocument(model); model.collection = collection; @@ -281,7 +285,7 @@ Document.addHook('afterCreate', async model => { if (!model.publishedAt) return; const collection = await Collection.findById(model.collectionId); - if (collection.type !== 'atlas') return; + if (!collection || collection.type !== 'atlas') return; await collection.addDocumentToStructure(model); model.collection = collection; @@ -296,6 +300,48 @@ Document.addHook('afterDestroy', model => // Instance methods +// Note: This method marks the document and it's children as deleted +// in the database, it does not permanantly delete them OR remove +// from the collection structure. +Document.prototype.deleteWithChildren = async function(options) { + // Helper to destroy all child documents for a document + const loopChildren = async (documentId, opts) => { + const childDocuments = await Document.findAll({ + where: { parentDocumentId: documentId }, + }); + childDocuments.forEach(async child => { + await loopChildren(child.id, opts); + await child.destroy(opts); + }); + }; + + await loopChildren(this.id, options); + await this.destroy(options); +}; + +Document.prototype.archiveWithChildren = async function(userId, options) { + const archivedAt = new Date(); + + // Helper to archive all child documents for a document + const archiveChildren = async parentDocumentId => { + const childDocuments = await Document.findAll({ + where: { parentDocumentId }, + }); + childDocuments.forEach(async child => { + await archiveChildren(child.id); + + child.archivedAt = archivedAt; + child.lastModifiedById = userId; + await child.save(options); + }); + }; + + await archiveChildren(this.id); + this.archivedAt = archivedAt; + this.lastModifiedById = userId; + return this.save(options); +}; + Document.prototype.publish = async function() { if (this.publishedAt) return this.save(); @@ -312,6 +358,74 @@ Document.prototype.publish = async function() { return this; }; +// Moves a document from being visible to the team within a collection +// to the archived area, where it can be subsequently restored. +Document.prototype.archive = async function(userId) { + // archive any children and remove from the document structure + const collection = await this.getCollection(); + await collection.removeDocumentInStructure(this, { save: true }); + this.collection = collection; + + this.archivedAt = new Date(); + this.lastModifiedById = userId; + await this.save(); + await this.archiveWithChildren(userId); + + events.add({ name: 'documents.archive', model: this }); + return this; +}; + +// Restore an archived document back to being visible to the team +Document.prototype.unarchive = async function(userId) { + const collection = await this.getCollection(); + + // check to see if the documents parent hasn't been archived also + // If it has then restore the document to the collection root. + if (this.parentDocumentId) { + const parent = await Document.findOne({ + where: { + id: this.parentDocumentId, + archivedAt: { + // $FlowFixMe + [Op.eq]: null, + }, + }, + }); + if (!parent) this.parentDocumentId = undefined; + } + + await collection.addDocumentToStructure(this); + this.collection = collection; + + this.archivedAt = null; + this.lastModifiedById = userId; + await this.save(); + + events.add({ name: 'documents.unarchive', model: this }); + return this; +}; + +// Delete a document, archived or otherwise. +Document.prototype.delete = function(options) { + return sequelize.transaction(async (transaction: Transaction): Promise<*> => { + if (!this.archivedAt) { + // delete any children and remove from the document structure + const collection = await this.getCollection(); + if (collection) await collection.deleteDocument(this, { transaction }); + } + + await Revision.destroy({ + where: { documentId: this.id }, + transaction, + }); + + await this.destroy({ transaction, ...options }); + + events.add({ name: 'documents.delete', model: this }); + return this; + }); +}; + Document.prototype.getTimestamp = function() { return Math.round(new Date(this.updatedAt).getTime() / 1000); }; diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index c611bc671..01c7e097b 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -370,9 +370,23 @@ export default function Pricing() { + + + Archive a document and all of its child documents, if any. + + + + + + - Delete a document and all of its child documents if any. + Permanantly delete a document and all of its child documents, if + any. Restores a document to a previous revision by creating a new - revision with the contents of the given revisionId. + revision with the contents of the given revisionId or restores an + archived document if no revisionId is passed. diff --git a/server/policies/document.js b/server/policies/document.js index 17b5d4da7..eb0e3b0b6 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -6,18 +6,40 @@ const { allow, cannot } = policy; allow(User, 'create', Document); -allow( - User, - ['read', 'update', 'delete', 'share'], - Document, - (user, document) => { - if (document.collection) { - if (cannot(user, 'read', document.collection)) return false; - } - - return user.teamId === document.teamId; +allow(User, ['read', 'delete'], Document, (user, document) => { + if (document.collection) { + if (cannot(user, 'read', document.collection)) return false; } -); + + return user.teamId === document.teamId; +}); + +allow(User, ['update', 'share'], Document, (user, document) => { + if (document.collection) { + if (cannot(user, 'read', document.collection)) return false; + } + if (document.archivedAt) return false; + + return user.teamId === document.teamId; +}); + +allow(User, 'archive', Document, (user, document) => { + if (document.collection) { + if (cannot(user, 'read', document.collection)) return false; + } + if (!document.publishedAt) return false; + + return user.teamId === document.teamId; +}); + +allow(User, 'unarchive', Document, (user, document) => { + if (document.collection) { + if (cannot(user, 'read', document.collection)) return false; + } + if (!document.archivedAt) return false; + + return user.teamId === document.teamId; +}); allow( Document, diff --git a/server/presenters/document.js b/server/presenters/document.js index a0cc240db..266e01b66 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -32,6 +32,8 @@ async function present(ctx: Object, document: Document, options: ?Options) { updatedAt: document.updatedAt, updatedBy: undefined, publishedAt: document.publishedAt, + archivedAt: document.archivedAt, + deletedAt: document.deletedAt, team: document.teamId, collaborators: [], starred: !!(document.starred && document.starred.length), diff --git a/server/presenters/user.js b/server/presenters/user.js index 850a4c49e..295071eeb 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -18,7 +18,7 @@ export default ( ctx: Object, user: User, options: Options = {} -): UserPresentation => { +): ?UserPresentation => { const userData = {}; userData.id = user.id; userData.createdAt = user.createdAt; diff --git a/shared/components/Notice.js b/shared/components/Notice.js index 02179f9cd..1a33554c0 100644 --- a/shared/components/Notice.js +++ b/shared/components/Notice.js @@ -2,9 +2,11 @@ import styled from 'styled-components'; const Notice = styled.p` - background: #ffd95c; - color: hsla(46, 100%, 20%, 1); - padding: 10px; + background: ${props => + props.muted ? props.theme.sidebarBackground : props.theme.yellow}; + color: ${props => + props.muted ? props.theme.sidebarText : 'hsla(46, 100%, 20%, 1)'}; + padding: 10px 12px; border-radius: 4px; `; diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js index 97d5db95d..b647f0eeb 100644 --- a/shared/utils/routeHelpers.js +++ b/shared/utils/routeHelpers.js @@ -84,3 +84,7 @@ export function integrations(): string { export function privacy(): string { return `${process.env.URL}/privacy`; } + +export function settings(): string { + return `${process.env.URL}/settings`; +} diff --git a/yarn.lock b/yarn.lock index 953bdc50b..0916e0fb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6332,9 +6332,9 @@ outline-icons@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.6.0.tgz#6c7897d354e6bd77ca5498cd3a989b8cb9482574" -outline-icons@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.7.0.tgz#093f2f18c80bf5577bc31a6ff41460f2feb76fb7" +outline-icons@^1.8.0-0: + version "1.8.0-0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.8.0-0.tgz#a3499cc0837626541e6bc00c2bfed7279d1c8bb3" oy-vey@^0.10.0: version "0.10.0"