mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user