Source

adminjs-design-system/src/molecules/drop-zone/drop-zone.tsx

import React, { useState, useCallback, useEffect } from 'react'
import styled from 'styled-components'

import { Label } from '../../atoms/label'
import { Box } from '../../atoms/box/box'
import { Text } from '../../atoms/text'
import { MessageBox } from '../message-box'
import { DropZoneItem } from './drop-zone-item'
import { humanFileSize, DisplaySizeUnit } from '../../utils/human-file-size'

const validateContentType = (
  mimeTypes: undefined | Array<string>,
  mimeType: string,
): boolean => {
  if (!mimeTypes || !mimeTypes.length) { return true }
  return mimeTypes.includes(mimeType)
}

const validateSize = (
  maxSize: string | number | undefined,
  size: string | number | null,
): boolean => {
  if (!maxSize) { return true }
  if (!size) { return true }
  return +maxSize >= +size
}

const inUnit = (size: string | number, unit?: FileSizeUnit): string => {
  if (!size) { return '' }

  return humanFileSize(size, unit)
}

/**
 * @memberof DropZone
 * @alias FileSizeUnit
 */
type FileSizeUnit = DisplaySizeUnit

/**
 * @returns {void}
 * @memberof DropZone
 * @alias OnDropDownChange
 */
export type OnDropZoneChange = (files: Array<File>) => void

/**
 * @memberof DropZone
 * @alias DropZoneProps
 */
export type DropZoneProps = {
  /**
   * if drop zone should handle multiple uploads
   */
  multiple?: boolean;
  /**
   * Initial files collection (in case you want to hold files state)
   */
  files?: Array<File>;
  /**
   * Callback performed when the file is dropped/selected
   */
  onChange?: OnDropZoneChange;
  /**
   * Validate options
   */
  validate?: {
    /**
     * Maximum size of the uploaded file in bytes. If not defined - all files are allowed.
     */
    maxSize?: number;
    /**
     * Available mime types. When not defined - all mime types are allowed.
     */
    mimeTypes?: Array<string>;
  };
  /**
   * Upload limit display e.g.: 'KB' (upper case)
   */
  uploadLimitIn?: FileSizeUnit;
}

const UploadInput = styled.input`
  font-size: 100px;
  position: absolute;
  left: 0;
  top: 0;
  opacity: 0;
  bottom: 0;
  cursor: pointer;
  width: 100%;
`

const StyledDropZone = styled(Box)`
  border: 1px dashed ${({ theme }): string => theme.colors.grey80};
  position: relative;
  text-align: center;

  & ${Label} {
    color: ${({ theme }): string => theme.colors.grey60};
    font-size: ${({ theme }): string => theme.fontSizes.xs};
    padding-right: 4px;
    letter-spacing: 1px;
  }
`

type ErrorMessage = {
  message: string;
  title: string;
}

/**
 * @classdesc
 *
 * <img src="components/drop-zone.png" />
 *
 * DropZone which can be used for uploading files.
 *
 * ### usage
 *
 * ```javascript
 * import { DropZone, DropZoneProps } from '@adminjs/design-system'
 * ```
 *
 * how to use it in your custom component.tsx (TypesScript):
 * ```
 * import React, { useState } from 'react'
 * import { DropZone, Label, BasePropertyProps } from '@adminjs/design-system'
 * import { unflatten } from 'flat'
 *
 * const UploadPhoto: React.FC<BasePropertyProps> = (props) => {
 *   const { property, record, onChange } = props
 *
 *   const onUpload = (files: FileList) => {
 *     const newRecord = {...record}
 *     const file = files.length && files[0]
 *
 *     onChange({
 *       ...newRecord,
 *       params: {
 *         ...newRecord.params,
 *         [property.name]: file,
 *       }
 *     })
 *     event.preventDefault()
 *   }
 *
 *   return (
 *     <Box>
 *       <Label>{property.label}</Label>
 *       <DropZone onChange={onUpload} />
 *     </Box>
 *   )
 * }
 * ```
 * @hideconstructor
 * @component
 * @subcategory Molecules
 * @see FileSizeUnit
 * @see OnDropDownChange
 * @see DropZoneProps
 * @see {@link https://storybook.adminjs.co/?path=/story/designsystem-molecules-dropzone--default Storybook}
 * @example <caption>Single file with validation</caption>
 * const maxSize = 1024 * 100
 * const mimeTypes = ['application/pdf']
 * const onUpload = (files) => { alert(files,length ? files[0].name : 'no files' ) }
 * return (
 * <Box>
 *   <DropZone
 *     onChange={onUpload}
 *     validate= { { maxSize, mimeTypes } }
 *   />
 * </Box>
 * )
 *
 * @example <caption>Multi file of photos</caption>
 * const mimeTypes = ['image/png']
 * const onUpload = (files) => { alert(files.length ? files.length : 'no files' ) }
 * return (
 * <Box>
 *   <DropZone
 *     multiple
 *     onChange={onUpload}
 *     validate= { { mimeTypes } }
 *   />
 * </Box>
 * )
 * @section design-system
 */
