From 9dfd32243432a52d5633d0b5c0b35c62a7ad7aca Mon Sep 17 00:00:00 2001 From: ManojNB Date: Thu, 7 Nov 2024 13:16:15 -0800 Subject: [PATCH 1/2] feat(react-storage): refactor useView action related hooks (#5998) Co-authored-by: Caleb Pollman --- packages/react-storage/jest.config.ts | 6 +- .../createEnhancedListHandler.spec.ts | 2 +- .../StorageBrowser/actions/configs/types.ts | 2 +- .../actions/createEnhancedListHandler.ts | 2 +- .../actions/handlers/__tests__/copy.spec.ts | 5 +- .../actions/handlers/__tests__/delete.spec.ts | 3 +- .../handlers/__tests__/download.spec.ts | 3 +- .../actions/handlers/__tests__/utils.spec.ts | 13 +- .../StorageBrowser/actions/handlers/copy.ts | 12 +- .../actions/handlers/createFolder.ts | 2 +- .../StorageBrowser/actions/handlers/delete.ts | 7 +- .../actions/handlers/download.ts | 8 +- .../StorageBrowser/actions/handlers/index.ts | 2 +- .../actions/handlers/listLocationItems.ts | 19 +- .../actions/handlers/listLocations.ts | 2 +- .../StorageBrowser/actions/handlers/types.ts | 89 ++++- .../StorageBrowser/actions/handlers/upload.ts | 2 +- .../StorageBrowser/actions/handlers/utils.ts | 25 +- .../StorageBrowser/actions/index.ts | 44 +-- .../StorageBrowser/actions/types.ts | 70 +--- .../StorageBrowser/controls/getTaskCounts.ts | 9 - .../hooks/__tests__/useStatusDisplay.spec.tsx | 11 +- .../controls/hooks/useStatusDisplay.ts | 14 +- .../StorageBrowser/controls/types.ts | 14 +- .../locationItems/__tests__/context.spec.ts | 69 +++- .../providers/store/locationItems/context.tsx | 23 +- .../tasks/__tests__/useProcessTasks.spec.ts | 27 ++ .../StorageBrowser/tasks/constants.ts | 11 + .../components/StorageBrowser/tasks/index.ts | 3 +- .../components/StorageBrowser/tasks/types.ts | 12 + .../StorageBrowser/tasks/useProcessTasks.ts | 30 +- .../components/StorageBrowser/tasks/utils.ts | 24 ++ .../LocationActionView/CopyFilesControls.tsx | 58 ++- ...snap => useDestinationPicker.spec.ts.snap} | 0 .../CopyView/__tests__/useCopyView.spec.ts | 182 ++++++++++ .../CopyView/__tests__/useCopyView.spec.tsx | 329 ------------------ ....spec.tsx => useDestinationPicker.spec.ts} | 3 +- .../LocationActionView/CopyView/types.ts | 5 +- .../CopyView/useCopyView.ts | 65 ++-- .../DeleteFilesControls.tsx | 31 +- .../__tests__/useDeleteView.spec.ts | 178 ++++++++++ .../__tests__/useDeleteView.spec.tsx | 329 ------------------ .../LocationActionView/DeleteView/types.ts | 6 +- .../DeleteView/useDeleteView.ts | 36 +- .../LocationActionView/DestinationPicker.tsx | 8 +- .../LocationActionView/UploadControls.tsx | 126 +++---- .../__tests__/useUploadView.spec.ts | 219 ++++++++++++ .../UploadView/constants.ts | 1 + .../LocationActionView/UploadView/types.ts | 9 +- .../UploadView/useUploadView.ts | 90 ++++- .../__tests__/ActionIcon.spec.tsx | 26 ++ .../__tests__/CopyFilesControls.spec.tsx | 261 +++++++------- .../__tests__/DeleteFilesControls.spec.tsx | 326 ++++++++++++----- .../__tests__/UploadControls.spec.tsx | 285 ++++++++------- .../__snapshots__/utils.spec.ts.snap | 24 +- .../__tests__/utils.spec.ts | 155 ++------- .../views/LocationActionView/constants.ts | 22 +- .../views/LocationActionView/types.ts | 16 +- .../views/LocationActionView/useActionView.ts | 14 +- .../views/LocationActionView/utils.ts | 144 +++----- .../__tests__/useLocationDetailView.spec.tsx | 142 ++++---- .../useLocationDetailView.ts | 2 +- 62 files changed, 1911 insertions(+), 1746 deletions(-) delete mode 100644 packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/constants.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/tasks/utils.ts rename packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/{useDestinationPicker.spec.tsx.snap => useDestinationPicker.spec.ts.snap} (100%) create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.tsx rename packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/{useDestinationPicker.spec.tsx => useDestinationPicker.spec.ts} (96%) create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/useUploadView.spec.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/constants.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/ActionIcon.spec.tsx diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts index 49e34711b39..6efee3aa2f1 100644 --- a/packages/react-storage/jest.config.ts +++ b/packages/react-storage/jest.config.ts @@ -16,10 +16,10 @@ const config: Config = { // functions: 90, // lines: 95, // statements: 95, - branches: 81, - functions: 85.8, + branches: 81.5, + functions: 86.5, lines: 93, - statements: 92.7, + statements: 93, }, }, moduleNameMapper: { '^uuid$': '/../../node_modules/uuid' }, diff --git a/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts index 7110d088f52..8ce3a5264a5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts @@ -7,7 +7,7 @@ import { ListHandler, ListHandlerInput, ListHandlerOutput, -} from '../types'; +} from '../handlers'; const mockAction = jest.fn(); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts index b42077edfdb..1aff6d45a3a 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts @@ -10,8 +10,8 @@ import { CreateFolderHandler, DeleteHandler, CopyHandler, + TaskHandler, } from '../handlers'; -import { TaskHandler } from '../types'; type StringWithoutSpaces = Exclude< T, diff --git a/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts b/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts index e3b31d944a6..74d2d943253 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts @@ -5,7 +5,7 @@ import { ListHandlerOptions, ListHandlerInput, ListHandlerOutput, -} from './types'; +} from './handlers'; interface SearchOptions { query: string; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts index cea65904072..21c19032f7e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts @@ -15,7 +15,8 @@ const baseInput: CopyHandlerInput = { }, data: { id: 'identity', - key: 'some-key', + key: 'some-prefixfix/some-key.hehe', + fileKey: 'some-key.hehe', lastModified: new Date(), size: 100000000, type: 'FILE', @@ -35,7 +36,7 @@ describe('copyHandler', () => { destination: { expectedBucketOwner: baseInput.config.accountId, bucket, - path: `${baseInput.destinationPrefix}${baseInput.data.key}`, + path: `${baseInput.destinationPrefix}${baseInput.data.fileKey}`, }, source: { expectedBucketOwner: `${baseInput.config.accountId}`, diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts index e500a1541ed..7e99f1cc3f4 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts @@ -14,7 +14,8 @@ const baseInput: DeleteHandlerInput = { }, data: { id: 'id', - key: 'prefix/key', + key: 'prefix/key.png', + fileKey: 'key.png', lastModified: new Date(), size: 829292, type: 'FILE', diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts index d4f6651f8fd..cf602feaf34 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts @@ -14,7 +14,8 @@ const baseInput: DownloadHandlerInput = { }, data: { id: 'id', - key: 'key', + key: 'prefix/file-name', + fileKey: 'file-name', lastModified: new Date(), size: 1000022, type: 'FILE', diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts index 89818777179..f716a988c7f 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts @@ -1,7 +1,7 @@ import { LocationAccess } from '../../../storage-internal'; import { LocationData } from '../types'; -import { parseLocationAccess } from '../utils'; +import { getFileKey, parseLocationAccess } from '../utils'; describe('parseLocationAccess', () => { const bucket = 'test-bucket'; @@ -93,3 +93,14 @@ describe('parseLocationAccess', () => { expect(parseLocationAccess(location)).toStrictEqual(expected); }); }); + +describe('getFileKey', () => { + it('should return the filename without the path', () => { + expect(getFileKey('/path/to/file.txt')).toBe('file.txt'); + expect(getFileKey('document.pdf')).toBe('document.pdf'); + }); + + it('should handle paths with multiple slashes', () => { + expect(getFileKey('/path//to///file.txt')).toBe('file.txt'); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts index 9437e490b2f..50c5576d285 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts @@ -1,17 +1,15 @@ import { copy } from '../../storage-internal'; -import { getFilenameWithoutPrefix } from '../../views/LocationActionView/utils'; import { + FileDataItem, TaskHandler, TaskHandlerInput, - TaskData, TaskHandlerOptions, TaskHandlerOutput, -} from '../types'; -import { FileData } from './listLocationItems'; +} from './types'; import { constructBucket } from './utils'; -export interface CopyHandlerData extends TaskData, FileData {} +export interface CopyHandlerData extends FileDataItem {} export interface CopyHandlerInput extends TaskHandlerInput { @@ -29,11 +27,11 @@ export const copyHandler: CopyHandler = (input) => { credentials, customEndpoint, } = config; - const { key: sourcePath } = data; + const { key: sourcePath, fileKey } = data; const bucket = constructBucket(config); - const destinationPath = `${path}${getFilenameWithoutPrefix(sourcePath)}`; + const destinationPath = `${path}${fileKey}`; const source = { bucket, expectedBucketOwner, path: sourcePath }; const destination = { bucket, expectedBucketOwner, path: destinationPath }; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts index 1460f770147..d7f783686a5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts @@ -4,7 +4,7 @@ import { TaskHandlerInput, TaskHandlerOutput, TaskHandlerOptions, -} from '../types'; +} from './types'; export interface CreateFolderHandlerData extends TaskData {} export interface CreateFolderHandlerOptions extends TaskHandlerOptions { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts index 8ffa58a6c96..7ce1b4aaf22 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts @@ -5,15 +5,14 @@ import { TaskHandlerOptions, TaskHandlerInput, TaskHandlerOutput, - TaskData, -} from '../types'; -import { FileData } from './listLocationItems'; + FileDataItem, +} from './types'; import { constructBucket } from './utils'; export interface DeleteHandlerOptions extends TaskHandlerOptions {} -export interface DeleteHandlerData extends TaskData, FileData {} +export interface DeleteHandlerData extends FileDataItem {} export interface DeleteHandlerInput extends TaskHandlerInput {} diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts index 27ae9203d9f..fcdbce0f62b 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts @@ -1,15 +1,15 @@ import { getUrl } from '../../storage-internal'; import { - TaskData, + FileDataItem, TaskHandler, TaskHandlerInput, TaskHandlerOptions, TaskHandlerOutput, -} from '../types'; -import { FileData } from './listLocationItems'; +} from './types'; + import { constructBucket } from './utils'; -export interface DownloadHandlerData extends TaskData, FileData {} +export interface DownloadHandlerData extends FileDataItem {} export interface DownloadHandlerOptions extends TaskHandlerOptions {} export interface DownloadHandlerInput diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts index 8bfa8388ab3..c093f1ba0d7 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts @@ -5,5 +5,5 @@ export * from './download'; export * from './listLocationItems'; export * from './listLocations'; export * from './upload'; - +export * from './utils'; export * from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts index dcb977f2d4a..d54a9d88aff 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts @@ -9,28 +9,13 @@ import { ListHandlerInput, ListHandlerOptions, ListHandlerOutput, -} from '../types'; + LocationItemData, +} from './types'; const DEFAULT_PAGE_SIZE = 1000; -export interface FolderData { - key: string; - id: string; - type: 'FOLDER'; -} - -export interface FileData { - key: string; - lastModified: Date; - id: string; - size: number; - type: 'FILE'; -} - type ListOutputItem = ListOutput['items'][number]; -export type LocationItemData = FileData | FolderData; - export type LocationItemType = LocationItemData['type']; export interface ListLocationItemsHandlerOptions diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts index 9a0c98b603f..9868d14dc39 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts @@ -9,7 +9,7 @@ import { ListHandlerInput, ListHandlerOutput, ListHandler, -} from '../types'; +} from './types'; import { LocationData } from './types'; import { parseLocations, ExcludeType } from './utils'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts index 6b3b0bbb0c0..3f339082ee7 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts @@ -1,4 +1,7 @@ -import { Permission } from '../../storage-internal'; +import { + LocationCredentialsProvider, + Permission, +} from '../../storage-internal'; export type LocationType = 'OBJECT' | 'PREFIX' | 'BUCKET'; @@ -33,3 +36,87 @@ export interface LocationData { */ type: LocationType; } + +export interface FolderData { + key: string; + id: string; + type: 'FOLDER'; +} + +export interface FileData { + key: string; + lastModified: Date; + id: string; + size: number; + type: 'FILE'; +} + +export type LocationItemData = FileData | FolderData; + +export interface FileDataItem extends FileData, TaskData { + fileKey: string; +} + +export interface FileItem extends TaskData { + file: File; +} + +export interface ActionInputConfig { + accountId?: string; + bucket: string; + credentials: LocationCredentialsProvider; + customEndpoint?: string; + region: string; +} + +interface ActionInput { + config: ActionInputConfig; + prefix: string; + options?: T; +} + +export interface TaskData { + key: string; + id: string; +} + +export interface TaskHandlerOptions { + onProgress?: ( + data: { key: string; id: string }, + progress: number | undefined + ) => void; +} + +export interface TaskHandlerInput< + T extends TaskData = TaskData, + K extends TaskHandlerOptions = TaskHandlerOptions, +> { + config: ActionInputConfig; + data: T; + options?: K; +} + +export interface TaskHandlerOutput { + cancel?: () => void; + result: Promise<{ + message?: string; + status: 'CANCELED' | 'COMPLETE' | 'FAILED' | 'OVERWRITE_PREVENTED'; + }>; +} + +export type TaskHandler = (input: T) => K; + +export interface ListHandlerOptions { + exclude?: T; + nextToken?: string; + pageSize?: number; +} + +export interface ListHandlerInput extends ActionInput {} + +export interface ListHandlerOutput { + nextToken: string | undefined; + items: T[]; +} + +export type ListHandler = (input: T) => Promise; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts index d3d2e2a16fb..cc1671c7cd0 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts @@ -9,7 +9,7 @@ import { TaskHandlerInput, TaskHandlerOutput, TaskHandlerOptions, -} from '../types'; +} from './types'; import { constructBucket } from './utils'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts index c5f5ec98dac..237cf56694e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts @@ -1,6 +1,12 @@ import { LocationAccess, Permission } from '../../storage-internal'; -import { ActionInputConfig } from '../types'; -import { LocationData, LocationType } from './types'; +import { + ActionInputConfig, + FileData, + FileDataItem, + FileItem, + LocationData, + LocationType, +} from './types'; export const constructBucket = ({ bucket: bucketName, @@ -33,7 +39,6 @@ export const parseLocationAccess = (location: LocationAccess): LocationData => { // { scope: 's3://bucket/path/*', type: 'PREFIX', }, bucket = slicedScope.slice(0, slicedScope.indexOf('/')); prefix = `${slicedScope.slice(bucket.length + 1, -1)}`; - break; } case 'OBJECT': { @@ -79,3 +84,17 @@ export const parseLocations = ( }, [] ); + +export const getFileKey = (key: string): string => + key.slice(key.lastIndexOf('/') + 1, key.length); + +export const createFileDataItem = (data: FileData): FileDataItem => ({ + ...data, + fileKey: getFileKey(data.key), +}); + +export const isFileItem = (value: unknown): value is FileItem => + !!(value as FileItem).file; + +export const isFileDataItem = (item: unknown): item is FileDataItem => + !!(item as FileDataItem).fileKey; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/index.ts index d9e52c34435..9c10e4de790 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/index.ts @@ -10,27 +10,39 @@ export { } from './configs'; export { + ActionInputConfig, + copyHandler, + CopyHandler, + CopyHandlerData, + CopyHandlerInput, + CopyHandlerOutput, + createFileDataItem, createFolderHandler, CreateFolderHandler, CreateFolderHandlerData, CreateFolderHandlerInput, CreateFolderHandlerOptions, CreateFolderHandlerOutput, - copyHandler, - CopyHandler, - CopyHandlerData, - CopyHandlerInput, - CopyHandlerOutput, DeleteHandler, - DeleteHandlerInput, DeleteHandlerData, + DeleteHandlerInput, DeleteHandlerOptions, DeleteHandlerOutput, + FileData, + FileDataItem, + FileItem, + FolderData, + isFileDataItem, + isFileItem, + ListHandler, + ListHandlerInput, + ListHandlerOptions, + ListHandlerOutput, listLocationItemsHandler, ListLocationItemsHandler, ListLocationItemsHandlerInput, - ListLocationItemsHandlerOutput, ListLocationItemsHandlerOptions, + ListLocationItemsHandlerOutput, listLocationsHandler, ListLocationsHandler, ListLocationsHandlerInput, @@ -39,6 +51,11 @@ export { LocationData, LocationItemData, LocationType, + TaskData, + TaskHandler, + TaskHandlerInput, + TaskHandlerOptions, + TaskHandlerOutput, uploadHandler, UploadHandler, UploadHandlerData, @@ -46,16 +63,3 @@ export { UploadHandlerOptions, UploadHandlerOutput, } from './handlers'; - -export { - ActionInputConfig, - ListHandler, - ListHandlerInput, - ListHandlerOptions, - ListHandlerOutput, - TaskData, - TaskHandler, - TaskHandlerInput, - TaskHandlerOutput, - TaskHandlerOptions, -} from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/types.ts index eaf76448264..7a09f7e1089 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/types.ts @@ -1,69 +1,11 @@ -import { LocationCredentialsProvider } from '../storage-internal'; +import { DataState } from '@aws-amplify/ui-react-core'; -import { ActionState } from '../do-not-import-from-here/actions/createActionStateContext'; -import { FileData } from './handlers'; +import { ListHandler, TaskHandler } from './handlers'; -export interface ActionInputConfig { - accountId?: string; - bucket: string; - credentials: LocationCredentialsProvider; - customEndpoint?: string; - region: string; -} - -interface ActionInput { - config: ActionInputConfig; - prefix: string; - options?: T; -} - -export interface TaskData { - key: string; - id: string; -} - -export interface ActionData extends TaskData, FileData {} - -export interface TaskHandlerOptions { - onProgress?: ( - data: { key: string; id: string }, - progress: number | undefined - ) => void; -} - -export interface TaskHandlerInput< - T extends TaskData = TaskData, - K extends TaskHandlerOptions = TaskHandlerOptions, -> { - config: ActionInputConfig; - data: T; - options?: K; -} - -export interface TaskHandlerOutput { - cancel?: () => void; - result: Promise<{ - message?: string; - status: 'CANCELED' | 'COMPLETE' | 'FAILED' | 'OVERWRITE_PREVENTED'; - }>; -} - -export type TaskHandler = (input: T) => K; - -export interface ListHandlerOptions { - exclude?: T; - nextToken?: string; - pageSize?: number; -} - -export interface ListHandlerInput extends ActionInput {} - -export interface ListHandlerOutput { - nextToken: string | undefined; - items: T[]; -} - -export type ListHandler = (input: T) => Promise; +export type ActionState = [ + state: DataState, + handleAction: (...input: K[]) => void, +]; export type UseAction = T extends | ListHandler diff --git a/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts b/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts deleted file mode 100644 index bd4258b32b1..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Task } from '../tasks'; -import { INITIAL_STATUS_COUNTS } from '../views/LocationActionView/constants'; -import { TaskCounts } from './types'; - -export const getTaskCounts = (tasks: Task[] = []): TaskCounts => - tasks.reduce( - (counts, { status }) => ({ ...counts, [status]: counts[status] + 1 }), - { ...INITIAL_STATUS_COUNTS, TOTAL: tasks.length } - ); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useStatusDisplay.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useStatusDisplay.spec.tsx index 3ced1e803f2..175c2d7c623 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useStatusDisplay.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useStatusDisplay.spec.tsx @@ -5,11 +5,10 @@ jest.mock('../../../controls/context'); describe('useStatusDisplay', () => { const data = { - taskCounts: { + statusCounts: { CANCELED: 2, COMPLETE: 4, FAILED: 3, - INITIAL: 0, PENDING: 0, QUEUED: 1, TOTAL: 10, @@ -41,7 +40,7 @@ describe('useStatusDisplay', () => { }); }); - it('returns empty object if taskCounts is undefined', () => { + it('returns empty object if statusCounts is undefined', () => { mockUseControlsContext.mockReturnValue({ data: {}, actionsConfig }); expect(useStatusDisplay()).toBeNull(); @@ -50,11 +49,10 @@ describe('useStatusDisplay', () => { it('returns empty object if taksCount total is 0', () => { mockUseControlsContext.mockReturnValue({ data: { - taskCounts: { + statusCounts: { CANCELED: 0, COMPLETE: 0, FAILED: 0, - INITIAL: 0, PENDING: 0, QUEUED: 0, TOTAL: 0, @@ -81,11 +79,10 @@ describe('useStatusDisplay', () => { it('omits canceled status if action is not cancelable', () => { mockUseControlsContext.mockReturnValue({ data: { - taskCounts: { + statusCounts: { CANCELED: 0, COMPLETE: 4, FAILED: 3, - INITIAL: 0, PENDING: 0, QUEUED: 1, TOTAL: 8, diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useStatusDisplay.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useStatusDisplay.ts index 61b11843558..9626779a841 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useStatusDisplay.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useStatusDisplay.ts @@ -4,28 +4,28 @@ import { displayText } from '../../displayText/en'; export const useStatusDisplay = (): StatusDisplayProps | null => { const { data, actionsConfig } = useControlsContext(); - const { taskCounts } = data; + const { statusCounts } = data; const { isCancelable, type } = actionsConfig ?? {}; - if (!taskCounts?.TOTAL || type !== 'BATCH_ACTION') { + if (!statusCounts?.TOTAL || type !== 'BATCH_ACTION') { return null; } const statuses = [ - { name: displayText.statusDisplayCompleted, count: taskCounts.COMPLETE }, - { name: displayText.statusDisplayFailed, count: taskCounts.FAILED }, - { name: displayText.statusDisplayQueued, count: taskCounts.QUEUED }, + { name: displayText.statusDisplayCompleted, count: statusCounts.COMPLETE }, + { name: displayText.statusDisplayFailed, count: statusCounts.FAILED }, + { name: displayText.statusDisplayQueued, count: statusCounts.QUEUED }, ]; if (isCancelable) { statuses.splice(2, 0, { name: displayText.statusDisplayCanceled, - count: taskCounts.CANCELED, + count: statusCounts.CANCELED, }); } return { statuses, - total: taskCounts.TOTAL, + total: statusCounts.TOTAL, }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/types.ts b/packages/react-storage/src/components/StorageBrowser/controls/types.ts index a1dae0c92aa..6743ff02555 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/types.ts @@ -1,7 +1,8 @@ import { Composables } from '../composables/types'; import { DataTableSortHeader, DataTableProps } from '../composables/DataTable'; -import { INITIAL_STATUS_COUNTS } from '../views/LocationActionView/constants'; +import { DropZoneProps } from '../composables/DropZone'; import { LocationData } from '../actions'; +import { StatusCounts } from '../tasks'; export interface ControlProps { className?: string; @@ -13,8 +14,6 @@ export interface Controls { export type ControlKey = keyof Composables; -export type TaskCounts = typeof INITIAL_STATUS_COUNTS; - interface TruncatedSortHeader extends Omit< Extract, @@ -34,16 +33,20 @@ interface TableData { export interface ControlsContext { data: { actionStartLabel?: string; - isActionStartDisabled?: boolean; actionCancelLabel?: string; isActionCancelDisabled?: boolean; + isActionStartDisabled?: boolean; + isAddFilesDisabled?: boolean; + isAddFolderDisabled?: boolean; isDataRefreshDisabled?: boolean; + isExitDisabled?: boolean; + isOverwriteCheckboxDisabled?: boolean; currentLocation?: LocationData; currentPath?: string; showIncludeSubfolders?: boolean; searchPlaceholder?: string; tableData?: TableData; - taskCounts?: TaskCounts; + statusCounts?: StatusCounts; }; actionsConfig?: { type: @@ -55,6 +58,7 @@ export interface ControlsContext { }; onActionCancel?: () => void; onActionStart?: () => void; + onDropComplete?: DropZoneProps['onDropComplete']; onDropFiles?: (files: File[]) => void; onNavigate?: (location: LocationData, path?: string) => void; onNavigateHome?: () => void; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts index 2574d736f29..f635f6c87dc 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts @@ -1,24 +1,34 @@ import { act, renderHook } from '@testing-library/react'; -import { FileData } from '../../../../actions/handlers'; +import { FileData, FileDataItem } from '../../../../actions/handlers'; import { useLocationItems, LocationItemsProvider } from '../context'; -const fileDataItemOne: FileData = { +const fileDataOne: FileData = { id: 'id-one', - key: 'key-one', + key: 'some-prefix/key-one', lastModified: new Date(), size: 100, type: 'FILE', }; -const fileDataItemTwo: FileData = { +const fileDataItemOne: FileDataItem = { + ...fileDataOne, + fileKey: 'key-one', +}; + +const fileDataTwo: FileData = { id: 'id-two', - key: 'key-two', + key: 'some-prefix/key-two', lastModified: new Date(), size: 200, type: 'FILE', }; +const fileDataItemTwo: FileDataItem = { + ...fileDataTwo, + fileKey: 'key-two', +}; + describe('useLocationItems', () => { it('updates the value of `fileDataItems` as expected', () => { const { result } = renderHook(() => useLocationItems(), { @@ -29,29 +39,33 @@ describe('useLocationItems', () => { expect(initState.fileDataItems).toBeUndefined(); - const fileDataItems: FileData[] = [fileDataItemOne]; + const items: FileData[] = [fileDataOne]; act(() => { - handler({ type: 'SET_LOCATION_ITEMS', items: fileDataItems }); + handler({ type: 'SET_LOCATION_ITEMS', items }); }); const [nextState] = result.current; - // has same reference - expect(nextState.fileDataItems).toBe(fileDataItems); + expect(nextState.fileDataItems).toStrictEqual([fileDataItemOne]); - const additionalFileDataItems = [...fileDataItems, fileDataItemTwo]; + const additionalItems = [...items, fileDataTwo]; act(() => { - handler({ type: 'SET_LOCATION_ITEMS', items: additionalFileDataItems }); + handler({ type: 'SET_LOCATION_ITEMS', items: additionalItems }); }); const [updatedState] = result.current; // ignores pre-existing file data item expect(updatedState.fileDataItems).toHaveLength(2); + expect(updatedState.fileDataItems).toStrictEqual([ + fileDataItemOne, + fileDataItemTwo, + ]); + + const targetId = fileDataOne.id; - const targetId = fileDataItemOne.id; act(() => { handler({ type: 'REMOVE_LOCATION_ITEM', id: targetId }); }); @@ -61,6 +75,35 @@ describe('useLocationItems', () => { expect(removedState.fileDataItems).toHaveLength(1); // remaining item - expect(removedState.fileDataItems?.[0].id).toBe(fileDataItemTwo.id); + expect(removedState.fileDataItems?.[0].id).toBe(fileDataTwo.id); + }); + + it('returns prevState on remove when filtered items have the same length as previous items', () => { + const { result } = renderHook(() => useLocationItems(), { + wrapper: LocationItemsProvider, + }); + + const handler = result.current[1]; + + act(() => { + handler({ + type: 'SET_LOCATION_ITEMS', + items: [fileDataOne, fileDataItemTwo], + }); + }); + + const [nextState] = result.current; + + expect(nextState.fileDataItems).toHaveLength(2); + + act(() => { + handler({ type: 'REMOVE_LOCATION_ITEM', id: '🥵' }); + }); + + const [resultState] = result.current; + + expect(resultState.fileDataItems).toHaveLength(2); + // has same reference + expect(resultState).toBe(resultState); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx index c6fa0e1f697..fb3c32a4ee9 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { createContextUtilities } from '@aws-amplify/ui-react-core'; import { noop } from '@aws-amplify/ui'; -import { FileData } from '../../../actions/handlers'; +import { createFileDataItem, FileData, FileDataItem } from '../../../actions'; export const DEFAULT_STATE: LocationItemsState = { fileDataItems: undefined, @@ -15,7 +15,7 @@ export type LocationItemsAction = | { type: 'RESET_LOCATION_ITEMS' }; export interface LocationItemsState { - fileDataItems: FileData[] | undefined; + fileDataItems: FileDataItem[] | undefined; } export type HandleLocationItemsAction = (event: LocationItemsAction) => void; @@ -38,20 +38,22 @@ const locatonItemsReducer = ( const { items } = event; if (!items?.length) return prevState; - if (!prevState.fileDataItems?.length) return { fileDataItems: items }; + if (!prevState.fileDataItems?.length) { + return { fileDataItems: items.map(createFileDataItem) }; + } - const nextFileDataItems = items?.reduce( - (fileDataItems: FileData[], item) => - prevState.fileDataItems?.some(({ id }) => id === item.id) + const nextFileDataItems: FileDataItem[] = items?.reduce( + (fileDataItems: FileDataItem[], data) => + prevState.fileDataItems?.some(({ id }) => id === data.id) ? fileDataItems - : [...fileDataItems, item], + : fileDataItems.concat(createFileDataItem(data)), [] ); if (!nextFileDataItems?.length) return prevState; return { - fileDataItems: [...prevState.fileDataItems, ...nextFileDataItems], + fileDataItems: prevState.fileDataItems.concat(nextFileDataItems), }; } case 'REMOVE_LOCATION_ITEM': { @@ -77,10 +79,7 @@ const locatonItemsReducer = ( const defaultValue: LocationItemStateContext = [DEFAULT_STATE, noop]; export const { LocationItemsContext, useLocationItems } = - createContextUtilities({ - contextName: 'LocationItems', - defaultValue, - }); + createContextUtilities({ contextName: 'LocationItems', defaultValue }); export function LocationItemsProvider({ children, diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts index b3619a2d44b..7b32e117cec 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts @@ -324,6 +324,33 @@ describe('useProcessTasks', () => { expect(nextTasks.length).toBe(3); }); + it('returns the expected values for `isProcessing` and `isProcessingComplete`', async () => { + const { result } = renderHook(() => useProcessTasks(action, items)); + + const [initState, handleProcess] = result.current; + + expect(initState.isProcessing).toBe(false); + expect(initState.isProcessingComplete).toBe(false); + + act(() => { + handleProcess({ config, prefix }); + }); + + const [processingState] = result.current; + + expect(processingState.isProcessing).toBe(true); + expect(processingState.isProcessingComplete).toBe(false); + + await waitFor(() => { + expect(action).toHaveBeenCalledTimes(3); + }); + + const [completedState] = result.current; + + expect(completedState.isProcessing).toBe(false); + expect(completedState.isProcessingComplete).toBe(true); + }); + it.todo('handles progress updates as expected'); it.todo('ignores calls to handle processing when isProcessing is true'); it.todo('handles data provided through input as expected'); diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/constants.ts b/packages/react-storage/src/components/StorageBrowser/tasks/constants.ts new file mode 100644 index 00000000000..2b664763b28 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/constants.ts @@ -0,0 +1,11 @@ +import { StatusCounts } from './types'; + +export const INITIAL_STATUS_COUNTS: StatusCounts = { + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + PENDING: 0, + OVERWRITE_PREVENTED: 0, + QUEUED: 0, + TOTAL: 0, +}; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/index.ts b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts index b22b5ffd51e..ad45ecfed15 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/index.ts @@ -1,2 +1,3 @@ +export { INITIAL_STATUS_COUNTS } from './constants'; export { useProcessTasks } from './useProcessTasks'; -export { Task, Tasks, TaskStatus } from './types'; +export { StatusCounts, Task, Tasks, TaskStatus } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts index 3dcf2bf0f4c..1007a6775c9 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts @@ -8,6 +8,8 @@ export type TaskStatus = | 'QUEUED' | 'PENDING'; +export type StatusCounts = Record; + export interface ProcessTasksOptions< T extends TaskData, U extends number | never, @@ -34,3 +36,13 @@ export type HandleProcessTasks = ( ? Omit & K, 'data'> : TaskHandlerInput & K ) => void; + +export type UseProcessTasksState = [ + { + isProcessing: boolean; + isProcessingComplete: boolean; + statusCounts: StatusCounts; + tasks: Tasks; + }, + HandleProcessTasks, +]; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts index d335fada39e..e75eb752a49 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts @@ -7,7 +7,17 @@ import { TaskHandler, } from '../actions'; -import { HandleProcessTasks, ProcessTasksOptions, Task, Tasks } from './types'; +import { + HandleProcessTasks, + ProcessTasksOptions, + Task, + UseProcessTasksState, +} from './types'; +import { + getStatusCounts, + isProcessingTasks, + hasCompletedProcessingTasks, +} from './utils'; export type UseProcessTasks = < T extends TaskData, @@ -17,7 +27,7 @@ export type UseProcessTasks = < handler: TaskHandler & K, TaskHandlerOutput>, items?: D, options?: ProcessTasksOptions -) => [{ isProcessing: boolean; tasks: Tasks }, HandleProcessTasks]; +) => UseProcessTasksState; const QUEUED_TASK_BASE = { cancel: undefined, @@ -30,7 +40,7 @@ const isTaskHandlerInput = ( input: TaskHandlerInput | Omit ): input is TaskHandlerInput => !!(input as TaskHandlerInput).data; -export const useProcessTasks = < +export const useProcessTasks: UseProcessTasks = < T extends TaskData, // input params not included in `TaskHandlerInput` K, @@ -40,10 +50,7 @@ export const useProcessTasks = < handler: TaskHandler & K, TaskHandlerOutput>, items?: D, options?: ProcessTasksOptions -): [ - { tasks: Tasks; isProcessing: boolean }, - HandleProcessTasks, -] => { +): UseProcessTasksState => { const flush = React.useReducer(() => ({}), {})[1]; const { concurrency } = options ?? {}; @@ -137,7 +144,9 @@ export const useProcessTasks = < ); const tasks = [...tasksRef.current.values()]; - const isProcessing = tasks.some(({ status }) => status === 'PENDING'); + const statusCounts = getStatusCounts(tasks); + const isProcessing = isProcessingTasks(statusCounts); + const isProcessingComplete = hasCompletedProcessingTasks(statusCounts); const handleProcessTasks: HandleProcessTasks = (input) => { if (isProcessing) { @@ -156,5 +165,8 @@ export const useProcessTasks = < } }; - return [{ isProcessing, tasks }, handleProcessTasks]; + return [ + { isProcessing, isProcessingComplete, statusCounts, tasks }, + handleProcessTasks, + ]; }; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts new file mode 100644 index 00000000000..fcf634a9138 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts @@ -0,0 +1,24 @@ +import { INITIAL_STATUS_COUNTS } from './constants'; +import { StatusCounts, Task } from './types'; + +export const getStatusCounts = (tasks: Task[] = []): StatusCounts => + tasks.reduce( + (counts, { status }) => ({ ...counts, [status]: counts[status] + 1 }), + { ...INITIAL_STATUS_COUNTS, TOTAL: tasks.length } + ); + +export const isProcessingTasks = (statusCounts: StatusCounts): boolean => { + if (statusCounts.TOTAL === 0 || statusCounts.TOTAL === statusCounts.QUEUED) { + return false; + } + + return !(statusCounts.QUEUED === 0 && statusCounts.PENDING === 0); +}; + +export const hasCompletedProcessingTasks = ( + statusCounts: StatusCounts +): boolean => { + if (isProcessingTasks(statusCounts)) return false; + + return statusCounts.QUEUED === 0 && statusCounts.PENDING === 0; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyFilesControls.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyFilesControls.tsx index 2ed80298600..38b2da4706d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyFilesControls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyFilesControls.tsx @@ -9,80 +9,72 @@ import { displayText } from '../../displayText/en'; import { CLASS_BASE } from '../constants'; import { DestinationPicker } from './DestinationPicker'; -import { useCopyView } from './CopyView/useCopyView'; +import { useCopyView } from './CopyView'; import { DataTableControl } from '../../controls/DataTableControl'; import { ControlsContextProvider } from '../../controls/context'; import { getActionViewTableData } from './utils'; import { useStore } from '../../providers/store'; import { ControlsContext } from '../../controls/types'; import { ActionStartControl } from '../../controls/ActionStartControl'; -import { getTasksHaveStarted } from './utils'; import { DescriptionList } from '../../components/DescriptionList'; import { StatusDisplayControl } from '../../controls/StatusDisplayControl'; import { getDestinationListFullPrefix } from './utils/getDestinationPickerDataTable'; import { ActionCancelControl } from '../../controls/ActionCancelControl'; -import { CopyHandlerData } from '../../actions'; const { Exit } = Controls; const { actionSetDestination } = displayText; -export const CopyFilesControls = ({ - onExit: _onExit, -}: { +export const CopyFilesControls = (props: { onExit?: () => void; }): React.JSX.Element => { const { destinationList, - onSetDestinationList, - disableCancel, - disableClose, - disablePrimary, + onDestinationChange, + isProcessing, + isProcessingComplete, onExit, onActionCancel, onActionStart, - taskCounts, + statusCounts, tasks, - } = useCopyView({ onExit: _onExit }); + } = useCopyView(props); const [{ location }] = useStore(); - const { current, key } = location; - const tableData = getActionViewTableData({ + const { key } = location; + + const tableData = getActionViewTableData({ tasks, - taskCounts, - path: key, + folder: key, + isProcessing, }); + const isActionStartDisabled = + isProcessing || isProcessingComplete || destinationList.length === 0; + + const isActionCancelDisabled = !isProcessing || isProcessingComplete; + const contextValue: ControlsContext = { data: { - taskCounts, + statusCounts, tableData, - actionStartLabel: 'Start', - isActionStartDisabled: disablePrimary, - isActionCancelDisabled: disableCancel, + actionStartLabel: 'Copy', + isActionStartDisabled, + isActionCancelDisabled, actionCancelLabel: 'Cancel', }, actionsConfig: { type: 'BATCH_ACTION', isCancelable: true }, onActionStart, onActionCancel, }; - const hasStarted = getTasksHaveStarted(taskCounts); return ( - { - onExit(current!); - }} - disabled={disableClose} - /> - + - <ViewElement className={`${CLASS_BASE}__table-wrapper`}> <DataTableControl className={`${CLASS_BASE}__table`} /> </ViewElement> - - {hasStarted ? ( + {isProcessing || isProcessingComplete ? ( <ViewElement className={`${CLASS_BASE}__action-destination`}> <DescriptionList descriptions={[ @@ -96,12 +88,12 @@ export const CopyFilesControls = ({ ) : ( <DestinationPicker destinationList={destinationList} - onSetDestinationList={onSetDestinationList} + onDestinationChange={onDestinationChange} /> )} <ViewElement className={`${CLASS_BASE}__action-footer`}> - {hasStarted ? ( + {isProcessing || isProcessingComplete ? ( <StatusDisplayControl className={`${CLASS_BASE}__action-status-display`} /> diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useDestinationPicker.spec.tsx.snap b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useDestinationPicker.spec.ts.snap similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useDestinationPicker.spec.tsx.snap rename to packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useDestinationPicker.spec.ts.snap diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.ts new file mode 100644 index 00000000000..2c519ffc3a1 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.ts @@ -0,0 +1,182 @@ +import { renderHook, act } from '@testing-library/react'; + +import * as Store from '../../../../providers/store'; +import * as Config from '../../../../providers/configuration'; +import * as Tasks from '../../../../tasks'; + +import { useCopyView } from '../useCopyView'; + +const mockProcessTasks = jest.fn(); +const mockDispatchStoreAction = jest.fn(); + +describe('useCopyView', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(Store, 'useStore').mockReturnValue([ + { + actionType: 'COPY', + files: [], + location: { + current: { + prefix: 'test-prefix/', + bucket: 'bucket', + id: 'id', + permission: 'READWRITE', + type: 'PREFIX', + }, + path: '', + key: 'test-prefix/', + }, + locationItems: { + fileDataItems: [ + { + key: 'pre-pre/test-file.txt', + fileKey: 'test-file.txt', + lastModified: new Date(), + id: 'id', + size: 10, + type: 'FILE', + }, + ], + }, + }, + mockDispatchStoreAction, + ]); + + jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials: jest.fn(), + region: 'us-west-2', + })); + + // Mock the useProcessTasks hook + jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + tasks: [ + { + status: 'QUEUED', + data: { key: 'test-item', id: 'id' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item2', id: 'id2' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item3', id: 'id3' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + ], + }, + mockProcessTasks, + ]); + }); + + it('should return the correct initial state', () => { + const { result } = renderHook(() => useCopyView()); + + expect(result.current).toEqual( + expect.objectContaining({ + destinationList: ['test-prefix'], + isProcessing: false, + isProcessingComplete: false, + onActionCancel: expect.any(Function), + onExit: expect.any(Function), + onActionStart: expect.any(Function), + tasks: expect.any(Array), + }) + ); + + expect(result.current.statusCounts).toEqual({ + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 0, + QUEUED: 3, + TOTAL: 3, + }); + }); + + it('should call processTasks when onActionStart is called', () => { + const { result } = renderHook(() => useCopyView()); + + act(() => { + result.current.onActionStart(); + }); + + expect(mockProcessTasks).toHaveBeenCalledTimes(1); + expect(mockProcessTasks).toHaveBeenCalledWith({ + destinationPrefix: 'test-prefix/', + config: { + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials: expect.any(Function), + region: 'us-west-2', + }, + }); + }); + + it('should call cancel on tasks when onActionCancel is called', () => { + const mockCancel = jest.fn(); + jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 1, TOTAL: 1 }, + tasks: [ + { + data: { key: 'test-item', id: 'id' }, + status: 'QUEUED', + cancel: mockCancel(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + ], + }, + mockProcessTasks, + ]); + + const { result } = renderHook(() => useCopyView()); + + act(() => { + result.current.onActionCancel(); + }); + + expect(mockCancel).toHaveBeenCalled(); + }); + + it('should reset state when onExit is called', () => { + const mockOnExit = jest.fn(); + const { result } = renderHook(() => useCopyView({ onExit: mockOnExit })); + + act(() => { + result.current.onExit(); + }); + + expect(mockOnExit).toHaveBeenCalled(); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ + type: 'RESET_LOCATION_ITEMS', + }); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ + type: 'RESET_ACTION_TYPE', + }); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.tsx deleted file mode 100644 index ff25904b59b..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useCopyView.spec.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; - -import * as Store from '../../../../providers/store'; -import * as Config from '../../../../providers/configuration'; -import * as Tasks from '../../../../tasks'; -import { useCopyView } from '../useCopyView'; - -import { LocationData } from '../../../../actions'; - -const mockProcessTasks = jest.fn(); -const mockDispatchStoreAction = jest.fn(); - -describe('useCopyView', () => { - beforeEach(() => { - jest.clearAllMocks(); - - jest.spyOn(Store, 'useStore').mockReturnValue([ - { - actionType: 'COPY', - files: [], - location: { - current: { - prefix: 'test-prefix/', - bucket: 'bucket', - id: 'id', - permission: 'READWRITE', - type: 'PREFIX', - }, - path: '', - key: 'test-prefix/', - }, - locationItems: { - fileDataItems: [ - { - key: 'test-file.txt', - lastModified: new Date(), - id: 'id', - size: 10, - type: 'FILE', - }, - ], - }, - }, - mockDispatchStoreAction, - ]); - - jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials: jest.fn(), - region: 'us-west-2', - })); - - // Mock the useProcessTasks hook - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - status: 'QUEUED', - data: { key: 'test-item', id: 'id' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - { - status: 'QUEUED', - data: { key: 'test-item2', id: 'id2' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - { - status: 'QUEUED', - data: { key: 'test-item3', id: 'id3' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - }); - - it('should return the correct initial state', () => { - const { result } = renderHook(() => useCopyView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: false, - disablePrimary: false, - onActionCancel: expect.any(Function), - onExit: expect.any(Function), - onActionStart: expect.any(Function), - tasks: expect.any(Array), - }) - ); - - expect(result.current.taskCounts).toEqual({ - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }); - }); - - it('should call processTasks when onActionStart is called', () => { - const { result } = renderHook(() => useCopyView({})); - - act(() => { - result.current.onActionStart(); - }); - - expect(mockProcessTasks).toHaveBeenCalledWith({ - destinationPrefix: 'test-prefix/', - config: { - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials: expect.any(Function), - region: 'us-west-2', - }, - }); - }); - - it('should call cancel on tasks when onActionCancel is called', () => { - const mockCancel = jest.fn(); - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: { key: 'test-item', id: 'id' }, - status: 'QUEUED', - cancel: mockCancel(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - - const { result } = renderHook(() => useCopyView({})); - - act(() => { - result.current.onActionCancel(); - }); - - expect(mockCancel).toHaveBeenCalled(); - }); - - it('should reset state when onExit is called', () => { - const mockOnExit = jest.fn(); - const { result } = renderHook(() => useCopyView({ onExit: mockOnExit })); - - act(() => { - result.current.onExit({} as LocationData); - }); - - expect(mockOnExit).toHaveBeenCalled(); - expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'RESET_LOCATION_ITEMS', - }); - expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'RESET_ACTION_TYPE', - }); - }); - - it('should disable close and primary when some tasks in progress', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: true, - tasks: [ - { - data: { key: 'item1', id: 'id1' }, - progress: undefined, - status: 'QUEUED', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { key: 'item2', id: 'id2' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { key: 'item3', id: 'id3' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useCopyView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: false, - disableClose: true, - disablePrimary: true, - }) - ); - }); - - it('should disable cancel, close and primary when all tasks in progress or complete', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: true, - tasks: [ - { - data: { id: 'id1', key: 'item1' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id2', key: 'item2' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id3', key: 'item3' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useCopyView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: true, - disablePrimary: true, - }) - ); - }); - - it('should disable cancel, primary, but allow close when all tasks in progress or complete', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: { id: 'id1', key: 'item1' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id2', key: 'item2' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id3', key: 'item3' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useCopyView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: false, - disablePrimary: true, - }) - ); - }); - - it('should provide tasks data and task counts', () => { - const { result } = renderHook(() => useCopyView({})); - - expect(result.current.tasks).toEqual(expect.any(Array)); - expect(result.current.taskCounts).toEqual({ - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.ts similarity index 96% rename from packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.tsx rename to packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.ts index 2edad112930..8e86dc06391 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useDestinationPicker.spec.ts @@ -40,7 +40,8 @@ describe('useDestinationPicker', () => { locationItems: { fileDataItems: [ { - key: 'test-file.txt', + key: 'prefixer/test-file.txt', + fileKey: 'test-file.txt', lastModified: new Date(), id: 'id', size: 10, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts index 38dacc9a570..90702d28353 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts @@ -7,10 +7,7 @@ import { export interface CopyViewState extends ActionViewState<CopyHandlerData> { destinationList: string[]; - onSetDestinationList: (destination: string[]) => void; - disableCancel: boolean; - disableClose: boolean; - disablePrimary: boolean; + onDestinationChange: (destination: string[]) => void; } export interface CopyViewProps diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useCopyView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useCopyView.ts index c7ef54c042b..cbc37b66673 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useCopyView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useCopyView.ts @@ -2,20 +2,29 @@ import { useState } from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { copyHandler } from '../../../actions/handlers'; +import { LocationData, copyHandler } from '../../../actions/handlers'; import { useProcessTasks } from '../../../tasks'; import { useGetActionInput } from '../../../providers/configuration'; import { useStore } from '../../../providers/store'; -import { getActionViewDisabledButtons } from '../utils'; -import { getTaskCounts } from '../../../controls/getTaskCounts'; import { getDestinationListFullPrefix } from '../utils/getDestinationPickerDataTable'; import { CopyViewState } from './types'; -export const useCopyView = ({ - onExit: _onExit, -}: { - onExit?: () => void; +const getInitialDestinationList = (key: string, prefix?: string) => + // handle root bucket access grant + key === '' + ? [''] + : // handle subfolder inside root access grant + key && prefix == '' + ? ['', ...key.split('/').slice(0, -1)] + : // regular access that starts at prefix (not root bucket) + key.includes('/') + ? key.split('/').slice(0, -1) + : []; + +export const useCopyView = (params?: { + onExit?: (location: LocationData) => void; }): CopyViewState => { + const { onExit: _onExit } = params ?? {}; const [ { location, @@ -27,39 +36,20 @@ export const useCopyView = ({ const getInput = useGetActionInput(); - const [{ tasks }, handleProcess] = useProcessTasks( + const [processState, handleProcess] = useProcessTasks( copyHandler, - // @ts-ignore - // temporarily ignore error that items is missing - // until PR that refactors useProcessTasks is merged fileDataItems, - { - concurrency: 1, - } + { concurrency: 4 } ); - const destinationListWithoutSlashes = - // handle root bucket access grant - key === '' - ? [''] - : // handle subfolder inside root access grant - key && current?.prefix == '' - ? ['', ...key.split('/').slice(0, -1)] - : // regular access that starts at prefix (not root bucket) - key.includes('/') - ? key.split('/').slice(0, -1) - : []; + const { isProcessing, isProcessingComplete, statusCounts, tasks } = + processState; - const [destinationList, onSetDestinationList] = useState( - destinationListWithoutSlashes + const [destinationList, onDestinationChange] = useState(() => + getInitialDestinationList(key, current?.prefix) ); - const taskCounts = getTaskCounts(tasks); - const { disableCancel, disableClose, disablePrimary } = - getActionViewDisabledButtons(taskCounts); - const onActionStart = () => { - if (!destinationList) return; handleProcess({ config: getInput(), destinationPrefix: getDestinationListFullPrefix(destinationList), @@ -77,19 +67,18 @@ export const useCopyView = ({ dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); // clear selected action dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); - if (isFunction(_onExit)) _onExit(); + if (isFunction(_onExit)) _onExit(current!); }; return { destinationList, - disableCancel, - disableClose, - disablePrimary, + isProcessing, + isProcessingComplete, onActionCancel, onExit, - onSetDestinationList, + onDestinationChange, onActionStart, - taskCounts, + statusCounts, tasks, }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteFilesControls.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteFilesControls.tsx index c3176117ca7..061c2e3d0d7 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteFilesControls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteFilesControls.tsx @@ -17,36 +17,35 @@ import { LocationData } from '../../actions'; const { Exit } = Controls; -export const DeleteFilesControls = (props: { +export const DeleteFilesControls = (props?: { onExit?: (location: LocationData) => void; }): React.JSX.Element => { const { - disableCancel, - disableClose, - disablePrimary, - onExit, + isProcessing, + isProcessingComplete, onActionCancel, onActionStart, - taskCounts, + onExit, + statusCounts, tasks, } = useDeleteView(props); const [{ location }] = useStore(); - const { current, key } = location; + const { key } = location; const tableData = getActionViewTableData({ tasks, - taskCounts, - path: key, + folder: key, + isProcessing, }); const contextValue: ControlsContext = { data: { - taskCounts, + statusCounts, tableData, - isActionStartDisabled: disablePrimary, + isActionStartDisabled: isProcessing || isProcessingComplete, actionStartLabel: 'Start', actionCancelLabel: 'Cancel', - isActionCancelDisabled: disableCancel, + isActionCancelDisabled: !isProcessing || isProcessingComplete, }, actionsConfig: { type: 'BATCH_ACTION', isCancelable: true }, onActionStart, @@ -55,12 +54,7 @@ export const DeleteFilesControls = (props: { return ( <ControlsContextProvider {...contextValue}> - <Exit - onClick={() => { - onExit(current!); - }} - disabled={disableClose} - /> + <Exit onClick={onExit} disabled={isProcessing} /> <Title /> <ViewElement className={`${CLASS_BASE}__table-wrapper`}> <DataTableControl className={`${CLASS_BASE}__table`} /> @@ -69,7 +63,6 @@ export const DeleteFilesControls = (props: { <StatusDisplayControl className={`${CLASS_BASE}__action-status-display`} /> - <ActionCancelControl className={`${CLASS_BASE}__cancel`} /> <ActionStartControl /> </ViewElement> diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.ts new file mode 100644 index 00000000000..2711cc00d71 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.ts @@ -0,0 +1,178 @@ +import { renderHook, act } from '@testing-library/react'; + +import * as Store from '../../../../providers/store'; +import * as Config from '../../../../providers/configuration'; +import * as Tasks from '../../../../tasks'; + +import { useDeleteView } from '../useDeleteView'; + +const mockProcessTasks = jest.fn(); +const mockDispatchStoreAction = jest.fn(); + +const credentials = jest.fn(); +jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials, + region: 'us-west-2', +})); + +describe('useDeleteView', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(Store, 'useStore').mockReturnValue([ + { + actionType: 'DELETE', + files: [], + location: { + current: { + prefix: 'test-prefix/', + bucket: 'bucket', + id: 'id', + permission: 'READ', + type: 'PREFIX', + }, + path: '', + key: 'test-prefix/', + }, + locationItems: { + fileDataItems: [ + { + key: 'pretend-prefix/test-file.txt', + fileKey: 'test-file.txt', + lastModified: new Date(), + id: 'id', + size: 10, + type: 'FILE', + }, + ], + }, + }, + mockDispatchStoreAction, + ]); + + // Mock the useProcessTasks hook + jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + tasks: [ + { + status: 'QUEUED', + data: { key: 'test-item', id: 'id' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item2', id: 'id2' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item3', id: 'id3' }, + cancel: jest.fn(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + ], + }, + mockProcessTasks, + ]); + }); + + it('should return the correct initial state', () => { + const { result } = renderHook(() => useDeleteView()); + + expect(result.current).toEqual( + expect.objectContaining({ + onActionCancel: expect.any(Function), + onExit: expect.any(Function), + onActionStart: expect.any(Function), + tasks: expect.any(Array), + }) + ); + + expect(result.current.statusCounts).toEqual({ + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 0, + QUEUED: 3, + TOTAL: 3, + }); + }); + + it('should call processTasks when onActionStart is called', () => { + const { result } = renderHook(() => useDeleteView()); + + act(() => { + result.current.onActionStart(); + }); + + expect(mockProcessTasks).toHaveBeenCalledWith({ + config: { + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials, + region: 'us-west-2', + }, + }); + }); + + it('should call cancel on tasks when onActionCancel is called', () => { + const mockCancel = jest.fn(); + jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 1, TOTAL: 1 }, + tasks: [ + { + data: { key: 'test-item', id: 'id' }, + status: 'QUEUED', + cancel: mockCancel(), + remove: jest.fn(), + message: 'test-message', + progress: undefined, + }, + ], + }, + mockProcessTasks, + ]); + + const { result } = renderHook(() => useDeleteView()); + + act(() => { + result.current.onActionCancel(); + }); + + expect(mockCancel).toHaveBeenCalled(); + }); + + it('should reset state when onExit is called', () => { + const mockOnExit = jest.fn(); + const { result } = renderHook(() => useDeleteView({ onExit: mockOnExit })); + + act(() => { + result.current.onExit(); + }); + + expect(mockOnExit).toHaveBeenCalled(); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ + type: 'RESET_LOCATION_ITEMS', + }); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ + type: 'RESET_ACTION_TYPE', + }); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.tsx deleted file mode 100644 index 8cc9ed5d693..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/useDeleteView.spec.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; - -import * as Store from '../../../../providers/store'; -import * as Config from '../../../../providers/configuration'; -import * as Tasks from '../../../../tasks'; -import { LocationData } from '../../../../actions'; - -import { useDeleteView } from '../useDeleteView'; - -const mockProcessTasks = jest.fn(); -const mockDispatchStoreAction = jest.fn(); - -const credentials = jest.fn(); -jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials, - region: 'us-west-2', -})); - -describe('useDeleteView', () => { - beforeEach(() => { - jest.clearAllMocks(); - - jest.spyOn(Store, 'useStore').mockReturnValue([ - { - actionType: 'DELETE', - files: [], - location: { - current: { - prefix: 'test-prefix/', - bucket: 'bucket', - id: 'id', - permission: 'READ', - type: 'PREFIX', - }, - path: '', - key: 'test-prefix/', - }, - locationItems: { - fileDataItems: [ - { - key: 'test-file.txt', - lastModified: new Date(), - id: 'id', - size: 10, - type: 'FILE', - }, - ], - }, - }, - mockDispatchStoreAction, - ]); - - // Mock the useProcessTasks hook - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - status: 'QUEUED', - data: { key: 'test-item', id: 'id' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - { - status: 'QUEUED', - data: { key: 'test-item2', id: 'id2' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - { - status: 'QUEUED', - data: { key: 'test-item3', id: 'id3' }, - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - }); - - it('should return the correct initial state', () => { - const { result } = renderHook(() => useDeleteView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: false, - disablePrimary: false, - onActionCancel: expect.any(Function), - onExit: expect.any(Function), - onActionStart: expect.any(Function), - tasks: expect.any(Array), - }) - ); - - expect(result.current.taskCounts).toEqual({ - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }); - }); - - it('should call processTasks when onActionStart is called', () => { - const { result } = renderHook(() => useDeleteView({})); - - act(() => { - result.current.onActionStart(); - }); - - expect(mockProcessTasks).toHaveBeenCalledWith({ - config: { - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials, - region: 'us-west-2', - }, - }); - }); - - it('should call cancel on tasks when onActionCancel is called', () => { - const mockCancel = jest.fn(); - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: { key: 'test-item', id: 'id' }, - status: 'QUEUED', - cancel: mockCancel(), - remove: jest.fn(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - - const { result } = renderHook(() => useDeleteView({})); - - act(() => { - result.current.onActionCancel(); - }); - - expect(mockCancel).toHaveBeenCalled(); - }); - - it('should reset state when onExit is called', () => { - const mockOnExit = jest.fn(); - const { result } = renderHook(() => useDeleteView({ onExit: mockOnExit })); - - act(() => { - result.current.onExit({} as LocationData); - }); - - expect(mockOnExit).toHaveBeenCalled(); - expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'RESET_LOCATION_ITEMS', - }); - expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'RESET_ACTION_TYPE', - }); - }); - - it('should disable close and primary when some tasks in progress', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: true, - tasks: [ - { - data: { key: 'item1', id: 'id1' }, - progress: undefined, - status: 'QUEUED', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { key: 'item2', id: 'id2' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { key: 'item3', id: 'id3' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useDeleteView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: false, - disableClose: true, - disablePrimary: true, - }) - ); - }); - - it('should disable cancel, close and primary when all tasks in progress or complete', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: true, - tasks: [ - { - data: { id: 'id1', key: 'item1' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id2', key: 'item2' }, - progress: undefined, - status: 'PENDING', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id3', key: 'item3' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useDeleteView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: true, - disablePrimary: true, - }) - ); - }); - - it('should disable cancel, primary, but allow close when all tasks in progress or complete', () => { - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: { id: 'id1', key: 'item1' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id2', key: 'item2' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - { - data: { id: 'id3', key: 'item3' }, - progress: undefined, - status: 'COMPLETE', - cancel: jest.fn(), - remove: jest.fn(), - message: 'test-message', - }, - ], - }, - jest.fn(), - ]); - - const { result } = renderHook(() => useDeleteView({})); - - expect(result.current).toEqual( - expect.objectContaining({ - disableCancel: true, - disableClose: false, - disablePrimary: true, - }) - ); - }); - - it('should provide tasks data and task counts', () => { - const { result } = renderHook(() => useDeleteView({})); - - expect(result.current.tasks).toEqual(expect.any(Array)); - expect(result.current.taskCounts).toEqual({ - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts index 1c0cf607bb1..e95fbc1fa45 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts @@ -5,11 +5,7 @@ import { ActionViewState, } from '../types'; -export interface DeleteViewState extends ActionViewState<DeleteHandlerData> { - disableCancel: boolean; - disableClose: boolean; - disablePrimary: boolean; -} +export interface DeleteViewState extends ActionViewState<DeleteHandlerData> {} export interface DeleteViewProps extends ActionViewProps, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/useDeleteView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/useDeleteView.ts index 5ad4c95f940..31e4284e079 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/useDeleteView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/useDeleteView.ts @@ -3,36 +3,29 @@ import { DeleteViewState } from './types'; import { isFunction } from '@aws-amplify/ui'; import { LocationData, deleteHandler } from '../../../actions/handlers'; -import { getTaskCounts } from '../../../controls/getTaskCounts'; import { useStore } from '../../../providers/store'; import { useGetActionInput } from '../../../providers/configuration'; import { useProcessTasks } from '../../../tasks'; -import { getActionViewDisabledButtons } from '../utils'; -export const useDeleteView = ({ - onExit: _onExit, -}: { +export const useDeleteView = (params?: { onExit?: (location: LocationData) => void; }): DeleteViewState => { - const [ - { - location, - locationItems: { fileDataItems }, - }, - dispatchStoreAction, - ] = useStore(); + const { onExit: _onExit } = params ?? {}; + + const [{ location, locationItems }, dispatchStoreAction] = useStore(); + const { fileDataItems } = locationItems; const { current } = location; const getInput = useGetActionInput(); - const [{ tasks }, handleProcess] = useProcessTasks( + const [processState, handleProcess] = useProcessTasks( deleteHandler, - fileDataItems + fileDataItems, + { concurrency: 4 } ); - const taskCounts = getTaskCounts(tasks); - const { disableCancel, disableClose, disablePrimary } = - getActionViewDisabledButtons(taskCounts); + const { isProcessing, isProcessingComplete, statusCounts, tasks } = + processState; const onActionStart = () => { if (!current) return; @@ -47,6 +40,8 @@ export const useDeleteView = ({ }; const onExit = () => { + // clear tasks state + tasks.forEach(({ remove }) => remove()); // clear files state dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); // clear selected action @@ -55,13 +50,12 @@ export const useDeleteView = ({ }; return { - disableCancel, - disableClose, - disablePrimary, + isProcessing, + isProcessingComplete, onActionCancel, onExit, onActionStart, - taskCounts, + statusCounts, tasks, }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DestinationPicker.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DestinationPicker.tsx index b094ad81ec3..dadb0cdbfc4 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DestinationPicker.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DestinationPicker.tsx @@ -34,10 +34,10 @@ export const DEFAULT_LIST_OPTIONS = { export const DestinationPicker = ({ destinationList, - onSetDestinationList, + onDestinationChange, }: { destinationList: string[]; - onSetDestinationList: (destination: string[]) => void; + onDestinationChange: (destination: string[]) => void; }): React.JSX.Element => { const { bucket, @@ -54,12 +54,12 @@ export const DestinationPicker = ({ const handleNavigateFolder = (key: string) => { const newPath = [...destinationList, key.replace('/', '')]; - onSetDestinationList(newPath); + onDestinationChange(newPath); }; const handleNavigatePath = (index: number) => { const newPath = destinationList.slice(0, index + 1); - onSetDestinationList(newPath); + onDestinationChange(newPath); }; const pageItems = React.useMemo(() => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadControls.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadControls.tsx index b5610be0c75..2ebef148178 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadControls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadControls.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { humanFileSize, isFunction } from '@aws-amplify/ui'; +import { humanFileSize } from '@aws-amplify/ui'; -import { LocationData, uploadHandler } from '../../actions'; +import { LocationData } from '../../actions'; import { displayText } from '../../displayText/en'; import { TABLE_HEADER_BUTTON_CLASS_NAME } from '../../components/DataTable'; import { DescriptionList } from '../../components/DescriptionList'; @@ -12,13 +12,12 @@ import { ViewElement, } from '../../context/elements'; import { IconElement, IconVariant } from '../../context/elements/IconElement'; -import { getTaskCounts } from '../../controls/getTaskCounts'; + import { StatusDisplayControl } from '../../controls/StatusDisplayControl'; import { ControlsContextProvider } from '../../controls/context'; import { ControlsContext } from '../../controls/types'; -import { useGetActionInput } from '../../providers/configuration'; import { useStore } from '../../providers/store'; -import { TaskStatus, useProcessTasks } from '../../tasks'; +import { TaskStatus } from '../../tasks'; import { compareNumbers, compareStrings, getPercentValue } from '../utils'; import { CLASS_BASE } from '../constants'; @@ -30,12 +29,10 @@ import { SortState, } from '../Controls/Table'; import { Title } from './Controls/Title'; -import { - DEFAULT_OVERWRITE_PROTECTION, - STATUS_DISPLAY_VALUES, -} from './constants'; +import { STATUS_DISPLAY_VALUES } from './constants'; import { FileItems } from '../../providers/store/files'; import { ActionStartControl } from '../../controls/ActionStartControl'; +import { useUploadView } from './UploadView'; import { ActionCancelControl } from '../../controls/ActionCancelControl'; const { Icon } = StorageBrowserElements; @@ -191,19 +188,11 @@ const getFileSelectionType = ( return actionType === 'UPLOAD_FILES' ? 'FILE' : 'FOLDER'; }; -export const UploadControls = ({ - onExit, -}: { +export const UploadControls = (props: { onExit?: (location: LocationData) => void; }): JSX.Element => { - const getInput = useGetActionInput(); - - const [preventOverwrite, setPreventOverwrite] = React.useState( - DEFAULT_OVERWRITE_PROTECTION - ); - const [{ actionType, files, location }, dispatchStoreAction] = useStore(); - const { current, key: destinationPrefix } = location; + const { key: destinationPrefix } = location; // launch native file picker on intiial render if no files are currently in state const selectionTypeRef = React.useRef<'FILE' | 'FOLDER' | undefined>( @@ -223,11 +212,19 @@ export const UploadControls = ({ }; }, [dispatchStoreAction]); - const [{ tasks, isProcessing }, handleProcess] = useProcessTasks( - uploadHandler, - files, - { concurrency: 4 } - ); + const { + tasks, + statusCounts, + isProcessing, + isProcessingComplete, + isOverwriteEnabled, + onToggleOverwrite, + onActionStart, + onActionCancel, + onSelectFiles, + onExit, + onDropFiles, + } = useUploadView(props); const [compareFn, setCompareFn] = React.useState<(a: any, b: any) => number>( () => compareStrings @@ -317,57 +314,40 @@ export const UploadControls = ({ [direction, selection] ); - const taskCounts = getTaskCounts(tasks); + const isActionStartDisabled = + isProcessing || isProcessingComplete || statusCounts.TOTAL === 0; + const isActionCancelDisabled = !isProcessing || isProcessingComplete; + const isAddFilesDisabled = isProcessing || isProcessingComplete; + const isAddFolderDisabled = isProcessing || isProcessingComplete; + const isExitDisabled = isProcessing; + const isOverwriteCheckboxDisabled = isProcessing || isProcessingComplete; - const hasStarted = !!taskCounts.PENDING; - const hasCompleted = - !!taskCounts.TOTAL && - taskCounts.CANCELED + taskCounts.COMPLETE + taskCounts.FAILED === - taskCounts.TOTAL; - - const disableCancel = !taskCounts.TOTAL || !hasStarted || hasCompleted; - const disablePrimary = !taskCounts.TOTAL || hasStarted || hasCompleted; - const disableOverwrite = hasStarted || hasCompleted; - const disableSelectFiles = hasStarted || hasCompleted; - - // FIXME: Eventually comes from useView hook const contextValue: ControlsContext = { data: { - taskCounts, - isActionStartDisabled: disablePrimary, - actionStartLabel: 'Start', actionCancelLabel: 'Cancel', - isActionCancelDisabled: disableCancel, + actionStartLabel: 'Upload', + isActionCancelDisabled, + isActionStartDisabled, + isAddFilesDisabled, + isAddFolderDisabled, + isExitDisabled, + isOverwriteCheckboxDisabled, + statusCounts, }, actionsConfig: { type: 'BATCH_ACTION', isCancelable: true, }, - onActionStart: () => { - handleProcess({ - config: getInput(), - destinationPrefix, - options: { preventOverwrite }, - }); - }, - onActionCancel: () => { - tasks.forEach((task) => { - task.cancel?.(); - }); - }, + onActionStart, + onActionCancel, }; return ( <ControlsContextProvider {...contextValue}> <Exit + disabled={isExitDisabled} onClick={() => { - if (isFunction(onExit)) onExit?.(current!); - // clear tasks state - tasks.forEach(({ remove }) => remove?.()); - // clear files state - dispatchStoreAction({ type: 'RESET_FILE_ITEMS' }); - // clear selected action - dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); + onExit(); }} /> <Title /> @@ -382,35 +362,27 @@ export const UploadControls = ({ ]} /> <Overwrite - defaultChecked={!preventOverwrite} - disabled={disableOverwrite} - handleChange={() => { - setPreventOverwrite((overwrite) => !overwrite); - }} + defaultChecked={isOverwriteEnabled} + disabled={isOverwriteCheckboxDisabled} + handleChange={onToggleOverwrite} /> </ViewElement> <ButtonElement - disabled={disableSelectFiles} + disabled={isAddFolderDisabled} className={`${CLASS_BASE}__add-folder`} variant="add-folder" onClick={() => { - dispatchStoreAction({ - type: 'SELECT_FILES', - selectionType: 'FOLDER', - }); + onSelectFiles('FOLDER'); }} > Add folder </ButtonElement> <ButtonElement - disabled={disableSelectFiles} + disabled={isAddFilesDisabled} className={`${CLASS_BASE}__add-files`} variant="add-files" onClick={() => { - dispatchStoreAction({ - type: 'SELECT_FILES', - selectionType: 'FILE', - }); + onSelectFiles('FILE'); }} > Add files @@ -419,9 +391,7 @@ export const UploadControls = ({ <Table data={tableData} columns={LOCATION_ACTION_VIEW_COLUMNS} - handleDroppedFiles={(files) => { - dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); - }} + handleDroppedFiles={(files) => onDropFiles(files)} renderHeaderItem={renderHeaderItem} renderRowItem={renderRowItem} /> diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/useUploadView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/useUploadView.spec.ts new file mode 100644 index 00000000000..3e04ef6f7af --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/useUploadView.spec.ts @@ -0,0 +1,219 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useUploadView } from '../useUploadView'; +import * as ConfigModule from '../../../../providers/configuration'; +import * as StoreModule from '../../../../providers/store'; +import * as TasksModule from '../../../../tasks'; + +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); + +const rootLocation = { + id: 'an-id-👍🏼', + bucket: 'test-bucket', + permission: 'READWRITE', + // a root `prefix` is an empty string + prefix: '', + type: 'BUCKET', +}; + +const dispatchStoreAction = jest.fn(); +useStoreSpy.mockReturnValue([ + { + location: { current: rootLocation, path: '', key: '' }, + } as StoreModule.UseStoreState, + dispatchStoreAction, +]); + +const credentials = jest.fn(); +const config: ConfigModule.GetActionInput = jest.fn(() => ({ + credentials, + bucket: rootLocation.bucket, + region: 'region', +})); + +const testFileOne = new File([], 'test-ooo'); +const fileItemOne = { + id: 'some-uuid', + file: testFileOne, + key: testFileOne.name, +}; +const testFileTwo = new File([], 'test-oooxxxxooooo'); +const fileItemTwo = { + id: 'some-uuid', + file: testFileTwo, + key: testFileTwo.name, +}; + +jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(config); +const handleProcessTasks = jest.fn(); + +const taskOne: TasksModule.Task<StoreModule.FileItem> = { + data: fileItemOne, + cancel: jest.fn(), + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'QUEUED', +}; + +const taskTwo: TasksModule.Task<StoreModule.FileItem> = { + data: fileItemTwo, + cancel: jest.fn(), + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'QUEUED', +}; + +const useProcessTasksSpy = jest + .spyOn(TasksModule, 'useProcessTasks') + .mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: TasksModule.INITIAL_STATUS_COUNTS, + tasks: [], + }, + handleProcessTasks, + ]); + +describe('useUploadView', () => { + beforeEach(jest.clearAllMocks); + + it('should dispatchStoreAction when onDropFiles is invoked', () => { + const { result } = renderHook(() => useUploadView()); + + act(() => { + result.current.onDropFiles([testFileOne]); + }); + + expect(dispatchStoreAction).toHaveBeenCalledTimes(1); + expect(dispatchStoreAction).toHaveBeenCalledWith({ + type: 'ADD_FILE_ITEMS', + files: [testFileOne], + }); + }); + + it('should dispatchStoreAction when onSelectFiles is invoked with different types', () => { + const { result } = renderHook(() => useUploadView()); + + act(() => { + result.current.onSelectFiles('FILE'); + }); + + expect(dispatchStoreAction).toHaveBeenCalledTimes(1); + expect(dispatchStoreAction).toHaveBeenCalledWith({ + type: 'SELECT_FILES', + selectionType: 'FILE', + }); + + act(() => { + result.current.onSelectFiles('FOLDER'); + }); + + expect(dispatchStoreAction).toHaveBeenCalledTimes(2); + expect(dispatchStoreAction).toHaveBeenCalledWith({ + type: 'SELECT_FILES', + selectionType: 'FOLDER', + }); + }); + + it('should call handleProcessTasks with the expected values', () => { + const { result } = renderHook(() => useUploadView()); + act(() => { + result.current.onActionStart(); + }); + expect(handleProcessTasks).toHaveBeenCalledTimes(1); + expect(handleProcessTasks).toHaveBeenCalledWith({ + config: { + bucket: rootLocation.bucket, + credentials, + region: 'region', + }, + options: { preventOverwrite: true }, + destinationPrefix: '', + }); + }); + it('should call cancel on each pending task when onCancel is invoked', () => { + const tasks: TasksModule.Task<StoreModule.FileItem>[] = [ + { ...taskOne, status: 'PENDING' }, + { ...taskTwo, status: 'PENDING' }, + ]; + + useProcessTasksSpy.mockReturnValue([ + { + tasks, + isProcessing: true, + isProcessingComplete: false, + statusCounts: { + ...TasksModule.INITIAL_STATUS_COUNTS, + PENDING: 2, + TOTAL: 2, + }, + }, + handleProcessTasks, + ]); + + const { result } = renderHook(() => useUploadView()); + + act(() => { + result.current.onActionCancel(); + }); + + expect(tasks[0].cancel).toHaveBeenCalledTimes(1); + expect(tasks[1].cancel).toHaveBeenCalledTimes(1); + }); + + it('should call remove on each task, provided onExit and dispatch actions when returned onExit is invoked', () => { + const tasks: TasksModule.Task<StoreModule.FileItem>[] = [ + { ...taskOne, status: 'FAILED' }, + { ...taskTwo, status: 'COMPLETE' }, + ]; + + useProcessTasksSpy.mockReturnValue([ + { + tasks, + isProcessing: true, + isProcessingComplete: false, + statusCounts: { + ...TasksModule.INITIAL_STATUS_COUNTS, + COMPLETE: 1, + FAILED: 1, + TOTAL: 2, + }, + }, + handleProcessTasks, + ]); + + const onExit = jest.fn(); + + const { result } = renderHook(() => useUploadView({ onExit })); + + act(() => { + result.current.onExit(); + }); + + expect(tasks[0].remove).toHaveBeenCalledTimes(1); + expect(tasks[1].remove).toHaveBeenCalledTimes(1); + + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(rootLocation); + + expect(dispatchStoreAction.mock.calls).toEqual([ + [{ type: 'RESET_FILE_ITEMS' }], + [{ type: 'RESET_ACTION_TYPE' }], + ]); + }); + + it('should change preventOverwrite state when onToggleOverwrite callback is called', () => { + const { result } = renderHook(() => useUploadView()); + + // initial + expect(result.current.isOverwriteEnabled).toBe(false); + + act(() => { + result.current.onToggleOverwrite(); + }); + + expect(result.current.isOverwriteEnabled).toBe(true); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/constants.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/constants.ts new file mode 100644 index 00000000000..c39ee4b10b2 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_OVERWRITE_ENABLED = false; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts index 751834e3018..a84401f5bd0 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts @@ -1,12 +1,17 @@ import { UploadHandlerData } from '../../../actions'; +import { FileItem } from '../../../providers'; import { ActionViewComponent, ActionViewProps, ActionViewState, } from '../types'; -export interface UploadViewState extends ActionViewState<UploadHandlerData> { - onOverwriteChange: (enabled: boolean) => void; +export interface UploadViewState extends ActionViewState<FileItem> { + destinationPrefix: string; + isOverwriteEnabled: boolean; + onDropFiles: (files?: File[]) => void; + onSelectFiles: (type?: 'FILE' | 'FOLDER') => void; + onToggleOverwrite: () => void; } export interface UploadViewProps diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/useUploadView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/useUploadView.ts index cab8e4319bf..e2abf9c5695 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/useUploadView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/useUploadView.ts @@ -1,5 +1,89 @@ +import React from 'react'; +import { isFunction } from '@aws-amplify/ui'; + +import { LocationData, uploadHandler } from '../../../actions'; + +import { useGetActionInput } from '../../../providers/configuration'; +import { useStore } from '../../../providers/store'; +import { useProcessTasks } from '../../../tasks'; + +import { DEFAULT_OVERWRITE_ENABLED } from './constants'; import { UploadViewState } from './types'; -export function useUploadView(): UploadViewState { - throw new Error('useUploadView is not implemented'); -} +export const useUploadView = (params?: { + onExit?: (location: LocationData) => void; +}): UploadViewState => { + const { onExit: _onExit } = params ?? {}; + + const getInput = useGetActionInput(); + const [{ files, location }, dispatchStoreAction] = useStore(); + const { current, key: destinationPrefix } = location; + + const [isOverwriteEnabled, setOverwriteEnabled] = React.useState( + DEFAULT_OVERWRITE_ENABLED + ); + + const [processState, handleProcess] = useProcessTasks(uploadHandler, files, { + concurrency: 4, + }); + + const { isProcessing, isProcessingComplete, statusCounts, tasks } = + processState; + + const onDropFiles = React.useCallback( + (files?: File[]) => { + if (files) { + dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); + } + }, + [dispatchStoreAction] + ); + + const onSelectFiles = React.useCallback( + (type?: 'FILE' | 'FOLDER') => { + dispatchStoreAction({ type: 'SELECT_FILES', selectionType: type }); + }, + [dispatchStoreAction] + ); + + const onActionStart = React.useCallback(() => { + handleProcess({ + config: getInput(), + destinationPrefix, + options: { preventOverwrite: !isOverwriteEnabled }, + }); + }, [destinationPrefix, getInput, handleProcess, isOverwriteEnabled]); + + const onActionCancel = React.useCallback(() => { + tasks.forEach((task) => task.cancel?.()); + }, [tasks]); + + const onExit = React.useCallback(() => { + if (isFunction(_onExit)) _onExit?.(current!); + // clear tasks state + tasks.forEach(({ remove }) => remove()); + // clear files state + dispatchStoreAction({ type: 'RESET_FILE_ITEMS' }); + // clear selected action + dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); + }, [tasks, dispatchStoreAction, _onExit, current]); + + const onToggleOverwrite = React.useCallback(() => { + setOverwriteEnabled((prev) => !prev); + }, []); + + return { + destinationPrefix, + isOverwriteEnabled, + isProcessing, + isProcessingComplete, + onActionCancel, + onActionStart, + onDropFiles, + onExit, + onSelectFiles, + onToggleOverwrite, + statusCounts, + tasks, + }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/ActionIcon.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/ActionIcon.spec.tsx new file mode 100644 index 00000000000..963cdf9f7d7 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/ActionIcon.spec.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ActionIcon, ICON_CLASS } from '../UploadControls'; + +describe('ActionIcon', () => { + it('should show all icon statuses', () => { + const { container } = render( + <> + <ActionIcon /> + <ActionIcon status="CANCELED" /> + <ActionIcon status="COMPLETE" /> + <ActionIcon status="QUEUED" /> + <ActionIcon status="FAILED" /> + <ActionIcon status="PENDING" /> + </> + ); + const svg = container.querySelectorAll('svg'); + expect(svg[0]?.classList).toContain(`${ICON_CLASS}--action-initial`); + expect(svg[1]?.classList).toContain(`${ICON_CLASS}--action-canceled`); + expect(svg[2]?.classList).toContain(`${ICON_CLASS}--action-success`); + expect(svg[3]?.classList).toContain(`${ICON_CLASS}--action-queued`); + expect(svg[4]?.classList).toContain(`${ICON_CLASS}--action-error`); + expect(svg[5]?.classList).toContain(`${ICON_CLASS}--action-progress`); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CopyFilesControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CopyFilesControls.spec.tsx index 8dfa93dc24f..c1547ab0bf0 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CopyFilesControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CopyFilesControls.spec.tsx @@ -2,179 +2,176 @@ import React from 'react'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import * as AmplifyReactCore from '@aws-amplify/ui-react-core'; +import * as ReactCoreModule from '@aws-amplify/ui-react-core'; import * as TempActions from '../../../do-not-import-from-here/createTempActionsProvider'; -import * as UseCopyViewModule from '../CopyView/useCopyView'; + import * as Config from '../../../providers/configuration'; +import { INITIAL_STATUS_COUNTS } from '../../../tasks'; +import * as UseCopyViewModule from '../CopyView'; import { CopyFilesControls } from '../CopyFilesControls'; -const TEST_ACTIONS = { - COPY_FILES: { - options: { displayName: 'Copy files' }, +const TEST_ACTIONS = { COPY_FILES: { options: { displayName: 'Copy files' } } }; +jest.spyOn(TempActions, 'useTempActions').mockReturnValue(TEST_ACTIONS); + +jest.spyOn(ReactCoreModule, 'useDataState').mockReturnValue([ + { + data: { + items: [{ id: '1', key: 'Location A', type: 'FOLDER' }], + nextToken: undefined, + }, + message: '', + hasError: false, + isLoading: false, }, + jest.fn(), +]); + +jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials: jest.fn(), + region: 'us-west-2', +})); + +const taskOne = { + status: 'QUEUED' as const, + data: { + id: 'id', + key: 'itsa-prefix/test-item', + fileKey: 'test-item', + lastModified: new Date(), + size: 1000, + type: 'FILE' as const, + }, + cancel: jest.fn(), + progress: undefined, + remove: jest.fn(), + message: 'test-message', }; -jest.spyOn(TempActions, 'useTempActions').mockReturnValue(TEST_ACTIONS); -const useCopyViewSpy = jest.spyOn(UseCopyViewModule, 'useCopyView'); -const useDataSpy = jest.spyOn(AmplifyReactCore, 'useDataState'); +const onActionCancel = jest.fn(); +const onActionStart = jest.fn(); +const onExit = jest.fn(); +const onDestinationChange = jest.fn(); + +const callbacks = { + onActionCancel, + onActionStart, + onExit, + onDestinationChange, +}; +const statusCounts = { ...INITIAL_STATUS_COUNTS, QUEUED: 1, TOTAL: 1 }; + +const initialViewState: UseCopyViewModule.CopyViewState = { + ...callbacks, + destinationList: [], + isProcessingComplete: false, + isProcessing: false, + statusCounts, + tasks: [taskOne], +}; + +const preprocessingViewState: UseCopyViewModule.CopyViewState = { + ...initialViewState, + destinationList: ['some-prefix'], +}; + +const processingViewState: UseCopyViewModule.CopyViewState = { + ...initialViewState, + destinationList: ['some-prefix'], + isProcessing: true, + tasks: [{ ...taskOne, status: 'PENDING' }], + statusCounts: { ...statusCounts, PENDING: 1, QUEUED: 0 }, +}; + +const postProcessingViewState: UseCopyViewModule.CopyViewState = { + ...initialViewState, + destinationList: ['some-prefix'], + isProcessingComplete: true, + tasks: [{ ...taskOne, status: 'COMPLETE' }], + statusCounts: { ...statusCounts, COMPLETE: 1, QUEUED: 0 }, +}; + +const useCopyViewSpy = jest.spyOn(UseCopyViewModule, 'useCopyView'); describe('CopyFilesControls', () => { - const onExitMock = jest.fn(); - const onActionCancelMock = jest.fn(); - const onActionStartMock = jest.fn(); - const onSetDestinationList = jest.fn(); - - beforeAll(() => { - useDataSpy.mockReturnValue([ - { - data: { - items: [{ id: '1', key: 'Location A', type: 'FOLDER' }], - nextToken: undefined, - }, - message: '', - hasError: false, - isLoading: false, - }, - jest.fn(), - ]); + beforeEach(jest.clearAllMocks); + + it('renders search input as expected', () => { + useCopyViewSpy.mockReturnValue(initialViewState); + + const { getByPlaceholderText } = render(<CopyFilesControls />); + + expect(getByPlaceholderText('Search for folders')).toBeInTheDocument(); }); - beforeEach(() => { - jest.clearAllMocks(); - useCopyViewSpy.mockReturnValue({ - destinationList: [], - onSetDestinationList, - onExit: onExitMock, - onActionCancel: onActionCancelMock, - onActionStart: onActionStartMock, - taskCounts: { - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }, - tasks: [ - { - status: 'QUEUED', - data: { - id: 'id', - key: 'test-item', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - { - status: 'QUEUED', - data: { - id: 'id2', - key: 'test-item2', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - { - status: 'QUEUED', - data: { - id: 'id3', - key: 'test-item3', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - ], - disableCancel: false, - disableClose: false, - disablePrimary: false, - }); - - jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials: jest.fn(), - region: 'us-west-2', - })); + it('has the expected enabled and disabled flags when a destination has not been set', () => { + useCopyViewSpy.mockReturnValue(initialViewState); + + const { getByRole } = render(<CopyFilesControls />); + + expect(getByRole('button', { name: 'Exit' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Copy' })).toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); }); - it('renders all controls', () => { - const { getByRole, getByPlaceholderText } = render(<CopyFilesControls />); + it('has the expected enabled and disabled flags when a destination has been set', () => { + useCopyViewSpy.mockReturnValue(preprocessingViewState); - expect(getByPlaceholderText('Search for folders')).toBeInTheDocument(); - expect(getByRole('button', { name: 'Exit' })).toBeInTheDocument(); - expect(getByRole('button', { name: 'Start' })).toBeInTheDocument(); - expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + const { getByRole } = render(<CopyFilesControls />); + + expect(getByRole('button', { name: 'Exit' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Copy' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); }); - it('disables controls based on useDeleteView hook', () => { - useCopyViewSpy.mockReturnValue({ - destinationList: [], - onSetDestinationList, - onExit: jest.fn(), - onActionCancel: jest.fn(), - onActionStart: jest.fn(), - taskCounts: { - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }, - tasks: [], - disableCancel: true, - disableClose: true, - disablePrimary: true, - }); + it('has the expected enabled and disabled flags when copying files', () => { + useCopyViewSpy.mockReturnValue(processingViewState); const { getByRole } = render(<CopyFilesControls />); expect(getByRole('button', { name: 'Exit' })).toBeDisabled(); - expect(getByRole('button', { name: 'Start' })).toBeDisabled(); + expect(getByRole('button', { name: 'Copy' })).toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).not.toBeDisabled(); + }); + + it('has the expected enabled and disabled flags when copying files is complete', () => { + useCopyViewSpy.mockReturnValue(postProcessingViewState); + + const { getByRole } = render(<CopyFilesControls />); + + expect(getByRole('button', { name: 'Exit' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Copy' })).toBeDisabled(); expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); }); it('calls onExit when Exit button is clicked', async () => { + useCopyViewSpy.mockReturnValue(initialViewState); + const { getByRole } = render(<CopyFilesControls />); await userEvent.click(getByRole('button', { name: 'Exit' })); - expect(onExitMock).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); }); it('calls onActionStart when Start button is clicked', async () => { + useCopyViewSpy.mockReturnValue(preprocessingViewState); const { getByRole } = render(<CopyFilesControls />); - await userEvent.click(getByRole('button', { name: 'Start' })); + await userEvent.click(getByRole('button', { name: 'Copy' })); - expect(onActionStartMock).toHaveBeenCalledTimes(1); + expect(onActionStart).toHaveBeenCalledTimes(1); }); it('calls onActionCancel when Cancel button is clicked', async () => { + useCopyViewSpy.mockReturnValue(processingViewState); + const { getByRole } = render(<CopyFilesControls />); await userEvent.click(getByRole('button', { name: 'Cancel' })); - expect(onActionCancelMock).toHaveBeenCalledTimes(1); + expect(onActionCancel).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/DeleteFilesControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/DeleteFilesControls.spec.tsx index 130d9ab4750..b39d9662fdd 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/DeleteFilesControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/DeleteFilesControls.spec.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { render } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; + import * as UseDeleteViewModule from '../DeleteView/useDeleteView'; import * as Config from '../../../providers/configuration'; -import userEvent from '@testing-library/user-event'; import * as TempActions from '../../../do-not-import-from-here/createTempActionsProvider'; import { DeleteFilesControls } from '../DeleteFilesControls'; @@ -14,79 +15,82 @@ const TEST_ACTIONS = { }; jest.spyOn(TempActions, 'useTempActions').mockReturnValue(TEST_ACTIONS); -const useDeleteViewSpy = jest.spyOn(UseDeleteViewModule, 'useDeleteView'); + +const onExit = jest.fn(); +const onActionCancel = jest.fn(); +const onActionStart = jest.fn(); + +const useDeleteViewSpy = jest + .spyOn(UseDeleteViewModule, 'useDeleteView') + .mockReturnValue({ + isProcessing: false, + isProcessingComplete: false, + onExit, + onActionCancel, + onActionStart, + statusCounts: { + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 0, + QUEUED: 3, + TOTAL: 3, + }, + tasks: [ + { + status: 'QUEUED', + data: { + id: 'id', + key: 'some-prefix/test-item', + fileKey: 'test-item', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + cancel: jest.fn(), + progress: undefined, + remove: jest.fn(), + message: 'test-message', + }, + { + status: 'QUEUED', + data: { + id: 'id2', + key: 'some-prefix/test-item2', + fileKey: 'test-item2', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + cancel: jest.fn(), + progress: undefined, + remove: jest.fn(), + message: 'test-message', + }, + { + status: 'QUEUED', + data: { + id: 'id3', + key: 'some-prefix/test-item3', + fileKey: 'test-item3', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + cancel: jest.fn(), + progress: undefined, + remove: jest.fn(), + message: 'test-message', + }, + ], + }); describe('DeleteFilesControls', () => { - const onExitMock = jest.fn(); - const onActionCancelMock = jest.fn(); - const onActionStartMock = jest.fn(); + let user: UserEvent; beforeEach(() => { jest.clearAllMocks(); - useDeleteViewSpy.mockImplementation(() => { - return { - onExit: onExitMock, - onActionCancel: onActionCancelMock, - onActionStart: onActionStartMock, - taskCounts: { - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 3, - TOTAL: 3, - }, - tasks: [ - { - status: 'QUEUED', - data: { - id: 'id', - key: 'test-item', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - { - status: 'QUEUED', - data: { - id: 'id2', - key: 'test-item2', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - { - status: 'QUEUED', - data: { - id: 'id3', - key: 'test-item3', - lastModified: new Date(), - size: 1000, - type: 'FILE', - }, - cancel: jest.fn(), - progress: undefined, - remove: jest.fn(), - message: 'test-message', - }, - ], - disableCancel: false, - disableClose: false, - disablePrimary: false, - }; - }); jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ accountId: '123456789012', @@ -94,6 +98,8 @@ describe('DeleteFilesControls', () => { credentials: jest.fn(), region: 'us-west-2', })); + + user = userEvent.setup(); }); it('renders all controls', () => { @@ -104,55 +110,199 @@ describe('DeleteFilesControls', () => { expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); - it('disables controls based on useDeleteView hook', () => { - useDeleteViewSpy.mockReturnValue({ - onExit: jest.fn(), - onActionCancel: jest.fn(), - onActionStart: jest.fn(), - taskCounts: { + it('has the expected enabled and disabled button controls prior to processing', () => { + useDeleteViewSpy.mockReturnValueOnce({ + isProcessing: false, + isProcessingComplete: false, + onExit, + onActionCancel, + onActionStart, + statusCounts: { CANCELED: 0, COMPLETE: 0, FAILED: 0, - INITIAL: 0, OVERWRITE_PREVENTED: 0, PENDING: 0, - QUEUED: 3, - TOTAL: 3, + QUEUED: 1, + TOTAL: 1, }, - tasks: [], - disableCancel: true, - disableClose: true, - disablePrimary: true, + tasks: [ + { + cancel: jest.fn(), + data: { + id: 'test-id', + key: 'some-prefix/key', + fileKey: 'key', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'QUEUED', + }, + ], + }); + + const { getByRole } = render(<DeleteFilesControls />); + + expect(getByRole('button', { name: 'Exit' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Start' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('has the expected enabled and disabled button controls while processing', () => { + useDeleteViewSpy.mockReturnValueOnce({ + isProcessing: true, + isProcessingComplete: false, + onExit, + onActionCancel, + onActionStart, + statusCounts: { + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 1, + QUEUED: 0, + TOTAL: 1, + }, + tasks: [ + { + cancel: jest.fn(), + data: { + id: 'test-id', + key: 'some-prefix/key', + fileKey: 'key', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'PENDING', + }, + ], }); const { getByRole } = render(<DeleteFilesControls />); expect(getByRole('button', { name: 'Exit' })).toBeDisabled(); expect(getByRole('button', { name: 'Start' })).toBeDisabled(); + expect(getByRole('button', { name: 'Cancel' })).not.toBeDisabled(); + }); + + it('has the expected enabled and disabled button controls post processing', () => { + useDeleteViewSpy.mockReturnValueOnce({ + isProcessing: false, + isProcessingComplete: true, + onExit, + onActionCancel, + onActionStart, + statusCounts: { + CANCELED: 0, + COMPLETE: 1, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 0, + QUEUED: 0, + TOTAL: 1, + }, + tasks: [ + { + cancel: jest.fn(), + data: { + id: 'test-id', + key: 'some-prefix/key', + fileKey: 'key', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'COMPLETE', + }, + ], + }); + + const { getByRole } = render(<DeleteFilesControls />); + + expect(getByRole('button', { name: 'Exit' })).not.toBeDisabled(); + expect(getByRole('button', { name: 'Start' })).toBeDisabled(); expect(getByRole('button', { name: 'Cancel' })).toBeDisabled(); }); it('calls onExit when Exit button is clicked', async () => { const { getByRole } = render(<DeleteFilesControls />); - await userEvent.click(getByRole('button', { name: 'Exit' })); + const button = getByRole('button', { name: 'Exit' }); - expect(onExitMock).toHaveBeenCalledTimes(1); + expect(button).toBeInTheDocument(); + + await user.click(button); + + expect(onExit).toHaveBeenCalledTimes(1); }); it('calls onActionStart when Start button is clicked', async () => { const { getByRole } = render(<DeleteFilesControls />); - await userEvent.click(getByRole('button', { name: 'Start' })); + const button = getByRole('button', { name: 'Start' }); + + expect(button).toBeInTheDocument(); + + await user.click(button); - expect(onActionStartMock).toHaveBeenCalledTimes(1); + expect(onActionStart).toHaveBeenCalledTimes(1); }); it('calls onActionCancel when Cancel button is clicked', async () => { + useDeleteViewSpy.mockReturnValueOnce({ + isProcessing: true, + isProcessingComplete: false, + onExit, + onActionCancel, + onActionStart, + statusCounts: { + CANCELED: 0, + COMPLETE: 0, + FAILED: 0, + OVERWRITE_PREVENTED: 0, + PENDING: 1, + QUEUED: 0, + TOTAL: 1, + }, + tasks: [ + { + cancel: jest.fn(), + data: { + id: 'test-id', + key: 'some-prefix/key', + fileKey: 'key', + lastModified: new Date(), + size: 1000, + type: 'FILE', + }, + message: undefined, + progress: undefined, + remove: jest.fn(), + status: 'PENDING', + }, + ], + }); + const { getByRole } = render(<DeleteFilesControls />); - await userEvent.click(getByRole('button', { name: 'Cancel' })); + const button = getByRole('button', { name: 'Cancel' }); + + expect(button).toBeInTheDocument(); + + await user.click(button); - expect(onActionCancelMock).toHaveBeenCalledTimes(1); + expect(onActionCancel).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx index 4a5b3852044..11481421cb7 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx @@ -1,17 +1,88 @@ import React from 'react'; import { render } from '@testing-library/react'; -import userEvent, { UserEvent } from '@testing-library/user-event'; import * as ConfigModule from '../../../providers/configuration'; import * as StoreModule from '../../../providers/store'; -import * as TasksModule from '../../../tasks'; +import { INITIAL_STATUS_COUNTS } from '../../../tasks'; -import { UploadControls, ActionIcon, ICON_CLASS } from '../UploadControls'; +import * as UseUploadViewModule from '../UploadView'; +import { UploadControls } from '../UploadControls'; jest.mock('../Controls/Title'); +const mockControlsContextProvider = jest.fn( + (_: any) => 'ControlsContextProvider' +); +jest.mock('../../../controls/context', () => ({ + ControlsContextProvider: (ctx: any) => mockControlsContextProvider(ctx), + useControlsContext: () => ({ actionConfig: {}, data: {} }), +})); + const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); -const useProcessTasksSpy = jest.spyOn(TasksModule, 'useProcessTasks'); + +const onActionCancel = jest.fn(); +const onActionStart = jest.fn(); +const onExit = jest.fn(); +const onDropFiles = jest.fn(); +const onSelectFiles = jest.fn(); +const onToggleOverwrite = jest.fn(); + +const callbacks = { + onActionCancel, + onActionStart, + onExit, + onDropFiles, + onSelectFiles, + onToggleOverwrite, +}; + +const statusCounts = { ...INITIAL_STATUS_COUNTS }; + +const testFile = new File([], 'test-ooo'); +const data = { id: 'some-uuid', file: testFile, key: testFile.name }; + +const taskOne = { + data, + cancel: jest.fn(), + message: undefined, + remove: jest.fn(), + progress: 0, + status: 'QUEUED' as const, +}; + +const initialViewState: UseUploadViewModule.UploadViewState = { + ...callbacks, + destinationPrefix: 'my-folder/', + isOverwriteEnabled: false, + isProcessingComplete: false, + isProcessing: false, + tasks: [], + statusCounts, +}; + +const preprocessingViewState: UseUploadViewModule.UploadViewState = { + ...initialViewState, + tasks: [taskOne], + statusCounts: { ...statusCounts, QUEUED: 1, TOTAL: 1 }, +}; + +const processingViewState: UseUploadViewModule.UploadViewState = { + ...initialViewState, + isProcessing: true, + tasks: [{ ...taskOne, status: 'PENDING' }], + statusCounts: { ...statusCounts, PENDING: 1, TOTAL: 1 }, +}; + +const postProcessingViewState: UseUploadViewModule.UploadViewState = { + ...initialViewState, + isProcessingComplete: true, + tasks: [{ ...taskOne, status: 'COMPLETE' }], + statusCounts: { ...statusCounts, COMPLETE: 1, TOTAL: 1 }, +}; + +const useUploadViewSpy = jest + .spyOn(UseUploadViewModule, 'useUploadView') + .mockReturnValue(initialViewState); const location = { id: 'an-id-👍🏼', @@ -20,6 +91,7 @@ const location = { prefix: 'test-prefix/', type: 'PREFIX', }; + const dispatchStoreAction = jest.fn(); useStoreSpy.mockReturnValue([ { @@ -35,163 +107,82 @@ const config: ConfigModule.GetActionInput = jest.fn(() => ({ region: 'region', })); -const testFile = new File([], 'test-ooo'); -const fileItem = { id: 'some-uuid', file: testFile, key: testFile.name }; - jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(config); describe('UploadControls', () => { - let user: UserEvent; - beforeEach(() => { - user = userEvent.setup(); - }); - afterEach(jest.clearAllMocks); - it('should render upload controls table', () => { - const { getByRole } = render(<UploadControls />); - - const table = getByRole('table'); - expect(table).toBeInTheDocument(); - }); - - it('should render the destination folder', () => { - const { getByText } = render(<UploadControls />); - - const destination = getByText('Destination:'); - const destinationFolder = getByText('test-prefix/'); - - expect(destination).toBeInTheDocument(); - expect(destinationFolder).toBeInTheDocument(); - }); - - it('calls `useProcessTasks` with the expected values when provided a root `prefix`', async () => { - const rootLocation = { - id: 'an-id-👍🏼', - bucket: 'test-bucket', - permission: 'READWRITE', - // a root `prefix` is an empty string - prefix: '', - type: 'BUCKET', - }; - - useStoreSpy.mockReturnValue([ - { - location: { current: rootLocation, path: '', key: rootLocation.prefix }, - files: [fileItem], - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - - const handleProcessTasks = jest.fn(); - useProcessTasksSpy.mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: fileItem, - cancel: jest.fn(), - message: undefined, - remove: jest.fn(), - progress: 0, - status: 'QUEUED', - }, - ], - }, - handleProcessTasks, - ]); - - const { getByText, getAllByRole } = render(<UploadControls />); - - // render a '/' as the destination folder when prefix is an empty string - const definitonEls = getAllByRole('definition'); - expect(definitonEls[0]).toHaveTextContent('/'); - - const startButton = getByText('Start'); - expect(startButton).toBeInTheDocument(); - - await user.click(startButton); - - expect(handleProcessTasks).toHaveBeenCalledTimes(1); - expect(handleProcessTasks).toHaveBeenCalledWith({ - config: { - bucket: rootLocation.bucket, - credentials, - region: 'region', + it('provides the expected boolean flags to `ControlsContextProvider` prior to processing when tasks is empty', () => { + render(<UploadControls />); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionStartDisabled: true, + isActionCancelDisabled: true, + isAddFilesDisabled: false, + isAddFolderDisabled: false, + isExitDisabled: false, + isOverwriteCheckboxDisabled: false, }, - options: { preventOverwrite: true }, - destinationPrefix: rootLocation.prefix, }); }); - it('calls `useProcessTasks` with the expected values when provided a nested `prefix`', async () => { - useStoreSpy.mockReturnValue([ - { - location: { current: location, path: '', key: location.prefix }, - files: [fileItem], - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - - const handleProcessTasks = jest.fn(); - useProcessTasksSpy.mockReturnValue([ - { - isProcessing: false, - tasks: [ - { - data: fileItem, - cancel: jest.fn(), - message: undefined, - remove: jest.fn(), - progress: 0, - status: 'QUEUED', - }, - ], + it('provides the expected boolean flags to `ControlsContextProvider` prior to processing', () => { + useUploadViewSpy.mockReturnValue(preprocessingViewState); + + render(<UploadControls />); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionStartDisabled: false, + isActionCancelDisabled: true, + isAddFilesDisabled: false, + isAddFolderDisabled: false, + isExitDisabled: false, + isOverwriteCheckboxDisabled: false, }, - handleProcessTasks, - ]); - - const { getByText, getAllByRole } = render(<UploadControls />); - - const definitonEls = getAllByRole('definition'); - expect(definitonEls[0]).toHaveTextContent(location.prefix); - - const startButton = getByText('Start'); - expect(startButton).toBeInTheDocument(); - - await user.click(startButton); + }); + }); - expect(handleProcessTasks).toHaveBeenCalledTimes(1); - expect(handleProcessTasks).toHaveBeenCalledWith({ - config: { - bucket: location.bucket, - credentials, - region: 'region', + it('provides the expected boolean flags to `ControlsContextProvider` while processing', () => { + useUploadViewSpy.mockReturnValue(processingViewState); + + render(<UploadControls />); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionStartDisabled: true, + isActionCancelDisabled: false, + isAddFilesDisabled: true, + isAddFolderDisabled: true, + isExitDisabled: true, + isOverwriteCheckboxDisabled: true, }, - options: { preventOverwrite: true }, - destinationPrefix: location.prefix, }); }); -}); -describe('ActionIcon', () => { - it('should show all icon statuses', () => { - const { container } = render( - <> - <ActionIcon /> - <ActionIcon status="CANCELED" /> - <ActionIcon status="COMPLETE" /> - <ActionIcon status="QUEUED" /> - <ActionIcon status="FAILED" /> - <ActionIcon status="PENDING" /> - </> - ); - const svg = container.querySelectorAll('svg'); - expect(svg[0]?.classList).toContain(`${ICON_CLASS}--action-initial`); - expect(svg[1]?.classList).toContain(`${ICON_CLASS}--action-canceled`); - expect(svg[2]?.classList).toContain(`${ICON_CLASS}--action-success`); - expect(svg[3]?.classList).toContain(`${ICON_CLASS}--action-queued`); - expect(svg[4]?.classList).toContain(`${ICON_CLASS}--action-error`); - expect(svg[5]?.classList).toContain(`${ICON_CLASS}--action-progress`); + it('provides the expected boolean flags to `ControlsContextProvider` post processing', () => { + useUploadViewSpy.mockReturnValue(postProcessingViewState); + + render(<UploadControls />); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionStartDisabled: true, + isActionCancelDisabled: true, + isAddFilesDisabled: true, + isAddFolderDisabled: true, + isExitDisabled: false, + isOverwriteCheckboxDisabled: true, + }, + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/__snapshots__/utils.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/__snapshots__/utils.spec.ts.snap index 3d2191bbc3d..6d922ca43a2 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/__snapshots__/utils.spec.ts.snap @@ -43,10 +43,10 @@ exports[`getActionViewTableData should handle tasks with prefix keys 1`] = ` }, { "content": { - "ariaLabel": "Cancel item: folder/subfolder/file1.txt", + "ariaLabel": "Cancel item: file1.txt", "icon": "cancel", "isDisabled": false, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-1", "type": "button", @@ -95,10 +95,10 @@ exports[`getActionViewTableData should handle tasks with prefix keys 1`] = ` }, { "content": { - "ariaLabel": "Cancel item: /root/file2.jpg", + "ariaLabel": "Cancel item: file2.jpg", "icon": "cancel", "isDisabled": true, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-2", "type": "button", @@ -152,10 +152,10 @@ exports[`getActionViewTableData should have remove handler on queued files 1`] = }, { "content": { - "ariaLabel": "Cancel item: folder/subfolder/file1.txt", + "ariaLabel": "Remove item: file1.txt", "icon": "cancel", "isDisabled": false, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-1", "type": "button", @@ -212,7 +212,7 @@ exports[`getActionViewTableData should return correct table data for all task st "ariaLabel": "Cancel item: file1.txt", "icon": "cancel", "isDisabled": false, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-1", "type": "button", @@ -263,8 +263,8 @@ exports[`getActionViewTableData should return correct table data for all task st "content": { "ariaLabel": "Cancel item: file2.jpg", "icon": "cancel", - "isDisabled": true, - "onClick": [Function], + "isDisabled": false, + "onClick": [MockFunction], }, "key": "action-2", "type": "button", @@ -316,7 +316,7 @@ exports[`getActionViewTableData should return correct table data for all task st "ariaLabel": "Cancel item: file3.pdf", "icon": "cancel", "isDisabled": true, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-3", "type": "button", @@ -368,7 +368,7 @@ exports[`getActionViewTableData should return correct table data for all task st "ariaLabel": "Cancel item: file4.doc", "icon": "cancel", "isDisabled": true, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-4", "type": "button", @@ -420,7 +420,7 @@ exports[`getActionViewTableData should return correct table data for all task st "ariaLabel": "Cancel item: file5", "icon": "cancel", "isDisabled": true, - "onClick": [Function], + "onClick": [MockFunction], }, "key": "action-5", "type": "button", diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/utils.spec.ts index ecd8a91351f..ddb3fb0ac2e 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/utils.spec.ts @@ -1,11 +1,9 @@ -import { FileData } from '../../../actions/handlers'; +import { FileDataItem } from '../../../actions/handlers'; import { Tasks } from '../../../tasks'; import { getActionIconVariant, - getActionViewDisabledButtons, getFileTypeDisplayValue, - getFilenameWithoutPrefix, getActionViewTableData, } from '../utils'; @@ -19,65 +17,6 @@ describe('getActionIconVariant', () => { }); }); -describe('getActionViewDisabledButtons', () => { - it('should return correct statuses when no tasks have started', () => { - const counts = { - INITIAL: 0, - QUEUED: 5, - PENDING: 0, - FAILED: 0, - COMPLETE: 0, - OVERWRITE_PREVENTED: 0, - CANCELED: 0, - TOTAL: 5, - }; - const result = getActionViewDisabledButtons(counts); - expect(result).toEqual({ - disableCancel: true, - disableClose: false, - disablePrimary: false, - }); - }); - - it('should return correct statuses when some tasks have started', () => { - const counts = { - INITIAL: 0, - QUEUED: 3, - PENDING: 2, - FAILED: 0, - COMPLETE: 0, - CANCELED: 0, - OVERWRITE_PREVENTED: 0, - TOTAL: 5, - }; - const result = getActionViewDisabledButtons(counts); - expect(result).toEqual({ - disableCancel: false, - disableClose: true, - disablePrimary: true, - }); - }); - - it('should return correct statuses when all tasks have completed', () => { - const counts = { - INITIAL: 0, - QUEUED: 0, - PENDING: 0, - FAILED: 1, - COMPLETE: 3, - CANCELED: 1, - OVERWRITE_PREVENTED: 0, - TOTAL: 5, - }; - const result = getActionViewDisabledButtons(counts); - expect(result).toEqual({ - disableCancel: true, - disableClose: false, - disablePrimary: true, - }); - }); -}); - describe('getFileTypeDisplayValue', () => { it('should return the file extension', () => { expect(getFileTypeDisplayValue('document.pdf')).toBe('pdf'); @@ -90,34 +29,15 @@ describe('getFileTypeDisplayValue', () => { }); }); -describe('getFilenameWithoutPrefix', () => { - it('should return the filename without the path', () => { - expect(getFilenameWithoutPrefix('/path/to/file.txt')).toBe('file.txt'); - expect(getFilenameWithoutPrefix('document.pdf')).toBe('document.pdf'); - }); - - it('should handle paths with multiple slashes', () => { - expect(getFilenameWithoutPrefix('/path//to///file.txt')).toBe('file.txt'); - }); -}); - describe('getActionViewTableData', () => { const mockRemove = jest.fn(); - const taskCounts = { - INITIAL: 0, - QUEUED: 1, - PENDING: 1, - FAILED: 1, - COMPLETE: 1, - CANCELED: 1, - OVERWRITE_PREVENTED: 0, - TOTAL: 5, - }; - const tasks: Tasks<FileData> = [ + + const tasks: Tasks<FileDataItem> = [ { data: { id: '1', - key: 'file1.txt', + key: 'some-prefix/file1.txt', + fileKey: 'file1.txt', lastModified: new Date(), size: 1000, type: 'FILE', @@ -131,7 +51,8 @@ describe('getActionViewTableData', () => { { data: { id: '2', - key: 'file2.jpg', + key: 'some-prefix/file2.jpg', + fileKey: 'file2.jpg', lastModified: new Date(), size: 1000, type: 'FILE', @@ -145,7 +66,8 @@ describe('getActionViewTableData', () => { { data: { id: '3', - key: 'file3.pdf', + key: 'some-prefix/file3.pdf', + fileKey: 'file3.pdf', lastModified: new Date(), size: 1000, type: 'FILE', @@ -159,7 +81,8 @@ describe('getActionViewTableData', () => { { data: { id: '4', - key: 'file4.doc', + key: 'some-prefix/file4.doc', + fileKey: 'file4.doc', lastModified: new Date(), size: 1000, type: 'FILE', @@ -173,7 +96,8 @@ describe('getActionViewTableData', () => { { data: { id: '5', - key: 'file5', + key: 'some-prefix/file5', + fileKey: 'file5', lastModified: new Date(), size: 1000, type: 'FILE', @@ -189,29 +113,20 @@ describe('getActionViewTableData', () => { it('should return correct table data for all task statuses', () => { const result = getActionViewTableData({ tasks, - taskCounts, - path: '', + folder: '', + isProcessing: true, }); expect(result.rows).toMatchSnapshot('tabledata'); }); it('should handle tasks with prefix keys', () => { - const taskCounts = { - INITIAL: 0, - QUEUED: 1, - PENDING: 0, - FAILED: 0, - COMPLETE: 1, - CANCELED: 0, - OVERWRITE_PREVENTED: 0, - TOTAL: 2, - }; - const tasksWithPaths: Tasks<FileData> = [ + const tasks: Tasks<FileDataItem> = [ { data: { id: '1', key: 'folder/subfolder/file1.txt', + fileKey: 'file1.txt', lastModified: new Date(), size: 1000, type: 'FILE', @@ -226,6 +141,7 @@ describe('getActionViewTableData', () => { data: { id: '2', key: '/root/file2.jpg', + fileKey: 'file2.jpg', lastModified: new Date(), size: 1000, type: 'FILE', @@ -239,31 +155,22 @@ describe('getActionViewTableData', () => { ]; const result = getActionViewTableData({ - tasks: tasksWithPaths, - taskCounts, - path: '', + tasks, + folder: '', + isProcessing: true, }); expect(result.rows).toMatchSnapshot(); }); it('should have remove handler on queued files', () => { - const taskCounts = { - INITIAL: 0, - QUEUED: 1, - PENDING: 0, - FAILED: 0, - COMPLETE: 1, - CANCELED: 0, - OVERWRITE_PREVENTED: 0, - TOTAL: 2, - }; const mockRemove = jest.fn(); const mockCancel = jest.fn(); - const tasksWithPaths: Tasks<FileData> = [ + const tasks: Tasks<FileDataItem> = [ { data: { id: '1', + fileKey: 'file1.txt', key: 'folder/subfolder/file1.txt', lastModified: new Date(), size: 1000, @@ -278,17 +185,19 @@ describe('getActionViewTableData', () => { ]; const result = getActionViewTableData({ - tasks: tasksWithPaths, - taskCounts, - path: 'folder/subfolder/', + tasks, + folder: 'folder/subfolder/', + isProcessing: false, }); - const actionCell = result.rows[0].content.filter( - (cell) => cell.key === 'action-1' - )[0]; + + // last cell + const actionCell = + result.rows[0].content[result.rows[0].content.length - 1]; + expect(actionCell.content).toHaveProperty('onClick'); expect(actionCell.content).toHaveProperty( 'ariaLabel', - 'Cancel item: folder/subfolder/file1.txt' + 'Remove item: file1.txt' ); expect(result.rows).toMatchSnapshot(); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts index 241d07161dd..f8fb1c55301 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts @@ -1,15 +1,4 @@ -export const DEFAULT_OVERWRITE_PROTECTION = true; - -export const INITIAL_STATUS_COUNTS = { - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - PENDING: 0, - OVERWRITE_PREVENTED: 0, - QUEUED: 0, - TOTAL: 0, -}; +import { DataTableProps } from '../../composables/DataTable'; export const STATUS_DISPLAY_VALUES = { CANCELED: 'Canceled', @@ -20,3 +9,12 @@ export const STATUS_DISPLAY_VALUES = { PENDING: 'In Progress', QUEUED: 'Queued', }; + +export const DEFAULT_ACTION_VIEW_HEADERS: DataTableProps['headers'] = [ + { key: 'key', type: 'sort', content: { label: 'Name' } }, + { key: 'folder', type: 'text', content: { text: 'Folder' } }, + { key: 'type', type: 'text', content: { text: 'Type' } }, + { key: 'size', type: 'text', content: { text: 'Size' } }, + { key: 'status', type: 'sort', content: { label: 'Status' } }, + { key: 'action', type: 'text', content: { text: '' } }, +]; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts index 36685d7951d..5ea5afb2d15 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts @@ -1,22 +1,18 @@ -import { - LocationData, - TaskData, - TaskHandler, - TaskHandlerInput, -} from '../../actions'; +import { TaskData, TaskHandler, TaskHandlerInput } from '../../actions'; import { ComponentName, DefaultActionKey, TaskActionConfig, } from '../../actions/configs'; -import { TaskCounts } from '../../controls/types'; -import { Tasks } from '../../tasks'; +import { StatusCounts, Tasks } from '../../tasks'; export interface ActionViewState<T extends TaskData = TaskData> { - onExit: (location: LocationData) => void; + isProcessing: boolean; + isProcessingComplete: boolean; onActionStart: () => void; onActionCancel: () => void; - taskCounts: TaskCounts; + onExit: () => void; + statusCounts: StatusCounts; tasks: Tasks<T>; } diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useActionView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useActionView.ts index 3670a4fc565..a192dd4a901 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useActionView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useActionView.ts @@ -1,20 +1,14 @@ +import { INITIAL_STATUS_COUNTS } from '../../tasks'; import { ActionViewState } from './types'; export function useActionView(): ActionViewState { return { + isProcessing: false, + isProcessingComplete: false, onExit: () => null, onActionStart: () => null, onActionCancel: () => null, tasks: [], - taskCounts: { - CANCELED: 0, - COMPLETE: 0, - FAILED: 0, - INITIAL: 0, - OVERWRITE_PREVENTED: 0, - PENDING: 0, - QUEUED: 0, - TOTAL: 0, - }, + statusCounts: INITIAL_STATUS_COUNTS, }; } diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/utils.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/utils.ts index bfb02d5e008..ebbb6e63252 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/utils.ts @@ -1,23 +1,25 @@ -import { humanFileSize } from '@aws-amplify/ui'; +import { humanFileSize, isUndefined } from '@aws-amplify/ui'; -import { DataTableProps } from '../../composables/DataTable'; +import { + DataTableButtonDataCell, + DataTableProps, +} from '../../composables/DataTable'; import { DataTableRow } from '../../composables/DataTable/DataTable'; import { IconVariant } from '../../context/elements'; import { WithKey } from '../../components/types'; -import { TaskCounts } from '../../controls/types'; import { Task, TaskStatus } from '../../tasks'; -import { STATUS_DISPLAY_VALUES } from './constants'; -import { ActionData } from '../../actions/types'; +import { + DEFAULT_ACTION_VIEW_HEADERS, + STATUS_DISPLAY_VALUES, +} from './constants'; -const DELETE_ACTION_VIEW_HEADERS: DataTableProps['headers'] = [ - { key: 'key', type: 'sort', content: { label: 'Name' } }, - { key: 'folder', type: 'text', content: { text: 'Folder' } }, - { key: 'type', type: 'text', content: { text: 'Type' } }, - { key: 'size', type: 'text', content: { text: 'Size' } }, - { key: 'status', type: 'sort', content: { label: 'Status' } }, - { key: 'action', type: 'text', content: { text: '' } }, -]; +import { + FileDataItem, + FileItem, + isFileItem, + isFileDataItem, +} from '../../actions'; export const getActionIconVariant = (status: TaskStatus): IconVariant => { switch (status) { @@ -35,58 +37,30 @@ export const getActionIconVariant = (status: TaskStatus): IconVariant => { } }; -export const getTasksHaveStarted = (taskCounts: TaskCounts): boolean => - taskCounts.QUEUED < taskCounts.TOTAL; - -export const getActionViewDisabledButtons = ( - taskCounts: TaskCounts -): { - disableCancel: boolean; - disableClose: boolean; - disablePrimary: boolean; -} => { - const hasStarted = getTasksHaveStarted(taskCounts); - const hasCompleted = - !!taskCounts.TOTAL && - taskCounts.CANCELED + taskCounts.COMPLETE + taskCounts.FAILED === - taskCounts.TOTAL; - - const disableCancel = !hasStarted || taskCounts.QUEUED < 1; - const disableClose = hasStarted && !hasCompleted; - const disablePrimary = - taskCounts.QUEUED < 1 || taskCounts.QUEUED < taskCounts.TOTAL; - - return { - disableCancel, - disableClose, - disablePrimary, - }; -}; - export const getFileTypeDisplayValue = (fileName: string): string => fileName.lastIndexOf('.') !== -1 ? fileName.slice(fileName.lastIndexOf('.') + 1) : ''; -export const getFilenameWithoutPrefix = (path: string): string => { - const folder = path.lastIndexOf('/') + 1; - return path.slice(folder, path.length); -}; - -export const getActionViewTableData = <T extends ActionData>({ +export const getActionViewTableData = <T extends FileItem | FileDataItem>({ tasks, - taskCounts, - path, + folder, + isProcessing, }: { tasks: Task<T>[]; - taskCounts: TaskCounts; - path: string; + folder: string; + isProcessing: boolean; }): DataTableProps => { const rows: DataTableProps['rows'] = tasks.map((item) => { const row: WithKey<DataTableRow> = { key: item.data.id, - content: DELETE_ACTION_VIEW_HEADERS.map(({ key: columnKey }) => { + content: DEFAULT_ACTION_VIEW_HEADERS.map(({ key: columnKey }) => { const key = `${columnKey}-${item.data.id}`; + + const displayKey = isFileDataItem(item.data) + ? item.data.fileKey + : item.data.key; + switch (columnKey) { case 'key': { return { @@ -94,61 +68,55 @@ export const getActionViewTableData = <T extends ActionData>({ type: 'text', content: { icon: getActionIconVariant(item.status), - text: getFilenameWithoutPrefix(item.data.key), + text: displayKey, }, }; } case 'folder': { - return { key, type: 'text', content: { text: path } }; + return { key, type: 'text', content: { text: folder } }; } case 'type': { return { key, type: 'text', - content: { text: getFileTypeDisplayValue(item.data.key) }, + content: { text: getFileTypeDisplayValue(displayKey) }, }; } - case 'size': + case 'size': { + const value = isFileItem(item.data) + ? item.data.file.size + : item.data.size; return { key, type: 'number', - content: { - value: item.data.size, - displayValue: humanFileSize(item.data.size, true), - }, + content: { value, displayValue: humanFileSize(value, true) }, }; - case 'status': + } + case 'status': { return { key, type: 'text', content: { text: STATUS_DISPLAY_VALUES[item.status] }, }; - case 'action': - // don't allow removing a single task - if (taskCounts.TOTAL > 1) { - return getTasksHaveStarted(taskCounts) - ? { - key, - type: 'button', - content: { - icon: 'cancel', - ariaLabel: `Cancel item: ${item.data.key}`, - onClick: () => item.cancel?.(), - isDisabled: item.status !== 'QUEUED', - }, - } - : { - key, - type: 'button', - content: { - icon: 'cancel', - ariaLabel: `Remove item: ${item.data.key}`, - onClick: () => item.remove(), - }, - }; - } else { - return { key, type: 'text', content: { text: '' } }; - } + } + case 'action': { + const isDisabled = + (isProcessing && isUndefined(item.cancel)) || + (item.status !== 'PENDING' && item.status !== 'QUEUED'); + const onClick = isProcessing ? item.cancel : item.remove; + const ariaLabel = `${ + isProcessing ? 'Cancel' : 'Remove' + } item: ${displayKey}`; + + const buttonCell: WithKey<DataTableButtonDataCell> = { + key, + type: 'button', + content: { isDisabled, onClick, ariaLabel, icon: 'cancel' }, + }; + + return buttonCell; + } + default: return { key, type: 'text', content: { text: '' } }; } @@ -157,5 +125,5 @@ export const getActionViewTableData = <T extends ActionData>({ return row; }); - return { headers: DELETE_ACTION_VIEW_HEADERS, rows }; + return { headers: DEFAULT_ACTION_VIEW_HEADERS, rows }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx index 215937810a8..326a9f837bf 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx @@ -2,52 +2,66 @@ import { renderHook, act } from '@testing-library/react'; import * as AmplifyReactCore from '@aws-amplify/ui-react-core'; -import { - useLocationDetailView, - DEFAULT_LIST_OPTIONS, -} from '../useLocationDetailView'; import { ActionInputConfig, LocationData, LocationItemData, + FileData, + FileDataItem, + FolderData, } from '../../../actions'; -import { FileData } from '../../../actions/handlers'; + import * as StoreModule from '../../../providers/store'; import * as ConfigModule from '../../../providers/configuration'; import { LocationState } from '../../../providers/store/location'; -const useActionSpy = jest.spyOn(AmplifyReactCore, 'useDataState'); +import { + useLocationDetailView, + DEFAULT_LIST_OPTIONS, +} from '../useLocationDetailView'; + +const useDataStateSpy = jest.spyOn(AmplifyReactCore, 'useDataState'); const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); +const folderDataOne: FolderData = { + id: '1', + key: 'Location A', + type: 'FOLDER', +}; + +const fileDataOne: FileData = { + id: '2', + key: 'some-prefix/cool.jpg', + type: 'FILE', + lastModified: new Date(), + size: 25600, +}; + +const fileDataTwo: FileData = { + id: '3', + key: 'some-prefix/maybe-cool.png', + type: 'FILE', + lastModified: new Date(), + size: 25600, +}; + // fake date for mock data below jest.useFakeTimers({ now: Date.UTC(2024, 0, 1) }); const testData: LocationItemData[] = [ - { id: '1', key: 'Location A', type: 'FOLDER' }, - { - id: '2', - key: 'Location B', - type: 'FILE', - lastModified: new Date(), - size: 25600, - }, - { - id: '3', - key: 'Location C', - type: 'FILE', - lastModified: new Date(), - size: 12800, - }, + folderDataOne, + fileDataOne, + fileDataTwo, { id: '4', - key: 'Location D', + key: 'Location-D.doc', type: 'FILE', lastModified: new Date(), size: 12800, }, { id: '5', - key: 'Location E', + key: 'Location-E.pdf', type: 'FILE', lastModified: new Date(), size: 25600, @@ -55,7 +69,7 @@ const testData: LocationItemData[] = [ ]; const fileItem: FileData = { - key: 'file-key', + key: 'some-prefix/file-key', lastModified: new Date(1), id: 'file-id', size: 1, @@ -111,7 +125,7 @@ describe('useLocationDetailView', () => { }; const handleListMock = jest.fn(); - useActionSpy.mockReturnValue([mockDataState, handleListMock]); + useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); const initialState = { initialValues: { pageSize: EXPECTED_PAGE_SIZE } }; const { result } = renderHook(() => useLocationDetailView(initialState)); @@ -146,7 +160,7 @@ describe('useLocationDetailView', () => { isLoading: false, }; const handleListMock = jest.fn(); - useActionSpy.mockReturnValue([mockDataState, handleListMock]); + useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); renderHook(() => useLocationDetailView({ @@ -162,12 +176,9 @@ describe('useLocationDetailView', () => { const mockHandleList = jest.fn(); // set up empty page - useActionSpy.mockReturnValue([ + useDataStateSpy.mockReturnValue([ { - data: { - items: [], - nextToken: undefined, - }, + data: { items: [], nextToken: undefined }, message: '', hasError: false, isLoading: false, @@ -195,12 +206,12 @@ describe('useLocationDetailView', () => { isLoading: false, }; - useActionSpy.mockReturnValue([mockDataState, mockHandleList]); + useDataStateSpy.mockReturnValue([mockDataState, mockHandleList]); rerender(initialValues); // set up second page mock - useActionSpy.mockReturnValue([ + useDataStateSpy.mockReturnValue([ { data: { items: testData, nextToken: undefined }, message: '', @@ -243,7 +254,7 @@ describe('useLocationDetailView', () => { isLoading: false, }; const handleListMock = jest.fn(); - useActionSpy.mockReturnValue([mockDataState, handleListMock]); + useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); const { result } = renderHook(() => useLocationDetailView()); @@ -282,7 +293,7 @@ describe('useLocationDetailView', () => { }; const handleListMock = jest.fn(); - useActionSpy.mockReturnValue([mockDataState, handleListMock]); + useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); const { result } = renderHook(() => useLocationDetailView()); @@ -367,61 +378,74 @@ describe('useLocationDetailView', () => { }); it('should set all file items as selected', () => { - const mockDataState = { - data: { items: testData, nextToken: undefined }, - message: '', - hasError: false, - isLoading: false, - }; - useStoreSpy.mockReturnValue([ { ...testStoreState, - locationItems: { - fileDataItems: [fileItem, fileItem, fileItem, fileItem], - }, + locationItems: { fileDataItems: undefined }, }, mockDispatchStoreAction, ]); - useActionSpy.mockReturnValue([mockDataState, jest.fn()]); + + const mockDataState = { + data: { + items: [folderDataOne, fileDataOne, fileDataTwo], + nextToken: undefined, + }, + message: '', + hasError: false, + isLoading: false, + }; + + useDataStateSpy.mockReturnValue([mockDataState, jest.fn()]); const { result } = renderHook(() => useLocationDetailView()); - const state = result.current; + const { onSelectAll } = result.current; - state.onSelectAll(); + onSelectAll(); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'RESET_LOCATION_ITEMS', + type: 'SET_LOCATION_ITEMS', + items: [fileDataOne, fileDataTwo], }); }); it('should set all file items as unselected', () => { const mockDataState = { - data: { items: testData, nextToken: undefined }, + data: { + items: [folderDataOne, fileDataOne, fileDataTwo], + nextToken: undefined, + }, message: '', hasError: false, isLoading: false, }; + // selected file items + const fileDataItemOne: FileDataItem = { + ...fileDataOne, + fileKey: 'cool.jpg', + }; + const fileDataItemTwo: FileDataItem = { + ...fileDataTwo, + fileKey: 'maybe-cool.png', + }; + useStoreSpy.mockReturnValue([ { ...testStoreState, - locationItems: { - fileDataItems: [], - }, + locationItems: { fileDataItems: [fileDataItemOne, fileDataItemTwo] }, }, mockDispatchStoreAction, ]); - useActionSpy.mockReturnValue([mockDataState, jest.fn()]); + useDataStateSpy.mockReturnValue([mockDataState, jest.fn()]); const { result } = renderHook(() => useLocationDetailView()); - const state = result.current; + const { onSelectAll } = result.current; - state.onSelectAll(); + onSelectAll(); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ - type: 'SET_LOCATION_ITEMS', - items: testData.slice(1), + type: 'RESET_LOCATION_ITEMS', }); }); @@ -512,7 +536,7 @@ describe('useLocationDetailView', () => { isLoading: false, }; const handleListMock = jest.fn(); - useActionSpy.mockReturnValue([mockDataState, handleListMock]); + useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); const { result } = renderHook(() => useLocationDetailView()); act(() => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts index 57c4cddf719..805d1a56d00 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts @@ -6,11 +6,11 @@ import { useDataState } from '@aws-amplify/ui-react-core'; import { usePaginate } from '../hooks/usePaginate'; import { useStore } from '../../providers/store'; import { + FileData, LocationData, LocationItemData, listLocationItemsHandler, } from '../../actions'; -import { FileData } from '../../actions/handlers'; import { isFile, isLastPage } from '../utils'; import { createEnhancedListHandler } from '../../actions/createEnhancedListHandler'; import { useGetActionInput } from '../../providers/configuration'; From bf164988e47042fc0790ccecd052dc1b1c820710 Mon Sep 17 00:00:00 2001 From: Pranav Malewadkar <pranavml@amazon.com> Date: Thu, 7 Nov 2024 13:57:14 -0800 Subject: [PATCH 2/2] fix: pass correct key to search (#6027) --- .../__tests__/useLocationDetailView.spec.tsx | 8 ++++---- .../views/LocationDetailView/useLocationDetailView.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx index 326a9f837bf..09e0a20d963 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/useLocationDetailView.spec.tsx @@ -87,7 +87,7 @@ const testLocation: LocationState = { type: 'PREFIX', }, path: '', - key: 'item-b/', + key: 'item-b-key/', }; const testStoreState = { @@ -138,7 +138,7 @@ describe('useLocationDetailView', () => { refresh: true, pageSize: EXPECTED_PAGE_SIZE, }, - prefix: 'item-b/', + prefix: 'item-b-key/', }); const state = result.current; @@ -275,7 +275,7 @@ describe('useLocationDetailView', () => { expect(handleListMock).toHaveBeenCalledWith({ config, options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - prefix: 'item-b/', + prefix: 'item-b-key/', }); }); @@ -551,7 +551,7 @@ describe('useLocationDetailView', () => { delimiter: undefined, search: { filterKey: 'key', query: 'moo' }, }, - prefix: 'item-b/', + prefix: 'item-b-key/', }); expect(handleStoreActionMock).toHaveBeenCalledWith({ type: 'RESET_LOCATION_ITEMS', diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts index 805d1a56d00..23a56541af1 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts @@ -260,8 +260,9 @@ export function useLocationDetailView( delimiter: includeSubfolders ? undefined : listOptions.delimiter, search: { query, filterKey: 'key' as const }, }; + handleReset(); - handleList({ config: getConfig(), prefix, options: searchOptions }); + handleList({ config: getConfig(), prefix: key, options: searchOptions }); dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); }, };