From c3dfbe044fd18e084cd411ce0ff84dcab1a80224 Mon Sep 17 00:00:00 2001 From: Jordan Van Ness Date: Wed, 4 Sep 2024 16:16:39 -0700 Subject: [PATCH] Copy storagemanager as fileuploader (#5744) * chore: copy StorageManager component, renaming copy FileUploader * chore: add FileUploader to component class names * chore: renaming internal FileUploader references to use its own name * chore: adding test for default prop in FileControl * chore: rename test file to use FileUploader reference * chore: add changeset * fix: adding reference to correct component class in test * Update .changeset/small-masks-act.md Co-authored-by: Scott Rees <6165315+reesscot@users.noreply.github.com> --------- Co-authored-by: Scott Rees <6165315+reesscot@users.noreply.github.com> --- .changeset/small-masks-act.md | 6 + docs/src/components/ComponentsMetadata.ts | 77 +++ .../components/FileUploader/FileUploader.tsx | 322 +++++++++++ .../__tests__/FileUploader.test.tsx | 498 ++++++++++++++++++ .../__snapshots__/FileUploader.test.tsx.snap | 113 ++++ .../components/FileUploader/hooks/index.ts | 2 + .../useFileUploader/__tests__/actions.test.ts | 103 ++++ .../useFileUploader/__tests__/reducer.test.ts | 399 ++++++++++++++ .../__tests__/useFileUploader.test.ts | 209 ++++++++ .../hooks/useFileUploader/actions.ts | 69 +++ .../hooks/useFileUploader/index.ts | 1 + .../hooks/useFileUploader/reducer.ts | 110 ++++ .../hooks/useFileUploader/types.ts | 63 +++ .../hooks/useFileUploader/useFileUploader.ts | 120 +++++ .../__tests__/useUploadFiles.spec.ts | 217 ++++++++ .../hooks/useUploadFiles/index.ts | 1 + .../hooks/useUploadFiles/useUploadFiles.ts | 127 +++++ .../src/components/FileUploader/index.ts | 10 + .../src/components/FileUploader/types.ts | 145 +++++ .../FileUploader/ui/Container/Container.tsx | 15 + .../ui/Container/__tests__/Container.spec.tsx | 30 ++ .../__snapshots__/Container.spec.tsx.snap | 13 + .../FileUploader/ui/Container/index.ts | 1 + .../FileUploader/ui/DropZone/DropZone.tsx | 52 ++ .../ui/DropZone/__tests__/DropZone.test.tsx | 94 ++++ .../__snapshots__/DropZone.test.tsx.snap | 59 +++ .../FileUploader/ui/DropZone/index.ts | 2 + .../FileUploader/ui/DropZone/types.ts | 13 + .../FileUploader/ui/FileList/FileControl.tsx | 84 +++ .../FileUploader/ui/FileList/FileDetails.tsx | 22 + .../FileUploader/ui/FileList/FileList.tsx | 88 ++++ .../ui/FileList/FileRemoveButton.tsx | 23 + .../ui/FileList/FileStatusMessage.tsx | 71 +++ .../ui/FileList/FileThumbnail.tsx | 25 + .../FileList/__tests__/FileControl.test.tsx | 115 ++++ .../FileList/__tests__/FileDetails.test.tsx | 45 ++ .../ui/FileList/__tests__/FileList.test.tsx | 121 +++++ .../__tests__/FileRemoveButton.test.tsx | 61 +++ .../__tests__/FileStatusMessage.test.tsx | 108 ++++ .../FileList/__tests__/FileThumbnail.test.tsx | 68 +++ .../__snapshots__/FileControl.test.tsx.snap | 357 +++++++++++++ .../__snapshots__/FileDetails.test.tsx.snap | 20 + .../__snapshots__/FileList.test.tsx.snap | 324 ++++++++++++ .../FileRemoveButton.test.tsx.snap | 61 +++ .../FileStatusMessage.test.tsx.snap | 106 ++++ .../__snapshots__/FileThumbnail.test.tsx.snap | 53 ++ .../FileUploader/ui/FileList/index.ts | 2 + .../FileUploader/ui/FileList/types.ts | 59 +++ .../ui/FileListFooter/FileListFooter.tsx | 33 ++ .../FileUploader/ui/FileListFooter/index.ts | 1 + .../ui/FileListHeader/FileListHeader.tsx | 33 ++ .../__tests__/FileListHeader.test.tsx | 52 ++ .../FileListHeader.test.tsx.snap | 21 + .../FileUploader/ui/FileListHeader/index.ts | 1 + .../FileUploader/ui/FilePicker/FilePicker.tsx | 19 + .../FilePicker/__tests__/FilePicker.test.tsx | 28 + .../__snapshots__/FilePicker.test.tsx.snap | 12 + .../FileUploader/ui/FilePicker/index.ts | 1 + .../src/components/FileUploader/ui/index.ts | 6 + .../utils/__tests__/checkMaxFileSize.test.ts | 38 ++ .../__tests__/filterAllowedFiles.test.ts | 72 +++ .../utils/__tests__/getInput.spec.ts | 284 ++++++++++ .../utils/__tests__/uploadFile.test.ts | 161 ++++++ .../FileUploader/utils/checkMaxFileSize.ts | 17 + .../FileUploader/utils/displayText.ts | 63 +++ .../FileUploader/utils/filterAllowedFiles.ts | 30 ++ .../components/FileUploader/utils/getInput.ts | 77 +++ .../components/FileUploader/utils/index.ts | 16 + .../FileUploader/utils/resolveFile.ts | 23 + .../FileUploader/utils/uploadFile.ts | 76 +++ .../StorageManager/StorageManager.tsx | 6 + .../FileList/__tests__/FileControl.test.tsx | 15 + .../__snapshots__/FileControl.test.tsx.snap | 105 ++++ .../react-storage/src/components/index.ts | 2 + packages/react-storage/src/index.ts | 2 + .../ui/src/theme/components/fileUploader.ts | 28 + .../types/primitives/componentClassName.ts | 19 + .../ui/src/utils/setUserAgent/constants.ts | 8 + .../ui/src/utils/setUserAgent/setUserAgent.ts | 9 + 79 files changed, 5942 insertions(+) create mode 100644 .changeset/small-masks-act.md create mode 100644 packages/react-storage/src/components/FileUploader/FileUploader.tsx create mode 100644 packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/__tests__/__snapshots__/FileUploader.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/hooks/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/actions.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/reducer.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/useFileUploader.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/actions.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/reducer.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/types.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useFileUploader/useFileUploader.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts create mode 100644 packages/react-storage/src/components/FileUploader/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/types.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/Container/Container.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/Container/__tests__/Container.spec.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/Container/__tests__/__snapshots__/Container.spec.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/Container/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/DropZone/DropZone.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/DropZone.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/DropZone/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/DropZone/types.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileControl.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileDetails.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileList.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileRemoveButton.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileStatusMessage.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/FileThumbnail.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileControl.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileDetails.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileList.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileRemoveButton.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileStatusMessage.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileThumbnail.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileDetails.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileList.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileRemoveButton.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileStatusMessage.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileThumbnail.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileList/types.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListFooter/FileListFooter.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListFooter/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListHeader/FileListHeader.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/FileListHeader.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/__snapshots__/FileListHeader.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FileListHeader/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/FilePicker/FilePicker.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/FilePicker.test.tsx create mode 100644 packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/__snapshots__/FilePicker.test.tsx.snap create mode 100644 packages/react-storage/src/components/FileUploader/ui/FilePicker/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/ui/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/__tests__/checkMaxFileSize.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/__tests__/filterAllowedFiles.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/__tests__/uploadFile.test.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/checkMaxFileSize.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/displayText.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/filterAllowedFiles.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/getInput.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/index.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/resolveFile.ts create mode 100644 packages/react-storage/src/components/FileUploader/utils/uploadFile.ts create mode 100644 packages/ui/src/theme/components/fileUploader.ts diff --git a/.changeset/small-masks-act.md b/.changeset/small-masks-act.md new file mode 100644 index 00000000000..a1bbb7565ca --- /dev/null +++ b/.changeset/small-masks-act.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/ui-react-storage': minor +'@aws-amplify/ui': minor +--- + +Adds FileUploader as new default name of StorageManager to avoid confusion with new components diff --git a/docs/src/components/ComponentsMetadata.ts b/docs/src/components/ComponentsMetadata.ts index 37abe03d6b9..25a188db712 100644 --- a/docs/src/components/ComponentsMetadata.ts +++ b/docs/src/components/ComponentsMetadata.ts @@ -19,6 +19,7 @@ type ComponentNameKey = | 'Divider' | 'DropZone' | 'Fieldset' + | 'FileUploader' | 'Flex' | 'Grid' | 'Heading' @@ -317,6 +318,82 @@ export const ComponentsMetadata: ComponentClassNameItems = { components: ['Fieldset'], description: 'Visual label for the Fieldset primitive', }, + FileUploader: { + className: ComponentClassName.FileUploader, + components: ['FileUploader'], + }, + FileUploaderDropZone: { + className: ComponentClassName.FileUploaderDropZone, + components: ['FileUploader'], + }, + FileUploaderDropZoneIcon: { + className: ComponentClassName.FileUploaderDropZoneIcon, + components: ['FileUploader'], + }, + FileUploaderDropZoneText: { + className: ComponentClassName.FileUploaderDropZoneText, + components: ['FileUploader'], + }, + FileUploaderFilePicker: { + className: ComponentClassName.FileUploaderFilePicker, + components: ['FileUploader'], + }, + FileUploaderFile: { + className: ComponentClassName.FileUploaderFile, + components: ['FileUploader'], + }, + FileUploaderFileWrapper: { + className: ComponentClassName.FileUploaderFileWrapper, + components: ['FileUploader'], + }, + FileUploaderFileList: { + className: ComponentClassName.FileUploaderFileList, + components: ['FileUploader'], + }, + FileUploaderFileName: { + className: ComponentClassName.FileUploaderFileName, + components: ['FileUploader'], + }, + FileUploaderLoader: { + className: ComponentClassName.FileUploaderLoader, + components: ['FileUploader'], + }, + FileUploaderFileSize: { + className: ComponentClassName.FileUploaderFileSize, + components: ['FileUploader'], + }, + FileUploaderFileInfo: { + className: ComponentClassName.FileUploaderFileInfo, + components: ['FileUploader'], + }, + FileUploaderFileImage: { + className: ComponentClassName.FileUploaderFileImage, + components: ['FileUploader'], + }, + FileUploaderFileMain: { + className: ComponentClassName.FileUploaderFileMain, + components: ['FileUploader'], + }, + FileUploaderFileStatus: { + className: ComponentClassName.FileUploaderFileStatus, + components: ['FileUploader'], + }, + FileUploaderPreviewer: { + className: ComponentClassName.FileUploaderPreviewer, + components: ['FileUploader'], + }, + FileUploaderPreviewerText: { + className: ComponentClassName.FileUploaderPreviewerText, + components: ['FileUploader'], + }, + FileUploaderPreviewerActions: { + className: ComponentClassName.FileUploaderPreviewerActions, + components: ['FileUploader'], + }, + FileUploaderPreviewerFooter: { + className: ComponentClassName.FileUploaderPreviewerFooter, + components: ['FileUploader'], + }, Flex: { className: ComponentClassName.Flex, components: ['Flex'], diff --git a/packages/react-storage/src/components/FileUploader/FileUploader.tsx b/packages/react-storage/src/components/FileUploader/FileUploader.tsx new file mode 100644 index 00000000000..34b396b2bde --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/FileUploader.tsx @@ -0,0 +1,322 @@ +import * as React from 'react'; + +import { getLogger, ComponentClassName } from '@aws-amplify/ui'; +import { VisuallyHidden } from '@aws-amplify/ui-react'; +import { + useDeprecationWarning, + useSetUserAgent, +} from '@aws-amplify/ui-react-core'; +import { useDropZone } from '@aws-amplify/ui-react/internal'; + +import { useFileUploader, useUploadFiles } from './hooks'; +import { + FileStatus, + FileUploaderProps, + FileUploaderPathProps, + FileUploaderHandle, +} from './types'; +import { + Container, + DropZone, + FileList, + FileListHeader, + FileListFooter, + FilePicker, +} from './ui'; +import { + checkMaxFileSize, + defaultFileUploaderDisplayText, + filterAllowedFiles, + TaskHandler, +} from './utils'; +import { VERSION } from '../../version'; + +const logger = getLogger('Storage'); + +export const MISSING_REQUIRED_PROPS_MESSAGE = + '`FileUploader` requires a `maxFileCount` prop to be provided.'; +export const ACCESS_LEVEL_WITH_PATH_CALLBACK_MESSAGE = + '`FileUploader` does not allow usage of a `path` callback prop with an `accessLevel` prop.'; +export const ACCESS_LEVEL_DEPRECATION_MESSAGE = + '`accessLevel` has been deprecated and will be removed in a future major version. See migration notes at https://ui.docs.amplify.aws/react/connected-components/storage/FileUploader'; + +const FileUploaderBase = React.forwardRef(function FileUploader( + { + acceptedFileTypes = [], + accessLevel, + autoUpload = true, + components, + defaultFiles, + displayText: overrideDisplayText, + isResumable = false, + maxFileCount, + maxFileSize, + onFileRemove, + onUploadError, + onUploadStart, + onUploadSuccess, + path, + processFile, + showThumbnails = true, + useAccelerateEndpoint, + }: FileUploaderPathProps | FileUploaderProps, + ref: React.ForwardedRef +): JSX.Element { + if (!maxFileCount) { + // eslint-disable-next-line no-console + console.warn(MISSING_REQUIRED_PROPS_MESSAGE); + } + + if (accessLevel && typeof path === 'function') { + throw new Error(ACCESS_LEVEL_WITH_PATH_CALLBACK_MESSAGE); + } + + useDeprecationWarning({ + message: ACCESS_LEVEL_DEPRECATION_MESSAGE, + shouldWarn: !!accessLevel, + }); + + const Components = { + Container, + DropZone, + FileList, + FilePicker, + FileListHeader, + FileListFooter, + ...components, + }; + + const allowMultipleFiles = + maxFileCount === undefined || + (typeof maxFileCount === 'number' && maxFileCount > 1); + + const displayText = { + ...defaultFileUploaderDisplayText, + ...overrideDisplayText, + }; + + const { getFileSizeErrorText } = displayText; + + const getMaxFileSizeErrorMessage = (file: File): string => { + return checkMaxFileSize({ + file, + maxFileSize, + getFileSizeErrorText, + }); + }; + + const { + addFiles, + clearFiles, + files, + removeUpload, + queueFiles, + setProcessedKey, + setUploadingFile, + setUploadPaused, + setUploadProgress, + setUploadSuccess, + setUploadResumed, + } = useFileUploader(defaultFiles); + + React.useImperativeHandle(ref, () => ({ clearFiles })); + + const { dragState, ...dropZoneProps } = useDropZone({ + acceptedFileTypes, + onDropComplete: ({ acceptedFiles, rejectedFiles }) => { + if (rejectedFiles && rejectedFiles.length > 0) { + logger.warn('Rejected files: ', rejectedFiles); + } + // We need to filter out files by extension here, + // we don't get filenames on the drag event, only on drop + const _acceptedFiles = filterAllowedFiles( + acceptedFiles, + acceptedFileTypes + ); + addFiles({ + files: _acceptedFiles, + status: autoUpload ? FileStatus.QUEUED : FileStatus.ADDED, + getFileErrorMessage: getMaxFileSizeErrorMessage, + }); + }, + }); + + useUploadFiles({ + accessLevel, + files, + isResumable, + maxFileCount, + onUploadError, + onUploadSuccess, + onUploadStart, + onProcessFileSuccess: setProcessedKey, + setUploadingFile, + setUploadProgress, + setUploadSuccess, + processFile, + path, + useAccelerateEndpoint, + }); + + const onFilePickerChange = (event: React.ChangeEvent) => { + const { files } = event.target; + if (!files || files.length === 0) { + return; + } + + addFiles({ + files: Array.from(files), + status: autoUpload ? FileStatus.QUEUED : FileStatus.ADDED, + getFileErrorMessage: getMaxFileSizeErrorMessage, + }); + }; + + const onClearAll = () => { + clearFiles(); + }; + + const onUploadAll = () => { + queueFiles(); + }; + + const onPauseUpload: TaskHandler = ({ id, uploadTask }) => { + uploadTask.pause(); + setUploadPaused({ id }); + }; + + const onResumeUpload: TaskHandler = ({ id, uploadTask }) => { + uploadTask.resume(); + setUploadResumed({ id }); + }; + + const onCancelUpload: TaskHandler = ({ id, uploadTask }) => { + // At this time we don't know if the delete + // permissions are enabled (required to cancel upload), + // so we do a pause instead and remove from files + uploadTask.pause(); + removeUpload({ id }); + }; + + const onDeleteUpload = ({ id }: { id: string }) => { + // At this time we don't know if the delete + // permissions are enabled, so we do a soft delete + // from file list, but don't remove from storage + removeUpload({ id }); + if (typeof onFileRemove === 'function') { + const file = files.find((file) => file.id === id); + if (file) { + // return `processedKey` if available and `processFile` is provided + const key = (processFile && file?.processedKey) ?? file.key; + onFileRemove({ key }); + } + } + }; + + // checks if all downloads completed to 100% + const allUploadsSuccessful = + files.length === 0 + ? false + : files.every((file) => file?.status === FileStatus.UPLOADED); + + // Displays if over max files + const hasMaxFilesError = + files.filter((file) => file.progress < 100).length > maxFileCount; + + const uploadedFilesLength = files.filter( + (file) => file?.status === FileStatus.UPLOADED + ).length; + + const remainingFilesCount = files.length - uploadedFilesLength; + + // number of files selected for upload when autoUpload is turned off + const selectedFilesCount = autoUpload ? 0 : remainingFilesCount; + + const hasFiles = files.length > 0; + + const hasUploadActions = !autoUpload && remainingFilesCount > 0; + + const hiddenInput = React.useRef(null); + function handleClick() { + if (hiddenInput.current) { + hiddenInput.current.click(); + hiddenInput.current.value = ''; + } + } + + useSetUserAgent({ + componentName: 'FileUploader', + packageName: 'react-storage', + version: VERSION, + }); + + return ( + + + <> + + {displayText.browseFilesText} + + + + + + + {hasFiles ? ( + + ) : null} + + {hasUploadActions ? ( + + ) : null} + + ); +}); + +// pass an empty object as first param to avoid destructive action on `FileUploaderBase` +const FileUploader = Object.assign({}, FileUploaderBase, { + Container, + DropZone, + FileList, + FileListHeader, + FileListFooter, + FilePicker, +}); + +export { FileUploader }; diff --git a/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx b/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx new file mode 100644 index 00000000000..abc5533c1ed --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx @@ -0,0 +1,498 @@ +import React from 'react'; +import { + fireEvent, + render, + waitFor, + act, + getByTestId, +} from '@testing-library/react'; +import * as Storage from 'aws-amplify/storage'; + +import { ComponentClassName } from '@aws-amplify/ui'; + +import * as StorageHooks from '../hooks'; +import { + FileUploader, + MISSING_REQUIRED_PROPS_MESSAGE, + ACCESS_LEVEL_DEPRECATION_MESSAGE, +} from '../FileUploader'; +import { FileUploaderProps, FileUploaderHandle, FileStatus } from '../types'; +import { defaultFileUploaderDisplayText } from '../utils'; + +const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + +const uploadDataSpy = jest + .spyOn(Storage, 'uploadData') + .mockImplementation((input) => ({ + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'SUCCESS', + result: Promise.resolve({ key: input.key, data: input.data }), + })); + +const fileUploaderProps: FileUploaderProps = { + accessLevel: 'guest', + maxFileCount: 100, +}; + +describe('FileUploader', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('behaves as expected with an accessLevel prop', () => { + const { container, getByText } = render( + + ); + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZone}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZoneText}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZoneIcon}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFilePicker}` + ) + ).toHaveLength(1); + + expect( + getByText(defaultFileUploaderDisplayText.browseFilesText) + ).toBeVisible(); + expect( + getByText(defaultFileUploaderDisplayText.dropFilesText) + ).toBeVisible(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('behaves as expected with a path prop', () => { + const { container, getByText } = render( + 'my-path'} /> + ); + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZone}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZoneText}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderDropZoneIcon}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFilePicker}` + ) + ).toHaveLength(1); + + expect( + getByText(defaultFileUploaderDisplayText.browseFilesText) + ).toBeVisible(); + expect( + getByText(defaultFileUploaderDisplayText.dropFilesText) + ).toBeVisible(); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('renders as expected with autoUpload turned off', () => { + const { getByText } = render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + const mockFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + fireEvent.change(hiddenInput, { target: { files: [mockFile] } }); + + expect( + getByText(defaultFileUploaderDisplayText.clearAllButtonText) + ).toBeVisible(); + expect( + getByText(defaultFileUploaderDisplayText.getSelectedFilesText(1)) + ).toBeVisible(); + }); + + it('renders as expected with override display text', () => { + const displayText = { + ...defaultFileUploaderDisplayText, + dropFilesText: 'Drag and drop files here, or click to select files', + browseFilesText: 'Select Files', + }; + const { getByText } = render( + + ); + expect( + getByText('Drag and drop files here, or click to select files') + ).toBeVisible(); + expect(getByText('Select Files')).toBeVisible(); + }); + + it('displays error message when file exceeds max file size', () => { + const maxFileSize = 0; + const { getByText } = render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + + const mockFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + fireEvent.change(hiddenInput, { target: { files: [mockFile] } }); + + expect( + getByText(defaultFileUploaderDisplayText.getFileSizeErrorText('0 B')) + ).toBeVisible(); + }); + + it('displays error message when max file count is exceeded', () => { + const maxFileCount = 1; + const { getByText } = render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + + const mockFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + const mockFile2 = new File(['hello2'], 'hello2.png', { type: 'image/png' }); + fireEvent.change(hiddenInput, { target: { files: [mockFile, mockFile2] } }); + + expect( + getByText( + defaultFileUploaderDisplayText.getMaxFilesErrorText(maxFileCount) + ) + ).toBeVisible(); + }); + + it('calls onUploadSuccess callback when file is successfully uploaded', async () => { + const onUploadSuccess = jest.fn(); + render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + fireEvent.change(hiddenInput, { + target: { files: [file] }, + }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalledWith({ + key: file.name, + data: file, + options: { + accessLevel: 'guest', + contentType: 'text/plain', + onProgress: expect.any(Function), + }, + }); + expect(onUploadSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onUploadStart callback when file starts uploading', async () => { + const onUploadStart = jest.fn(); + render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + fireEvent.change(hiddenInput, { + target: { files: [file] }, + }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalledWith({ + key: file.name, + data: file, + options: { + accessLevel: 'guest', + contentType: 'text/plain', + onProgress: expect.any(Function), + }, + }); + expect(onUploadStart).toHaveBeenCalledTimes(1); + }); + }); + + it('provides the correct file key on a remove file event before upload', () => { + const onFileRemove = jest.fn(); + + const { container } = render( + + ); + + const hiddenInput: HTMLInputElement = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + + const removeButton = getByTestId( + container, + 'storage-manager-remove-button' + ); + expect(removeButton).toBeDefined(); + + fireEvent.click(removeButton); + + expect(onFileRemove).toHaveBeenCalledTimes(1); + expect(onFileRemove).toHaveBeenCalledWith({ key: file.name }); + }); + + it('provides the correct file key on a remove file event after upload', async () => { + const onFileRemove = jest.fn(); + + const { container } = render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + fireEvent.change(hiddenInput, { + target: { files: [file] }, + }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalled(); + + const removeButton = getByTestId( + container, + 'storage-manager-remove-button' + ); + expect(removeButton).toBeDefined(); + + fireEvent.click(removeButton); + + expect(onFileRemove).toHaveBeenCalledTimes(1); + expect(onFileRemove).toHaveBeenCalledWith({ key: file.name }); + }); + }); + + it('provides the processed file key on a remove file event after upload when processFile is provided', async () => { + const onFileRemove = jest.fn(); + + const processedKey = 'processedKey'; + const processFile: FileUploaderProps['processFile'] = (input) => ({ + ...input, + key: processedKey, + }); + + const { container } = render( + + ); + + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalled(); + + const removeButton = getByTestId( + container, + 'storage-manager-remove-button' + ); + expect(removeButton).toBeDefined(); + + fireEvent.click(removeButton); + + expect(onFileRemove).toHaveBeenCalledTimes(1); + expect(onFileRemove).toHaveBeenCalledWith({ key: processedKey }); + }); + }); + + it('provides the processed file key on a remove file event after upload when processFile is provided with a path function', async () => { + const onFileRemove = jest.fn(); + + const processedKey = 'processedKey'; + const processFile: FileUploaderProps['processFile'] = (input) => ({ + ...input, + key: processedKey, + }); + + const { container } = render( + 'my-path'} + accessLevel={undefined} + /> + ); + + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + + fireEvent.change(hiddenInput, { target: { files: [file] } }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalled(); + + const removeButton = getByTestId( + container, + 'storage-manager-remove-button' + ); + expect(removeButton).toBeDefined(); + + fireEvent.click(removeButton); + + expect(onFileRemove).toHaveBeenCalledTimes(1); + expect(onFileRemove).toHaveBeenCalledWith({ key: processedKey }); + }); + }); + + it('logs a warning if maxFileCount is zero', () => { + render(); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy.mock.calls[0][0]).toBe(MISSING_REQUIRED_PROPS_MESSAGE); + expect(warnSpy.mock.calls[1][0]).toBe(ACCESS_LEVEL_DEPRECATION_MESSAGE); + }); + + it('logs a warning if provided an accessLevel prop', () => { + render(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toBe(ACCESS_LEVEL_DEPRECATION_MESSAGE); + }); + + it('should trigger hidden input onChange', async () => { + const mockAddFiles = jest.fn(); + jest.spyOn(StorageHooks, 'useFileUploader').mockReturnValue({ + addFiles: mockAddFiles, + files: [], + status: FileStatus.QUEUED, + } as unknown as StorageHooks.UseFileUploader); + + const { findByRole } = render(); + + const filePickerButton = await findByRole('button'); + + expect(filePickerButton).toBeInTheDocument(); + + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + + fireEvent.click(filePickerButton); + + expect(hiddenInput).toHaveValue(''); + + const mockFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + + fireEvent.change(hiddenInput, { target: { files: [mockFile] } }); + + expect(mockAddFiles).toHaveBeenCalledTimes(1); + expect(mockAddFiles).toHaveBeenCalledWith({ + files: [mockFile], + status: FileStatus.QUEUED, + getFileErrorMessage: expect.any(Function), + }); + }); + + it('clears files when imperative handle clearFiles() is called', () => { + const ref = React.createRef(); + const mockAddFiles = jest.fn(); + const mockClearFiles = jest.fn(); + jest.spyOn(StorageHooks, 'useFileUploader').mockReturnValue({ + addFiles: mockAddFiles, + clearFiles: mockClearFiles, + files: [], + } as unknown as StorageHooks.UseFileUploader); + + render(); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + fireEvent.change(hiddenInput, { + target: { files: [file] }, + }); + + expect(mockAddFiles).toHaveBeenCalledTimes(1); + expect(mockAddFiles).toHaveBeenCalledWith({ + files: [file], + status: FileStatus.QUEUED, + getFileErrorMessage: expect.any(Function), + }); + + act(() => ref.current?.clearFiles()); + expect(mockClearFiles).toHaveBeenCalledTimes(1); + expect(mockClearFiles).toHaveBeenCalledWith(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/__tests__/__snapshots__/FileUploader.test.tsx.snap b/packages/react-storage/src/components/FileUploader/__tests__/__snapshots__/FileUploader.test.tsx.snap new file mode 100644 index 00000000000..e603c4db8ce --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/__tests__/__snapshots__/FileUploader.test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileUploader behaves as expected with a path prop 1`] = ` +
+
+
+ +

+ Drop files here or +

+ + + + +
+
+
+`; + +exports[`FileUploader behaves as expected with an accessLevel prop 1`] = ` +
+
+
+ +

+ Drop files here or +

+ + + + +
+
+
+`; diff --git a/packages/react-storage/src/components/FileUploader/hooks/index.ts b/packages/react-storage/src/components/FileUploader/hooks/index.ts new file mode 100644 index 00000000000..bc728ec60ba --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/index.ts @@ -0,0 +1,2 @@ +export { useFileUploader, UseFileUploader } from './useFileUploader'; +export { useUploadFiles } from './useUploadFiles'; diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/actions.test.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/actions.test.ts new file mode 100644 index 00000000000..4b7b8b1319d --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/actions.test.ts @@ -0,0 +1,103 @@ +import { UploadDataOutput } from 'aws-amplify/storage'; + +import { FileStatus } from '../../../types'; +import { + addFilesAction, + clearFilesAction, + queueFilesAction, + removeUploadAction, + setUploadingFileAction, + setUploadProgressAction, + setUploadStatusAction, +} from '../actions'; +import { FileUploaderActionTypes } from '../types'; + +describe('addFilesAction', () => { + it('creates an action with the ADD_FILES type and the given files and error message', () => { + const files = [new File(['file contents'], 'filename')]; + const status = FileStatus.QUEUED; + const getFileErrorMessage = () => 'Something went wrong'; + const expectedAction = { + type: FileUploaderActionTypes.ADD_FILES, + files, + status, + getFileErrorMessage, + }; + const action = addFilesAction({ files, status, getFileErrorMessage }); + expect(action).toEqual(expectedAction); + }); +}); + +describe('queueFilesAction', () => { + it('creates an action with the QUEUE_FILES type', () => { + const expectedAction = { + type: FileUploaderActionTypes.QUEUE_FILES, + }; + const action = queueFilesAction(); + expect(action).toEqual(expectedAction); + }); +}); + +describe('clearFilesAction', () => { + it('creates an action with the CLEAR_FILES type', () => { + const expectedAction = { + type: FileUploaderActionTypes.CLEAR_FILES, + }; + const action = clearFilesAction(); + expect(action).toEqual(expectedAction); + }); +}); + +describe('setUploadingFileAction', () => { + it('creates an action with the SET_STATUS_UPLOADING type and the given id and upload task', () => { + const id = 'test-id'; + const uploadTask = {} as UploadDataOutput; + const expectedAction = { + type: FileUploaderActionTypes.SET_STATUS_UPLOADING, + id, + uploadTask, + }; + const action = setUploadingFileAction({ id, uploadTask }); + expect(action).toEqual(expectedAction); + }); +}); + +describe('setUploadProgressAction', () => { + it('creates an action with the SET_UPLOAD_PROGRESS type and the given id and progress', () => { + const id = 'test-id'; + const progress = 50; + const expectedAction = { + type: FileUploaderActionTypes.SET_UPLOAD_PROGRESS, + id, + progress, + }; + const action = setUploadProgressAction({ id, progress }); + expect(action).toEqual(expectedAction); + }); +}); + +describe('setUploadStatusAction', () => { + it('creates an action with the SET_STATUS type and the given file status', () => { + const id = 'test-id'; + const status = FileStatus.PAUSED; + const expectedAction = { + type: FileUploaderActionTypes.SET_STATUS, + id, + status, + }; + const action = setUploadStatusAction({ id, status }); + expect(action).toEqual(expectedAction); + }); +}); + +describe('removeUploadAction', () => { + it('creates an action with the REMOVE_UPLOAD type', () => { + const id = 'test-id'; + const expectedAction = { + type: FileUploaderActionTypes.REMOVE_UPLOAD, + id, + }; + const action = removeUploadAction({ id }); + expect(action).toEqual(expectedAction); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/reducer.test.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/reducer.test.ts new file mode 100644 index 00000000000..9086d4796ae --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/reducer.test.ts @@ -0,0 +1,399 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useReducer } from 'react'; + +import { UploadDataOutput } from 'aws-amplify/storage'; + +import { fileUploaderStateReducer } from '../reducer'; +import { + Action, + FileUploaderActionTypes, + UseFileUploaderState, +} from '../types'; +import { FileStatus, StorageFile, StorageFiles } from '../../../types'; + +const imageFile = new File(['hello'], 'hello.png', { type: 'image/png' }); +const initialState: UseFileUploaderState = { + files: [], +}; + +// mock Date.now() so we can get accurate file IDs +const dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => 1487076708000); + +describe('fileUploaderStateReducer', () => { + beforeEach(() => { + dateSpy.mockClear(); + }); + + it('should add files to state on ADD_FILES action', () => { + const addFilesAction: Action = { + type: FileUploaderActionTypes.ADD_FILES, + files: [imageFile], + status: FileStatus.QUEUED, + getFileErrorMessage: jest.fn().mockReturnValue('Test error'), + }; + + const expectedFiles: StorageFiles = [ + { + id: `${Date.now()}-${imageFile.name}`, + file: imageFile, + error: 'Test error', + key: imageFile.name, + status: FileStatus.ERROR, + isImage: true, + progress: -1, + }, + ]; + const { result } = renderHook(() => { + const [state, dispatch] = useReducer( + fileUploaderStateReducer, + initialState + ); + return { state, dispatch }; + }); + + expect(result.current.state.files).toStrictEqual([]); + + act(() => result.current.dispatch(addFilesAction)); + + expect(result.current.state.files).toStrictEqual(expectedFiles); + }); + + it('should clear files from state on CLEAR_FILES action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }, + ], + }); + return { state, dispatch }; + }); + + const clearFilesAction: Action = { + type: FileUploaderActionTypes.CLEAR_FILES, + }; + act(() => result.current.dispatch(clearFilesAction)); + + expect(result.current.state.files).toEqual([]); + }); + + it('should set uploading status and progress on SET_STATUS_UPLOADING action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.QUEUED, + isImage: true, + progress: -1, + }, + ], + }); + return { state, dispatch }; + }); + + const testUploadTask = {} as UploadDataOutput; + const uploadingAction: Action = { + type: FileUploaderActionTypes.SET_STATUS_UPLOADING, + id: imageFile.name, + uploadTask: testUploadTask, + }; + act(() => result.current.dispatch(uploadingAction)); + + const expectedFiles: StorageFiles = [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: 0, + uploadTask: testUploadTask, + }, + ]; + expect(result.current.state.files).toEqual(expectedFiles); + }); + + it('should set upload progress of a file on SET_UPLOAD_PROGRESS action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }, + ], + }); + return { state, dispatch }; + }); + + const uploadProgressAction: Action = { + type: FileUploaderActionTypes.SET_UPLOAD_PROGRESS, + id: imageFile.name, + progress: 50, + }; + act(() => result.current.dispatch(uploadProgressAction)); + + const expectedFiles: StorageFiles = [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: 50, + }, + ]; + expect(result.current.state.files).toEqual(expectedFiles); + }); + + it('should return previous state if file not found on SET_UPLOAD_PROGRESS action', () => { + const file: StorageFile = { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }; + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [file], + }); + return { state, dispatch }; + }); + + const uploadProgressAction: Action = { + type: FileUploaderActionTypes.SET_UPLOAD_PROGRESS, + id: 'not-found', + progress: 50, + }; + act(() => result.current.dispatch(uploadProgressAction)); + + expect(result.current.state.files).toEqual([file]); + }); + + it('should update the status of a file progress of a file on SET_STATUS action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }, + ], + }); + return { state, dispatch }; + }); + + const setStatusAction: Action = { + type: FileUploaderActionTypes.SET_STATUS, + id: imageFile.name, + status: FileStatus.UPLOADED, + }; + act(() => result.current.dispatch(setStatusAction)); + + const expectedFiles: StorageFiles = [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADED, + isImage: true, + progress: -1, + }, + ]; + expect(result.current.state.files).toEqual(expectedFiles); + }); + + it('should return previous state if file not found on SET_STATUS action', () => { + const file: StorageFile = { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }; + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [file], + }); + return { state, dispatch }; + }); + + const setStatusAction: Action = { + type: FileUploaderActionTypes.SET_STATUS, + id: 'not-found', + status: FileStatus.UPLOADED, + }; + act(() => result.current.dispatch(setStatusAction)); + + expect(result.current.state.files).toEqual([file]); + }); + + it('should remove file from state on REMOVE_UPLOAD action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }, + ], + }); + return { state, dispatch }; + }); + + const removeUploadAction: Action = { + type: FileUploaderActionTypes.REMOVE_UPLOAD, + id: imageFile.name, + }; + act(() => result.current.dispatch(removeUploadAction)); + + expect(result.current.state.files).toEqual([]); + }); + + it('should return previous state if file not found on REMOVE_UPLOAD action', () => { + const file: StorageFile = { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADING, + isImage: true, + progress: -1, + }; + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [file], + }); + return { state, dispatch }; + }); + + const removeUploadAction: Action = { + type: FileUploaderActionTypes.REMOVE_UPLOAD, + id: 'not-found', + }; + act(() => result.current.dispatch(removeUploadAction)); + + expect(result.current.state.files).toEqual([file]); + }); + + it('updates the key of a target file on SET_PROCESSED_FILE_KEY', () => { + const file: StorageFile = { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.QUEUED, + isImage: true, + progress: -1, + }; + + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [file], + }); + return { state, dispatch }; + }); + + const processedKey = `processed-${imageFile.name}`; + const action: Action = { + type: FileUploaderActionTypes.SET_PROCESSED_FILE_KEY, + id: imageFile.name, + processedKey, + }; + + expect(result.current.state.files[0].processedKey).toBeUndefined(); + + act(() => result.current.dispatch(action)); + + expect(result.current.state.files[0].processedKey).toBe(processedKey); + }); + + it('should only change added files to queued in QUEUE_FILES action', () => { + const { result } = renderHook(() => { + const [state, dispatch] = useReducer(fileUploaderStateReducer, { + files: [ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.ADDED, + isImage: true, + progress: -1, + }, + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADED, + isImage: true, + progress: 100, + }, + ], + }); + return { state, dispatch }; + }); + + const queueFilesAction: Action = { + type: FileUploaderActionTypes.QUEUE_FILES, + }; + + act(() => result.current.dispatch(queueFilesAction)); + + expect(result.current.state.files).toEqual([ + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.QUEUED, + isImage: true, + progress: -1, + }, + { + id: imageFile.name, + file: imageFile, + error: '', + key: imageFile.name, + status: FileStatus.UPLOADED, + isImage: true, + progress: 100, + }, + ]); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/useFileUploader.test.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/useFileUploader.test.ts new file mode 100644 index 00000000000..dd8e7fd71dc --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/__tests__/useFileUploader.test.ts @@ -0,0 +1,209 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { DefaultFile, FileStatus } from '../../../types'; +import { useFileUploader } from '../useFileUploader'; + +jest.mock('aws-amplify/storage'); + +const defaultFiles: DefaultFile[] = [{ key: 'file1' }, { key: 'file2' }]; + +describe('useUploadFiles', () => { + afterEach(() => jest.clearAllMocks()); + + it('should initialize with default files', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + expect(result.current.files.length).toBe(2); + + result.current.files.forEach((file) => { + expect(file.id).toBe(file.key); + expect(file.status).toBe(FileStatus.UPLOADED); + }); + }); + + it('should add files', () => { + const { result } = renderHook(() => useFileUploader()); + const status = FileStatus.QUEUED; + + expect(result.current.files.length).toBe(0); + act(() => + result.current.addFiles({ + files: [ + new File(['test1'], 'test1.txt', { type: 'text/plain' }), + new File(['test2'], 'test2.txt', { type: 'text/plain' }), + ], + status, + getFileErrorMessage: () => '', + }) + ); + + expect(result.current.files.length).toBe(2); + + expect(result.current.files[0].status).toStrictEqual(status); + expect(result.current.files[1].status).toStrictEqual(status); + }); + + it('should queue files', () => { + const { result } = renderHook(() => useFileUploader()); + const status = FileStatus.ADDED; + + act(() => + result.current.addFiles({ + files: [ + new File(['test1'], 'test1.txt', { type: 'text/plain' }), + new File(['test2'], 'test2.txt', { type: 'text/plain' }), + ], + status, + getFileErrorMessage: () => '', + }) + ); + + act(() => result.current.queueFiles()); + + expect(result.current.files.length).toBe(2); + + expect(result.current.files[0].status).toStrictEqual(FileStatus.QUEUED); + expect(result.current.files[1].status).toStrictEqual(FileStatus.QUEUED); + }); + + it('should clear files', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + act(() => + result.current.addFiles({ + files: [ + new File(['test1'], 'test1.txt', { type: 'text/plain' }), + new File(['test2'], 'test2.txt', { type: 'text/plain' }), + ], + status: FileStatus.QUEUED, + getFileErrorMessage: () => '', + }) + ); + + expect(result.current.files.length).toBe(4); + act(() => result.current.clearFiles()); + expect(result.current.files.length).toBe(0); + }); + + it('should set uploading file', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + act(() => + result.current.setUploadingFile({ + id: 'file1', + uploadTask: { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'IN_PROGRESS', + result: Promise.resolve({ + key: 'key', + }), + }, + }) + ); + + expect(result.current.files[0].status).toStrictEqual(FileStatus.UPLOADING); + }); + + it('should set upload progress', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + act(() => + result.current.setUploadProgress({ + id: 'file1', + progress: 50, + }) + ); + + expect(result.current.files[0].progress).toStrictEqual(50); + }); + + it('should set upload success', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + act(() => + result.current.setUploadSuccess({ + id: 'file1', + }) + ); + + expect(result.current.files[0].status).toStrictEqual(FileStatus.UPLOADED); + }); + + it('should set upload paused', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + act(() => + result.current.setUploadPaused({ + id: 'file1', + }) + ); + + expect(result.current.files[0].status).toStrictEqual(FileStatus.PAUSED); + }); + + it('should set upload resumed', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + act(() => + result.current.setUploadResumed({ + id: 'file1', + }) + ); + + expect(result.current.files[0].status).toStrictEqual(FileStatus.UPLOADING); + }); + + it('should remove upload', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + expect(result.current.files.length).toBe(2); + act(() => + result.current.removeUpload({ + id: 'file1', + }) + ); + expect(result.current.files.length).toBe(1); + }); + + it('should update a target file key', () => { + const { result } = renderHook(() => useFileUploader(defaultFiles)); + + const processedKey = 'processedKey'; + + expect(result.current.files[0].processedKey).toBeUndefined(); + + act(() => result.current.setProcessedKey({ id: 'file1', processedKey })); + + expect(result.current.files[0].processedKey).toBe(processedKey); + }); + + describe('defaultFiles', () => { + it('should handle good defaultFiles', () => { + const { result } = renderHook(() => + useFileUploader([{ key: 'file.jpg' }]) + ); + expect(result.current.files).toHaveLength(1); + }); + + it('should handle null defaultFiles', () => { + // @ts-expect-error + const { result } = renderHook(() => useFileUploader(null)); + expect(result.current.files).toHaveLength(0); + }); + + it('should handle bad defaultFiles', () => { + const { result } = renderHook(() => + useFileUploader([ + // @ts-expect-error + null, + // @ts-expect-error + { key: null }, + // @ts-expect-error + { foo: 'bar' }, + ]) + ); + expect(result.current.files).toHaveLength(0); + }); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/actions.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/actions.ts new file mode 100644 index 00000000000..9ee45720360 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/actions.ts @@ -0,0 +1,69 @@ +import { FileStatus } from '../../types'; + +import { Action, AddFilesActionParams, FileUploaderActionTypes } from './types'; +import { TaskEvent } from '../../utils'; + +export const addFilesAction = ({ + files, + status, + getFileErrorMessage, +}: AddFilesActionParams): Action => ({ + type: FileUploaderActionTypes.ADD_FILES, + files, + status, + getFileErrorMessage, +}); + +export const clearFilesAction = (): Action => ({ + type: FileUploaderActionTypes.CLEAR_FILES, +}); + +export const queueFilesAction = (): Action => ({ + type: FileUploaderActionTypes.QUEUE_FILES, +}); + +export const setProcessedKeyAction = (input: { + id: string; + processedKey: string; +}): Action => ({ + ...input, + type: FileUploaderActionTypes.SET_PROCESSED_FILE_KEY, +}); + +export const setUploadingFileAction = ({ + id, + uploadTask, +}: TaskEvent): Action => ({ + type: FileUploaderActionTypes.SET_STATUS_UPLOADING, + id, + uploadTask, +}); + +export const setUploadProgressAction = ({ + id, + progress, +}: { + id: string; + progress: number; +}): Action => ({ + type: FileUploaderActionTypes.SET_UPLOAD_PROGRESS, + id, + progress, +}); + +export const setUploadStatusAction = ({ + id, + status, +}: { + id: string; + status: FileStatus; +}): Action => ({ + type: FileUploaderActionTypes.SET_STATUS, + id, + status, +}); + +export const removeUploadAction = ({ id }: { id: string }): Action => ({ + type: FileUploaderActionTypes.REMOVE_UPLOAD, + id, +}); diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/index.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/index.ts new file mode 100644 index 00000000000..63e543d727c --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/index.ts @@ -0,0 +1 @@ +export { useFileUploader, UseFileUploader } from './useFileUploader'; diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/reducer.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/reducer.ts new file mode 100644 index 00000000000..c255401481d --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/reducer.ts @@ -0,0 +1,110 @@ +import { FileStatus, StorageFile, StorageFiles } from '../../types'; +import { Action, FileUploaderActionTypes, UseFileUploaderState } from './types'; + +const updateFiles = ( + files: StorageFiles, + nextFileData: Pick & Partial +) => + files.reduce((files, currentFile) => { + if (currentFile.id === nextFileData.id) { + return [...files, { ...currentFile, ...nextFileData }]; + } + return [...files, currentFile]; + }, []); + +export function fileUploaderStateReducer( + state: UseFileUploaderState, + action: Action +): UseFileUploaderState { + switch (action.type) { + case FileUploaderActionTypes.ADD_FILES: { + const { files, status } = action; + + const newUploads: StorageFiles = files.map((file) => { + const errorText = action.getFileErrorMessage(file); + + return { + // make sure id is unique, + // we only use it internally and don't send it to Storage + id: `${Date.now()}-${file.name}`, + file, + error: errorText, + key: file.name, + status: errorText ? FileStatus.ERROR : status, + isImage: file.type.startsWith('image/'), + progress: -1, + }; + }); + + const newFiles: StorageFiles = [...state.files, ...newUploads]; + + return { ...state, files: newFiles }; + } + case FileUploaderActionTypes.CLEAR_FILES: { + return { ...state, files: [] }; + } + case FileUploaderActionTypes.QUEUE_FILES: { + const { files } = state; + + const newFiles = files.reduce((files, currentFile) => { + return [ + ...files, + { + ...currentFile, + ...(currentFile.status === FileStatus.ADDED + ? { status: FileStatus.QUEUED } + : {}), + }, + ]; + }, []); + return { + ...state, + files: newFiles, + }; + } + case FileUploaderActionTypes.SET_STATUS_UPLOADING: { + const { id, uploadTask } = action; + const status = FileStatus.UPLOADING; + const progress = 0; + const nextFileData = { status, progress, id, uploadTask }; + + const files = updateFiles(state.files, nextFileData); + + return { ...state, files }; + } + case FileUploaderActionTypes.SET_PROCESSED_FILE_KEY: { + const { processedKey, id } = action; + const files = updateFiles(state.files, { processedKey, id }); + + return { files }; + } + case FileUploaderActionTypes.SET_UPLOAD_PROGRESS: { + const { id, progress } = action; + const files = updateFiles(state.files, { id, progress }); + + return { ...state, files }; + } + case FileUploaderActionTypes.SET_STATUS: { + const { id, status } = action; + const files = updateFiles(state.files, { id, status }); + + return { ...state, files }; + } + case FileUploaderActionTypes.REMOVE_UPLOAD: { + const { id } = action; + const { files } = state; + + const newFiles = files.reduce((files, currentFile) => { + if (currentFile.id === id) { + // remove by not returning currentFile + return [...files]; + } + return [...files, currentFile]; + }, []); + return { + ...state, + files: newFiles, + }; + } + } +} diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/types.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/types.ts new file mode 100644 index 00000000000..4c92334de0a --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/types.ts @@ -0,0 +1,63 @@ +import { FileStatus, StorageFiles } from '../../types'; +import { UploadTask } from '../../utils'; + +export interface UseFileUploaderState { + files: StorageFiles; +} + +export enum FileUploaderActionTypes { + ADD_FILES = 'ADD_FILES', + CLEAR_FILES = 'CLEAR_FILES', + QUEUE_FILES = 'QUEUE_FILES', + SET_STATUS = 'SET_STATUS', + SET_PROCESSED_FILE_KEY = 'SET_PROCESSED_FILE_KEY', + SET_STATUS_UPLOADING = 'SET_STATUS_UPLOADING', + SET_UPLOAD_PROGRESS = 'SET_UPLOAD_PROGRESS', + REMOVE_UPLOAD = 'REMOVE_UPLOAD', +} + +export type GetFileErrorMessage = (file: File) => string; + +export type Action = + | { + type: FileUploaderActionTypes.ADD_FILES; + files: File[]; + status: FileStatus; + getFileErrorMessage: GetFileErrorMessage; + } + | { + type: FileUploaderActionTypes.CLEAR_FILES; + } + | { + type: FileUploaderActionTypes.SET_STATUS; + id: string; + status: FileStatus; + } + | { + type: FileUploaderActionTypes.QUEUE_FILES; + } + | { + type: FileUploaderActionTypes.SET_STATUS_UPLOADING; + id: string; + uploadTask?: UploadTask; + } + | { + type: FileUploaderActionTypes.SET_UPLOAD_PROGRESS; + id: string; + progress: number; + } + | { + type: FileUploaderActionTypes.SET_PROCESSED_FILE_KEY; + id: string; + processedKey: string; + } + | { + type: FileUploaderActionTypes.REMOVE_UPLOAD; + id: string; + }; + +export interface AddFilesActionParams { + files: File[]; + status: FileStatus; + getFileErrorMessage: GetFileErrorMessage; +} diff --git a/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/useFileUploader.ts b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/useFileUploader.ts new file mode 100644 index 00000000000..550704085ce --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useFileUploader/useFileUploader.ts @@ -0,0 +1,120 @@ +import React from 'react'; + +import { isObject } from '@aws-amplify/ui'; + +import { StorageFiles, FileStatus, DefaultFile } from '../../types'; +import { Action, GetFileErrorMessage, UseFileUploaderState } from './types'; +import { fileUploaderStateReducer } from './reducer'; +import { + addFilesAction, + clearFilesAction, + queueFilesAction, + removeUploadAction, + setProcessedKeyAction, + setUploadingFileAction, + setUploadProgressAction, + setUploadStatusAction, +} from './actions'; +import { TaskHandler } from '../../utils'; + +export interface UseFileUploader { + addFiles: (params: { + files: File[]; + status: FileStatus; + getFileErrorMessage: GetFileErrorMessage; + }) => void; + clearFiles: () => void; + queueFiles: () => void; + setUploadingFile: TaskHandler; + setProcessedKey: (params: { id: string; processedKey: string }) => void; + setUploadProgress: (params: { id: string; progress: number }) => void; + setUploadSuccess: (params: { id: string }) => void; + setUploadResumed: (params: { id: string }) => void; + setUploadPaused: (params: { id: string }) => void; + removeUpload: (params: { id: string }) => void; + files: StorageFiles; +} + +const isDefaultFile = (file: unknown): file is DefaultFile => + !!(isObject(file) && (file as DefaultFile).key); + +const createFileFromDefault = (file: DefaultFile) => + isDefaultFile(file) + ? { ...file, id: file.key, status: FileStatus.UPLOADED } + : undefined; + +export function useFileUploader( + defaultFiles: Array = [] +): UseFileUploader { + const [{ files }, dispatch] = React.useReducer< + (prevState: UseFileUploaderState, action: Action) => UseFileUploaderState + >(fileUploaderStateReducer, { + files: (Array.isArray(defaultFiles) + ? defaultFiles.map(createFileFromDefault).filter((file) => !!file) + : []) as StorageFiles, + }); + + const addFiles: UseFileUploader['addFiles'] = ({ + files, + status, + getFileErrorMessage, + }) => { + dispatch(addFilesAction({ files, status, getFileErrorMessage })); + }; + + const clearFiles: UseFileUploader['clearFiles'] = () => { + dispatch(clearFilesAction()); + }; + + const queueFiles: UseFileUploader['queueFiles'] = () => { + dispatch(queueFilesAction()); + }; + + const setUploadingFile: UseFileUploader['setUploadingFile'] = ({ + uploadTask, + id, + }) => { + dispatch(setUploadingFileAction({ id, uploadTask })); + }; + + const setProcessedKey: UseFileUploader['setProcessedKey'] = (input) => { + dispatch(setProcessedKeyAction(input)); + }; + + const setUploadProgress: UseFileUploader['setUploadProgress'] = ({ + progress, + id, + }) => { + dispatch(setUploadProgressAction({ id, progress })); + }; + + const setUploadSuccess: UseFileUploader['setUploadSuccess'] = ({ id }) => { + dispatch(setUploadStatusAction({ id, status: FileStatus.UPLOADED })); + }; + + const setUploadPaused: UseFileUploader['setUploadPaused'] = ({ id }) => { + dispatch(setUploadStatusAction({ id, status: FileStatus.PAUSED })); + }; + + const setUploadResumed: UseFileUploader['setUploadPaused'] = ({ id }) => { + dispatch(setUploadStatusAction({ id, status: FileStatus.UPLOADING })); + }; + + const removeUpload: UseFileUploader['removeUpload'] = ({ id }) => { + dispatch(removeUploadAction({ id })); + }; + + return { + removeUpload, + setProcessedKey, + setUploadPaused, + setUploadProgress, + setUploadResumed, + setUploadSuccess, + setUploadingFile, + queueFiles, + addFiles, + clearFiles, + files, + }; +} diff --git a/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts new file mode 100644 index 00000000000..08ee387c1b1 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts @@ -0,0 +1,217 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import * as Storage from 'aws-amplify/storage'; + +import { FileStatus, StorageFile, FileUploaderProps } from '../../../types'; +import { useUploadFiles, UseUploadFilesProps } from '../useUploadFiles'; + +const uploadDataSpy = jest + .spyOn(Storage, 'uploadData') + .mockImplementation((input) => { + return { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'SUCCESS', + result: Promise.resolve({ key: input.key, data: input.data }), + }; + }); + +const mockUploadingFile: StorageFile = { + id: 'uploading', + status: FileStatus.UPLOADING, + progress: 0, + error: '', + isImage: false, + key: '', +}; + +const imageFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + +const mockQueuedFile: StorageFile = { + id: 'queued', + status: FileStatus.QUEUED, + progress: 0, + error: '', + isImage: false, + key: 'key', + file: imageFile, +}; + +const mockOnProcessFileSuccess = jest.fn(); +const mockOnUploadError = jest.fn(); +const mockOnUploadStart = jest.fn(); +const mockSetUploadingFile = jest.fn(); +const mockSetUploadProgress = jest.fn(); +const mockSetUploadSuccess = jest.fn(); +const props: Omit = { + accessLevel: 'guest', + maxFileCount: 2, + onProcessFileSuccess: mockOnProcessFileSuccess, + onUploadError: mockOnUploadError, + onUploadStart: mockOnUploadStart, + setUploadingFile: mockSetUploadingFile, + setUploadProgress: mockSetUploadProgress, + setUploadSuccess: mockSetUploadSuccess, +}; + +describe('useUploadFiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should upload all queued files', async () => { + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ ...props, files: [mockUploadingFile, mockQueuedFile] }) + ); + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockSetUploadingFile).toHaveBeenCalledTimes(1); + expect(mockSetUploadingFile).toHaveBeenCalledWith({ + id: mockQueuedFile.id, + uploadTask: expect.any(Object), + }); + expect(mockSetUploadingFile).not.toHaveBeenCalledWith({ + id: mockUploadingFile.id, + }); + expect(mockSetUploadSuccess).toHaveBeenCalledTimes(1); + expect(mockSetUploadSuccess).toHaveBeenCalledWith({ + id: mockQueuedFile.id, + }); + expect(mockSetUploadSuccess).not.toHaveBeenCalledWith({ + id: mockUploadingFile.id, + }); + expect(mockOnUploadError).not.toHaveBeenCalled(); + }); + }); + + it('should upload all resumable queued files', async () => { + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ + ...props, + isResumable: true, + files: [mockUploadingFile, mockQueuedFile], + }) + ); + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockSetUploadingFile).toHaveBeenCalledTimes(1); + expect(mockSetUploadingFile).toHaveBeenCalledWith({ + id: mockQueuedFile.id, + uploadTask: expect.any(Object), + }); + expect(mockSetUploadingFile).not.toHaveBeenCalledWith({ + id: mockUploadingFile.id, + }); + }); + }); + + it('should do nothing if number of queued files exceeds max number of files', async () => { + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ ...props, maxFileCount: 0, files: [mockQueuedFile] }) + ); + + waitForNextUpdate(); + + expect(mockSetUploadingFile).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockSetUploadSuccess).not.toHaveBeenCalled(); + }); + }); + + it('should call onUploadError when upload fails', async () => { + const errorMessage = new Error('Error'); + uploadDataSpy.mockImplementationOnce(() => { + return { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'ERROR', + result: Promise.reject(errorMessage), + }; + }); + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ ...props, files: [mockQueuedFile] }) + ); + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockOnUploadError).toHaveBeenCalledTimes(1); + expect(mockOnUploadError).toHaveBeenCalledWith('Error', { key: 'key' }); + }); + }); + + it('should start upload after processFile', async () => { + const processFile: FileUploaderProps['processFile'] = ({ file }) => ({ + file, + key: 'test.png', + }); + + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ + ...props, + isResumable: true, + processFile, + files: [mockQueuedFile], + }) + ); + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockOnUploadStart).toHaveBeenCalledWith({ key: 'test.png' }); + }); + }); + + it('should start upload after processFile promise resolves', async () => { + const processFile: FileUploaderProps['processFile'] = ({ file }) => + new Promise((resolve) => resolve({ file, key: 'test.png' })); + + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ + ...props, + isResumable: true, + processFile, + files: [mockQueuedFile], + }) + ); + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockOnUploadStart).toHaveBeenCalledWith({ + key: 'test.png', + }); + }); + }); + + it('prepends valid provided `path` to `processedKey`', async () => { + const path = 'test-path/'; + const { waitForNextUpdate } = renderHook(() => + useUploadFiles({ + ...props, + isResumable: true, + files: [mockQueuedFile], + path, + }) + ); + const expected = { key: `${path}${mockQueuedFile.key}` }; + + waitForNextUpdate(); + + await waitFor(() => { + expect(mockOnUploadStart).toHaveBeenCalledWith(expected); + expect(uploadDataSpy).toHaveBeenCalledTimes(1); + expect(uploadDataSpy).toHaveBeenCalledWith( + expect.objectContaining(expected) + ); + }); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/index.ts b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/index.ts new file mode 100644 index 00000000000..1b272e6ad93 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/index.ts @@ -0,0 +1 @@ +export { useUploadFiles, UseUploadFilesProps } from './useUploadFiles'; diff --git a/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts new file mode 100644 index 00000000000..757f30c6091 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts @@ -0,0 +1,127 @@ +import * as React from 'react'; + +import { TransferProgressEvent } from 'aws-amplify/storage'; +import { isFunction } from '@aws-amplify/ui'; + +import { PathCallback, uploadFile } from '../../utils'; +import { getInput } from '../../utils'; +import { FileStatus } from '../../types'; +import { FileUploaderProps } from '../../types'; +import { UseFileUploader } from '../useFileUploader'; + +export interface UseUploadFilesProps + extends Pick< + FileUploaderProps, + | 'isResumable' + | 'onUploadSuccess' + | 'onUploadError' + | 'onUploadStart' + | 'maxFileCount' + | 'processFile' + | 'useAccelerateEndpoint' + >, + Pick< + UseFileUploader, + 'setUploadingFile' | 'setUploadProgress' | 'setUploadSuccess' | 'files' + > { + accessLevel?: FileUploaderProps['accessLevel']; + onProcessFileSuccess: (input: { id: string; processedKey: string }) => void; + path?: string | PathCallback; +} + +export function useUploadFiles({ + accessLevel, + files, + isResumable, + maxFileCount, + onProcessFileSuccess, + onUploadError, + onUploadStart, + onUploadSuccess, + path, + processFile, + setUploadingFile, + setUploadProgress, + setUploadSuccess, + useAccelerateEndpoint, +}: UseUploadFilesProps): void { + React.useEffect(() => { + const filesReadyToUpload = files.filter( + (file) => file.status === FileStatus.QUEUED + ); + + if (filesReadyToUpload.length > maxFileCount) { + return; + } + + for (const { file, key, id } of filesReadyToUpload) { + const onProgress = (event: TransferProgressEvent): void => { + /** + * When a file is zero bytes, the progress.total will equal zero. + * Therefore, this will prevent a divide by zero error. + */ + const progress = + event.totalBytes === undefined || event.totalBytes === 0 + ? 100 + : Math.floor((event.transferredBytes / event.totalBytes) * 100); + setUploadProgress({ id, progress }); + }; + + if (file) { + const handleProcessFileSuccess = (input: { processedKey: string }) => + onProcessFileSuccess({ id, ...input }); + + const input = getInput({ + accessLevel, + file, + key, + onProcessFileSuccess: handleProcessFileSuccess, + onProgress, + path, + processFile, + useAccelerateEndpoint, + }); + + uploadFile({ + input, + onComplete: (event) => { + if (isFunction(onUploadSuccess)) { + onUploadSuccess({ + key: + (event as { key: string }).key ?? + (event as { path: string }).path, + }); + } + setUploadSuccess({ id }); + }, + onError: ({ key, error }) => { + if (isFunction(onUploadError)) { + onUploadError(error.message, { key }); + } + }, + onStart: ({ key, uploadTask }) => { + if (isFunction(onUploadStart)) { + onUploadStart({ key }); + } + setUploadingFile({ id, uploadTask }); + }, + }); + } + } + }, [ + files, + accessLevel, + isResumable, + setUploadProgress, + setUploadingFile, + onUploadError, + onProcessFileSuccess, + onUploadSuccess, + onUploadStart, + maxFileCount, + setUploadSuccess, + processFile, + path, + useAccelerateEndpoint, + ]); +} diff --git a/packages/react-storage/src/components/FileUploader/index.ts b/packages/react-storage/src/components/FileUploader/index.ts new file mode 100644 index 00000000000..9ca596b4ad9 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/index.ts @@ -0,0 +1,10 @@ +export { FileUploader } from './FileUploader'; +export { FileUploaderProps } from './types'; +export { + DropZoneProps, + ContainerProps, + FileListProps, + FilePickerProps, + FileListHeaderProps, + FileListFooterProps, +} from './ui'; diff --git a/packages/react-storage/src/components/FileUploader/types.ts b/packages/react-storage/src/components/FileUploader/types.ts new file mode 100644 index 00000000000..83167fa0c7e --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/types.ts @@ -0,0 +1,145 @@ +import * as React from 'react'; + +import type { StorageAccessLevel } from '@aws-amplify/core'; + +import { + ContainerProps, + DropZoneProps, + FileListHeaderProps, + FileListFooterProps, + FileListProps, + FilePickerProps, +} from './ui'; +import { FileUploaderDisplayText, PathCallback, UploadTask } from './utils'; + +export enum FileStatus { + ADDED = 'added', + QUEUED = 'queued', + UPLOADING = 'uploading', + PAUSED = 'paused', + ERROR = 'error', + UPLOADED = 'uploaded', +} + +export interface StorageFile { + id: string; + file?: File; + status: FileStatus; + progress: number; + // only present after `processFile` complete + processedKey?: string; + uploadTask?: UploadTask; + key: string; + error: string; + isImage: boolean; +} + +export type StorageFiles = StorageFile[]; + +export type DefaultFile = Pick; + +export interface ProcessFileParams extends Record { + file: File; + key: string; + useAccelerateEndpoint?: boolean; +} + +export type ProcessFile = ( + params: ProcessFileParams +) => Promise | ProcessFileParams; + +export interface FileUploaderHandle { + clearFiles: () => void; +} + +export interface FileUploaderProps { + /** + * List of accepted File types, values of `['*']` or undefined allow any files + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept + */ + acceptedFileTypes?: string[]; + /** + * Access level for file uploads + * @see https://docs.amplify.aws/lib/storage/configureaccess/q/platform/js/ + */ + accessLevel: StorageAccessLevel; + + /** + * Determines if the upload will automatically start after a file is selected, default value: true + */ + autoUpload?: boolean; + /** + * Component overrides + */ + components?: { + Container?: React.ComponentType; + DropZone?: React.ComponentType; + FileList?: React.ComponentType; + FilePicker?: React.ComponentType; + FileListHeader?: React.ComponentType; + FileListFooter?: React.ComponentType; + }; + /** + * List of default files already uploaded + */ + defaultFiles?: DefaultFile[]; + /** + * Overrides default display text + */ + displayText?: FileUploaderDisplayText; + /** + * Determines if upload can be paused / resumed + */ + isResumable?: boolean; + /** + * Maximum total files to upload in each batch + */ + maxFileCount: number; + /** + * Maximum file size in bytes + */ + maxFileSize?: number; + /** + * When a file is removed + */ + onFileRemove?: (file: { key: string }) => void; + /** + * Monitor upload errors + */ + onUploadError?: (error: string, file: { key: string }) => void; + /** + * Monitor upload success + */ + onUploadSuccess?: (event: { key?: string }) => void; + /** + * When a file begins uploading + */ + onUploadStart?: (event: { key?: string }) => void; + /** + * Process file before upload + */ + processFile?: ProcessFile; + /** + * Determines if thumbnails show for image files + */ + showThumbnails?: boolean; + /** + * Provided value is prefixed to the file `key` for each file + */ + path?: string; + + useAccelerateEndpoint?: boolean; +} + +export interface FileUploaderPathProps + extends Omit { + /** + * S3 bucket key, allows either a `string` or a `PathCallback`: + * - `string`: `path` is prefixed to the file `key` for each file + * - `PathCallback`: callback provided an input containing the current `identityId`, + * resolved value is prefixed to the file `key` for each file + */ + path: string | PathCallback; + accessLevel?: never; + useAccelerateEndpoint?: boolean; +} diff --git a/packages/react-storage/src/components/FileUploader/ui/Container/Container.tsx b/packages/react-storage/src/components/FileUploader/ui/Container/Container.tsx new file mode 100644 index 00000000000..e417120f27e --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/Container/Container.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { View } from '@aws-amplify/ui-react'; + +export interface ContainerProps { + children: React.ReactNode; + className: string; +} + +export function Container({ + children, + className, +}: ContainerProps): JSX.Element { + return {children}; +} diff --git a/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/Container.spec.tsx b/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/Container.spec.tsx new file mode 100644 index 00000000000..b32f06661bf --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/Container.spec.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { View } from '@aws-amplify/ui-react'; + +import { Container } from '../Container'; + +const CHILD_CONTENT = 'Test Children'; + +const MockChildren = (): JSX.Element => { + return {CHILD_CONTENT}; +}; + +const mockClassName = 'mockClassName'; + +describe('Container', () => { + it('renders as expected', async () => { + const { container, findByText } = render( + + + + ); + + expect(container).toMatchSnapshot(); + + expect(container.getElementsByClassName(mockClassName)).toHaveLength(1); + + expect(await findByText(CHILD_CONTENT)).toBeVisible(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/__snapshots__/Container.spec.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/__snapshots__/Container.spec.tsx.snap new file mode 100644 index 00000000000..543f45187b6 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/Container/__tests__/__snapshots__/Container.spec.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Container renders as expected 1`] = ` +
+
+
+ Test Children +
+
+
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/Container/index.ts b/packages/react-storage/src/components/FileUploader/ui/Container/index.ts new file mode 100644 index 00000000000..23a2500e466 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/Container/index.ts @@ -0,0 +1 @@ +export { Container, ContainerProps } from './Container'; diff --git a/packages/react-storage/src/components/FileUploader/ui/DropZone/DropZone.tsx b/packages/react-storage/src/components/FileUploader/ui/DropZone/DropZone.tsx new file mode 100644 index 00000000000..0d1111aad29 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/DropZone/DropZone.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { classNames } from '@aws-amplify/ui'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { View, Text } from '@aws-amplify/ui-react'; +import { classNameModifier } from '@aws-amplify/ui'; +import { IconUpload, useIcons } from '@aws-amplify/ui-react/internal'; +import { DropZoneProps } from './types'; + +export function DropZone({ + children, + displayText, + inDropZone, + onDragEnter, + onDragLeave, + onDragOver, + onDragStart, + onDrop, + testId, +}: DropZoneProps): JSX.Element { + const { dropFilesText } = displayText; + const icons = useIcons('storageManager'); + + return ( + + + {icons?.upload ?? } + + + + {dropFilesText} + + {children} + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/DropZone.test.tsx b/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/DropZone.test.tsx new file mode 100644 index 00000000000..fe50764c41f --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/DropZone.test.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { IconsProvider, View } from '@aws-amplify/ui-react'; +import { classNameModifier } from '@aws-amplify/ui'; + +import { defaultFileUploaderDisplayText } from '../../../utils/displayText'; +import { DropZone } from '../DropZone'; + +describe('DropZone', () => { + it('renders correctly', () => { + const { container } = render( + {}} + onDragLeave={() => {}} + onDragOver={() => {}} + onDragStart={() => {}} + onDrop={() => {}} + displayText={defaultFileUploaderDisplayText} + /> + ); + + expect(container).toMatchSnapshot(); + }); + + it('shows correct class when inDropZone is true', async () => { + const testId = 'dropzone'; + render( + {}} + onDragLeave={() => {}} + onDragOver={() => {}} + onDragStart={() => {}} + onDrop={() => {}} + displayText={defaultFileUploaderDisplayText} + testId={testId} + /> + ); + + const dropZoneElement = await screen.findByTestId(testId); + expect(dropZoneElement).toHaveClass( + classNameModifier(ComponentClassName.FileUploaderDropZone, 'active') + ); + }); + + it('renders children', async () => { + const testId = 'dropzone'; + const testText = 'test'; + render( + {}} + onDragLeave={() => {}} + onDragOver={() => {}} + onDragStart={() => {}} + onDrop={() => {}} + displayText={defaultFileUploaderDisplayText} + testId={testId} + > + {testText} + + ); + + const dropZoneChildren = await screen.findByTestId(testId); + expect(dropZoneChildren).toHaveTextContent(testText); + }); + + it('renders custom icons from IconProvider', () => { + const { container } = render( + , + }, + }} + > + {}} + onDragLeave={() => {}} + onDragOver={() => {}} + onDragStart={() => {}} + onDrop={() => {}} + displayText={defaultFileUploaderDisplayText} + /> + + ); + expect(screen.getByTestId('upload')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap new file mode 100644 index 00000000000..dab9262dcf9 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DropZone renders correctly 1`] = ` +
+
+ +

+ Drop files here or +

+
+
+`; + +exports[`DropZone renders custom icons from IconProvider 1`] = ` +
+
+
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/DropZone/index.ts b/packages/react-storage/src/components/FileUploader/ui/DropZone/index.ts new file mode 100644 index 00000000000..417ad3f1099 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/DropZone/index.ts @@ -0,0 +1,2 @@ +export { DropZone } from './DropZone'; +export { DropZoneProps } from './types'; diff --git a/packages/react-storage/src/components/FileUploader/ui/DropZone/types.ts b/packages/react-storage/src/components/FileUploader/ui/DropZone/types.ts new file mode 100644 index 00000000000..4877f8445ac --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/DropZone/types.ts @@ -0,0 +1,13 @@ +import { FileUploaderDisplayText } from '../../utils/displayText'; + +export interface DropZoneProps { + children?: React.ReactNode; + displayText: FileUploaderDisplayText; + inDropZone: boolean; + onDragEnter: (event: React.DragEvent) => void; + onDragLeave: (event: React.DragEvent) => void; + onDragOver: (event: React.DragEvent) => void; + onDragStart: (event: React.DragEvent) => void; + onDrop: (event: React.DragEvent) => void; + testId?: string; +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileControl.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileControl.tsx new file mode 100644 index 00000000000..b20f1614e3c --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileControl.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { View, Loader, Button } from '@aws-amplify/ui-react'; + +import { FileStatus } from '../../types'; +import { FileStatusMessage } from './FileStatusMessage'; +import { FileRemoveButton } from './FileRemoveButton'; +import { UploadDetails } from './FileDetails'; +import { FileThumbnail } from './FileThumbnail'; +import { FileControlProps } from './types'; + +export function FileControl({ + onPause, + onResume, + displayName, + errorMessage, + isImage, + isResumable, + loaderIsDeterminate, + onRemove, + progress, + showThumbnails = true, + size, + status, + displayText, + thumbnailUrl, +}: FileControlProps): JSX.Element { + const { + getPausedText, + getUploadingText, + uploadSuccessfulText, + pauseButtonText, + resumeButtonText, + } = displayText; + + return ( + + + {showThumbnails ? ( + + ) : null} + + {status === FileStatus.UPLOADING ? ( + + ) : null} + {isResumable && + (status === FileStatus.UPLOADING || status === FileStatus.PAUSED) ? ( + status === FileStatus.PAUSED ? ( + + ) : ( + + ) + ) : null} + + + + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileDetails.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileDetails.tsx new file mode 100644 index 00000000000..54321b215e6 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileDetails.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { ComponentClassName, humanFileSize } from '@aws-amplify/ui'; +import { Text, View } from '@aws-amplify/ui-react'; +import { UploadDetailsProps } from './types'; + +export const UploadDetails = ({ + displayName, + fileSize, +}: UploadDetailsProps): JSX.Element => { + return ( + <> + + + {displayName} + + + + {fileSize ? humanFileSize(fileSize, true) : ''} + + + ); +}; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileList.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileList.tsx new file mode 100644 index 00000000000..4c584a06c06 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileList.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { Alert, View } from '@aws-amplify/ui-react'; + +import { FileStatus } from '../../types'; +import { FileControl } from './FileControl'; +import { FileListProps } from './types'; + +export function FileList({ + displayText, + files, + hasMaxFilesError, + isResumable, + onCancelUpload, + onDeleteUpload, + onResume, + onPause, + showThumbnails, + maxFileCount, +}: FileListProps): JSX.Element | null { + if (files.length < 1) { + return null; + } + + const { getMaxFilesErrorText } = displayText; + const headingMaxFiles = getMaxFilesErrorText(maxFileCount); + + return ( + + {files.map((storageFile) => { + const { file, status, progress, error, key, isImage, id, uploadTask } = + storageFile; + + const thumbnailUrl = file && isImage ? URL.createObjectURL(file) : ''; + const loaderIsDeterminate = isResumable ? progress > 0 : true; + const isUploading = status === FileStatus.UPLOADING; + + const onRemove = () => { + if ( + isResumable && + (status === FileStatus.UPLOADING || status === FileStatus.PAUSED) && + uploadTask + ) { + onCancelUpload({ id, uploadTask }); + } else { + onDeleteUpload({ id }); + } + }; + + const handlePauseUpload = () => { + if (uploadTask) { + onPause({ id, uploadTask }); + } + }; + const handleResumeUpload = () => { + if (uploadTask) { + onResume({ id, uploadTask }); + } + }; + + return ( + + ); + })} + {hasMaxFilesError && ( + + )} + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileRemoveButton.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileRemoveButton.tsx new file mode 100644 index 00000000000..a117ad4753a --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileRemoveButton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { IconClose, useIcons } from '@aws-amplify/ui-react/internal'; +import { Button, View, VisuallyHidden } from '@aws-amplify/ui-react'; +import { FileRemoveButtonProps } from './types'; + +export const FileRemoveButton = ({ + altText, + onClick, +}: FileRemoveButtonProps): JSX.Element => { + const icons = useIcons('storageManager'); + return ( + + ); +}; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileStatusMessage.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileStatusMessage.tsx new file mode 100644 index 00000000000..4818537ddad --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileStatusMessage.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { classNames } from '@aws-amplify/ui'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { Text, View } from '@aws-amplify/ui-react'; +import { IconCheck, IconError, useIcons } from '@aws-amplify/ui-react/internal'; +import { classNameModifier } from '@aws-amplify/ui'; +import { FileStatus } from '../../types'; +import { FileStatusMessageProps } from './types'; + +export const FileStatusMessage = ({ + errorMessage, + getPausedText, + getUploadingText, + percentage, + status, + uploadSuccessfulText, +}: FileStatusMessageProps): JSX.Element | null => { + const icons = useIcons('storageManager'); + switch (status) { + case FileStatus.UPLOADING: { + return ( + + {getUploadingText(percentage)} + + ); + } + case FileStatus.PAUSED: + return ( + + {getPausedText(percentage)} + + ); + case FileStatus.UPLOADED: + return ( + + + {icons?.success ?? } + + {uploadSuccessfulText} + + ); + case FileStatus.ERROR: + return ( + + + {icons?.error ?? } + + {errorMessage} + + ); + default: + return null; + } +}; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/FileThumbnail.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/FileThumbnail.tsx new file mode 100644 index 00000000000..8bbfc12b052 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/FileThumbnail.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { View, Image } from '@aws-amplify/ui-react'; +import { IconFile, useIcons } from '@aws-amplify/ui-react/internal'; +import { FileThumbnailProps } from './types'; + +export const FileThumbnail = ({ + fileName, + isImage, + url, +}: FileThumbnailProps): JSX.Element => { + const icons = useIcons('storageManager'); + const thumbnail = isImage ? ( + {fileName} + ) : ( + icons?.file ?? + ); + + return ( + + {thumbnail} + + ); +}; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileControl.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileControl.test.tsx new file mode 100644 index 00000000000..a0697860c6d --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileControl.test.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; + +import { FileControlProps } from '../types'; +import { FileControl } from '../FileControl'; +import { FileStatus } from '../../../types'; +import { defaultFileUploaderDisplayText } from '../../../utils/displayText'; + +const fileControlProps: FileControlProps = { + displayText: defaultFileUploaderDisplayText, + displayName: 'fileName', + errorMessage: '', + isImage: false, + isResumable: false, + isUploading: false, + loaderIsDeterminate: false, + onRemove: jest.fn(), + onPause: jest.fn(), + onResume: jest.fn(), + progress: 0, + showThumbnails: false, + status: FileStatus.UPLOADING, + thumbnailUrl: '', +}; + +describe('FileControl', () => { + it('renders as expected when file is uploading', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName(`${ComponentClassName.FileUploaderFile}`) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileWrapper}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderLoader}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileImage}` + ) + ).toHaveLength(0); + }); + + it('renders as expected when uploading is paused', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName(`${ComponentClassName.FileUploaderFile}`) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderLoader}` + ) + ).toHaveLength(0); + }); + + it('renders thumbnails', () => { + const { container } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileImage}` + ) + ).toHaveLength(1); + + expect(container).toMatchSnapshot(); + }); + + it('should default to showThumbnails being true', () => { + //@ts-expect-error + fileControlProps.showThumbnails = undefined; + + const { container } = render(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileImage}` + ) + ).toHaveLength(1); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileDetails.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileDetails.test.tsx new file mode 100644 index 00000000000..085b54983af --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileDetails.test.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ComponentClassName, humanFileSize } from '@aws-amplify/ui'; + +import { UploadDetails } from '../FileDetails'; +import { UploadDetailsProps } from '../types'; + +const fileDetailsProps: UploadDetailsProps = { + displayName: 'Test', + fileSize: 100, +}; + +describe('FileDetails', () => { + it('renders as expected', async () => { + const { container, findByText } = render( + + ); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileMain}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileName}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileSize}` + ) + ).toHaveLength(1); + + expect(await findByText(fileDetailsProps.displayName)).toBeVisible(); + expect( + await findByText(humanFileSize(fileDetailsProps.fileSize!, true)) + ).toBeVisible(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileList.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileList.test.tsx new file mode 100644 index 00000000000..8f71f4f0be2 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileList.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { UploadDataOutput } from 'aws-amplify/storage'; +import { ComponentClassName } from '@aws-amplify/ui'; + +import { FileList } from '../FileList'; +import { FileListProps } from '../types'; +import { FileStatus, StorageFile } from '../../../types'; +import { defaultFileUploaderDisplayText } from '../../../utils'; + +const mockFile: StorageFile = { + id: 'test', + status: FileStatus.UPLOADING, + progress: 0, + error: '', + isImage: false, + key: '', + uploadTask: {} as UploadDataOutput, +}; + +const mockOnCancelUpload = jest.fn(); +const mockOnDeleteUpload = jest.fn(); +const mockOnResume = jest.fn(); +const mockOnPause = jest.fn(); + +const fileListProps: FileListProps = { + displayText: defaultFileUploaderDisplayText, + files: [mockFile], + isResumable: false, + onCancelUpload: mockOnCancelUpload, + onDeleteUpload: mockOnDeleteUpload, + onPause: mockOnPause, + onResume: mockOnResume, + showThumbnails: false, + hasMaxFilesError: false, + maxFileCount: 0, +}; + +describe('FileList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders as expected', () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileList}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName(`${ComponentClassName.FileUploaderFile}`) + ).toHaveLength(fileListProps.files.length); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + }); + + it('renders as expected when upload is resumable', () => { + const { container, getByText } = render( + + ); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileList}` + ) + ).toHaveLength(1); + + expect( + container.getElementsByClassName(`${ComponentClassName.FileUploaderFile}`) + ).toHaveLength(fileListProps.files.length); + + expect( + getByText(defaultFileUploaderDisplayText.pauseButtonText) + ).toBeInTheDocument(); + }); + + it('renders an alert in case of error', () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); + + expect( + container.getElementsByClassName(`${ComponentClassName.Alert}--error`) + ).toHaveLength(1); + }); + + it('renders nothing when there are no files', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should call onDeleteUpload when remove button is clicked', () => { + const { getByText } = render(); + const removeButton = getByText('Remove file'); + fireEvent.click(removeButton); + expect(mockOnDeleteUpload).toHaveBeenCalledTimes(1); + }); + + it('should call onPause when pause button is clicked', () => { + const { getByText } = render(); + const pauseButton = getByText( + defaultFileUploaderDisplayText.pauseButtonText + ); + fireEvent.click(pauseButton); + expect(mockOnPause).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileRemoveButton.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileRemoveButton.test.tsx new file mode 100644 index 00000000000..bd6014d06fe --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileRemoveButton.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { IconsProvider, View } from '@aws-amplify/ui-react'; + +import { FileRemoveButton } from '../FileRemoveButton'; +import { FileRemoveButtonProps } from '../types'; + +const onClick = jest.fn(); +const fileRemoveButtonProps: FileRemoveButtonProps = { + altText: 'Alt text', + onClick: onClick, +}; + +describe('FileRemoveButton', () => { + it('renders as expected', async () => { + const { container, findByRole, findByText } = render( + + ); + + const button = await findByRole('button'); + expect(button).toHaveClass(`${ComponentClassName.Button}--small`); + + const image = await findByText(fileRemoveButtonProps.altText); + expect(image).toHaveClass('amplify-visually-hidden'); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + expect(container).toMatchSnapshot(); + }); + + it('should fire onClick function if the button is clicked on', async () => { + const { findByRole } = render( + + ); + + const button = await findByRole('button'); + await userEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders custom icons from IconProvider', () => { + const { container } = render( + , + }, + }} + > + + + ); + + expect(screen.getByTestId('remove')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileStatusMessage.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileStatusMessage.test.tsx new file mode 100644 index 00000000000..f29f64bba25 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileStatusMessage.test.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { IconsProvider, View } from '@aws-amplify/ui-react'; + +import { FileStatusMessage } from '../FileStatusMessage'; +import { FileStatusMessageProps } from '../types'; +import { FileStatus } from '../../../types'; + +const uploadingText = 'Uploading...'; +const uploadingPausedText = 'Uploading paused...'; +const uploadSuccessful = 'Upload successful!'; +const errorUploading = 'Error'; +const defaultProps: Omit = { + errorMessage: errorUploading, + percentage: 50, + getUploadingText: (percentage: number) => `${uploadingText} ${percentage}%`, + getPausedText: () => uploadingPausedText, + uploadSuccessfulText: uploadSuccessful, +}; + +describe('FileStatusMessage', () => { + it('renders as expected when file is uploading', async () => { + const { container, findByText } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + expect( + await findByText(`${uploadingText} ${defaultProps.percentage}%`) + ).toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it('renders as expected when file uploading is paused', async () => { + const { container, findByText } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + expect(await findByText(uploadingPausedText)).toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it('renders as expected when file is uploaded', async () => { + const { container, findByText } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + expect(await findByText(uploadSuccessful)).toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it('renders as expected when there is an error uploading', async () => { + const { container, findByText } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileStatus}` + ) + ).toHaveLength(1); + expect(await findByText(errorUploading)).toBeVisible(); + expect(container).toMatchSnapshot(); + }); + + it('renders nothing when status is queued', () => { + const { container } = render( + + ); + expect(container).toMatchSnapshot(); + }); + + it('renders custom icons from IconProvider', () => { + const { container } = render( + , + error: , + }, + }} + > + + + + ); + + expect(screen.getByTestId('success')).toBeInTheDocument(); + expect(screen.getByTestId('error')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileThumbnail.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileThumbnail.test.tsx new file mode 100644 index 00000000000..fcc0c41fd4b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/FileThumbnail.test.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { IconsProvider, View } from '@aws-amplify/ui-react'; + +import { FileThumbnail } from '../FileThumbnail'; +import { FileThumbnailProps } from '../types'; + +const thumbnailProps: FileThumbnailProps = { + fileName: 'test', + url: 'testURL', + isImage: false, +}; + +describe('FileThumbnail', () => { + it('renders an icon', () => { + const { container } = render(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileImage}` + ) + ).toHaveLength(1); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + const img = container.querySelector('img'); + expect(img).not.toBeInTheDocument(); + + expect(container).toMatchSnapshot(); + }); + + it('renders an image', () => { + const { container } = render(); + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderFileImage}` + ) + ).toHaveLength(1); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', thumbnailProps.url); + + const svg = container.querySelector('svg'); + expect(svg).not.toBeInTheDocument(); + + expect(container).toMatchSnapshot(); + }); + + it('renders custom icons from IconProvider', () => { + const { container } = render( + , + }, + }} + > + + + ); + expect(screen.getByTestId('file')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap new file mode 100644 index 00000000000..7e843b63e3b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap @@ -0,0 +1,357 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileControl renders as expected when file is uploading 1`] = ` +
+
+
+
+

+ fileName +

+
+ + + + + + + + +
+

+ Uploading +

+
+
+`; + +exports[`FileControl renders as expected when uploading is paused 1`] = ` +
+
+
+
+

+ fileName +

+
+ + +
+

+ Paused: 0% +

+
+
+`; + +exports[`FileControl renders thumbnails 1`] = ` +
+
+
+
+ + + + + +
+
+

+ fileName +

+
+ + + + + + + + +
+

+ Uploading +

+
+
+`; + +exports[`FileControl should default to showThumbnails being true 1`] = ` +
+
+
+
+ + + + + +
+
+

+ fileName +

+
+ + + + + + + + +
+

+ Uploading +

+
+
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileDetails.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileDetails.test.tsx.snap new file mode 100644 index 00000000000..33e570e48cf --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileDetails.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileDetails renders as expected 1`] = ` +
+
+

+ Test +

+
+ + 100 B + +
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileList.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileList.test.tsx.snap new file mode 100644 index 00000000000..45a9bb9efa5 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileList.test.tsx.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileList renders an alert in case of error 1`] = ` +
+
+
+
+
+

+

+ + + + + + + 0% + + + + +
+

+ Uploading +

+
+ +
+`; + +exports[`FileList renders as expected 1`] = ` +
+
+
+
+
+

+

+ + + + + + + 0% + + + + +
+

+ Uploading +

+
+
+
+`; + +exports[`FileList renders as expected when upload is resumable 1`] = ` +
+
+
+
+
+

+

+ + + + + + + + + +
+

+ Uploading +

+
+
+
+`; + +exports[`FileList renders nothing when there are no files 1`] = `
`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileRemoveButton.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileRemoveButton.test.tsx.snap new file mode 100644 index 00000000000..dc81b0fe016 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileRemoveButton.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileRemoveButton renders as expected 1`] = ` +
+ +
+`; + +exports[`FileRemoveButton renders custom icons from IconProvider 1`] = ` +
+ +
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileStatusMessage.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileStatusMessage.test.tsx.snap new file mode 100644 index 00000000000..c53f01778f8 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileStatusMessage.test.tsx.snap @@ -0,0 +1,106 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileStatusMessage renders as expected when file is uploaded 1`] = ` +
+

+ + + + + + + + Upload successful! +

+
+`; + +exports[`FileStatusMessage renders as expected when file is uploading 1`] = ` +
+

+ Uploading... 50% +

+
+`; + +exports[`FileStatusMessage renders as expected when file uploading is paused 1`] = ` +
+

+ Uploading paused... +

+
+`; + +exports[`FileStatusMessage renders as expected when there is an error uploading 1`] = ` +
+

+ + + + + + + + Error +

+
+`; + +exports[`FileStatusMessage renders custom icons from IconProvider 1`] = ` +
+

+ + + + Error +

+

+ + + + Upload successful! +

+
+`; + +exports[`FileStatusMessage renders nothing when status is queued 1`] = `
`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileThumbnail.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileThumbnail.test.tsx.snap new file mode 100644 index 00000000000..7fa2ab84e7f --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/__tests__/__snapshots__/FileThumbnail.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileThumbnail renders an icon 1`] = ` +
+
+ + + + + +
+
+`; + +exports[`FileThumbnail renders an image 1`] = ` +
+
+ test +
+
+`; + +exports[`FileThumbnail renders custom icons from IconProvider 1`] = ` +
+
+
+
+
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/index.ts b/packages/react-storage/src/components/FileUploader/ui/FileList/index.ts new file mode 100644 index 00000000000..341ffecc73c --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/index.ts @@ -0,0 +1,2 @@ +export { FileList } from './FileList'; +export { FileListProps } from './types'; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileList/types.ts b/packages/react-storage/src/components/FileUploader/ui/FileList/types.ts new file mode 100644 index 00000000000..ae8bca2f210 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileList/types.ts @@ -0,0 +1,59 @@ +import { FileUploaderDisplayTextDefault, TaskHandler } from '../../utils'; +import { FileStatus, StorageFile } from '../../types'; + +export interface FileListProps { + displayText: FileUploaderDisplayTextDefault; + files: StorageFile[]; + isResumable: boolean; + onCancelUpload: TaskHandler; + onDeleteUpload: (params: { id: string }) => void; + onPause: TaskHandler; + onResume: TaskHandler; + showThumbnails: boolean; + hasMaxFilesError: boolean; + maxFileCount: number; +} + +export interface FileControlProps { + displayText: FileUploaderDisplayTextDefault; + displayName: string; + errorMessage: string; + isImage: boolean; + isResumable: boolean; + isUploading: boolean; + loaderIsDeterminate: boolean; + onRemove: () => void; + onPause: () => void; + onResume: () => void; + progress: number; + showThumbnails: boolean; + size?: number; + status: FileStatus; + thumbnailUrl: string; +} + +export interface FileStatusMessageProps + extends Pick< + FileUploaderDisplayTextDefault, + 'getUploadingText' | 'getPausedText' | 'uploadSuccessfulText' + > { + status: FileStatus; + errorMessage: string; + percentage: number; +} + +export interface UploadDetailsProps { + displayName: string; + fileSize?: number; +} + +export interface FileThumbnailProps { + isImage: boolean; + fileName: string; + url: string; +} + +export interface FileRemoveButtonProps { + altText: string; + onClick: () => void; +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListFooter/FileListFooter.tsx b/packages/react-storage/src/components/FileUploader/ui/FileListFooter/FileListFooter.tsx new file mode 100644 index 00000000000..beb0a1d4a58 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListFooter/FileListFooter.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { View, Button } from '@aws-amplify/ui-react'; +import { FileUploaderDisplayTextDefault } from '../../utils'; + +export interface FileListFooterProps { + remainingFilesCount: number; + displayText: FileUploaderDisplayTextDefault; + onClearAll: () => void; + onUploadAll: () => void; +} + +export function FileListFooter({ + displayText, + remainingFilesCount, + onClearAll, + onUploadAll, +}: FileListFooterProps): JSX.Element { + const { clearAllButtonText, getUploadButtonText } = displayText; + return ( + + + + + + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListFooter/index.ts b/packages/react-storage/src/components/FileUploader/ui/FileListFooter/index.ts new file mode 100644 index 00000000000..d8706ab12dc --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListFooter/index.ts @@ -0,0 +1 @@ +export { FileListFooter, FileListFooterProps } from './FileListFooter'; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListHeader/FileListHeader.tsx b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/FileListHeader.tsx new file mode 100644 index 00000000000..1dedbb4cc2b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/FileListHeader.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { FileUploaderDisplayTextDefault } from '../../utils'; +import { ComponentClassName } from '@aws-amplify/ui'; +import { Text } from '@aws-amplify/ui-react'; + +export interface FileListHeaderProps { + allUploadsSuccessful: boolean; + displayText: FileUploaderDisplayTextDefault; + fileCount: number; + remainingFilesCount: number; + selectedFilesCount?: number; +} + +export function FileListHeader({ + allUploadsSuccessful, + displayText, + fileCount, + remainingFilesCount, + selectedFilesCount = 0, +}: FileListHeaderProps): JSX.Element { + const { getFilesUploadedText, getRemainingFilesText, getSelectedFilesText } = + displayText; + + return ( + + {selectedFilesCount + ? getSelectedFilesText(selectedFilesCount) + : allUploadsSuccessful + ? getFilesUploadedText(fileCount) + : getRemainingFilesText(remainingFilesCount)} + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/FileListHeader.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/FileListHeader.test.tsx new file mode 100644 index 00000000000..2cf537ff47b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/FileListHeader.test.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; + +import { FileListHeader, FileListHeaderProps } from '../FileListHeader'; +import { defaultFileUploaderDisplayText } from '../../../utils/displayText'; + +const headerProps: FileListHeaderProps = { + fileCount: 2, + remainingFilesCount: 1, + displayText: defaultFileUploaderDisplayText, + allUploadsSuccessful: false, +}; + +describe('FileListHeader', () => { + it('renders as expected when uploads are in progress', async () => { + const { container, findByText } = render( + + ); + + expect( + container.getElementsByClassName( + `${ComponentClassName.FileUploaderPreviewerText}` + ) + ).toHaveLength(1); + + expect( + await findByText( + headerProps.displayText.getRemainingFilesText( + headerProps.remainingFilesCount + ) + ) + ).toBeVisible(); + + expect(container).toMatchSnapshot(); + }); + + it('renders as expected when uploads are successful', async () => { + const { container, findByText } = render( + + ); + + expect( + await findByText( + headerProps.displayText.getFilesUploadedText(headerProps.fileCount) + ) + ).toBeVisible(); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/__snapshots__/FileListHeader.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/__snapshots__/FileListHeader.test.tsx.snap new file mode 100644 index 00000000000..469a2e7edb9 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/__tests__/__snapshots__/FileListHeader.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FileListHeader renders as expected when uploads are in progress 1`] = ` +
+

+ 1 file uploading +

+
+`; + +exports[`FileListHeader renders as expected when uploads are successful 1`] = ` +
+

+ 2 files uploaded +

+
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FileListHeader/index.ts b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/index.ts new file mode 100644 index 00000000000..52a720d330a --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FileListHeader/index.ts @@ -0,0 +1 @@ +export { FileListHeader, FileListHeaderProps } from './FileListHeader'; diff --git a/packages/react-storage/src/components/FileUploader/ui/FilePicker/FilePicker.tsx b/packages/react-storage/src/components/FileUploader/ui/FilePicker/FilePicker.tsx new file mode 100644 index 00000000000..c249b5fd976 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FilePicker/FilePicker.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { ComponentClassName } from '@aws-amplify/ui'; +import { Button, ButtonProps } from '@aws-amplify/ui-react'; + +export type FilePickerProps = ButtonProps; + +export function FilePicker({ + children, + className = ComponentClassName.FileUploaderFilePicker, + size = 'small', + ...props +}: FilePickerProps): JSX.Element { + return ( + + ); +} diff --git a/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/FilePicker.test.tsx b/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/FilePicker.test.tsx new file mode 100644 index 00000000000..37707f7869d --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/FilePicker.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ComponentClassName } from '@aws-amplify/ui'; + +import { FilePicker } from '../FilePicker'; + +const children = 'Pick a file, any file.'; +const onClick = jest.fn(); + +describe('FilePicker', () => { + it('renders correctly', () => { + const { container } = render( + {children} + ); + + expect(container).toMatchSnapshot(); + }); + + it('shows correct classname', async () => { + render({children}); + + const filePickerButton = await screen.findByRole('button'); + expect(filePickerButton).toHaveClass( + ComponentClassName.FileUploaderFilePicker + ); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/__snapshots__/FilePicker.test.tsx.snap b/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/__snapshots__/FilePicker.test.tsx.snap new file mode 100644 index 00000000000..ca7ea306474 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FilePicker/__tests__/__snapshots__/FilePicker.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FilePicker renders correctly 1`] = ` +
+ +
+`; diff --git a/packages/react-storage/src/components/FileUploader/ui/FilePicker/index.ts b/packages/react-storage/src/components/FileUploader/ui/FilePicker/index.ts new file mode 100644 index 00000000000..164ec8fd806 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/FilePicker/index.ts @@ -0,0 +1 @@ +export { FilePicker, FilePickerProps } from './FilePicker'; diff --git a/packages/react-storage/src/components/FileUploader/ui/index.ts b/packages/react-storage/src/components/FileUploader/ui/index.ts new file mode 100644 index 00000000000..92cbefd6fe5 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/ui/index.ts @@ -0,0 +1,6 @@ +export { Container, ContainerProps } from './Container'; +export { DropZone, DropZoneProps } from './DropZone'; +export { FileList, FileListProps } from './FileList'; +export { FileListHeader, FileListHeaderProps } from './FileListHeader'; +export { FileListFooter, FileListFooterProps } from './FileListFooter'; +export { FilePicker, FilePickerProps } from './FilePicker'; diff --git a/packages/react-storage/src/components/FileUploader/utils/__tests__/checkMaxFileSize.test.ts b/packages/react-storage/src/components/FileUploader/utils/__tests__/checkMaxFileSize.test.ts new file mode 100644 index 00000000000..891cfb3a96b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/__tests__/checkMaxFileSize.test.ts @@ -0,0 +1,38 @@ +import { checkMaxFileSize } from '../checkMaxFileSize'; + +const imageFile = new File(['hello'], 'hello.png', { type: 'image/png' }); + +const getFileSizeErrorText = (sizeText: string) => `Error over ${sizeText}`; + +describe('checkMaxFileSize', () => { + it('returns empty string if maxFileSize is undefined', () => { + const message = checkMaxFileSize({ + file: imageFile, + maxFileSize: undefined, + getFileSizeErrorText, + }); + + expect(message).toBe(''); + }); + + it('returns empty string if file size is under maxFileSize', () => { + const message = checkMaxFileSize({ + file: imageFile, + maxFileSize: 6, + getFileSizeErrorText, + }); + + expect(message).toBe(''); + }); + + it('returns correct max error string if file size is over maxFileSize', () => { + const maxFileSize = 4; + const message = checkMaxFileSize({ + file: imageFile, + maxFileSize, + getFileSizeErrorText, + }); + + expect(message).toBe(`Error over ${maxFileSize} B`); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/utils/__tests__/filterAllowedFiles.test.ts b/packages/react-storage/src/components/FileUploader/utils/__tests__/filterAllowedFiles.test.ts new file mode 100644 index 00000000000..e3edb59ba04 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/__tests__/filterAllowedFiles.test.ts @@ -0,0 +1,72 @@ +import { filterAllowedFiles } from '../filterAllowedFiles'; +const imageFile = new File(['hello'], 'hello.png', { type: 'image/png' }); +const docFile = new File(['goodbye'], 'goodbye.doc', { + type: 'application/msword', +}); + +describe('filterAllowedFiles', () => { + it('returns only image files with mimetype image/*', () => { + const acceptedFileTypes = ['image/*']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile]); + }); + it('returns only doc files', () => { + const acceptedFileTypes = ['.doc']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([docFile]); + }); + it('returns no acceptable files', () => { + const acceptedFileTypes = ['.xls']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([]); + }); + it('returns all files with undefined filter', () => { + const acceptedFileTypes = undefined; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile, docFile]); + }); + it('returns all files with empty array', () => { + const acceptedFileTypes: string[] = []; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile, docFile]); + }); + it('returns all files with star value in array', () => { + const acceptedFileTypes = ['*']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile, docFile]); + }); + it('returns all files with star value anywhere in array', () => { + const acceptedFileTypes = ['.doc', '*', '.xls']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile, docFile]); + }); + it('returns only image with star file specifier', () => { + const acceptedFileTypes = ['image/*']; + const acceptedFiles = filterAllowedFiles( + [imageFile, docFile], + acceptedFileTypes + ); + expect(acceptedFiles).toEqual([imageFile]); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts b/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts new file mode 100644 index 00000000000..901b261ee8b --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts @@ -0,0 +1,284 @@ +import * as AuthModule from 'aws-amplify/auth'; +import { UploadDataWithPathInput, UploadDataInput } from 'aws-amplify/storage'; +import { getInput, GetInputParams } from '../getInput'; + +const identityId = 'identity-id'; +const fetchAuthSpy = jest + .spyOn(AuthModule, 'fetchAuthSession') + .mockResolvedValue({ identityId }); + +const file = new File(['hello'], 'hello.png', { type: 'image/png' }); +const key = file.name; +const onProgress = jest.fn(); +const accessLevel = 'guest'; + +const processFilePrefix = 'my-prefix/'; +const processFile: GetInputParams['processFile'] = ({ key, ...rest }) => ({ + key: `${processFilePrefix}${key}`, + ...rest, +}); + +const stringPath = 'my-path/'; + +const onProcessFileSuccess = jest.fn(); +const inputBase: Omit = { + file, + key, + onProgress, + processFile: undefined, + onProcessFileSuccess, +}; +const pathStringInput: GetInputParams = { + ...inputBase, + accessLevel: undefined, + path: stringPath, +}; +const pathCallbackInput: GetInputParams = { + ...inputBase, + accessLevel: undefined, + path: ({ identityId }) => `${stringPath}${identityId}`, +}; + +const accessLevelWithoutPathInput: GetInputParams = { + ...inputBase, + accessLevel, + path: undefined, +}; + +const accessLevelWithPathInput: GetInputParams = { + ...inputBase, + accessLevel, + file, + key, + path: stringPath, +}; + +describe('getInput', () => { + beforeEach(() => { + onProcessFileSuccess.mockClear(); + fetchAuthSpy.mockClear(); + }); + + it('resolves an UploadDataWithPathInput with a string `path` as expected', async () => { + const expected: UploadDataWithPathInput = { + data: file, + options: { + contentType: file.type, + useAccelerateEndpoint: undefined, + onProgress, + }, + path: `${stringPath}${key}`, + }; + + const input = getInput(pathStringInput); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('resolves an UploadDataWithPathInput with a callback `path` as expected', async () => { + const expected: UploadDataWithPathInput = { + data: file, + options: { + contentType: file.type, + useAccelerateEndpoint: undefined, + onProgress, + }, + path: `${stringPath}${identityId}${key}`, + }; + + const input = getInput(pathCallbackInput); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('resolves an UploadDataInput without a `path` as expected', async () => { + const expected: UploadDataInput = { + data: file, + options: { + accessLevel, + contentType: file.type, + useAccelerateEndpoint: undefined, + onProgress, + }, + key, + }; + + const input = getInput(accessLevelWithoutPathInput); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('resolves an UploadDataInput with a `path` as expected', async () => { + const expected: UploadDataInput = { + data: file, + options: { + accessLevel, + contentType: file.type, + useAccelerateEndpoint: undefined, + onProgress, + }, + key: `${stringPath}${key}`, + }; + + const input = getInput(accessLevelWithPathInput); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('handles a `processFile` param expected', async () => { + const expected: UploadDataWithPathInput = { + data: file, + options: { + contentType: file.type, + useAccelerateEndpoint: undefined, + onProgress, + }, + path: `${stringPath}${identityId}${processFilePrefix}${key}`, + }; + + const input = getInput({ ...pathCallbackInput, processFile }); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('calls `onProcessFileSuccess` when `processFile` is provided', async () => { + const processedKey = `processedKey`; + + const input = getInput({ + ...pathStringInput, + processFile: ({ key: _, ...rest }) => ({ + key: processedKey, + ...rest, + }), + }); + + await input(); + + expect(onProcessFileSuccess).toHaveBeenCalledTimes(1); + expect(onProcessFileSuccess).toHaveBeenCalledWith({ + processedKey, + }); + }); + + it('does not call `onProcessFileSuccess` when `processFile` is not provided', async () => { + const input = getInput(pathStringInput); + + await input(); + + expect(onProcessFileSuccess).not.toHaveBeenCalled(); + }); + + it('includes additional values returned from `processFile` in `options`', async () => { + const contentDisposition = 'attachment'; + const metadata = { key }; + + const expected: UploadDataWithPathInput = { + data: file, + options: { + contentDisposition, + contentType: file.type, + metadata, + onProgress, + useAccelerateEndpoint: undefined, + }, + path: `${stringPath}${processFilePrefix}${key}`, + }; + + const input = getInput({ + ...pathStringInput, + processFile: ({ key, ...rest }) => ({ + key: `${processFilePrefix}${key}`, + metadata, + contentDisposition, + ...rest, + }), + }); + + const output = await input(); + + expect(output).toStrictEqual(expected); + expect(output.options?.metadata).toStrictEqual(metadata); + expect(output.options?.contentDisposition).toStrictEqual( + contentDisposition + ); + }); + + it('calls `onProcessFileSuccess` after fetchAuthSession', async () => { + const processedKey = `processedKey`; + + const input = getInput({ + ...pathCallbackInput, + processFile: ({ key: _, ...rest }) => ({ + key: processedKey, + ...rest, + }), + }); + + await input(); + + const fetchAuthSessionCallOrder = fetchAuthSpy.mock.invocationCallOrder[0]; + const onProcessFileSuccessCallORder = + onProcessFileSuccess.mock.invocationCallOrder[0]; + expect(fetchAuthSessionCallOrder).toBeLessThan( + onProcessFileSuccessCallORder + ); + + expect(fetchAuthSpy).toHaveBeenCalledTimes(1); + expect(onProcessFileSuccess).toHaveBeenCalledTimes(1); + expect(onProcessFileSuccess).toHaveBeenCalledWith({ + processedKey, + }); + }); + + it('defaults `options.contentType` to "binary/octet-stream" when no file type is provided', async () => { + const data = new File(['hello'], 'hello.png'); + const expected: UploadDataWithPathInput = { + data, + options: { + contentType: 'binary/octet-stream', + useAccelerateEndpoint: undefined, + onProgress, + }, + path: `${stringPath}${key}`, + }; + + const input = getInput({ ...pathStringInput, file: data }); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); + + it('accepts useAccelerateEndpoint', async () => { + const data = new File(['hello'], 'hello.png'); + const expected: UploadDataWithPathInput = { + data, + options: { + contentType: 'binary/octet-stream', + useAccelerateEndpoint: true, + onProgress, + }, + path: `${stringPath}${key}`, + }; + + const input = getInput({ + ...pathStringInput, + file: data, + useAccelerateEndpoint: true, + }); + + const output = await input(); + + expect(output).toStrictEqual(expected); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/utils/__tests__/uploadFile.test.ts b/packages/react-storage/src/components/FileUploader/utils/__tests__/uploadFile.test.ts new file mode 100644 index 00000000000..eae7186f3cc --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/__tests__/uploadFile.test.ts @@ -0,0 +1,161 @@ +import * as Storage from 'aws-amplify/storage'; + +import { UploadFileProps, uploadFile } from '../uploadFile'; + +const imageFile = new File(['hello'], 'hello.png', { type: 'image/png' }); +const key = imageFile.name; +const data = imageFile; + +const onError = jest.fn(); +const onComplete = jest.fn(); +const onProgress = jest.fn(); + +const uploadDataSpy = jest.spyOn(Storage, 'uploadData'); + +describe('uploadFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('behaves as expected with an accessLevel provided in the input', async () => { + const uploadDataOutput: Storage.UploadDataOutput = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'SUCCESS', + result: Promise.resolve({ key, data }), + }; + + uploadDataSpy.mockReturnValueOnce(uploadDataOutput); + const input: UploadFileProps['input'] = () => + Promise.resolve({ + data, + key, + options: { + accessLevel: 'guest', + contentType: 'image/png', + onProgress, + }, + }); + const { result } = await uploadFile({ input, onComplete }); + + await result; + + expect(uploadDataSpy).toHaveBeenCalledWith({ + data, + key, + options: { + accessLevel: 'guest', + contentType: imageFile.type, + onProgress: expect.any(Function), + }, + }); + + expect(onComplete).toHaveBeenCalledWith({ key, data }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('behaves as expected without an accessLevel provided in the input', async () => { + const path = `my-path/${key}`; + const uploadDataPathOutput: Storage.UploadDataWithPathOutput = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'SUCCESS', + result: Promise.resolve({ path, data }), + }; + // @ts-expect-error amplify storage doesn't expose the base overload of `uploadData` + uploadDataSpy.mockReturnValueOnce(uploadDataPathOutput); + const input: UploadFileProps['input'] = () => + Promise.resolve({ + data, + path, + options: { contentType: 'image/png', onProgress }, + }); + const { result } = await uploadFile({ + input, + onComplete, + }); + + await result; + + expect(uploadDataSpy).toHaveBeenCalledWith({ + data, + options: { + contentType: imageFile.type, + onProgress: expect.any(Function), + }, + path, + }); + + expect(onComplete).toHaveBeenCalledWith({ path, data }); + expect(onError).not.toHaveBeenCalled(); + }); + + it('calls onStart as expected', async () => { + const onStart = jest.fn(); + const uploadDataOutput: Storage.UploadDataOutput = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + state: 'SUCCESS', + result: Promise.resolve({ key, data }), + }; + + uploadDataSpy.mockReturnValueOnce(uploadDataOutput); + const input: UploadFileProps['input'] = () => + Promise.resolve({ + data, + key, + options: { + accessLevel: 'guest', + contentType: 'image/png', + onProgress, + }, + }); + const { result } = await uploadFile({ input, onComplete, onStart }); + + await result; + + expect(uploadDataSpy).toHaveBeenCalledWith({ + data, + key, + options: { + accessLevel: 'guest', + contentType: imageFile.type, + onProgress: expect.any(Function), + }, + }); + + expect(onStart).toHaveBeenCalledWith({ key, uploadTask: uploadDataOutput }); + }); + + it('calls errorCallback on upload error', async () => { + const error = new Error('Error'); + uploadDataSpy.mockReturnValueOnce({ + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + result: Promise.reject(error), + state: 'ERROR', + }); + + const input: UploadFileProps['input'] = () => + Promise.resolve({ + data, + key, + options: { + accessLevel: 'guest', + contentType: 'image/png', + onProgress, + }, + }); + const { result } = await uploadFile({ input, onComplete, onError }); + + await expect(result).rejects.toThrow(); + expect(onProgress).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith({ error, key }); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-storage/src/components/FileUploader/utils/checkMaxFileSize.ts b/packages/react-storage/src/components/FileUploader/utils/checkMaxFileSize.ts new file mode 100644 index 00000000000..261c0398e2f --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/checkMaxFileSize.ts @@ -0,0 +1,17 @@ +import { humanFileSize } from '@aws-amplify/ui'; + +export const checkMaxFileSize = ({ + file, + getFileSizeErrorText, + maxFileSize, +}: { + file: File; + getFileSizeErrorText: (sizeText: string) => string; + maxFileSize?: number; +}): string => { + if (maxFileSize === undefined) return ''; + if (file.size > maxFileSize) { + return getFileSizeErrorText(humanFileSize(maxFileSize, true)); + } + return ''; +}; diff --git a/packages/react-storage/src/components/FileUploader/utils/displayText.ts b/packages/react-storage/src/components/FileUploader/utils/displayText.ts new file mode 100644 index 00000000000..403cb3420e9 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/displayText.ts @@ -0,0 +1,63 @@ +import { DisplayTextTemplate } from '@aws-amplify/ui'; + +export type FileUploaderDisplayText = DisplayTextTemplate<{ + getFilesUploadedText?: (count: number) => string; + getFileSizeErrorText?: (sizeText: string) => string; + getRemainingFilesText?: (count: number) => string; + getSelectedFilesText?: (count: number) => string; + getUploadingText?: (percentage: number) => string; + getUploadButtonText?: (count: number) => string; + getMaxFilesErrorText?: (count: number) => string; + getErrorText?: (message: string) => string; + doneButtonText?: string; + clearAllButtonText?: string; + extensionNotAllowedText?: string; + browseFilesText?: string; + dropFilesText?: string; + pauseButtonText?: string; + resumeButtonText?: string; + uploadSuccessfulText?: string; + getPausedText?: (percentage: number) => string; +}>; + +export type FileUploaderDisplayTextDefault = Required; + +export const defaultFileUploaderDisplayText: FileUploaderDisplayTextDefault = { + getFilesUploadedText(count: number): string { + return `${count} ${count === 1 ? 'file uploaded' : 'files uploaded'}`; + }, + getFileSizeErrorText(sizeText: string): string { + return `File size must be below ${sizeText}`; + }, + getRemainingFilesText(count: number): string { + return `${count} ${count === 1 ? 'file' : 'files'} uploading`; + }, + getSelectedFilesText(count: number): string { + return `${count} ${count === 1 ? 'file' : 'files'} selected`; + }, + getUploadingText(percentage: number): string { + return `Uploading${percentage > 0 ? `: ${percentage}%` : ''}`; + }, + getUploadButtonText(count: number): string { + return `Upload ${count} ${count === 1 ? 'file' : 'files'}`; + }, + getMaxFilesErrorText(count: number): string { + return `Cannot choose more than ${count} ${ + count === 1 ? 'file' : 'files' + }. Remove files before updating`; + }, + getErrorText(message: string): string { + return message; + }, + doneButtonText: 'Done', + clearAllButtonText: 'Clear all', + extensionNotAllowedText: 'Extension not allowed', + browseFilesText: 'Browse files', + dropFilesText: 'Drop files here or', + pauseButtonText: 'Pause', + resumeButtonText: 'Resume', + uploadSuccessfulText: 'Uploaded', + getPausedText(percentage: number): string { + return `Paused: ${percentage}%`; + }, +}; diff --git a/packages/react-storage/src/components/FileUploader/utils/filterAllowedFiles.ts b/packages/react-storage/src/components/FileUploader/utils/filterAllowedFiles.ts new file mode 100644 index 00000000000..323203d28dd --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/filterAllowedFiles.ts @@ -0,0 +1,30 @@ +export const filterAllowedFiles = ( + files: File[], + acceptedFileTypes?: string[] +): File[] => { + // Allow any files if acceptedFileTypes is undefined, empty array, or contains '*' + if ( + !acceptedFileTypes || + acceptedFileTypes.length === 0 || + acceptedFileTypes.includes('*') + ) { + return files; + } + + // Remove any files that are not in the accepted file list + return files.filter((file) => { + const fileName = file.name || ''; + const mimeType = (file.type || '').toLowerCase(); + const baseMimeType = mimeType.replace(/\/.*$/, ''); + return acceptedFileTypes.some((type) => { + const validType = type.trim().toLowerCase(); + if (validType.charAt(0) === '.') { + return fileName.toLowerCase().endsWith(validType); + } else if (validType.endsWith('/*')) { + // This is something like a image/* mime type + return baseMimeType === validType.replace(/\/.*$/, ''); + } + return mimeType === validType; + }); + }); +}; diff --git a/packages/react-storage/src/components/FileUploader/utils/getInput.ts b/packages/react-storage/src/components/FileUploader/utils/getInput.ts new file mode 100644 index 00000000000..9f810d1d1b1 --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/getInput.ts @@ -0,0 +1,77 @@ +import { fetchAuthSession } from 'aws-amplify/auth'; +import { StorageAccessLevel } from '@aws-amplify/core'; +import { UploadDataWithPathInput, UploadDataInput } from 'aws-amplify/storage'; + +import { isString, isTypedFunction } from '@aws-amplify/ui'; + +import { ProcessFile } from '../types'; +import { resolveFile } from './resolveFile'; +import { PathCallback, PathInput } from './uploadFile'; + +export interface GetInputParams { + accessLevel: StorageAccessLevel | undefined; + file: File; + key: string; + onProcessFileSuccess: (input: { processedKey: string }) => void; + onProgress: NonNullable['onProgress']; + path: string | PathCallback | undefined; + processFile: ProcessFile | undefined; + useAccelerateEndpoint?: boolean; +} + +export const getInput = ({ + accessLevel, + file, + key, + onProcessFileSuccess, + onProgress, + path, + processFile, + useAccelerateEndpoint, +}: GetInputParams) => { + return async (): Promise => { + const hasCallbackPath = isTypedFunction(path); + const hasStringPath = isString(path); + + const hasKeyInput = !!accessLevel && !hasCallbackPath; + + const { + file: data, + key: processedKey, + ...rest + } = await resolveFile({ file, key, processFile }); + + const contentType = file.type || 'binary/octet-stream'; + + // IMPORTANT: always pass `...rest` here for backwards compatibility + const options = { contentType, onProgress, useAccelerateEndpoint, ...rest }; + + let inputResult: PathInput | UploadDataInput; + if (hasKeyInput) { + // legacy handling of `path` is to prefix to `fileKey` + const resolvedKey = hasStringPath + ? `${path}${processedKey}` + : processedKey; + + inputResult = { + data, + key: resolvedKey, + options: { ...options, accessLevel }, + }; + } else { + const { identityId } = await fetchAuthSession(); + const resolvedPath = `${ + hasCallbackPath ? path({ identityId }) : path + }${processedKey}`; + + inputResult = { data: file, path: resolvedPath, options }; + } + + if (processFile) { + // provide post-processing value of target `key` + onProcessFileSuccess({ processedKey }); + } + + return inputResult; + }; +}; diff --git a/packages/react-storage/src/components/FileUploader/utils/index.ts b/packages/react-storage/src/components/FileUploader/utils/index.ts new file mode 100644 index 00000000000..243a4d1f8eb --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/index.ts @@ -0,0 +1,16 @@ +export { checkMaxFileSize } from './checkMaxFileSize'; +export { + defaultFileUploaderDisplayText, + FileUploaderDisplayText, + FileUploaderDisplayTextDefault, +} from './displayText'; +export { filterAllowedFiles } from './filterAllowedFiles'; +export { getInput } from './getInput'; + +export { + PathCallback, + TaskEvent, + TaskHandler, + uploadFile, + UploadTask, +} from './uploadFile'; diff --git a/packages/react-storage/src/components/FileUploader/utils/resolveFile.ts b/packages/react-storage/src/components/FileUploader/utils/resolveFile.ts new file mode 100644 index 00000000000..faac8b90d1d --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/resolveFile.ts @@ -0,0 +1,23 @@ +import { isFunction } from '@aws-amplify/ui'; +import { ProcessFile, ProcessFileParams } from '../types'; + +/** + * Utility function that takes the processFile prop, along with a file a key + * and returns a Promise that resolves to { file, key, ..rest } + * regardless if processFile is defined and if it is sync or async + */ +export const resolveFile = ({ + processFile, + ...input +}: ProcessFileParams & { + processFile?: ProcessFile; +}): Promise => { + return new Promise((resolve, reject) => { + const result = isFunction(processFile) ? processFile(input) : input; + if (result instanceof Promise) { + result.then(resolve).catch(reject); + } else { + resolve(result); + } + }); +}; diff --git a/packages/react-storage/src/components/FileUploader/utils/uploadFile.ts b/packages/react-storage/src/components/FileUploader/utils/uploadFile.ts new file mode 100644 index 00000000000..762dbdca2ee --- /dev/null +++ b/packages/react-storage/src/components/FileUploader/utils/uploadFile.ts @@ -0,0 +1,76 @@ +import { + uploadData, + UploadDataInput, + UploadDataWithPathOutput, + UploadDataWithPathInput, + UploadDataOutput, +} from 'aws-amplify/storage'; +import { isFunction } from '@aws-amplify/ui'; + +/** + * Callback provided an input containing the current `identityId` + * + * @param {{identityId: string | undefined}} input - Input parameters + * @returns target S3 bucket key + */ +export type PathCallback = (input: { + identityId: string | undefined; +}) => string; + +export type UploadTask = UploadDataOutput | UploadDataWithPathOutput; +export interface TaskEvent { + id: string; + uploadTask: UploadTask; +} + +// omit `path` callback, `path` must always be a string to support resolving +// `path` callback with `fileKey` and `identityId` +export type PathInput = Omit & { + path: string; +}; + +export type TaskHandler = (event: TaskEvent) => void; +export interface UploadFileProps { + input: () => Promise; + onComplete?: ( + result: Awaited<(UploadDataWithPathOutput | UploadDataOutput)['result']> + ) => void; + onError?: (event: { key: string; error: Error }) => void; + onStart?: (event: { key: string; uploadTask: UploadTask }) => void; +} + +type UploadData = ( + input: PathInput | UploadDataInput +) => UploadDataWithPathOutput | UploadDataOutput; + +export async function uploadFile({ + input, + onError, + onStart, + onComplete, +}: UploadFileProps): Promise { + const resolvedInput = await input(); + + const uploadTask = (uploadData as UploadData)(resolvedInput); + + const key = + (resolvedInput as { key: string })?.key ?? + (resolvedInput as { path: string })?.path; + + if (isFunction(onStart)) { + onStart({ key, uploadTask }); + } + + uploadTask.result + .then((result) => { + if (isFunction(onComplete) && uploadTask.state === 'SUCCESS') { + onComplete(result); + } + }) + .catch((error: Error) => { + if (isFunction(onError)) { + onError({ key, error }); + } + }); + return uploadTask; +} diff --git a/packages/react-storage/src/components/StorageManager/StorageManager.tsx b/packages/react-storage/src/components/StorageManager/StorageManager.tsx index 79a99005c6c..0eb13627e40 100644 --- a/packages/react-storage/src/components/StorageManager/StorageManager.tsx +++ b/packages/react-storage/src/components/StorageManager/StorageManager.tsx @@ -62,6 +62,12 @@ const StorageManagerBase = React.forwardRef(function StorageManager( }: StorageManagerPathProps | StorageManagerProps, ref: React.ForwardedRef ): JSX.Element { + useDeprecationWarning({ + message: + 'The `StorageManager` component has been renamed as the `FileUploader` component.', + shouldWarn: false, + }); + if (!maxFileCount) { // eslint-disable-next-line no-console console.warn(MISSING_REQUIRED_PROPS_MESSAGE); diff --git a/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/FileControl.test.tsx b/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/FileControl.test.tsx index 227ec9d9091..ececed8160b 100644 --- a/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/FileControl.test.tsx +++ b/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/FileControl.test.tsx @@ -101,4 +101,19 @@ describe('FileControl', () => { expect(container).toMatchSnapshot(); }); + + it('should default to showThumbnails being true', () => { + //@ts-expect-error + fileControlProps.showThumbnails = undefined; + + const { container } = render(); + + expect( + container.getElementsByClassName( + `${ComponentClassName.StorageManagerFileImage}` + ) + ).toHaveLength(1); + + expect(container).toMatchSnapshot(); + }); }); diff --git a/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap b/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap index 52a96b05e66..bb8a73ced68 100644 --- a/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap +++ b/packages/react-storage/src/components/StorageManager/ui/FileList/__tests__/__snapshots__/FileControl.test.tsx.snap @@ -250,3 +250,108 @@ exports[`FileControl renders thumbnails 1`] = `
`; + +exports[`FileControl should default to showThumbnails being true 1`] = ` +
+
+
+
+ + + + + +
+
+

+ fileName +

+
+ + + + + + + + +
+

+ Uploading +

+
+
+`; diff --git a/packages/react-storage/src/components/index.ts b/packages/react-storage/src/components/index.ts index 98e15752280..6e2b1cc219b 100644 --- a/packages/react-storage/src/components/index.ts +++ b/packages/react-storage/src/components/index.ts @@ -1,3 +1,5 @@ +export { FileUploader, FileUploaderProps } from './FileUploader'; + export { StorageImage, StorageImageProps } from './StorageImage'; export { diff --git a/packages/react-storage/src/index.ts b/packages/react-storage/src/index.ts index 4815f931238..58770585c53 100644 --- a/packages/react-storage/src/index.ts +++ b/packages/react-storage/src/index.ts @@ -1,4 +1,6 @@ export { + FileUploader, + FileUploaderProps, StorageImage, StorageImageProps, StorageManager, diff --git a/packages/ui/src/theme/components/fileUploader.ts b/packages/ui/src/theme/components/fileUploader.ts new file mode 100644 index 00000000000..244c164eb19 --- /dev/null +++ b/packages/ui/src/theme/components/fileUploader.ts @@ -0,0 +1,28 @@ +import { Modifiers, ComponentStyles, Elements, ColorTheme } from './utils'; + +export type FileUploaderTheme = + ComponentStyles & + Modifiers & + Elements< + { + dropzone?: ComponentStyles & Modifiers<'active' | 'small', Required>; + dropzone__icon?: ComponentStyles; + dropzone__text?: ComponentStyles; + file?: ComponentStyles; + file__picker?: ComponentStyles; + file__wrapper?: ComponentStyles; + file__name?: ComponentStyles; + file__size?: ComponentStyles; + file__list?: ComponentStyles; + file__main?: ComponentStyles; + file__image?: ComponentStyles; + file__status?: ComponentStyles & + Modifiers<'error' | 'success', Required>; + loader?: ComponentStyles; + previewer?: ComponentStyles; + previewer__text?: ComponentStyles; + previewer__footer?: ComponentStyles; + previewer__actions?: ComponentStyles; + }, + Required + >; diff --git a/packages/ui/src/types/primitives/componentClassName.ts b/packages/ui/src/types/primitives/componentClassName.ts index 8c8ce03850d..216791a1608 100644 --- a/packages/ui/src/types/primitives/componentClassName.ts +++ b/packages/ui/src/types/primitives/componentClassName.ts @@ -59,6 +59,25 @@ export const ComponentClassName = { FieldGroupFieldWrapper: 'amplify-field-group__field-wrapper', Fieldset: 'amplify-fieldset', FieldsetLegend: 'amplify-fieldset__legend', + FileUploader: 'amplify-fileuploader', + FileUploaderDropZone: 'amplify-fileuploader__dropzone', + FileUploaderDropZoneIcon: 'amplify-fileuploader__dropzone__icon', + FileUploaderDropZoneText: 'amplify-fileuploader__dropzone__text', + FileUploaderFilePicker: 'amplify-fileuploader__file__picker', + FileUploaderFile: 'amplify-fileuploader__file', + FileUploaderFileWrapper: 'amplify-fileuploader__file__wrapper', + FileUploaderFileList: 'amplify-fileuploader__file__list', + FileUploaderFileName: 'amplify-fileuploader__file__name', + FileUploaderFileSize: 'amplify-fileuploader__file__size', + FileUploaderFileInfo: 'amplify-fileuploader__file__info', + FileUploaderFileImage: 'amplify-fileuploader__file__image', + FileUploaderFileMain: 'amplify-fileuploader__file__main', + FileUploaderFileStatus: 'amplify-fileuploader__file__status', + FileUploaderLoader: 'amplify-fileuploader__loader', + FileUploaderPreviewer: 'amplify-fileuploader__previewer', + FileUploaderPreviewerText: 'amplify-fileuploader__previewer__text', + FileUploaderPreviewerActions: 'amplify-fileuploader__previewer__actions', + FileUploaderPreviewerFooter: 'amplify-fileuploader__previewer__footer', Flex: 'amplify-flex', Grid: 'amplify-grid', Heading: 'amplify-heading', diff --git a/packages/ui/src/utils/setUserAgent/constants.ts b/packages/ui/src/utils/setUserAgent/constants.ts index fdc3426fb10..6ca75550a3d 100644 --- a/packages/ui/src/utils/setUserAgent/constants.ts +++ b/packages/ui/src/utils/setUserAgent/constants.ts @@ -37,6 +37,14 @@ export const AUTHENTICATOR_INPUT_BASE: Omit< category: Category.Auth, }; +export const FILE_UPLOADER_BASE_INPUT: Omit< + StorageUserAgentInput, + 'additionalDetails' +> = { + apis: [StorageAction.UploadData], + category: Category.Storage, +}; + export const IN_APP_MESSAGING_INPUT_BASE: Omit< InAppMessagingUserAgentInput, 'additionalDetails' diff --git a/packages/ui/src/utils/setUserAgent/setUserAgent.ts b/packages/ui/src/utils/setUserAgent/setUserAgent.ts index 54121a78722..45e4065c134 100644 --- a/packages/ui/src/utils/setUserAgent/setUserAgent.ts +++ b/packages/ui/src/utils/setUserAgent/setUserAgent.ts @@ -3,6 +3,7 @@ import { setCustomUserAgent } from '@aws-amplify/core/internals/utils'; import { ACCOUNT_SETTINGS_INPUT_BASE, AUTHENTICATOR_INPUT_BASE, + FILE_UPLOADER_BASE_INPUT, IN_APP_MESSAGING_INPUT_BASE, LOCATION_SEARCH_INPUT_BASE, MAP_VIEW_INPUT_BASE, @@ -28,6 +29,7 @@ export type ComponentName = | 'ChangePassword' | 'DeleteUser' | 'FaceLivenessDetector' + | 'FileUploader' | 'InAppMessaging' | 'LocationSearch' | 'MapView' @@ -76,6 +78,13 @@ export const setUserAgent = ({ }); break; } + case 'FileUploader': { + setCustomUserAgent({ + ...FILE_UPLOADER_BASE_INPUT, + additionalDetails: [[componentName], packageData], + }); + break; + } case 'InAppMessaging': { setCustomUserAgent({ ...IN_APP_MESSAGING_INPUT_BASE,