From 2f85df1960cad855b0d0ea53e61a863e27d14d65 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Tue, 12 Nov 2024 13:21:00 -0500 Subject: [PATCH 1/9] SWC-7064 - FileUploadProgress component --- .../synapse-react-client/.storybook/main.mts | 4 - .../FileUploadProgress.stories.ts | 65 +++++++ .../EntityUpload/FileUploadProgress.test.tsx | 160 ++++++++++++++++ .../EntityUpload/FileUploadProgress.tsx | 180 ++++++++++++++++++ 4 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.stories.ts create mode 100644 packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.test.tsx create mode 100644 packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx diff --git a/packages/synapse-react-client/.storybook/main.mts b/packages/synapse-react-client/.storybook/main.mts index 3c81f5b644..a3aafa856f 100644 --- a/packages/synapse-react-client/.storybook/main.mts +++ b/packages/synapse-react-client/.storybook/main.mts @@ -65,10 +65,6 @@ const config: StorybookConfig = { return mergeConfig(config, customStorybookConfig) }, - - docs: { - autodocs: false, - }, } export default config diff --git a/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.stories.ts b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.stories.ts new file mode 100644 index 0000000000..eb216bcde5 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.stories.ts @@ -0,0 +1,65 @@ +import { Meta, StoryObj } from '@storybook/react' +import { fn } from '@storybook/test' +import { FileUploadProgress } from './FileUploadProgress' + +const meta = { + title: 'Synapse/Upload/FileUploadProgress', + component: FileUploadProgress, + args: { + uploadedSizeInBytes: 1024 * 1024 * 50, + totalSizeInBytes: 1024 * 1024 * 100, + onCancel: fn(), + onPause: fn(), + onResume: fn(), + onRemove: fn(), + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Uploading: Story = { + args: { + fileName: 'UploadingFile.jpg', + status: 'UPLOADING', + }, +} + +export const Preparing: Story = { + args: { + fileName: 'PreparingToUploadFile.fastq', + status: 'PREPARING', + uploadedSizeInBytes: 0, + }, +} + +export const Paused: Story = { + args: { + fileName: 'path/to/paused.png', + status: 'PAUSED', + }, +} + +export const Cancelled: Story = { + args: { + fileName: 'Cancelled.pdf', + status: 'CANCELED_BY_USER', + }, +} + +export const Failed: Story = { + args: { + fileName: 'Failed.java', + status: 'FAILED', + errorMessage: 'Something went wrong.', + }, +} +export const Complete: Story = { + args: { + fileName: 'Complete.tsx', + status: 'COMPLETE', + uploadedSizeInBytes: 1024 * 1024 * 100, + totalSizeInBytes: 1024 * 1024 * 100, + }, +} diff --git a/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.test.tsx new file mode 100644 index 0000000000..270572d951 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.test.tsx @@ -0,0 +1,160 @@ +import userEvent from '@testing-library/user-event' +import React from 'react' +import { render, screen } from '@testing-library/react' +import { + FileUploadProgress, + FileUploadProgressProps, +} from './FileUploadProgress' + +describe('FileUploadProgress', () => { + const props = { + status: 'PREPARING', + fileName: 'file.txt', + uploadedSizeInBytes: 0, + totalSizeInBytes: 1024 * 1024 * 100, + onCancel: jest.fn(), + onPause: jest.fn(), + onResume: jest.fn(), + onRemove: jest.fn(), + } satisfies FileUploadProgressProps + + function renderComponent( + propOverrides: Partial = {}, + ) { + const user = userEvent.setup() + + render() + + const pauseButton = screen.queryByRole('button', { name: 'Pause' }) + const resumeButton = screen.queryByRole('button', { name: 'Resume' }) + const cancelButton = screen.queryByRole('button', { name: 'Cancel' }) + const removeButton = screen.queryByRole('button', { name: 'Remove' }) + + return { user, pauseButton, resumeButton, cancelButton, removeButton } + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('preparing upload', async () => { + const { user, pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ status: 'PREPARING' }) + + await screen.findByText('file.txt') + await screen.findByText('Preparing to upload...') + + expect(cancelButton).toBeInTheDocument() + + expect(pauseButton).not.toBeInTheDocument() + expect(resumeButton).not.toBeInTheDocument() + expect(removeButton).not.toBeInTheDocument() + + await user.click(cancelButton!) + expect(props.onCancel).toHaveBeenCalledTimes(1) + }) + + test('uploading', async () => { + const { user, pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ + status: 'UPLOADING', + uploadedSizeInBytes: 1024 * 1024 * 50, + }) + + await screen.findByText('file.txt') + await screen.findByText('50.0 MB') + await screen.findByText('100.0 MB') + + expect(cancelButton).toBeInTheDocument() + expect(pauseButton).toBeInTheDocument() + + expect(resumeButton).not.toBeInTheDocument() + expect(removeButton).not.toBeInTheDocument() + + await user.click(cancelButton!) + expect(props.onCancel).toHaveBeenCalledTimes(1) + + await user.click(pauseButton!) + expect(props.onPause).toHaveBeenCalledTimes(1) + }) + + test('paused', async () => { + const { user, pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ + status: 'PAUSED', + uploadedSizeInBytes: 1024 * 1024 * 50, + }) + + await screen.findByText('file.txt') + await screen.findByText('50.0 MB') + await screen.findByText('100.0 MB') + + expect(cancelButton).toBeInTheDocument() + expect(resumeButton).toBeInTheDocument() + + expect(pauseButton).not.toBeInTheDocument() + expect(removeButton).not.toBeInTheDocument() + + await user.click(cancelButton!) + expect(props.onCancel).toHaveBeenCalledTimes(1) + + await user.click(resumeButton!) + expect(props.onResume).toHaveBeenCalledTimes(1) + }) + + test('canceled by user', async () => { + const { user, pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ + status: 'CANCELED_BY_USER', + }) + + await screen.findByText('file.txt') + await screen.findByText('Canceled') + + expect(removeButton).toBeInTheDocument() + + expect(cancelButton).not.toBeInTheDocument() + expect(pauseButton).not.toBeInTheDocument() + expect(resumeButton).not.toBeInTheDocument() + + await user.click(removeButton!) + expect(props.onRemove).toHaveBeenCalledTimes(1) + }) + + test('failed', async () => { + const { user, pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ + status: 'FAILED', + errorMessage: 'Something went wrong.', + }) + + await screen.findByText('file.txt') + await screen.findByText('Failed') + await screen.findByLabelText('Something went wrong.') + + expect(removeButton).toBeInTheDocument() + + expect(cancelButton).not.toBeInTheDocument() + expect(pauseButton).not.toBeInTheDocument() + expect(resumeButton).not.toBeInTheDocument() + + await user.click(removeButton!) + expect(props.onRemove).toHaveBeenCalledTimes(1) + }) + + test('complete', async () => { + const { pauseButton, resumeButton, cancelButton, removeButton } = + renderComponent({ + status: 'COMPLETE', + uploadedSizeInBytes: 1024 * 1024 * 100, + }) + + await screen.findByText('file.txt') + await screen.findAllByText('100.0 MB') + + expect(removeButton).not.toBeInTheDocument() + expect(cancelButton).not.toBeInTheDocument() + expect(pauseButton).not.toBeInTheDocument() + expect(resumeButton).not.toBeInTheDocument() + }) +}) diff --git a/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx new file mode 100644 index 0000000000..487249db4b --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx @@ -0,0 +1,180 @@ +import { + CheckCircleTwoTone, + DeleteTwoTone, + ErrorTwoTone, + Pause, + PlayArrow, +} from '@mui/icons-material' +import CloseIcon from '@mui/icons-material/Close' +import { + Box, + IconButton, + LinearProgress, + Tooltip, + Typography, +} from '@mui/material' +import React from 'react' +import { calculateFriendlyFileSize } from '../../utils/functions/calculateFriendlyFileSize' + +export type FileUploadProgressProps = { + /** The status of the upload. */ + status: + | 'PREPARING' + | 'UPLOADING' + | 'PAUSED' + | 'CANCELED_BY_USER' + | 'FAILED' + | 'COMPLETE' + /** The name of the file */ + fileName: string + /** The size of the file, in bytes */ + totalSizeInBytes: number + /** The number of this file's bytes uploaded so far. */ + uploadedSizeInBytes?: number + /** Invoked when the upload is cancelled. */ + onCancel: () => void + /** Invoked when the upload is paused. */ + onPause: () => void + /** Invoked when the resumed. */ + onResume: () => void + /** Invoked when the upload is removed from the list. */ + onRemove: () => void + /** An optional error message to display if the upload has been cancelled due to error. */ + errorMessage?: string +} + +/** + * Component that displays the upload progress of a file, with controls to pause or cancel the upload. + */ +export function FileUploadProgress(props: FileUploadProgressProps) { + const { + status, + fileName, + onCancel, + onPause, + onResume, + onRemove, + uploadedSizeInBytes = 0, + totalSizeInBytes, + errorMessage, + } = props + + const isPaused = status === 'PAUSED' + const isCanceled = status === 'CANCELED_BY_USER' || status === 'FAILED' + const isComplete = status === 'COMPLETE' + const isFailed = status === 'FAILED' + const isUploading = status === 'UPLOADING' + const isPreparingUpload = status === 'PREPARING' + + let progress: number | undefined = + (uploadedSizeInBytes / totalSizeInBytes) * 100 + + if (isPreparingUpload) { + progress = undefined + } else if (isFailed || isCanceled) { + progress = 0 + } + + return ( + + + + {fileName} + + {!isComplete && ( + + {isFailed && ( + + + + )} + {!isCanceled && ( + + { + onCancel() + }} + > + + + + )} + {isUploading && ( + + { + onPause() + }} + > + + + + )} + {isPaused && ( + + { + onResume() + }} + > + + + + )} + {isCanceled && ( + + { + onRemove() + }} + > + + + + )} + + )} + {isComplete && } + + + + {!isCanceled && ( + + {isPreparingUpload ? ( + 'Preparing to upload...' + ) : ( + <> + {calculateFriendlyFileSize(uploadedSizeInBytes, 1)} + + | + + {calculateFriendlyFileSize(totalSizeInBytes, 1)} + + )} + + )} + {isCanceled && ( + + {isFailed ? 'Failed' : 'Canceled'} + + )} + + + ) +} From 833eb6aa7cb5fedb36c382bbc44a0d9e8fa0316b Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 11:11:48 -0500 Subject: [PATCH 2/9] SWC-7064 - EntityUpload component & wrapper modal component --- .../EntityUpload/EntityUpload.stories.ts | 27 ++ .../EntityUpload/EntityUpload.test.tsx | 313 ++++++++++++++ .../components/EntityUpload/EntityUpload.tsx | 388 ++++++++++++++++++ .../EntityUpload/EntityUploadModal.tsx | 69 ++++ .../src/mocks/mock_upload_destination.ts | 10 +- .../synapse-react-client/src/umd.index.ts | 2 + .../functions/calculateFriendlyFileSize.ts | 10 +- .../useUploadFileEntities.ts | 2 +- 8 files changed, 810 insertions(+), 11 deletions(-) create mode 100644 packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts create mode 100644 packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx create mode 100644 packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx create mode 100644 packages/synapse-react-client/src/components/EntityUpload/EntityUploadModal.tsx diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts new file mode 100644 index 0000000000..1a864b3724 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react' +import { EntityUpload } from './EntityUpload' + +const meta = { + title: 'Synapse/Upload/EntityUpload', + component: EntityUpload, +} satisfies Meta +export default meta +type Story = StoryObj + +export const Demo: Story = { + args: { + containerId: 'syn23567475', + }, +} + +export const Dev: Story = { + args: { + containerId: 'syn12554559', + }, +} + +export const ExternalS3Bucket: Story = { + args: { + containerId: 'syn63917361', + }, +} diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx new file mode 100644 index 0000000000..70ab86bd23 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx @@ -0,0 +1,313 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import mockFileEntity from '../../mocks/entity/mockFileEntity' +import mockProject from '../../mocks/entity/mockProject' +import { + mockExternalObjectStoreUploadDestination, + mockExternalS3UploadDestination, + mockSynapseStorageUploadDestination, +} from '../../mocks/mock_upload_destination' +import { + useGetDefaultUploadDestination, + useGetEntity, +} from '../../synapse-queries/index' +import { getUseQuerySuccessMock } from '../../testutils/ReactQueryMockUtils' +import { createWrapper } from '../../testutils/TestingLibraryUtils' +import { + useUploadFileEntities, + UseUploadFileEntitiesReturn, +} from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' +import { + EntityUpload, + EntityUploadHandle, + EntityUploadProps, +} from './EntityUpload' +import { FileUploadProgress } from './FileUploadProgress' + +jest.mock( + '../../utils/hooks/useUploadFileEntity/useUploadFileEntities', + () => ({ + useUploadFileEntities: jest.fn(), + }), +) + +jest.mock('../../synapse-queries/entity/useEntity', () => ({ + useGetEntity: jest.fn(), +})) + +jest.mock('../../synapse-queries/file/useUploadDestination.ts', () => ({ + useGetDefaultUploadDestination: jest.fn(), +})) + +jest.mock('./FileUploadProgress', () => ({ + FileUploadProgress: jest.fn(() =>
), +})) + +const mockFileUploadProgress = jest.mocked(FileUploadProgress) +const mockUseUploadFileEntities = jest.mocked(useUploadFileEntities) +const mockUseGetEntity = jest.mocked(useGetEntity) +const mockUseGetDefaultUploadDestination = jest.mocked( + useGetDefaultUploadDestination, +) + +const mockUseUploadFileEntitiesReturn = { + state: 'WAITING', + isPrecheckingUpload: false, + activePrompts: [], + activeUploadCount: 0, + uploadProgress: [], + initiateUpload: jest.fn(), +} satisfies UseUploadFileEntitiesReturn + +describe('EntityUpload', () => { + function renderComponent(propOverrides: Partial = {}) { + const user = userEvent.setup() + const ref = React.createRef() + const result = render( + , + { + wrapper: createWrapper(), + }, + ) + + return { user, ref, result } + } + + beforeEach(() => { + jest.clearAllMocks() + + mockUseUploadFileEntities.mockReturnValue(mockUseUploadFileEntitiesReturn) + mockUseGetEntity.mockReturnValue(getUseQuerySuccessMock(mockProject.entity)) + mockUseGetDefaultUploadDestination.mockReturnValue( + getUseQuerySuccessMock(mockSynapseStorageUploadDestination), + ) + }) + + it('supports selecting files for upload into a container', async () => { + const { user, result } = renderComponent() + + // Verify the user can select either files or a folder -- we cannot test clicking these buttons with testing-library, however + await user.click(await screen.findByText('Click to upload')) + await screen.findByRole('menuitem', { name: 'Files' }) + await screen.findByRole('menuitem', { name: 'Folder' }) + + const fileInput = result.container.querySelector( + 'input[type="file"][id=filesToUpload]', + )! + expect(fileInput).toBeInTheDocument() + expect(fileInput).toHaveAttribute('multiple') + const filesToUpload = [ + new File(['contents'], 'file1.txt'), + new File(['contents'], 'file2.txt'), + ] + await user.upload(fileInput, filesToUpload) + + expect(mockUseUploadFileEntitiesReturn.initiateUpload).toHaveBeenCalledWith( + [ + { file: filesToUpload[0], rootContainerId: mockProject.entity.id }, + { file: filesToUpload[1], rootContainerId: mockProject.entity.id }, + ], + ) + }) + + it('supports uploading a new version of a specified FileEntity', async () => { + mockUseGetEntity.mockReturnValue( + getUseQuerySuccessMock(mockFileEntity.entity), + ) + const { user, result } = renderComponent({ + entityId: mockFileEntity.entity.id, + }) + + const fileInput = result.container.querySelector( + 'input[type="file"][id=filesToUpload]', + )! + expect(fileInput).toBeInTheDocument() + const fileToUpload = new File(['contents'], 'file1.txt') + + await user.upload(fileInput, fileToUpload) + + expect(mockUseUploadFileEntitiesReturn.initiateUpload).toHaveBeenCalledWith( + [{ file: fileToUpload, existingEntityId: mockFileEntity.entity.id }], + ) + }) + + it('does not support selecting a folder when updating a specified FileEntity', async () => { + mockUseGetEntity.mockReturnValue( + getUseQuerySuccessMock(mockFileEntity.entity), + ) + const { user, result } = renderComponent({ + entityId: mockFileEntity.entity.id, + }) + + // Verify no menu options appear, because uploading + await user.click(await screen.findByText('Click to upload')) + expect( + screen.queryByRole('menuitem', { name: 'Files' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('menuitem', { name: 'Folder' }), + ).not.toBeInTheDocument() + + // Verify that the file input does not support selecting multiple files + const fileInput = result.container.querySelector( + 'input[type="file"][id=filesToUpload]', + )! + expect(fileInput).toBeInTheDocument() + expect(fileInput).not.toHaveAttribute('multiple') + }) + + it('shows uploads in progress', async () => { + const hookReturnValue = { + ...mockUseUploadFileEntitiesReturn, + state: 'UPLOADING', + activeUploadCount: 1, + uploadProgress: [ + { + file: new File(['contents'], 'file1.txt'), + progress: { value: 1024 * 1024 * 50, total: 1024 * 1024 * 100 }, + status: 'UPLOADING', + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + remove: jest.fn(), + failureReason: undefined, + }, + ], + } satisfies UseUploadFileEntitiesReturn + mockUseUploadFileEntities.mockReturnValue(hookReturnValue) + + renderComponent({ + entityId: mockFileEntity.entity.id, + }) + + await screen.findByTestId('FileUploadProgress') + expect(mockFileUploadProgress).toHaveBeenLastCalledWith( + { + status: 'UPLOADING', + fileName: 'file1.txt', + totalSizeInBytes: hookReturnValue.uploadProgress[0].file.size, + uploadedSizeInBytes: hookReturnValue.uploadProgress[0].file.size / 2, + onCancel: hookReturnValue.uploadProgress[0].cancel, + onPause: hookReturnValue.uploadProgress[0].pause, + onResume: hookReturnValue.uploadProgress[0].resume, + onRemove: hookReturnValue.uploadProgress[0].remove, + errorMessage: undefined, + }, + expect.anything(), + ) + + await screen.findByText('Uploading 1 Item') + }) + + it('displays a prompt to the user', async () => { + const hookReturnValue = { + ...mockUseUploadFileEntitiesReturn, + state: 'PROMPT_USER', + activePrompts: [ + { + title: 'This is the prompt title', + message: 'This is the prompt message', + onConfirmAll: jest.fn(), + onConfirm: jest.fn(), + onSkip: jest.fn(), + onCancelAll: jest.fn(), + }, + ], + } satisfies UseUploadFileEntitiesReturn + mockUseUploadFileEntities.mockReturnValue(hookReturnValue) + + const { user } = renderComponent({ + entityId: mockFileEntity.entity.id, + }) + + const dialog = await screen.findByRole('dialog') + within(dialog).getByText('This is the prompt title') + within(dialog).getByText('This is the prompt message') + + await user.click(screen.getByRole('button', { name: 'Yes' })) + expect(hookReturnValue.activePrompts[0].onConfirm).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'Skip' })) + expect(hookReturnValue.activePrompts[0].onSkip).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'Yes to All' })) + expect(hookReturnValue.activePrompts[0].onConfirmAll).toHaveBeenCalledTimes( + 1, + ) + + await user.click(screen.getByRole('button', { name: 'Cancel All Uploads' })) + expect(hookReturnValue.activePrompts[0].onCancelAll).toHaveBeenCalledTimes( + 1, + ) + }) + + it('displays a banner for an alternative storage location', async () => { + const bannerText = 'a rad custom storage location' + mockUseGetDefaultUploadDestination.mockReturnValue( + getUseQuerySuccessMock({ + ...mockExternalS3UploadDestination, + banner: bannerText, + }), + ) + + renderComponent() + + await screen.findByText('All uploaded files will be stored in:', { + exact: false, + }) + await screen.findByText(bannerText, { exact: false }) + }) + + it('allows entering AWS credentials when the UploadDestination is an ExternalObjectStoreUploadDestination', async () => { + mockUseGetDefaultUploadDestination.mockReturnValue( + getUseQuerySuccessMock(mockExternalObjectStoreUploadDestination), + ) + + const accessKeyValue = 'myAccessKey' + const secretKeyValue = 'mySecretKey' + + const { user } = renderComponent() + + const accessKeyInput = await screen.findByLabelText('Access Key') + const secretKeyInput = await screen.findByLabelText('Secret Key') + await screen.findByText( + 'Keys are used to locally sign a web request. They are not transmitted or stored by Synapse.', + ) + + await user.type(accessKeyInput, accessKeyValue) + await user.type(secretKeyInput, secretKeyValue) + + await waitFor(() => { + expect(mockUseUploadFileEntities).toHaveBeenLastCalledWith( + mockProject.entity.id, + accessKeyValue, + secretKeyValue, + ) + }) + }) + + it('supports upload via programmatic handle', async () => { + const { ref } = renderComponent() + + const filesToUpload = [ + new File(['contents'], 'file1.txt'), + new File(['contents'], 'file2.txt'), + ] + + act(() => { + ref.current?.handleUploads(filesToUpload) + }) + + expect(mockUseUploadFileEntitiesReturn.initiateUpload).toHaveBeenCalledWith( + [ + { file: filesToUpload[0], rootContainerId: mockProject.entity.id }, + { file: filesToUpload[1], rootContainerId: mockProject.entity.id }, + ], + ) + }) +}) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx new file mode 100644 index 0000000000..c65389f201 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx @@ -0,0 +1,388 @@ +import { + Box, + Button, + DialogActions, + Fade, + Link, + Menu, + MenuItem, + Paper, + Stack, + TextField, + Typography, +} from '@mui/material' +import { instanceOfExternalObjectStoreUploadDestination } from '@sage-bionetworks/synapse-client' +import { noop } from 'lodash-es' +import pluralize from 'pluralize' +import React, { useEffect, useImperativeHandle, useRef, useState } from 'react' +import { FixedSizeList } from 'react-window' +import { SYNAPSE_STORAGE_LOCATION_ID } from '../../synapse-client/index' +import { + useGetDefaultUploadDestination, + useGetEntity, +} from '../../synapse-queries' +import { + UploaderState, + useUploadFileEntities, +} from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' +import { DialogBase } from '../DialogBase' +import { SynapseSpinner } from '../LoadingScreen/LoadingScreen' +import { FileUploadProgress } from './FileUploadProgress' + +export type EntityUploadProps = { + /** The ID of the entity to upload to. If this is a container, file(s) will be added as children. If this is a + * FileEntity, then a file may be uploaded as a new version */ + entityId: string + /** Callback that is invoked when the state of the uploader changes */ + onStateChange?: (state: UploaderState) => void +} + +// This padding value will be used to manipulate the appearance of a virtualized list of FileUploadProgress components +const UPLOAD_CONTAINER_PADDING_X_PX = 24 + +export type EntityUploadHandle = { + /** Programmatically add files to the upload (e.g. on drag & drop) */ + handleUploads: (fileList: ArrayLike) => void +} + +export const EntityUpload = React.forwardRef(function EntityUpload( + props: EntityUploadProps, + ref: React.ForwardedRef, +) { + const { entityId, onStateChange = noop } = props + + const { data: entity, isLoading: isLoadingEntity } = useGetEntity(entityId) + + const isFileEntity = + entity?.concreteType === 'org.sagebionetworks.repo.model.FileEntity' + + const { data: uploadDestination, isLoading: isLoadingUploadDestination } = + useGetDefaultUploadDestination(entityId) + const isLoading = isLoadingEntity || isLoadingUploadDestination + + const [accessKey, setAccessKey] = useState('') + const [secretKey, setSecretKey] = useState('') + + const { + initiateUpload, + state, + uploadProgress, + activePrompts, + activeUploadCount, + isPrecheckingUpload, + } = useUploadFileEntities(entityId, accessKey, secretKey) + + useEffect(() => { + onStateChange(state) + }, [state, onStateChange]) + + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const handleClick = (event: React.MouseEvent) => { + if (isFileEntity) { + fileInputRef.current!.click() + } else { + setAnchorEl(event.currentTarget) + } + } + const handleClose = () => { + setAnchorEl(null) + } + + const numberOfItemsCompleted = uploadProgress.filter( + p => p.status === 'COMPLETE', + ).length + + function uploadFileList(fileList: ArrayLike) { + const args = Array.from(fileList).map(file => { + if (isFileEntity) { + return { file, existingEntityId: entityId } + } + return { + file, + rootContainerId: entityId, + } + }) + initiateUpload(args) + } + + useImperativeHandle(ref, () => ({ + handleUploads: uploadFileList, + })) + + return ( +
+ {activePrompts.length > 0 && ( + + {activePrompts[0].onCancelAll && ( + + )} + {activePrompts[0].onSkip && ( + + )} + {activePrompts[0].onConfirmAll && ( + + )} + {activePrompts[0].onConfirm && ( + + )} + + } + /> + )} + {uploadDestination && + instanceOfExternalObjectStoreUploadDestination(uploadDestination) && + uploadDestination.endpointUrl && ( + + + Authorization is required to access{' '} + {uploadDestination.endpointUrl} + + { + setAccessKey(e.target.value) + }} + /> + { + setSecretKey(e.target.value) + }} + /> + + Keys are used to locally sign a web request. They are not + transmitted or stored by Synapse. + + + )} + + {(isPrecheckingUpload || isLoading) && ( + <> +
+ +
+ + {isLoading ? 'Loading...' : 'Preparing files for upload...'} + + + )} + {!isPrecheckingUpload && !isLoading && ( + <> + + {/* File input */} + { + if (e.target.files != null) { + uploadFileList(e.target.files) + } + }} + /> + {/* Folder input */} + { + if (e.target.files != null) { + uploadFileList(e.target.files) + } + }} + // @ts-expect-error - webkitdirectory is not included in the React.InputHTMLAttributes type + webkitdirectory="true" + /> + + + Click to upload or drag and + drop + + + { + handleClose() + fileInputRef.current!.click() + }} + > + Files + + { + handleClose() + folderInputRef.current!.click() + }} + > + Folder + + + + + All uploaded files will be stored in + {uploadDestination?.storageLocationId === + SYNAPSE_STORAGE_LOCATION_ID && ' Synapse storage'} + {uploadDestination?.storageLocationId !== + SYNAPSE_STORAGE_LOCATION_ID && ( + <> + :
+ {uploadDestination?.banner} + + )} +
+ + )} +
+ {uploadProgress.length > 0 && ( + + + {state === 'WAITING' && <>Uploads} + {state === 'UPLOADING' && ( + <> + Uploading {activeUploadCount}{' '} + {pluralize('Item', activeUploadCount)} + + )} + {state === 'COMPLETE' && ( + <> + Uploaded {numberOfItemsCompleted}{' '} + {pluralize('Item', numberOfItemsCompleted)} + + )} + + div > div > :not(:last-child)': { + borderBottom: 'solid 1px #EAECEE', + }, + }} + // Add negative margin equivalent to the container's padding + // This will put the scrollbar on the right edge of the container + mx={`${UPLOAD_CONTAINER_PADDING_X_PX * -1}px`} + > + + {({ index, style }) => { + const fileProgress = uploadProgress[index] + const fileNameWithPath = + fileProgress.file.webkitRelativePath ?? fileProgress.file.name + + const totalSizeInBytes = fileProgress.file.size + const fractionOfPartsUploaded = + fileProgress.progress.value / fileProgress.progress.total + const uploadedSizeInBytes = + totalSizeInBytes * fractionOfPartsUploaded + + return ( + + + + ) + }} + + + + )} +
+ ) +}) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUploadModal.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadModal.tsx new file mode 100644 index 0000000000..da39f07cad --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadModal.tsx @@ -0,0 +1,69 @@ +import { Button } from '@mui/material' +import React, { useState } from 'react' +import { UploaderState } from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' +import { DialogBase } from '../DialogBase' +import { EntityUpload, EntityUploadHandle } from './EntityUpload' + +type EntityUploadModalProps = { + entityId: string + open: boolean + onClose: () => void +} + +export const EntityUploadModal = React.forwardRef(function EntityUploadModal( + props: EntityUploadModalProps, + ref: React.ForwardedRef, +) { + const { entityId, open, onClose } = props + const [uploadState, setUploadState] = useState('LOADING') + + const disableClose = + uploadState === 'PROMPT_USER' || uploadState === 'UPLOADING' + const disableCancel = disableClose || uploadState === 'COMPLETE' + + const disableFinish = uploadState !== 'COMPLETE' + + return ( + + } + onCancel={() => { + if (!disableClose) { + onClose() + } + }} + hasCloseButton={!disableClose} + actions={ + <> + + + + } + /> + ) +}) diff --git a/packages/synapse-react-client/src/mocks/mock_upload_destination.ts b/packages/synapse-react-client/src/mocks/mock_upload_destination.ts index 98e4289774..d37ab76e59 100644 --- a/packages/synapse-react-client/src/mocks/mock_upload_destination.ts +++ b/packages/synapse-react-client/src/mocks/mock_upload_destination.ts @@ -14,7 +14,7 @@ export const MOCK_EXTERNAL_GOOGLE_CLOUD_STORAGE_LOCATION_ID = 2222 export const MOCK_EXTERNAL_STORAGE_LOCATION_ID = 3333 export const MOCK_EXTERNAL_OBJECT_STORE_STORAGE_LOCATION_ID = 4444 -const baseUploadDestination: UploadDestination = { +export const mockSynapseStorageUploadDestination: UploadDestination = { storageLocationId: SYNAPSE_STORAGE_LOCATION_ID, uploadType: UploadType.S3, banner: '', @@ -22,7 +22,7 @@ const baseUploadDestination: UploadDestination = { } export const mockS3UploadDestination: S3UploadDestination = { - ...baseUploadDestination, + ...mockSynapseStorageUploadDestination, baseKey: 'exampleS3BaseKey', stsEnabled: true, concreteType: 'org.sagebionetworks.repo.model.file.S3UploadDestination', @@ -39,7 +39,7 @@ export const mockExternalS3UploadDestination: ExternalS3UploadDestination = { export const mockExternalGoogleCloudUploadDestination: ExternalGoogleCloudUploadDestination = { - ...baseUploadDestination, + ...mockSynapseStorageUploadDestination, baseKey: 'exampleGCPBaseKey', storageLocationId: MOCK_EXTERNAL_GOOGLE_CLOUD_STORAGE_LOCATION_ID, uploadType: UploadType.GOOGLECLOUDSTORAGE, @@ -49,7 +49,7 @@ export const mockExternalGoogleCloudUploadDestination: ExternalGoogleCloudUpload } export const mockExternalUploadDestination: ExternalUploadDestination = { - ...baseUploadDestination, + ...mockSynapseStorageUploadDestination, storageLocationId: MOCK_EXTERNAL_STORAGE_LOCATION_ID, uploadType: UploadType.HTTPS, url: 'https://myurl.fake', @@ -58,7 +58,7 @@ export const mockExternalUploadDestination: ExternalUploadDestination = { export const mockExternalObjectStoreUploadDestination: ExternalObjectStoreUploadDestination = { - ...baseUploadDestination, + ...mockSynapseStorageUploadDestination, storageLocationId: MOCK_EXTERNAL_OBJECT_STORE_STORAGE_LOCATION_ID, uploadType: UploadType.HTTPS, endpointUrl: 'https://my-endpoint.fake', diff --git a/packages/synapse-react-client/src/umd.index.ts b/packages/synapse-react-client/src/umd.index.ts index 23b8013b9b..5005a14e90 100644 --- a/packages/synapse-react-client/src/umd.index.ts +++ b/packages/synapse-react-client/src/umd.index.ts @@ -1,3 +1,4 @@ +import { EntityUploadModal } from './components/EntityUpload/EntityUploadModal' import { SkeletonButton } from './components/Skeleton/SkeletonButton' import { AccountLevelBadges } from './components/AccountLevelBadges/AccountLevelBadges' import ChangePassword from './components/ChangePassword/ChangePassword' @@ -191,6 +192,7 @@ const SynapseComponents = { GovernanceMarkdownGithub, MarkdownGithubLatestTag, ProjectDataAvailability, + EntityUploadModal, } // Include the version in the build diff --git a/packages/synapse-react-client/src/utils/functions/calculateFriendlyFileSize.ts b/packages/synapse-react-client/src/utils/functions/calculateFriendlyFileSize.ts index 9778554527..feafed0484 100644 --- a/packages/synapse-react-client/src/utils/functions/calculateFriendlyFileSize.ts +++ b/packages/synapse-react-client/src/utils/functions/calculateFriendlyFileSize.ts @@ -1,4 +1,4 @@ -const sufixes: string[] = [ +const suffixes: string[] = [ 'Bytes', 'KB', 'MB', @@ -11,17 +11,17 @@ const sufixes: string[] = [ ] export function calculateFriendlyFileSize( - bytes: number, + bytes: number | null | undefined, fractionDigits?: number, ) { - if (!bytes) { + if (bytes == null) { return '' } // https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string const i = Math.floor(Math.log(bytes) / Math.log(1024)) // tslint:disable-next-line return ( - (!bytes && '0 Bytes') || - (bytes / Math.pow(1024, i)).toFixed(fractionDigits ?? 2) + ' ' + sufixes[i] + (bytes == 0 && '0 Bytes') || + (bytes / Math.pow(1024, i)).toFixed(fractionDigits ?? 2) + ' ' + suffixes[i] ) } diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts index bede08d214..d961c01950 100644 --- a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts @@ -67,7 +67,7 @@ type FileUploadProgress = { export type InitiateUploadArgs = PrepareFileEntityUploadArgs -type UseUploadFileEntitiesReturn = { +export type UseUploadFileEntitiesReturn = { state: UploaderState errorMessage?: string isPrecheckingUpload: boolean From 92031a71a604653332900d93f3ffae4cd0d1400a Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 11:12:59 -0500 Subject: [PATCH 3/9] SWC-7064 - remove extra stories --- .../EntityUpload/EntityUpload.stories.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts index 1a864b3724..3ea12af325 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.stories.ts @@ -1,4 +1,5 @@ import { Meta, StoryObj } from '@storybook/react' +import mockProjectEntityData from '../../mocks/entity/mockProject' import { EntityUpload } from './EntityUpload' const meta = { @@ -10,18 +11,9 @@ type Story = StoryObj export const Demo: Story = { args: { - containerId: 'syn23567475', + entityId: mockProjectEntityData.entity.id, }, -} - -export const Dev: Story = { - args: { - containerId: 'syn12554559', - }, -} - -export const ExternalS3Bucket: Story = { - args: { - containerId: 'syn63917361', + parameters: { + stack: 'mock', }, } From d83f69391b09d620bbc016ebfd1a8f66fba68a7d Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 11:22:32 -0500 Subject: [PATCH 4/9] SWC-7064 - extract simple components --- .../components/EntityUpload/EntityUpload.tsx | 90 +++---------------- .../EntityUpload/EntityUploadPromptDialog.tsx | 62 +++++++++++++ .../ExternalObjectStoreCredentialsForm.tsx | 64 +++++++++++++ 3 files changed, 136 insertions(+), 80 deletions(-) create mode 100644 packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx create mode 100644 packages/synapse-react-client/src/components/EntityUpload/ExternalObjectStoreCredentialsForm.tsx diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx index c65389f201..7b48349b09 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx @@ -1,17 +1,13 @@ import { Box, - Button, - DialogActions, Fade, Link, Menu, MenuItem, Paper, Stack, - TextField, Typography, } from '@mui/material' -import { instanceOfExternalObjectStoreUploadDestination } from '@sage-bionetworks/synapse-client' import { noop } from 'lodash-es' import pluralize from 'pluralize' import React, { useEffect, useImperativeHandle, useRef, useState } from 'react' @@ -25,8 +21,9 @@ import { UploaderState, useUploadFileEntities, } from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' -import { DialogBase } from '../DialogBase' import { SynapseSpinner } from '../LoadingScreen/LoadingScreen' +import { EntityUploadPromptDialog } from './EntityUploadPromptDialog' +import { ExternalObjectStoreCredentialsForm } from './ExternalObjectStoreCredentialsForm' import { FileUploadProgress } from './FileUploadProgress' export type EntityUploadProps = { @@ -114,81 +111,14 @@ export const EntityUpload = React.forwardRef(function EntityUpload( return (
- {activePrompts.length > 0 && ( - - {activePrompts[0].onCancelAll && ( - - )} - {activePrompts[0].onSkip && ( - - )} - {activePrompts[0].onConfirmAll && ( - - )} - {activePrompts[0].onConfirm && ( - - )} - - } - /> - )} - {uploadDestination && - instanceOfExternalObjectStoreUploadDestination(uploadDestination) && - uploadDestination.endpointUrl && ( - - - Authorization is required to access{' '} - {uploadDestination.endpointUrl} - - { - setAccessKey(e.target.value) - }} - /> - { - setSecretKey(e.target.value) - }} - /> - - Keys are used to locally sign a web request. They are not - transmitted or stored by Synapse. - - - )} + + + {activePrompts[0].onCancelAll && ( + + )} + {activePrompts[0].onSkip && ( + + )} + {activePrompts[0].onConfirmAll && ( + + )} + {activePrompts[0].onConfirm && ( + + )} + + } + /> + ) +} diff --git a/packages/synapse-react-client/src/components/EntityUpload/ExternalObjectStoreCredentialsForm.tsx b/packages/synapse-react-client/src/components/EntityUpload/ExternalObjectStoreCredentialsForm.tsx new file mode 100644 index 0000000000..14c6f46eb1 --- /dev/null +++ b/packages/synapse-react-client/src/components/EntityUpload/ExternalObjectStoreCredentialsForm.tsx @@ -0,0 +1,64 @@ +import { Stack, TextField, Typography } from '@mui/material' +import { + instanceOfExternalObjectStoreUploadDestination, + UploadDestination, +} from '@sage-bionetworks/synapse-client' +import React from 'react' + +type ExternalObjectStoreCredentialsFormProps = { + uploadDestination?: UploadDestination + accessKey: string + setAccessKey: React.Dispatch> + secretKey: string + setSecretKey: React.Dispatch> +} + +export function ExternalObjectStoreCredentialsForm( + props: ExternalObjectStoreCredentialsFormProps, +) { + const { + uploadDestination, + accessKey, + setAccessKey, + secretKey, + setSecretKey, + } = props + + if ( + uploadDestination == null || + !instanceOfExternalObjectStoreUploadDestination(uploadDestination) || + !uploadDestination.endpointUrl + ) { + return <> + } + + return ( + + + Authorization is required to access{' '} + {uploadDestination.endpointUrl} + + { + setAccessKey(e.target.value) + }} + /> + { + setSecretKey(e.target.value) + }} + /> + + Keys are used to locally sign a web request. They are not transmitted or + stored by Synapse. + + + ) +} From 47c42a6b5ea0a566d875330243a7999667751973 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 11:36:04 -0500 Subject: [PATCH 5/9] SWC-7064 - fewer magic numbers --- .../src/components/EntityUpload/EntityUpload.test.tsx | 1 + .../src/components/EntityUpload/EntityUpload.tsx | 11 +++++++---- .../components/EntityUpload/FileUploadProgress.tsx | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx index 70ab86bd23..83cb3055d2 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx @@ -41,6 +41,7 @@ jest.mock('../../synapse-queries/file/useUploadDestination.ts', () => ({ })) jest.mock('./FileUploadProgress', () => ({ + FILE_UPLOAD_PROGRESS_COMPONENT_HEIGHT_PX: 100, FileUploadProgress: jest.fn(() =>
), })) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx index 7b48349b09..6765a36d0f 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx @@ -24,7 +24,10 @@ import { import { SynapseSpinner } from '../LoadingScreen/LoadingScreen' import { EntityUploadPromptDialog } from './EntityUploadPromptDialog' import { ExternalObjectStoreCredentialsForm } from './ExternalObjectStoreCredentialsForm' -import { FileUploadProgress } from './FileUploadProgress' +import { + FILE_UPLOAD_PROGRESS_COMPONENT_HEIGHT_PX, + FileUploadProgress, +} from './FileUploadProgress' export type EntityUploadProps = { /** The ID of the entity to upload to. If this is a container, file(s) will be added as children. If this is a @@ -124,7 +127,7 @@ export const EntityUpload = React.forwardRef(function EntityUpload( width: '100%', height: '235px', border: '1px dashed #D9D9D9', - background: '#FBFBFC', + backgroundColor: 'grey.100', textAlign: 'center', }} justifyContent={'center'} @@ -263,7 +266,7 @@ export const EntityUpload = React.forwardRef(function EntityUpload( mx={`${UPLOAD_CONTAINER_PADDING_X_PX * -1}px`} > { const fileProgress = uploadProgress[index] const fileNameWithPath = - fileProgress.file.webkitRelativePath ?? fileProgress.file.name + fileProgress.file.webkitRelativePath || fileProgress.file.name const totalSizeInBytes = fileProgress.file.size const fractionOfPartsUploaded = diff --git a/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx index 487249db4b..f39c11143f 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/FileUploadProgress.tsx @@ -43,6 +43,8 @@ export type FileUploadProgressProps = { errorMessage?: string } +export const FILE_UPLOAD_PROGRESS_COMPONENT_HEIGHT_PX = 92 + /** * Component that displays the upload progress of a file, with controls to pause or cancel the upload. */ @@ -76,7 +78,7 @@ export function FileUploadProgress(props: FileUploadProgressProps) { } return ( - + Date: Wed, 13 Nov 2024 11:38:16 -0500 Subject: [PATCH 6/9] SWC-7064 - prefer div over Box when sx is not used --- .../src/components/EntityUpload/EntityUpload.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx index 6765a36d0f..136f965156 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx @@ -283,7 +283,7 @@ export const EntityUpload = React.forwardRef(function EntityUpload( totalSizeInBytes * fractionOfPartsUploaded return ( - - +
) }} From cd38272499742bf9596f6f4d0cc817c59c5c4873 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 12:27:37 -0500 Subject: [PATCH 7/9] SWC-7064 - fix lint error --- .../src/components/EntityUpload/EntityUpload.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx index 83cb3055d2..e1dfcc5cf6 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx @@ -292,7 +292,7 @@ describe('EntityUpload', () => { }) }) - it('supports upload via programmatic handle', async () => { + it('supports upload via programmatic handle', () => { const { ref } = renderComponent() const filesToUpload = [ From 9cb6f5ba471cc1607d08ad4371392cd86439f504 Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 13:49:34 -0500 Subject: [PATCH 8/9] SWC-7064 - refine prompt dialog, refactor text to component-level --- .../EntityUpload/EntityUpload.test.tsx | 39 ++++++--- .../EntityUpload/EntityUploadPromptDialog.tsx | 79 ++++++++++++++----- .../useUploadFileEntities.test.ts | 70 +++++++++++----- .../useUploadFileEntities.ts | 23 ++++-- 4 files changed, 156 insertions(+), 55 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx index e1dfcc5cf6..76ebe90d79 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx @@ -211,8 +211,22 @@ describe('EntityUpload', () => { state: 'PROMPT_USER', activePrompts: [ { - title: 'This is the prompt title', - message: 'This is the prompt message', + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: 'file1.txt', + existingEntityId: 'syn123', + }, + onConfirmAll: jest.fn(), + onConfirm: jest.fn(), + onSkip: jest.fn(), + onCancelAll: jest.fn(), + }, + { + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: 'file2.txt', + existingEntityId: 'syn456', + }, onConfirmAll: jest.fn(), onConfirm: jest.fn(), onSkip: jest.fn(), @@ -227,22 +241,29 @@ describe('EntityUpload', () => { }) const dialog = await screen.findByRole('dialog') - within(dialog).getByText('This is the prompt title') - within(dialog).getByText('This is the prompt message') + within(dialog).getByText('Update existing file?') + within(dialog).getByText( + 'A file named "file1.txt" (syn123) already exists in this location. Do you want to update the existing file and create a new version?', + ) await user.click(screen.getByRole('button', { name: 'Yes' })) expect(hookReturnValue.activePrompts[0].onConfirm).toHaveBeenCalledTimes(1) - await user.click(screen.getByRole('button', { name: 'Skip' })) + await user.click(screen.getByRole('button', { name: 'No' })) expect(hookReturnValue.activePrompts[0].onSkip).toHaveBeenCalledTimes(1) - await user.click(screen.getByRole('button', { name: 'Yes to All' })) - expect(hookReturnValue.activePrompts[0].onConfirmAll).toHaveBeenCalledTimes( + await user.click(screen.getByRole('button', { name: 'Cancel All Uploads' })) + expect(hookReturnValue.activePrompts[0].onCancelAll).toHaveBeenCalledTimes( 1, ) - await user.click(screen.getByRole('button', { name: 'Cancel All Uploads' })) - expect(hookReturnValue.activePrompts[0].onCancelAll).toHaveBeenCalledTimes( + await user.click( + screen.getByLabelText( + 'Also update 1 other uploaded file that already exists', + ), + ) + await user.click(screen.getByRole('button', { name: 'Yes' })) + expect(hookReturnValue.activePrompts[0].onConfirmAll).toHaveBeenCalledTimes( 1, ) }) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx index d3c45c540d..e75138b22a 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx @@ -1,30 +1,75 @@ -import { Button, DialogActions } from '@mui/material' +import { + Box, + Button, + Checkbox, + FormControlLabel, + Typography, +} from '@mui/material' import { noop } from 'lodash-es' -import React from 'react' -import { UseUploadFileEntitiesReturn } from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' +import pluralize from 'pluralize' +import React, { useState } from 'react' +import { + PromptInfo, + UseUploadFileEntitiesReturn, +} from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' import { DialogBase } from '../DialogBase' type EntityUploadPromptDialogProps = { activePrompts: UseUploadFileEntitiesReturn['activePrompts'] } +function getTitle(promptInfo: PromptInfo) { + if (promptInfo.type === 'CONFIRM_NEW_VERSION') { + return 'Update existing file?' + } + return '' +} + +function getMessage(promptInfo: PromptInfo) { + if (promptInfo.type === 'CONFIRM_NEW_VERSION') { + return `A file named "${promptInfo.fileName}" (${promptInfo.existingEntityId}) already exists in this location. Do you want to update the existing file and create a new version?` + } + return '' +} + export function EntityUploadPromptDialog(props: EntityUploadPromptDialogProps) { const { activePrompts } = props + const [yesToAllChecked, setYesToAllChecked] = useState(false) if (activePrompts.length === 0) { return null } + const numberOfOtherPrompts = activePrompts.length - 1 + return ( + + {getMessage(activePrompts[0].info)} + + {activePrompts[0].info.type == 'CONFIRM_NEW_VERSION' && + numberOfOtherPrompts > 0 && ( + } + value={yesToAllChecked} + onChange={(_e, value) => setYesToAllChecked(value)} + label={`Also update ${numberOfOtherPrompts.toLocaleString()} other uploaded ${pluralize( + 'file', + numberOfOtherPrompts, + )} that already exist${numberOfOtherPrompts == 1 ? 's' : ''}`} + /> + )} + + } // Force the user to make a decision onCancel={noop} hasCloseButton={false} actions={ - + <> {activePrompts[0].onCancelAll && ( - )} - {activePrompts[0].onConfirmAll && ( - + )} {activePrompts[0].onConfirm && ( )} - + } /> ) diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.test.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.test.ts index 5a8c78d25d..c883a820fd 100644 --- a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.test.ts +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.test.ts @@ -520,16 +520,22 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(2) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file1.txt" (syn456) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file1.name, + existingEntityId: 'syn456', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), onCancelAll: expect.any(Function), }) expect(hook.current.activePrompts[1]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), @@ -545,8 +551,11 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(1) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), @@ -689,16 +698,22 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(2) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file1.txt" (syn456) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file1.name, + existingEntityId: 'syn456', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), onCancelAll: expect.any(Function), }) expect(hook.current.activePrompts[1]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), @@ -829,16 +844,22 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(2) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file1.txt" (syn456) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file1.name, + existingEntityId: 'syn456', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), onCancelAll: expect.any(Function), }) expect(hook.current.activePrompts[1]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), @@ -854,8 +875,11 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(1) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), @@ -984,16 +1008,22 @@ describe('useUploadFileEntities', () => { expect(hook.current.state).toBe('PROMPT_USER') expect(hook.current.activePrompts.length).toBe(2) expect(hook.current.activePrompts[0]).toEqual({ - title: 'Update existing file?', - message: `A file named "file1.txt" (syn456) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file1.name, + existingEntityId: 'syn456', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), onCancelAll: expect.any(Function), }) expect(hook.current.activePrompts[1]).toEqual({ - title: 'Update existing file?', - message: `A file named "file2.txt" (syn457) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: file2.name, + existingEntityId: 'syn457', + }, onConfirm: expect.any(Function), onSkip: expect.any(Function), onConfirmAll: expect.any(Function), diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts index d961c01950..1ee9615225 100644 --- a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useUploadFileEntities.ts @@ -37,9 +37,14 @@ type UploadFileStatus = | 'FAILED' | 'COMPLETE' -type Prompt = { - title: string - message: string +export type PromptInfo = { + type: 'CONFIRM_NEW_VERSION' + fileName: string + existingEntityId: string +} + +export type Prompt = { + info: PromptInfo onConfirmAll: () => void onConfirm: () => void onSkip: () => void @@ -350,13 +355,15 @@ export function useUploadFileEntities( [postPrepareUpload, prepareUpload], ) - const activePrompts = useMemo(() => { + const activePrompts: Prompt[] = useMemo(() => { return filesToConfirmNewVersion.map(fileToPrompt => { return { - title: 'Update existing file?', - message: `A file named "${fileToPrompt.file.name}" (${ - (fileToPrompt as UpdateEntityFileUpload).existingEntityId - }) already exists in this location. Do you want to update the existing file and create a new version?`, + info: { + type: 'CONFIRM_NEW_VERSION', + fileName: fileToPrompt.file.name, + existingEntityId: (fileToPrompt as UpdateEntityFileUpload) + .existingEntityId, + }, onConfirm: () => { const { confirmedItems, pendingItems } = confirmUploadFileWithNewVersion(fileToPrompt) From ef8a12117531966a0eeca978a15e0bd8e38967fe Mon Sep 17 00:00:00 2001 From: Nick Grosenbacher Date: Wed, 13 Nov 2024 16:59:43 -0500 Subject: [PATCH 9/9] SWC-7064 - code review changes --- .../EntityUpload/EntityUpload.test.tsx | 8 +++++ .../components/EntityUpload/EntityUpload.tsx | 31 ++++++++++++++----- .../EntityUpload/EntityUploadPromptDialog.tsx | 13 +++++--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx index 76ebe90d79..c0ebc3341e 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.test.tsx @@ -246,17 +246,25 @@ describe('EntityUpload', () => { 'A file named "file1.txt" (syn123) already exists in this location. Do you want to update the existing file and create a new version?', ) + expect(hookReturnValue.activePrompts[0].onConfirm).toHaveBeenCalledTimes(0) await user.click(screen.getByRole('button', { name: 'Yes' })) expect(hookReturnValue.activePrompts[0].onConfirm).toHaveBeenCalledTimes(1) + expect(hookReturnValue.activePrompts[0].onSkip).toHaveBeenCalledTimes(0) await user.click(screen.getByRole('button', { name: 'No' })) expect(hookReturnValue.activePrompts[0].onSkip).toHaveBeenCalledTimes(1) + expect(hookReturnValue.activePrompts[0].onCancelAll).toHaveBeenCalledTimes( + 0, + ) await user.click(screen.getByRole('button', { name: 'Cancel All Uploads' })) expect(hookReturnValue.activePrompts[0].onCancelAll).toHaveBeenCalledTimes( 1, ) + expect(hookReturnValue.activePrompts[0].onConfirmAll).toHaveBeenCalledTimes( + 0, + ) await user.click( screen.getByLabelText( 'Also update 1 other uploaded file that already exists', diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx index 136f965156..2e2ee9b858 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUpload.tsx @@ -17,10 +17,12 @@ import { useGetDefaultUploadDestination, useGetEntity, } from '../../synapse-queries' +import { getUploadDestinationString } from '../../utils/functions/FileHandleUtils' import { UploaderState, useUploadFileEntities, } from '../../utils/hooks/useUploadFileEntity/useUploadFileEntities' +import FullWidthAlert from '../FullWidthAlert/FullWidthAlert' import { SynapseSpinner } from '../LoadingScreen/LoadingScreen' import { EntityUploadPromptDialog } from './EntityUploadPromptDialog' import { ExternalObjectStoreCredentialsForm } from './ExternalObjectStoreCredentialsForm' @@ -125,11 +127,12 @@ export const EntityUpload = React.forwardRef(function EntityUpload( @@ -218,14 +221,26 @@ export const EntityUpload = React.forwardRef(function EntityUpload( All uploaded files will be stored in {uploadDestination?.storageLocationId === SYNAPSE_STORAGE_LOCATION_ID && ' Synapse storage'} - {uploadDestination?.storageLocationId !== - SYNAPSE_STORAGE_LOCATION_ID && ( - <> - :
- {uploadDestination?.banner} - - )} + {uploadDestination && + uploadDestination?.storageLocationId !== + SYNAPSE_STORAGE_LOCATION_ID && ( + <> + :
+ {getUploadDestinationString(uploadDestination)} + + )} + {uploadDestination && uploadDestination.banner && ( + + )} )}
diff --git a/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx index e75138b22a..8a93bc7470 100644 --- a/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx +++ b/packages/synapse-react-client/src/components/EntityUpload/EntityUploadPromptDialog.tsx @@ -40,7 +40,8 @@ export function EntityUploadPromptDialog(props: EntityUploadPromptDialogProps) { return null } - const numberOfOtherPrompts = activePrompts.length - 1 + const numberOfOtherConfirmVersionPrompts = + activePrompts.filter(p => p.info.type === 'CONFIRM_NEW_VERSION').length - 1 return ( {activePrompts[0].info.type == 'CONFIRM_NEW_VERSION' && - numberOfOtherPrompts > 0 && ( + numberOfOtherConfirmVersionPrompts > 0 && ( } value={yesToAllChecked} onChange={(_e, value) => setYesToAllChecked(value)} - label={`Also update ${numberOfOtherPrompts.toLocaleString()} other uploaded ${pluralize( + label={`Also update ${numberOfOtherConfirmVersionPrompts.toLocaleString()} other uploaded ${pluralize( 'file', - numberOfOtherPrompts, - )} that already exist${numberOfOtherPrompts == 1 ? 's' : ''}`} + numberOfOtherConfirmVersionPrompts, + )} that already exist${ + numberOfOtherConfirmVersionPrompts == 1 ? 's' : '' + }`} /> )}