mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
UI: Dropzone component (#36646)
* Dropzone component * Add file list * Add progress, error and cancelation to filelistitem * Update Dropzone component to support progress Cancelation Retry * Update file name changes * Rename to FileDropzone * FileListItem tests A11y updates for icon buttons Use value formatter from grafana/data * Add tests for FileDropzone Review comments * export FileDropzoneDefaultChildren * Change primary text when multiple false * Review comments addressed * Extract remove file to constant * No need to await after await
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
"react-colorful": "5.1.2",
|
||||
"react-custom-scrollbars": "4.2.1",
|
||||
"react-dom": "17.0.1",
|
||||
"react-dropzone": "11.3.4",
|
||||
"react-highlight-words": "0.16.0",
|
||||
"react-hook-form": "7.5.3",
|
||||
"react-inlinesvg": "2.3.0",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Story, Preview, Props } from '@storybook/addon-docs/blocks';
|
||||
import { FileDropzone } from './FileDropzone';
|
||||
|
||||
# FileDropzone
|
||||
|
||||
A dropzone component to use for file uploads.
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { FileDropzone } from '@grafana/ui';
|
||||
|
||||
<FileDropzone onLoad={(result) => console.log(result)} />;
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<Props of={FileDropzone} />
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FileDropzone, FileDropzoneProps } from '@grafana/ui';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import mdx from './FileDropzone.mdx';
|
||||
|
||||
export default {
|
||||
title: 'Forms/FileDropzone',
|
||||
component: FileDropzone,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
onLoad: { action: 'onLoad' },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const Basic: Story<FileDropzoneProps> = (args) => {
|
||||
return <FileDropzone {...args} />;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { FileDropzone } from './FileDropzone';
|
||||
import { REMOVE_FILE } from './FileListItem';
|
||||
|
||||
const file = ({
|
||||
fileBits = JSON.stringify({ ping: true }),
|
||||
fileName = 'ping.json',
|
||||
options = { type: 'application/json' },
|
||||
}) => new File([fileBits], fileName, options);
|
||||
|
||||
const files = [
|
||||
file({}),
|
||||
file({ fileName: 'pong.json' }),
|
||||
file({ fileBits: 'something', fileName: 'something.jpg', options: { type: 'image/jpeg' } }),
|
||||
];
|
||||
|
||||
describe('The FileDropzone component', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should show the default text of the dropzone component when no props passed', () => {
|
||||
render(<FileDropzone />);
|
||||
|
||||
expect(screen.getByText('Upload file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show accepted file type when passed in the options as a string', () => {
|
||||
render(<FileDropzone options={{ accept: '.json' }} />);
|
||||
|
||||
expect(screen.getByText('Accepted file type: .json')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show accepted file types when passed in the options as a string array', () => {
|
||||
render(<FileDropzone options={{ accept: ['.json', '.txt'] }} />);
|
||||
|
||||
expect(screen.getByText('Accepted file types: .json, .txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle file removal from the list', async () => {
|
||||
render(<FileDropzone />);
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData(files));
|
||||
|
||||
expect(await screen.findAllByLabelText(REMOVE_FILE)).toHaveLength(3);
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText(REMOVE_FILE)[0]);
|
||||
|
||||
expect(await screen.findAllByLabelText(REMOVE_FILE)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should overwrite selected file when multiple false', async () => {
|
||||
render(<FileDropzone options={{ multiple: false }} />);
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData([file({})]));
|
||||
|
||||
expect(await screen.findAllByLabelText(REMOVE_FILE)).toHaveLength(1);
|
||||
expect(screen.getByText('ping.json')).toBeInTheDocument();
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData([file({ fileName: 'newFile.jpg' })]));
|
||||
|
||||
expect(await screen.findByText('newFile.jpg')).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText(REMOVE_FILE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should use the passed readAs prop with the FileReader API', async () => {
|
||||
render(<FileDropzone readAs="readAsDataURL" />);
|
||||
const fileReaderSpy = jest.spyOn(FileReader.prototype, 'readAsDataURL');
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData([file({})]));
|
||||
|
||||
expect(await screen.findByText('ping.json')).toBeInTheDocument();
|
||||
expect(fileReaderSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should use the readAsText FileReader API if no readAs prop passed', async () => {
|
||||
render(<FileDropzone />);
|
||||
const fileReaderSpy = jest.spyOn(FileReader.prototype, 'readAsText');
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData([file({})]));
|
||||
|
||||
expect(await screen.findByText('ping.json')).toBeInTheDocument();
|
||||
expect(fileReaderSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should use the onDrop that is passed', async () => {
|
||||
const onDrop = jest.fn();
|
||||
const fileToUpload = file({});
|
||||
render(<FileDropzone options={{ onDrop }} />);
|
||||
const fileReaderSpy = jest.spyOn(FileReader.prototype, 'readAsText');
|
||||
|
||||
dispatchEvt(screen.getByTestId('dropzone'), 'drop', mockData([fileToUpload]));
|
||||
|
||||
expect(await screen.findByText('ping.json')).toBeInTheDocument();
|
||||
expect(fileReaderSpy).not.toBeCalled();
|
||||
expect(onDrop).toBeCalledWith([fileToUpload], [], expect.anything());
|
||||
});
|
||||
|
||||
it('should show children inside the dropzone', () => {
|
||||
const component = (
|
||||
<FileDropzone>
|
||||
<p>Custom dropzone text</p>
|
||||
</FileDropzone>
|
||||
);
|
||||
render(component);
|
||||
|
||||
screen.getByText('Custom dropzone text');
|
||||
});
|
||||
});
|
||||
|
||||
function dispatchEvt(node: HTMLElement, type: string, data: any) {
|
||||
const event = new Event(type, { bubbles: true });
|
||||
Object.assign(event, data);
|
||||
fireEvent(node, event);
|
||||
}
|
||||
|
||||
function mockData(files: File[]) {
|
||||
return {
|
||||
dataTransfer: {
|
||||
files,
|
||||
items: files.map((file) => ({
|
||||
kind: 'file',
|
||||
type: file.type,
|
||||
getAsFile: () => file,
|
||||
})),
|
||||
types: ['Files'],
|
||||
},
|
||||
};
|
||||
}
|
||||
220
packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx
Normal file
220
packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import { DropEvent, DropzoneOptions, FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useTheme2 } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { FileListItem } from './FileListItem';
|
||||
|
||||
export interface FileDropzoneProps {
|
||||
/**
|
||||
* Use the children property to have custom dropzone view.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* Use this property to override the default behaviour for the react-dropzone options.
|
||||
* @default {
|
||||
* maxSize: Infinity,
|
||||
* minSize: 0,
|
||||
* multiple: true,
|
||||
* maxFiles: 0,
|
||||
* }
|
||||
*/
|
||||
options?: DropzoneOptions;
|
||||
/**
|
||||
* Use this to change the FileReader's read.
|
||||
*/
|
||||
readAs?: 'readAsArrayBuffer' | 'readAsText' | 'readAsBinaryString' | 'readAsDataURL';
|
||||
/**
|
||||
* Use the onLoad function to get the result from FileReader.
|
||||
*/
|
||||
onLoad?: (result: string | ArrayBuffer | null) => void;
|
||||
}
|
||||
|
||||
export interface DropzoneFile {
|
||||
file: File;
|
||||
id: string;
|
||||
error: DOMException | null;
|
||||
progress?: number;
|
||||
abortUpload?: () => void;
|
||||
retryUpload?: () => void;
|
||||
}
|
||||
|
||||
export function FileDropzone({ options, children, readAs, onLoad }: FileDropzoneProps) {
|
||||
const [files, setFiles] = useState<DropzoneFile[]>([]);
|
||||
|
||||
const setFileProperty = useCallback(
|
||||
(customFile: DropzoneFile, action: (customFileToModify: DropzoneFile) => void) => {
|
||||
setFiles((oldFiles) => {
|
||||
return oldFiles.map((oldFile) => {
|
||||
if (oldFile.id === customFile.id) {
|
||||
action(oldFile);
|
||||
return oldFile;
|
||||
}
|
||||
return oldFile;
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[], event: DropEvent) => {
|
||||
let customFiles = acceptedFiles.map(mapToCustomFile);
|
||||
if (options?.multiple === false) {
|
||||
setFiles(customFiles);
|
||||
} else {
|
||||
setFiles((oldFiles) => [...oldFiles, ...customFiles]);
|
||||
}
|
||||
|
||||
if (options?.onDrop) {
|
||||
options.onDrop(acceptedFiles, rejectedFiles, event);
|
||||
} else {
|
||||
for (const customFile of customFiles) {
|
||||
const reader = new FileReader();
|
||||
|
||||
const read = () => {
|
||||
if (readAs) {
|
||||
reader[readAs](customFile.file);
|
||||
} else {
|
||||
reader.readAsText(customFile.file);
|
||||
}
|
||||
};
|
||||
|
||||
// Set abort FileReader
|
||||
setFileProperty(customFile, (fileToModify) => {
|
||||
fileToModify.abortUpload = () => {
|
||||
reader.abort();
|
||||
};
|
||||
fileToModify.retryUpload = () => {
|
||||
setFileProperty(customFile, (fileToModify) => {
|
||||
fileToModify.error = null;
|
||||
fileToModify.progress = undefined;
|
||||
});
|
||||
read();
|
||||
};
|
||||
});
|
||||
|
||||
reader.onabort = () => {
|
||||
setFileProperty(customFile, (fileToModify) => {
|
||||
fileToModify.error = new DOMException('Aborted');
|
||||
});
|
||||
};
|
||||
|
||||
reader.onprogress = (event) => {
|
||||
setFileProperty(customFile, (fileToModify) => {
|
||||
fileToModify.progress = event.loaded;
|
||||
});
|
||||
};
|
||||
|
||||
reader.onload = () => {
|
||||
onLoad?.(reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
setFileProperty(customFile, (fileToModify) => {
|
||||
fileToModify.error = reader.error;
|
||||
});
|
||||
};
|
||||
|
||||
read();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onLoad, options, readAs, setFileProperty]
|
||||
);
|
||||
|
||||
const removeFile = (file: DropzoneFile) => {
|
||||
const newFiles = files.filter((f) => file.id !== f.id);
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({ ...options, onDrop });
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme, isDragActive);
|
||||
const fileList = files.map((file) => <FileListItem key={file.id} file={file} removeFile={removeFile} />);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div data-testid="dropzone" {...getRootProps({ className: styles.dropzone })}>
|
||||
<input {...getInputProps()} />
|
||||
{children ?? <FileDropzoneDefaultChildren primaryText={getPrimaryText(files, options)} />}
|
||||
</div>
|
||||
{options?.accept && (
|
||||
<small className={cx(styles.small, styles.acceptMargin)}>{getAcceptedFileTypeText(options)}</small>
|
||||
)}
|
||||
{fileList}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDropzoneDefaultChildren({
|
||||
primaryText = 'Upload file',
|
||||
secondaryText = 'Drag and drop here or browse',
|
||||
}) {
|
||||
const theme = useTheme2();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.iconWrapper}>
|
||||
<Icon name="upload" size="xxl" />
|
||||
<h3>{primaryText}</h3>
|
||||
<small className={styles.small}>{secondaryText}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function getPrimaryText(files: DropzoneFile[], options?: DropzoneOptions) {
|
||||
if (options?.multiple === undefined || options?.multiple) {
|
||||
return 'Upload file';
|
||||
}
|
||||
return files.length ? 'Replace file' : 'Upload file';
|
||||
}
|
||||
|
||||
function getAcceptedFileTypeText(options: DropzoneOptions) {
|
||||
if (Array.isArray(options.accept)) {
|
||||
return `Accepted file types: ${options.accept.join(', ')}`;
|
||||
}
|
||||
|
||||
return `Accepted file type: ${options.accept}`;
|
||||
}
|
||||
|
||||
function mapToCustomFile(file: File): DropzoneFile {
|
||||
return {
|
||||
id: uniqueId('file'),
|
||||
file,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, isDragActive?: boolean) {
|
||||
return {
|
||||
container: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`,
|
||||
dropzone: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: ${theme.spacing(6)};
|
||||
border-radius: 2px;
|
||||
border: 2px dashed ${theme.colors.border.medium};
|
||||
background-color: ${isDragActive ? theme.colors.background.secondary : theme.colors.background.primary};
|
||||
cursor: pointer;
|
||||
`,
|
||||
iconWrapper: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`,
|
||||
acceptMargin: css`
|
||||
margin: ${theme.spacing(2, 0, 1)};
|
||||
`,
|
||||
small: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Story, Preview, Props } from '@storybook/addon-docs/blocks';
|
||||
import { FileListItem } from './FileListItem';
|
||||
|
||||
# FileListItem
|
||||
|
||||
A FileListItem component used for the FileDropzone component to show uploaded files.
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { FileListItem } from '@grafana/ui';
|
||||
|
||||
<FileListItem file={{ file: { name: 'someFile.jpg', size: 12345 } }} />;
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
<Props of={FileListItem} />
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { FileListItem as FileListItemComponent, FileListItemProps } from './FileListItem';
|
||||
import mdx from './FileListItem.mdx';
|
||||
|
||||
export default {
|
||||
title: 'Forms/FileListItem',
|
||||
component: FileListItemComponent,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
abortUpload: { action: 'abortUpload' },
|
||||
retryUpload: { action: 'retryUpload' },
|
||||
removeFile: { action: 'removeFile' },
|
||||
},
|
||||
args: {
|
||||
file: { file: { name: 'some-file.jpg', size: 123456 } as any, id: '1', error: new DOMException('error') },
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const FileListItem: Story<FileListItemProps> = (args) => {
|
||||
return <FileListItemComponent {...args} />;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { FileListItem, REMOVE_FILE } from './FileListItem';
|
||||
|
||||
const file = ({
|
||||
fileBits = 'prettyPicture',
|
||||
fileName = 'someFile.jpg',
|
||||
options = { lastModified: 1604849095696, type: 'image/jpeg' },
|
||||
}) => new File([fileBits], fileName, options);
|
||||
|
||||
describe('The FileListItem component', () => {
|
||||
it('should show an error message when error prop is not null', () => {
|
||||
render(<FileListItem file={{ file: file({}), id: '1', error: new DOMException('error') }} />);
|
||||
|
||||
expect(screen.getByText('error')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Retry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a retry icon when error is not null and retryUpload prop is passed', () => {
|
||||
const retryUpload = jest.fn();
|
||||
render(<FileListItem file={{ file: file({}), id: '1', error: new DOMException('error'), retryUpload }} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Retry'));
|
||||
|
||||
expect(screen.getByText('error')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Retry'));
|
||||
expect(retryUpload).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show a progressbar when the progress prop has a value', () => {
|
||||
render(<FileListItem file={{ file: file({}), id: '1', error: null, progress: 6 }} />);
|
||||
|
||||
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('46%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show a progressbar when progress is equal to the size', () => {
|
||||
render(<FileListItem file={{ file: file({}), id: '1', error: null, progress: 13 }} />);
|
||||
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a Cancel button when abortUpload prop is passed', () => {
|
||||
const abortUpload = jest.fn();
|
||||
render(<FileListItem file={{ file: file({}), id: '1', error: null, progress: 6, abortUpload }} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(abortUpload).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show a Remove icon when removeFile prop is passed', () => {
|
||||
const removeFile = jest.fn();
|
||||
const customFile = { file: file({}), id: '1', error: null };
|
||||
render(<FileListItem file={customFile} removeFile={removeFile} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: REMOVE_FILE }));
|
||||
|
||||
expect(removeFile).toBeCalledWith(customFile);
|
||||
});
|
||||
});
|
||||
127
packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx
Normal file
127
packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { formattedValueToString, getValueFormat, GrafanaTheme2 } from '@grafana/data';
|
||||
import React from 'react';
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { trimFileName } from '../../utils/file';
|
||||
import { Button } from '../Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconButton } from '../IconButton/IconButton';
|
||||
import { DropzoneFile } from './FileDropzone';
|
||||
|
||||
export const REMOVE_FILE = 'Remove file';
|
||||
export interface FileListItemProps {
|
||||
file: DropzoneFile;
|
||||
removeFile?: (file: DropzoneFile) => void;
|
||||
}
|
||||
|
||||
export function FileListItem({ file: customFile, removeFile }: FileListItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { file, progress, error, abortUpload, retryUpload } = customFile;
|
||||
|
||||
const renderRightSide = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.error}>{error.message}</span>
|
||||
{retryUpload && (
|
||||
<IconButton aria-label="Retry" name="sync" tooltip="Retry" tooltipPlacement="top" onClick={retryUpload} />
|
||||
)}
|
||||
{removeFile && (
|
||||
<IconButton
|
||||
className={retryUpload ? styles.marginLeft : ''}
|
||||
name="trash-alt"
|
||||
onClick={() => removeFile(customFile)}
|
||||
tooltip={REMOVE_FILE}
|
||||
aria-label={REMOVE_FILE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (progress && file.size > progress) {
|
||||
return (
|
||||
<>
|
||||
<progress className={styles.progressBar} max={file.size} value={progress} />
|
||||
<span className={styles.paddingLeft}>{Math.round((progress / file.size) * 100)}%</span>
|
||||
{abortUpload && (
|
||||
<Button variant="secondary" type="button" fill="text" onClick={abortUpload}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
removeFile && (
|
||||
<IconButton
|
||||
name="trash-alt"
|
||||
onClick={() => removeFile(customFile)}
|
||||
tooltip={REMOVE_FILE}
|
||||
aria-label={REMOVE_FILE}
|
||||
tooltipPlacement="top"
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const valueFormat = getValueFormat('decbytes')(file.size);
|
||||
|
||||
return (
|
||||
<div className={styles.fileListContainer}>
|
||||
<span className={styles.fileNameWrapper}>
|
||||
<Icon name="file-blank" size="lg" />
|
||||
<span className={styles.padding}>{trimFileName(file.name)}</span>
|
||||
<span>{formattedValueToString(valueFormat)}</span>
|
||||
</span>
|
||||
|
||||
<div className={styles.fileNameWrapper}>{renderRightSide()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
fileListContainer: css`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(2)};
|
||||
border: 1px dashed ${theme.colors.border.medium};
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
margin-top: ${theme.spacing(1)};
|
||||
`,
|
||||
fileNameWrapper: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`,
|
||||
padding: css`
|
||||
padding: ${theme.spacing(0, 1)};
|
||||
`,
|
||||
paddingLeft: css`
|
||||
padding-left: ${theme.spacing(2)};
|
||||
`,
|
||||
marginLeft: css`
|
||||
margin-left: ${theme.spacing(1)};
|
||||
`,
|
||||
error: css`
|
||||
padding-right: ${theme.spacing(2)};
|
||||
color: ${theme.colors.error.text};
|
||||
`,
|
||||
progressBar: css`
|
||||
border-radius: ${theme.spacing(1)};
|
||||
height: 4px;
|
||||
::-webkit-progress-bar {
|
||||
background-color: ${theme.colors.border.weak};
|
||||
border-radius: ${theme.spacing(1)};
|
||||
}
|
||||
::-webkit-progress-value {
|
||||
background-color: ${theme.colors.primary.main};
|
||||
border-radius: ${theme.spacing(1)};
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
4
packages/grafana-ui/src/components/FileDropzone/index.ts
Normal file
4
packages/grafana-ui/src/components/FileDropzone/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { FileDropzone, DropzoneFile, FileDropzoneProps, FileDropzoneDefaultChildren } from './FileDropzone';
|
||||
import { FileListItem, FileListItemProps } from './FileListItem';
|
||||
|
||||
export { FileDropzone, FileDropzoneProps, DropzoneFile, FileListItem, FileListItemProps, FileDropzoneDefaultChildren };
|
||||
@@ -5,6 +5,7 @@ import { Icon } from '../index';
|
||||
import { stylesFactory, useTheme2 } from '../../themes';
|
||||
import { ComponentSize } from '../../types/size';
|
||||
import { getButtonStyles } from '../Button';
|
||||
import { trimFileName } from '../../utils/file';
|
||||
|
||||
export interface Props {
|
||||
/** Callback function to handle uploaded file */
|
||||
@@ -17,19 +18,6 @@ export interface Props {
|
||||
size?: ComponentSize;
|
||||
}
|
||||
|
||||
function trimFileName(fileName: string) {
|
||||
const nameLength = 16;
|
||||
const delimiter = fileName.lastIndexOf('.');
|
||||
const extension = fileName.substring(delimiter);
|
||||
const file = fileName.substring(0, delimiter);
|
||||
|
||||
if (file.length < nameLength) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return `${file.substring(0, nameLength)}...${extension}`;
|
||||
}
|
||||
|
||||
export const FileUpload: FC<Props> = ({
|
||||
onFileUpload,
|
||||
className,
|
||||
|
||||
@@ -201,6 +201,7 @@ export { Checkbox } from './Forms/Checkbox';
|
||||
|
||||
export { TextArea } from './TextArea/TextArea';
|
||||
export { FileUpload } from './FileUpload/FileUpload';
|
||||
export * from './FileDropzone';
|
||||
export { TimeRangeInput } from './DateTimePickers/TimeRangeInput';
|
||||
export { RelativeTimeRangePicker } from './DateTimePickers/RelativeTimeRangePicker/RelativeTimeRangePicker';
|
||||
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
|
||||
|
||||
16
packages/grafana-ui/src/utils/file.ts
Normal file
16
packages/grafana-ui/src/utils/file.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shortens the filename to 16 length
|
||||
* @param fileName
|
||||
*/
|
||||
export function trimFileName(fileName: string): string {
|
||||
const nameLength = 16;
|
||||
const delimiter = fileName.lastIndexOf('.');
|
||||
const extension = fileName.substring(delimiter);
|
||||
const file = fileName.substring(0, delimiter);
|
||||
|
||||
if (file.length < nameLength) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
return `${file.substring(0, nameLength)}...${extension}`;
|
||||
}
|
||||
21
yarn.lock
21
yarn.lock
@@ -7214,6 +7214,11 @@ atob@^2.1.1, atob@^2.1.2:
|
||||
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
|
||||
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
|
||||
|
||||
attr-accept@^2.2.1:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
|
||||
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
|
||||
|
||||
autoprefixer@9.7.4:
|
||||
version "9.7.4"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.7.4.tgz#f8bf3e06707d047f0641d87aee8cfb174b2a5378"
|
||||
@@ -11841,6 +11846,13 @@ file-saver@2.0.2:
|
||||
resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.2.tgz#06d6e728a9ea2df2cce2f8d9e84dfcdc338ec17a"
|
||||
integrity sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw==
|
||||
|
||||
file-selector@^0.2.2:
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.4.tgz#7b98286f9dbb9925f420130ea5ed0a69238d4d80"
|
||||
integrity sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==
|
||||
dependencies:
|
||||
tslib "^2.0.3"
|
||||
|
||||
file-system-cache@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-1.0.5.tgz#84259b36a2bbb8d3d6eb1021d3132ffe64cfff4f"
|
||||
@@ -19618,6 +19630,15 @@ react-draggable@^4.0.0, react-draggable@^4.0.3, react-draggable@^4.4.3:
|
||||
classnames "^2.2.5"
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react-dropzone@11.3.4:
|
||||
version "11.3.4"
|
||||
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.3.4.tgz#aeb098df5c4491e165042c9f0b5e2e7185484740"
|
||||
integrity sha512-B1nzNRZ4F1cnrfEC0T6KXeBN1mCPinu4JCoTrp7NjB+442KSPxqfDrw41QIA2kAwlYs1+wj/0BTedeM5hc2+xw==
|
||||
dependencies:
|
||||
attr-accept "^2.2.1"
|
||||
file-selector "^0.2.2"
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-element-to-jsx-string@^14.3.2:
|
||||
version "14.3.2"
|
||||
resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.2.tgz#c0000ed54d1f8b4371731b669613f2d4e0f63d5c"
|
||||
|
||||
Reference in New Issue
Block a user