Skip to content

Commit 93cab68

Browse files
committed
Handle mass actions for file deletion
1 parent 82bc9e6 commit 93cab68

File tree

9 files changed

+147
-31
lines changed

9 files changed

+147
-31
lines changed

app/Http/Controllers/Api/Client/Servers/FileController.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,17 +216,18 @@ public function compressFiles(CompressFilesRequest $request, Server $server): ar
216216
}
217217

218218
/**
219-
* Deletes a file or folder from the server.
219+
* Deletes files or folders for the server in the given root directory.
220220
*
221221
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request
222222
* @param \Pterodactyl\Models\Server $server
223223
* @return \Illuminate\Http\JsonResponse
224224
*/
225225
public function delete(DeleteFileRequest $request, Server $server): JsonResponse
226226
{
227-
$this->fileRepository
228-
->setServer($server)
229-
->deleteFile($request->input('location'));
227+
$this->fileRepository->setServer($server)
228+
->deleteFiles(
229+
$request->input('root'), $request->input('files')
230+
);
230231

231232
return new JsonResponse([], Response::HTTP_NO_CONTENT);
232233
}

app/Http/Requests/Api/Client/Servers/Files/DeleteFileRequest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ public function permission(): string
2222
public function rules(): array
2323
{
2424
return [
25-
'location' => 'required|string',
25+
'root' => 'required|nullable|string',
26+
'files' => 'required|array',
27+
'files.*' => 'string',
2628
];
2729
}
2830
}

