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:
@@ -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 && (
|
||||
|
||||
@@ -158,6 +158,7 @@ const Label = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 4.4em;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const Disclosure = styled(CollapsedIcon)`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
38
app/scenes/Trash.js
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user