fix: Delete collection exports (#2595)
This commit is contained in:
parent
be905a6993
commit
81718c8ee1
|
@ -0,0 +1,43 @@
|
|||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
|
||||
type Props = {|
|
||||
id: string,
|
||||
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|
||||
|};
|
||||
|
||||
function FileOperationMenu({ id, onDelete }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({ modal: true });
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show Menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("File Operation options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("Download"),
|
||||
href: "/api/fileOperations.redirect?id=" + id,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: t("Delete"),
|
||||
onClick: onDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileOperationMenu;
|
|
@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
|
|||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import { parseOutlineExport } from "shared/utils/zip";
|
||||
import FileOperation from "models/FileOperation";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
|
@ -102,6 +103,18 @@ function ImportExport() {
|
|||
[t, collections, showToast]
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async (fileOperation: FileOperation) => {
|
||||
try {
|
||||
await fileOperations.delete(fileOperation);
|
||||
showToast(t("Export deleted"));
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
[fileOperations, showToast, t]
|
||||
);
|
||||
|
||||
const hasCollections = importDetails
|
||||
? !!importDetails.filter((detail) => detail.type === "collection").length
|
||||
: false;
|
||||
|
@ -216,6 +229,7 @@ function ImportExport() {
|
|||
<FileOperationListItem
|
||||
key={item.id + item.state}
|
||||
fileOperation={item}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -2,16 +2,19 @@
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileOperation from "models/FileOperation";
|
||||
import Button from "components/Button";
|
||||
import { Action } from "components/Actions";
|
||||
import ListItem from "components/List/Item";
|
||||
import Time from "components/Time";
|
||||
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import FileOperationMenu from "menus/FileOperationMenu";
|
||||
type Props = {|
|
||||
fileOperation: FileOperation,
|
||||
handleDelete: (FileOperation) => Promise<void>,
|
||||
|};
|
||||
|
||||
const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const stateMapping = {
|
||||
creating: t("Processing"),
|
||||
|
@ -34,7 +37,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
|||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
fileOperation.id === fileOperation.user.id
|
||||
user.id === fileOperation.user.id
|
||||
? t("You")
|
||||
: fileOperation.user.name,
|
||||
})}
|
||||
|
@ -45,13 +48,15 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
|||
}
|
||||
actions={
|
||||
fileOperation.state === "complete" ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={`/api/fileOperations.redirect?id=${fileOperation.id}`}
|
||||
neutral
|
||||
>
|
||||
{t("Download")}
|
||||
</Button>
|
||||
<Action>
|
||||
<FileOperationMenu
|
||||
id={fileOperation.id}
|
||||
onDelete={async (ev) => {
|
||||
ev.preventDefault();
|
||||
await handleDelete(fileOperation);
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,7 @@ import BaseStore from "./BaseStore";
|
|||
import RootStore from "./RootStore";
|
||||
|
||||
export default class FileOperationsStore extends BaseStore<FileOperation> {
|
||||
actions = ["list", "info"];
|
||||
actions = ["list", "info", "delete"];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, FileOperation);
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
import { FileOperation, Event, User } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
|
||||
export default async function fileOperationDeleter(
|
||||
fileOp: FileOperation,
|
||||
user: User,
|
||||
ip: string
|
||||
) {
|
||||
let transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
await fileOp.destroy({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "fileOperations.delete",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: fileOp.dataValues,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
import { FileOperation } from "../models";
|
||||
import { buildAdmin, buildFileOperation } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import fileOperationDeleter from "./fileOperationDeleter";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("fileOperationDeleter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should destroy file operation", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const fileOp = await buildFileOperation({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
|
||||
await fileOperationDeleter(fileOp, admin, ip);
|
||||
|
||||
expect(await FileOperation.count()).toEqual(0);
|
||||
});
|
||||
});
|
|
@ -34,10 +34,14 @@ const FileOperation = sequelize.define("file_operations", {
|
|||
},
|
||||
});
|
||||
|
||||
FileOperation.beforeDestroy(async (model) => {
|
||||
await deleteFromS3(model.key);
|
||||
});
|
||||
|
||||
FileOperation.prototype.expire = async function () {
|
||||
this.state = "expired";
|
||||
await deleteFromS3(this.key);
|
||||
this.save();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
FileOperation.associate = (models) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// @flow
|
||||
import Router from "koa-router";
|
||||
import fileOperationDeleter from "../../commands/fileOperationDeleter";
|
||||
import { NotFoundError, ValidationError } from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { FileOperation, Team } from "../../models";
|
||||
|
@ -88,7 +89,7 @@ router.post("fileOperations.redirect", auth(), async (ctx) => {
|
|||
authorize(user, fileOp.type, team);
|
||||
|
||||
if (fileOp.state !== "complete") {
|
||||
throw new ValidationError("file operation is not complete yet");
|
||||
throw new ValidationError(`${fileOp.type} is not complete yet`);
|
||||
}
|
||||
|
||||
const accessUrl = await getSignedUrl(fileOp.key);
|
||||
|
@ -96,4 +97,24 @@ router.post("fileOperations.redirect", auth(), async (ctx) => {
|
|||
ctx.redirect(accessUrl);
|
||||
});
|
||||
|
||||
router.post("fileOperations.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
const fileOp = await FileOperation.findByPk(id);
|
||||
|
||||
if (!fileOp) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
authorize(user, fileOp.type, team);
|
||||
|
||||
await fileOperationDeleter(fileOp, user, ctx.request.ip);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
export default router;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
|
||||
import { Collection, User } from "../../models";
|
||||
import { Collection, User, Event, FileOperation } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import {
|
||||
buildAdmin,
|
||||
|
@ -15,6 +15,14 @@ import { flushdb } from "../../test/support";
|
|||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
|
@ -234,7 +242,7 @@ describe("#fileOperations.redirect", () => {
|
|||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("file operation is not complete yet");
|
||||
expect(body.message).toEqual("export is not complete yet");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -281,3 +289,27 @@ describe("#fileOperations.info", () => {
|
|||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#fileOperations.delete", () => {
|
||||
it("should delete file operation", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
state: "complete",
|
||||
});
|
||||
|
||||
const deleteResponse = await server.post("/api/fileOperations.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: exportData.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.status).toBe(200);
|
||||
expect(await Event.count()).toBe(1);
|
||||
expect(await FileOperation.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -268,15 +268,12 @@ export async function buildFileOperation(overrides: Object = {}) {
|
|||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
if (!overrides.collectionId) {
|
||||
const collection = await buildCollection(overrides);
|
||||
overrides.collectionId = collection.id;
|
||||
}
|
||||
|
||||
return FileOperation.create({
|
||||
state: "creating",
|
||||
size: 0,
|
||||
key: "key/to/aws/file.zip",
|
||||
key: "uploads/key/to/file.zip",
|
||||
collectionId: null,
|
||||
type: "export",
|
||||
url: "https://www.urltos3file.com/file.zip",
|
||||
...overrides,
|
||||
});
|
||||
|
|
|
@ -132,7 +132,7 @@ export type CollectionExportAllEvent = {
|
|||
};
|
||||
|
||||
export type FileOperationEvent = {
|
||||
name: "fileOperations.update",
|
||||
name: "fileOperations.update" | "fileOperation.delete",
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
data: {
|
||||
|
|
|
@ -227,6 +227,8 @@
|
|||
"Print": "Print",
|
||||
"Move {{ documentName }}": "Move {{ documentName }}",
|
||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||
"Show Menu": "Show Menu",
|
||||
"File Operation options": "File Operation options",
|
||||
"Edit group": "Edit group",
|
||||
"Delete group": "Delete group",
|
||||
"Group options": "Group options",
|
||||
|
@ -518,6 +520,7 @@
|
|||
"No groups have been created yet": "No groups have been created yet",
|
||||
"Import started": "Import started",
|
||||
"Export in progress…": "Export in progress…",
|
||||
"Export deleted": "Export deleted",
|
||||
"It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.",
|
||||
"Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.": "Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.",
|
||||
"Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.": "Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.",
|
||||
|
|
Reference in New Issue