From 8229b31c804600f5f6e12a3402f8d96ebc76a519 Mon Sep 17 00:00:00 2001 From: Sinan Bolel <1915925+sbolel@users.noreply.github.com> Date: Mon, 17 Jul 2023 14:30:36 -0400 Subject: [PATCH] feat(components): add MultiDropzone component (#94) --- .../MultiDropzone/MultiDropzone.stories.tsx | 72 +++++ .../MultiDropzone/MultiDropzone.test.tsx | 125 ++++++++ .../MultiDropzone/MultiDropzone.tsx | 293 ++++++++++++++++++ .../MultiDropzone/UploadFileCell.test.tsx | 160 ++++++++++ .../MultiDropzone/UploadFileCell.tsx | 155 +++++++++ src/components/MultiDropzone/constants.ts | 31 ++ src/components/MultiDropzone/types.ts | 76 +++++ src/components/MultiDropzone/utils.test.ts | 190 ++++++++++++ src/components/MultiDropzone/utils.ts | 106 +++++++ 9 files changed, 1208 insertions(+) create mode 100644 src/components/MultiDropzone/MultiDropzone.stories.tsx create mode 100644 src/components/MultiDropzone/MultiDropzone.test.tsx create mode 100644 src/components/MultiDropzone/MultiDropzone.tsx create mode 100644 src/components/MultiDropzone/UploadFileCell.test.tsx create mode 100644 src/components/MultiDropzone/UploadFileCell.tsx create mode 100644 src/components/MultiDropzone/constants.ts create mode 100644 src/components/MultiDropzone/types.ts create mode 100644 src/components/MultiDropzone/utils.test.ts create mode 100644 src/components/MultiDropzone/utils.ts diff --git a/src/components/MultiDropzone/MultiDropzone.stories.tsx b/src/components/MultiDropzone/MultiDropzone.stories.tsx new file mode 100644 index 0000000..1361ae2 --- /dev/null +++ b/src/components/MultiDropzone/MultiDropzone.stories.tsx @@ -0,0 +1,72 @@ +import type { StoryObj, Meta } from '@storybook/react' +import { useArgs } from '@storybook/client-api' +import MultiDropzone from '@/components/MultiDropzone/MultiDropzone' + +type Story = StoryObj + +export default { + title: 'Upload-UI/Components/MultiDropzone', + component: MultiDropzone, + argTypes: { + uploadedFiles: { + control: 'object', + }, + onFileSelect: { + control: 'function', + }, + uploading: { + control: 'boolean', + }, + onRemoveFile: { + control: 'function', + }, + }, + args: { + uploadedFiles: [], + uploading: false, + onFileSelect: () => null, + onRemoveFile: () => null, + }, + render: (args) => , +} as Meta + +export const Default: Story = { + args: { + uploadedFiles: [], + uploading: false, + }, +} + +export const Playground = ({ ...args }) => { + const [{ uploadedFiles }, updateArgs] = useArgs() + + const onFileSelect = (files: File[]) => { + updateArgs({ + uploadedFiles: files.map((file) => ({ + id: file.name, + name: file.name, + progress: 0, + previewUrl: URL.createObjectURL(file), + })), + }) + } + + const onRemoveFile = (name: string) => { + updateArgs({ + uploadedFiles: uploadedFiles.filter((file: File) => file.name !== name), + }) + } + + return ( + + ) +} diff --git a/src/components/MultiDropzone/MultiDropzone.test.tsx b/src/components/MultiDropzone/MultiDropzone.test.tsx new file mode 100644 index 0000000..36c010a --- /dev/null +++ b/src/components/MultiDropzone/MultiDropzone.test.tsx @@ -0,0 +1,125 @@ +import { render, fireEvent, screen, waitFor } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import MultiDropzone from '@/components/MultiDropzone/MultiDropzone' + +describe('MultiDropzone', () => { + const onFileSelect = jest.fn() + const onRemoveFile = jest.fn() + + it('renders without crashing', () => { + const { container } = render( + ({})} + onRemoveFile={() => ({})} + uploadedFiles={[]} + uploading={false} + /> + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('calls onFileSelect when a file is dropped', () => { + const handleFileSelect = jest.fn() + render( + ({})} + uploadedFiles={[]} + uploading={false} + /> + ) + const file = new File(['hello'], 'hello.png', { type: 'image/png' }) + const dataTransfer = { + files: [file], + items: [ + { + kind: 'file', + type: file.type, + getAsFile: () => file, + }, + ], + types: ['Files'], + } + act(() => { + fireEvent.drop( + screen.getByLabelText('Drag and Drop File Selection'), + dataTransfer + ) + waitFor(() => { + expect(handleFileSelect).toHaveBeenCalledWith([file]) + }) + }) + }) + + it('calls onRemoveFile when a file is removed', () => { + const handleRemoveFile = jest.fn() + render( + ({})} + onRemoveFile={handleRemoveFile} + uploadedFiles={[{ id: '1', name: 'hello.png', progress: 0 }]} + uploading={false} + /> + ) + fireEvent.click(screen.getByRole('button', { name: /file-action/i })) + expect(handleRemoveFile).toHaveBeenCalledWith('1') + }) + + it('displays uploaded files', () => { + render( + + ) + const uploadedFile = screen.getByText('hello.png') + expect(uploadedFile).toBeInTheDocument() + }) + + it('displays an error message when a file exceeds the maxSize', () => { + render( + ({})} + onRemoveFile={() => ({})} + uploadedFiles={[ + { + id: '1', + name: 'hello.png', + progress: 0, + error: 'File is too large.', + }, + ]} + maxSize={10} + maxFiles={1} + uploading={false} + /> + ) + // Create a mock file larger than maxSize + const file = new File([new Array(1024).join('a')], 'hello.png', { + type: 'image/png', + }) + // Create a mock DataTransfer object with file larger than maxSize + const dataTransfer = { + files: [file], + items: [ + { + kind: 'file', + type: file.type, + getAsFile: () => file, + }, + ], + types: ['json'], + } + act(() => { + fireEvent.drop( + screen.getByLabelText('Drag and Drop File Selection'), + dataTransfer + ) + waitFor(() => { + expect(screen.getByText(/File is too large./i)).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/components/MultiDropzone/MultiDropzone.tsx b/src/components/MultiDropzone/MultiDropzone.tsx new file mode 100644 index 0000000..14399b1 --- /dev/null +++ b/src/components/MultiDropzone/MultiDropzone.tsx @@ -0,0 +1,293 @@ +/** + * Component that via drag and drop or selection of files for upload. + * @module sbom-harbor-ui/components/MultiDropzone/MultiDropzone + */ +import React, { useCallback, useState } from 'react' +import { useDropzone, FileRejection } from 'react-dropzone' +import { v4 as uuidv4 } from 'uuid' +import { styled } from '@mui/material/styles' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Collapse from '@mui/material/Collapse' +import Typography from '@mui/material/Typography' +import UploadFileIcon from '@mui/icons-material/UploadFile' +import UploadFileCell from '@/components/MultiDropzone/UploadFileCell' +import { + DEFAULT_TOO_MANY_FILES_ERROR, + DEFAULT_UPLOADING_TEXT, +} from '@/components/MultiDropzone/constants' +import { + AcceptType, + ErrorMessage, + FileMimeTypes, + FileType, + MultiDropzoneProps, + MultiDropzoneStyleProps, + TextOverrides, + UploadedFile, + UploadFileCellProps, + UploadStatus, +} from '@/components/MultiDropzone/types' +import { + formatAcceptFileList, + getErrorMessage, + getFormattedAcceptObject, + getUploadStatus, +} from '@/components/MultiDropzone/utils' +import formatBytes from '@/utils/formatBytes' + +/** + * The styled container that's the target for the drag and drop functionality. + * @param {MultiDropzoneStyleProps} props - The props for the styled container + * @param {Theme} props.theme - The theme object provided by the ThemeProvider + * @returns {React.FC} - The styled container + */ +const StyledBox = styled(Box)(({ theme }) => ({ + alignItems: 'center', + border: `3px dashed ${theme.palette.primary.main}`, + borderRadius: theme.shape.borderRadius, + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + outline: 'none', + padding: theme.spacing(4), + transition: 'border .24s ease-in-out', + '&.active': { + border: `2px dashed ${theme.palette.primary.light}`, + }, + '&.disabled': { + cursor: 'not-allowed', + opacity: 0.5, + }, + '& .MuiSvgIcon-fontSizeLarge': { + fontSize: theme.typography.fontSize * 5, + marginBottom: theme.spacing(2), + }, + '& .MuiDialogTitle-root': { + fontWeight: theme.typography.fontWeightMedium, + }, +})) + +/** + * The default CallToAction component rendered in the MultiDropzone component. + * @returns {React.FC} - The default CallToAction component + */ +const DefaultCTA: React.FC = () => ( + + Drag and drop files or + + +) + +/** + * The CallToAction component rendered inside the MultiDropzone component. + * @param {MultiDropzoneStyleProps} param0 props - Input props + * @param {boolean} props.uploading - Whether or not a file is uploading + * @param {[boolean=false]} props.isCondensed - Whether or not text is condensed + * @param {TextOverrides} props.textOverrides - Overrides for the CTA text + * @param {string} props.textOverrides.currentlyUploadingText - Text override for when a file is uploading + * @param {string} props.textOverrides.instructionsText - Text override for when a file is not uploading + * @returns {React.FC} - The conditionally rendered CallToAction component + */ +const CallToAction: React.FC = ({ + uploading, + isCondensed = false, + textOverrides: { currentlyUploadingText = '', instructionsText = '' } = {}, +}) => ( + + {uploading + ? currentlyUploadingText || DEFAULT_UPLOADING_TEXT + : instructionsText || } + +) + +/** + * The MultiDropzone component that allows for file(s) to be uploaded via drag and drop or file selection. + * @param {MultiDropzoneProps} props - Input props + * @param {AcceptType} props.accept - The accepted file types + * @param {[boolean=false]} props.isCondensed - Whether or not the component is condensed, defaults to false + * @param {[number=0]} props.maxFiles - The maximum number of files allowed, defaults to 0 (unlimited) + * @param {number} props.maxSize - The max size of a file allowed + * @param {[boolean=false]} props.multiple - Whether or not multiple files are allowed, defaults to false + * @param {(files: File[]) => void} props.onFileSelect - The callback for when a file is selected + * @param {(id: string) => void} props.onRemoveFile - The callback for when a file is removed + * @param {UploadedFile[]} props.uploadedFiles - The list of uploaded files + * @param {boolean} props.uploading - Whether or not a file is uploading + * @param {TextOverrides} props.textOverrides - The text overrides for the component + * @returns {React.FC} - The MultiDropzone component + */ +const MultiDropZone: React.FC = ({ + accept, + isCondensed = false, + maxFiles = 0, + maxSize, + multiple = false, + onFileSelect, + onRemoveFile, + textOverrides, + uploadedFiles, + uploading, +}) => { + const [errors, setErrors] = useState(() => { + return uploadedFiles + .filter((file) => file.error) + .map((file) => ({ + id: file.id || uuidv4(), + message: file.error || '', + })) + }) + const formattedAccept = getFormattedAcceptObject(accept) + const fileList = formatAcceptFileList(formattedAccept) + const maxSizePlaceholder = + maxSize && maxSize > 0 + ? `${textOverrides?.sizeUpToText || 'up to'} ${formatBytes(maxSize)}` + : '' + const placeholder = `${textOverrides?.supportsTextShort || 'Supports'} ${ + fileList || 'JSON' + } ${maxSizePlaceholder}` + const isOverMaxFiles = maxFiles > 0 && uploadedFiles.length > maxFiles + + /** + * Removes an error from the list of errors. + * @param {string} removeId - The id of the error to remove + */ + const removeError = (removeId: string) => + setErrors(errors.filter(({ id }) => id !== removeId)) + + /** + * Handles the onDrop event from react-dropzone. + * @param {File[]} acceptedFiles - List of accepted files + * @param {FileRejection[]} filesRejected - List of rejected files and their errors + */ + const onDrop = useCallback( + (acceptedFiles: File[], filesRejected: FileRejection[]) => { + // call the onFileSelect callback with the accepted files + onFileSelect(acceptedFiles) + // get errors from any rejected files + const currentErrors = filesRejected.map(({ file, errors }) => ({ + file, + id: uuidv4(), + message: getErrorMessage( + errors[0], + { fileList, maxSize }, + textOverrides + ), + })) + // add new errors to the list of errors + setErrors(() => [...currentErrors]) + // log any new errors to console + if (currentErrors.length > 0) { + console.error(currentErrors) + } + }, + [fileList, maxSize, textOverrides] + ) + + const handleRemoveFile = useCallback( + (id: string) => { + onRemoveFile(id) + removeError(id) + }, + [onRemoveFile] + ) + + // set the dropzone props + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: formattedAccept, + disabled: uploading, + maxSize, + multiple, + onDrop, + }) + + return ( + <> + + + + + + {textOverrides?.supportsText || placeholder} + + + + {errors.length > 0 && ( + + {errors.map(({ id, message }) => ( + + {message} + + ))} + + )} + + {uploadedFiles.length > 0 && ( + + {uploadedFiles.map((file) => ( + + ))} + + )} + + + {isOverMaxFiles && ( + + {textOverrides?.tooManyFilesError || DEFAULT_TOO_MANY_FILES_ERROR} + + )} + + + ) +} + +MultiDropZone.displayName = 'MultiDropZone' + +export default MultiDropZone + +export type { + FileMimeTypes, + FileType, + UploadStatus, + UploadedFile, + AcceptType, + TextOverrides, + ErrorMessage, + MultiDropzoneStyleProps, + MultiDropzoneProps, + UploadFileCellProps, +} diff --git a/src/components/MultiDropzone/UploadFileCell.test.tsx b/src/components/MultiDropzone/UploadFileCell.test.tsx new file mode 100644 index 0000000..0d6709a --- /dev/null +++ b/src/components/MultiDropzone/UploadFileCell.test.tsx @@ -0,0 +1,160 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import UploadFileCell from '@/components/MultiDropzone/UploadFileCell' +import { UploadStatus, UploadedFile } from '@/components/MultiDropzone/types' + +describe('UploadFileCell', () => { + const mockFile: UploadedFile = { + id: '1', + name: 'testFile.json', + progress: 0, + error: '', + } + + const handleRemove = jest.fn() + + it('renders correctly when uploading', () => { + render( + + ) + expect(screen.getByText(/Uploading.../i)).toBeInTheDocument() + expect(screen.getByText(/testFile.json/)).toBeInTheDocument() + }) + + it('renders correctly when upload is complete', () => { + render( + + ) + expect(screen.getByTestId('CheckIcon')).toBeInTheDocument() + expect(screen.getByText(/testFile.json/i)).toBeInTheDocument() + }) + + it('renders correctly when there is an error after uploading is done', () => { + render( + + ) + expect(screen.getByTestId('ErrorIcon')).toBeInTheDocument() + expect(screen.getByText('testFile.json')).toBeInTheDocument() + }) + + it('renders correctly when there is an error', () => { + render( + + ) + expect(screen.getByTestId('ErrorIcon')).toBeInTheDocument() + expect(screen.getByText(/testFile.json/)).toBeInTheDocument() + }) + + it('renders correctly when there is an error while uploading is active', () => { + render( + + ) + expect(screen.getByText(/testFile.json/)).toBeInTheDocument() + expect( + screen.getByText(/Something went wrong. Try again./i) + ).toBeInTheDocument() + }) + + it('calls onRemoveFile when delete button is clicked', () => { + render( + + ) + fireEvent.click(screen.getByRole('button')) + expect(handleRemove).toHaveBeenCalledTimes(1) + }) + + it('renders progress bar when showProgressBar and does not render CircularProgress with default file arguments', () => { + const testFile = { ...mockFile } + render( + + ) + const progressBars = screen.getAllByRole('progressbar') + expect( + progressBars.find((e) => e.classList.contains('MuiCircularProgress-root')) + ).not.toBeDefined() + expect( + progressBars.find((e) => e.classList.contains('MuiLinearProgress-root')) + ).toBeInTheDocument() + }) + + it('renders CircularProgress when showLoadingSpinner is true', () => { + const testFile = { ...mockFile, showLoadingSpinner: true } + render( + + ) + expect( + screen + .getAllByRole('progressbar') + .find((e) => e.classList.contains('MuiCircularProgress-root')) + ).toBeInTheDocument() + }) + + it('renders DeleteIcon when upload is not complete and not uploading', () => { + render( + + ) + expect(screen.getByTestId('DeleteIcon')).toBeInTheDocument() + }) + + it('renders ErrorIcon when hasError is true', () => { + render( + + ) + expect(screen.getByTestId('ErrorIcon')).toBeInTheDocument() + }) +}) diff --git a/src/components/MultiDropzone/UploadFileCell.tsx b/src/components/MultiDropzone/UploadFileCell.tsx new file mode 100644 index 0000000..3539eb7 --- /dev/null +++ b/src/components/MultiDropzone/UploadFileCell.tsx @@ -0,0 +1,155 @@ +/** + * Component that renders a single file for the MultiDropzone component. + * @module sbom-harbor-ui/components/MultiDropzone/UploadFileCell + */ +import React, { ReactNode, SyntheticEvent, useCallback, useMemo } from 'react' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import IconButton from '@mui/material/IconButton' +import LinearProgress from '@mui/material/LinearProgress' +import Typography from '@mui/material/Typography' +import CheckIcon from '@mui/icons-material/Check' +import DeleteIcon from '@mui/icons-material/Delete' +import ErrorIcon from '@mui/icons-material/Error' +import TaskIcon from '@mui/icons-material/Task' +import { + DEFAULT_CELL_ERROR_TEXT, + DEFAULT_CELL_UPLOADED_TEXT, + DEFAULT_CELL_UPLOADING_TEXT, +} from '@/components/MultiDropzone/constants' +import { + UploadFileCellProps, + UploadStatus, +} from '@/components/MultiDropzone/types' + +/** + * The UploadFileCell component. + * @param {UploadFileCellProps} props - The input props for the component + * @param {UploadedFile} props.file - The file to render + * @param {boolean} props.uploading - Whether or not the file is uploading + * @param {UploadStatus} props.uploadStatus - The upload status of the file + * @param {Function} props.onRemoveFile - The callback for when the file is removed + * @returns {React.FC} - The UploadFileCell component + */ +const UploadFileCell: React.FC = ({ + file, + uploading, + uploadStatus, + onRemoveFile, +}) => { + const { + error, + id, + name, + progress, + showLoadingSpinner = false, + showProgressBar = true, + } = file + + const { + hasError, + isComplete, + isUploading, + fileIcon, + }: { + hasError: boolean + isComplete: boolean + isUploading: boolean + fileIcon: ReactNode + } = useMemo( + () => ({ + hasError: uploadStatus === UploadStatus.ERROR, + isComplete: uploadStatus === UploadStatus.COMPLETE, + isUploading: uploadStatus === UploadStatus.UPLOADING && uploading, + fileIcon: { + UPLOADING: null, + COMPLETE: , + ERROR: , + }[uploadStatus], + }), + [uploadStatus, uploading] + ) + + const mapDisplayText: { [s in UploadStatus]: string } = useMemo( + () => ({ + UPLOADING: DEFAULT_CELL_UPLOADING_TEXT, + COMPLETE: DEFAULT_CELL_UPLOADED_TEXT, + ERROR: error ?? DEFAULT_CELL_ERROR_TEXT, + }), + [error] + ) + + const handleRemoveFile = useCallback( + (event: SyntheticEvent) => { + event.stopPropagation() + onRemoveFile(id) + }, + [id, onRemoveFile] + ) + + return ( + + + + {name} + + {!hasError && + uploading && + progress < 100 && + mapDisplayText[uploadStatus]} + {hasError && mapDisplayText[uploadStatus]} + + {isUploading && showProgressBar && ( + + + + )} + + + + {isUploading ? ( + + {showLoadingSpinner && ( + + )} + + ) : ( + + {isComplete && } + {isUploading && fileIcon} + {!isUploading && ( + + {hasError ? ( + + ) : !isComplete ? ( + + ) : null} + + )} + + )} + + + ) +} + +UploadFileCell.displayName = 'UploadFileCell' + +export default UploadFileCell diff --git a/src/components/MultiDropzone/constants.ts b/src/components/MultiDropzone/constants.ts new file mode 100644 index 0000000..d42ebfb --- /dev/null +++ b/src/components/MultiDropzone/constants.ts @@ -0,0 +1,31 @@ +/** + * Constants for the MultiDropzone component. + * @module sbom-harbor-ui/components/MultiDropzone/constants + */ +import { FileType } from '@/components/MultiDropzone/types' +import { formatMimeType } from '@/components/MultiDropzone/utils' + +//* Constants for the MultiDropzone component. +// The default text shown in the uploading state. +export const DEFAULT_UPLOADING_TEXT = 'Please wait while uploading file...' +// The default text for the error shown if too many files are selected. +export const DEFAULT_TOO_MANY_FILES_ERROR = 'Too many files.' + +//* Constants for the UploadFileCell component. +// The default text shown when the file is uploaded. +export const DEFAULT_CELL_UPLOADED_TEXT = 'Uploaded' +// The default text shown when the file is uploading. +export const DEFAULT_CELL_UPLOADING_TEXT = 'Uploading...' +// The default text shown when the file upload fails. +export const DEFAULT_CELL_ERROR_TEXT = 'Something went wrong. Try again.' + +//* Constants for the file types. +const SOURCE_CODE_FILES: FileType[] = ['json'] +const DOCUMENT_FILES: FileType[] = ['doc', 'docx', 'pdf'] +const IMAGE_FILES: FileType[] = ['heic', 'bmp', 'jpeg', 'jpg', 'png'] +// The default source code file types accepted. +export const SOURCE_CODE_FILES_ACCEPT = formatMimeType(SOURCE_CODE_FILES) +// The default document file types accepted. +export const DOCUMENT_FILES_ACCEPT = formatMimeType(DOCUMENT_FILES) +// The default image file types accepted. +export const IMAGE_FILES_ACCEPT = formatMimeType(IMAGE_FILES) diff --git a/src/components/MultiDropzone/types.ts b/src/components/MultiDropzone/types.ts new file mode 100644 index 0000000..26a052d --- /dev/null +++ b/src/components/MultiDropzone/types.ts @@ -0,0 +1,76 @@ +/** + * MultiDropzone types + * @module sbom-harbor-ui/components/MultiDropzone/types + */ +import { Accept } from 'react-dropzone' + +export enum FileMimeTypes { + bmp = 'image/bmp', + doc = 'application/msword', + docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + heic = 'image/heic', + jpeg = 'image/jpeg', + jpg = 'image/jpg', + pdf = 'application/pdf', + png = 'image/png', + tif = 'image/tiff', + webp = 'image/webp', + json = 'application/json', +} + +export type FileType = keyof typeof FileMimeTypes + +export enum UploadStatus { + UPLOADING = 'UPLOADING', + COMPLETE = 'COMPLETE', + ERROR = 'ERROR', +} +export interface UploadedFile { + id: string + name: string + previewUrl?: string + progress: number + error?: string + showProgressBar?: boolean + showLoadingSpinner?: boolean +} + +export type AcceptType = 'source' | 'document' | 'image' | Accept + +export interface TextOverrides { + currentlyUploadingText?: string + fileTypeError?: string + fileTooLargeError?: string + instructionsText?: string + sizeUpToText?: string + supportsText?: string + supportsTextShort?: string + tooManyFilesError?: string +} +export interface ErrorMessage { + id: string + message: string +} + +export interface MultiDropzoneStyleProps { + isCondensed?: boolean + multiple?: boolean + textOverrides?: TextOverrides + uploading: boolean +} + +export interface MultiDropzoneProps extends MultiDropzoneStyleProps { + accept?: AcceptType + maxFiles?: number + maxSize?: number + onFileSelect: (files: File[]) => void + onRemoveFile: (id: string) => void + uploadedFiles: UploadedFile[] +} + +export interface UploadFileCellProps { + file: UploadedFile + onRemoveFile: (id: string) => void + uploading: boolean + uploadStatus: UploadStatus +} diff --git a/src/components/MultiDropzone/utils.test.ts b/src/components/MultiDropzone/utils.test.ts new file mode 100644 index 0000000..f4bbe03 --- /dev/null +++ b/src/components/MultiDropzone/utils.test.ts @@ -0,0 +1,190 @@ +import { Accept, ErrorCode, FileError } from 'react-dropzone' +import { + DOCUMENT_FILES_ACCEPT, + IMAGE_FILES_ACCEPT, + SOURCE_CODE_FILES_ACCEPT, +} from '@/components/MultiDropzone/constants' +import { + getUploadStatus, + formatMimeType, + getFormattedAcceptObject, + formatAcceptFileList, + getErrorMessage, +} from '@/components/MultiDropzone/utils' +import { FileType, UploadStatus } from '@/components/MultiDropzone/types' + +describe('MultiDropzone utils', () => { + describe('getUploadStatus', () => { + it('should return ERROR if error is present', () => { + expect(getUploadStatus(0, 'error')).toEqual(UploadStatus.ERROR) + }) + + it('should return UPLOADING if progress is less than 100', () => { + expect(getUploadStatus(50)).toEqual(UploadStatus.UPLOADING) + }) + + it('should return COMPLETE if progress is 100 and no error', () => { + expect(getUploadStatus(100)).toEqual(UploadStatus.COMPLETE) + }) + }) + + describe('formatMimeType', () => { + it('should return correct mime types', () => { + const fileType: FileType[] = ['json'] + const expected: Accept = { + 'application/json': ['.json'], + } + expect(formatMimeType(fileType)).toEqual(expected) + }) + }) + + describe('getFormattedAcceptObject', () => { + it('returns SOURCE_CODE_FILES_ACCEPT for "source"', () => { + expect(getFormattedAcceptObject('source')).toBe(SOURCE_CODE_FILES_ACCEPT) + }) + + it('returns DOCUMENT_FILES_ACCEPT for "document"', () => { + expect(getFormattedAcceptObject('document')).toBe(DOCUMENT_FILES_ACCEPT) + }) + + it('returns IMAGE_FILES_ACCEPT for "image"', () => { + expect(getFormattedAcceptObject('image')).toBe(IMAGE_FILES_ACCEPT) + }) + + it('returns the same object for any other input', () => { + const acceptObject = { example: ['.ext'] } + expect(getFormattedAcceptObject(acceptObject)).toBe(acceptObject) + }) + + it('returns an empty object when no argument is provided', () => { + expect(getFormattedAcceptObject()).toEqual({}) + }) + }) + + describe('formatAcceptFileList', () => { + it('should return formatted accept object', () => { + const accept: Accept = { + 'application/json': ['.json'], + } + expect(formatAcceptFileList(accept)).toEqual('JSON') + }) + }) + + describe('getErrorMessage', () => { + it('returns custom error message for invalid file type', () => { + const error: FileError = { + code: ErrorCode.FileInvalidType, + message: 'Invalid file type', + } + const params = { fileList: 'jpg, png', maxSize: 20000 } + const textOverrides = { + fileTypeError: 'Please upload a valid file type:', + } + + expect(getErrorMessage(error, params, textOverrides)).toBe( + 'Please upload a valid file type: jpg, png.' + ) + }) + + it('returns default error message if fileTypeError is not provided in textOverrides', () => { + const error: FileError = { + code: ErrorCode.FileInvalidType, + message: 'Invalid file type', + } + const params = { fileList: 'jpg, png', maxSize: 20000 } + expect(getErrorMessage(error, params)).toBe( + 'Only the following file types are accepted: jpg, png.' + ) + }) + + it('returns custom error message for file too large', () => { + const error: FileError = { + code: ErrorCode.FileTooLarge, + message: 'File too large', + } + const params = { fileList: 'jpg, png', maxSize: 20000 } + const textOverrides = { + fileTooLargeError: 'File is too large. It should be less than:', + } + expect(getErrorMessage(error, params, textOverrides)).toContain( + 'File is too large. It should be less than:' + ) + }) + + it('returns custom error message for invalid file type with default fileList', () => { + const error: FileError = { + code: ErrorCode.FileInvalidType, + message: 'Invalid file type', + } + const params = { maxSize: 20000, fileList: 'json' } + const textOverrides = { + fileTypeError: 'Please upload a valid file type:', + } + expect(getErrorMessage(error, params, textOverrides)).toBe( + 'Please upload a valid file type: json.' + ) + }) + + it('returns custom error message for file too large with default maxSize', () => { + const error: FileError = { + code: ErrorCode.FileTooLarge, + message: 'File too large', + } + const params = { fileList: 'jpg, png' } + const textOverrides = { + fileTooLargeError: 'File is too large. It should be less than:', + } + expect(getErrorMessage(error, params, textOverrides)).toContain( + 'File is too large. It should be less than: 0 Bytes.' + ) + }) + + it('returns default error message if fileTooLargeError is not provided in textOverrides', () => { + const error: FileError = { + code: ErrorCode.FileTooLarge, + message: 'File too large', + } + const params = { fileList: 'jpg, png', maxSize: 20000 } + expect(getErrorMessage(error, params)).toContain( + 'File is too large. It must be less than 20 KB.' + ) + }) + + it('returns default error message for file too large with maxSize formatted', () => { + const error: FileError = { + code: ErrorCode.FileTooLarge, + message: 'File too large', + } + const params = { maxSize: 20000 } + expect(getErrorMessage(error, params)).toContain( + 'File is too large. It must be less than 20 KB.' + ) + }) + + it('returns default error message for invalid file type with no fileList', () => { + const error: FileError = { + code: ErrorCode.FileInvalidType, + message: 'Invalid file type', + } + const params = { fileList: '', maxSize: 20000 } + expect(getErrorMessage(error, params)).toBe( + 'Only the following file types are accepted: NONE.' + ) + }) + + it('returns default error message if error code does not match', () => { + const error: FileError = { + code: 'unknown_error', + message: 'Unknown error', + } + const params = {} + expect(getErrorMessage(error, params)).toBe('Unknown error') + }) + + it('returns default error message if error code does not match and no error message', () => { + const error: FileError = { code: 'unknown_error', message: '' } + const params = {} + expect(getErrorMessage(error, params)).toBe('') + }) + }) +}) diff --git a/src/components/MultiDropzone/utils.ts b/src/components/MultiDropzone/utils.ts new file mode 100644 index 0000000..e02799b --- /dev/null +++ b/src/components/MultiDropzone/utils.ts @@ -0,0 +1,106 @@ +/** + * Utility functions for the MultiDropzone component. + * @module sbom-harbor-ui/components/MultiDropzone/utils + * @exports formatAcceptFileList + * @exports getUploadStatus + * @exports formatMimeType + * @exports getFormattedAcceptObject + * @exports getErrorMessage + */ +import { Accept, ErrorCode, FileError } from 'react-dropzone' +import formatBytes from '@/utils/formatBytes' +import { + DOCUMENT_FILES_ACCEPT, + IMAGE_FILES_ACCEPT, + SOURCE_CODE_FILES_ACCEPT, +} from '@/components/MultiDropzone/constants' +import { + AcceptType, + FileMimeTypes, + FileType, + TextOverrides, + UploadStatus, +} from '@/components/MultiDropzone/types' + +/** + * Get the upload status based on the progress and error. + * @param {number} progress - The progress of the upload from 0 to 100 + * @param {string} error - The error message if the upload failed + * @returns {UploadStatus} - The upload status enum value + */ +export const getUploadStatus = ( + progress: number, + error?: string +): UploadStatus => { + if (error) return UploadStatus.ERROR + if (progress < 100) return UploadStatus.UPLOADING + return UploadStatus.COMPLETE +} + +/** + * Format mime types to be used in the accept prop. + * @param {FileType[]} values - The file types to format + * @returns {Accept} - The formatted accept object + */ +export const formatMimeType = (values: FileType[]): Accept => { + const formatedValues = {} as Accept + values.forEach( + (value) => (formatedValues[FileMimeTypes[value]] = [`.${value}`]) + ) + return formatedValues +} + +/** + * Get the file types that are accepted for upload from mime types. + * @param {AcceptType} accept - The accept type to format + * @returns {Accept} - The formatted accept object + */ +export const getFormattedAcceptObject = (accept: AcceptType = {}): Accept => { + if (accept === 'source') return SOURCE_CODE_FILES_ACCEPT + if (accept === 'document') return DOCUMENT_FILES_ACCEPT + if (accept === 'image') return IMAGE_FILES_ACCEPT + return accept +} + +/** + * Concatenate the accepted file values as a string for display. + * @param {Accept} accept - The accept object to format + * @returns {string} - The formatted accept object + */ +export const formatAcceptFileList = (accept: Accept): string => + Object.values(accept) + .reduce((acc, value) => [...acc, ...value], []) + .join(', ') + .replace(/\./g, '') + .toUpperCase() + +/** + * Get the error message to display based on the error code. + * @param {FileError} error - The error object + * @param {{fileList?: string; maxSize?: number}} - The file list and max size + * @param {string} fileList - The list of file types that are accepted + * @param {number} maxSize - The max size of the file in bytes + * @param {TextOverrides} textOverrides - The text overrides for the component + * @returns {string} - The formatted error message + * @todo - Add support for multiple errors + */ +export const getErrorMessage = ( + { code, message }: FileError, + { fileList = '', maxSize }: { fileList?: string; maxSize?: number }, + textOverrides?: TextOverrides +): string => { + switch (code) { + case ErrorCode.FileInvalidType: + return `${ + textOverrides?.fileTypeError || + 'Only the following file types are accepted:' + } ${fileList || 'NONE'}.` + case ErrorCode.FileTooLarge: + return `${ + textOverrides?.fileTooLargeError || + 'File is too large. It must be less than' + } ${formatBytes(maxSize || 0)}.` + default: + return message + } +}