Handle mass actions for file deletion

This commit is contained in:
Dane Everitt 2020-07-11 15:37:59 -07:00
parent 82bc9e617b
commit 93cab68cc3
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
9 changed files with 147 additions and 31 deletions

View file

@ -216,7 +216,7 @@ class FileController extends ClientApiController
} }
/** /**
* Deletes a file or folder from the server. * Deletes files or folders for the server in the given root directory.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
@ -224,9 +224,10 @@ class FileController extends ClientApiController
*/ */
public function delete(DeleteFileRequest $request, Server $server): JsonResponse public function delete(DeleteFileRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository $this->fileRepository->setServer($server)
->setServer($server) ->deleteFiles(
->deleteFile($request->input('location')); $request->input('root'), $request->input('files')
);
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }

View file

@ -22,7 +22,9 @@ class DeleteFileRequest extends ClientApiRequest implements ClientPermissionsReq
public function rules(): array public function rules(): array
{ {
return [ return [
'location' => 'required|string', 'root' => 'required|nullable|string',
'files' => 'required|array',
'files.*' => 'string',
]; ];
} }
} }

View file

@ -151,10 +151,11 @@ class DaemonFileRepository extends DaemonRepository
/** /**
* Delete a file or folder for the server. * Delete a file or folder for the server.
* *
* @param string $location * @param string|null $root
* @param array $files
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*/ */
public function deleteFile(string $location): ResponseInterface public function deleteFiles(?string $root, array $files): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
@ -162,7 +163,8 @@ class DaemonFileRepository extends DaemonRepository
sprintf('/api/servers/%s/files/delete', $this->server->uuid), sprintf('/api/servers/%s/files/delete', $this->server->uuid),
[ [
'json' => [ 'json' => [
'location' => $location, 'root' => $root,
'files' => $files,
], ],
] ]
); );

View file