const DropZone: React.FC<DropZoneProps> = (props) => {
  const { validate, onChange, multiple, files: filesFromProps, uploadLimitIn, ...other } = props

  const [, setIsDragging] = useState(false)
  const [error, setError] = useState<ErrorMessage | null>(null)
  const [filesToUpload, setFilesToUpload] = useState<Array<File>>(filesFromProps ?? [])

  useEffect(() => {
    if (filesFromProps) {
      setFilesToUpload(filesFromProps)
    }
  }, [filesFromProps])

  const onDragEnter = (): void => setIsDragging(true)
  const onDragLeave = (): void => setIsDragging(false)
  const onDragOver = (): void => setIsDragging(true)

  const removeItem = useCallback((index: number): void => {
    const newItems = [...filesToUpload]
    newItems.splice(index, 1)
    if (onChange) {
      onChange(newItems)
    }
    setFilesToUpload(newItems)
  }, [filesToUpload, setFilesToUpload, onChange])

  const onDrop = useCallback((event: React.DragEvent | React.SyntheticEvent): void => {
    event.preventDefault()
    setIsDragging(false)

    const { files } = ((event as React.DragEvent).dataTransfer || event.target)
    const validatedFiles: Array<File> = []

    for (let i = 0; i < files.length; i += 1) {
      const file = files.item(i) as File
      if (!file) { return }
      if (validate && !validateSize(validate.maxSize, file && file.size)) {
        setError({ message: `File: ${file.name} size is too big`, title: 'Wrong Size' })
        return
      }
      if (validate && !validateContentType(validate.mimeTypes, file.type)) {
        setError({ message: `File: ${file.name} has unsupported type: ${file.type}`, title: 'Wrong Type' })
        return
      }
      validatedFiles.push(file)
      setError(null)
    }
    let newFiles
    if (!multiple && validatedFiles.length) {
      newFiles = [validatedFiles[0]]
    } else {
      newFiles = [
        ...filesToUpload,
        ...validatedFiles,
      ]
    }
    if (onChange) {
      onChange(newFiles)
    }
    setFilesToUpload(newFiles)
  }, [onChange, setFilesToUpload, setIsDragging])

  const displayUploadLimit = useCallback(() => {
    if (validate && validate.maxSize) {
      return inUnit(validate.maxSize, uploadLimitIn)
    }
    return ''
  }, [validate])

  return (
    <Box>
      <StyledDropZone
        onDragEnter={onDragEnter}
        onDragOver={onDragOver}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        {...other}
        p="xl"
      >
        <UploadInput type="file" onChange={(event): void => onDrop(event)} multiple={multiple} />
        <Box>
          <Text fontSize="sm">
            Pick or Drop File here to upload it.
          </Text>
          <Box>
            {validate && validate.maxSize ? (
              <Text variant="xs" color="grey60" lineHeight="default" mt="sm">
                <Label inline uppercase>Max size:</Label>
                {displayUploadLimit()}
              </Text>
            ) : ''}
            {validate && validate.mimeTypes && validate.mimeTypes.length ? (
              <Text variant="xs" color="grey60" lineHeight="default" mt="sm">
                {validate.mimeTypes.join(', ')}
              </Text>
            ) : ''}
          </Box>
        </Box>
      </StyledDropZone>
      {error ? (
        <MessageBox
          mt="default"
          variant="danger"
          size="sm"
          icon="Warning"
          message={error.title}
          onCloseClick={(): void => setError(null)}
        >
          {error.message}
        </MessageBox>
      ) : ''}
      {filesToUpload.map((file, index) => (
        // eslint-disable-next-line react/no-array-index-key
        <DropZoneItem file={file} key={index} onRemove={(): void => removeItem(index)} />
      ))}
    </Box>
  )
}

export { DropZone }
export default DropZone