Storage: add delete / deleteFolder / createFolder (#51887)

* delete / delete folder / create folder

* add backend tests

* implement force delete

* fix merge

* lint fix

* fix delete root folder

* fix folder name validation

* fix mysql path_hash issue

* Fix returning error
This commit is contained in:
Artur Wierzbicki
2022-07-08 22:23:16 +04:00
committed by GitHub
parent e51187a474
commit 1d2aa7c69b
14 changed files with 853 additions and 103 deletions
@@ -0,0 +1,44 @@
import React from 'react';
import { SubmitHandler, Validate } from 'react-hook-form';
import { Button, Field, Form, Input, Modal } from '@grafana/ui';
type FormModel = { folderName: string };
interface Props {
onSubmit: SubmitHandler<FormModel>;
onDismiss: () => void;
validate: Validate<string>;
}
const initialFormModel = { folderName: '' };
export function CreateNewFolderModal({ validate, onDismiss, onSubmit }: Props) {
return (
<Modal onDismiss={onDismiss} isOpen={true} title="New Folder">
<Form defaultValues={initialFormModel} onSubmit={onSubmit} maxWidth={'none'}>
{({ register, errors }) => (
<>
<Field
label="Folder name"
invalid={!!errors.folderName}
error={errors.folderName && errors.folderName.message}
>
<Input
id="folder-name-input"
{...register('folderName', {
required: 'Folder name is required.',
validate: { validate },
})}
/>
</Field>
<Modal.ButtonRow>
<Button type="submit">Create</Button>
</Modal.ButtonRow>
</>
)}
</Form>
</Modal>
);
}
+94 -10
View File
@@ -1,16 +1,19 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, GrafanaTheme2, isDataFrame, ValueLinkConfig } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { useStyles2, IconName, Spinner, TabsBar, Tab, Button, HorizontalGroup } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/core/hooks/useNavModel';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { AddRootView } from './AddRootView';
import { Breadcrumb } from './Breadcrumb';
import { CreateNewFolderModal } from './CreateNewFolderModal';
import { ExportView } from './ExportView';
import { FileView } from './FileView';
import { FolderView } from './FolderView';
@@ -26,8 +29,20 @@ interface QueryParams {
view: StorageView;
}
const folderNameRegex = /^[a-z\d!\-_.*'() ]+$/;
const folderNameMaxLength = 256;
interface Props extends GrafanaRouteComponentProps<RouteParams, QueryParams> {}
const getParentPath = (path: string) => {
const lastSlashIdx = path.lastIndexOf('/');
if (lastSlashIdx < 1) {
return '';
}
return path.substring(0, lastSlashIdx);
};
export default function StoragePage(props: Props) {
const styles = useStyles2(getStyles);
const navModel = useNavModel('storage');
@@ -41,6 +56,8 @@ export default function StoragePage(props: Props) {
locationService.push(url);
};
const [isAddingNewFolder, setIsAddingNewFolder] = useState(false);
const listing = useAsync((): Promise<DataFrame | undefined> => {
return getGrafanaStorage()
.list(path)
@@ -74,17 +91,26 @@ export default function StoragePage(props: Props) {
let isFolder = path?.indexOf('/') < 0;
if (listing.value) {
const length = listing.value.length;
if (length > 1) {
isFolder = true;
}
if (length === 1) {
const first = listing.value.fields[0].values.get(0) as string;
isFolder = !path.endsWith(first);
} else {
// TODO: handle files/folders which do not exist
isFolder = true;
}
}
return isFolder;
}, [path, listing]);
const fileNames = useMemo(() => {
return (
listing.value?.fields
?.find((f) => f.name === 'name')
?.values?.toArray()
?.filter((v) => typeof v === 'string') ?? []
);
}, [listing]);
const renderView = () => {
const isRoot = !path?.length || path === '/';
switch (view) {
@@ -135,20 +161,43 @@ export default function StoragePage(props: Props) {
});
}
const canAddFolder = isFolder && path.startsWith('resources');
const canDelete = !isFolder && path.startsWith('resources/');
const canDelete = path.startsWith('resources/');
return (
<div className={styles.wrapper}>
<HorizontalGroup width="100%" justify="space-between" height={25}>
<HorizontalGroup width="100%" justify="space-between" spacing={'md'} height={25}>
<Breadcrumb pathName={path} onPathChange={setPath} rootIcon={navModel.node.icon as IconName} />
<div>
{canAddFolder && <Button onClick={() => alert('TODO: new folder modal')}>New Folder</Button>}
<HorizontalGroup>
{canAddFolder && <Button onClick={() => setIsAddingNewFolder(true)}>New Folder</Button>}
{canDelete && (
<Button variant="destructive" onClick={() => alert('TODO: confirm delete modal')}>
<Button
variant="destructive"
onClick={() => {
const text = isFolder
? 'Are you sure you want to delete this folder and all its contents?'
: 'Are you sure you want to delete this file?';
const parentPath = getParentPath(path);
appEvents.publish(
new ShowConfirmModalEvent({
title: `Delete ${isFolder ? 'folder' : 'file'}`,
text,
icon: 'trash-alt',
yesText: 'Delete',
onConfirm: () =>
getGrafanaStorage()
.delete({ path, isFolder })
.then(() => {
setPath(parentPath);
}),
})
);
}}
>
Delete
</Button>
)}
</div>
</HorizontalGroup>
</HorizontalGroup>
<TabsBar>
@@ -166,6 +215,41 @@ export default function StoragePage(props: Props) {
) : (
<FileView path={path} listing={frame} onPathChange={setPath} view={view} />
)}
{isAddingNewFolder && (
<CreateNewFolderModal
onSubmit={async ({ folderName }) => {
const folderPath = `${path}/${folderName}`;
const res = await getGrafanaStorage().createFolder(folderPath);
if (typeof res?.error !== 'string') {
setPath(folderPath);
setIsAddingNewFolder(false);
}
}}
onDismiss={() => {
setIsAddingNewFolder(false);
}}
validate={(folderName) => {
const lowerCase = folderName.toLowerCase();
const trimmedLowerCase = lowerCase.trim();
const existingTrimmedLowerCaseNames = fileNames.map((f) => f.trim().toLowerCase());
if (existingTrimmedLowerCaseNames.includes(trimmedLowerCase)) {
return 'A file or a folder with the same name already exists';
}
if (!folderNameRegex.test(lowerCase)) {
return 'Name contains illegal characters';
}
if (folderName.length > folderNameMaxLength) {
return `Name is too long, maximum length: ${folderNameMaxLength} characters`;
}
return true;
}}
/>
)}
</div>
);
};
+48
View File
@@ -8,6 +8,8 @@ export interface GrafanaStorage {
get: <T = any>(path: string) => Promise<T>;
list: (path: string) => Promise<DataFrame | undefined>;
upload: (folder: string, file: File) => Promise<UploadReponse>;
createFolder: (path: string) => Promise<{ error?: string }>;
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
}
class SimpleStorage implements GrafanaStorage {
@@ -34,6 +36,52 @@ class SimpleStorage implements GrafanaStorage {
return undefined;
}
async createFolder(path: string): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(
'/api/storage/createFolder',
JSON.stringify({ path })
);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async deleteFolder(req: { path: string; force: boolean }): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(
`/api/storage/deleteFolder`,
JSON.stringify(req)
);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async deleteFile(req: { path: string }): Promise<{ error?: string }> {
const res = await getBackendSrv().post<{ success: boolean; message: string }>(`/api/storage/delete/${req.path}`);
if (!res.success) {
return {
error: res.message ?? 'unknown error',
};
}
return {};
}
async delete(req: { isFolder: boolean; path: string }): Promise<{ error?: string }> {
return req.isFolder ? this.deleteFolder({ path: req.path, force: true }) : this.deleteFile({ path: req.path });
}
async upload(folder: string, file: File): Promise<UploadReponse> {
const formData = new FormData();
formData.append('folder', folder);