@ -1,8 +1,8 @@
import http from '@/api/http'; import http from '@/api/http';
export default (uuid: string, location: string): Promise<void> => { export default (uuid: string, directory: string, files: string[]): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/files/delete`, { location }) http.post(`/api/client/servers/${uuid}/files/delete`, { root: directory, files })
.then(() => resolve()) .then(() => resolve())
.catch(reject); .catch(reject);
}); });

View file

@ -5,13 +5,14 @@ import Input from '@/components/elements/Input';
interface Props { interface Props {
name: string; name: string;
value: string; value: string;
className?: string;
} }
type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange'; type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange';
type InputProps = Omit<JSX.IntrinsicElements['input'], OmitFields>; type InputProps = Omit<JSX.IntrinsicElements['input'], OmitFields>;
const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => (
<Field name={name}> <Field name={name}>
{({ field, form }: FieldProps) => { {({ field, form }: FieldProps) => {
if (!Array.isArray(field.value)) { if (!Array.isArray(field.value)) {
@ -24,6 +25,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => (
<Input <Input
{...field} {...field}
{...props} {...props}
className={className}
type={'checkbox'} type={'checkbox'}
checked={(field.value || []).includes(value)} checked={(field.value || []).includes(value)}
onClick={() => form.setFieldTouched(field.name, true)} onClick={() => form.setFieldTouched(field.name, true)}

View file

@ -13,7 +13,7 @@ import {
import RenameFileModal from '@/components/server/files/RenameFileModal'; import RenameFileModal from '@/components/server/files/RenameFileModal';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { join } from 'path'; import { join } from 'path';
import deleteFile from '@/api/server/files/deleteFile'; import deleteFiles from '@/api/server/files/deleteFiles';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile'; import copyFile from '@/api/server/files/copyFile';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
@ -71,7 +71,7 @@ export default ({ file }: { file: FileObject }) => {
// If the delete actually fails, we'll fetch the current directory contents again automatically. // If the delete actually fails, we'll fetch the current directory contents again automatically.
mutate(files => files.filter(f => f.uuid !== file.uuid), false); mutate(files => files.filter(f => f.uuid !== file.uuid), false);
deleteFile(uuid, join(directory, file.name)).catch(error => { deleteFiles(uuid, directory, [ file.name ]).catch(error => {
mutate(); mutate();
clearAndAddHttpError({ key: 'files', error }); clearAndAddHttpError({ key: 'files', error });
}); });

View file

@ -15,6 +15,8 @@ import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import { Form, Formik } from 'formik';
import MassActionsBar from '@/components/server/files/MassActionsBar';
const sortFiles = (files: FileObject[]): FileObject[] => { const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name)) return files.sort((a, b) => a.name.localeCompare(b.name))
@ -55,8 +57,12 @@ export default () => {
</p> </p>
: :
<CSSTransition classNames={'fade'} timeout={150} appear in> <CSSTransition classNames={'fade'} timeout={150} appear in>
<React.Fragment>
<div> <div>
<Formik
onSubmit={() => undefined}
initialValues={{ selectedFiles: [] }}
>
<Form>
{files.length > 250 && {files.length > 250 &&
<div css={tw`rounded bg-yellow-400 mb-px p-3`}> <div css={tw`rounded bg-yellow-400 mb-px p-3`}>
<p css={tw`text-yellow-900 text-sm text-center`}> <p css={tw`text-yellow-900 text-sm text-center`}>
@ -70,8 +76,10 @@ export default () => {
<FileObjectRow key={file.uuid} file={file}/> <FileObjectRow key={file.uuid} file={file}/>
)) ))
} }
<MassActionsBar/>
</Form>
</Formik>
</div> </div>
</React.Fragment>
</CSSTransition> </CSSTransition>
} }
<Can action={'file.create'}> <Can action={'file.create'}>

View file

@ -10,11 +10,22 @@ import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro'; import tw from 'twin.macro';
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import FormikCheckbox from '@/components/elements/Checkbox';
const Row = styled.div` const Row = styled.div`
${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`}; ${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`};
`; `;
const Checkbox = styled(FormikCheckbox)`
&& {
${tw`border-neutral-500`};
&:not(:checked) {
${tw`hover:border-neutral-300`};
}
}
`;
const FileObjectRow = ({ file }: { file: FileObject }) => { const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
@ -44,12 +55,15 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX }));
}} }}
> >
<label css={tw`flex-none p-4 absolute self-center z-30 cursor-pointer`}>
<Checkbox name={'selectedFiles'} value={file.name}/>
</label>
<NavLink <NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`} to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`} css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
onClick={onRowClick} onClick={onRowClick}
> >
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}> <div css={tw`flex-none self-center text-neutral-400 mr-4 text-lg pl-3 ml-6`}>
{file.isFile ? {file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/> <FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
: :

View file

@ -0,0 +1,87 @@
import React, { useState } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { useFormikContext } from 'formik';
import Fade from '@/components/elements/Fade';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileArchive, faLevelUpAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash';
import compressFiles from '@/api/server/files/compressFiles';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteFiles from '@/api/server/files/deleteFiles';
const MassActionsBar = () => {
const { uuid } = useServer();
const { mutate } = useFileManagerSwr();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState(false);
const [ showConfirm, setShowConfirm ] = useState(false);
const { values, setFieldValue } = useFormikContext<{ selectedFiles: string[] }>();
const directory = ServerContext.useStoreState(state => state.files.directory);
const onClickCompress = () => {
setLoading(true);
clearFlashes('files');
compressFiles(uuid, directory, values.selectedFiles)
.then(() => mutate())
.then(() => setFieldValue('selectedFiles', []))
.catch(error => clearAndAddHttpError({ key: 'files', error }))
.then(() => setLoading(false));
};
const onClickConfirmDeletion = () => {
setLoading(true);
setShowConfirm(false);
clearFlashes('files');
deleteFiles(uuid, directory, values.selectedFiles)
.then(() => {
mutate(files => files.filter(f => values.selectedFiles.indexOf(f.name) < 0), false);
setFieldValue('selectedFiles', []);
})
.catch(error => {
mutate();
clearAndAddHttpError({ key: 'files', error });
})
.then(() => setLoading(false));
};
return (
<Fade timeout={75} in={values.selectedFiles.length > 0} unmountOnExit>
<div css={tw`fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
<SpinnerOverlay visible={loading} size={'large'} fixed/>
<ConfirmationModal
visible={showConfirm}
title={'Delete these files?'}
buttonText={'Yes, Delete Files'}
onConfirmed={onClickConfirmDeletion}
onDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
</ConfirmationModal>
<div css={tw`rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
<Button size={'xsmall'} css={tw`mr-4`}>
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move
</Button>
<Button
size={'xsmall'}
css={tw`mr-4`}
onClick={onClickCompress}
>
<FontAwesomeIcon icon={faFileArchive} css={tw`mr-2`}/> Archive
</Button>
<Button size={'xsmall'} color={'red'} isSecondary onClick={() => setShowConfirm(true)}>
<FontAwesomeIcon icon={faTrashAlt} css={tw`mr-2`}/> Delete
</Button>
</div>
</div>
</Fade>
);
};
export default MassActionsBar;