diff --git a/.changeset/brave-queens-clean.md b/.changeset/brave-queens-clean.md new file mode 100644 index 00000000000..2f7183fbded --- /dev/null +++ b/.changeset/brave-queens-clean.md @@ -0,0 +1,5 @@ +--- +"@aws-amplify/ui-react-storage": minor +--- + +feat(storage-browser): Multi File Download diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Composed.tsx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Composed.tsx index d77d67440c8..5b5c5744c50 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Composed.tsx +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Composed.tsx @@ -5,6 +5,7 @@ import { StorageBrowser, useView } from './StorageBrowser'; import { ComposedCopyView } from './ComposedCopyView'; import { ComposedCreateFolderView } from './ComposedCreateFolderView'; import { ComposedDeleteView } from './ComposedDeleteView'; +import { ComposedDownloadView } from './ComposedDownloadView'; import { ComposedUploadView } from './ComposedUploadView'; function LocationsView() { @@ -52,6 +53,8 @@ function MyLocationActionView() { return ; case 'delete': return ; + case 'download': + return ; case 'upload': return ; default: diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/ComposedDownloadView.tsx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/ComposedDownloadView.tsx new file mode 100644 index 00000000000..d45da749030 --- /dev/null +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/ComposedDownloadView.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { StorageBrowser, useView } from './StorageBrowser'; + +export function ComposedDownloadView({ onExit }: { onExit: () => void }) { + const state = useView('Download'); + + return ( + + + + + + + ); +} diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Custom.tsx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Custom.tsx index 018f81b0704..e2cffb40ef7 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Custom.tsx +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/Custom.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import { StorageBrowser, useView } from './StorageBrowser'; -import { CustomDeleteView } from './CustomDeleteView'; import { CustomCopyView } from './CustomCopyView'; import { CustomCreateFolderView } from './CustomCreateFolderView'; -import { CustomUploadView } from './CustomUploadView'; +import { CustomDeleteView } from './CustomDeleteView'; +import { CustomDownloadView } from './CustomDownloadView'; import { CustomLocationsView } from './CustomLocationsView'; +import { CustomUploadView } from './CustomUploadView'; function MyLocationActionView() { const state = useView('LocationDetail'); @@ -19,6 +20,8 @@ function MyLocationActionView() { return ; case 'delete': return ; + case 'download': + return ; case 'upload': return ; default: diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDeleteView.tsx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDeleteView.tsx index a355377d0ae..d53239d5f27 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDeleteView.tsx +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDeleteView.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Button, Flex, Text } from '@aws-amplify/ui-react'; -import { StorageBrowser, useView } from './StorageBrowser'; +import { useView } from './StorageBrowser'; export function CustomDeleteView({ onExit }: { onExit: () => void }) { const state = useView('Delete'); diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDownloadView.tsx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDownloadView.tsx new file mode 100644 index 00000000000..f79f015dc2f --- /dev/null +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/CustomDownloadView.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { Button, Flex, Text, View } from '@aws-amplify/ui-react'; +import { useView } from './StorageBrowser'; + +export function CustomDownloadView({ onExit }: { onExit: () => void }) { + const state = useView('Download'); + + return ( + + + {state.tasks.map((task) => ( + + {task.data.key} + + ))} + + + ); +} diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/defaultActions.ts b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/defaultActions.ts index c99b313df37..fda7d3a7f9f 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/defaultActions.ts +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/examples/defaultActions.ts @@ -1,6 +1,7 @@ import { CreateStorageBrowserInput, DeleteHandlerOutput, + DownloadHandlerOutput, UploadHandlerOutput, } from '@aws-amplify/ui-react-storage/browser'; import uniqueId from 'lodash/uniqueId'; @@ -59,13 +60,27 @@ export const defaultActions: CreateStorageBrowserInput['actions']['default'] = { }, viewName: 'DeleteView', }, - download: () => { - return { - result: Promise.resolve({ - status: 'COMPLETE' as const, - value: { url: new URL('') }, - }), - }; + download: { + actionListItem: { + icon: 'download', + label: 'Download', + }, + handler: () => { + const result: DownloadHandlerOutput['result'] = new Promise((resolve) => { + setTimeout(() => { + resolve({ + status: 'COMPLETE' as const, + // creating URL with empty string will throw TypeError, + // preventing promise from resolving + value: { url: null }, + }); + }, 500); + }); + return { + result, + }; + }, + viewName: 'DownloadView', }, upload: { actionListItem: { diff --git a/docs/src/pages/[platform]/connected-components/storage/storage-browser/react.mdx b/docs/src/pages/[platform]/connected-components/storage/storage-browser/react.mdx index 84c1de64cf7..2abb88e0129 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storage-browser/react.mdx +++ b/docs/src/pages/[platform]/connected-components/storage/storage-browser/react.mdx @@ -362,6 +362,7 @@ Below you wll find examples of how to compose each view. See [View reference](#v Locations Location Detail Upload + Download Copy CreateFolder Delete @@ -396,6 +397,12 @@ Below you wll find examples of how to compose each view. See [View reference](#v ``` + + + ```jsx file=./examples/ComposedDownloadView.tsx + ``` + + ```jsx file=./examples/ComposedCopyView.tsx @@ -415,7 +422,7 @@ Below you wll find examples of how to compose each view. See [View reference](#v The `createStorageBrowser` function returns a `useView()` hook that lets you use the Storage Browser's internal state and event handlers to build your own UI on top the Storage Browser functionality. -The `useView()` hook takes a single argument which is the name of the view you want to get the UI state and event handlers for. The available views are `Locations`, `LocationDetails`, `Copy`, `Upload`, `Delete`, and `CreateFolder`. The return value of the hook will have all the necessary internal state and event handlers required to build that view. In fact, the Storage Browser component itself uses the `useView()` to manage its state, so you can build the UI exactly how we do! +The `useView()` hook takes a single argument which is the name of the view you want to get the UI state and event handlers for. The available views are `Locations`, `LocationDetails`, `Copy`, `Upload`, `Download`, `Delete`, and `CreateFolder`. The return value of the hook will have all the necessary internal state and event handlers required to build that view. In fact, the Storage Browser component itself uses the `useView()` to manage its state, so you can build the UI exactly how we do! @@ -424,6 +431,7 @@ The `useView()` hook takes a single argument which is the name of the view you w StorageBrowser Locations Upload + Download Copy CreateFolder Delete @@ -457,6 +465,12 @@ The `useView()` hook takes a single argument which is the name of the view you w ```jsx file=./examples/CustomUploadView.tsx ``` + + + + ```jsx file=./examples/CustomDownloadView.tsx + ``` + @@ -479,7 +493,7 @@ The following `default` actions are invoked by the default UI views: * `copy`: Copy data to another location within a `bucket` or `prefix` in [Copy action view](#copy-view). * `createFolder`: Create a folder within a `bucket` or `prefix` in [CreateFolder action view](#createfolder-view). * `delete`: Delete a given object in [Delete action view](#delete-view). -* `download`: Download a given object in [LocationDetails view](#locationdetails-view) using an [anchor link](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download). +* `download`: Download a given object in [LocationDetails view](#locationdetails-view) using an [anchor link](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download) or in [Download action view](#download-view). * `upload`: Upload a file in [Upload action view](#upload-view). `defaultActionConfigs` allows access to the default implementation of these actions. Below is an example to instrument the default upload action to count the files being uploaded. @@ -824,6 +838,36 @@ interface UploadViewState { * `` +### Download view + +#### Download view state + +```ts +interface DownloadViewState { + isProcessing: boolean; + isProcessingComplete: boolean; + location: LocationState; + onActionCancel: () => void; + onActionExit: () => void; + onActionStart: () => void; + onTaskRemove?: (task: Task) => void; + statusCounts: StatusCounts; + tasks: Task[]; +} +``` + +#### Download view components + +* `` +* `` +* `` +* `` +* `` +* `` +* `` +* `` + + ### Copy view #### Copy view state diff --git a/packages/e2e/features/ui/components/storage/storage-browser/action-menu.feature b/packages/e2e/features/ui/components/storage/storage-browser/action-menu.feature index e2e4f2dcd32..a1c1a89ab9e 100644 --- a/packages/e2e/features/ui/components/storage/storage-browser/action-menu.feature +++ b/packages/e2e/features/ui/components/storage/storage-browser/action-menu.feature @@ -74,6 +74,13 @@ Feature: Create folder with Storage Browser When I click the "Exit" button # list uploaded file Then I see "1" files with random names + # download file + Then I click checkbox for with "1" files with random names + When I click the "Menu Toggle" button + Then I click the "Download" menuitem + Then I click the "Download" button + Then I see "All files downloaded" + When I click the "Exit" button # copy file Then I click checkbox for with "1" files with random names When I click the "Menu Toggle" button @@ -174,3 +181,12 @@ Feature: Create folder with Storage Browser When A network failure occurs Then I click the "Delete" button Then I see "All files failed to delete" + @react + Scenario: Download file shows a Network error if offline + When I click the first button containing "public" + Then I click checkbox for file "001_dont_delete_file.txt" + When I click the "Menu Toggle" button + Then I click the "Download" menuitem + When A network failure occurs + Then I click the "Download" button + Then I see "All files failed to download" \ No newline at end of file diff --git a/packages/react-storage/package.json b/packages/react-storage/package.json index 785bac90960..ed12f6cb998 100644 --- a/packages/react-storage/package.json +++ b/packages/react-storage/package.json @@ -66,7 +66,7 @@ "name": "createStorageBrowser", "path": "dist/esm/browser.mjs", "import": "{ createStorageBrowser }", - "limit": "64.5 kB", + "limit": "64.6 kB", "ignore": [ "@aws-amplify/storage" ] @@ -75,7 +75,7 @@ "name": "StorageBrowser", "path": "dist/esm/index.mjs", "import": "{ StorageBrowser }", - "limit": "87 kB" + "limit": "88 kB" }, { "name": "FileUploader", diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap index 8cb6a31c908..61d1469a754 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap @@ -31,7 +31,16 @@ exports[`defaultActionConfigs matches expected shape 1`] = ` "handler": [Function], "viewName": "DeleteView", }, - "download": [Function], + "download": { + "actionListItem": { + "disable": [Function], + "hide": [Function], + "icon": "download", + "label": "Download", + }, + "handler": [Function], + "viewName": "DownloadView", + }, "listLocationItems": [Function], "upload": { "actionListItem": { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts index 6feaeab7d8f..21f9adb6b12 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts @@ -79,6 +79,28 @@ describe('defaultActionConfigs', () => { }); }); + describe('download', () => { + const { disable, hide } = defaultActionConfigs.download.actionListItem; + it('hides the action list item as expected', () => { + for (const permissionsWithoutDownload of generateCombinations( + LOCATION_PERMISSION_VALUES.filter((value) => value !== 'get') + )) { + const permissionsWithDownload = [ + ...permissionsWithoutDownload, + 'get' as const, + ]; + expect(hide?.(permissionsWithoutDownload)).toBe(true); + expect(hide?.(permissionsWithDownload)).toBe(false); + } + }); + + it('is disabled when no files are selected', () => { + expect(disable?.(undefined)).toBe(true); + expect(disable?.([])).toBe(true); + expect(disable?.([file])).toBe(false); + }); + }); + describe('copy', () => { const { disable, hide } = defaultActionConfigs.copy.actionListItem; it('hides the action list item as expected', () => { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx index 9ab60e82d57..f223ad76d31 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx @@ -4,6 +4,7 @@ import type { CopyActionConfig, CreateFolderActionConfig, DeleteActionConfig, + DownloadActionConfig, UploadActionConfig, } from './types'; @@ -49,12 +50,22 @@ export const uploadActionConfig: UploadActionConfig = { handler: defaultHandlers.upload, }; +export const downloadActionConfig: DownloadActionConfig = { + viewName: 'DownloadView', + actionListItem: { + disable: (selected) => !selected || selected.length === 0, + hide: (permissions) => !permissions.includes('get'), + icon: 'download', + label: 'Download', + }, + handler: defaultHandlers.download, +}; + // Action view configs only, does not include `listLocationItems` export const defaultActionViewConfigs = { copy: copyActionConfig, createFolder: createFolderActionConfig, - // provide `download` handler only; `download` does not have a dedicated view/config - download: defaultHandlers.download, + download: downloadActionConfig, delete: deleteActionConfig, upload: uploadActionConfig, }; 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 ad9360feaaf..94140aa169e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts @@ -128,6 +128,9 @@ export interface CopyActionConfig export interface CreateFolderActionConfig extends ActionViewConfig {} +export interface DownloadActionConfig + extends ActionViewConfig {} + export interface ListActionConfig { /** * action handler @@ -140,7 +143,7 @@ export interface DefaultActionConfigs { listLocationItems?: ListLocationItemsHandler; upload?: UploadActionConfig; delete?: DeleteActionConfig; - download?: DownloadHandler; + download?: DownloadHandler | DownloadActionConfig; copy?: CopyActionConfig; } 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 11cf158f255..2ee25810b47 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts @@ -1,5 +1,6 @@ import { getUrl } from '../../storage-internal'; import type { + OptionalFileData, TaskData, TaskHandler, TaskHandlerInput, @@ -9,7 +10,7 @@ import type { import { constructBucket } from './utils'; -export interface DownloadHandlerData extends TaskData { +export interface DownloadHandlerData extends OptionalFileData, TaskData { fileKey: string; } diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/StorageBrowserDefault.spec.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/StorageBrowserDefault.spec.tsx index 44786a2c668..26c3ebc1b92 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/StorageBrowserDefault.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/StorageBrowserDefault.spec.tsx @@ -16,6 +16,7 @@ jest.spyOn(ViewsModule, 'useViews').mockReturnValue({ action: { copy: () =>
, createFolder: () =>
, + download: () =>
, delete: () =>
, upload: () =>
, }, diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.tsx index e8576e5de7e..64871c5df52 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.tsx +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.tsx @@ -12,6 +12,7 @@ import { CopyView, CreateFolderView, DeleteView, + DownloadView, LocationActionView, LocationDetailView, LocationsView, @@ -81,6 +82,7 @@ export default function createStorageBrowser< StorageBrowser.CopyView = CopyView; StorageBrowser.CreateFolderView = CreateFolderView; StorageBrowser.DeleteView = DeleteView; + StorageBrowser.DownloadView = DownloadView; StorageBrowser.UploadView = UploadView; StorageBrowser.Provider = Provider; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts index 65768f67e90..1d9d4a5873f 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts @@ -24,6 +24,7 @@ import type { CopyViewType, CreateFolderViewType, DeleteViewType, + DownloadViewType, UploadViewType, LocationActionViewType, LocationDetailViewType, @@ -292,6 +293,7 @@ export interface StorageBrowserType { CopyView: CopyViewType; CreateFolderView: CreateFolderViewType; DeleteView: DeleteViewType; + DownloadView: DownloadViewType; UploadView: UploadViewType; } diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/context.spec.tsx b/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/context.spec.tsx index ed62b9b6803..34172c95d62 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/context.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/context.spec.tsx @@ -51,6 +51,9 @@ describe('resolveDisplayText', () => { DeleteView: { title: 'DeleteViewTitle', }, + DownloadView: { + title: 'DownloadViewTitle', + }, LocationDetailView: { getTitle: () => 'LocationDetailViewTitle', }, @@ -65,6 +68,7 @@ describe('resolveDisplayText', () => { expect(result.CopyView.title).toBe('CopyViewTitle'); expect(result.CreateFolderView.title).toBe('CreateFolderViewTitle'); expect(result.DeleteView.title).toBe('DeleteViewTitle'); + expect(result.DownloadView.title).toBe('DownloadViewTitle'); expect(result.LocationDetailView.getTitle({} as LocationState)).toBe( 'LocationDetailViewTitle' ); diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/utils.spec.ts index 9563c517333..90967d73ceb 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/__tests__/utils.spec.ts @@ -1,4 +1,8 @@ -import { isCopyViewDisplayTextKey, isDeleteViewDisplayTextKey } from '../utils'; +import { + isCopyViewDisplayTextKey, + isDeleteViewDisplayTextKey, + isDownloadViewDisplayTextKey, +} from '../utils'; describe('display text utils', () => { describe('isCopyViewDisplayTextKey', () => { @@ -22,4 +26,15 @@ describe('display text utils', () => { expect(output).toBe(false); }); }); + + describe('isDownloadViewDisplayTextKey', () => { + it('returns `true` when provided a valid key', () => { + const output = isDownloadViewDisplayTextKey('statusDisplayCanceledLabel'); + expect(output).toBe(true); + }); + it('returns `false` when provided an invalid key', () => { + const output = isDownloadViewDisplayTextKey('invalid'); + expect(output).toBe(false); + }); + }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/context.tsx b/packages/react-storage/src/components/StorageBrowser/displayText/context.tsx index 62a5e28e198..9f8fdb9468d 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/displayText/context.tsx @@ -24,6 +24,7 @@ export function resolveDisplayText( CopyView, CreateFolderView, DeleteView, + DownloadView, LocationDetailView, LocationsView, UploadView, @@ -38,6 +39,10 @@ export function resolveDisplayText( ...DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT.DeleteView, ...DeleteView, }, + DownloadView: { + ...DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT.DownloadView, + ...DownloadView, + }, LocationDetailView: { ...DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT.LocationDetailView, ...LocationDetailView, diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/index.ts b/packages/react-storage/src/components/StorageBrowser/displayText/index.ts index 461c6b19a15..bf2d35317cc 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/index.ts @@ -7,6 +7,11 @@ export type { CopyViewDisplayText, UploadViewDisplayText, DeleteViewDisplayText, + DownloadViewDisplayText, StorageBrowserDisplayText, } from './types'; -export { isCopyViewDisplayTextKey, isDeleteViewDisplayTextKey } from './utils'; +export { + isCopyViewDisplayTextKey, + isDeleteViewDisplayTextKey, + isDownloadViewDisplayTextKey, +} from './utils'; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/deleteView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/deleteView.spec.ts.snap index f2a42149a41..1bf6af76d61 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/deleteView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/deleteView.spec.ts.snap @@ -1,55 +1,55 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all failed scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the all failed scenario 1`] = ` { "content": "All files failed to delete.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all prevented overwrites scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the all prevented overwrites scenario 1`] = ` { "content": "0 files deleted, 0 files failed to delete.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all success scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the all success scenario 1`] = ` { "content": "All files deleted.", "type": "success", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the no failures, some prevented overwrites, some success scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the no failures, some prevented overwrites, some success scenario 1`] = ` { "content": "2 files deleted, 0 files failed to delete.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failed scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the some failed scenario 1`] = ` { "content": "8 files deleted, 3 files failed to delete.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, no success scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, no success scenario 1`] = ` { "content": "0 files deleted, 2 files failed to delete.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, some success scenario 1`] = ` +exports[`DeleteView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, some success scenario 1`] = ` { "content": "8 files deleted, 2 files failed to delete.", "type": "error", } `; -exports[`CopyView display text values should match snapshot values 1`] = ` +exports[`DeleteView display text values should match snapshot values 1`] = ` { "actionCancelLabel": "Cancel", "actionDestinationLabel": "Destination", diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/downloadView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/downloadView.spec.ts.snap new file mode 100644 index 00000000000..1418b26f479 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/downloadView.spec.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the all failed scenario 1`] = ` +{ + "content": "All files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the all prevented overwrites scenario 1`] = ` +{ + "content": "0 files downloaded, 0 files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the all success scenario 1`] = ` +{ + "content": "All files downloaded.", + "type": "success", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the no failures, some prevented overwrites, some success scenario 1`] = ` +{ + "content": "2 files downloaded, 0 files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the some failed scenario 1`] = ` +{ + "content": "8 files downloaded, 3 files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, no success scenario 1`] = ` +{ + "content": "0 files downloaded, 2 files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, some success scenario 1`] = ` +{ + "content": "8 files downloaded, 2 files failed to download.", + "type": "error", +} +`; + +exports[`DownloadView display text values should match snapshot values 1`] = ` +{ + "actionCancelLabel": "Cancel", + "actionDestinationLabel": "Destination", + "actionExitLabel": "Exit", + "actionStartLabel": "Download", + "getActionCompleteMessage": [Function], + "statusDisplayCanceledLabel": "Canceled", + "statusDisplayCompletedLabel": "Completed", + "statusDisplayFailedLabel": "Failed", + "statusDisplayInProgressLabel": "In progress", + "statusDisplayQueuedLabel": "Not started", + "statusDisplayTotalLabel": "Total", + "tableColumnCancelHeader": "", + "tableColumnFolderHeader": "Folder", + "tableColumnNameHeader": "Name", + "tableColumnSizeHeader": "Size", + "tableColumnStatusHeader": "Status", + "tableColumnTypeHeader": "Type", + "title": "Download", +} +`; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/deleteView.spec.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/deleteView.spec.ts index 1f3c4488fc2..0a6e8b94fd6 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/deleteView.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/deleteView.spec.ts @@ -1,7 +1,7 @@ import { ACTION_SCENARIOS } from './scenarios'; import { DEFAULT_DELETE_VIEW_DISPLAY_TEXT } from '../deleteView'; -describe('CopyView display text values', () => { +describe('DeleteView display text values', () => { it('should match snapshot values', () => { expect(DEFAULT_DELETE_VIEW_DISPLAY_TEXT).toMatchSnapshot(); }); diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/downloadView.spec.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/downloadView.spec.ts new file mode 100644 index 00000000000..9d0f984665a --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/downloadView.spec.ts @@ -0,0 +1,17 @@ +import { ACTION_SCENARIOS } from './scenarios'; +import { DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT } from '../downloadView'; + +describe('DownloadView display text values', () => { + it('should match snapshot values', () => { + expect(DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT).toMatchSnapshot(); + }); + + it.each(ACTION_SCENARIOS)( + '`getActionCompleteMessage` returns the expected values in the %s scenario', + (_, counts) => { + const { getActionCompleteMessage } = DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT; + + expect(getActionCompleteMessage({ counts })).toMatchSnapshot(); + } + ); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/default.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/default.ts index 0b451385ef0..01d6ff1a017 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/default.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/default.ts @@ -6,12 +6,14 @@ import { DEFAULT_DELETE_VIEW_DISPLAY_TEXT } from './deleteView'; import { DEFAULT_LOCATION_DETAIL_VIEW_DISPLAY_TEXT } from './locationDetailView'; import { DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT } from './locationsView'; import { DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT } from './uploadView'; +import { DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT } from './downloadView'; export const DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT: DefaultStorageBrowserDisplayText = { CopyView: DEFAULT_COPY_VIEW_DISPLAY_TEXT, CreateFolderView: DEFAULT_CREATE_FOLDER_VIEW_DISPLAY_TEXT, DeleteView: DEFAULT_DELETE_VIEW_DISPLAY_TEXT, + DownloadView: DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT, LocationDetailView: DEFAULT_LOCATION_DETAIL_VIEW_DISPLAY_TEXT, LocationsView: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, UploadView: DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT, diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/downloadView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/downloadView.ts new file mode 100644 index 00000000000..684f783f886 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/downloadView.ts @@ -0,0 +1,26 @@ +import { DEFAULT_ACTION_VIEW_DISPLAY_TEXT } from './shared'; +import type { DefaultDownloadViewDisplayText } from '../../types'; + +export const DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT: DefaultDownloadViewDisplayText = + { + ...DEFAULT_ACTION_VIEW_DISPLAY_TEXT, + title: 'Download', + actionStartLabel: 'Download', + getActionCompleteMessage: (data) => { + const { counts } = data ?? {}; + const { COMPLETE, FAILED, TOTAL } = counts ?? {}; + + if (COMPLETE === TOTAL) { + return { content: 'All files downloaded.', type: 'success' }; + } + + if (FAILED === TOTAL) { + return { content: 'All files failed to download.', type: 'error' }; + } + + return { + content: `${COMPLETE} files downloaded, ${FAILED} files failed to download.`, + type: 'error', + }; + }, + }; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts index f3d5a8a2080..9d3685e2b11 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts @@ -69,6 +69,8 @@ export const DEFAULT_LOCATION_DETAIL_VIEW_DISPLAY_TEXT: DefaultLocationDetailVie return 'Create folder'; case 'Upload': return 'Upload'; + case 'Download': + return 'Download'; default: return key; } diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts index c9669d32513..5924c4be884 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts @@ -9,6 +9,7 @@ import type { TaskData, UploadHandlerData, LocationPermissions, + DownloadHandlerData, } from '../actions'; import type { MessageType } from '../components'; import type { FileItems } from '../fileItems'; @@ -141,6 +142,9 @@ export interface DefaultDeleteViewDisplayText tableColumnProgressHeader?: string; } +export interface DefaultDownloadViewDisplayText + extends DefaultActionViewDisplayText {} + export interface DefaultUploadViewDisplayText extends DefaultActionViewDisplayText { addFilesLabel: string; @@ -157,6 +161,7 @@ export interface DefaultStorageBrowserDisplayText { CopyView: DefaultCopyViewDisplayText; CreateFolderView: DefaultCreateFolderViewDisplayText; DeleteView: DefaultDeleteViewDisplayText; + DownloadView: DefaultDownloadViewDisplayText; LocationsView: DefaultLocationsViewDisplayText; LocationDetailView: DefaultLocationDetailViewDisplayText; UploadView: DefaultUploadViewDisplayText; @@ -171,6 +176,9 @@ export interface CopyViewDisplayText export interface DeleteViewDisplayText extends Partial {} +export interface DownloadViewDisplayText + extends Partial {} + export interface LocationsViewDisplayText extends Partial {} @@ -188,6 +196,7 @@ export interface StorageBrowserDisplayText { LocationDetailView?: LocationDetailViewDisplayText; UploadView?: UploadViewDisplayText; DeleteView?: DeleteViewDisplayText; + DownloadView?: DownloadViewDisplayText; CopyView?: CopyViewDisplayText; CreateFolderView?: CreateFolderViewDisplayText; } diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/utils.ts b/packages/react-storage/src/components/StorageBrowser/displayText/utils.ts index ab932ab066a..069b53ced73 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/utils.ts @@ -1,6 +1,12 @@ import { DEFAULT_COPY_VIEW_DISPLAY_TEXT } from './libraries/en/copyView'; import { DEFAULT_DELETE_VIEW_DISPLAY_TEXT } from './libraries/en/deleteView'; -import type { CopyViewDisplayText, DeleteViewDisplayText } from './types'; +import { DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT } from './libraries/en/downloadView'; + +import type { + CopyViewDisplayText, + DeleteViewDisplayText, + DownloadViewDisplayText, +} from './types'; export const isCopyViewDisplayTextKey = ( value: string @@ -11,3 +17,8 @@ export const isDeleteViewDisplayTextKey = ( value: string ): value is keyof DeleteViewDisplayText => !!DEFAULT_DELETE_VIEW_DISPLAY_TEXT[value as keyof DeleteViewDisplayText]; + +export const isDownloadViewDisplayTextKey = ( + value: string +): value is keyof DownloadViewDisplayText => + !!DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT[value as keyof DownloadViewDisplayText]; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts b/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts index 60d27756519..a7aec1dc33a 100644 --- a/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts @@ -24,7 +24,7 @@ export const getActionHandlers = < copy: copyConfig, createFolder: createFolderConfig, delete: deleteConfig, - download, + download: downloadConfig, upload: uploadConfig, listLocationItems, listLocations, @@ -34,7 +34,8 @@ export const getActionHandlers = < copy: copyConfig.handler, createFolder: createFolderConfig.handler, delete: deleteConfig.handler, - download, + download: + 'handler' in downloadConfig ? downloadConfig.handler : downloadConfig, listLocationItems, listLocations, upload: uploadConfig.handler, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx index 2d329be769a..ee8fbf8ac09 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx @@ -30,7 +30,6 @@ export function DeleteViewProvider({ isProcessingComplete, statusCounts, tasks: items, - onActionCancel, onActionStart, onActionExit, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadView.tsx new file mode 100644 index 00000000000..339f5e0d054 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadView.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { ViewElement } from '../../../components/elements'; +import { ActionCancelControl } from '../../../controls/ActionCancelControl'; +import { ActionExitControl } from '../../../controls/ActionExitControl'; +import { ActionStartControl } from '../../../controls/ActionStartControl'; +import { DataTableControl } from '../../../controls/DataTableControl'; +import { MessageControl } from '../../../controls/MessageControl'; +import { StatusDisplayControl } from '../../../controls/StatusDisplayControl'; +import { TitleControl } from '../../../controls/TitleControl'; + +import { STORAGE_BROWSER_BLOCK } from '../../../components'; + +import { DownloadViewProvider } from './DownloadViewProvider'; +import { useDownloadView } from './useDownloadView'; +import type { DownloadViewType } from './types'; +import { classNames } from '@aws-amplify/ui'; + +export const DownloadView: DownloadViewType = ({ className, ...props }) => { + const state = useDownloadView(props); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +DownloadView.displayName = 'DownloadView'; + +DownloadView.Provider = DownloadViewProvider; + +DownloadView.Cancel = ActionCancelControl; +DownloadView.Exit = ActionExitControl; +DownloadView.Message = MessageControl; +DownloadView.Start = ActionStartControl; +DownloadView.Statuses = StatusDisplayControl; +DownloadView.TasksTable = DataTableControl; +DownloadView.Title = TitleControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.tsx new file mode 100644 index 00000000000..8fd0cc9eee9 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../../controls/context'; +import { useDisplayText } from '../../../displayText'; + +import { useResolveTableData } from '../../hooks/useResolveTableData'; +import { + FILE_DATA_ITEM_TABLE_KEYS, + DOWNLOAD_TABLE_RESOLVERS, +} from '../../utils'; + +import type { DownloadViewProviderProps } from './types'; + +export function DownloadViewProvider({ + children, + ...props +}: DownloadViewProviderProps): React.JSX.Element { + const { DownloadView: displayText } = useDisplayText(); + + const { + actionCancelLabel, + actionExitLabel, + actionStartLabel, + title, + statusDisplayCanceledLabel, + statusDisplayCompletedLabel, + statusDisplayFailedLabel, + statusDisplayQueuedLabel, + getActionCompleteMessage, + } = displayText; + + const { + isProcessing, + isProcessingComplete, + statusCounts, + tasks: items, + onActionCancel, + onActionStart, + onActionExit, + onTaskRemove, + } = props; + + const message = isProcessingComplete + ? getActionCompleteMessage({ counts: statusCounts }) + : undefined; + + const tableData = useResolveTableData( + FILE_DATA_ITEM_TABLE_KEYS, + DOWNLOAD_TABLE_RESOLVERS, + { + items, + props: { displayText, isProcessing, onTaskRemove }, + } + ); + + return ( + + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/DownloadView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/DownloadView.spec.tsx new file mode 100644 index 00000000000..cd24f5277c7 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/DownloadView.spec.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { LocationData } from '../../../../actions'; +import { INITIAL_STATUS_COUNTS } from '../../../../tasks'; +import * as Config from '../../../../configuration'; +import * as UseDownloadViewModule from '../useDownloadView'; + +import { DownloadViewState } from '../types'; +import { DownloadView } from '../DownloadView'; + +jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => ({ + accountId: '123456789012', + bucket: 'XXXXXXXXXXX', + credentials: jest.fn(), + region: 'us-west-2', +})); + +jest.mock('../../../../displayText', () => ({ + ...jest.requireActual( + '../../../../displayText' + ), + useDisplayText: () => ({ + DownloadView: { getActionCompleteMessage: jest.fn() }, + }), +})); + +const mockControlsContextProvider = jest.fn( + (_: any) => 'ControlsContextProvider' +); +jest.mock('../../../../controls/context', () => ({ + ControlsContextProvider: (ctx: any) => mockControlsContextProvider(ctx), + useControlsContext: () => ({ actionConfig: {}, data: {} }), +})); + +const location: LocationData = { + bucket: 'bucket', + id: 'id', + permissions: ['get'], + prefix: `prefix/`, + type: 'PREFIX', +}; + +const onActionCancel = jest.fn(); +const onActionExit = jest.fn(); +const onActionStart = jest.fn(); + +const actionCallbacks = { onActionCancel, onActionExit, onActionStart }; + +const taskOne = { + 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: undefined, +} as const; +const taskTwo = { + 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: undefined, +} as const; +const taskThree = { + 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: undefined, +} as const; + +const defaultViewState: DownloadViewState = { + ...actionCallbacks, + location: { + current: location, + path: 'some-prefix/', + key: 'prefix/some-prefix/', + }, + isProcessingComplete: false, + isProcessing: false, + statusCounts: { ...INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + tasks: [taskOne, taskTwo, taskThree], +}; + +const useDownloadViewSpy = jest + .spyOn(UseDownloadViewModule, 'useDownloadView') + .mockReturnValue(defaultViewState); + +describe('DownloadView', () => { + afterEach(jest.clearAllMocks); + + it('has the expected composable components', () => { + expect(DownloadView.Cancel).toBeDefined(); + expect(DownloadView.Exit).toBeDefined(); + expect(DownloadView.Message).toBeDefined(); + expect(DownloadView.Start).toBeDefined(); + expect(DownloadView.Statuses).toBeDefined(); + expect(DownloadView.TasksTable).toBeDefined(); + expect(DownloadView.Title).toBeDefined(); + }); + + it('provides the expected values to `ControlsContextProvider` on initial render', () => { + render(); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionCancelDisabled: true, + isActionStartDisabled: false, + isActionExitDisabled: false, + statusCounts: defaultViewState.statusCounts, + }, + ...actionCallbacks, + }); + }); + + it('provides the expected values to `ControlsContextProvider` while processing', () => { + const processingViewState: DownloadViewState = { + ...defaultViewState, + isProcessing: true, + statusCounts: { ...defaultViewState.statusCounts, QUEUED: 0, PENDING: 3 }, + tasks: [ + { ...taskOne, status: 'PENDING' }, + { ...taskTwo, status: 'PENDING' }, + { ...taskThree, status: 'PENDING' }, + ], + }; + + useDownloadViewSpy.mockReturnValueOnce(processingViewState); + + render(); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionCancelDisabled: false, + isActionStartDisabled: true, + isActionExitDisabled: true, + statusCounts: processingViewState.statusCounts, + }, + ...actionCallbacks, + }); + }); + + it('provides the expected values to `ControlsContextProvider` post processing in the happy path', () => { + const postProcessingViewState: DownloadViewState = { + ...defaultViewState, + isProcessing: false, + isProcessingComplete: true, + statusCounts: { + ...defaultViewState.statusCounts, + QUEUED: 0, + COMPLETE: 3, + }, + tasks: [ + { ...taskOne, status: 'COMPLETE' }, + { ...taskTwo, status: 'COMPLETE' }, + { ...taskThree, status: 'COMPLETE' }, + ], + }; + + useDownloadViewSpy.mockReturnValue(postProcessingViewState); + + render(); + + const { calls } = mockControlsContextProvider.mock; + expect(calls).toHaveLength(1); + expect(calls[0][0]).toMatchObject({ + data: { + isActionCancelDisabled: true, + isActionStartDisabled: true, + isActionExitDisabled: false, + statusCounts: postProcessingViewState.statusCounts, + }, + ...actionCallbacks, + }); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/useDownloadView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/useDownloadView.spec.ts new file mode 100644 index 00000000000..e569b53eb6e --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/__tests__/useDownloadView.spec.ts @@ -0,0 +1,180 @@ +import { act, renderHook } from '@testing-library/react'; + +import type { FileDataItem } from '../../../../actions'; +import { useLocationItems } from '../../../../locationItems'; +import { useStore } from '../../../../store'; +import { INITIAL_STATUS_COUNTS } from '../../../../tasks'; +import { useAction } from '../../../../useAction'; + +import { useDownloadView } from '../useDownloadView'; + +jest.mock('../../../../fileItems'); +jest.mock('../../../../locationItems'); +jest.mock('../../../../store'); +jest.mock('../../../../useAction'); + +const fileDataItems: FileDataItem[] = [ + { + key: 'pretend-prefix/test-file.txt', + fileKey: 'test-file.txt', + lastModified: new Date(), + id: 'id-1', + size: 10, + type: 'FILE', + }, + { + key: 'pretend-prefix/deeply-nested/test-file.txt', + fileKey: 'test-file.txt', + lastModified: new Date(), + id: 'id-2', + size: 10, + type: 'FILE', + }, +]; + +const mockLocationItemsState = { fileDataItems }; + +describe('useDownloadView', () => { + const mockUseAction = jest.mocked(useAction); + const mockUseLocationItems = jest.mocked(useLocationItems); + const mockUseStore = jest.mocked(useStore); + + const mockCancel = jest.fn(); + const mockStoreDispatch = jest.fn(); + const mockLocationItemsDispatch = jest.fn(); + const mockHandleDownload = jest.fn(); + const mockReset = jest.fn(); + + beforeEach(() => { + mockUseLocationItems.mockReturnValue([ + mockLocationItemsState, + mockLocationItemsDispatch, + ]); + mockUseStore.mockReturnValue([ + { + actionType: 'DOWNLOAD', + location: { + current: { + prefix: 'test-prefix/', + bucket: 'bucket', + id: 'id', + permissions: ['get'], + type: 'PREFIX', + }, + path: '', + key: 'test-prefix/', + }, + }, + mockStoreDispatch, + ]); + + mockUseAction.mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + reset: mockReset, + statusCounts: { ...INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + tasks: [ + { + status: 'QUEUED', + data: { key: 'test-item', id: 'id' }, + cancel: mockCancel, + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item2', id: 'id2' }, + cancel: mockCancel, + message: 'test-message', + progress: undefined, + }, + { + status: 'QUEUED', + data: { key: 'test-item3', id: 'id3' }, + cancel: mockCancel, + message: 'test-message', + progress: undefined, + }, + ], + }, + mockHandleDownload, + ]); + }); + + afterEach(jest.clearAllMocks); + + it('should return the correct initial state', () => { + const { result } = renderHook(() => useDownloadView()); + + expect(result.current).toEqual( + expect.objectContaining({ + onActionCancel: expect.any(Function), + onActionExit: 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(() => useDownloadView()); + + act(() => { + result.current.onActionStart(); + }); + + expect(mockHandleDownload).toHaveBeenCalledTimes(1); + }); + + it('should call cancel on tasks when onActionCancel is called', () => { + const { result } = renderHook(() => useDownloadView()); + + act(() => { + result.current.onActionCancel(); + }); + + expect(mockCancel).toHaveBeenCalledTimes(3); + }); + + it('should reset state when onActionExit is called', () => { + const mockOnExit = jest.fn(); + const { result } = renderHook(() => + useDownloadView({ onExit: mockOnExit }) + ); + + act(() => { + result.current.onActionExit(); + }); + + expect(mockOnExit).toHaveBeenCalledTimes(1); + expect(mockLocationItemsDispatch).toHaveBeenCalledTimes(1); + expect(mockLocationItemsDispatch).toHaveBeenCalledWith({ + type: 'RESET_LOCATION_ITEMS', + }); + expect(mockStoreDispatch).toHaveBeenCalledTimes(1); + expect(mockStoreDispatch).toHaveBeenCalledWith({ + type: 'RESET_ACTION_TYPE', + }); + }); + + it('provides the unmodified value of `fileDataItems` to `useAction` as `items`', () => { + renderHook(() => useDownloadView()); + + expect(mockUseAction).toHaveBeenCalledTimes(1); + expect(mockUseAction).toHaveBeenCalledWith( + 'download', + expect.objectContaining({ items: fileDataItems }) + ); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/index.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/index.ts new file mode 100644 index 00000000000..b11c69f98f4 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/index.ts @@ -0,0 +1,3 @@ +export { DownloadView } from './DownloadView'; +export { useDownloadView } from './useDownloadView'; +export * from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/types.ts new file mode 100644 index 00000000000..7e8aa1fb743 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/types.ts @@ -0,0 +1,29 @@ +import type { DownloadHandlerData, LocationData } from '../../../actions'; +import type { + ActionViewProps, + ActionViewState, + ActionViewType, +} from '../types'; + +export interface DownloadViewState + extends ActionViewState {} +export interface DownloadViewProps extends ActionViewProps {} +export interface UseDownloadViewOptions { + onExit?: (location?: LocationData) => void; +} + +export interface DownloadViewProviderProps extends DownloadViewState { + children?: React.ReactNode; +} + +export interface DownloadViewType + extends ActionViewType { + Provider: (props: DownloadViewProviderProps) => React.JSX.Element; + Cancel: () => React.JSX.Element | null; + Exit: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Start: () => React.JSX.Element | null; + Statuses: () => React.JSX.Element | null; + TasksTable: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.ts new file mode 100644 index 00000000000..0d7c1f669e2 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.ts @@ -0,0 +1,72 @@ +import React from 'react'; +import { isFunction } from '@aws-amplify/ui'; + +import type { FileDataItem } from '../../../actions'; +import type { Task } from '../../../tasks'; +import { useLocationItems } from '../../../locationItems'; +import { useStore } from '../../../store'; +import { useAction } from '../../../useAction'; + +import type { DownloadViewState, UseDownloadViewOptions } from './types'; + +// assign to constant to ensure referential equality +const EMPTY_ITEMS: FileDataItem[] = []; + +export const useDownloadView = ( + options?: UseDownloadViewOptions +): DownloadViewState => { + const { onExit: _onExit } = options ?? {}; + + const [{ location }, storeDispatch] = useStore(); + const [locationItems, locationItemsDispatch] = useLocationItems(); + const { current } = location; + const { fileDataItems: items = EMPTY_ITEMS } = locationItems; + + const [processState, handleProcess] = useAction('download', { + items, + }); + + const { isProcessing, isProcessingComplete, statusCounts, tasks } = + processState; + + const onActionStart = () => { + if (!current) return; + handleProcess(); + }; + + const onActionCancel = () => { + tasks.forEach((task) => { + // Calling cancel on task works only on queued tasks. + // In case of download, all download presigned url open at once + // When certain threshold is reached for queuing inside StorageBrowser, cancel might be possible. + if (isFunction(task.cancel)) task.cancel(); + }); + }; + + const onActionExit = () => { + // clear files state + locationItemsDispatch({ type: 'RESET_LOCATION_ITEMS' }); + // clear selected action + storeDispatch({ type: 'RESET_ACTION_TYPE' }); + if (isFunction(_onExit)) _onExit(current); + }; + + const onTaskRemove = React.useCallback( + ({ data }: Task) => { + locationItemsDispatch({ type: 'REMOVE_LOCATION_ITEM', id: data.id }); + }, + [locationItemsDispatch] + ); + + return { + isProcessing, + isProcessingComplete, + location, + statusCounts, + tasks, + onActionCancel, + onActionExit, + onActionStart, + onTaskRemove, + }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts index e580fd4e218..703641a6b8a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts @@ -12,6 +12,12 @@ export type { DeleteViewState, } from './DeleteView'; export { DeleteView, useDeleteView } from './DeleteView'; +export type { + DownloadViewProps, + DownloadViewType, + DownloadViewState, +} from './DownloadView'; +export { DownloadView, useDownloadView } from './DownloadView'; export { LocationActionView } from './LocationActionView'; export type { UploadViewProps, diff --git a/packages/react-storage/src/components/StorageBrowser/views/__tests__/__snapshots__/useView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/views/__tests__/__snapshots__/useView.spec.ts.snap index 963383cd9f4..63c54094821 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/__tests__/__snapshots__/useView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/views/__tests__/__snapshots__/useView.spec.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`useView throws when provided an unexepected key 1`] = `"Value of \`unexpected!\` cannot be used to index \`useView\`"`; +exports[`useView throws when provided an unexpected key 1`] = `"Value of \`unexpected!\` cannot be used to index \`useView\`"`; diff --git a/packages/react-storage/src/components/StorageBrowser/views/__tests__/useView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/__tests__/useView.spec.ts index 5b1d356e858..a0690936789 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/__tests__/useView.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/__tests__/useView.spec.ts @@ -15,7 +15,7 @@ describe('useView', () => { } ); - it('throws when provided an unexepected key', () => { + it('throws when provided an unexpected key', () => { // turn off console.error logging for unhappy path test case jest.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx b/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx index a806743917b..ba03152721d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx @@ -5,6 +5,7 @@ import { UploadView } from '../LocationActionView/UploadView'; import { CreateFolderView } from '../LocationActionView/CreateFolderView'; import { CopyView } from '../LocationActionView/CopyView'; import { DeleteView } from '../LocationActionView/DeleteView'; +import { DownloadView } from '../LocationActionView/DownloadView'; import type { DefaultActionViewsByActionName } from '../types'; @@ -12,6 +13,7 @@ export const DEFAULT_ACTION_VIEWS: DefaultActionViewsByActionName = { createFolder: CreateFolderView, copy: CopyView, delete: DeleteView, + download: DownloadView, upload: UploadView, }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/index.ts b/packages/react-storage/src/components/StorageBrowser/views/index.ts index 9339b81f1e0..2b15b3ec76a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/index.ts @@ -3,6 +3,7 @@ export type { CopyViewType, CreateFolderViewType, DeleteViewType, + DownloadViewType, LocationActionViewProps, LocationActionViewType, UploadViewType, @@ -10,6 +11,7 @@ export type { export { CopyView, CreateFolderView, + DownloadView, DeleteView, LocationActionView, UploadView, diff --git a/packages/react-storage/src/components/StorageBrowser/views/types.ts b/packages/react-storage/src/components/StorageBrowser/views/types.ts index 78c663da686..5ab912ddca6 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/types.ts @@ -2,11 +2,12 @@ import type React from 'react'; import type { LocationData } from '../actions'; import type { - LocationActionViewProps, - UploadViewProps, - CreateFolderViewProps, CopyViewProps, + CreateFolderViewProps, DeleteViewProps, + DownloadViewProps, + LocationActionViewProps, + UploadViewProps, } from './LocationActionView'; import type { LocationDetailViewProps } from './LocationDetailView'; import type { LocationsViewProps } from './LocationsView'; @@ -41,15 +42,17 @@ export interface PrimaryViews { } export interface DefaultActionViews { - CreateFolderView: (props: CreateFolderViewProps) => React.JSX.Element | null; CopyView: (props: CopyViewProps) => React.JSX.Element | null; + CreateFolderView: (props: CreateFolderViewProps) => React.JSX.Element | null; DeleteView: (props: DeleteViewProps) => React.JSX.Element | null; + DownloadView: (props: DownloadViewProps) => React.JSX.Element | null; UploadView: (props: UploadViewProps) => React.JSX.Element | null; } export interface DefaultActionViewsByActionName { - createFolder: (props: CreateFolderViewProps) => React.JSX.Element | null; copy: (props: CopyViewProps) => React.JSX.Element | null; + createFolder: (props: CreateFolderViewProps) => React.JSX.Element | null; delete: (props: DeleteViewProps) => React.JSX.Element | null; + download: (props: DownloadViewProps) => React.JSX.Element | null; upload: (props: UploadViewProps) => React.JSX.Element | null; } diff --git a/packages/react-storage/src/components/StorageBrowser/views/useView.ts b/packages/react-storage/src/components/StorageBrowser/views/useView.ts index 0d63ad9741c..c8b2f13bbaf 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/useView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/useView.ts @@ -1,14 +1,16 @@ import type { - UploadViewState, + CopyViewState, CreateFolderViewState, DeleteViewState, - CopyViewState, + DownloadViewState, + UploadViewState, } from './LocationActionView'; import { useCopyView, useCreateFolderView, - useUploadView, useDeleteView, + useDownloadView, + useUploadView, } from './LocationActionView'; import type { LocationDetailViewState } from './LocationDetailView'; import { useLocationDetailView } from './LocationDetailView'; @@ -18,6 +20,7 @@ import { useLocationsView } from './LocationsView'; interface DefaultUseViewStates { Copy: CopyViewState; CreateFolder: CreateFolderViewState; + Download: DownloadViewState; Delete: DeleteViewState; LocationDetail: LocationDetailViewState; Locations: LocationsViewState; @@ -31,6 +34,7 @@ type UseViewHooks = { export const USE_VIEW_HOOKS: UseViewHooks = { Copy: useCopyView, CreateFolder: useCreateFolderView, + Download: useDownloadView, Delete: useDeleteView, LocationDetail: useLocationDetailView, Locations: useLocationsView, diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/index.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/index.ts index 1fd81a99216..f55e6f9f4fe 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/index.ts @@ -4,4 +4,5 @@ export { DELETE_TABLE_RESOLVERS, UPLOAD_TABLE_KEYS, UPLOAD_TABLE_RESOLVERS, + DOWNLOAD_TABLE_RESOLVERS, } from './tableResolvers'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__testUtils__/tasks.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__testUtils__/tasks.ts index 6bff0bfee5d..78339b4b40f 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__testUtils__/tasks.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__testUtils__/tasks.ts @@ -1,12 +1,7 @@ import { MULTIPART_UPLOAD_THRESHOLD_BYTES } from '../../../../actions/handlers/constants'; import type { TaskStatus } from '../../../../tasks'; -import type { - FileDataTask, - CopyActionTask, - DeleteActionTask, - UploadActionTask, -} from '../types'; +import type { FileDataTask, CopyActionTask, UploadActionTask } from '../types'; type MockFileDataTaskStatus = Exclude; @@ -88,15 +83,15 @@ export const MOCK_COPY_TASKS: MockFileDataTasks = { }, }; -const MOCK_DELETE_DATA: Omit = { +const MOCK_FILE_DATA: Omit = { ...MOCK_FILE_DATA_ITEM_BASE, key: `${MOCK_PREFIX}file1.txt`, }; -export const MOCK_DELETE_TASKS: MockFileDataTasks = { +export const MOCK_FILE_DATA_TASKS: MockFileDataTasks = { CANCELED: { data: { - ...MOCK_DELETE_DATA, + ...MOCK_FILE_DATA, id: 'CANCELED-ID', }, cancel: undefined, @@ -106,7 +101,7 @@ export const MOCK_DELETE_TASKS: MockFileDataTasks = { }, FAILED: { data: { - ...MOCK_DELETE_DATA, + ...MOCK_FILE_DATA, id: 'FAILED-ID', }, status: 'FAILED', @@ -115,7 +110,7 @@ export const MOCK_DELETE_TASKS: MockFileDataTasks = { }, COMPLETE: { data: { - ...MOCK_DELETE_DATA, + ...MOCK_FILE_DATA, id: 'COMPLETE-ID', }, status: 'COMPLETE', @@ -124,7 +119,7 @@ export const MOCK_DELETE_TASKS: MockFileDataTasks = { }, QUEUED: { data: { - ...MOCK_DELETE_DATA, + ...MOCK_FILE_DATA, id: 'QUEUED-ID', }, cancel: jest.fn(), @@ -134,7 +129,7 @@ export const MOCK_DELETE_TASKS: MockFileDataTasks = { }, PENDING: { data: { - ...MOCK_DELETE_DATA, + ...MOCK_FILE_DATA, id: 'PENDING-ID', }, status: 'PENDING', diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/copyActionTask.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/copyActionTask.spec.ts index 9010f199324..f6184b74fcf 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/copyActionTask.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/copyActionTask.spec.ts @@ -33,7 +33,7 @@ describe('COPY_TABLE_RESOLVERS', () => { describe('getCell', () => { it.each(FILE_DATA_ITEM_TABLE_KEYS)( - 'returns the expect cell `key` for a "%s" table `key`', + 'returns the expected cell `key` for a "%s" table `key`', (key) => { const data = { key, @@ -240,7 +240,7 @@ describe('COPY_TABLE_RESOLVERS', () => { describe('getHeader', () => { // filter out cancel, does not allow sorting it.each(FILE_DATA_ITEM_TABLE_KEYS.filter((key) => key !== 'cancel'))( - 'returns the expect header data for a %s column', + 'returns the expected header data for a %s column', (key) => { const data = { key, props: mockProps }; const output = COPY_TABLE_RESOLVERS.getHeader(data); @@ -253,7 +253,7 @@ describe('COPY_TABLE_RESOLVERS', () => { } ); - it('returns the expect header data for a cancel column', () => { + it('returns the expected header data for a cancel column', () => { const key = 'cancel' as const; const data = { key, props: mockProps }; const output = COPY_TABLE_RESOLVERS.getHeader(data); diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/deleteActionTask.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/deleteActionTask.spec.ts index 2421eb1ae96..a469cfce79d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/deleteActionTask.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/deleteActionTask.spec.ts @@ -1,7 +1,7 @@ import { capitalize } from '@aws-amplify/ui'; import { DeleteViewDisplayText } from '../../../../displayText'; -import { MOCK_DELETE_TASKS } from '../__testUtils__/tasks'; +import { MOCK_FILE_DATA_TASKS } from '../__testUtils__/tasks'; import { FILE_DATA_ITEM_TABLE_KEYS, STATUS_ICONS, @@ -20,7 +20,7 @@ const mockDisplayText: DeleteViewDisplayText = { tableColumnCancelHeader: 'Cancel', }; -const mockItems = Object.values(MOCK_DELETE_TASKS); +const mockItems = Object.values(MOCK_FILE_DATA_TASKS); const mockProps: DeleteTableResolverProps = { displayText: mockDisplayText, @@ -33,17 +33,17 @@ describe('DELETE_TABLE_RESOLVERS', () => { describe('getCell', () => { it.each(FILE_DATA_ITEM_TABLE_KEYS)( - 'returns the expect cell `key` for a "%s" table `key`', + 'returns the expected cell `key` for a "%s" table `key`', (key) => { const data = { key, - item: MOCK_DELETE_TASKS.QUEUED, + item: MOCK_FILE_DATA_TASKS.QUEUED, props: mockProps, }; const cell = DELETE_TABLE_RESOLVERS.getCell(data); expect(cell).toBeDefined(); - expect(cell.key).toBe(`${key}-${MOCK_DELETE_TASKS.QUEUED.data.id}`); + expect(cell.key).toBe(`${key}-${MOCK_FILE_DATA_TASKS.QUEUED.data.id}`); } ); @@ -128,7 +128,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { const key = 'status'; const item = { - ...MOCK_DELETE_TASKS.FAILED, + ...MOCK_FILE_DATA_TASKS.FAILED, status: 'OVERWRITE_PREVENTED', } as DeleteActionTask; @@ -171,7 +171,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { describe('cancel cell', () => { it('returns the expected cell values for an `item` with "QUEUED" status when `isProcessing` is "true"', () => { const key = 'cancel'; - const item = MOCK_DELETE_TASKS.QUEUED; + const item = MOCK_FILE_DATA_TASKS.QUEUED; const output = DELETE_TABLE_RESOLVERS.getCell({ item, key, @@ -192,7 +192,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { it('returns the expected cell values for an `item` with "QUEUED" status when `isProcessing` is "false"', () => { const key = 'cancel'; - const item = MOCK_DELETE_TASKS.QUEUED; + const item = MOCK_FILE_DATA_TASKS.QUEUED; const output = DELETE_TABLE_RESOLVERS.getCell({ item, key, @@ -211,7 +211,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { }); }); - it.each([MOCK_DELETE_TASKS.PENDING, MOCK_DELETE_TASKS.COMPLETE])( + it.each([MOCK_FILE_DATA_TASKS.PENDING, MOCK_FILE_DATA_TASKS.COMPLETE])( 'returns the expected cell values for an `item` with "$status" status', (item) => { const key = 'cancel'; @@ -240,7 +240,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { describe('getHeader', () => { // filter out cancel, does not allow sorting it.each(FILE_DATA_ITEM_TABLE_KEYS.filter((key) => key !== 'cancel'))( - 'returns the expect header data for a %s column', + 'returns the expected header data for a %s column', (key) => { const data = { key, props: mockProps }; const output = DELETE_TABLE_RESOLVERS.getHeader(data); @@ -253,7 +253,7 @@ describe('DELETE_TABLE_RESOLVERS', () => { } ); - it('returns the expect header data for a cancel column', () => { + it('returns the expected header data for a cancel column', () => { const key = 'cancel' as const; const data = { key, props: mockProps }; const output = DELETE_TABLE_RESOLVERS.getHeader(data); @@ -269,11 +269,11 @@ describe('DELETE_TABLE_RESOLVERS', () => { describe('getRowKey', () => { it('resolves a row key as expected', () => { const rowKey = DELETE_TABLE_RESOLVERS.getRowKey({ - item: MOCK_DELETE_TASKS.PENDING, + item: MOCK_FILE_DATA_TASKS.PENDING, props: mockProps, }); - expect(rowKey).toBe(MOCK_DELETE_TASKS.PENDING.data.id); + expect(rowKey).toBe(MOCK_FILE_DATA_TASKS.PENDING.data.id); }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/downloadActionTask.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/downloadActionTask.spec.ts new file mode 100644 index 00000000000..1f73a10b1c3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/downloadActionTask.spec.ts @@ -0,0 +1,278 @@ +import { capitalize } from '@aws-amplify/ui'; +import { DownloadViewDisplayText } from '../../../../displayText'; + +import { MOCK_FILE_DATA_TASKS } from '../__testUtils__/tasks'; +import { + FILE_DATA_ITEM_TABLE_KEYS, + STATUS_ICONS, + STATUS_LABELS, +} from '../constants'; +import { DownloadActionTask, DownloadTableResolverProps } from '../types'; +import { DOWNLOAD_TABLE_RESOLVERS } from '../downloadResolvers'; + +const mockDisplayText: DownloadViewDisplayText = { + tableColumnNameHeader: 'Name', + tableColumnFolderHeader: 'Folder', + tableColumnTypeHeader: 'Type', + tableColumnSizeHeader: 'Size', + tableColumnStatusHeader: 'Status', + tableColumnCancelHeader: 'Cancel', +}; + +const mockItems = Object.values(MOCK_FILE_DATA_TASKS); + +const mockProps: DownloadTableResolverProps = { + displayText: mockDisplayText, + isProcessing: true, + onTaskRemove: jest.fn(), +}; + +describe('DOWNLOAD_TABLE_RESOLVERS', () => { + beforeEach(jest.clearAllMocks); + + describe('getCell', () => { + it.each(FILE_DATA_ITEM_TABLE_KEYS)( + 'returns the expected cell `key` for a "%s" table `key`', + (key) => { + const data = { + key, + item: MOCK_FILE_DATA_TASKS.QUEUED, + props: mockProps, + }; + const cell = DOWNLOAD_TABLE_RESOLVERS.getCell(data); + + expect(cell).toBeDefined(); + expect(cell.key).toBe(`${key}-${MOCK_FILE_DATA_TASKS.QUEUED.data.id}`); + } + ); + + describe('text cell resolvers', () => { + it.each(mockItems)( + 'returns the expected cell values when `key` is "name" for an item with "$status" status', + (item) => { + const key = 'name'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'text', + content: { + icon: STATUS_ICONS[item.status], + text: item.data.fileKey, + }, + }); + } + ); + + it.each(mockItems)( + 'returns the expected cell values when `key` is `folder` for an item with "$status" status', + (item) => { + const key = 'folder'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'text', + content: { text: 'test-folder/' }, + }); + } + ); + + it.each(mockItems)( + 'returns the expected cell values when `key` is `status` for an item with "$status" status', + (item) => { + const key = 'status'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'text', + content: { text: mockDisplayText[STATUS_LABELS[item.status]] }, + }); + } + ); + + it.each(mockItems)( + 'returns the expected cell values when `key` is `type` for an item with "$status" status', + (item) => { + const key = 'type'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'text', + content: { text: 'txt' }, + }); + } + ); + + describe('status cell', () => { + it('returns an empty string for `context.text` when `item.status` is "OVERWRITE_PREVENTED"', () => { + const key = 'status'; + + const item = { + ...MOCK_FILE_DATA_TASKS.FAILED, + status: 'OVERWRITE_PREVENTED', + } as DownloadActionTask; + + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'text', + content: { text: '' }, + }); + }); + }); + }); + + describe('number cell resolvers', () => { + it.each(mockItems)( + 'returns the expected cell values when `key` is `size` for an item with "$status" status', + (item) => { + const key = 'size'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'number', + content: { value: item.data.size, displayValue: '1.0 kB' }, + }); + } + ); + }); + + describe('button cell resolvers', () => { + describe('cancel cell', () => { + it('returns the expected cell values for an `item` with "QUEUED" status when `isProcessing` is "true"', () => { + const key = 'cancel'; + const item = MOCK_FILE_DATA_TASKS.QUEUED; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'button', + content: { + ariaLabel: `Cancel item: ${item.data.fileKey}`, + isDisabled: false, + onClick: expect.any(Function), + icon: 'cancel', + }, + }); + }); + + it('returns the expected cell values for an `item` with "QUEUED" status when `isProcessing` is "false"', () => { + const key = 'cancel'; + const item = MOCK_FILE_DATA_TASKS.QUEUED; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: { ...mockProps, isProcessing: false }, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'button', + content: { + ariaLabel: `Remove item: ${item.data.fileKey}`, + isDisabled: false, + onClick: expect.any(Function), + icon: 'cancel', + }, + }); + }); + + it.each([MOCK_FILE_DATA_TASKS.PENDING, MOCK_FILE_DATA_TASKS.COMPLETE])( + 'returns the expected cell values for an `item` with "$status" status', + (item) => { + const key = 'cancel'; + const output = DOWNLOAD_TABLE_RESOLVERS.getCell({ + item, + key, + props: mockProps, + }); + + expect(output).toStrictEqual({ + key: `${key}-${item.data.id}`, + type: 'button', + content: { + ariaLabel: `Cancel item: ${item.data.fileKey}`, + isDisabled: true, + onClick: undefined, + icon: 'cancel', + }, + }); + } + ); + }); + }); + }); + + describe('getHeader', () => { + // filter out cancel, does not allow sorting + it.each(FILE_DATA_ITEM_TABLE_KEYS.filter((key) => key !== 'cancel'))( + 'returns the expected header data for a %s column', + (key) => { + const data = { key, props: mockProps }; + const output = DOWNLOAD_TABLE_RESOLVERS.getHeader(data); + + expect(output).toStrictEqual({ + key, + type: 'sort', + content: { label: capitalize(key) }, + }); + } + ); + + it('returns the expected header data for a cancel column', () => { + const key = 'cancel' as const; + const data = { key, props: mockProps }; + const output = DOWNLOAD_TABLE_RESOLVERS.getHeader(data); + + expect(output).toStrictEqual({ + key, + type: 'text', + content: { text: capitalize(key) }, + }); + }); + }); + + describe('getRowKey', () => { + it('resolves a row key as expected', () => { + const rowKey = DOWNLOAD_TABLE_RESOLVERS.getRowKey({ + item: MOCK_FILE_DATA_TASKS.PENDING, + props: mockProps, + }); + + expect(rowKey).toBe(MOCK_FILE_DATA_TASKS.PENDING.data.id); + }); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/utils.spec.ts index 59ca391eb0b..fe82ea78d8a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { MOCK_COPY_TASKS, - MOCK_DELETE_TASKS, + MOCK_FILE_DATA_TASKS, MOCK_UPLOAD_TASKS_SINGLE_PART, } from '../__testUtils__/tasks'; import { @@ -14,8 +14,8 @@ import { describe('table resolver utils', () => { describe.each([ { name: 'copy', tasks: MOCK_COPY_TASKS }, - { name: 'delete', tasks: MOCK_DELETE_TASKS }, - ])('getCopyOrDeleteCancelCellContent with $name tasks', ({ tasks }) => { + { name: 'delete', tasks: MOCK_FILE_DATA_TASKS }, + ])('getFileDataCancelCellContent with $name tasks', ({ tasks }) => { const onTaskRemove = jest.fn(); const props = { displayText: {}, onTaskRemove }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/downloadResolvers.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/downloadResolvers.ts new file mode 100644 index 00000000000..1f4e8f53c46 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/downloadResolvers.ts @@ -0,0 +1,46 @@ +import { capitalize } from '@aws-amplify/ui'; + +import { isDownloadViewDisplayTextKey } from '../../../displayText'; + +import { STATUS_LABELS } from './constants'; +import type { FileDataTaskTableResolvers, GetFileDataCell } from './types'; +import { cancel, folder, getFileDataCellKey, name, size, type } from './utils'; + +const status: GetFileDataCell = (data) => { + const key = getFileDataCellKey(data); + const { + item: { status }, + props: { displayText }, + } = data; + + const statusLabelKey = STATUS_LABELS[status]; + + const text = isDownloadViewDisplayTextKey(statusLabelKey) + ? displayText[statusLabelKey] + : ''; + + return { key, type: 'text', content: { text } }; +}; + +const DOWNLOAD_CELL_RESOLVERS = { + name, + folder, + type, + size, + status, + cancel, +}; + +export const DOWNLOAD_TABLE_RESOLVERS: FileDataTaskTableResolvers = { + getCell: (data) => DOWNLOAD_CELL_RESOLVERS[data.key](data), + getHeader: ({ key, props: { displayText } }) => { + const text = displayText[`tableColumn${capitalize(key)}Header`]; + + if (key === 'cancel') { + return { key, type: 'text', content: { text } }; + } + + return { key, type: 'sort', content: { label: text } }; + }, + getRowKey: ({ item }) => item.data.id, +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/index.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/index.ts index be1d39548a6..66bdee68635 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/index.ts @@ -1,4 +1,5 @@ export { COPY_TABLE_RESOLVERS } from './copyResolvers'; -export { UPLOAD_TABLE_KEYS, UPLOAD_TABLE_RESOLVERS } from './uploadResolvers'; export { DELETE_TABLE_RESOLVERS } from './deleteResolvers'; +export { DOWNLOAD_TABLE_RESOLVERS } from './downloadResolvers'; +export { UPLOAD_TABLE_KEYS, UPLOAD_TABLE_RESOLVERS } from './uploadResolvers'; export { FILE_DATA_ITEM_TABLE_KEYS } from './constants'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/types.ts b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/types.ts index d3eaa699e64..4dcde54b74a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils/tableResolvers/types.ts @@ -1,13 +1,15 @@ import type { CopyHandlerData, DeleteHandlerData, - UploadHandlerData, - TaskData, + DownloadHandlerData, OptionalFileData, + TaskData, + UploadHandlerData, } from '../../../actions'; import type { CopyViewDisplayText, DeleteViewDisplayText, + DownloadViewDisplayText, UploadViewDisplayText, } from '../../../displayText'; import type { Task } from '../../../tasks'; @@ -15,6 +17,7 @@ import type { DataTableResolvers } from '../../hooks/useResolveTableData'; export interface CopyActionTask extends Task {} export interface DeleteActionTask extends Task {} +export interface DownloadActionTask extends Task {} export interface UploadActionTask extends Task {} export interface FileDataTask extends Task {} @@ -31,6 +34,12 @@ export interface CopyTableResolverProps export interface DeleteTableResolverProps extends ActionTableResolverProps {} +export interface DownloadTableResolverProps + extends ActionTableResolverProps< + DownloadViewDisplayText, + DownloadActionTask + > {} + export interface UploadTableResolverProps extends ActionTableResolverProps { isMultipartUpload: (file: File) => boolean;