import { SvgIconComponent } from "@mui/icons-material"; import AttachFileIcon from "@mui/icons-material/AttachFile"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Box, Theme } from "@mui/material"; import { BoxProps } from "@mui/material/Box"; import { ChipProps } from "@mui/material/Chip"; import { GridProps } from "@mui/material/Grid"; import Snackbar, { SnackbarOrigin, SnackbarProps, } from "@mui/material/Snackbar"; import Typography, { TypographyProps } from "@mui/material/Typography"; import clsx from "clsx"; import PropTypes from "prop-types"; import React, { ComponentProps, Fragment, HTMLProps, PureComponent, } from "react"; import Dropzone, { DropEvent, DropzoneProps } from "react-dropzone"; import { convertBytesToMbsOrKbs, isImage, readFile } from "../helpers"; import { AlertType, FileObject } from "../types"; import { withTheme } from "../withTheme"; import PreviewList, { PreviewListProps } from "./PreviewList"; import SnackbarContentWrapper from "./SnackbarContentWrapper"; const defaultSnackbarAnchorOrigin: SnackbarOrigin = { horizontal: "left", vertical: "bottom", }; const defaultGetPreviewIcon: PreviewListProps["getPreviewIcon"] = ( fileObject, classes ) => { const { data, file } = fileObject || {}; if (isImage(file)) { const src = typeof data === "string" ? data : undefined; return ; } return ( ); }; export const FileObjectShape = PropTypes.shape({ file: PropTypes.object, data: PropTypes.any, }); export type DropzoneAreaBaseClasses = { /** Material-UI class applied to the root Dropzone div */ root: string; /** Material-UI class applied to the Dropzone when 'active' */ active: string; /** Material-UI class applied to the Dropzone when 'invalid' */ invalid: string; /** Material-UI class applied to the Dropzone text container div */ textContainer: string; /** Material-UI class applied to the Dropzone text */ text: string; /** Material-UI class applied to the Dropzone icon */ icon: string; }; export type DropzoneAreaBaseProps = { classes?: Partial; /** A list of file types to accept. * * @see See [here](https://react-dropzone.js.org/#section-accepting-specific-file-types) for more details. */ acceptedFiles?: string[]; /** Maximum number of files that can be loaded into the dropzone. */ filesLimit?: number; /** Currently loaded files. */ fileObjects: FileObject[]; /** Icon to be displayed inside the dropzone area. */ Icon?: SvgIconComponent; /** Maximum file size (in bytes) that the dropzone will accept. */ maxFileSize?: number; /** Text inside the dropzone. */ dropzoneText?: string; /** The label for the file preview section. */ previewText?: string; /** Shows previews **BELOW** the dropzone. */ showPreviews?: boolean; /** Shows preview **INSIDE** the dropzone area. */ showPreviewsInDropzone?: boolean; /** Shows file name under the image. */ showFileNamesInPreview?: boolean; /** Shows file name under the dropzone image. */ showFileNames?: boolean; /** Uses deletable Material-UI Chip components to display file names. */ useChipsForPreview?: boolean; /** * Props to pass to the Material-UI Chip components. * * Requires `useChipsForPreview` prop to be `true`. * * @see See [Material-UI Chip](https://material-ui.com/api/chip/#props) for available values. */ previewChipProps?: ChipProps; /** * Custom CSS classNames for preview Grid components. * * Should be in the form {container: string, item: string, image: string}. */ previewGridClasses?: { container?: string; item?: string; image?: string; }; /** * Props to pass to the Material-UI Grid components. * * Should be in the form {container: GridProps, item: GridProps}. * * @see See [Material-UI Grid](https://material-ui.com/api/grid/#props) for available GridProps values. */ previewGridProps?: { container?: GridProps; item?: GridProps; }; /** * Shows styled Material-UI Snackbar when files are dropped, deleted or rejected. * * - can be a boolean ("global" `true` or `false` for all alerts). * - can be an array, with values 'error', 'info', 'success', 'warning' to select to view only certain alerts: * - showAlerts={['error']} for only errors. * - showAlerts={['error', 'info']} for both errors and info. * - showAlerts={['error', 'success', 'info', 'warning']} is same as showAlerts={true}. * - showAlerts={[]} is same as showAlerts={false}. */ showAlerts?: boolean | AlertType[]; /** * Props to pass to the Material-UI Snackbar components. * Requires `showAlerts` prop to be `true`. * * @see See [Material-UI Snackbar](https://material-ui.com/api/snackbar/#props) for available values. */ alertSnackbarProps?: SnackbarProps; /** * Props to pass to the Dropzone component. * * @see See [Dropzone props](https://react-dropzone.js.org/#src) for available values. */ dropzoneProps?: DropzoneProps; /** * Attributes applied to the input element. * * @see See [MDN Input File attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Additional_attributes) for available values. */ inputProps?: HTMLProps; clearOnUnmount?: boolean; /** Custom CSS class name for dropzone container. */ dropzoneClass?: string; /** Custom CSS class name for text inside the container. */ dropzoneParagraphClass?: string; /** Disable feedback effect when dropping rejected files. */ disableRejectionFeedback?: boolean; /** * Fired when new files are added to dropzone. * * @param {FileObject[]} newFiles The new files added to the dropzone. */ onAdd?: (newFiles: FileObject[]) => void; /** * Fired when a file is deleted from the previews panel. * * @param {FileObject} deletedFileObject The file that was removed. * @param {number} index The index of the removed file object. */ onDelete?: (deletedFileObject: FileObject, index: number) => void; /** * Fired when the user drops files into the dropzone. * * @param {File[]} droppedFiles All the files dropped into the dropzone. * @param {Event} event The react-dropzone drop event. */ onDrop?: (droppedFiles: File[], event: DropEvent) => void; /** * Fired when a file is rejected because of wrong file type, size or goes beyond the filesLimit. * * @param {File[]} rejectedFiles All the rejected files. * @param {Event} event The react-dropzone drop event. */ onDropRejected?: (rejectedFiles: File[], event: DropEvent) => void; /** * Fired when an alert is triggered. * * @param {string} message Alert message. * @param {string} variant One of "error", "info", "success". */ onAlert?: (message: string, variant: AlertType) => void; /** * Get alert message to display when files limit is exceed. * * *Default*: "Maximum allowed number of files exceeded. Only ${filesLimit} allowed" * * @param {number} filesLimit The `filesLimit` currently set for the component. */ getFileLimitExceedMessage?: (filesLimit: number) => string; /** * Get alert message to display when a new file is added. * * *Default*: "File ${fileName} successfully added." * * @param {string} fileName The newly added file name. */ getFileAddedMessage?: (fileName: string) => string; /** * Get alert message to display when a file is removed. * * *Default*: "File ${fileName} removed." * * @param {string} fileName The name of the removed file. */ getFileRemovedMessage?: (fileName: string) => string; /** * Get alert message to display when a file is rejected onDrop. * * *Default*: "File ${rejectedFile.name} was rejected." * * @param {Object} rejectedFile The file that got rejected * @param {string[]} acceptedFiles The `acceptedFiles` prop currently set for the component * @param {number} maxFileSize The `maxFileSize` prop currently set for the component */ getDropRejectMessage?: ( rejectedFile: File, acceptedFiles: string[], maxFileSize: number ) => string; /** * A function which determines which icon to display for a file preview. * * *Default*: If its an image then displays a preview the image, otherwise it will display an attachment icon * * @param {FileObject} fileObject The file which the preview will belong to * @param {Object} classes The classes for the file preview icon, in the default case we use the 'image' className. */ getPreviewIcon?: PreviewListProps["getPreviewIcon"]; }; type DropzoneAreaBaseState = { openSnackBar: boolean; snackbarMessage: string; snackbarVariant: AlertType; }; /** * This components creates a Material-UI Dropzone, with previews and snackbar notifications. */ class DropzoneAreaBase extends PureComponent< DropzoneAreaBaseProps & { theme: Theme }, DropzoneAreaBaseState > { static propTypes = { classes: PropTypes.object, acceptedFiles: PropTypes.arrayOf(PropTypes.string), filesLimit: PropTypes.number, Icon: PropTypes.elementType, fileObjects: PropTypes.arrayOf(FileObjectShape), maxFileSize: PropTypes.number, dropzoneText: PropTypes.string, dropzoneClass: PropTypes.string, dropzoneParagraphClass: PropTypes.string, disableRejectionFeedback: PropTypes.bool, showPreviews: PropTypes.bool, showPreviewsInDropzone: PropTypes.bool, showFileNames: PropTypes.bool, showFileNamesInPreview: PropTypes.bool, useChipsForPreview: PropTypes.bool, previewChipProps: PropTypes.object, previewGridClasses: PropTypes.object, previewGridProps: PropTypes.object, previewText: PropTypes.string, showAlerts: PropTypes.oneOfType([ PropTypes.bool, PropTypes.arrayOf( PropTypes.oneOf(["error", "success", "info", "warning"]) ), ]), alertSnackbarProps: PropTypes.object, dropzoneProps: PropTypes.object, inputProps: PropTypes.object, getFileLimitExceedMessage: PropTypes.func, getFileAddedMessage: PropTypes.func, getFileRemovedMessage: PropTypes.func, getDropRejectMessage: PropTypes.func, getPreviewIcon: PropTypes.func, onAdd: PropTypes.func, onDelete: PropTypes.func, onDrop: PropTypes.func, onDropRejected: PropTypes.func, onAlert: PropTypes.func, }; static defaultProps = { acceptedFiles: [], filesLimit: 3, fileObjects: [] as FileObject[], maxFileSize: 3000000, dropzoneText: "Drag and drop a file here or click", previewText: "Preview:", disableRejectionFeedback: false, showPreviews: false, // By default previews show up under in the dialog and inside in the standalone showPreviewsInDropzone: true, showFileNames: false, showFileNamesInPreview: false, useChipsForPreview: false, previewChipProps: {}, previewGridClasses: {}, previewGridProps: {}, showAlerts: true, alertSnackbarProps: { anchorOrigin: { horizontal: "left", vertical: "bottom", }, autoHideDuration: 6000, }, getFileLimitExceedMessage: ((filesLimit) => `Maximum allowed number of files exceeded. Only ${filesLimit} allowed`) as NonNullable< DropzoneAreaBaseProps["getFileLimitExceedMessage"] >, getFileAddedMessage: ((fileName) => `File ${fileName} successfully added.`) as NonNullable< DropzoneAreaBaseProps["getFileAddedMessage"] >, getPreviewIcon: defaultGetPreviewIcon, getFileRemovedMessage: ((fileName) => `File ${fileName} removed.`) as NonNullable< DropzoneAreaBaseProps["getFileRemovedMessage"] >, getDropRejectMessage: ((rejectedFile, acceptedFiles, maxFileSize) => { let message = `File ${rejectedFile.name} was rejected. `; if (!acceptedFiles.includes(rejectedFile.type)) { message += "File type not supported. "; } if (rejectedFile.size > maxFileSize) { message += "File is too big. Size limit is " + convertBytesToMbsOrKbs(maxFileSize) + ". "; } return message; }) as NonNullable, }; state: DropzoneAreaBaseState = { openSnackBar: false, snackbarMessage: "", snackbarVariant: "success", }; notifyAlert() { const { onAlert } = this.props; const { openSnackBar, snackbarMessage, snackbarVariant } = this.state; if (openSnackBar && onAlert) { onAlert(snackbarMessage, snackbarVariant); } } handleDropAccepted: DropzoneProps["onDropAccepted"] = async ( acceptedFiles, evt ) => { const { fileObjects, filesLimit = DropzoneAreaBase.defaultProps.filesLimit, getFileAddedMessage = DropzoneAreaBase.defaultProps.getFileAddedMessage, getFileLimitExceedMessage = DropzoneAreaBase.defaultProps .getFileLimitExceedMessage, onAdd, onDrop, } = this.props; if ( filesLimit > 1 && fileObjects.length + acceptedFiles.length > filesLimit ) { this.setState( { openSnackBar: true, snackbarMessage: getFileLimitExceedMessage(filesLimit), snackbarVariant: "error", }, this.notifyAlert ); return; } // Notify Drop event if (onDrop) { onDrop(acceptedFiles, evt); } // Retrieve fileObjects data const fileObjs = await Promise.all( acceptedFiles.map(async (file) => { const data = await readFile(file); return { file, data, }; }) ); // Notify added files if (onAdd) { onAdd(fileObjs); } // Display message const message = fileObjs.reduce( (msg, fileObj) => msg + getFileAddedMessage(fileObj.file.name), "" ); this.setState( { openSnackBar: true, snackbarMessage: message, snackbarVariant: "success", }, this.notifyAlert ); }; handleDropRejected: DropzoneProps["onDropRejected"] = ( rejectedFiles, evt ) => { const { acceptedFiles, filesLimit = DropzoneAreaBase.defaultProps.filesLimit, fileObjects, getDropRejectMessage = DropzoneAreaBase.defaultProps.getDropRejectMessage, getFileLimitExceedMessage = DropzoneAreaBase.defaultProps .getFileLimitExceedMessage, maxFileSize = DropzoneAreaBase.defaultProps.maxFileSize, onDropRejected, } = this.props; let message = ""; if (fileObjects.length + rejectedFiles.length > filesLimit) { message = getFileLimitExceedMessage(filesLimit); } else { rejectedFiles.forEach((rejectedFile) => { message = getDropRejectMessage( rejectedFile, acceptedFiles || [], maxFileSize ); }); } if (onDropRejected) { onDropRejected(rejectedFiles, evt); } this.setState( { openSnackBar: true, snackbarMessage: message, snackbarVariant: "error", }, this.notifyAlert ); }; handleRemove: PreviewListProps["handleRemove"] = (fileIndex) => (event) => { event.stopPropagation(); const { fileObjects, getFileRemovedMessage = DropzoneAreaBase.defaultProps .getFileRemovedMessage, onDelete, } = this.props; // Find removed fileObject const removedFileObj = fileObjects[fileIndex]; // Notify removed file if (onDelete) { onDelete(removedFileObj, fileIndex); } this.setState( { openSnackBar: true, snackbarMessage: getFileRemovedMessage(removedFileObj.file.name), snackbarVariant: "info", }, this.notifyAlert ); }; handleCloseSnackbar = () => { this.setState({ openSnackBar: false, }); }; defaultSx = { root: { "@keyframes progress": { "0%": { backgroundPosition: "0 0", }, "100%": { backgroundPosition: "-70px 0", }, }, position: "relative", width: "100%", minHeight: "250px", backgroundColor: "background.paper", border: "dashed", borderColor: "divider", borderRadius: 1, boxSizing: "border-box", cursor: "pointer", overflow: "hidden", } as BoxProps["sx"], active: { animation: "$progress 2s linear infinite !important", backgroundImage: `repeating-linear-gradient(-45deg, ${this.props.theme.palette.background.paper}, ${this.props.theme.palette.background.paper} 25px, ${this.props.theme.palette.divider} 25px, ${this.props.theme.palette.divider} 50px)`, backgroundSize: "150% 100%", border: "solid", borderColor: "primary.light", } as BoxProps["sx"], invalid: { backgroundImage: `repeating-linear-gradient(-45deg, ${this.props.theme.palette.error.light}, ${this.props.theme.palette.error.light} 25px, ${this.props.theme.palette.error.dark} 25px, ${this.props.theme.palette.error.dark} 50px)`, borderColor: "error.main", } as BoxProps["sx"], textContainer: { textAlign: "center", } as BoxProps["sx"], text: { marginBottom: 3, marginTop: 3, } as TypographyProps["sx"], icon: { width: 51, height: 51, color: "text.primary", } as ComponentProps["sx"], }; render() { const { acceptedFiles, alertSnackbarProps, classes = {}, disableRejectionFeedback, dropzoneClass, dropzoneParagraphClass, dropzoneProps, dropzoneText, fileObjects, filesLimit = DropzoneAreaBase.defaultProps.filesLimit, getPreviewIcon = DropzoneAreaBase.defaultProps.getPreviewIcon, Icon, inputProps, maxFileSize, previewChipProps, previewGridClasses, previewGridProps, previewText, showAlerts, showFileNames, showFileNamesInPreview, showPreviews, showPreviewsInDropzone, useChipsForPreview, } = this.props; const { openSnackBar, snackbarMessage, snackbarVariant } = this.state; const acceptFiles = acceptedFiles?.join(","); const isMultiple = filesLimit > 1; const previewsVisible = showPreviews && fileObjects.length > 0; const previewsInDropzoneVisible = showPreviewsInDropzone && fileObjects.length > 0; return ( {({ getRootProps, getInputProps, isDragActive, isDragReject }) => { const isActive = isDragActive; const isInvalid = !disableRejectionFeedback && isDragReject; return ( {dropzoneText} {Icon ? ( ) : ( )} {previewsInDropzoneVisible ? ( ) : null} ); }} {previewsVisible ? ( {previewText} ) : null} {(typeof showAlerts === "boolean" && showAlerts) || (Array.isArray(showAlerts) && showAlerts.includes(snackbarVariant)) ? ( ) : null} ); } } // @ts-expect-error const ThemedDropzoneAreaBase = withTheme(DropzoneAreaBase); export default ThemedDropzoneAreaBase;