app/Repositories/Wings/DaemonFileRepository.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,20 @@ public function copyFile(string $location): ResponseInterface
151151
/**
152152
* Delete a file or folder for the server.
153153
*
154-
* @param string $location
154+
* @param string|null $root
155+
* @param array $files
155156
* @return \Psr\Http\Message\ResponseInterface
156157
*/
157-
public function deleteFile(string $location): ResponseInterface
158+
public function deleteFiles(?string $root, array $files): ResponseInterface
158159
{
159160
Assert::isInstanceOf($this->server, Server::class);
160161

161162
return $this->getHttpClient()->post(
162163
sprintf('/api/servers/%s/files/delete', $this->server->uuid),
163164
[
164165
'json' => [
165-
'location' => $location,
166+
'root' => $root,
167+
'files' => $files,
166168
],
167169
]
168170
);

resources/scripts/api/server/files/deleteFile.ts renamed to resources/scripts/api/server/files/deleteFiles.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import http from '@/api/http';
22

3-
export default (uuid: string, location: string): Promise<void> => {
3+
export default (uuid: string, directory: string, files: string[]): Promise<void> => {
44
return new Promise((resolve, reject) => {
5-
http.post(`/api/client/servers/${uuid}/files/delete`, { location })
5+
http.post(`/api/client/servers/${uuid}/files/delete`, { root: directory, files })
66
.then(() => resolve())
77
.catch(reject);
88
});

resources/scripts/components/elements/Checkbox.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import Input from '@/components/elements/Input';
55
interface Props {
66
name: string;
77
value: string;
8+
className?: string;
89
}
910

1011
type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange';
1112

1213
type InputProps = Omit<JSX.IntrinsicElements['input'], OmitFields>;
1314

14-
const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
15+
const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => (
1516
<Field name={name}>
1617
{({ field, form }: FieldProps) => {
1718
if (!Array.isArray(field.value)) {
@@ -24,6 +25,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
2425
<Input
2526
{...field}
2627
{...props}
28+
className={className}
2729
type={'checkbox'}
2830
checked={(field.value || []).includes(value)}
2931
onClick={() => form.setFieldTouched(field.name, true)}

resources/scripts/components/server/files/FileDropdownMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import RenameFileModal from '@/components/server/files/RenameFileModal';
1414
import { ServerContext } from '@/state/server';
1515
import { join } from 'path';
16-
import deleteFile from '@/api/server/files/deleteFile';
16+
import deleteFiles from '@/api/server/files/deleteFiles';
1717
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
1818
import copyFile from '@/api/server/files/copyFile';
1919
import Can from '@/components/elements/Can';
@@ -71,7 +71,7 @@ export default ({ file }: { file: FileObject }) => {
7171
// If the delete actually fails, we'll fetch the current directory contents again automatically.
7272
mutate(files => files.filter(f => f.uuid !== file.uuid), false);
7373

74-
deleteFile(uuid, join(directory, file.name)).catch(error => {
74+
deleteFiles(uuid, directory, [ file.name ]).catch(error => {
7575
mutate();
7676
clearAndAddHttpError({ key: 'files', error });
7777
});

resources/scripts/components/server/files/FileManagerContainer.tsx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import Button from '@/components/elements/Button';
1515
import useServer from '@/plugins/useServer';
1616
import { ServerContext } from '@/state/server';
1717
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
18+
import { Form, Formik } from 'formik';
19+
import MassActionsBar from '@/components/server/files/MassActionsBar';
1820

1921
const sortFiles = (files: FileObject[]): FileObject[] => {
2022
return files.sort((a, b) => a.name.localeCompare(b.name))
@@ -55,23 +57,29 @@ export default () => {
5557
</p>
5658
:
5759
<CSSTransition classNames={'fade'} timeout={150} appear in>
58-
<React.Fragment>
59-
<div>
60-
{files.length > 250 &&
61-
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
62-
<p css={tw`text-yellow-900 text-sm text-center`}>
63-
This directory is too large to display in the browser,
64-
limiting the output to the first 250 files.
65-
</p>
66-
</div>
67-
}
68-
{
69-
sortFiles(files.slice(0, 250)).map(file => (
70-
<FileObjectRow key={file.uuid} file={file}/>
71-
))
72-
}
73-
</div>
74-
</React.Fragment>
60+
<div>
61+
<Formik
62+
onSubmit={() => undefined}
63+
initialValues={{ selectedFiles: [] }}
64+
>
65+
<Form>
66+
{files.length > 250 &&
67+
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
68+
<p css={tw`text-yellow-900 text-sm text-center`}>
69+
This directory is too large to display in the browser,
70+
limiting the output to the first 250 files.
71+
</p>
72+
</div>
73+
}
74+
{
75+
sortFiles(files.slice(0, 250)).map(file => (
76+
<FileObjectRow key={file.uuid} file={file}/>
77+
))
78+
}
79+
<MassActionsBar/>
80+
</Form>
81+
</Formik>
82+
</div>
7583
</CSSTransition>
7684
}
7785
<Can action={'file.create'}>

resources/scripts/components/server/files/FileObjectRow.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,22 @@ import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
1010
import tw from 'twin.macro';
1111
import isEqual from 'react-fast-compare';
1212
import styled from 'styled-components/macro';
13+
import FormikCheckbox from '@/components/elements/Checkbox';
1314

1415
const Row = styled.div`
1516
${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`};
1617
`;
1718

19+
const Checkbox = styled(FormikCheckbox)`
20+
&& {
21+
${tw`border-neutral-500`};
22+
23+
&:not(:checked) {
24+
${tw`hover:border-neutral-300`};
25+
}
26+
}
27+
`;
28+
1829
const FileObjectRow = ({ file }: { file: FileObject }) => {
1930
const directory = ServerContext.useStoreState(state => state.files.directory);
2031
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
@@ -44,12 +55,15 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
4455
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX }));
4556
}}
4657
>
58+
<label css={tw`flex-none p-4 absolute self-center z-30 cursor-pointer`}>
59+
<Checkbox name={'selectedFiles'} value={file.name}/>
60+
</label>
4761
<NavLink
4862
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
4963
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
5064
onClick={onRowClick}
5165
>
52-
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}>
66+
<div css={tw`flex-none self-center text-neutral-400 mr-4 text-lg pl-3 ml-6`}>
5367
{file.isFile ?
5468
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
5569
:
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React, { useState } from 'react';
2+
import tw from 'twin.macro';
3+
import Button from '@/components/elements/Button';
4+
import { useFormikContext } from 'formik';
5+
import Fade from '@/components/elements/Fade';
6+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
7+
import { faFileArchive, faLevelUpAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
8+
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
9+
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
10+
import useFlash from '@/plugins/useFlash';
11+
import compressFiles from '@/api/server/files/compressFiles';
12+
import useServer from '@/plugins/useServer';
13+
import { ServerContext } from '@/state/server';
14+
import ConfirmationModal from '@/components/elements/ConfirmationModal';
15+
import deleteFiles from '@/api/server/files/deleteFiles';
16+
17+
const MassActionsBar = () => {
18+
const { uuid } = useServer();
19+
const { mutate } = useFileManagerSwr();
20+
const { clearFlashes, clearAndAddHttpError } = useFlash();
21+
const [ loading, setLoading ] = useState(false);
22+
const [ showConfirm, setShowConfirm ] = useState(false);
23+
const { values, setFieldValue } = useFormikContext<{ selectedFiles: string[] }>();
24+
const directory = ServerContext.useStoreState(state => state.files.directory);
25+
26+
const onClickCompress = () => {
27+
setLoading(true);
28+
clearFlashes('files');
29+
30+
compressFiles(uuid, directory, values.selectedFiles)
31+
.then(() => mutate())
32+
.then(() => setFieldValue('selectedFiles', []))
33+
.catch(error => clearAndAddHttpError({ key: 'files', error }))
34+
.then(() => setLoading(false));
35+
};
36+
37+
const onClickConfirmDeletion = () => {
38+
setLoading(true);
39+
setShowConfirm(false);
40+
clearFlashes('files');
41+
42+
deleteFiles(uuid, directory, values.selectedFiles)
43+
.then(() => {
44+
mutate(files => files.filter(f => values.selectedFiles.indexOf(f.name) < 0), false);
45+
setFieldValue('selectedFiles', []);
46+
})
47+
.catch(error => {
48+
mutate();
49+
clearAndAddHttpError({ key: 'files', error });
50+
})
51+
.then(() => setLoading(false));
52+
};
53+
54+
return (
55+
<Fade timeout={75} in={values.selectedFiles.length > 0} unmountOnExit>
56+
<div css={tw`fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
57+
<SpinnerOverlay visible={loading} size={'large'} fixed/>
58+
<ConfirmationModal
59+
visible={showConfirm}
60+
title={'Delete these files?'}
61+
buttonText={'Yes, Delete Files'}
62+
onConfirmed={onClickConfirmDeletion}
63+
onDismissed={() => setShowConfirm(false)}
64+
>
65+
Deleting files is a permanent operation, you cannot undo this action.
66+
</ConfirmationModal>
67+
<div css={tw`rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
68+
<Button size={'xsmall'} css={tw`mr-4`}>
69+
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move
70+
</Button>
71+
<Button
72+
size={'xsmall'}
73+
css={tw`mr-4`}
74+
onClick={onClickCompress}
75+
>
76+
<FontAwesomeIcon icon={faFileArchive} css={tw`mr-2`}/> Archive
77+
</Button>
78+
<Button size={'xsmall'} color={'red'} isSecondary onClick={() => setShowConfirm(true)}>
79+
<FontAwesomeIcon icon={faTrashAlt} css={tw`mr-2`}/> Delete
80+
</Button>
81+
</div>
82+
</div>
83+
</Fade>
84+
);
85+
};
86+
87+
export default MassActionsBar;

0 commit comments

Comments
 (0)