Compare commits

...

10 Commits

Author SHA1 Message Date
CuriousCorrelation 75d1329c2e Merge branch 'main' into feat/empty-trash 2022-08-13 18:58:44 +05:30
CuriousCorrelation 68e1483fa8 fix(test): Merge draft and published doc tests 2022-08-13 18:54:13 +05:30
CuriousCorrelation 406d5e89c9 fix(server): empty_trash excluding drafts 2022-08-03 15:54:20 +05:30
CuriousCorrelation 19df7bee78 feat: documents.empty_trash endpoint 2022-08-03 15:18:50 +05:30
CuriousCorrelation 16d576b4db fix(app): Early exit on already empty trash 2022-08-02 17:47:40 +05:30
CuriousCorrelation 78845d1535 fix(app): Reduce Empty trash action severity 2022-08-02 17:46:31 +05:30
CuriousCorrelation 36d718d123 Merge branch 'main' into feat/empty-trash 2022-08-02 17:22:57 +05:30
CuriousCorrelation 95a734b11e feat(app): Add model for already empty trash 2022-07-24 16:35:18 +05:30
CuriousCorrelation a20f02e5f3 fix: Revert temp package changes 2022-07-24 14:43:48 +05:30
CuriousCorrelation 134c69b4df feat(app): Add Empty Trash button to trash
Only available to admins
2022-07-24 14:41:37 +05:30
7 changed files with 290 additions and 2 deletions
+97
View File
@@ -0,0 +1,97 @@
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
function EmptyTrashMenu() {
const { t } = useTranslation();
const { documents } = useStores();
const [showModal, setShowModal] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const { showToast } = useToasts();
const history = useHistory();
const [trashed, setTrashed] = React.useState<Document[]>([]);
React.useEffect(() => {
async function getTrashed() {
const trashedDocs = await documents.fetchDeleted();
setTrashed(trashedDocs);
}
getTrashed();
}, [documents]);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
try {
setIsDeleting(true);
await documents.emptyTrash();
// If all trashed documents were not removed.
// E.g. More trashed docs than `emptyTrash` limit.
const trashedDocs = await documents.fetchDeleted();
setTrashed(trashedDocs);
showToast(t("Trash permanently deleted"), {
type: "success",
});
history.push("/trash");
setShowModal(false);
} catch (err) {
showToast(err.message, {
type: "error",
});
} finally {
setIsDeleting(false);
setTrashed([]);
}
},
[documents, history, showToast, t]
);
if (!trashed.length) {
return <></>;
}
return (
<>
<Button icon={<TrashIcon />} onClick={() => setShowModal(true)}>
{t("Empty trash")}
</Button>
{showModal && (
<Modal
title={t("Empty trash")}
isOpen={showModal}
onRequestClose={() => setShowModal(false)}
isCentered
>
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans
defaults="Are you sure you want to permanently delete all documents in the trash? This action cannot be undone."
components={{
em: <strong />,
}}
/>
</Text>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure Delete")}
</Button>
</form>
</Flex>
</Modal>
)}
</>
);
}
export default observer(EmptyTrashMenu);
+16 -1
View File
@@ -2,18 +2,33 @@ import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Action } from "~/components/Actions";
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 useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import EmptyTrashMenu from "~/menus/EmptyTrashMenu";
function Trash() {
const { t } = useTranslation();
const { documents } = useStores();
const user = useCurrentUser();
return (
<Scene icon={<TrashIcon color="currentColor" />} title={t("Trash")}>
<Scene
icon={<TrashIcon color="currentColor" />}
title={t("Trash")}
actions={
user.isAdmin && (
<Action>
<EmptyTrashMenu />
</Action>
)
}
>
<Heading>{t("Trash")}</Heading>
<PaginatedDocumentList
documents={documents.deleted}
+31 -1
View File
@@ -684,6 +684,33 @@ export default class DocumentsStore extends BaseStore<Document> {
}
}
@action
async emptyTrash() {
const res = await client.post("/documents.empty_trash");
invariant(res?.data, "Data should be available");
res.data.forEach(
({
documentId,
collectionId,
}: {
documentId: string;
collectionId: string;
}) => {
this.remove(documentId);
const share = this.rootStore.shares.getByDocumentId(documentId);
if (share) {
this.rootStore.shares.remove(share.id);
}
const collection = this.rootStore.collections.data.get(collectionId);
if (collection) {
collection.refresh();
}
}
);
}
@action
archive = async (document: Document) => {
const res = await client.post("/documents.archive", {
@@ -718,7 +745,10 @@ export default class DocumentsStore extends BaseStore<Document> {
document.updateFromJson(res.data);
this.addPolicies(res.policies);
});
const collection = this.getCollectionForDocument(document);
const collection = this.rootStore.collections.data.get(
document.collectionId
);
if (collection) {
collection.refresh();
}
+81
View File
@@ -2492,3 +2492,84 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(401);
});
});
describe("#documents.empty_trash", () => {
it("should require admin", async () => {
const viewer = await buildViewer();
const res = await server.post("/api/documents.empty_trash", {
body: {
token: viewer.getJwtToken(),
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.empty_trash");
expect(res.status).toEqual(401);
});
it("should permenantly delete trashed documents", async () => {
const { admin, document } = await seed();
// To simulate deleting draft
document.publishedAt = null;
await document.save();
await document.delete(admin.id);
const publishedDoc = await buildDocument({
userId: admin.id,
teamId: admin.teamId,
});
await publishedDoc.delete(admin.id);
const res0 = await server.post("/api/documents.deleted", {
body: {
token: admin.getJwtToken(),
},
});
const body0 = await res0.json();
expect(res0.status).toEqual(200);
expect(body0.data.length).toEqual(2);
const res1 = await server.post("/api/documents.empty_trash", {
body: {
token: admin.getJwtToken(),
},
});
const body1 = await res1.json();
expect(res1.status).toEqual(200);
expect(body1.data.length).toEqual(2);
expect(body1.data[0].documentId).toEqual(document.id);
expect(body1.data[0].collectionId).toEqual(document.collectionId);
expect(body1.data[1].documentId).toEqual(publishedDoc.id);
expect(body1.data[1].collectionId).toEqual(publishedDoc.collectionId);
const res2 = await server.post("/api/documents.deleted", {
body: {
token: admin.getJwtToken(),
},
});
const body2 = await res2.json();
expect(res2.status).toEqual(200);
expect(body2.data.length).toEqual(0);
const events = await Event.findAll();
expect(events.length).toEqual(2);
expect(events[0].name).toEqual("documents.permanent_delete");
expect(events[0].documentId).toEqual(document.id);
expect(events[0].collectionId).toEqual(document.collectionId);
expect(events[0].teamId).toEqual(document.teamId);
expect(events[0].actorId).toEqual(admin.id);
expect(events[0].data.title).toEqual(document.title);
expect(events[1].name).toEqual("documents.permanent_delete");
expect(events[1].documentId).toEqual(publishedDoc.id);
expect(events[1].collectionId).toEqual(publishedDoc.collectionId);
expect(events[1].teamId).toEqual(publishedDoc.teamId);
expect(events[1].actorId).toEqual(admin.id);
expect(events[1].data.title).toEqual(publishedDoc.title);
});
});
+59
View File
@@ -3,6 +3,7 @@ import invariant from "invariant";
import Router from "koa-router";
import { Op, ScopeOptions, WhereOptions } from "sequelize";
import { subtractDate } from "@shared/utils/date";
import { DocumentValidation } from "@shared/validations";
import documentCreator from "@server/commands/documentCreator";
import documentImporter from "@server/commands/documentImporter";
import documentLoader from "@server/commands/documentLoader";
@@ -1063,6 +1064,64 @@ router.post("documents.delete", auth({ member: true }), async (ctx) => {
};
});
router.post("documents.empty_trash", auth({ admin: true }), async (ctx) => {
const { user } = ctx.state;
const deleted: { documentId: string; collectionId: string }[] = [];
await Document.unscoped().findAllInBatches<Document>(
{
where: {
teamId: user.teamId,
deletedAt: {
[Op.ne]: null,
},
},
paranoid: false,
limit: DocumentValidation.emptyTrash,
},
async (documents) => {
for (const document of documents) {
authorize(user, "permanentDelete", document);
deleted.push({
documentId: document.id,
collectionId: document.collectionId,
});
await Document.update(
{
parentDocumentId: null,
},
{
where: {
parentDocumentId: document.id,
},
paranoid: false,
}
);
await documentPermanentDeleter([document]);
await Event.create({
name: "documents.permanent_delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: {
title: document.title,
},
ip: ctx.request.ip,
});
}
}
);
ctx.body = {
success: true,
data: deleted,
};
});
router.post("documents.unpublish", auth({ member: true }), async (ctx) => {
const { id } = ctx.body;
assertPresent(id, "id is required");
@@ -287,6 +287,9 @@
"Unpublish": "Unpublish",
"Enable embeds": "Enable embeds",
"Full width": "Full width",
"Trash permanently deleted": "Trash permanently deleted",
"Empty trash": "Empty trash",
"Are you sure you want to permanently delete all documents in the trash? This action cannot be undone.": "Are you sure you want to permanently delete all documents in the trash? This action cannot be undone.",
"Export options": "Export options",
"Edit group": "Edit group",
"Delete group": "Delete group",
+3
View File
@@ -30,6 +30,9 @@ export const CollectionValidation = {
export const DocumentValidation = {
/** The maximum length of the document title */
maxTitleLength: 100,
/** The maximum number of documents allowed to be permanently deleted in one batch */
emptyTrash: 100,
};
export const PinValidation = {