Handle mass actions for file deletion
This commit is contained in:
parent
82bc9e617b
commit
93cab68cc3
9 changed files with 147 additions and 31 deletions
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
|
@ -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)}
|
||||||
|
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,23 +57,29 @@ export default () => {
|
||||||
</p>
|
</p>
|
||||||
:
|
:
|
||||||
<CSSTransition classNames={'fade'} timeout={150} appear in>
|
<CSSTransition classNames={'fade'} timeout={150} appear in>
|
||||||
<React.Fragment>
|
<div>
|
||||||
<div>
|
<Formik
|
||||||
{files.length > 250 &&
|
onSubmit={() => undefined}
|
||||||
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
|
initialValues={{ selectedFiles: [] }}
|
||||||
<p css={tw`text-yellow-900 text-sm text-center`}>
|
>
|
||||||
This directory is too large to display in the browser,
|
<Form>
|
||||||
limiting the output to the first 250 files.
|
{files.length > 250 &&
|
||||||
</p>
|
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
|
||||||
</div>
|
<p css={tw`text-yellow-900 text-sm text-center`}>
|
||||||
}
|
This directory is too large to display in the browser,
|
||||||
{
|
limiting the output to the first 250 files.
|
||||||
sortFiles(files.slice(0, 250)).map(file => (
|
</p>
|
||||||
<FileObjectRow key={file.uuid} file={file}/>
|
</div>
|
||||||
))
|
}
|
||||||
}
|
{
|
||||||
</div>
|
sortFiles(files.slice(0, 250)).map(file => (
|
||||||
</React.Fragment>
|
<FileObjectRow key={file.uuid} file={file}/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<MassActionsBar/>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
}
|
}
|
||||||
<Can action={'file.create'}>
|
<Can action={'file.create'}>
|
||||||
|
|
|
@ -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}/>
|
||||||
:
|
:
|
||||||
|
|
87
resources/scripts/components/server/files/MassActionsBar.tsx
Normal file
87
resources/scripts/components/server/files/MassActionsBar.tsx
Normal 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;
|
Loading…
Reference in a new issue