diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index a9adddac887..8691a9d012b 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -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", diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.mdx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.mdx new file mode 100644 index 00000000000..19b71160ff9 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.mdx @@ -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'; + + console.log(result)} />; +``` + +### Props + + diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx new file mode 100644 index 00000000000..b4f5741c78e --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.story.tsx @@ -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 = (args) => { + return ; +}; diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.test.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.test.tsx new file mode 100644 index 00000000000..09074c95a15 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.test.tsx @@ -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(); + + expect(screen.getByText('Upload file')).toBeInTheDocument(); + }); + + it('should show accepted file type when passed in the options as a string', () => { + render(); + + expect(screen.getByText('Accepted file type: .json')).toBeInTheDocument(); + }); + + it('should show accepted file types when passed in the options as a string array', () => { + render(); + + expect(screen.getByText('Accepted file types: .json, .txt')).toBeInTheDocument(); + }); + + it('should handle file removal from the list', async () => { + render(); + + 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(); + + 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(); + 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(); + 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(); + 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 = ( + +

Custom dropzone text

+
+ ); + 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'], + }, + }; +} diff --git a/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx new file mode 100644 index 00000000000..215d67c1642 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileDropzone.tsx @@ -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([]); + + 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) => ); + + return ( +
+
+ + {children ?? } +
+ {options?.accept && ( + {getAcceptedFileTypeText(options)} + )} + {fileList} +
+ ); +} + +export function FileDropzoneDefaultChildren({ + primaryText = 'Upload file', + secondaryText = 'Drag and drop here or browse', +}) { + const theme = useTheme2(); + const styles = getStyles(theme); + + return ( +
+ +

{primaryText}

+ {secondaryText} +
+ ); +} +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}; + `, + }; +} diff --git a/packages/grafana-ui/src/components/FileDropzone/FileListItem.mdx b/packages/grafana-ui/src/components/FileDropzone/FileListItem.mdx new file mode 100644 index 00000000000..a3889ae5d3d --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileListItem.mdx @@ -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'; + +; +``` + +### Props + + diff --git a/packages/grafana-ui/src/components/FileDropzone/FileListItem.story.tsx b/packages/grafana-ui/src/components/FileDropzone/FileListItem.story.tsx new file mode 100644 index 00000000000..af0ae0ce612 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileListItem.story.tsx @@ -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 = (args) => { + return ; +}; diff --git a/packages/grafana-ui/src/components/FileDropzone/FileListItem.test.tsx b/packages/grafana-ui/src/components/FileDropzone/FileListItem.test.tsx new file mode 100644 index 00000000000..95b5caf5ae2 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileListItem.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('should show a Cancel button when abortUpload prop is passed', () => { + const abortUpload = jest.fn(); + render(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: REMOVE_FILE })); + + expect(removeFile).toBeCalledWith(customFile); + }); +}); diff --git a/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx b/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx new file mode 100644 index 00000000000..27255b55731 --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/FileListItem.tsx @@ -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 ( + <> + {error.message} + {retryUpload && ( + + )} + {removeFile && ( + removeFile(customFile)} + tooltip={REMOVE_FILE} + aria-label={REMOVE_FILE} + /> + )} + + ); + } + + if (progress && file.size > progress) { + return ( + <> + + {Math.round((progress / file.size) * 100)}% + {abortUpload && ( + + )} + + ); + } + return ( + removeFile && ( + removeFile(customFile)} + tooltip={REMOVE_FILE} + aria-label={REMOVE_FILE} + tooltipPlacement="top" + /> + ) + ); + }; + + const valueFormat = getValueFormat('decbytes')(file.size); + + return ( +
+ + + {trimFileName(file.name)} + {formattedValueToString(valueFormat)} + + +
{renderRightSide()}
+
+ ); +} + +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)}; + } + `, + }; +} diff --git a/packages/grafana-ui/src/components/FileDropzone/index.ts b/packages/grafana-ui/src/components/FileDropzone/index.ts new file mode 100644 index 00000000000..bab787c841a --- /dev/null +++ b/packages/grafana-ui/src/components/FileDropzone/index.ts @@ -0,0 +1,4 @@ +import { FileDropzone, DropzoneFile, FileDropzoneProps, FileDropzoneDefaultChildren } from './FileDropzone'; +import { FileListItem, FileListItemProps } from './FileListItem'; + +export { FileDropzone, FileDropzoneProps, DropzoneFile, FileListItem, FileListItemProps, FileDropzoneDefaultChildren }; diff --git a/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx b/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx index 0dea0661c2c..48310576ae8 100644 --- a/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx +++ b/packages/grafana-ui/src/components/FileUpload/FileUpload.tsx @@ -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 = ({ onFileUpload, className, diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 596fc7de1f6..76bc5dcf55e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/utils/file.ts b/packages/grafana-ui/src/utils/file.ts new file mode 100644 index 00000000000..abec14058bc --- /dev/null +++ b/packages/grafana-ui/src/utils/file.ts @@ -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}`; +} diff --git a/yarn.lock b/yarn.lock index 6584fd5cb56..0fc30d1c819 100644 --- a/yarn.lock +++ b/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"