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:
Zoltán Bedi
2021-07-26 13:21:35 +02:00
committed by GitHub
parent b254e4eb31
commit 04a196da4b
14 changed files with 670 additions and 13 deletions

View File

@@ -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",

View File

@@ -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} />

View File

@@ -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} />;
};

View File

@@ -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'],
},
};
}

View 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};
`,
};
}

View File

@@ -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} />

View File

@@ -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} />;
};

View File

@@ -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);
});
});

View 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)};
}
`,
};
}

View File

@@ -0,0 +1,4 @@
import { FileDropzone, DropzoneFile, FileDropzoneProps, FileDropzoneDefaultChildren } from './FileDropzone';
import { FileListItem, FileListItemProps } from './FileListItem';
export { FileDropzone, FileDropzoneProps, DropzoneFile, FileListItem, FileListItemProps, FileDropzoneDefaultChildren };

View File

@@ -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,

View File

@@ -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';

View 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}`;
}

View File

@@ -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"