feat: Add button to empty trash (#6772)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Hemachandar
2024-04-16 18:34:56 +05:30
committed by GitHub
parent a5d2752122
commit ef0fb74308
11 changed files with 244 additions and 18 deletions

View File

@@ -37,11 +37,12 @@ import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove"; import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish"; import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog"; import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing"; import SharePopover from "~/components/Sharing";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections"; import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env"; import env from "~/env";
import history from "~/utils/history"; import history from "~/utils/history";
import { import {
@@ -52,6 +53,7 @@ import {
searchPath, searchPath,
documentPath, documentPath,
urlify, urlify,
trashPath,
} from "~/utils/routeHelpers"; } from "~/utils/routeHelpers";
export const openDocument = createAction({ export const openDocument = createAction({
@@ -828,6 +830,27 @@ export const permanentlyDeleteDocument = createAction({
}, },
}); });
export const permanentlyDeleteDocumentsInTrash = createAction({
name: ({ t }) => t("Empty"),
analyticsName: "Empty trash",
section: TrashSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) =>
stores.documents.deleted.length > 0 && !!stores.auth.user?.isAdmin,
perform: ({ stores, t, location }) => {
stores.dialogs.openModal({
title: t("Permanently delete documents in trash"),
content: (
<DeleteDocumentsInTrash
onSubmit={stores.dialogs.closeAllModals}
shouldRedirect={location.pathname === trashPath()}
/>
),
});
},
});
export const openDocumentComments = createAction({ export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"), name: ({ t }) => t("Comments"),
analyticsName: "Open comments", analyticsName: "Open comments",
@@ -952,6 +975,7 @@ export const rootDocumentActions = [
moveDocument, moveDocument,
openRandomDocument, openRandomDocument,
permanentlyDeleteDocument, permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
printDocument, printDocument,
pinDocumentToCollection, pinDocumentToCollection,
pinDocumentToHome, pinDocumentToHome,

View File

@@ -20,3 +20,5 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) => export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches"); t("Recent searches");
export const TrashSection = ({ t }: ActionContext) => t("Trash");

View File

@@ -61,7 +61,7 @@ export const CollectionForm = observer(function CollectionForm_({
React.useEffect(() => { React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on // If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes. // the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker) { if (!hasOpenedIconPicker && !collection) {
setValue( setValue(
"icon", "icon",
IconLibrary.findIconByKeyword(values.name) ?? IconLibrary.findIconByKeyword(values.name) ??
@@ -69,7 +69,7 @@ export const CollectionForm = observer(function CollectionForm_({
"collection" "collection"
); );
} }
}, [values.name]); }, [values.name, collection]);
const handleIconPickerChange = React.useCallback( const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => { (color: string, icon: string) => {

View File

@@ -0,0 +1,43 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
type Props = {
onSubmit: () => void;
shouldRedirect: boolean;
};
function DeleteDocumentsInTrash({ onSubmit, shouldRedirect }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const history = useHistory();
const handleSubmit = async () => {
await documents.emptyTrash();
toast.success(t("Trash emptied"));
onSubmit();
if (shouldRedirect) {
history.push("/home");
}
};
return (
<Flex column>
<ConfirmationDialog
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
onSubmit={handleSubmit}
danger
>
<Trans defaults="Are you sure you want to permanently delete all the documents in Trash? This action is immediate and cannot be undone." />
</ConfirmationDialog>
</Flex>
);
}
export default observer(DeleteDocumentsInTrash);

View File

@@ -2,18 +2,36 @@ import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons"; import { TrashIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Button from "~/components/Button";
import Empty from "~/components/Empty"; import Empty from "~/components/Empty";
import Heading from "~/components/Heading"; import Heading from "~/components/Heading";
import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading"; import Subheading from "~/components/Subheading";
import { permanentlyDeleteDocumentsInTrash } from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
function Trash() { function Trash() {
const { t } = useTranslation(); const { t } = useTranslation();
const { documents } = useStores(); const { documents } = useStores();
const context = useActionContext();
return ( return (
<Scene icon={<TrashIcon />} title={t("Trash")}> <Scene
icon={<TrashIcon />}
title={t("Trash")}
actions={
documents.deleted.length > 0 && (
<Button
neutral
action={permanentlyDeleteDocumentsInTrash}
context={context}
>
Empty
</Button>
)
}
>
<Heading>{t("Trash")}</Heading> <Heading>{t("Trash")}</Heading>
<PaginatedDocumentList <PaginatedDocumentList
documents={documents.deleted} documents={documents.deleted}

View File

@@ -765,6 +765,14 @@ export default class DocumentsStore extends Store<Document> {
}); });
}; };
@action
emptyTrash = async () => {
await client.post("/documents.empty_trash");
const documentIdsSet = new Set(this.deleted.map((doc) => doc.id));
this.removeAll((doc: Document) => documentIdsSet.has(doc.id));
};
star = (document: Document, index?: string) => star = (document: Document, index?: string) =>
this.rootStore.stars.create({ this.rootStore.stars.create({
documentId: document.id, documentId: document.id,

View File

@@ -1,5 +1,5 @@
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import { QueryTypes } from "sequelize"; import { Op, QueryTypes } from "sequelize";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
import { Document, Attachment } from "@server/models"; import { Document, Attachment } from "@server/models";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask"; import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
@@ -73,6 +73,21 @@ export default async function documentPermanentDeleter(documents: Document[]) {
); );
} }
const documentIds = documents.map((document) => document.id);
await Document.update(
{
parentDocumentId: null,
},
{
where: {
parentDocumentId: {
[Op.in]: documentIds,
},
},
paranoid: false,
}
);
return Document.scope("withDrafts").destroy({ return Document.scope("withDrafts").destroy({
where: { where: {
id: documents.map((document) => document.id), id: documents.map((document) => document.id),

View File

@@ -18,6 +18,24 @@ exports[`#documents.delete should require authentication 1`] = `
} }
`; `;
exports[`#documents.empty_trash should not allow non-admin users 1`] = `
{
"error": "authorization_error",
"message": "Admin role required",
"ok": false,
"status": 403,
}
`;
exports[`#documents.empty_trash should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.list should require authentication 1`] = ` exports[`#documents.list should require authentication 1`] = `
{ {
"error": "authentication_required", "error": "authentication_required",

View File

@@ -4345,3 +4345,58 @@ describe("#documents.memberships", () => {
expect(body.data.users[0].id).toEqual(members[1].id); expect(body.data.users[0].id).toEqual(members[1].id);
}); });
}); });
describe("#documents.empty_trash", () => {
it("should require authentication", async () => {
const res = await server.post("/api/documents.empty_trash");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should allow admin users", async () => {
const user = await buildAdmin();
const res = await server.post("/api/documents.empty_trash", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should not allow non-admin users", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.empty_trash", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(403);
expect(body).toMatchSnapshot();
});
it("should permanently delete documents", async () => {
const user = await buildAdmin();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await document.delete(user.id);
const res = await server.post("/api/documents.empty_trash", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const deletedDoc = await Document.findByPk(document.id, {
userId: user.id,
paranoid: false,
});
expect(deletedDoc).toBeNull();
});
});

View File

@@ -1213,17 +1213,6 @@ router.post(
}); });
authorize(user, "permanentDelete", document); authorize(user, "permanentDelete", document);
await Document.update(
{
parentDocumentId: null,
},
{
where: {
parentDocumentId: document.id,
},
paranoid: false,
}
);
await documentPermanentDeleter([document]); await documentPermanentDeleter([document]);
await Event.create({ await Event.create({
name: "documents.permanent_delete", name: "documents.permanent_delete",
@@ -1701,4 +1690,55 @@ router.post(
} }
); );
router.post(
"documents.empty_trash",
auth({ role: UserRole.Admin }),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const collectionIds = await user.collectionIds({
paranoid: false,
});
const collectionScope: Readonly<ScopeOptions> = {
method: ["withCollectionPermissions", user.id],
};
const documents = await Document.scope([
collectionScope,
"withDrafts",
]).findAll({
where: {
deletedAt: {
[Op.ne]: null,
},
[Op.or]: [
{
collectionId: {
[Op.in]: collectionIds,
},
},
{
createdById: user.id,
collectionId: {
[Op.is]: null,
},
},
],
},
paranoid: false,
});
await documentPermanentDeleter(documents);
await Event.create({
name: "documents.empty_trash",
teamId: user.teamId,
actorId: user.id,
ip: ctx.request.ip,
});
ctx.body = {
success: true,
};
}
);
export default router; export default router;

View File

@@ -66,6 +66,8 @@
"Delete {{ documentName }}": "Delete {{ documentName }}", "Delete {{ documentName }}": "Delete {{ documentName }}",
"Permanently delete": "Permanently delete", "Permanently delete": "Permanently delete",
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}", "Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
"Empty": "Empty",
"Permanently delete documents in trash": "Permanently delete documents in trash",
"Comments": "Comments", "Comments": "Comments",
"History": "History", "History": "History",
"Insights": "Insights", "Insights": "Insights",
@@ -89,7 +91,6 @@
"Download {{ platform }} app": "Download {{ platform }} app", "Download {{ platform }} app": "Download {{ platform }} app",
"Log out": "Log out", "Log out": "Log out",
"Mark notifications as read": "Mark notifications as read", "Mark notifications as read": "Mark notifications as read",
"Notification settings": "Notification settings",
"Archive all notifications": "Archive all notifications", "Archive all notifications": "Archive all notifications",
"Restore revision": "Restore revision", "Restore revision": "Restore revision",
"Link copied": "Link copied", "Link copied": "Link copied",
@@ -302,7 +303,6 @@
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection", "You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Collections": "Collections", "Collections": "Collections",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word", "Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
"Empty": "Empty",
"Go back": "Go back", "Go back": "Go back",
"Go forward": "Go forward", "Go forward": "Go forward",
"Could not load shared documents": "Could not load shared documents", "Could not load shared documents": "Could not load shared documents",
@@ -453,6 +453,7 @@
"New child document": "New child document", "New child document": "New child document",
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>", "New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
"New template": "New template", "New template": "New template",
"Notification settings": "Notification settings",
"Revision options": "Revision options", "Revision options": "Revision options",
"Share link revoked": "Share link revoked", "Share link revoked": "Share link revoked",
"Share link copied": "Share link copied", "Share link copied": "Share link copied",
@@ -953,6 +954,8 @@
"Workspace name": "Workspace name", "Workspace name": "Workspace name",
"You are creating a new workspace using your current account — <em>{{email}}</em>": "You are creating a new workspace using your current account — <em>{{email}}</em>", "You are creating a new workspace using your current account — <em>{{email}}</em>": "You are creating a new workspace using your current account — <em>{{email}}</em>",
"To create a workspace under another email please sign up from the homepage": "To create a workspace under another email please sign up from the homepage", "To create a workspace under another email please sign up from the homepage": "To create a workspace under another email please sign up from the homepage",
"Trash emptied": "Trash emptied",
"Are you sure you want to permanently delete all the documents in Trash? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete all the documents in Trash? This action is immediate and cannot be undone.",
"Recently deleted": "Recently deleted", "Recently deleted": "Recently deleted",
"Trash is empty at the moment.": "Trash is empty at the moment.", "Trash is empty at the moment.": "Trash is empty at the moment.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.", "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.",