From ef0fb74308f36995c80ba790874f68132d07c300 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:34:56 +0530 Subject: [PATCH] feat: Add button to empty trash (#6772) Co-authored-by: Tom Moor --- app/actions/definitions/documents.tsx | 26 +++++++- app/actions/sections.ts | 2 + app/components/Collection/CollectionForm.tsx | 4 +- .../components/DeleteDocumentsInTrash.tsx | 43 +++++++++++++ app/scenes/{Trash.tsx => Trash/index.tsx} | 20 +++++- app/stores/DocumentsStore.ts | 8 +++ server/commands/documentPermanentDeleter.ts | 17 ++++- .../__snapshots__/documents.test.ts.snap | 18 ++++++ server/routes/api/documents/documents.test.ts | 55 ++++++++++++++++ server/routes/api/documents/documents.ts | 62 +++++++++++++++---- shared/i18n/locales/en_US/translation.json | 7 ++- 11 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 app/scenes/Trash/components/DeleteDocumentsInTrash.tsx rename app/scenes/{Trash.tsx => Trash/index.tsx} (63%) diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 72f592be0..affb19ed6 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -37,11 +37,12 @@ import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPublish from "~/scenes/DocumentPublish"; +import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import DuplicateDialog from "~/components/DuplicateDialog"; import SharePopover from "~/components/Sharing"; import { createAction } from "~/actions"; -import { DocumentSection } from "~/actions/sections"; +import { DocumentSection, TrashSection } from "~/actions/sections"; import env from "~/env"; import history from "~/utils/history"; import { @@ -52,6 +53,7 @@ import { searchPath, documentPath, urlify, + trashPath, } from "~/utils/routeHelpers"; 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: , + 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: ( + + ), + }); + }, +}); + export const openDocumentComments = createAction({ name: ({ t }) => t("Comments"), analyticsName: "Open comments", @@ -952,6 +975,7 @@ export const rootDocumentActions = [ moveDocument, openRandomDocument, permanentlyDeleteDocument, + permanentlyDeleteDocumentsInTrash, printDocument, pinDocumentToCollection, pinDocumentToHome, diff --git a/app/actions/sections.ts b/app/actions/sections.ts index 1541637d2..0224757c6 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -20,3 +20,5 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace"); export const RecentSearchesSection = ({ t }: ActionContext) => t("Recent searches"); + +export const TrashSection = ({ t }: ActionContext) => t("Trash"); diff --git a/app/components/Collection/CollectionForm.tsx b/app/components/Collection/CollectionForm.tsx index 8c629b5d5..69f9dcea1 100644 --- a/app/components/Collection/CollectionForm.tsx +++ b/app/components/Collection/CollectionForm.tsx @@ -61,7 +61,7 @@ export const CollectionForm = observer(function CollectionForm_({ React.useEffect(() => { // 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. - if (!hasOpenedIconPicker) { + if (!hasOpenedIconPicker && !collection) { setValue( "icon", IconLibrary.findIconByKeyword(values.name) ?? @@ -69,7 +69,7 @@ export const CollectionForm = observer(function CollectionForm_({ "collection" ); } - }, [values.name]); + }, [values.name, collection]); const handleIconPickerChange = React.useCallback( (color: string, icon: string) => { diff --git a/app/scenes/Trash/components/DeleteDocumentsInTrash.tsx b/app/scenes/Trash/components/DeleteDocumentsInTrash.tsx new file mode 100644 index 000000000..6189511a0 --- /dev/null +++ b/app/scenes/Trash/components/DeleteDocumentsInTrash.tsx @@ -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 ( + + + + + + ); +} + +export default observer(DeleteDocumentsInTrash); diff --git a/app/scenes/Trash.tsx b/app/scenes/Trash/index.tsx similarity index 63% rename from app/scenes/Trash.tsx rename to app/scenes/Trash/index.tsx index ee659372e..4f571da7c 100644 --- a/app/scenes/Trash.tsx +++ b/app/scenes/Trash/index.tsx @@ -2,18 +2,36 @@ import { observer } from "mobx-react"; import { TrashIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import Button from "~/components/Button"; import Empty from "~/components/Empty"; import Heading from "~/components/Heading"; import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import Scene from "~/components/Scene"; import Subheading from "~/components/Subheading"; +import { permanentlyDeleteDocumentsInTrash } from "~/actions/definitions/documents"; +import useActionContext from "~/hooks/useActionContext"; import useStores from "~/hooks/useStores"; function Trash() { const { t } = useTranslation(); const { documents } = useStores(); + const context = useActionContext(); return ( - } title={t("Trash")}> + } + title={t("Trash")} + actions={ + documents.deleted.length > 0 && ( + + ) + } + > {t("Trash")} { }); }; + @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) => this.rootStore.stars.create({ documentId: document.id, diff --git a/server/commands/documentPermanentDeleter.ts b/server/commands/documentPermanentDeleter.ts index a0c4a23b3..ec02a9d22 100644 --- a/server/commands/documentPermanentDeleter.ts +++ b/server/commands/documentPermanentDeleter.ts @@ -1,5 +1,5 @@ import uniq from "lodash/uniq"; -import { QueryTypes } from "sequelize"; +import { Op, QueryTypes } from "sequelize"; import Logger from "@server/logging/Logger"; import { Document, Attachment } from "@server/models"; 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({ where: { id: documents.map((document) => document.id), diff --git a/server/routes/api/documents/__snapshots__/documents.test.ts.snap b/server/routes/api/documents/__snapshots__/documents.test.ts.snap index bd99e53f3..d1eaf5ed7 100644 --- a/server/routes/api/documents/__snapshots__/documents.test.ts.snap +++ b/server/routes/api/documents/__snapshots__/documents.test.ts.snap @@ -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`] = ` { "error": "authentication_required", diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index cd294b9e5..1a820b147 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -4345,3 +4345,58 @@ describe("#documents.memberships", () => { 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(); + }); +}); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 8f4581fb3..048353765 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1213,17 +1213,6 @@ router.post( }); authorize(user, "permanentDelete", document); - await Document.update( - { - parentDocumentId: null, - }, - { - where: { - parentDocumentId: document.id, - }, - paranoid: false, - } - ); await documentPermanentDeleter([document]); await Event.create({ 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 = { + 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; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 270c19115..c5794bf9c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -66,6 +66,8 @@ "Delete {{ documentName }}": "Delete {{ documentName }}", "Permanently delete": "Permanently delete", "Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}", + "Empty": "Empty", + "Permanently delete documents in trash": "Permanently delete documents in trash", "Comments": "Comments", "History": "History", "Insights": "Insights", @@ -89,7 +91,6 @@ "Download {{ platform }} app": "Download {{ platform }} app", "Log out": "Log out", "Mark notifications as read": "Mark notifications as read", - "Notification settings": "Notification settings", "Archive all notifications": "Archive all notifications", "Restore revision": "Restore revision", "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", "Collections": "Collections", "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 forward": "Go forward", "Could not load shared documents": "Could not load shared documents", @@ -453,6 +453,7 @@ "New child document": "New child document", "New document in {{ collectionName }}": "New document in {{ collectionName }}", "New template": "New template", + "Notification settings": "Notification settings", "Revision options": "Revision options", "Share link revoked": "Share link revoked", "Share link copied": "Share link copied", @@ -953,6 +954,8 @@ "Workspace name": "Workspace name", "You are creating a new workspace using your current account — {{email}}": "You are creating a new workspace using your current account — {{email}}", "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", "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.",