feat: Trash (#1082)

* wip: trash

* Enable restoration of deleted documents

* update Trash icon

* Add endpoint to trigger garbage collection

* fix: account for drafts

* fix: Archived documents should be deletable

* fix: Missing delete cascade

* bump: upgrade rich-markdown-editor
This commit is contained in:
Tom Moor
2019-11-18 18:51:32 -08:00
committed by GitHub
parent 14f6e6abad
commit e404955394
20 changed files with 346 additions and 30 deletions

View File

@@ -8,6 +8,7 @@ import {
EditIcon,
SearchIcon,
StarredIcon,
TrashIcon,
PlusIcon,
} from 'outline-icons';
@@ -111,7 +112,10 @@ class MainSidebar extends React.Component<Props> {
</Drafts>
}
active={
documents.active ? !documents.active.publishedAt : undefined
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted
: undefined
}
/>
</Section>
@@ -125,7 +129,18 @@ class MainSidebar extends React.Component<Props> {
exact={false}
label="Archive"
active={
documents.active ? documents.active.isArchived : undefined
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon />}
exact={false}
label="Trash"
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
{can.invite && (

View File

@@ -158,6 +158,7 @@ const Label = styled.div`
position: relative;
width: 100%;
max-height: 4.4em;
line-height: 1.6;
`;
const Disclosure = styled(CollapsedIcon)`

View File

@@ -59,7 +59,10 @@ class SocketProvider extends React.Component<Props> {
let document = documents.get(documentId) || {};
if (event.event === 'documents.delete') {
documents.remove(documentId);
const document = documents.get(documentId);
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
continue;
}

View File

@@ -132,6 +132,7 @@ class DocumentMenu extends React.Component<Props> {
const can = policies.abilities(document.id);
const canShareDocuments = can.share && auth.team && auth.team.sharing;
const canViewHistory = can.read && !can.restore;
return (
<DropdownMenu
@@ -140,7 +141,7 @@ class DocumentMenu extends React.Component<Props> {
onOpen={onOpen}
onClose={onClose}
>
{can.unarchive && (
{(can.unarchive || can.restore) && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
@@ -176,11 +177,13 @@ class DocumentMenu extends React.Component<Props> {
Share link
</DropdownMenuItem>
)}
<hr />
{can.read && (
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history
</DropdownMenuItem>
{canViewHistory && (
<React.Fragment>
<hr />
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history
</DropdownMenuItem>
</React.Fragment>
)}
{can.update && (
<DropdownMenuItem

View File

@@ -1,5 +1,6 @@
// @flow
import { action, set, computed } from 'mobx';
import addDays from 'date-fns/add_days';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import parseTitle from 'shared/utils/parseTitle';
@@ -76,6 +77,15 @@ export default class Document extends BaseModel {
return !this.publishedAt;
}
@computed
get permanentlyDeletedAt(): ?string {
if (!this.deletedAt) {
return;
}
return addDays(new Date(this.deletedAt), 30).toString();
}
@action
share = async () => {
const res = await client.post('/shares.create', { documentId: this.id });

View File

@@ -6,6 +6,7 @@ import Dashboard from 'scenes/Dashboard';
import Starred from 'scenes/Starred';
import Drafts from 'scenes/Drafts';
import Archive from 'scenes/Archive';
import Trash from 'scenes/Trash';
import Collection from 'scenes/Collection';
import KeyedDocument from 'scenes/Document/KeyedDocument';
import DocumentNew from 'scenes/DocumentNew';
@@ -49,6 +50,7 @@ export default function Routes() {
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />

View File

@@ -400,10 +400,25 @@ class DocumentScene extends React.Component<Props> {
/>
)}
<MaxWidth archived={document.isArchived} column auto>
{document.archivedAt && (
{document.archivedAt &&
!document.deletedAt && (
<Notice muted>
Archived by {document.updatedBy.name}{' '}
<Time dateTime={document.archivedAt} /> ago
</Notice>
)}
{document.deletedAt && (
<Notice muted>
Archived by {document.updatedBy.name}{' '}
<Time dateTime={document.archivedAt} /> ago
Deleted by {document.updatedBy.name}{' '}
<Time dateTime={document.deletedAt} /> ago
{document.permanentlyDeletedAt && (
<React.Fragment>
<br />
This document will be permanently deleted in{' '}
<Time dateTime={document.permanentlyDeletedAt} /> unless
restored.
</React.Fragment>
)}
</Notice>
)}
<Editor

View File

@@ -50,8 +50,8 @@ class DocumentDelete extends React.Component<Props> {
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{' '}
<strong>{document.title}</strong> document is permanent, and will
delete all of its history, and any child documents.
<strong>{document.title}</strong> document will delete all of its
history, and any child documents.
</HelpText>
{!document.isDraft &&
!document.isArchived && (

38
app/scenes/Trash.js Normal file
View File

@@ -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 Trash extends React.Component<Props> {
render() {
const { documents } = this.props;
return (
<CenteredContent column auto>
<PageTitle title="Trash" />
<Heading>Trash</Heading>
<PaginatedDocumentList
documents={documents.deleted}
fetch={documents.fetchDeleted}
heading={<Subheading>Documents</Subheading>}
empty={<Empty>Trash is empty at the moment.</Empty>}
showCollection
/>
</CenteredContent>
);
}
}
export default inject('documents')(Trash);

View File

@@ -121,6 +121,14 @@ export default class DocumentsStore extends BaseStore<Document> {
);
}
@computed
get deleted(): Document[] {
return filter(
orderBy(this.orderedData, 'deletedAt', 'desc'),
d => d.deletedAt
);
}
@computed
get starredAlphabetical(): Document[] {
return naturalSort(this.starred, 'title');
@@ -189,6 +197,11 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage('archived', options);
};
@action
fetchDeleted = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage('deleted', options);
};
@action
fetchRecentlyUpdated = async (options: ?PaginationParams): Promise<*> => {
return this.fetchNamedPage('list', options);