From 3e9f95d940812284d83e9c8096262c9cf6c15d75 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Wed, 27 Nov 2024 15:10:57 -0800 Subject: [PATCH] feat(storage-browser): add custom actions (#6210) * feat(storage-browser): add custom actions * Fix circular dependency * Remove unused variables from example apps * Update unit tests * Update unit tests. Fix types * Bump coverage down * Remove 'as const' from permissions in example * Address feedback * Fix copy regression * Fix issue with custom action view filtering * fix: task data are not being refreshed when it's updated --------- Co-authored-by: Chris Fang Co-authored-by: Hui Zhao --- .../composable-playground/index.page.tsx | 1 - .../custom-actions/index.page.tsx | 129 +++++ .../default-auth/index.page.tsx | 1 - .../default-auth/routed/StorageBrowser.ts | 2 - .../[location-detail]/index.page.tsx | 1 - .../routed/[locations]/index.page.tsx | 1 - .../default-auth/routed/index.page.tsx | 1 - .../managed-auth/index.page.tsx | 100 +++- .../[location-detail]/index.page.tsx | 6 +- .../routed/[locations]/index.page.tsx | 1 - packages/react-storage/jest.config.ts | 4 +- .../StorageBrowser/StorageBrowserAmplify.tsx | 6 +- .../StorageBrowser/StorageBrowserDefault.tsx | 5 +- .../__tests__/StorageBrowserAmplify.spec.tsx | 1 - .../__tests__/StorageBrowserDefault.spec.tsx | 14 +- .../__tests__/createStorageBrowser.spec.tsx | 8 - .../__snapshots__/defaults.spec.ts.snap | 38 +- .../configs/__tests__/context.spec.tsx | 19 +- .../configs/__tests__/defaults.spec.ts | 90 +--- .../StorageBrowser/actions/configs/context.ts | 21 +- .../actions/configs/defaults.tsx | 54 +-- .../StorageBrowser/actions/configs/index.ts | 4 +- .../StorageBrowser/actions/configs/types.ts | 161 +++---- .../StorageBrowser/actions/configs/utils.ts | 19 + .../StorageBrowser/actions/createUseAction.ts | 6 - .../actions/handlers/__tests__/copy.spec.ts | 29 +- .../handlers/__tests__/createFolder.spec.ts | 28 +- .../actions/handlers/__tests__/delete.spec.ts | 9 +- .../handlers/__tests__/download.spec.ts | 5 +- .../actions/handlers/__tests__/upload.spec.ts | 37 +- .../actions/handlers/__tests__/utils.spec.ts | 24 - .../StorageBrowser/actions/handlers/copy.ts | 48 +- .../actions/handlers/createFolder.ts | 31 +- .../StorageBrowser/actions/handlers/delete.ts | 27 +- .../actions/handlers/download.ts | 28 +- .../StorageBrowser/actions/handlers/types.ts | 10 +- .../StorageBrowser/actions/handlers/upload.ts | 37 +- .../StorageBrowser/actions/handlers/utils.ts | 13 - .../StorageBrowser/actions/index.ts | 35 +- .../StorageBrowser/actions/types.ts | 18 - .../StorageBrowser/actions/useAction/index.ts | 1 - .../adapters/permissionParsers.ts | 2 +- .../composables/ActionsList.tsx | 4 +- .../StorageBrowser/controls/types.ts | 4 +- .../StorageBrowser/createStorageBrowser.tsx | 107 +++-- .../libraries/en/__tests__/scenarios.ts | 14 +- .../displayText/libraries/en/locationsView.ts | 4 +- .../StorageBrowser/displayText/types.ts | 2 +- .../src/components/StorageBrowser/index.ts | 1 + .../providers/configuration/context.tsx | 1 - .../createConfigurationProvider.tsx | 22 +- .../providers/configuration/index.ts | 2 +- .../providers/configuration/types.ts | 9 +- .../useGetActionInputCallback.ts | 16 +- .../providers/store/files/types.ts | 12 +- .../providers/store/files/utils.ts | 4 +- .../tasks/__tests__/useProcessTasks.spec.ts | 24 +- .../components/StorageBrowser/tasks/types.ts | 32 +- .../StorageBrowser/tasks/useProcessTasks.ts | 100 ++-- .../src/components/StorageBrowser/types.ts | 78 ++- .../createEnhancedListHandler.spec.ts | 17 +- .../useAction/__tests__/search.spec.ts | 0 .../StorageBrowser/useAction/constants.ts | 5 + .../StorageBrowser/useAction/context.tsx | 10 + .../useAction/createEnhancedListHandler.ts | 6 +- .../StorageBrowser/useAction/index.ts | 11 + .../StorageBrowser/useAction/types.ts | 123 +++++ .../StorageBrowser/useAction/useAction.ts | 32 ++ .../StorageBrowser/useAction/useHandler.ts | 67 +++ .../StorageBrowser/useAction/useList.ts | 31 ++ .../useAction/useListFolderItems.ts | 65 +++ .../useAction/useListLocationItems.ts | 55 +++ .../useAction/useListLocations.ts | 20 +- .../StorageBrowser/useAction/utils.ts | 52 ++ .../__tests__/CopyViewProvider.spec.tsx | 1 + .../CopyView/__tests__/useCopyView.spec.ts | 124 ++--- .../CopyView/__tests__/useFolders.spec.ts | 90 +--- .../CopyView/useCopyView.ts | 45 +- .../LocationActionView/CopyView/useFolders.ts | 35 +- .../__tests__/useCreateFolderView.spec.ts | 78 ++- .../CreateFolderView/types.ts | 8 +- .../CreateFolderView/useCreateFolderView.ts | 34 +- .../__tests__/useDeleteView.spec.ts | 82 ++-- .../DeleteView/useDeleteView.ts | 31 +- .../LocationActionView/LocationActionView.tsx | 39 +- .../__tests__/useUploadView.spec.ts | 159 +++---- .../UploadView/useUploadView.ts | 129 +++-- .../__tests__/getActionViewTableData.spec.ts | 2 +- .../views/LocationActionView/constants.ts | 1 - .../getActionViewTableData.ts | 2 +- .../views/LocationActionView/index.ts | 19 +- .../views/LocationActionView/types.ts | 37 +- .../LocationDetailViewProvider.tsx | 31 +- .../__tests__/LocationDetailView.spec.tsx | 441 +++-------------- .../__tests__/useLocationDetailView.spec.tsx | 175 +++---- .../getLocationDetailViewTableData.ts | 11 +- .../views/LocationDetailView/types.ts | 33 +- .../useLocationDetailView.ts | 148 +++--- .../LocationsView/LocationsViewProvider.tsx | 2 +- .../__tests__/LocationsView.spec.tsx | 445 ++---------------- .../__tests__/useLocationsView.spec.tsx | 112 ++--- .../views/LocationsView/types.ts | 22 +- .../views/LocationsView/useLocationsView.ts | 48 +- .../StorageBrowser/views/context.tsx | 63 --- .../views/context/actionViews.tsx | 24 + .../StorageBrowser/views/context/getViews.ts | 45 ++ .../StorageBrowser/views/context/index.ts | 1 + .../views/context/primaryViews.tsx | 24 + .../StorageBrowser/views/context/types.ts | 13 + .../StorageBrowser/views/context/views.tsx | 36 ++ .../StorageBrowser/views/createUseView.ts | 63 --- .../components/StorageBrowser/views/index.ts | 4 +- .../components/StorageBrowser/views/types.ts | 39 ++ .../StorageBrowser/views/useView.ts | 48 ++ 114 files changed, 2265 insertions(+), 2484 deletions(-) create mode 100644 examples/next/pages/ui/components/storage/storage-browser/custom-actions/index.page.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/actions/configs/utils.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/actions/types.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts rename packages/react-storage/src/components/StorageBrowser/{actions => }/useAction/__tests__/createEnhancedListHandler.spec.ts (96%) rename packages/react-storage/src/components/StorageBrowser/{actions => }/useAction/__tests__/search.spec.ts (100%) create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/constants.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/context.tsx rename packages/react-storage/src/components/StorageBrowser/{actions => }/useAction/createEnhancedListHandler.ts (97%) create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/index.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/types.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/useAction.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/useHandler.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/useList.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/useListFolderItems.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/useListLocationItems.ts rename packages/react-storage/src/components/StorageBrowser/{actions => }/useAction/useListLocations.ts (70%) create mode 100644 packages/react-storage/src/components/StorageBrowser/useAction/utils.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/context.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/getViews.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/index.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/primaryViews.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/types.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/context/views.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/createUseView.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/useView.ts diff --git a/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx index 66db5fb3850..f1ca05f4d87 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx @@ -11,7 +11,6 @@ import { Auth } from '../managedAuthAdapter'; import { Button, Flex, Breadcrumbs } from '@aws-amplify/ui-react'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; const components: CreateStorageBrowserInput['components'] = { Navigation: ({ items }) => ( diff --git a/examples/next/pages/ui/components/storage/storage-browser/custom-actions/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/custom-actions/index.page.tsx new file mode 100644 index 00000000000..f5eccbf88b1 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/custom-actions/index.page.tsx @@ -0,0 +1,129 @@ +import React from 'react'; + +import { createStorageBrowser } from '@aws-amplify/ui-react-storage/browser'; + +import { Flex } from '@aws-amplify/ui-react'; + +import '@aws-amplify/ui-react-storage/styles.css'; + +const { StorageBrowser } = createStorageBrowser({ + actions: { + default: { + copy: { + actionListItem: { + icon: 'copy-file', + label: 'Override Copy', + }, + handler: ({ data }) => { + const { key } = data; + return { + result: Promise.resolve({ status: 'COMPLETE', value: { key } }), + }; + }, + viewName: 'CopyView', + }, + createFolder: { + actionListItem: { + icon: 'create-folder', + label: 'Override Create Folder', + }, + handler: ({ data }) => { + const { key } = data; + return { + result: Promise.resolve({ status: 'COMPLETE', value: { key } }), + }; + }, + viewName: 'CreateFolderView', + }, + delete: { + actionListItem: { + icon: 'delete-file', + label: 'Override Delete', + }, + handler: ({ data }) => { + const { key } = data; + return { + result: Promise.resolve({ status: 'COMPLETE', value: { key } }), + }; + }, + viewName: 'DeleteView', + }, + download: () => { + return { + result: Promise.resolve({ + status: 'COMPLETE', + value: { url: new URL('') }, + }), + }; + }, + upload: { + actionListItem: { + icon: 'upload-file', + label: 'Override Upload', + }, + handler: ({ data }) => { + const { key } = data; + return { + result: Promise.resolve({ status: 'COMPLETE', value: { key } }), + }; + }, + viewName: 'UploadView', + }, + listLocationItems: () => + Promise.resolve({ + items: [ + { + id: 'jaskjkaska', + key: 'item-key', + lastModified: new Date(), + size: 1008, + type: 'FILE' as const, + }, + ], + nextToken: undefined, + }), + }, + }, + config: { + getLocationCredentials: () => + Promise.resolve({ + credentials: { + accessKeyId: '', + expiration: new Date(), + secretAccessKey: '', + sessionToken: '', + }, + }), + region: '', + registerAuthListener: () => null, + listLocations: () => + Promise.resolve({ + items: [ + { + bucket: 'my-bucket', + id: crypto.randomUUID(), + permissions: ['delete', 'get', 'list', 'write'], + prefix: 'my-prefix', + type: 'PREFIX', + }, + ], + nextToken: undefined, + }), + }, +}); + +function Example() { + return ( + + + + ); +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx index 494fc67d94f..2af0d66bc65 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx @@ -12,7 +12,6 @@ import { import { StorageBrowser } from '@aws-amplify/ui-react-storage'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import config from './aws-exports'; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts index de650f99e9c..30eb2ac6093 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts @@ -4,8 +4,6 @@ import { createAmplifyAuthAdapter, createStorageBrowser, } from '@aws-amplify/ui-react-storage/browser'; -import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import config from './aws-exports'; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx index be01ddb40ca..32ab12f4431 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -6,7 +6,6 @@ import { Button, Flex } from '@aws-amplify/ui-react'; import { StorageBrowser } from '../../StorageBrowser'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import '@aws-amplify/ui-react-storage/styles.css'; export default function Page() { diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx index b50fd485c02..8d3093dd95c 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx @@ -6,7 +6,6 @@ import { Button, Flex } from '@aws-amplify/ui-react'; import { StorageBrowser } from '../StorageBrowser'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import '@aws-amplify/ui-react-storage/styles.css'; function Locations() { diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx index 9cfc40de58b..d298cb47bc0 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx @@ -6,7 +6,6 @@ import useIsSignedIn from './useIsSignedIn'; import { Authenticator } from '@aws-amplify/ui-react'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; function Example() { const router = useRouter(); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx index 18f2c1f20e9..a718060b9bb 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx @@ -1,19 +1,104 @@ import React from 'react'; +import { getUrl } from '@aws-amplify/storage/internals'; -import { createStorageBrowser } from '@aws-amplify/ui-react-storage/browser'; +import { + ActionViewConfig, + ActionHandler, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; import { managedAuthAdapter } from '../managedAuthAdapter'; import { SignIn, SignOutButton } from './routed/components'; - -import { Flex, View } from '@aws-amplify/ui-react'; +import { + Button, + Flex, + Link, + StepperField, + Text, + View, +} from '@aws-amplify/ui-react'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; -const { StorageBrowser } = createStorageBrowser({ +type GetLink = ActionHandler<{ duration: number; fileKey: string }, string>; + +const getLink: GetLink = ({ data, config }) => { + const result = getUrl({ + path: data.key, + options: { + bucket: { bucketName: config.bucket, region: config.region }, + locationCredentialsProvider: config.credentials, + expiresIn: data.duration * 60, + validateObjectExistence: true, + }, + }).then((res) => ({ + status: 'COMPLETE' as const, + value: res.url.toString(), + })); + + return { result }; +}; + +const generateLink: ActionViewConfig = { + handler: getLink, + viewName: 'LinkActionView', + actionListItem: { + icon: 'download', + label: 'Generate Download Links', + disable: (selected) => !selected?.length, + }, +}; + +const { StorageBrowser, useAction, useView } = createStorageBrowser({ + actions: { custom: { generateLink } }, config: managedAuthAdapter, }); +const LinkActionView = () => { + const [duration, setDuration] = React.useState(60); + + const locationDetailState = useView('LocationDetail'); + const { onActionExit, fileDataItems } = locationDetailState; + + const items = React.useMemo( + () => + !fileDataItems + ? [] + : fileDataItems.map((item) => ({ ...item, duration })), + [fileDataItems, duration] + ); + + const [{ tasks }, handleCreate] = useAction('generateLink', { items }); + + return ( + + + { + setDuration(value); + }} + /> + + {!tasks + ? null + : tasks.map(({ data, status, value }) => { + return ( + + {data.fileKey} + {value ? link : null} + {status} + + ); + })} + + ); +}; + function Example() { const [showSignIn, setShowSignIn] = React.useState(false); @@ -29,9 +114,8 @@ function Example() { > setShowSignIn(false)} /> - + + ); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx index f97c16a4e6c..4ddefd7cadc 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -6,7 +6,6 @@ import { SignOutButton } from '../../components'; import { StorageBrowser } from '../../StorageBrowser'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; export default function Page() { const { back, query, pathname, replace } = useRouter(); @@ -50,7 +49,10 @@ export default function Page() { }} /> {typeof query.actionType === 'string' ? ( - + { replace({ query: { ...query, actionType: undefined } }); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx index dd84cfba9ba..a28df87d223 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx @@ -7,7 +7,6 @@ import { SignOutButton } from '../components'; import { StorageBrowser } from '../StorageBrowser'; import '@aws-amplify/ui-react-storage/styles.css'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; function Locations() { const router = useRouter(); diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts index 294793ecdc1..217cec0d7d0 100644 --- a/packages/react-storage/jest.config.ts +++ b/packages/react-storage/jest.config.ts @@ -16,8 +16,8 @@ const config: Config = { // functions: 90, // lines: 95, // statements: 95, - branches: 84, - functions: 88, + branches: 82, + functions: 86, lines: 94, statements: 94, }, diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx index db84a3de1ee..b1d92988c5d 100644 --- a/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx +++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { createStorageBrowser } from './createStorageBrowser'; import { StorageBrowserProps as StorageBrowserPropsBase } from './types'; import { createAmplifyAuthAdapter } from './adapters'; -import { componentsDefault } from './componentsDefault'; export interface StorageBrowserProps extends StorageBrowserPropsBase {} @@ -12,10 +11,7 @@ export const StorageBrowser = ({ displayText, }: StorageBrowserProps): React.JSX.Element => { const { StorageBrowser } = React.useRef( - createStorageBrowser({ - components: componentsDefault, - config: createAmplifyAuthAdapter(), - }) + createStorageBrowser({ config: createAmplifyAuthAdapter() }) ).current; return ; diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx index cf74af8127f..7fe85960271 100644 --- a/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx +++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { useViews } from './views'; +import { useViews } from './views/context'; import { useStore } from './providers/store'; /** @@ -10,7 +10,8 @@ import { useStore } from './providers/store'; * - render `ActionView` on action selection */ export function StorageBrowserDefault(): React.JSX.Element { - const { LocationActionView, LocationDetailView, LocationsView } = useViews(); + const { primary } = useViews(); + const { LocationActionView, LocationDetailView, LocationsView } = primary; const [{ actionType, location }] = useStore(); const { current } = location; diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx index 5d6b33b7440..d5abaaf99b5 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx @@ -36,7 +36,6 @@ describe('StorageBrowser', () => { expect(createStorageBrowserSpy).toHaveBeenCalledTimes(1); expect(createStorageBrowserSpy).toHaveBeenCalledWith({ - components: expect.anything(), config: expect.anything(), }); diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx index b3e24e3c09f..128472bbd59 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx @@ -7,9 +7,17 @@ import { StorageBrowserDefault } from '../StorageBrowserDefault'; import { LocationData } from '../actions'; jest.spyOn(ViewsModule, 'useViews').mockReturnValue({ - LocationsView: () =>
, - LocationDetailView: () =>
, - LocationActionView: () =>
, + primary: { + LocationsView: () =>
, + LocationDetailView: () =>
, + LocationActionView: () =>
, + }, + action: { + copy: () =>
, + createFolder: () =>
, + delete: () =>
, + upload: () =>
, + }, }); const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx index bfb6c7fbea3..59e2aa8e654 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx @@ -56,14 +56,6 @@ describe('createStorageBrowser', () => { getLocationCredentials: config.getLocationCredentials, region: config.region, registerAuthListener: config.registerAuthListener, - actions: { - copy: expect.any(Object), - createFolder: expect.any(Object), - delete: expect.any(Object), - listLocationItems: expect.any(Object), - listLocations: expect.any(Object), - upload: expect.any(Object), - }, }); }); 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 d2894ca6058..8cb6a31c908 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 @@ -3,62 +3,44 @@ exports[`defaultActionConfigs matches expected shape 1`] = ` { "copy": { - "actionsListItemConfig": { + "actionListItem": { "disable": [Function], "hide": [Function], "icon": "copy-file", "label": "Copy", }, - "componentName": "CopyView", - "displayName": "Copy", "handler": [Function], + "viewName": "CopyView", }, "createFolder": { - "actionsListItemConfig": { - "disable": [Function], + "actionListItem": { "hide": [Function], "icon": "create-folder", "label": "Create folder", }, - "componentName": "CreateFolderView", - "displayName": "Create Folder", "handler": [Function], - "isCancelable": false, + "viewName": "CreateFolderView", }, "delete": { - "actionsListItemConfig": { + "actionListItem": { "disable": [Function], "hide": [Function], "icon": "delete-file", "label": "Delete", }, - "componentName": "DeleteView", - "displayName": "Delete", - "handler": [Function], - }, - "listLocationItems": { - "componentName": "LocationDetailView", - "displayName": [Function], - "handler": [Function], - }, - "listLocations": { - "componentName": "LocationsView", - "displayName": "Home", "handler": [Function], + "viewName": "DeleteView", }, + "download": [Function], + "listLocationItems": [Function], "upload": { - "actionsListItemConfig": { - "disable": [Function], - "fileSelection": "FILE", + "actionListItem": { "hide": [Function], "icon": "upload-file", "label": "Upload", }, - "componentName": "UploadView", - "displayName": "Upload", "handler": [Function], - "includeProgress": true, - "isCancelable": true, + "viewName": "UploadView", }, } `; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx index 9008ffc0c21..12fe8b90930 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx @@ -2,29 +2,32 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { defaultActionConfigs } from '../defaults'; -import { ActionConfigs } from '../types'; +import { ActionViewConfigs } from '../types'; import { ActionConfigsProvider, useActionConfigs } from '../context'; describe('useActionConfigs', () => { it('returns default and custom config values passed to `ActionConfigsProvider`', () => { const someCoolHandler = jest.fn(); - const configs: ActionConfigs = { - ...defaultActionConfigs, + const configs: ActionViewConfigs = { + copy: defaultActionConfigs.copy, + upload: defaultActionConfigs.upload, SomeCoolAction: { - componentName: 'SomeCoolView', + viewName: 'SomeCoolView', handler: someCoolHandler, - isCancelable: false, - displayName: 'Do Cool Action', + actionListItem: { + icon: 'info', + label: 'Do something cool', + }, }, }; const { result } = renderHook(useActionConfigs, { wrapper: (props) => ( - + ), }); - expect(result.current.actions).toStrictEqual(configs); + expect(result.current.actionConfigs).toStrictEqual(configs); }); }); 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 d5e2689c1cd..6feaeab7d8f 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 @@ -1,10 +1,4 @@ -import { ActionListItemConfig } from '../types'; -import { - createFolderActionConfig, - defaultActionConfigs, - listLocationItemsActionConfig, - uploadActionConfig, -} from '../defaults'; +import { defaultActionConfigs } from '../defaults'; import { generateCombinations, LOCATION_PERMISSION_VALUES, @@ -27,47 +21,25 @@ describe('defaultActionConfigs', () => { expect(defaultActionConfigs).toMatchSnapshot(); }); - describe('createFolderActionConfig', () => { + describe('createFolder', () => { + const { disable, hide } = defaultActionConfigs.createFolder.actionListItem; it('hides the action list item as expected', () => { - const hide = - (createFolderActionConfig.actionsListItemConfig as ActionListItemConfig)! - .hide!; for (const permissionsWithoutWrite of generateCombinations( permissionValuesWithoutWrite )) { - expect(hide(permissionsWithoutWrite)).toBe(true); - expect(hide([...permissionValuesWithoutWrite, 'write'])).toBe(false); + expect(hide?.(permissionsWithoutWrite)).toBe(true); + expect(hide?.([...permissionValuesWithoutWrite, 'write'])).toBe(false); } }); it('is never disabled', () => { - const disable = - (createFolderActionConfig.actionsListItemConfig as ActionListItemConfig)! - .disable!; - - expect(disable([])).toBe(false); - expect(disable([file])).toBe(false); - expect(disable(undefined)).toBe(false); - }); - }); - - describe('listLocationItemsActionConfig', () => { - it('returns the expected value of title', () => { - const { displayName } = listLocationItemsActionConfig; - - expect(displayName(undefined, undefined)).toBe('-'); - expect(displayName('bucket', undefined)).toBe('bucket'); - expect(displayName('bucket', 'prefix/')).toBe('bucket: prefix/'); - expect(displayName('bucket', 'prefix/nested/')).toBe( - 'bucket: ../nested/' - ); + expect(disable).toBeUndefined(); }); }); - describe('uploadActionConfig', () => { + describe('upload', () => { + const { disable, hide } = defaultActionConfigs.upload.actionListItem; it('hides the action list item as expected', () => { - const uploadFileListItem = uploadActionConfig.actionsListItemConfig!; - for (const permissionsWithoutWrite of generateCombinations( permissionValuesWithoutWrite )) { @@ -75,25 +47,19 @@ describe('defaultActionConfigs', () => { ...permissionValuesWithoutWrite, 'write' as const, ]; - expect(uploadFileListItem.hide?.(permissionsWithoutWrite)).toBe(true); - expect(uploadFileListItem.hide?.(permissionsWithWrite)).toBe(false); + expect(hide?.(permissionsWithoutWrite)).toBe(true); + expect(hide?.(permissionsWithWrite)).toBe(false); } }); it('is never disabled', () => { - const uploadFileListItem = uploadActionConfig.actionsListItemConfig!; - - expect(uploadFileListItem.disable?.([])).toBe(false); - expect(uploadFileListItem.disable?.([file])).toBe(false); - expect(uploadFileListItem.disable?.(undefined)).toBe(false); + expect(disable).toBeUndefined(); }); }); - describe('deleteActionConfig', () => { + describe('delete', () => { + const { disable, hide } = defaultActionConfigs.delete.actionListItem; it('hides the action list item as expected', () => { - const deleteFileListItem = - defaultActionConfigs.delete.actionsListItemConfig!; - for (const permissionsWithoutDelete of generateCombinations( LOCATION_PERMISSION_VALUES.filter((value) => value !== 'delete') )) { @@ -101,25 +67,21 @@ describe('defaultActionConfigs', () => { ...permissionsWithoutDelete, 'delete' as const, ]; - expect(deleteFileListItem.hide?.(permissionsWithoutDelete)).toBe(true); - expect(deleteFileListItem.hide?.(permissionsWithDelete)).toBe(false); + expect(hide?.(permissionsWithoutDelete)).toBe(true); + expect(hide?.(permissionsWithDelete)).toBe(false); } }); it('is disabled when no files are selected', () => { - const deleteFileListItem = - defaultActionConfigs.delete.actionsListItemConfig!; - - expect(deleteFileListItem.disable?.(undefined)).toBe(true); - expect(deleteFileListItem.disable?.([])).toBe(true); - expect(deleteFileListItem.disable?.([file])).toBe(false); + expect(disable?.(undefined)).toBe(true); + expect(disable?.([])).toBe(true); + expect(disable?.([file])).toBe(false); }); }); - describe('copyActionConfig', () => { + describe('copy', () => { + const { disable, hide } = defaultActionConfigs.copy.actionListItem; it('hides the action list item as expected', () => { - const copyFileListItem = defaultActionConfigs.copy.actionsListItemConfig!; - for (const permissionsWithoutWrite of generateCombinations( permissionValuesWithoutWrite )) { @@ -127,17 +89,15 @@ describe('defaultActionConfigs', () => { ...permissionValuesWithoutWrite, 'write' as const, ]; - expect(copyFileListItem.hide?.(permissionsWithoutWrite)).toBe(true); - expect(copyFileListItem.hide?.(permissionsWithWrite)).toBe(false); + expect(hide?.(permissionsWithoutWrite)).toBe(true); + expect(hide?.(permissionsWithWrite)).toBe(false); } }); it('is disabled when no files are selected', () => { - const copyFileListItem = defaultActionConfigs.copy.actionsListItemConfig!; - - expect(copyFileListItem.disable?.(undefined)).toBe(true); - expect(copyFileListItem.disable?.([])).toBe(true); - expect(copyFileListItem.disable?.([file])).toBe(false); + expect(disable?.(undefined)).toBe(true); + expect(disable?.([])).toBe(true); + expect(disable?.([file])).toBe(false); }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts index 2bde82f1c50..34fd4a1ac30 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts @@ -1,23 +1,14 @@ import { createContextUtilities } from '@aws-amplify/ui-react-core'; -import { ActionConfigs } from './types'; +import { ActionViewConfigs } from './types'; export interface ActionConfigsProviderProps { - actions?: ActionConfigs; + actionConfigs: ActionViewConfigs; children?: React.ReactNode; } -const defaultValue: { actions?: ActionConfigs } = { actions: undefined }; +const defaultValue: { actionConfigs: ActionViewConfigs | undefined } = { + actionConfigs: undefined, +}; + export const { useActionConfigs, ActionConfigsProvider } = createContextUtilities({ contextName: 'ActionConfigs', defaultValue }); - -export function useActionConfig( - type?: T -): ActionConfigs[T] { - const { actions } = useActionConfigs(); - - const config = type && actions?.[type]; - - if (!config) throw new Error('No action!'); - - return config; -} 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 a58ec847424..0b0a6de906e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx @@ -1,96 +1,65 @@ import { listLocationItemsHandler, - listLocationsHandler, createFolderHandler, uploadHandler, copyHandler, deleteHandler, + downloadHandler, } from '../handlers'; import { CopyActionConfig, CreateFolderActionConfig, DeleteActionConfig, - ListLocationItemsActionConfig, - ListLocationsActionConfig, UploadActionConfig, } from './types'; export const copyActionConfig: CopyActionConfig = { - componentName: 'CopyView', - actionsListItemConfig: { + viewName: 'CopyView', + actionListItem: { disable: (selected) => !selected || selected.length === 0, hide: (permissions) => !permissions.includes('write'), icon: 'copy-file', label: 'Copy', }, - displayName: 'Copy', handler: copyHandler, }; export const deleteActionConfig: DeleteActionConfig = { - componentName: 'DeleteView', - actionsListItemConfig: { + viewName: 'DeleteView', + actionListItem: { disable: (selected) => !selected || selected.length === 0, hide: (permissions) => !permissions.includes('delete'), icon: 'delete-file', label: 'Delete', }, - displayName: 'Delete', handler: deleteHandler, }; export const createFolderActionConfig: CreateFolderActionConfig = { - componentName: 'CreateFolderView', - actionsListItemConfig: { - disable: () => false, + viewName: 'CreateFolderView', + actionListItem: { hide: (permissions) => !permissions.includes('write'), icon: 'create-folder', label: 'Create folder', }, handler: createFolderHandler, - isCancelable: false, - displayName: 'Create Folder', -}; - -export const listLocationItemsActionConfig: ListLocationItemsActionConfig = { - componentName: 'LocationDetailView', - handler: listLocationItemsHandler, - displayName: (bucket, prefix) => { - if (bucket && prefix) { - const prefixes = prefix.split('/'); - return `${bucket}: ${ - prefixes.length > 2 ? `../${prefixes[prefixes.length - 2]}/` : prefix - }`; - } - return !bucket ? '-' : bucket; - }, -}; - -export const listLocationsActionConfig: ListLocationsActionConfig = { - componentName: 'LocationsView', - handler: listLocationsHandler, - displayName: 'Home', }; export const uploadActionConfig: UploadActionConfig = { - componentName: 'UploadView', - actionsListItemConfig: { - disable: () => false, - fileSelection: 'FILE', + viewName: 'UploadView', + actionListItem: { hide: (permissions) => !permissions.includes('write'), icon: 'upload-file', label: 'Upload', }, - isCancelable: true, - includeProgress: true, handler: uploadHandler, - displayName: 'Upload', }; export const defaultActionViewConfigs = { copy: copyActionConfig, createFolder: createFolderActionConfig, + download: downloadHandler, delete: deleteActionConfig, upload: uploadActionConfig, }; @@ -108,6 +77,5 @@ export const isDefaultActionViewType = ( export const defaultActionConfigs = { ...defaultActionViewConfigs, - listLocationItems: listLocationItemsActionConfig, - listLocations: listLocationsActionConfig, + listLocationItems: listLocationItemsHandler, }; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts index b94b3f540be..c3e14326cbf 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts @@ -1,11 +1,13 @@ export { ActionConfigsProvider, ActionConfigsProviderProps, - useActionConfig, + useActionConfigs, } from './context'; export { defaultActionConfigs, defaultActionViewConfigs, + DefaultActionViewType, isDefaultActionViewType, } from './defaults'; export * from './types'; +export { getActionConfigs } from './utils'; 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 52ae4082997..6815e905396 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts @@ -1,47 +1,33 @@ import { StorageBrowserIconType } from '../../context/elements'; -import { LocationPermissions } from '../../actions'; import { - ListLocationsHandler, - ListLocationItemsHandler, - LocationItemData, - LocationItemType, - UploadHandler, + CopyHandler, CreateFolderHandler, DeleteHandler, - CopyHandler, + DownloadHandler, + ListLocationItemsHandler, + ListLocations, + LocationItemData, + LocationPermissions, + TaskData, TaskHandler, + TaskHandlerInput, + TaskHandlerOutput, + UploadHandler, } from '../handlers'; +export type ActionHandler = TaskHandler< + TaskHandlerInput, + TaskHandlerOutput +>; + type StringWithoutSpaces = Exclude< T, ` ${string}` | `${string} ` | `${string} ${string}` >; -export type ComponentName = Capitalize<`${string}View`>; -type ActionName = StringWithoutSpaces; - -/** - * native OS file picker type. to restrict selectable file types, define the picker types - * followed by accepted file types as strings - * @example - * ```ts - * type JPEGOnly = ['FOLDER', '.jpeg']; - * ``` - */ -export type SelectionType = LocationItemType | [LocationItemType, ...string[]]; - -export interface ActionConfigTemplate { - /** - * The name of the component associated with the action - */ - componentName: ComponentName; - - /** - * action handler - */ - handler: T; -} +export type ViewName = Capitalize<`${string}View`>; +export type ActionName = StringWithoutSpaces; export interface ActionListItemConfig { /** @@ -50,11 +36,6 @@ export interface ActionListItemConfig { */ disable?: (selectedValues: LocationItemData[] | undefined) => boolean; - /** - * open native OS file picker with associated selection type on item select - */ - fileSelection?: SelectionType; - /** * conditionally render list item based on location permission * @default false @@ -76,92 +57,70 @@ export interface ActionListItemConfig { * defines an action to be included in the actions list of the `LocationDetailView` with * a dedicated subcomponent of the `LocationActionView` */ -export interface TaskActionConfig - extends ActionConfigTemplate { +export interface ActionViewConfig< + T extends ActionHandler = ActionHandler, + K extends ViewName = ViewName, +> { /** - * configure action list item behavior. provide multiple configs - * to create additional list items for a single action - */ - actionsListItemConfig?: ActionListItemConfig; - - /** - * whether the provided `handler` allow inflight cancellation - * @default false + * action handler */ - isCancelable?: boolean; + handler: T; /** - * show per task progress in the action task table - * @default false + * The view slot name associated with the action provided on the + * `StorageBrowser` through the `views` prop */ - includeProgress?: boolean; + viewName: K; /** - * default display name value displayed on action view + * configure action list item behavior. provide multiple configs + * to create additional list items for a single action */ - displayName: string; + actionListItem: ActionListItemConfig; } -export interface ListActionConfig extends ActionConfigTemplate {} - -export interface UploadActionConfig extends TaskActionConfig { - componentName: 'UploadView'; -} +export interface UploadActionConfig + extends ActionViewConfig {} -export interface DeleteActionConfig extends TaskActionConfig { - componentName: 'DeleteView'; -} +export interface DeleteActionConfig + extends ActionViewConfig {} -export interface CopyActionConfig extends TaskActionConfig { - componentName: 'CopyView'; -} +export interface CopyActionConfig + extends ActionViewConfig {} export interface CreateFolderActionConfig - extends TaskActionConfig { - componentName: 'CreateFolderView'; -} - -export interface ListLocationsActionConfig - extends ListActionConfig { - componentName: 'LocationsView'; - displayName: string; -} + extends ActionViewConfig {} -export interface ListLocationItemsActionConfig - extends ListActionConfig { - componentName: 'LocationDetailView'; - displayName: ( - bucket: string | undefined, - prefix: string | undefined - ) => string; +export interface ListActionConfig { + /** + * action handler + */ + handler: T; } export interface DefaultActionConfigs { - ListLocationItems: ListLocationItemsActionConfig; - ListLocations: ListLocationsActionConfig; - CreateFolder: CreateFolderActionConfig; - Upload: UploadActionConfig; - Delete: DeleteActionConfig; - Copy: CopyActionConfig; + createFolder?: CreateFolderActionConfig; + listLocationItems?: ListLocationItemsHandler; + upload?: UploadActionConfig; + delete?: DeleteActionConfig; + download?: DownloadHandler; + copy?: CopyActionConfig; } -export type DefaultActionKey = keyof DefaultActionConfigs; +export interface ExtendedDefaultActionConfigs + extends Required { + listLocations: ListLocations; +} -export type ActionConfigs = Record< - ActionsKeys, - | ListLocationItemsActionConfig - | ListLocationsActionConfig - | CreateFolderActionConfig - | UploadActionConfig - | TaskActionConfig +export type CustomActionConfigs = Record< + ActionName, + ActionViewConfig | ActionHandler >; -export type ResolveActionHandler = T extends - | TaskActionConfig - | ListActionConfig - ? K - : never; +export interface ExtendedActionConfigs { + default?: ExtendedDefaultActionConfigs; + custom?: CustomActionConfigs; +} -export type ResolveActionHandlers = { - [K in keyof T]: ResolveActionHandler; -}; +export type ActionViewConfigs = + Record; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/utils.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/utils.ts new file mode 100644 index 00000000000..5b54e968a59 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/utils.ts @@ -0,0 +1,19 @@ +import { isObject } from '@aws-amplify/ui'; +import { + ActionViewConfig, + ExtendedActionConfigs, + ActionViewConfigs, +} from './types'; + +const isActionConfig = (value: unknown): value is ActionViewConfig => + isObject(value); + +export const getActionConfigs = ( + configs: ExtendedActionConfigs +): ActionViewConfigs => { + return Object.entries({ ...configs.default, ...configs.custom }).reduce( + (configs: ActionViewConfigs, [type, config]) => + !isActionConfig(config) ? configs : { ...configs, [type]: config }, + {} + ); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts b/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts deleted file mode 100644 index 58e47777a8c..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { CreateUseAction } from './types'; - -export const createUseAction: CreateUseAction = (_) => { - // TODO: implement this function - throw new Error('Not implemented'); -}; 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 699ac5f19f3..4c7e9dabe8f 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 @@ -4,7 +4,6 @@ import { copyHandler, CopyHandlerInput } from '../copy'; jest.mock('../../../storage-internal'); const baseInput: CopyHandlerInput = { - destinationPrefix: 'destination/', config: { accountId: '012345678901', bucket: 'bucket', @@ -14,20 +13,21 @@ const baseInput: CopyHandlerInput = { }, data: { id: 'identity', - key: 'some-prefixfix/some-key.hehe', + key: 'destination/some-prefixfix/some-key.hehe', + sourceKey: 'some-prefixfix/some-key.hehe', fileKey: 'some-key.hehe', lastModified: new Date(), - size: 100000000, eTag: 'etag', - type: 'FILE', }, }; describe('copyHandler', () => { + const path = 'path'; + const mockCopy = jest.mocked(copy); beforeEach(() => { - mockCopy.mockResolvedValue({ path: '' }); + mockCopy.mockResolvedValue({ path }); }); afterEach(() => { @@ -38,20 +38,20 @@ describe('copyHandler', () => { copyHandler(baseInput); const bucket = { - bucketName: `${baseInput.config.bucket}`, - region: `${baseInput.config.region}`, + bucketName: baseInput.config.bucket, + region: baseInput.config.region, }; const expected: CopyInput = { destination: { expectedBucketOwner: baseInput.config.accountId, bucket, - path: `${baseInput.destinationPrefix}${baseInput.data.fileKey}`, + path: baseInput.data.key, }, source: { - expectedBucketOwner: `${baseInput.config.accountId}`, + expectedBucketOwner: baseInput.config.accountId, bucket, - path: baseInput.data.key, + path: baseInput.data.sourceKey, eTag: baseInput.data.eTag, notModifiedSince: baseInput.data.lastModified, }, @@ -76,7 +76,7 @@ describe('copyHandler', () => { expect(copyInput).toHaveProperty('source', { expectedBucketOwner: `${baseInput.config.accountId}`, bucket, - path: baseInput.data.key, + path: baseInput.data.sourceKey, eTag: baseInput.data.eTag, notModifiedSince: baseInput.data.lastModified, }); @@ -93,10 +93,7 @@ describe('copyHandler', () => { ])('encodes the source path that is %s', (_, sourcePath, expectedPath) => { copyHandler({ ...baseInput, - data: { - ...baseInput.data, - key: sourcePath, - }, + data: { ...baseInput.data, sourceKey: sourcePath }, }); const expected = expect.objectContaining({ @@ -111,7 +108,7 @@ describe('copyHandler', () => { it('returns a complete status', async () => { const { result } = copyHandler(baseInput); - expect(await result).toEqual({ status: 'COMPLETE' }); + expect(await result).toEqual({ status: 'COMPLETE', value: { key: path } }); }); it('returns failed status', async () => { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts index e7c83f2ff38..7b4277fd32d 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts @@ -18,16 +18,16 @@ const onProgress = jest.fn(); const baseInput: CreateFolderHandlerInput = { config, - data: { key: '', id: 'an-id' }, - destinationPrefix: 'prefix/', + data: { key: 'prefix/', id: 'an-id' }, }; describe('createFolderHandler', () => { + const path = 'path'; const mockUploadDataReturnValue = { cancel: jest.fn(), pause: jest.fn(), resume: jest.fn(), - result: Promise.resolve({ path: '' }), + result: Promise.resolve({ path }), state: 'SUCCESS' as const, }; const mockUploadData = jest.mocked(uploadData); @@ -46,11 +46,17 @@ describe('createFolderHandler', () => { it('behaves as expected in the happy path', async () => { const { result } = createFolderHandler(baseInput); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: path }, + }); }); it('calls `uploadData` with the expected values', () => { - createFolderHandler({ ...baseInput, options: { preventOverwrite: true } }); + createFolderHandler({ + ...baseInput, + data: { ...baseInput.data, preventOverwrite: true }, + }); const expected: UploadDataInput = { data: '', @@ -65,7 +71,7 @@ describe('createFolderHandler', () => { onProgress: expect.any(Function), preventOverwrite: true, }, - path: `${baseInput.destinationPrefix}${baseInput.data.key}`, + path: baseInput.data.key, }; expect(mockUploadData).toHaveBeenCalledWith(expected); @@ -83,7 +89,10 @@ describe('createFolderHandler', () => { options: { onProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: path }, + }); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith(baseInput.data, 1); @@ -101,7 +110,10 @@ describe('createFolderHandler', () => { options: { onProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: path }, + }); expect(onProgress).toHaveBeenCalledTimes(1); expect(onProgress).toHaveBeenCalledWith(baseInput.data, undefined); 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 82ad887b76c..98ca8f7d597 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 @@ -16,17 +16,16 @@ const baseInput: DeleteHandlerInput = { id: 'id', key: 'prefix/key.png', fileKey: 'key.png', - lastModified: new Date(), - size: 829292, - type: 'FILE', }, }; describe('deleteHandler', () => { + const path = 'path'; + const mockRemove = jest.mocked(remove); beforeEach(() => { - mockRemove.mockResolvedValue({ path: '' }); + mockRemove.mockResolvedValue({ path }); }); afterEach(() => { @@ -55,7 +54,7 @@ describe('deleteHandler', () => { it('returns a complete status', async () => { const { result } = deleteHandler(baseInput); - expect(await result).toEqual({ status: 'COMPLETE' }); + expect(await result).toEqual({ status: 'COMPLETE', value: { key: path } }); }); it('returns failed status', async () => { 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 046b68a5b32..f427408870f 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 @@ -16,9 +16,6 @@ const baseInput: DownloadHandlerInput = { id: 'id', key: 'prefix/file-name', fileKey: 'file-name', - lastModified: new Date(), - size: 1000022, - type: 'FILE', }, }; @@ -60,7 +57,7 @@ describe('downloadHandler', () => { it('returns a complete status', async () => { const { result } = downloadHandler(baseInput); - expect(await result).toEqual({ status: 'COMPLETE' }); + expect(await result).toEqual({ status: 'COMPLETE', value: { url } }); }); it('returns failed status', async () => { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts index 8d1692d84ee..a0c9640eecc 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts @@ -25,8 +25,7 @@ const file = new File([], 'test-o'); const baseInput: UploadHandlerInput = { config, - data: { key: file.name, id: 'an-id', file }, - destinationPrefix: 'prefix/', + data: { key: `'prefix/'${file.name}`, id: 'an-id', file }, }; const error = new Error('Failed!'); @@ -56,11 +55,17 @@ describe('uploadHandler', () => { it('behaves as expected in the happy path', async () => { const { result } = uploadHandler(baseInput); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); }); it('calls upload with the expected values', () => { - uploadHandler({ ...baseInput, options: { preventOverwrite: true } }); + uploadHandler({ + ...baseInput, + data: { ...baseInput.data, preventOverwrite: true }, + }); const expected: UploadDataInput = { data: file, @@ -76,7 +81,7 @@ describe('uploadHandler', () => { preventOverwrite: true, checksumAlgorithm: 'crc-32', }, - path: `${baseInput.destinationPrefix}${baseInput.data.key}`, + path: baseInput.data.key, }; expect(mockUploadData).toHaveBeenCalledWith(expected); @@ -94,7 +99,10 @@ describe('uploadHandler', () => { options: { onProgress: mockOnProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); expect(mockOnProgress).toHaveBeenCalledTimes(1); expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, 1); @@ -112,7 +120,10 @@ describe('uploadHandler', () => { options: { onProgress: mockOnProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); expect(mockOnProgress).toHaveBeenCalledTimes(1); expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, undefined); @@ -129,7 +140,10 @@ describe('uploadHandler', () => { data: { key: bigFile.name, id: 'hi!', file: bigFile }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); expect(callbacks).toStrictEqual({ cancel: expect.any(Function), @@ -146,7 +160,10 @@ describe('uploadHandler', () => { data: { key: smallFile.name, id: 'ohh', file: smallFile }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); expect(callbacks).toStrictEqual(UNDEFINED_CALLBACKS); }); @@ -196,7 +213,7 @@ describe('uploadHandler', () => { const { result } = uploadHandler({ ...baseInput, - options: { preventOverwrite: true }, + data: { ...baseInput.data, preventOverwrite: true }, }); expect(await result).toStrictEqual({ 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 5bd1e33ffce..f2152b8c741 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 @@ -8,7 +8,6 @@ import { getFilteredLocations, isFileItem, isFileDataItem, - createFileDataItemFromLocation, createFileDataItem, } from '../utils'; @@ -199,29 +198,6 @@ describe('utils', () => { }); }); - describe('createFileDataItemFromLocation', () => { - const location: LocationData = { - bucket: 'bucket', - id: 'id', - permissions: ['list', 'get'], - prefix: `prefix/${fileKey}`, - type: 'OBJECT', - }; - - it('creates a FileDataItem from location', () => { - expect(createFileDataItemFromLocation(location)).toStrictEqual( - expect.objectContaining({ - id: location.id, - type: 'FILE', - key: location.prefix, - fileKey, - lastModified: expect.any(Date), - size: 0, - }) - ); - }); - }); - describe('isFileItem', () => { it('should return true if object is FileItem', () => { expect(isFileItem({ file: {} })).toBe(true); 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 a214f44cfa0..062b7ce1524 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts @@ -1,6 +1,6 @@ import { copy, CopyInput } from '../../storage-internal'; import { - FileDataItem, + TaskData, TaskHandler, TaskHandlerInput, TaskHandlerOptions, @@ -9,40 +9,45 @@ import { import { constructBucket } from './utils'; -export interface CopyHandlerData extends FileDataItem {} +export interface CopyHandlerData extends TaskData { + sourceKey: string; + eTag?: string; + fileKey: string; + lastModified: Date; +} export interface CopyHandlerInput - extends TaskHandlerInput { - destinationPrefix: string; -} -export interface CopyHandlerOutput extends TaskHandlerOutput {} + extends TaskHandlerInput< + CopyHandlerData, + TaskHandlerOptions<{ key: string }> + > {} + +export interface CopyHandlerOutput extends TaskHandlerOutput<{ key: string }> {} export interface CopyHandler extends TaskHandler {} export const copyHandler: CopyHandler = (input) => { - const { config, destinationPrefix: path, data } = input; + const { config, data } = input; const { accountId: expectedBucketOwner, credentials, customEndpoint, } = config; - const { key: sourcePath, fileKey, lastModified, eTag } = data; + + const { key, sourceKey, lastModified, eTag } = data; const bucket = constructBucket(config); - const destinationPath = `${path}${fileKey}`; const source: CopyInput['source'] = { bucket, expectedBucketOwner, - /** - * Per S3 requirement, copy source must the URI encoded. - * This is NOT added to Amplify JS v6 because it will be a breaking - * change to suddenly introduce URI encode to copy API source. - * - * see: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_RequestSyntax - */ - path: sourcePath.split('/').map(encodeURIComponent).join('/'), + // Per S3 requirement, copy source must the URI encoded. + // This is NOT added to Amplify JS v6 because it will be a breaking + // change to suddenly introduce URI encode to copy API source. + // + // see: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_RequestSyntax + path: sourceKey.split('/').map(encodeURIComponent).join('/'), notModifiedSince: lastModified, eTag, }; @@ -50,7 +55,7 @@ export const copyHandler: CopyHandler = (input) => { const destination: CopyInput['destination'] = { bucket, expectedBucketOwner, - path: destinationPath, + path: key, }; const result = copy({ @@ -61,7 +66,10 @@ export const copyHandler: CopyHandler = (input) => { return { result: result - .then(() => ({ status: 'COMPLETE' as const })) - .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })), + .then(({ path }) => ({ + status: 'COMPLETE' as const, + value: { key: path }, + })) + .catch(({ message }: Error) => ({ message, status: 'FAILED' })), }; }; 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 4ba599a96c4..961bcf90cd4 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts @@ -1,5 +1,5 @@ -import { uploadData } from '../../storage-internal'; import { isFunction } from '@aws-amplify/ui'; +import { uploadData } from '../../storage-internal'; import { TaskData, @@ -10,34 +10,34 @@ import { } from './types'; import { constructBucket, getProgress } from './utils'; -export interface CreateFolderHandlerData extends TaskData {} -export interface CreateFolderHandlerOptions extends TaskHandlerOptions { +export interface CreateFolderHandlerData extends TaskData { preventOverwrite?: boolean; } +export interface CreateFolderHandlerOptions + extends TaskHandlerOptions<{ key: string }> {} export interface CreateFolderHandlerInput extends TaskHandlerInput< CreateFolderHandlerData, CreateFolderHandlerOptions - > { - destinationPrefix: string; -} + > {} -export interface CreateFolderHandlerOutput extends TaskHandlerOutput {} +export interface CreateFolderHandlerOutput + extends TaskHandlerOutput<{ key: string }> {} export interface CreateFolderHandler extends TaskHandler {} export const createFolderHandler: CreateFolderHandler = (input) => { - const { destinationPrefix, config, data, options } = input; + const { config, data, options } = input; const { accountId, credentials, customEndpoint } = config; - const { onProgress, preventOverwrite } = options ?? {}; - const { key } = data; + const { onProgress } = options ?? {}; + const { key, preventOverwrite } = data; const bucket = constructBucket(config); const { result } = uploadData({ - path: `${destinationPrefix}${key}`, + path: key, data: '', options: { bucket, @@ -53,12 +53,15 @@ export const createFolderHandler: CreateFolderHandler = (input) => { return { result: result - .then(() => ({ status: 'COMPLETE' as const })) + .then(({ path }) => ({ + status: 'COMPLETE' as const, + value: { key: path }, + })) .catch(({ message, name }: Error) => { if (name === 'PreconditionFailed') { - return { message, status: 'OVERWRITE_PREVENTED' } as const; + return { message, status: 'OVERWRITE_PREVENTED' }; } - return { message, status: 'FAILED' as const }; + return { message, status: 'FAILED' }; }), }; }; 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 7ce1b4aaf22..8b312a609bd 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts @@ -5,27 +5,31 @@ import { TaskHandlerOptions, TaskHandlerInput, TaskHandlerOutput, - FileDataItem, + TaskData, } from './types'; import { constructBucket } from './utils'; export interface DeleteHandlerOptions extends TaskHandlerOptions {} -export interface DeleteHandlerData extends FileDataItem {} +export interface DeleteHandlerData extends TaskData { + fileKey: string; +} export interface DeleteHandlerInput extends TaskHandlerInput {} -export interface DeleteHandlerOutput extends TaskHandlerOutput {} +export interface DeleteHandlerOutput + extends TaskHandlerOutput<{ key: string }> {} export interface DeleteHandler extends TaskHandler {} export const deleteHandler: DeleteHandler = ({ config, - data: { key }, + data, }): DeleteHandlerOutput => { + const { key } = data; const { accountId, credentials, customEndpoint } = config; const result = remove({ @@ -36,11 +40,12 @@ export const deleteHandler: DeleteHandler = ({ expectedBucketOwner: accountId, customEndpoint, }, - }); - - return { - result: result - .then(() => ({ status: 'COMPLETE' as const })) - .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })), - }; + }) + .then(({ path }) => ({ + status: 'COMPLETE' as const, + value: { key: path }, + })) + .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })); + + return { result }; }; 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 63cb0f7e3ac..49425231659 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts @@ -1,6 +1,6 @@ import { getUrl } from '../../storage-internal'; import { - FileDataItem, + TaskData, TaskHandler, TaskHandlerInput, TaskHandlerOptions, @@ -9,13 +9,17 @@ import { import { constructBucket } from './utils'; -export interface DownloadHandlerData extends FileDataItem {} +export interface DownloadHandlerData extends TaskData { + fileKey: string; +} + export interface DownloadHandlerOptions extends TaskHandlerOptions {} export interface DownloadHandlerInput extends TaskHandlerInput {} -export interface DownloadHandlerOutput extends TaskHandlerOutput {} +export interface DownloadHandlerOutput + extends TaskHandlerOutput<{ url: URL }> {} export interface DownloadHandler extends TaskHandler {} @@ -49,16 +53,12 @@ export const downloadHandler: DownloadHandler = ({ contentDisposition: 'attachment', expectedBucketOwner: accountId, }, - }).then((result) => { - return result; - }); + }) + .then(({ url }) => { + downloadFromUrl(key, url.toString()); + return { status: 'COMPLETE' as const, value: { url } }; + }) + .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })); - return { - result: result - .then(({ url }) => { - downloadFromUrl(key, url.toString()); - return { status: 'COMPLETE' as const }; - }) - .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })), - }; + return { result }; }; 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 25fd7df1abc..2243bbb39f6 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts @@ -80,11 +80,16 @@ export interface TaskData { id: string; } -export interface TaskHandlerOptions { +export interface TaskHandlerOptions { onProgress?: ( data: { key: string; id: string }, progress: number | undefined ) => void; + onSuccess?: (data: { key: string; id: string }, value: V) => void; + onError?: ( + data: { key: string; id: string }, + message: string | undefined + ) => void; } export interface TaskHandlerInput< @@ -96,11 +101,12 @@ export interface TaskHandlerInput< options?: K; } -export interface TaskHandlerOutput { +export interface TaskHandlerOutput { cancel?: () => void; result: Promise<{ message?: string; status: 'CANCELED' | 'COMPLETE' | 'FAILED' | 'OVERWRITE_PREVENTED'; + value?: K; }>; } 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 d42b7b7ef38..a61d3bef823 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts @@ -13,20 +13,19 @@ import { import { constructBucket, getProgress } from './utils'; -export interface UploadHandlerOptions extends TaskHandlerOptions { - preventOverwrite?: boolean; -} +export interface UploadHandlerOptions + extends TaskHandlerOptions<{ key: string }> {} export interface UploadHandlerData extends TaskData { file: File; + preventOverwrite?: boolean; } export interface UploadHandlerInput - extends TaskHandlerInput { - destinationPrefix: string; -} + extends TaskHandlerInput {} -export interface UploadHandlerOutput extends TaskHandlerOutput {} +export interface UploadHandlerOutput + extends TaskHandlerOutput<{ key: string }> {} export interface UploadHandler extends TaskHandler {} @@ -35,24 +34,21 @@ export interface UploadHandler // https://github.com/aws-amplify/amplify-js/blob/1a5366d113c9af4ce994168653df3aadb142c581/packages/storage/src/providers/s3/utils/constants.ts#L16 export const MULTIPART_UPLOAD_THRESHOLD_BYTES = 5 * 1024 * 1024; +export const DEFAULT_CHECKSUM_ALGORITHM = 'crc-32'; + export const UNDEFINED_CALLBACKS = { cancel: undefined, pause: undefined, resume: undefined, }; -export const uploadHandler: UploadHandler = ({ - config, - data, - destinationPrefix, - options, -}) => { +export const uploadHandler: UploadHandler = ({ config, data, options }) => { const { accountId, credentials, customEndpoint } = config; - const { key, file } = data; - const { onProgress, preventOverwrite } = options ?? {}; + const { key, file, preventOverwrite } = data; + const { onProgress } = options ?? {}; const input: UploadDataInput = { - path: `${destinationPrefix}${key}`, + path: key, data: file, options: { bucket: constructBucket(config), @@ -63,7 +59,7 @@ export const uploadHandler: UploadHandler = ({ }, preventOverwrite, customEndpoint, - checksumAlgorithm: 'crc-32', + checksumAlgorithm: DEFAULT_CHECKSUM_ALGORITHM, }, }; @@ -74,11 +70,14 @@ export const uploadHandler: UploadHandler = ({ ? { cancel, pause, resume } : UNDEFINED_CALLBACKS), result: result - .then(() => ({ status: 'COMPLETE' as const })) + .then((output) => ({ + status: 'COMPLETE' as const, + value: { key: output.path }, + })) .catch((error: Error) => { const { message } = error; if (error.name === 'PreconditionFailed') { - return { message, status: 'OVERWRITE_PREVENTED' as const }; + return { message, status: 'OVERWRITE_PREVENTED' }; } return { message, 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 99610f61541..70324c50665 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts @@ -146,19 +146,6 @@ export const createFileDataItem = (data: FileData): FileDataItem => ({ fileKey: getFileKey(data.key), }); -export const createFileDataItemFromLocation = ( - data: LocationData -): FileDataItem => ({ - id: data.id, - type: 'FILE', - key: data.prefix, - fileKey: getFileKey(data.prefix), - // `lastModified` and `size` included to satisfy - // expected shape of `FileDataItem` - lastModified: new Date(), - size: 0, -}); - export const isFileItem = (value: unknown): value is FileItem => !!(value as FileItem).file; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/index.ts index a6fb6f1f009..4e45cc64cb6 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/index.ts @@ -1,18 +1,3 @@ -export { - ActionConfigs, - ActionConfigsProvider, - ActionConfigsProviderProps, - ComponentName, - defaultActionConfigs, - DefaultActionConfigs, - defaultActionViewConfigs, - DefaultActionKey, - isDefaultActionViewType, - SelectionType, - TaskActionConfig, - useActionConfig, -} from './configs'; - export { ActionInputConfig, copyHandler, @@ -20,7 +5,6 @@ export { CopyHandlerData, CopyHandlerInput, CopyHandlerOutput, - createFileDataItemFromLocation, createFileDataItem, createFolderHandler, CreateFolderHandler, @@ -65,6 +49,7 @@ export { ListLocationsHandlerOutput, LocationData, LocationItemData, + LocationItemType, LocationPermissions, LocationType, TaskData, @@ -80,6 +65,18 @@ export { UploadHandlerOutput, } from './handlers'; -export { ActionState } from './types'; - -export { useListLocations, UseListLocationsState } from './useAction'; +export { + ExtendedActionConfigs, + ActionViewConfig, + ActionViewConfigs, + ActionConfigsProvider, + ActionConfigsProviderProps, + ActionHandler, + CustomActionConfigs, + defaultActionConfigs, + DefaultActionConfigs, + defaultActionViewConfigs, + getActionConfigs, + isDefaultActionViewType, + useActionConfigs, +} from './configs'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/types.ts deleted file mode 100644 index 7a09f7e1089..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/actions/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { DataState } from '@aws-amplify/ui-react-core'; - -import { ListHandler, TaskHandler } from './handlers'; - -export type ActionState = [ - state: DataState, - handleAction: (...input: K[]) => void, -]; - -export type UseAction = T extends - | ListHandler - | TaskHandler - ? ActionState - : never; - -export type CreateUseAction = ( - actions: T -) => (key: K) => UseAction; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts deleted file mode 100644 index 8b7ee5535f2..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useListLocations, UseListLocationsState } from './useListLocations'; diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts b/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts index b29d4d0990d..155cb1a0eae 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts @@ -1,4 +1,4 @@ -import { LocationPermissions } from '../actions/handlers/types'; +import { LocationPermissions } from '../actions'; import { Permission, StorageAccess } from '../storage-internal'; export const parseAccessGrantPermission = ( diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx index 8516fc1dc1c..3a0e65d5d2f 100644 --- a/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx +++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { DropdownMenu } from '../components/DropdownMenu'; import { StorageBrowserIconType } from '../context/elements'; -export interface ActionsListItem { +export interface ActionListItem { isDisabled?: boolean; isHidden?: boolean; icon?: StorageBrowserIconType; @@ -13,7 +13,7 @@ export interface ActionsListItem { export interface ActionsListProps { isDisabled?: boolean; - items: ActionsListItem[]; + items: ActionListItem[]; onActionSelect?: (id: string) => void; } diff --git a/packages/react-storage/src/components/StorageBrowser/controls/types.ts b/packages/react-storage/src/components/StorageBrowser/controls/types.ts index 9cda3ac6af9..527846622b8 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/types.ts @@ -1,5 +1,5 @@ import { LocationData } from '../actions'; -import { ActionsListItem } from '../composables/ActionsList'; +import { ActionListItem } from '../composables/ActionsList'; import { DataTableSortHeader, DataTableProps } from '../composables/DataTable'; import { MessageProps } from '../composables/Message'; import { Composables } from '../composables/types'; @@ -36,7 +36,7 @@ interface PaginationData { export interface ControlsContext { data: { - actions?: ActionsListItem[]; + actions?: ActionListItem[]; actionCancelLabel?: string; actionDestinationLabel?: string; actionExitLabel?: string; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx index d96d9af62ae..d5a84badabd 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx @@ -1,5 +1,12 @@ import React from 'react'; +import { + defaultActionConfigs, + getActionConfigs, + ActionConfigsProvider, + ExtendedActionConfigs, +} from './actions'; + import { DEFAULT_COMPOSABLES } from './composables'; import { elementsDefault } from './context/elements'; import { ComponentsProvider } from './ComponentsProvider'; @@ -17,28 +24,33 @@ import { LocationDetailView, LocationsView, UploadView, - ViewsProvider, + LocationActionViewType, } from './views'; -import { defaultActionConfigs } from './actions'; +import { useView } from './views/useView'; +import { ViewsProvider } from './views/context'; import { DisplayTextProvider } from './displayText'; -import { createUseView } from './views/createUseView'; import { CreateStorageBrowserInput, + CreateStorageBrowserOutput, StorageBrowserProviderProps, StorageBrowserType, + DerivedCustomViews, + DerivedActionViewType, } from './types'; - -export function createStorageBrowser(input: CreateStorageBrowserInput): { - StorageBrowser: StorageBrowserType< - keyof Omit< - typeof defaultActionConfigs, - 'listLocationItems' | 'listLocations' - > - >; - useView: ReturnType>; -} { +import { + getActionHandlers, + ActionHandlersProvider, + useAction, +} from './useAction'; + +export function createStorageBrowser< + Input extends CreateStorageBrowserInput, + RInput extends Input['actions'] extends ExtendedActionConfigs + ? Input['actions'] + : ExtendedActionConfigs, +>(input: Input): CreateStorageBrowserOutput { assertRegisterAuthListener(input.config.registerAuthListener); const { @@ -49,16 +61,22 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { region, } = input.config; - const ConfigurationProvider = createConfigurationProvider({ - accountId, - actions: { + const actions = { + default: { ...defaultActionConfigs, - // @ts-expect-error To be addressed with line 40 - listLocations: { - componentName: 'LocationsView', - handler: input.config.listLocations, - }, + ...input.actions?.default, + // always last + listLocations: input.config.listLocations, }, + custom: input.actions?.custom, + }; + + const handlers = getActionHandlers(actions); + + const actionConfigs = getActionConfigs(actions); + + const ConfigurationProvider = createConfigurationProvider({ + accountId, customEndpoint, displayName: 'ConfigurationProvider', getLocationCredentials, @@ -79,34 +97,47 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { * Provides state, configuration and action values that are shared between * the primary View components */ - function Provider({ children, ...props }: StorageBrowserProviderProps) { + function Provider({ + children, + displayText, + views, + ...props + }: StorageBrowserProviderProps) { return ( - - - {children} - - + + + + + + {children} + + + + + ); } - const StorageBrowser: StorageBrowserType = ({ views, displayText }) => ( + const StorageBrowser: StorageBrowserType< + DerivedActionViewType, + DerivedCustomViews + > = ({ views, displayText }) => ( - - - - + + ); - StorageBrowser.LocationActionView = LocationActionView; + StorageBrowser.LocationActionView = + LocationActionView as LocationActionViewType>; StorageBrowser.LocationDetailView = LocationDetailView; StorageBrowser.LocationsView = LocationsView; @@ -119,7 +150,5 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { StorageBrowser.displayName = 'StorageBrowser'; - const useView = createUseView(defaultActionConfigs); - - return { StorageBrowser, useView }; + return { StorageBrowser, useAction, useView }; } diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/scenarios.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/scenarios.ts index 0fe65c66c57..263781167e8 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/scenarios.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/scenarios.ts @@ -226,7 +226,7 @@ export const LIST_FOLDERS_SCENARIOS: [ export const LIST_LOCATIONS_SCENARIOS: [ string, { - locations: LocationData[] | undefined; + items: LocationData[] | undefined; query?: string; hasError?: boolean; message?: string; @@ -234,22 +234,22 @@ export const LIST_LOCATIONS_SCENARIOS: [ hasExhaustedSearch?: boolean; }, ][] = [ - ['empty results', { locations: [] }], + ['empty results', { items: [] }], [ 'failed', { // @ts-expect-error pretend folders - locations: [...Array(101).keys()], + items: [...Array(101).keys()], hasError: true, message: 'Network got confused', }, ], - ['empty search results', { locations: [], query: 'something to look for' }], + ['empty search results', { items: [], query: 'something to look for' }], [ 'search failed', { // @ts-expect-error pretend folders - locations: [...Array(101).keys()], + items: [...Array(101).keys()], query: 'something to look for', hasError: true, message: 'Network got confused', @@ -259,7 +259,7 @@ export const LIST_LOCATIONS_SCENARIOS: [ 'search limit exhausted', { // @ts-expect-error pretend folders - locations: [...Array(10000).keys()], + items: [...Array(10000).keys()], query: 'something to look for', hasExhaustedSearch: true, }, @@ -267,7 +267,7 @@ export const LIST_LOCATIONS_SCENARIOS: [ [ 'loading', { - locations: [], + items: [], isLoading: true, hasExhaustedSearch: false, }, diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationsView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationsView.ts index bac3e3db1d1..46523509339 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationsView.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationsView.ts @@ -12,7 +12,7 @@ export const DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT: DefaultLocationsViewDisplayTex getListLocationsResultMessage: (data) => { const { isLoading, - locations, + items, hasExhaustedSearch, hasError = false, message, @@ -29,7 +29,7 @@ export const DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT: DefaultLocationsViewDisplayTex }; } - if (locations?.length === 0 && !hasExhaustedSearch) { + if (items?.length === 0 && !hasExhaustedSearch) { return { type: 'info', content: 'No folders or files.', diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts index ad509473d57..b9cdc5ec74d 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts @@ -34,7 +34,7 @@ interface ListMessageData { } interface ListLocationsMessageData extends ListMessageData { - locations: LocationData[] | undefined; + items: LocationData[] | undefined; } export interface DefaultLocationsViewDisplayText diff --git a/packages/react-storage/src/components/StorageBrowser/index.ts b/packages/react-storage/src/components/StorageBrowser/index.ts index 8fd0012a095..99fc9a5c94f 100644 --- a/packages/react-storage/src/components/StorageBrowser/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/index.ts @@ -1,5 +1,6 @@ export { componentsDefault } from './componentsDefault'; export { createStorageBrowser } from './createStorageBrowser'; +export { ActionViewConfig, ActionHandler, FileDataItem } from './actions'; export { createAmplifyAuthAdapter, createManagedAuthAdapter, diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx b/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx index 255065c8c7c..b2deeac27d3 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx @@ -1,5 +1,4 @@ import React from 'react'; - import { createContextUtilities } from '@aws-amplify/ui-react-core'; import { GetActionInputProviderProps, GetActionInput } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/createConfigurationProvider.tsx b/packages/react-storage/src/components/StorageBrowser/providers/configuration/createConfigurationProvider.tsx index 07b865df2bb..057bdf4daf2 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/createConfigurationProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/createConfigurationProvider.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { isComponent } from '@aws-amplify/ui-react-core/elements'; -import { ActionConfigsProvider } from '../../actions'; import { CredentialsProvider } from './credentials'; import { GetActionInputProvider } from './context'; @@ -19,7 +18,6 @@ export function createConfigurationProvider>( ): ConfigurationProviderComponent { const { accountId, - actions, ChildComponent, displayName, region, @@ -30,17 +28,15 @@ export function createConfigurationProvider>( const Child = isComponent(ChildComponent) ? ChildComponent : Passthrough; const Provider: ConfigurationProviderComponent = (props) => ( - - - - - - - + + + + + ); Provider.displayName = displayName; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts index a0a4778832f..cec83a42712 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts @@ -1,4 +1,4 @@ -export { useGetActionInput } from './context'; export { createConfigurationProvider } from './createConfigurationProvider'; +export { useGetActionInput } from './context'; export { CredentialsProviderProps, RegisterAuthListener } from './credentials'; export { GetActionInput } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/types.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/types.ts index ff61e62920a..b5caa3d11b4 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/types.ts @@ -1,10 +1,6 @@ import React from 'react'; -import { - ActionInputConfig, - ActionConfigsProviderProps, - LocationData, -} from '../../actions'; +import { ActionInputConfig, LocationData } from '../../actions'; import { CredentialsProviderProps } from './credentials'; @@ -19,8 +15,7 @@ export interface GetActionInputProviderProps { export interface CreateConfigurationProviderInput< T extends React.ComponentType, -> extends ActionConfigsProviderProps, - GetActionInputProviderProps, +> extends GetActionInputProviderProps, CredentialsProviderProps { ChildComponent?: T; displayName: string; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts index 3a55a13809e..82548f88df9 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts @@ -25,19 +25,21 @@ export function useGetActionInputCallback({ const { current, key } = location; return React.useCallback( - (location?: LocationData) => { + (_location?: LocationData) => { // prefer passed in location / prefix over current location in state - const _location = location ?? current; + const location = _location ?? current; // when `location` has been provided as a param, resolve `_prefix` to `location.prefix`. // in the default scenario where `current` is the target `location` use the fully qualified `key` // that includes the default `prefix` and any additional prefixes from navigation - const _prefix = location ? location.prefix : key; - assertLocationData(_location, getErrorMessage('locationData')); - assertPrefix(_prefix, getErrorMessage('prefix')); + const prefix = _location ? _location.prefix : key; - const { bucket, permissions, type } = _location; + assertLocationData(location, getErrorMessage('locationData')); + + assertPrefix(prefix, getErrorMessage('prefix')); + + const { bucket, permissions, type } = location; // BUCKET/PREFIX grants end with `*`, but object grants do not. - const scope = `s3://${bucket}/${_prefix}${type === 'OBJECT' ? '' : '*'}`; + const scope = `s3://${bucket}/${prefix}${type === 'OBJECT' ? '' : '*'}`; return { accountId, diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts index b4076f19274..14772ff6e27 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts @@ -1,4 +1,14 @@ -import { SelectionType, TaskData } from '../../../actions'; +import { LocationItemType, TaskData } from '../../../actions'; + +/** + * native OS file picker type. to restrict selectable file types, define the picker types + * followed by accepted file types as strings + * @example + * ```ts + * type JPEGOnly = ['FOLDER', '.jpeg']; + * ``` + */ +export type SelectionType = LocationItemType | [LocationItemType, ...string[]]; export type FilesActionType = | { type: 'ADD_FILE_ITEMS'; files?: File[] } diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts index f093f5f7c21..357f2dcb4ec 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts @@ -3,9 +3,7 @@ import React from 'react'; import { isEmpty, isString, isUndefined } from '@aws-amplify/ui'; import { HandleFileSelect } from '@aws-amplify/ui-react/internal'; -import { SelectionType } from '../../../actions/configs'; - -import { FileItem, FileItems, FilesActionType } from './types'; +import { FileItem, FileItems, FilesActionType, SelectionType } from './types'; const compareFileItems = (prev: FileItem, next: FileItem) => prev.key.localeCompare(next.key); 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 11bb53d58b8..4e7c5bd63e9 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 @@ -17,8 +17,6 @@ const config: ActionInputConfig = { region: 'region', }; -const prefix = 'prefix'; - const items: FileItem[] = [ { key: '0', id: '0', file: new File([], '0') }, { key: '1', id: '1', file: new File([], '1') }, @@ -32,7 +30,7 @@ const action = jest.fn( }: TaskHandlerInput< FileItem, TaskHandlerOptions & { extraOption?: boolean } - > & { prefix: string }): TaskHandlerOutput => { + >): TaskHandlerOutput => { const { key } = data; // initial progress options?.onProgress?.(data, 0.5); @@ -80,7 +78,7 @@ const createTimedAction = ms?: number; resolvedStatus?: 'COMPLETE' | 'FAILED' | 'CANCELED' | 'OVERWRITE_PREVENTED'; shouldReject?: boolean; - }): ((input: TaskHandlerInput & { prefix: string }) => TaskHandlerOutput) => + }): ((input: TaskHandlerInput) => TaskHandlerOutput) => () => ({ cancel, pause: undefined, @@ -113,7 +111,7 @@ describe('useProcessTasks', () => { expect(result.current[0].tasks[2].status).toBe('QUEUED'); act(() => { - processTasks({ config, prefix }); + processTasks({ config }); }); expect(action).toHaveBeenCalledTimes(2); @@ -121,13 +119,11 @@ describe('useProcessTasks', () => { config, data: { key: items[0].key, id: items[0].id, file: items[0].file }, options: { onProgress: expect.any(Function) }, - prefix, }); expect(action).toHaveBeenCalledWith({ config, data: { key: items[1].key, id: items[1].id, file: items[1].file }, options: { onProgress: expect.any(Function) }, - prefix, }); expect(result.current[0].tasks[0].status).toBe('PENDING'); @@ -164,7 +160,7 @@ describe('useProcessTasks', () => { expect(result.current[0].tasks[0].status).toBe('QUEUED'); act(() => { - processTasks({ config, prefix }); + processTasks({ config }); }); expect(result.current[0].tasks[0].cancel).toBeDefined(); @@ -230,7 +226,7 @@ describe('useProcessTasks', () => { expect(result.current[0].tasks[0].status).toBe('QUEUED'); act(() => { - processTasks({ config, prefix }); + processTasks({ config }); }); expect(result.current[0].tasks[0].status).toBe('PENDING'); @@ -265,7 +261,8 @@ describe('useProcessTasks', () => { expect(result.current[0].tasks[2].status).toBe('QUEUED'); act(() => { - processTasks({ config, prefix, options: { extraOption: true } }); + // @ts-expect-error options typing is broken right now + processTasks({ config, options: { extraOption: true } }); }); expect(action).toHaveBeenCalledTimes(1); @@ -273,7 +270,6 @@ describe('useProcessTasks', () => { config, data: { key: items[0].key, id: items[0].id, file: items[0].file }, options: { extraOption: true, onProgress: expect.any(Function) }, - prefix, }); expect(result.current[0].tasks[0].status).toBe('PENDING'); @@ -321,7 +317,7 @@ describe('useProcessTasks', () => { expect(initState.tasks.length).toBe(3); act(() => { - handleProcess({ config, prefix }); + handleProcess({ config }); }); const nextItems = [items[1], items[2]]; @@ -358,7 +354,7 @@ describe('useProcessTasks', () => { expect(initState.isProcessingComplete).toBe(false); act(() => { - handleProcess({ config, prefix }); + handleProcess({ config }); }); const [processingState] = result.current; @@ -388,7 +384,7 @@ describe('useProcessTasks', () => { expect(initState.tasks[0].cancel).toBeDefined(); act(() => { - handleProcess({ config, prefix }); + handleProcess({ config }); }); const [processingState] = result.current; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts index 9b7a67ae20e..afc82c94d83 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts @@ -12,6 +12,7 @@ export type StatusCounts = Record; export interface ProcessTasksOptions< T extends TaskData = TaskData, + V = any, U extends number | never = never, > { concurrency?: U; @@ -19,32 +20,33 @@ export interface ProcessTasksOptions< onTaskComplete?: (data: Task) => void; onTaskError?: (data: Task, error: Error | undefined) => void; onTaskProgress?: (data: Task, progress: number | undefined) => void; - onTaskSuccess?: (data: Task) => void; + onTaskSuccess?: (data: Task, value: V | undefined) => void; onTaskRemove?: (data: Task) => void; } -export interface Task { +export interface Task { data: T; message: string | undefined; progress: number | undefined; status: TaskStatus; cancel?: () => void; + value?: V; } -export type Tasks = Task[]; +export type Tasks = Task[]; -export type HandleProcessTasks = ( - input: U extends T[] - ? Omit & K, 'data'> - : TaskHandlerInput & K +export type HandleProcessTasks = ( + input: U extends T[] ? Omit, 'data'> : TaskHandlerInput ) => void; -export type UseProcessTasksState = [ - { - isProcessing: boolean; - isProcessingComplete: boolean; - statusCounts: StatusCounts; - tasks: Tasks; - }, - HandleProcessTasks, +export interface TasksState { + isProcessing: boolean; + isProcessingComplete: boolean; + reset: () => void; + statusCounts: StatusCounts; + tasks: Tasks; +} +export type UseProcessTasksState = [ + TasksState, + HandleProcessTasks, ]; diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts index bf19288f1d1..296425674c9 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts @@ -1,16 +1,13 @@ import React from 'react'; -import { - TaskHandlerInput, - TaskData, - TaskHandlerOutput, - TaskHandler, -} from '../actions'; +import { ActionHandler, TaskHandlerInput, TaskData } from '../actions'; + +import { isFunction } from '@aws-amplify/ui'; import { HandleProcessTasks, - ProcessTasksOptions, Task, + ProcessTasksOptions, UseProcessTasksState, } from './types'; import { @@ -18,17 +15,6 @@ import { isProcessingTasks, hasCompletedProcessingTasks, } from './utils'; -import { isFunction } from '@aws-amplify/ui'; - -export type UseProcessTasks = < - T extends TaskData, - K, - D extends T[] | undefined, ->( - handler: TaskHandler & K, TaskHandlerOutput>, - items?: D, - options?: ProcessTasksOptions -) => UseProcessTasksState; const QUEUED_TASK_BASE = { cancel: undefined, @@ -37,21 +23,24 @@ const QUEUED_TASK_BASE = { status: 'QUEUED' as const, }; -const isTaskHandlerInput = ( - input: TaskHandlerInput | Omit -): input is TaskHandlerInput => !!(input as TaskHandlerInput).data; +const isTaskHandlerInput = ( + input: TaskHandlerInput | Omit, 'data'> +): input is TaskHandlerInput => !!(input as TaskHandlerInput).data; -export const useProcessTasks: UseProcessTasks = < - T extends TaskData, - // input params not included in `TaskHandlerInput` - K, - // infered value of `items` for conditional typing of `concurrency - D extends T[] | undefined, +export const useProcessTasks = < + TData extends TaskData = TaskData, + RValue = any, + // infered value of `items` for conditional typing of `concurrency` + D extends TData[] | undefined = undefined, >( - handler: TaskHandler & K, TaskHandlerOutput>, + handler: ActionHandler, items?: D, - options?: ProcessTasksOptions -): UseProcessTasksState => { + options?: ProcessTasksOptions< + TData, + RValue, + D extends TData[] ? number : never + > +): UseProcessTasksState => { const { concurrency, ...callbacks } = options ?? {}; const callbacksRef = React.useRef(callbacks); @@ -60,12 +49,20 @@ export const useProcessTasks: UseProcessTasks = < callbacksRef.current = callbacks; } - const tasksRef = React.useRef>>(new Map()); + const tasksRef = React.useRef>>(new Map()); const flush = React.useReducer(() => ({}), {})[1]; + const refreshTaskData = React.useCallback((id: string, data: TData) => { + const task = tasksRef.current.get(id); + + if (!task || task.data.id !== data.id) return; + + tasksRef.current.set(id, { ...task, data }); + }, []); + const updateTask = React.useCallback( - (id: string, next?: Partial>) => { + (id: string, next?: Partial>) => { const { onTaskRemove } = callbacksRef.current; const task = tasksRef.current.get(id); @@ -84,7 +81,7 @@ export const useProcessTasks: UseProcessTasks = < ); const createTask = React.useCallback( - (data: T) => { + (data: TData) => { const getTask = () => tasksRef.current.get(data.id); const { onTaskCancel } = callbacksRef.current; @@ -109,10 +106,12 @@ export const useProcessTasks: UseProcessTasks = < taskLookup[data.id] = true; }); - items?.forEach((item) => { + items?.forEach((item: TData) => { if (!taskLookup[item.id]) { // If an item doesn't yet have a task created for it, create one createTask(item); + } else { + refreshTaskData(item.id, item); } // Remove the item from the lookup to mark it as "synced" delete taskLookup[item.id]; @@ -126,9 +125,9 @@ export const useProcessTasks: UseProcessTasks = < }); flush(); - }, [createTask, flush, updateTask, items]); + }, [createTask, flush, updateTask, items, refreshTaskData]); - const processNextTask: HandleProcessTasks = (_input) => { + const processNextTask: HandleProcessTasks = (_input) => { const hasInputData = isTaskHandlerInput(_input); if (hasInputData) { createTask(_input.data); @@ -153,21 +152,27 @@ export const useProcessTasks: UseProcessTasks = < const getTask = () => tasksRef.current.get(data.id); - const onProgress = ({ id }: T, progress?: number) => { + const { options } = _input; + const { onProgress: _onProgress, onSuccess, onError } = options ?? {}; + + const onProgress = ({ id }: TData, progress?: number) => { const task = getTask(); if (task && isFunction(onTaskProgress)) { onTaskProgress(task, progress); } + if (task && isFunction(_onProgress)) { + _onProgress(data, progress); + } + updateTask(id, { progress }); }; - const { options } = _input; const input = { ..._input, data, options: { ...options, onProgress } }; const { cancel: _cancel, result } = handler( - input as TaskHandlerInput & K + input as TaskHandlerInput ); const cancel = !_cancel @@ -181,7 +186,12 @@ export const useProcessTasks: UseProcessTasks = < result .then((output) => { const task = getTask(); - if (task && isFunction(onTaskSuccess)) onTaskSuccess(task); + + if (task && isFunction(onTaskSuccess)) { + onTaskSuccess(task, output?.value); + } + + if (task && isFunction(onSuccess)) onSuccess(data, output?.value); updateTask(data.id, output); }) @@ -189,6 +199,8 @@ export const useProcessTasks: UseProcessTasks = < const task = getTask(); if (task && isFunction(onTaskError)) onTaskError(task, e); + if (task && isFunction(onError)) onError(data, e?.message); + updateTask(data.id, { message: e.message, status: 'FAILED' }); }) .finally(() => { @@ -209,7 +221,7 @@ export const useProcessTasks: UseProcessTasks = < const isProcessing = isProcessingTasks(statusCounts); const isProcessingComplete = hasCompletedProcessingTasks(statusCounts); - const handleProcessTasks: HandleProcessTasks = (input) => { + const handleProcessTasks: HandleProcessTasks = (input) => { if (isProcessing) { return; } @@ -226,8 +238,12 @@ export const useProcessTasks: UseProcessTasks = < } }; + const reset = () => { + tasks.forEach(({ data }) => updateTask(data.id)); + }; + return [ - { isProcessing, isProcessingComplete, statusCounts, tasks }, + { isProcessing, isProcessingComplete, reset, statusCounts, tasks }, handleProcessTasks, ]; }; diff --git a/packages/react-storage/src/components/StorageBrowser/types.ts b/packages/react-storage/src/components/StorageBrowser/types.ts index e53cca42be3..2757ef61546 100644 --- a/packages/react-storage/src/components/StorageBrowser/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/types.ts @@ -1,6 +1,14 @@ import React from 'react'; -import { ListLocations } from './actions'; +import { + CustomActionConfigs, + DefaultActionConfigs, + ExtendedActionConfigs, + ListLocations, +} from './actions'; +import { GetLocationCredentials } from './credentials/types'; + +import { UseView } from './views/useView'; import { Components } from './ComponentsProvider'; @@ -11,15 +19,16 @@ import { CreateFolderViewType, DeleteViewType, UploadViewType, - LocationActionViewProps, + LocationActionViewType, LocationDetailViewType, LocationsViewType, Views, } from './views'; -import { GetLocationCredentials } from './credentials/types'; import { StorageBrowserDisplayText } from './displayText'; +import { DerivedActionHandlers, UseAction } from './useAction'; + export interface Config { accountId?: string; customEndpoint?: string; @@ -29,38 +38,69 @@ export interface Config { region: string; } +export interface StorageBrowserActions { + default?: DefaultActionConfigs; + custom?: CustomActionConfigs; +} + export interface CreateStorageBrowserInput { + actions?: StorageBrowserActions; config: Config; components?: Components; } -export interface StorageBrowserProps { - views?: Views; +export interface StorageBrowserProps { displayText?: StorageBrowserDisplayText; + views?: Views; } -export interface StorageBrowserType { - ( - props: StorageBrowserProps & Exclude - ): React.JSX.Element; +export interface StorageBrowserProviderProps + extends StoreProviderProps { + displayText?: StorageBrowserDisplayText; + // `views` intentionally scoped to custom slots to prevent conflicts with composability + views?: V; +} + +export interface StorageBrowserType { + (props: StorageBrowserProps): React.JSX.Element; displayName: string; - Provider: (props: StorageBrowserProviderProps) => React.JSX.Element; + Provider: (props: StorageBrowserProviderProps) => React.JSX.Element; CopyView: CopyViewType; CreateFolderView: CreateFolderViewType; DeleteView: DeleteViewType; UploadView: UploadViewType; - LocationActionView: ( - props: LocationActionViewProps - ) => React.JSX.Element | null; + LocationActionView: LocationActionViewType; LocationDetailView: LocationDetailViewType; LocationsView: LocationsViewType; } -export type ActionViewName = Exclude< - T, - 'listLocationItems' | 'listLocations' ->; +type DefaultActionType = Exclude; -export interface StorageBrowserProviderProps extends StoreProviderProps { - displayText?: StorageBrowserDisplayText; +export type DerivedCustomViews = { + [K in keyof T['custom'] as K extends DefaultActionType + ? T['custom'][K] extends { viewName: `${string}View` } + ? T['custom'][K]['viewName'] + : never + : never]?: () => React.JSX.Element | null; +}; + +export type DerivedActionViewType = + | keyof { + [K in keyof T['custom'] as K extends DefaultActionType + ? T['custom'][K] extends { viewName: `${string}View` } + ? K + : never + : never]?: any; + } + | Exclude; + +export interface CreateStorageBrowserOutput< + C extends ExtendedActionConfigs = ExtendedActionConfigs, +> { + StorageBrowser: StorageBrowserType< + DerivedActionViewType, + DerivedCustomViews + >; + useAction: UseAction>; + useView: UseView; } diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/useAction/__tests__/createEnhancedListHandler.spec.ts similarity index 96% rename from packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts rename to packages/react-storage/src/components/StorageBrowser/useAction/__tests__/createEnhancedListHandler.spec.ts index 217a169a317..09d2a01951b 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/useAction/__tests__/createEnhancedListHandler.spec.ts @@ -3,19 +3,13 @@ import { SEARCH_LIMIT, } from '../createEnhancedListHandler'; import { - ActionInputConfig, ListHandler, ListHandlerInput, ListHandlerOutput, -} from '../../handlers'; +} from '../../actions'; const mockAction = jest.fn(); -const config: ActionInputConfig = { - bucket: 'bucky', - credentials: jest.fn(), - region: 'us-west-1', -}; type Output = ListHandlerOutput<{ name: string; alt: string; @@ -35,7 +29,6 @@ describe('createEnhancedListHandler', () => { const options = { reset: true }; const result = await handler(prevState, { - config, prefix: 'a_prefix', options, }); @@ -61,7 +54,6 @@ describe('createEnhancedListHandler', () => { }; const result = await handler(prevState, { - config, prefix: 'a_prefix', options, }); @@ -92,7 +84,6 @@ describe('createEnhancedListHandler', () => { }; const result = await handler(prevState, { - config, prefix: '', options, }); @@ -129,7 +120,6 @@ describe('createEnhancedListHandler', () => { const prevState = { items: [], nextToken: undefined }; const result = await handler(prevState, { - config, prefix: '', options: { search: { @@ -165,7 +155,6 @@ describe('createEnhancedListHandler', () => { }; const result = await handler(prevState, { - config, prefix: 'foo/', options, }); @@ -194,7 +183,6 @@ describe('createEnhancedListHandler', () => { }; const result = await handler(prevState, { - config, prefix: 'a_prefix', options, }); @@ -218,13 +206,11 @@ describe('createEnhancedListHandler', () => { const options = { refresh: true, nextToken: 'token' }; const result = await handler(prevState, { - config, prefix: 'a_prefix', options, }); expect(mockAction).toHaveBeenCalledWith({ - config, prefix: 'a_prefix', options: { nextToken: undefined }, }); @@ -244,7 +230,6 @@ describe('createEnhancedListHandler', () => { const options = { refresh: false }; const result = await handler(prevState, { - config, prefix: 'a_prefix', options, }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts b/packages/react-storage/src/components/StorageBrowser/useAction/__tests__/search.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts rename to packages/react-storage/src/components/StorageBrowser/useAction/__tests__/search.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/constants.ts b/packages/react-storage/src/components/StorageBrowser/useAction/constants.ts new file mode 100644 index 00000000000..d3443e2267e --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/constants.ts @@ -0,0 +1,5 @@ +export const DEFAULT_ACTION_CONCURRENCY = 4; +export const USE_ACTION_ERROR_MESSAGE = + '`useAction` must be called from within `StorageBrowser.Provider`'; +export const USE_LIST_ERROR_MESSAGE = + '`useList` must be called from within `StorageBrowser.Provider`'; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/context.tsx b/packages/react-storage/src/components/StorageBrowser/useAction/context.tsx new file mode 100644 index 00000000000..47289b84787 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/context.tsx @@ -0,0 +1,10 @@ +import { createContextUtilities } from '@aws-amplify/ui-react-core'; + +import { ActionHandlersContext } from './types'; + +export const { ActionHandlersProvider, useActionHandlers } = + createContextUtilities({ + contextName: 'ActionHandlers', + errorMessage: + '`useActionHandlers` must be called from within an `ActionHandlersProvider', + }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts b/packages/react-storage/src/components/StorageBrowser/useAction/createEnhancedListHandler.ts similarity index 97% rename from packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts rename to packages/react-storage/src/components/StorageBrowser/useAction/createEnhancedListHandler.ts index abb909eec2b..79a3104dfe5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts +++ b/packages/react-storage/src/components/StorageBrowser/useAction/createEnhancedListHandler.ts @@ -5,7 +5,7 @@ import { ListHandlerOptions, ListHandlerInput, ListHandlerOutput, -} from '../handlers'; +} from '../actions'; type KeyWithStringValue = keyof { [P in keyof T as T[P] extends string ? P : never]: T[P]; @@ -42,7 +42,7 @@ export interface EnhancedListHandlerOutput extends ListHandlerOutput { } export interface EnhancedListHandlerInput - extends ListHandlerInput> {} + extends Omit>, 'config'> {} export interface EnhancedListHandler extends AsyncDataAction< @@ -58,7 +58,7 @@ type ListItem = Action extends ListHandler< : never; type Options = Action extends ListHandler< - ListHandlerInput> + Omit>, 'config'> > ? E : never; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/index.ts b/packages/react-storage/src/components/StorageBrowser/useAction/index.ts new file mode 100644 index 00000000000..434fc626e80 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/index.ts @@ -0,0 +1,11 @@ +export { ActionHandlersProvider } from './context'; +export { createEnhancedListHandler } from './createEnhancedListHandler'; +export { + ActionHandlersProviderProps, + DefaultActionHandlers, + DerivedActionHandlers, + UseAction, +} from './types'; +export { useAction } from './useAction'; +export { useList } from './useList'; +export { getActionHandlers } from './utils'; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/types.ts b/packages/react-storage/src/components/StorageBrowser/useAction/types.ts new file mode 100644 index 00000000000..5a8541dea32 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/types.ts @@ -0,0 +1,123 @@ +import React from 'react'; +import { DataState } from '@aws-amplify/ui-react-core'; + +import { + ActionHandler, + ExtendedActionConfigs, + CopyHandler, + CreateFolderHandler, + DeleteHandler, + DownloadHandler, + ListLocationItemsHandler, + ListLocations, + LocationData, + TaskData, + UploadHandler, +} from '../actions'; +import { StatusCounts, Task, Tasks } from '../tasks'; + +export type ListActionState = [ + state: DataState, + handleAction: (...input: K[]) => void, +]; + +export interface DefaultActionHandlers { + upload: UploadHandler; + download: DownloadHandler; + copy: CopyHandler; + createFolder: CreateFolderHandler; + delete: DeleteHandler; +} + +export type ActionHandlers = Record< + string, + ActionHandler | ListLocationItemsHandler | ListLocations +>; + +export interface ActionHandlersContext { + handlers: ActionHandlers; +} + +export interface ActionHandlersProviderProps extends ActionHandlersContext { + children?: React.ReactNode; +} + +type DerivedCustomActions = T extends { custom?: infer U } ? U : {}; + +export type ResolveHandlerType = T extends { handler: infer X } | infer X + ? X + : never; + +export type DerivedActionHandlers< + C extends ExtendedActionConfigs = ExtendedActionConfigs, + D extends DerivedCustomActions = DerivedCustomActions, +> = DefaultActionHandlers & { + [K in keyof D]: ResolveHandlerType; +}; + +export interface HandleTasksOptions { + items: U[]; + onTaskSuccess?: (task: Task) => void; +} + +interface HandleTasksInput { + location?: LocationData; +} + +export interface HandleTaskInput { + data: T; + location?: LocationData; + options?: { + onSuccess?: (data: { id: string; key: string }, value: K) => void; + onError?: ( + data: { id: string; key: string }, + message: string | undefined + ) => void; + }; +} + +export type HandlerInput< + T extends TaskData, + K, + U = undefined, +> = U extends undefined ? HandleTaskInput : HandleTasksInput; + +export interface TasksState { + isProcessing: boolean; + isProcessingComplete: boolean; + reset: () => void; + statusCounts: StatusCounts; + tasks: Tasks; +} + +export type HandleTasks = (input?: HandleTasksInput) => void; +export type UseTasksState = [ + TasksState, + HandleTasks, +]; + +export type HandleTask = (input: HandleTaskInput) => void; +export type UseTaskState = [ + { task: Task | undefined; isProcessing: boolean }, + HandleTask, +]; + +export type UseHandlerState< + T extends TaskData, + R, + U = undefined, +> = U extends undefined ? UseTaskState : UseTasksState; + +export type UseAction> = < + K extends keyof V, + TData extends V[K] extends ActionHandler ? D & TaskData : never, + TOptions extends HandleTasksOptions, + U extends TOptions | undefined = undefined, +>( + key: K, + options?: U +) => UseHandlerState< + TData, + V[K] extends ActionHandler ? R : never, + U +>; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/useAction.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useAction.ts new file mode 100644 index 00000000000..107074c38af --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useAction.ts @@ -0,0 +1,32 @@ +import { ActionHandler } from '../actions'; +import { useActionHandlers } from './context'; +import { DefaultActionHandlers, UseAction } from './types'; +import { useHandler } from './useHandler'; + +type ListHandlerKeys = 'listLocations' | 'listLocationItems'; + +export const ERROR_MESSAGE = + '`useAction` must be called from within `StorageBrowser.Provider`'; + +export const useAction: UseAction = (key, options) => { + if ( + (key as ListHandlerKeys) === 'listLocations' || + (key as ListHandlerKeys) === 'listLocationItems' + ) { + throw new Error( + `Value of \`${key}\` cannot be used to index \`useAction\`` + ); + } + + const { handlers } = useActionHandlers({ errorMessage: ERROR_MESSAGE }); + + const handler = handlers[key]; + + if (!handler) { + throw new Error( + `No handler found for value of \`${key}\` provided to \`useAction\`` + ); + } + + return useHandler(handler as ActionHandler, options); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/useHandler.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useHandler.ts new file mode 100644 index 00000000000..b4f8a3d574d --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useHandler.ts @@ -0,0 +1,67 @@ +import React from 'react'; +import { isObject } from '@aws-amplify/ui'; + +import { + TaskData, + TaskHandler, + TaskHandlerInput, + TaskHandlerOutput, +} from '../actions'; +import { useGetActionInput } from '../providers/configuration/context'; +import { useStore } from '../providers/store'; +import { useProcessTasks } from '../tasks'; + +import { DEFAULT_ACTION_CONCURRENCY } from './constants'; +import { HandleTasksOptions, HandlerInput, UseHandlerState } from './types'; + +const isTasksOptions = ( + value?: HandleTasksOptions +): value is HandleTasksOptions => isObject(value); + +export const useHandler = < + TData extends TaskData, + RValue, + TOptions extends HandleTasksOptions, + // provides conditonal return of task/tasks states + U extends TOptions | undefined = undefined, +>( + action: TaskHandler, TaskHandlerOutput>, + options?: U +): UseHandlerState => { + const hasOptions = isTasksOptions(options); + const { items, onTaskSuccess } = options ?? {}; + const getConfig = useGetActionInput(); + + const { + location: { current }, + } = useStore()[0]; + + const [state, processTask] = useProcessTasks(action, items, { + onTaskSuccess, + ...(items ? { concurrency: DEFAULT_ACTION_CONCURRENCY } : undefined), + }); + + const { reset, isProcessing, tasks } = state; + + const handler = React.useCallback( + (input: HandlerInput) => { + const { location } = input ?? {}; + const config = getConfig(location ?? current); + + if (!hasOptions) { + // clean up previous state + reset(); + processTask({ ...input, config }); + return; + } + + processTask({ config }); + }, + [current, getConfig, hasOptions, processTask, reset] + ); + + return [ + hasOptions ? state : { isProcessing, task: tasks?.[0] }, + handler, + ] as UseHandlerState; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/useList.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useList.ts new file mode 100644 index 00000000000..71a06d0dcf2 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useList.ts @@ -0,0 +1,31 @@ +import { useListLocations } from './useListLocations'; +import { useListLocationItems } from './useListLocationItems'; +import { useListFolderItems } from './useListFolderItems'; + +const LIST_ACTION_HOOKS = { + folderItems: useListFolderItems, + locationItems: useListLocationItems, + locations: useListLocations, +}; + +type ListActionHooks = typeof LIST_ACTION_HOOKS; + +type ListActionType = keyof ListActionHooks; + +export type UseList = < + K extends keyof ListActionHooks, + S extends ListActionHooks[K], +>( + type: K +) => ReturnType; + +const isListActionViewType = (value: unknown): value is ListActionType => + Object.keys(LIST_ACTION_HOOKS).includes(value as ListActionType); + +// @ts-expect-error +export const useList: UseList = (type) => { + if (!isListActionViewType(type)) { + throw new Error(`Value of \`${type}\` cannot be used to index \`useList\``); + } + return LIST_ACTION_HOOKS[type](); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/useListFolderItems.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useListFolderItems.ts new file mode 100644 index 00000000000..2f419187ab0 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useListFolderItems.ts @@ -0,0 +1,65 @@ +import React from 'react'; + +import { useDataState } from '@aws-amplify/ui-react-core'; + +import { + LocationItemType, + FolderData, + ListLocationItemsHandlerInput, + ListHandlerOutput, +} from '../actions'; + +import { USE_LIST_ERROR_MESSAGE } from './constants'; +import { useActionHandlers } from './context'; +import { + createEnhancedListHandler, + EnhancedListHandlerInput, + EnhancedListHandlerOutput, +} from './createEnhancedListHandler'; +import { ListActionState } from './types'; +import { useGetActionInput } from '../providers/configuration'; + +type RemoveConfig = Omit; +interface EnhancedInput + extends RemoveConfig< + EnhancedListHandlerInput + > {} + +export interface UseListLocationItemsState + extends ListActionState< + EnhancedListHandlerOutput, + EnhancedInput + > {} + +export type ListFolderItemsAction = ( + input: ListLocationItemsHandlerInput +) => Promise>; + +export interface UseListFolderItemsState + extends ListActionState< + EnhancedListHandlerOutput, + EnhancedListHandlerInput + > {} + +export const useListFolderItems = (): UseListFolderItemsState => { + const { handlers } = useActionHandlers({ + errorMessage: USE_LIST_ERROR_MESSAGE, + }); + const getConfig = useGetActionInput(); + const { listLocationItems } = handlers as { + listLocationItems: ListFolderItemsAction; + }; + + const enhancedHandler = React.useMemo( + () => + createEnhancedListHandler((input: EnhancedInput) => + listLocationItems({ ...input, config: getConfig() }) + ), + [getConfig, listLocationItems] + ); + + return useDataState(enhancedHandler, { + items: [], + nextToken: undefined, + }); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/useListLocationItems.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useListLocationItems.ts new file mode 100644 index 00000000000..6ddc9c8d2b4 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useListLocationItems.ts @@ -0,0 +1,55 @@ +import React from 'react'; + +import { useDataState } from '@aws-amplify/ui-react-core'; + +import { + LocationItemType, + ListLocationItemsHandler, + LocationItemData, +} from '../actions'; +import { useGetActionInput } from '../providers/configuration'; + +import { USE_LIST_ERROR_MESSAGE } from './constants'; +import { useActionHandlers } from './context'; +import { + createEnhancedListHandler, + EnhancedListHandlerInput, + EnhancedListHandlerOutput, +} from './createEnhancedListHandler'; +import { ListActionState } from './types'; + +type RemoveConfig = Omit; + +interface EnhancedInput + extends RemoveConfig< + EnhancedListHandlerInput + > {} + +export interface UseListLocationItemsState + extends ListActionState< + EnhancedListHandlerOutput, + EnhancedInput + > {} + +export const useListLocationItems = (): UseListLocationItemsState => { + const { handlers } = useActionHandlers({ + errorMessage: USE_LIST_ERROR_MESSAGE, + }); + const getConfig = useGetActionInput(); + const { listLocationItems } = handlers as { + listLocationItems: ListLocationItemsHandler; + }; + + const enhancedHandler = React.useMemo( + () => + createEnhancedListHandler((input: EnhancedInput) => + listLocationItems({ ...input, config: getConfig() }) + ), + [getConfig, listLocationItems] + ); + + return useDataState(enhancedHandler, { + items: [], + nextToken: undefined, + }); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts b/packages/react-storage/src/components/StorageBrowser/useAction/useListLocations.ts similarity index 70% rename from packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts rename to packages/react-storage/src/components/StorageBrowser/useAction/useListLocations.ts index 9de7697950e..7d2312c0b4d 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts +++ b/packages/react-storage/src/components/StorageBrowser/useAction/useListLocations.ts @@ -2,19 +2,20 @@ import React from 'react'; import { useDataState } from '@aws-amplify/ui-react-core'; -import { useActionConfig } from '../configs'; import { - ListLocations, LocationData, ListLocationsExcludeOptions, -} from '../handlers'; -import { ActionState } from '../types'; + ListLocations, +} from '../actions'; +import { USE_LIST_ERROR_MESSAGE } from './constants'; +import { useActionHandlers } from './context'; import { createEnhancedListHandler, EnhancedListHandlerInput, EnhancedListHandlerOutput, } from './createEnhancedListHandler'; +import { ListActionState } from './types'; // Utility type functioning as a shim to allow for the outputted // enhanced `ListLocations` handler to not require `config` and `prefix` @@ -22,7 +23,7 @@ import { type RemoveConfigAndPrefix = Omit; export interface UseListLocationsState - extends ActionState< + extends ListActionState< EnhancedListHandlerOutput, RemoveConfigAndPrefix< EnhancedListHandlerInput @@ -30,10 +31,13 @@ export interface UseListLocationsState > {} export const useListLocations = (): UseListLocationsState => { - const { handler } = useActionConfig('listLocations'); + const { handlers } = useActionHandlers({ + errorMessage: USE_LIST_ERROR_MESSAGE, + }); + const { listLocations } = handlers; const enhancedHandler = React.useMemo( - () => createEnhancedListHandler(handler as ListLocations), - [handler] + () => createEnhancedListHandler(listLocations as ListLocations), + [listLocations] ); return useDataState(enhancedHandler, { diff --git a/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts b/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts new file mode 100644 index 00000000000..62711c89936 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/useAction/utils.ts @@ -0,0 +1,52 @@ +import { isFunction } from '@aws-amplify/ui'; +import { + ActionHandler, + isDefaultActionViewType, + CustomActionConfigs, + ActionViewConfig, + ExtendedActionConfigs, +} from '../actions'; +import { ActionHandlers } from './types'; + +const resolveHandler = ( + value: V +): ActionHandler => (isFunction(value) ? value : value.handler); + +export const getActionHandlers = < + T extends { + default: Required['default']; + custom?: CustomActionConfigs; + }, +>( + configs: T +): ActionHandlers => { + const { + copy: copyConfig, + createFolder: createFolderConfig, + delete: deleteConfig, + download, + upload: uploadConfig, + listLocationItems, + listLocations, + } = configs.default; + + const defaultHandlers = { + copy: copyConfig.handler, + createFolder: createFolderConfig.handler, + delete: deleteConfig.handler, + download, + listLocationItems, + listLocations, + upload: uploadConfig.handler, + }; + + return !configs.custom + ? defaultHandlers + : Object.entries(configs.custom).reduce( + (handlers, [key, config]) => + isDefaultActionViewType(key) + ? handlers + : { ...handlers, [key]: resolveHandler(config) }, + defaultHandlers + ); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyViewProvider.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyViewProvider.spec.tsx index 81d0cf93e70..b98339d5140 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyViewProvider.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyViewProvider.spec.tsx @@ -51,6 +51,7 @@ const taskOne = { data: { id: 'id', key: 'itsa-prefix/test-item', + sourceKey: 'itsa-prefix/test-item', fileKey: 'test-item', lastModified: new Date(), size: 1000, 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 index a81885d3ae0..2375dca8685 100644 --- 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 @@ -1,21 +1,18 @@ import { renderHook, act } from '@testing-library/react'; import { LocationData } from '../../../../actions'; -import * as Store from '../../../../providers/store'; -import * as Config from '../../../../providers/configuration'; -import * as Tasks from '../../../../tasks'; -import { useFolders } from '../useFolders'; +import { useStore } from '../../../../providers/store'; +import { INITIAL_STATUS_COUNTS } from '../../../../tasks'; +import { useAction } from '../../../../useAction'; import { useCopyView } from '../useCopyView'; +import { useFolders } from '../useFolders'; +jest.mock('../../../../providers/store'); +jest.mock('../../../../useAction'); jest.mock('../useFolders'); describe('useCopyView', () => { - const mockProcessTasks = jest.fn(); - const mockDispatchStoreAction = jest.fn(); - const mockCancel = jest.fn(); - const mockUseFolders = jest.mocked(useFolders); - const location = { current: { prefix: 'test-prefix/', @@ -27,81 +24,85 @@ describe('useCopyView', () => { path: '', key: 'test-prefix/', }; + const mockUseAction = jest.mocked(useAction); + const mockUseFolders = jest.mocked(useFolders); + const mockUseStore = jest.mocked(useStore); + const mockCancel = jest.fn(); + const mockDispatchStoreAction = jest.fn(); + const mockHandleCopy = jest.fn(); beforeAll(() => { // @ts-expect-error partial mock mockUseFolders.mockReturnValue({ onInitialize: jest.fn(), }); + + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: () => 'intentionally-static-test-id' }, + }); }); beforeEach(() => { - jest.spyOn(Store, 'useStore').mockReturnValue([ - { - actionType: 'COPY', - files: [], - location, - 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([ + mockUseAction.mockReturnValue([ { isProcessing: false, isProcessingComplete: false, - statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + statusCounts: { ...INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, tasks: [ { status: 'QUEUED', data: { key: 'test-item', id: 'id' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, { status: 'QUEUED', data: { key: 'test-item2', id: 'id2' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, { status: 'QUEUED', data: { key: 'test-item3', id: 'id3' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, ], }, - mockProcessTasks, + mockHandleCopy, + ]); + mockUseStore.mockReturnValue([ + { + actionType: 'COPY', + files: [], + location, + locationItems: { + fileDataItems: [ + { + key: 'pre-pre/test-file.txt', + fileKey: 'test-file.txt', + lastModified: new Date(), + id: 'id', + size: 10, + type: 'FILE', + }, + ], + }, + }, + mockDispatchStoreAction, ]); }); afterEach(() => { - mockProcessTasks.mockClear(); - mockDispatchStoreAction.mockClear(); mockCancel.mockClear(); + mockDispatchStoreAction.mockClear(); + mockHandleCopy.mockClear(); + mockUseFolders.mockClear(); + mockUseAction.mockReset(); + mockUseStore.mockReset(); }); it('should return the correct initial state', () => { @@ -136,44 +137,17 @@ describe('useCopyView', () => { 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', - }, - }); + expect(mockHandleCopy).toHaveBeenCalledTimes(1); }); it('should call cancel on tasks when onActionCancel is called', () => { - 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(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - const { result } = renderHook(() => useCopyView()); act(() => { result.current.onActionCancel(); }); - expect(mockCancel).toHaveBeenCalled(); + expect(mockCancel).toHaveBeenCalledTimes(3); }); it('should reset state when onActionExit is called', () => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useFolders.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useFolders.spec.ts index 738bd6b2413..059c30e45c1 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useFolders.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/useFolders.spec.ts @@ -1,22 +1,13 @@ import { act, renderHook, waitFor } from '@testing-library/react'; -import * as AmplifyReactCore from '@aws-amplify/ui-react-core'; - import { LocationData } from '../../../../actions'; -import * as Store from '../../../../providers/store'; -import * as Config from '../../../../providers/configuration'; -import { DEFAULT_LIST_OPTIONS, useFolders } from '../useFolders'; +import { useStore } from '../../../../providers/store'; import { LocationState } from '../../../../providers/store/location'; +import { useList } from '../../../../useAction'; +import { DEFAULT_LIST_OPTIONS, useFolders } from '../useFolders'; -const mockDispatchStoreAction = jest.fn(); -const mockHandleList = jest.fn(); -const config = { - accountId: '123456789012', - bucket: 'bucket', - credentials: jest.fn(), - region: 'us-west-2', -}; - +jest.mock('../../../../useAction'); +jest.mock('../../../../providers/store'); jest.useFakeTimers(); jest.setSystemTime(1731366223230); @@ -26,14 +17,14 @@ const mockItems = [ lastModified: new Date(), id: 'id', size: 10, - type: 'FOLDER', + type: 'FOLDER' as const, }, { key: 'prefix2/', lastModified: new Date(), id: 'id', size: 10, - type: 'FOLDER', + type: 'FOLDER' as const, }, ]; @@ -50,12 +41,14 @@ describe('useFolders', () => { key: 'prefix1/', }; + const mockUseList = jest.mocked(useList); + const mockUseStore = jest.mocked(useStore); + const mockDispatchStoreAction = jest.fn(); + const mockHandleList = jest.fn(); const mockSetDestination = jest.fn(); beforeEach(() => { - jest.clearAllMocks(); - - jest.spyOn(Store, 'useStore').mockReturnValue([ + mockUseStore.mockReturnValue([ { actionType: 'COPY', files: [], @@ -75,12 +68,7 @@ describe('useFolders', () => { }, mockDispatchStoreAction, ]); - - jest.spyOn(Config, 'useGetActionInput').mockReturnValue(() => config); - }); - - it('should return the correct initial state', async () => { - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValueOnce([ + mockUseList.mockReturnValue([ { data: { items: mockItems, @@ -92,7 +80,17 @@ describe('useFolders', () => { }, mockHandleList, ]); + }); + + afterEach(() => { + mockDispatchStoreAction.mockClear(); + mockHandleList.mockClear(); + mockSetDestination.mockClear(); + mockUseList.mockReset(); + mockUseStore.mockReset(); + }); + it('should return the correct initial state', async () => { const { result } = renderHook(() => useFolders({ destination: location, setDestination: mockSetDestination }) ); @@ -103,19 +101,6 @@ describe('useFolders', () => { }); it('should update the reference of onInitialize on destination change', () => { - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValue([ - { - data: { - items: mockItems, - nextToken: 'token', - }, - hasError: false, - isLoading: false, - message: undefined, - }, - mockHandleList, - ]); - const { rerender, result } = renderHook( ( props: { destination: LocationState; setDestination: () => void } = { @@ -142,18 +127,6 @@ describe('useFolders', () => { }); it('should handle search', () => { - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValueOnce([ - { - data: { - items: mockItems, - nextToken: 'token', - }, - hasError: false, - isLoading: false, - message: undefined, - }, - mockHandleList, - ]); const { result } = renderHook(() => useFolders({ destination: location, setDestination: mockSetDestination }) ); @@ -167,7 +140,6 @@ describe('useFolders', () => { }); expect(mockHandleList).toHaveBeenCalledWith({ - config, options: { ...DEFAULT_LIST_OPTIONS, exclude: 'FILE', @@ -191,19 +163,6 @@ describe('useFolders', () => { }); it('should reset search on selecting folder', () => { - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValueOnce([ - { - data: { - items: mockItems, - nextToken: 'token', - }, - hasError: false, - isLoading: false, - message: undefined, - }, - mockHandleList, - ]); - const { result } = renderHook(() => useFolders({ destination: location, setDestination: mockSetDestination }) ); @@ -223,7 +182,7 @@ describe('useFolders', () => { it('should handle paginate', () => { const nextToken = 'token'; - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValue([ + mockUseList.mockReturnValue([ { data: { items: mockItems, @@ -246,7 +205,6 @@ describe('useFolders', () => { }); expect(mockHandleList).toHaveBeenCalledWith({ - config, options: { ...DEFAULT_LIST_OPTIONS, exclude: 'FILE', 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 3dcf3059c40..f0695dfd4df 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 @@ -1,11 +1,10 @@ -import React, { useState } from 'react'; - +import React, { useRef, useState } from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { copyHandler, LocationData } from '../../../actions/handlers'; -import { Task, useProcessTasks } from '../../../tasks'; -import { useGetActionInput } from '../../../providers/configuration'; +import { LocationData } from '../../../actions'; import { useStore } from '../../../providers/store'; +import { Task } from '../../../tasks'; +import { useAction } from '../../../useAction'; import { CopyViewState, UseCopyViewOptions } from './types'; import { useFolders } from './useFolders'; @@ -19,18 +18,30 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { }, dispatchStoreAction, ] = useStore(); + const idLookup = useRef>({}); - const getInput = useGetActionInput(); - - const [processState, handleProcess] = useProcessTasks( - copyHandler, - fileDataItems, - { concurrency: 4 } - ); const [destination, setDestination] = useState(location); + const data = React.useMemo(() => { + idLookup.current = {}; + return fileDataItems?.map((item) => { + // generate new `id` on each `destination.key` change to refresh + // task data provided to `useActon` + const id = crypto.randomUUID(); + idLookup.current[id] = item.id; + return { + ...item, + id, + key: `${destination.key}${item.fileKey}`, + sourceKey: item.key, + }; + }); + }, [destination.key, fileDataItems]); + const folders = useFolders({ destination, setDestination }); + const [processState, handleProcess] = useAction('copy', { items: data! }); + const { isProcessing, isProcessingComplete, statusCounts, tasks } = processState; const { current } = location; @@ -42,10 +53,7 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { }, [onInitialize]); const onActionStart = () => { - handleProcess({ - config: getInput(), - destinationPrefix: destination.key, - }); + handleProcess(); }; const onActionCancel = () => { @@ -64,7 +72,10 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { const onTaskRemove = React.useCallback( ({ data }: Task) => { - dispatchStoreAction({ type: 'REMOVE_LOCATION_ITEM', id: data.id }); + dispatchStoreAction({ + type: 'REMOVE_LOCATION_ITEM', + id: idLookup.current[data.id], + }); }, [dispatchStoreAction] ); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useFolders.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useFolders.ts index 0409b5c0a12..14160b98973 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useFolders.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/useFolders.ts @@ -1,19 +1,12 @@ import React from 'react'; -import { useDataState } from '@aws-amplify/ui-react-core'; +import { LocationState } from '../../../providers/store/location'; +import { useList } from '../../../useAction'; import { usePaginate } from '../../hooks/usePaginate'; -import { listLocationItemsHandler, FolderData } from '../../../actions'; -import { useGetActionInput } from '../../../providers/configuration'; - -import { createEnhancedListHandler } from '../../../actions/useAction/createEnhancedListHandler'; import { useSearch } from '../../hooks/useSearch'; -import { - ListLocationItemsHandlerInput, - ListHandlerOutput, -} from '../../../actions'; + import { FoldersState } from './types'; -import { LocationState } from '../../../providers/store/location'; const DEFAULT_PAGE_SIZE = 100; export const DEFAULT_LIST_OPTIONS = { @@ -24,42 +17,29 @@ export const DEFAULT_LIST_OPTIONS = { const DEFAULT_REFRESH_OPTIONS = { ...DEFAULT_LIST_OPTIONS, refresh: true }; -export type ListFoldersAction = ( - input: ListLocationItemsHandlerInput -) => Promise>; - interface UseFoldersInput { destination: LocationState; setDestination: (destination: LocationState) => void; } -const listLocationItemsAction = createEnhancedListHandler( - listLocationItemsHandler as ListFoldersAction -); - export const useFolders = ({ destination, setDestination, }: UseFoldersInput): FoldersState => { const { current, key } = destination; - const [{ data, hasError, isLoading, message }, handleList] = useDataState( - listLocationItemsAction, - { items: [], nextToken: undefined } - ); - - const getInput = useGetActionInput(); + const [{ data, hasError, isLoading, message }, handleList] = + useList('folderItems'); const { items, nextToken, search } = data; const { hasExhaustedSearch = false } = search ?? {}; const onInitialize = React.useCallback(() => { handleList({ - config: getInput(), prefix: key, options: { ...DEFAULT_REFRESH_OPTIONS }, }); - }, [getInput, handleList, key]); + }, [handleList, key]); const hasNextToken = !!nextToken; @@ -67,7 +47,6 @@ export const useFolders = ({ if (!nextToken) return; handleList({ - config: getInput(), prefix: key, options: { ...DEFAULT_LIST_OPTIONS, nextToken }, }); @@ -89,7 +68,6 @@ export const useFolders = ({ const onSearch = (query: string) => { handleReset(); handleList({ - config: getInput(), prefix: key, options: { ...DEFAULT_LIST_OPTIONS, @@ -136,7 +114,6 @@ export const useFolders = ({ handleReset(); resetSearch(); handleList({ - config: getInput(), prefix: key, options: { ...DEFAULT_REFRESH_OPTIONS }, }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/useCreateFolderView.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/useCreateFolderView.spec.ts index bb59f01fd58..36a211d8b19 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/useCreateFolderView.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/useCreateFolderView.spec.ts @@ -1,34 +1,14 @@ import { renderHook, act } from '@testing-library/react'; import { LocationData } from '../../../../actions'; -import * as StoreModule from '../../../../providers/store'; -import * as ConfigModule from '../../../../providers/configuration'; -import * as TasksModule from '../../../../tasks'; +import { useStore } from '../../../../providers/store'; +import { INITIAL_STATUS_COUNTS } from '../../../../tasks'; +import { useAction } from '../../../../useAction'; import { useCreateFolderView } from '../useCreateFolderView'; -const mockDispatchStoreAction = jest.fn(); - -const credentials = jest.fn(); -const config = { - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials, - region: 'us-west-2', -}; -jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(() => config); - -const defaultProcessingState = { - isProcessing: false, - isProcessingComplete: false, - statusCounts: { ...TasksModule.INITIAL_STATUS_COUNTS }, - tasks: [], -}; - -const handleProcessTasks = jest.fn(); -jest - .spyOn(TasksModule, 'useProcessTasks') - .mockReturnValue([defaultProcessingState, handleProcessTasks]); +jest.mock('../../../../providers/store'); +jest.mock('../../../../useAction'); const location: LocationData = { prefix: 'test-prefix/', @@ -38,38 +18,50 @@ const location: LocationData = { type: 'PREFIX', }; -jest.spyOn(StoreModule, 'useStore').mockReturnValue([ - { - actionType: 'CREATE_FOLDER', - files: [], - location: { current: location, path: '', key: 'test-prefix/' }, - locationItems: { fileDataItems: undefined }, - }, - mockDispatchStoreAction, -]); - describe('useCreateFolderView', () => { + const mockUseStore = jest.mocked(useStore); + const mockUseAction = jest.mocked(useAction); + const mockDispatchStoreAction = jest.fn(); + const mockHandleCreateFolder = jest.fn(); + beforeAll(() => { Object.defineProperty(globalThis, 'crypto', { value: { randomUUID: () => 'intentionally-static-test-id' }, }); + mockUseAction.mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + reset: jest.fn(), + statusCounts: { ...INITIAL_STATUS_COUNTS }, + tasks: [], + }, + mockHandleCreateFolder, + ]); + mockUseStore.mockReturnValue([ + { + actionType: 'CREATE_FOLDER', + files: [], + location: { current: location, path: '', key: 'test-prefix/' }, + locationItems: { fileDataItems: undefined }, + }, + mockDispatchStoreAction, + ]); }); - afterEach(jest.clearAllMocks); + afterEach(() => { + mockDispatchStoreAction.mockClear(); + mockHandleCreateFolder.mockClear(); + }); - it('should call handleProcessTasks when onActionStart is called', () => { + it('should call mockHandleCreateFolder when onActionStart is called', () => { const { result } = renderHook(() => useCreateFolderView()); act(() => { result.current.onActionStart(); }); - expect(handleProcessTasks).toHaveBeenCalledWith({ - config, - data: { id: 'intentionally-static-test-id', key: '/' }, - destinationPrefix: 'test-prefix/', - options: { preventOverwrite: true }, - }); + expect(mockHandleCreateFolder).toHaveBeenCalledTimes(1); }); it('resets state when onActionExit is called', () => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/types.ts index 47ed8650782..f44af8c7bb2 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/types.ts @@ -1,8 +1,4 @@ -import { - CopyHandlerData, - CreateFolderHandlerData, - LocationData, -} from '../../../actions'; +import { CreateFolderHandlerData, LocationData } from '../../../actions'; import { ActionViewType, ActionViewState, ActionViewProps } from '../types'; @@ -22,7 +18,7 @@ export interface CreateFolderViewProviderProps extends CreateFolderViewState { } export interface CreateFolderViewType - extends ActionViewType { + extends ActionViewType { Provider: (props: CreateFolderViewProviderProps) => React.JSX.Element; Exit: () => React.JSX.Element | null; NameField: () => React.JSX.Element | null; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/useCreateFolderView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/useCreateFolderView.ts index ebd993b19c4..25724ca3d03 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/useCreateFolderView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/useCreateFolderView.ts @@ -1,10 +1,9 @@ import React from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { createFolderHandler } from '../../../actions'; -import { useGetActionInput } from '../../../providers/configuration'; +import { CreateFolderHandlerData } from '../../../actions'; +import { useAction } from '../../../useAction'; import { useStore } from '../../../providers/store'; -import { useProcessTasks } from '../../../tasks'; import { CreateFolderViewState, UseCreateFolderViewOptions } from './types'; @@ -15,14 +14,26 @@ export const useCreateFolderView = ( const [folderName, setFolderName] = React.useState(''); const folderNameId = React.useRef(crypto.randomUUID()).current; - const getConfig = useGetActionInput(); + const [{ location }, dispatchStoreAction] = useStore(); + const { current, key } = location; + + const data: CreateFolderHandlerData[] = React.useMemo( + () => [ + { + // generate new `id` on each `folderName` change to refresh task + // data provided to `useAction` + id: crypto.randomUUID(), + key: `${key}${folderName}/`, + preventOverwrite: true, + }, + ], + [key, folderName] + ); + const [ { tasks, isProcessing, isProcessingComplete, statusCounts }, handleCreateFolder, - ] = useProcessTasks(createFolderHandler); - - const [{ location }, dispatchStoreAction] = useStore(); - const { current, key: destinationPrefix } = location; + ] = useAction('createFolder', { items: data }); return { folderName, @@ -31,12 +42,7 @@ export const useCreateFolderView = ( isProcessingComplete, location, onActionStart: () => { - handleCreateFolder({ - config: getConfig(), - data: { id: folderNameId, key: `${folderName}/` }, - destinationPrefix, - options: { preventOverwrite: true }, - }); + handleCreateFolder(); }, onActionExit: () => { if (isFunction(onExit)) onExit(current); 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 index 03c30ff068b..af0ff3dee11 100644 --- 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 @@ -1,27 +1,24 @@ 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 { useStore } from '../../../../providers/store'; +import { useAction } from '../../../../useAction'; import { useDeleteView } from '../useDeleteView'; +import { INITIAL_STATUS_COUNTS } from '../../../../tasks'; -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', -})); +jest.mock('../../../../providers/store'); +jest.mock('../../../../useAction'); describe('useDeleteView', () => { - beforeEach(() => { - jest.clearAllMocks(); + const mockeUseAction = jest.mocked(useAction); + const mockeUseStore = jest.mocked(useStore); + const mockCancel = jest.fn(); + const mockDispatchStoreAction = jest.fn(); + const mockHandleDelete = jest.fn(); + const mockReset = jest.fn(); - jest.spyOn(Store, 'useStore').mockReturnValue([ + beforeEach(() => { + mockeUseStore.mockReturnValue([ { actionType: 'DELETE', files: [], @@ -52,40 +49,49 @@ describe('useDeleteView', () => { mockDispatchStoreAction, ]); - // Mock the useProcessTasks hook - jest.spyOn(Tasks, 'useProcessTasks').mockReturnValue([ + mockeUseAction.mockReturnValue([ { isProcessing: false, isProcessingComplete: false, - statusCounts: { ...Tasks.INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, + reset: mockReset, + statusCounts: { ...INITIAL_STATUS_COUNTS, QUEUED: 3, TOTAL: 3 }, tasks: [ { status: 'QUEUED', data: { key: 'test-item', id: 'id' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, { status: 'QUEUED', data: { key: 'test-item2', id: 'id2' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, { status: 'QUEUED', data: { key: 'test-item3', id: 'id3' }, - cancel: jest.fn(), + cancel: mockCancel, message: 'test-message', progress: undefined, }, ], }, - mockProcessTasks, + mockHandleDelete, ]); }); + afterEach(() => { + mockCancel.mockClear(); + mockDispatchStoreAction.mockClear(); + mockHandleDelete.mockClear(); + mockReset.mockClear(); + mockeUseAction.mockReset(); + mockeUseStore.mockReset(); + }); + it('should return the correct initial state', () => { const { result } = renderHook(() => useDeleteView()); @@ -116,43 +122,17 @@ describe('useDeleteView', () => { result.current.onActionStart(); }); - expect(mockProcessTasks).toHaveBeenCalledWith({ - config: { - accountId: '123456789012', - bucket: 'XXXXXXXXXXX', - credentials, - region: 'us-west-2', - }, - }); + expect(mockHandleDelete).toHaveBeenCalledTimes(1); }); 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(), - message: 'test-message', - progress: undefined, - }, - ], - }, - mockProcessTasks, - ]); - const { result } = renderHook(() => useDeleteView()); act(() => { result.current.onActionCancel(); }); - expect(mockCancel).toHaveBeenCalled(); + expect(mockCancel).toHaveBeenCalledTimes(3); }); it('should reset state when onActionExit is called', () => { 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 4298a9b0bb7..e189ae53c62 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 @@ -1,11 +1,11 @@ +import React from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { DeleteViewState, UseDeleteViewOptions } from './types'; -import { deleteHandler } from '../../../actions/handlers'; import { useStore } from '../../../providers/store'; -import { useGetActionInput } from '../../../providers/configuration'; -import { Task, useProcessTasks } from '../../../tasks'; -import React from 'react'; +import { Task } from '../../../tasks'; +import { useAction } from '../../../useAction'; + +import { DeleteViewState, UseDeleteViewOptions } from './types'; export const useDeleteView = ( options?: UseDeleteViewOptions @@ -14,22 +14,27 @@ export const useDeleteView = ( const [{ location, locationItems }, dispatchStoreAction] = useStore(); const { fileDataItems } = locationItems; - const { current } = location; - - const getInput = useGetActionInput(); + const { current, key } = location; - const [processState, handleProcess] = useProcessTasks( - deleteHandler, - fileDataItems, - { concurrency: 4 } + const data = React.useMemo( + () => + !fileDataItems + ? [] + : fileDataItems.map((item) => ({ + ...item, + key: `${key}${item.fileKey}`, + })), + [fileDataItems, key] ); + const [processState, handleProcess] = useAction('delete', { items: data }); + const { isProcessing, isProcessingComplete, statusCounts, tasks } = processState; const onActionStart = () => { if (!current) return; - handleProcess({ config: getInput() }); + handleProcess(); }; const onActionCancel = () => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx index 631b6002403..ecebad1f3c1 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx @@ -1,37 +1,24 @@ import React from 'react'; -import { isDefaultActionViewType } from '../../actions'; import { useStore } from '../../providers/store'; +import { useActionViews } from '../context/actionViews'; -import { CreateFolderView } from './CreateFolderView'; -import { CopyView } from './CopyView'; -import { DeleteView } from './DeleteView'; -import { UploadView } from './UploadView'; +import { LocationActionViewType } from './types'; -export interface LocationActionViewProps { - onExit?: () => void; - type?: T; -} - -export const LocationActionView = ({ +export const LocationActionView: LocationActionViewType = ({ type, ...props -}: LocationActionViewProps): React.JSX.Element | null => { +}) => { const [{ actionType = type }] = useStore(); + const views = useActionViews().action; + + const ActionView = actionType + ? views[actionType as keyof typeof views] + : undefined; - if (!isDefaultActionViewType(actionType)) return null; + if (ActionView) { + return ; + } - return ( - <> - {actionType === 'createFolder' ? ( - - ) : actionType === 'delete' ? ( - - ) : actionType === 'copy' ? ( - - ) : ( - - )} - - ); + return null; }; 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 index cbb2f680723..06d65f44ec4 100644 --- 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 @@ -1,12 +1,14 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useUploadView } from '../useUploadView'; -import { LocationData } from '../../../../actions'; -import * as ConfigModule from '../../../../providers/configuration'; -import * as StoreModule from '../../../../providers/store'; -import * as TasksModule from '../../../../tasks'; +import { FileItem, LocationData } from '../../../../actions'; + +import { UseStoreState, useStore } from '../../../../providers/store'; +import { Task, INITIAL_STATUS_COUNTS } from '../../../../tasks'; +import { useAction } from '../../../../useAction'; import { UPLOAD_FILE_SIZE_LIMIT } from '../../../../validators/isFileTooBig'; +import { useUploadView } from '../useUploadView'; -const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); +jest.mock('../../../../providers/store'); +jest.mock('../../../../useAction'); const rootLocation: LocationData = { id: 'an-id-👍🏼', @@ -17,20 +19,6 @@ const rootLocation: LocationData = { type: 'BUCKET', }; -const mockUserStoreState = { - location: { current: rootLocation, path: '', key: '' }, - files: undefined, -} as StoreModule.UseStoreState; -const dispatchStoreAction = jest.fn(); -useStoreSpy.mockReturnValue([mockUserStoreState, 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', @@ -53,10 +41,7 @@ const invalidFileItem = { key: invalidFile.name, }; -jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(config); -const handleProcessTasks = jest.fn(); - -const taskOne: TasksModule.Task = { +const taskOne: Task = { data: fileItemOne, cancel: jest.fn(), message: undefined, @@ -64,7 +49,7 @@ const taskOne: TasksModule.Task = { status: 'QUEUED', }; -const taskTwo: TasksModule.Task = { +const taskTwo: Task = { data: fileItemTwo, cancel: jest.fn(), message: undefined, @@ -72,54 +57,78 @@ const taskTwo: TasksModule.Task = { status: 'QUEUED', }; -const useProcessTasksSpy = jest - .spyOn(TasksModule, 'useProcessTasks') - .mockReturnValue([ - { - isProcessing: false, - isProcessingComplete: false, - statusCounts: TasksModule.INITIAL_STATUS_COUNTS, - tasks: [], - }, - handleProcessTasks, - ]); - describe('useUploadView', () => { + const mockUserStoreState = { + location: { current: rootLocation, path: '', key: '' }, + files: undefined, + } as UseStoreState; + + const mockUseAction = jest.mocked(useAction); + const mockUseStore = jest.mocked(useStore); + const mockCancel = jest.fn(); + const mockDispatchStoreAction = jest.fn(); + const mockHandleUpload = jest.fn(); + + beforeEach(() => { + mockUseStore.mockReturnValue([ + { ...mockUserStoreState }, + mockDispatchStoreAction, + ]); + mockUseAction.mockReturnValue([ + { + isProcessing: false, + isProcessingComplete: false, + statusCounts: INITIAL_STATUS_COUNTS, + tasks: [ + { ...taskOne, status: 'PENDING', cancel: mockCancel }, + { ...taskTwo, status: 'PENDING', cancel: mockCancel }, + ], + }, + mockHandleUpload, + ]); + }); + afterEach(() => { - mockUserStoreState.files = undefined; - jest.clearAllMocks(); + mockUseAction.mockReset(); + mockUseStore.mockReset(); + mockCancel.mockClear(); + mockDispatchStoreAction.mockClear(); + mockHandleUpload.mockClear(); }); - it('should dispatchStoreAction when onDropFiles is invoked', () => { + it('should mockDispatchStoreAction when onDropFiles is invoked', () => { const { result } = renderHook(() => useUploadView()); act(() => { result.current.onDropFiles([testFileOne]); }); - expect(dispatchStoreAction).toHaveBeenCalledTimes(1); - expect(dispatchStoreAction).toHaveBeenCalledWith({ + expect(mockDispatchStoreAction).toHaveBeenCalledTimes(1); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'ADD_FILE_ITEMS', files: [testFileOne], }); }); it('should show invalid files if exists', () => { - mockUserStoreState.files = [invalidFileItem]; + mockUseStore.mockReturnValue([ + { ...mockUserStoreState, files: [invalidFileItem] }, + mockDispatchStoreAction, + ]); const { result } = renderHook(() => useUploadView()); expect(result.current.invalidFiles).toEqual([invalidFileItem]); }); - it('should dispatchStoreAction when onSelectFiles is invoked with different types', () => { + it('should mockDispatchStoreAction when onSelectFiles is invoked with different types', () => { const { result } = renderHook(() => useUploadView()); act(() => { result.current.onSelectFiles('FILE'); }); - expect(dispatchStoreAction).toHaveBeenCalledTimes(1); - expect(dispatchStoreAction).toHaveBeenCalledWith({ + expect(mockDispatchStoreAction).toHaveBeenCalledTimes(1); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SELECT_FILES', selectionType: 'FILE', }); @@ -128,21 +137,24 @@ describe('useUploadView', () => { result.current.onSelectFiles('FOLDER'); }); - expect(dispatchStoreAction).toHaveBeenCalledTimes(2); - expect(dispatchStoreAction).toHaveBeenCalledWith({ + expect(mockDispatchStoreAction).toHaveBeenCalledTimes(2); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SELECT_FILES', selectionType: 'FOLDER', }); }); - it('should call handleProcessTasks with the expected values', () => { - mockUserStoreState.files = [invalidFileItem]; + it('should call mockHandleUpload with the expected values', () => { + mockUseStore.mockReturnValue([ + { ...mockUserStoreState, files: [invalidFileItem] }, + mockDispatchStoreAction, + ]); const { result } = renderHook(() => useUploadView()); act(() => { result.current.onActionStart(); }); - expect(dispatchStoreAction).toHaveBeenCalledTimes(1); - expect(dispatchStoreAction).toHaveBeenCalledWith({ + expect(mockDispatchStoreAction).toHaveBeenCalledTimes(1); + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'REMOVE_FILE_ITEM', id: invalidFileItem.id, }); @@ -153,67 +165,38 @@ describe('useUploadView', () => { act(() => { result.current.onActionStart(); }); - expect(handleProcessTasks).toHaveBeenCalledTimes(1); - expect(handleProcessTasks).toHaveBeenCalledWith({ - config: { - bucket: rootLocation.bucket, - credentials, - region: 'region', - }, - options: { preventOverwrite: true }, - destinationPrefix: '', - }); + expect(mockHandleUpload).toHaveBeenCalledTimes(1); }); it('should call cancel on each pending task when onCancel is invoked', () => { - const tasks: TasksModule.Task[] = [ - { ...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); + expect(mockCancel).toHaveBeenCalledTimes(2); }); it('should call remove on each task, provided onExit and dispatch actions when returned onExit is invoked', () => { - const tasks: TasksModule.Task[] = [ + const tasks: Task[] = [ { ...taskOne, status: 'FAILED' }, { ...taskTwo, status: 'COMPLETE' }, ]; - useProcessTasksSpy.mockReturnValue([ + mockUseAction.mockReturnValue([ { tasks, isProcessing: true, isProcessingComplete: false, statusCounts: { - ...TasksModule.INITIAL_STATUS_COUNTS, + ...INITIAL_STATUS_COUNTS, COMPLETE: 1, FAILED: 1, TOTAL: 2, }, }, - handleProcessTasks, + mockHandleUpload, ]); const onExit = jest.fn(); @@ -227,7 +210,7 @@ describe('useUploadView', () => { expect(onExit).toHaveBeenCalledTimes(1); expect(onExit).toHaveBeenCalledWith(rootLocation); - expect(dispatchStoreAction.mock.calls).toEqual([ + expect(mockDispatchStoreAction.mock.calls).toEqual([ [{ type: 'RESET_FILE_ITEMS' }], [{ type: 'RESET_ACTION_TYPE' }], ]); 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 1d7e6313b0c..2737488da90 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,117 +1,108 @@ import React from 'react'; +import { isUndefined } from '@aws-amplify/ui'; -import { uploadHandler } from '../../../actions'; - -import { useGetActionInput } from '../../../providers/configuration'; +import { UploadHandlerData } from '../../../actions'; import { FileItems, useStore } from '../../../providers/store'; -import { Task, useProcessTasks } from '../../../tasks'; +import { Task } from '../../../tasks'; +import { useAction } from '../../../useAction'; +import { isFileTooBig } from '../../../validators'; -import { DEFAULT_ACTION_CONCURRENCY } from '../constants'; import { UploadViewState, UseUploadViewOptions } from './types'; import { DEFAULT_OVERWRITE_ENABLED } from './constants'; -import { isUndefined } from '@aws-amplify/ui'; -import { isFileTooBig } from '../../../validators'; + +interface FilesData { + invalidFiles: FileItems | undefined; + validFiles: FileItems | undefined; + data: UploadHandlerData[]; +} export const useUploadView = ( options?: UseUploadViewOptions ): UploadViewState => { const { onExit: _onExit } = options ?? {}; - const getInput = useGetActionInput(); + const [{ files, location }, dispatchStoreAction] = useStore(); - const { current, key } = location; + const { current } = location; - const { invalidFiles, validFiles } = React.useMemo( + const [isOverwritingEnabled, setIsOverwritingEnabled] = React.useState( + DEFAULT_OVERWRITE_ENABLED + ); + + const filesData = React.useMemo( () => (files ?? [])?.reduce( - (curr, file) => { - if (isFileTooBig(file.file)) { + (curr: FilesData, item) => { + if (isFileTooBig(item.file)) { curr.invalidFiles = isUndefined(curr.invalidFiles) - ? [file] - : curr.invalidFiles.concat(file); + ? [item] + : curr.invalidFiles.concat(item); } else { curr.validFiles = isUndefined(curr.validFiles) - ? [file] - : curr.validFiles.concat(file); + ? [item] + : curr.validFiles.concat(item); + + const parsedFileItem = { + ...item, + key: `${location.key}${item.key}`, + }; + + curr.data = curr.data.concat({ + ...parsedFileItem, + preventOverwrite: !isOverwritingEnabled, + }); } return curr; }, - {} as { - invalidFiles: FileItems | undefined; - validFiles: FileItems | undefined; - } + { invalidFiles: undefined, validFiles: undefined, data: [] } ), - [files] + [files, isOverwritingEnabled, location.key] ); - const [isOverwritingEnabled, setIsOverwritingEnabled] = React.useState( - DEFAULT_OVERWRITE_ENABLED - ); + const { data, invalidFiles } = filesData; const [ { isProcessing, isProcessingComplete, statusCounts, tasks }, - handleProcess, - ] = useProcessTasks(uploadHandler, validFiles, { - concurrency: DEFAULT_ACTION_CONCURRENCY, - }); - - const onDropFiles = React.useCallback( - (files: File[]) => { - if (files) { - dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); - } - }, - [dispatchStoreAction] - ); + handleUploads, + ] = useAction('upload', { items: data }); - const onSelectFiles = React.useCallback( - (type?: 'FILE' | 'FOLDER') => { - dispatchStoreAction({ type: 'SELECT_FILES', selectionType: type }); - }, - [dispatchStoreAction] - ); + const onDropFiles = (files: File[]) => { + if (files) { + dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); + } + }; - const onActionStart = React.useCallback(() => { + const onSelectFiles = (type?: 'FILE' | 'FOLDER') => { + dispatchStoreAction({ type: 'SELECT_FILES', selectionType: type }); + }; + + const onActionStart = () => { invalidFiles?.forEach((file) => { dispatchStoreAction({ type: 'REMOVE_FILE_ITEM', id: file.id }); }); - handleProcess({ - config: getInput(), - destinationPrefix: key, - options: { preventOverwrite: !isOverwritingEnabled }, - }); - }, [ - isOverwritingEnabled, - key, - getInput, - handleProcess, - invalidFiles, - dispatchStoreAction, - ]); + handleUploads(); + }; - const onActionCancel = React.useCallback(() => { + const onActionCancel = () => { tasks.forEach((task) => task.cancel?.()); - }, [tasks]); + }; - const onActionExit = React.useCallback(() => { + const onActionExit = () => { // clear files state dispatchStoreAction({ type: 'RESET_FILE_ITEMS' }); // clear selected action dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); _onExit?.(current); - }, [dispatchStoreAction, _onExit, current]); + }; - const onToggleOverwrite = React.useCallback(() => { + const onToggleOverwrite = () => { setIsOverwritingEnabled((prev) => !prev); - }, []); + }; - const onTaskRemove = React.useCallback( - ({ data }: Task) => { - dispatchStoreAction({ type: 'REMOVE_FILE_ITEM', id: data.id }); - }, - [dispatchStoreAction] - ); + const onTaskRemove = ({ data }: Task) => { + dispatchStoreAction({ type: 'REMOVE_FILE_ITEM', id: data.id }); + }; return { isProcessing, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getActionViewTableData.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getActionViewTableData.spec.ts index 1adace8a7f6..2dce64bf61a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getActionViewTableData.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getActionViewTableData.spec.ts @@ -1,4 +1,4 @@ -import { FileDataItem } from '../../../actions/handlers'; +import { FileDataItem } from '../../../actions'; import { DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT } from '../../../displayText/libraries/en/uploadView'; import { Tasks } from '../../../tasks'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts deleted file mode 100644 index 3ec4f59938a..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_ACTION_CONCURRENCY = 4; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts index 7d727005b2d..fae3067cfeb 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts @@ -48,7 +48,7 @@ const getTaskStatusDisplayLabel = ({ } }; -export const getProgressHeader = (label: string): ActionViewHeaders[0] => ({ +const getProgressHeader = (label: string): ActionViewHeaders[0] => ({ key: 'progress', type: 'sort', content: { label }, 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 ca7f5952ca4..f00db8a9f51 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts @@ -1,26 +1,35 @@ -export { CopyView, CopyViewType, CopyViewState, useCopyView } from './CopyView'; +export { + CopyView, + CopyViewProps, + CopyViewType, + CopyViewState, + useCopyView, +} from './CopyView'; export { CreateFolderView, + CreateFolderViewProps, CreateFolderViewType, CreateFolderViewState, useCreateFolderView, } from './CreateFolderView'; export { DeleteView, + DeleteViewProps, DeleteViewType, DeleteViewState, useDeleteView, } from './DeleteView'; +export { LocationActionView } from './LocationActionView'; export { UploadView, + UploadViewProps, UploadViewType, UploadViewState, useUploadView, } from './UploadView'; export { useActionView } from './useActionView'; - export { - LocationActionView, + ActionViewState, + LocationActionViewType, LocationActionViewProps, -} from './LocationActionView'; -export { ActionViewState } from './types'; +} from './types'; 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 860ad3a0f4b..13d244a25c4 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts @@ -1,12 +1,4 @@ -import { - ComponentName, - DefaultActionKey, - LocationData, - TaskActionConfig, - TaskData, - TaskHandler, - TaskHandlerInput, -} from '../../actions'; +import { LocationData, TaskData } from '../../actions'; import { LocationState } from '../../providers/store/location'; @@ -32,18 +24,14 @@ export interface ActionViewProps { onExit?: (location?: LocationData) => void; } -export interface LocationActionViewProps< - T = string, - K extends TaskData = TaskData, -> extends Partial>, - ActionViewProps { +export interface LocationActionViewProps { + onExit?: () => void; type?: T; } -export type LocationActionViewType< - T = string, - K extends TaskData = TaskData, -> = (props: LocationActionViewProps) => React.JSX.Element | null; +export type LocationActionViewType = ( + props: LocationActionViewProps +) => React.JSX.Element | null; export interface ActionViewType { ( @@ -52,19 +40,6 @@ export interface ActionViewType { displayName: string; } -// Custom actions derived views -export type DerivedActionViews = { - readonly [K in keyof T as K extends DefaultActionKey - ? never - : T[K] extends { componentName: ComponentName } - ? T[K]['componentName'] - : never]: ActionViewType< - T[K] extends TaskActionConfig>> - ? X - : never - >; -}; - export type HeaderKeys = | 'name' | 'folder' diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailViewProvider.tsx index dcd291f08e4..ad3f786fa2d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailViewProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailViewProvider.tsx @@ -5,6 +5,7 @@ import { useDisplayText } from '../../displayText'; import { LocationDetailViewProviderProps } from './types'; import { getLocationDetailViewTableData } from './getLocationDetailViewTableData'; +import { FileData } from '../../actions'; export function LocationDetailViewProvider({ children, @@ -27,17 +28,15 @@ export function LocationDetailViewProvider({ } = useDisplayText(); const { - actions, + actionItems, page, pageItems, hasNextPage, highestPageVisited, isLoading, - isSearchingSubfolders, + isSearchSubfoldersEnabled, location, - areAllFilesSelected, fileDataItems, - hasFiles, hasError, hasDownloadError, message, @@ -52,18 +51,26 @@ export function LocationDetailViewProvider({ onNavigate, onNavigateHome, onSelect, - onSelectAll, + onToggleSelectAll, onSearch, onSearchQueryChange, onSearchClear, onToggleSearchSubfolders, } = props; - const actionsWithDisplayText = actions.map((item) => ({ + const actionsWithDisplayText = actionItems.map((item) => ({ ...item, label: getActionListItemLabel(item.label), })); + const fileItems = pageItems.filter( + (item): item is FileData => item.type === 'FILE' + ); + + const areAllFilesSelected = fileDataItems?.length === fileItems.length; + + const hasFiles = fileItems.length > 0; + const messageControlContent = getListItemsResultMessage({ isLoading, items: pageItems, @@ -72,17 +79,19 @@ export function LocationDetailViewProvider({ message: hasError ? message : downloadErrorMessage, }); - const isNoActionAvailable = - !Array.isArray(actions) || actions?.every((action) => action.isHidden); + const isActionsListDisabled = + isLoading || + !actionItems?.length || + actionItems.every(({ isHidden }) => isHidden); return ( { - const mockGetListItemsResultMessage = jest.fn(); - return { - useDisplayText: () => ({ - LocationDetailView: { - getTitle: jest.fn(), - getListItemsResultMessage: mockGetListItemsResultMessage, - searchPlaceholder: 'Search current folder', - searchSubmitLabel: 'Submit', - searchExhaustedMessage: 'Exhausted', - getDateDisplayValue: (date: Date) => date.toLocaleString(), - getActionListItemLabel: (key: string | undefined) => key, - }, - }), - }; -}); -jest.mock('../../../providers/configuration'); +jest.mock('../../../controls/ActionsListControl', () => ({ + ActionsListControl: () =>
, +})); +jest.mock('../../../controls/DataRefreshControl', () => ({ + DataRefreshControl: () =>
, +})); jest.mock('../../../controls/DataTableControl', () => ({ DataTableControl: () =>
, })); +jest.mock('../../../controls/DropZoneControl', () => ({ + DropZoneControl: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); jest.mock('../../../controls/LoadingIndicatorControl', () => ({ LoadingIndicatorControl: () => (
), })); +jest.mock('../../../controls/MessageControl', () => ({ + MessageControl: () =>
, +})); jest.mock('../../../controls/NavigationControl', () => ({ - NavigationControl: () => 'NavigationControl', + NavigationControl: () =>
, +})); +jest.mock('../../../controls/PaginationControl', () => ({ + PaginationControl: () =>
, +})); +jest.mock('../../../controls/SearchFieldControl', () => ({ + SearchFieldControl: () =>
, })); jest.mock('../../../controls/SearchSubfoldersToggleControl', () => ({ SearchSubfoldersToggleControl: () => (
), })); -jest.mock('../../../tasks/useProcessTasks'); - -const handleList = jest.fn(); - -const prefix = 'b_prefix/'; -const getFolderPrefix = (index: number) => `a_prefix_${index}`; -const testFolder = { type: 'FOLDER', id: 'folder-01', key: 'a_prefix_test/' }; - -let uuid = 0; -const generateMockItems = ( - size: number -): ListLocationItemsHandlerOutput['items'] => { - return Array.apply(0, new Array(size)).map((_, index) => { - const type = index % 2 == 0 ? 'FILE' : 'FOLDER'; - uuid++; - const id = uuid.toString(); - return type === 'FOLDER' - ? { key: getFolderPrefix(index), id, type: 'FOLDER' } - : { - key: `${prefix}key${index}`, - type: 'FILE', - id, - lastModified: new Date(), - size: Math.floor(Math.random() * 1000000), - }; - }); -}; - -const testResult = [testFolder, ...generateMockItems(200)]; - -const mockListItemsAction = ({ - hasError = false, - isLoading = false, - message, - result, - search, - nextToken = undefined, -}: { - hasError?: boolean; - isLoading?: boolean; - message?: string; - result: any[]; - search?: SearchOutput; - nextToken?: string; -}) => { - jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValue([ - { - data: { items: result, nextToken, search }, - hasError, - isLoading, - message, - }, - handleList, - ]); -}; -const mockUseDisplayText = jest.mocked(useDisplayText); -const mockGetListItemsResultMessage = jest.mocked( - mockUseDisplayText().LocationDetailView.getListItemsResultMessage -); - -const dispatchStoreAction = jest.fn(); -const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); - -const location: LocationData = { - id: 'an-id-👍🏼', - bucket: 'test-bucket', - permissions: ['delete', 'get', 'list', 'write'], - prefix: 'test-prefix/', - type: 'PREFIX', -}; -const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); -const config: ActionInputConfig = { - bucket: 'bucky', - credentials: jest.fn(), - region: 'us-weast-1', -}; -useGetActionSpy.mockReturnValue(() => config); +jest.mock('../../../controls/TitleControl', () => ({ + TitleControl: () =>
, +})); +jest.mock('../LocationDetailViewProvider', () => ({ + LocationDetailViewProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); +jest.mock('../useLocationDetailView'); describe('LocationDetailView', () => { - let user: UserEvent; - - const mockUseProcessTasks = jest.mocked(useProcessTasks); - - beforeAll(() => { - mockUseProcessTasks.mockReturnValue([ - { - isProcessing: false, - isProcessingComplete: false, - statusCounts: INITIAL_STATUS_COUNTS, - tasks: [], - }, - jest.fn(), - ]); - }); + const mockUseLocationDetailView = jest.mocked(useLocationDetailView); beforeEach(() => { - user = userEvent.setup(); + // @ts-expect-error partial mock return value + mockUseLocationDetailView.mockReturnValue({ hasError: false }); }); afterEach(() => { - mockGetListItemsResultMessage.mockClear(); - uuid = 0; - jest.clearAllMocks(); - }); - - it('has the expected composable components', () => { - expect(LocationDetailView.ActionsList).toBeDefined(); - expect(LocationDetailView.DropZone).toBeDefined(); - expect(LocationDetailView.LoadingIndicator).toBeDefined(); - expect(LocationDetailView.LocationItemsTable).toBeDefined(); - expect(LocationDetailView.Message).toBeDefined(); - expect(LocationDetailView.Navigation).toBeDefined(); - expect(LocationDetailView.Pagination).toBeDefined(); - expect(LocationDetailView.Refresh).toBeDefined(); - expect(LocationDetailView.Search).toBeDefined(); - expect(LocationDetailView.SearchSubfoldersToggle).toBeDefined(); - expect(LocationDetailView.Title).toBeDefined(); - }); - - it('shows a Loading element when first loaded', () => { - useStoreSpy.mockReturnValueOnce([ - { - location: { current: location, path: '', key: location.prefix }, - locationItems: { fileDataItems: undefined }, - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - mockListItemsAction({ isLoading: true, result: [] }); - - const { getByTestId } = render(); - - const loadingIndicator = getByTestId('loading-indicator-control'); - - expect(loadingIndicator).toBeInTheDocument(); - }); - - it('invokes getListItemsResultMessage() with `errorMessage` param', () => { - const errorMessage = 'A network error occurred.'; - - mockListItemsAction({ - isLoading: false, - hasError: true, - message: errorMessage, - result: [{ key: 'test1', type: 'FOLDER' }], - nextToken: 'some-token', - }); - - render(); - - expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ - items: expect.any(Array), - isLoading: false, - hasError: true, - message: errorMessage, - hasExhaustedSearch: false, - }); + mockUseLocationDetailView.mockReset(); }); - it('invokes getListItemsResultMessage() with `isLoading` param', () => { - mockListItemsAction({ - isLoading: true, - hasError: false, - result: [], - }); - + it('renders', () => { render(); - expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ - items: [], - isLoading: true, - hasError: false, - hasExhaustedSearch: false, - }); + expect(screen.getByTestId('actions-list-control')).toBeInTheDocument(); + expect(screen.getByTestId('data-refresh-control')).toBeInTheDocument(); + expect(screen.getByTestId('data-table-control')).toBeInTheDocument(); + expect(screen.getByTestId('drop-zone-control')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator-control')).toBeInTheDocument(); + expect(screen.getByTestId('message-control')).toBeInTheDocument(); + expect(screen.getByTestId('navigation-control')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-control')).toBeInTheDocument(); + expect(screen.getByTestId('search-field-control')).toBeInTheDocument(); + expect( + screen.getByTestId('search-subfolders-toggle-control') + ).toBeInTheDocument(); + expect(screen.getByTestId('title-control')).toBeInTheDocument(); }); - it('invokes getListItemsResultMessage() with expected params when there is a download error', () => { - mockUseProcessTasks.mockReturnValueOnce([ - { - isProcessing: false, - isProcessingComplete: false, - statusCounts: { - ...INITIAL_STATUS_COUNTS, - FAILED: 1, - }, - tasks: [ - { - data: { key: 'test-key', id: '123' }, - message: 'NotFound', - progress: 0, - status: 'FAILED', - }, - ], - }, - jest.fn(), - ]); - - mockListItemsAction({ - isLoading: false, - hasError: false, - result: [{ key: 'test1', type: 'FOLDER' }], - nextToken: 'some-token', - }); + it('does not render content on error', () => { + // @ts-expect-error partial mock return value + mockUseLocationDetailView.mockReturnValue({ hasError: true }); render(); - expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ - items: expect.any(Array), - hasError: true, - isLoading: false, - message: 'Failed to download test-key due to error: NotFound.', - hasExhaustedSearch: false, - }); - }); - - it('allows searching for items', async () => { - useStoreSpy.mockReturnValue([ - { - location: { current: location, path: '', key: location.prefix }, - locationItems: { fileDataItems: undefined }, - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - mockListItemsAction({ result: testResult }); - - const { getByPlaceholderText, getByTestId, getByText, getByLabelText } = - render(); - - const input = getByPlaceholderText('Search current folder'); - const searchSubfoldersToggle = getByTestId( - 'search-subfolders-toggle-control' - ); - - expect(input).toBeInTheDocument(); - expect(searchSubfoldersToggle).toBeInTheDocument(); - - input.focus(); - await act(async () => { - await user.keyboard('boo'); - await user.click(searchSubfoldersToggle); - await user.click(getByText('Submit')); - }); - - expect(input).toHaveValue('boo'); - - // search initiated - expect(handleList).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.objectContaining({ - search: { - filterBy: 'key', - query: 'boo', - }, - }), - }) - ); - - // refresh - await act(async () => { - await user.click(getByLabelText('Refresh data')); - }); - - // clears search - expect(input).toHaveValue(''); - }); - - it('shows search exhausted message', async () => { - useStoreSpy.mockReturnValue([ - { - location: { current: location, path: '', key: location.prefix }, - locationItems: { fileDataItems: undefined }, - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - mockListItemsAction({ - result: testResult, - search: { hasExhaustedSearch: true }, - }); - - const { getByPlaceholderText, getByText } = render(); - - const input = getByPlaceholderText('Search current folder'); - expect(input).toBeInTheDocument(); - input.focus(); - await act(async () => { - await user.keyboard('boo'); - await user.click(getByText('Submit')); - }); - - expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ - items: expect.any(Array), - hasExhaustedSearch: true, - isLoading: false, - hasError: false, - message: undefined, - }); - - // search initiated - expect(handleList).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.objectContaining({ - search: { - filterBy: 'key', - query: 'boo', - }, - }), - }) - ); - }); - - it('loads initial location items for a BUCKET location as expected', () => { - useStoreSpy.mockReturnValueOnce([ - { - location: { current: location, path: '', key: location.prefix }, - locationItems: { fileDataItems: undefined }, - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - - mockListItemsAction({ - isLoading: false, - hasError: false, - result: [{ key: 'test1', type: 'FOLDER' }], - nextToken: 'some-token', - }); - - const { getByTestId } = render(); - - expect(getByTestId('data-table-control')).toBeInTheDocument(); - expect(handleList).toHaveBeenCalledTimes(1); - expect(handleList).toHaveBeenCalledWith({ - config, - prefix: location.prefix, - options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - }); - }); - - it('refreshes table and clears selection state when refresh button is clicked', async () => { - useStoreSpy.mockReturnValue([ - { - location: { current: location, path: '', key: location.prefix }, - locationItems: { fileDataItems: undefined }, - } as StoreModule.UseStoreState, - dispatchStoreAction, - ]); - - mockListItemsAction({ result: testResult }); - - const { getByLabelText } = render(); - - const refreshButton = getByLabelText('Refresh data'); - - await act(async () => { - await user.click(refreshButton); - }); - - expect(handleList).toHaveBeenCalledWith({ - config, - prefix: location.prefix, - options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - }); - - expect(dispatchStoreAction).toHaveBeenLastCalledWith({ - type: 'RESET_LOCATION_ITEMS', - }); + expect(screen.queryByTestId('data-table-control')).not.toBeInTheDocument(); + expect(screen.queryByTestId('drop-zone-control')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('loading-indicator-control') + ).not.toBeInTheDocument(); }); }); 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 0d9a6d53f80..5ab95b6c6b1 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 @@ -1,9 +1,6 @@ import { renderHook, act } from '@testing-library/react'; -import * as AmplifyReactCore from '@aws-amplify/ui-react-core'; - import { - ActionInputConfig, LocationData, LocationItemData, FileData, @@ -11,19 +8,18 @@ import { FolderData, } from '../../../actions'; -import * as StoreModule from '../../../providers/store'; -import * as ConfigModule from '../../../providers/configuration'; -import * as TasksModule from '../../../tasks'; +import { useStore } from '../../../providers/store'; import { LocationState } from '../../../providers/store/location'; +import { useAction, useList } from '../../../useAction'; 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'); +jest.mock('../../../actions/handlers'); +jest.mock('../../../providers/store'); +jest.mock('../../../useAction'); const folderDataOne: FolderData = { id: '1', @@ -102,59 +98,45 @@ const testStoreState = { actionType: undefined, }; -const config: ActionInputConfig = { - bucket: 'bucky', - credentials: jest.fn(), - region: 'us-weast-1', -}; -useGetActionSpy.mockReturnValue(() => config); - -const taskOne: TasksModule.Task = { - data: fileItem, - cancel: jest.fn(), - message: undefined, - progress: undefined, - status: 'QUEUED', -}; - -const handleDownload = jest.fn(); -jest.spyOn(TasksModule, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - isProcessingComplete: false, - statusCounts: TasksModule.INITIAL_STATUS_COUNTS, - tasks: [taskOne], - }, - handleDownload, -]); - describe('useLocationDetailView', () => { const mockLocation = { current: undefined, path: '', key: '' }; - // create mocks + const mockDataState = { + data: { items: testData, nextToken: undefined }, + message: '', + hasError: false, + isLoading: false, + }; + const mockUseAction = jest.mocked(useAction); + const mockUseList = jest.mocked(useList); + const mockUseStore = jest.mocked(useStore); const mockDispatchStoreAction = jest.fn(); + const mockHandleDownload = jest.fn(); + const mockHandleList = jest.fn(); - afterEach(() => { - jest.clearAllMocks(); + beforeAll(() => { + mockUseAction.mockReturnValue([{}, mockHandleDownload]); }); - it('should fetch and set location data on mount', () => { - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); - const mockDataState = { - data: { items: testData, nextToken: undefined }, - message: '', - hasError: false, - isLoading: false, - }; + beforeEach(() => { + mockUseStore.mockReturnValue([testStoreState, mockDispatchStoreAction]); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); + }); - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); + afterEach(() => { + mockUseAction.mockClear(); + mockDispatchStoreAction.mockClear(); + mockHandleDownload.mockClear(); + mockHandleList.mockClear(); + mockUseList.mockReset(); + mockUseStore.mockReset(); + }); + it('should fetch and set location data on mount', () => { const initialState = { initialValues: { pageSize: EXPECTED_PAGE_SIZE } }; const { result } = renderHook(() => useLocationDetailView(initialState)); // fetches data - expect(handleListMock).toHaveBeenCalledWith({ - config, + expect(mockHandleList).toHaveBeenCalledWith({ options: { ...DEFAULT_LIST_OPTIONS, refresh: true, @@ -170,35 +152,25 @@ describe('useLocationDetailView', () => { }); it('should not fetch on mount for invalid prefix', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); - const mockDataState = { - data: { items: testData, nextToken: undefined }, - message: '', - hasError: false, - isLoading: false, - }; - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); - renderHook(() => useLocationDetailView({ initialValues: { pageSize: EXPECTED_PAGE_SIZE }, }) ); - expect(handleListMock).not.toHaveBeenCalled(); + expect(mockHandleList).not.toHaveBeenCalled(); }); it('should handle pagination actions', () => { - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); const mockHandleList = jest.fn(); // set up empty page - useDataStateSpy.mockReturnValue([ + mockUseList.mockReturnValue([ { data: { items: [], nextToken: undefined }, message: '', @@ -226,12 +198,12 @@ describe('useLocationDetailView', () => { isLoading: false, }; - useDataStateSpy.mockReturnValue([mockDataState, mockHandleList]); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); rerender(initialValues); // set up second page mock - useDataStateSpy.mockReturnValue([ + mockUseList.mockReturnValue([ { data: { items: testData, nextToken: undefined }, message: '', @@ -261,16 +233,14 @@ describe('useLocationDetailView', () => { }); it('should handle refreshing location data', () => { - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); - const mockDataState = { - data: { result: [], nextToken: 'token123' }, + data: { items: [], nextToken: 'token123' }, message: '', hasError: false, isLoading: false, }; - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); + const mockHandleList = jest.fn(); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); const { result } = renderHook(() => useLocationDetailView()); @@ -288,15 +258,14 @@ describe('useLocationDetailView', () => { expect(result.current.page).toEqual(1); // data refreshed - expect(handleListMock).toHaveBeenCalledWith({ - config, + expect(mockHandleList).toHaveBeenCalledWith({ options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, prefix: 'item-b-key/', }); }); it('should not refresh location data for invalid paths', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); @@ -308,8 +277,8 @@ describe('useLocationDetailView', () => { isLoading: false, }; - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); + const mockHandleList = jest.fn(); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); const { result } = renderHook(() => useLocationDetailView()); @@ -317,11 +286,11 @@ describe('useLocationDetailView', () => { result.current.onRefresh(); }); expect(result.current.page).toEqual(1); - expect(handleListMock).not.toHaveBeenCalled(); + expect(mockHandleList).not.toHaveBeenCalled(); }); it('should handle selecting a location', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); @@ -356,14 +325,13 @@ describe('useLocationDetailView', () => { ); result.current.onDownload(fileDataOne); - expect(handleDownload).toHaveBeenCalledTimes(1); - expect(handleDownload).toHaveBeenCalledWith({ config, data: fileDataOne }); + expect(mockHandleDownload).toHaveBeenCalledTimes(1); + expect(mockHandleDownload).toHaveBeenCalledWith({ data: fileDataOne }); }); it('should navigate home', () => { const mockOnExit = jest.fn(); - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); const { result } = renderHook(() => useLocationDetailView({ onExit: mockOnExit }) ); @@ -380,7 +348,6 @@ describe('useLocationDetailView', () => { }); it('should set a file item as selected', () => { - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); const { result } = renderHook(() => useLocationDetailView()); const state = result.current; state.onSelect(false, fileItem); @@ -392,7 +359,6 @@ describe('useLocationDetailView', () => { }); it('should set a file item as unselected', () => { - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); const { result } = renderHook(() => useLocationDetailView()); const state = result.current; state.onSelect(true, fileItem); @@ -404,7 +370,7 @@ describe('useLocationDetailView', () => { }); it('should set all file items as selected', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, locationItems: { fileDataItems: undefined }, @@ -422,12 +388,12 @@ describe('useLocationDetailView', () => { isLoading: false, }; - useDataStateSpy.mockReturnValue([mockDataState, jest.fn()]); + mockUseList.mockReturnValue([mockDataState, jest.fn()]); const { result } = renderHook(() => useLocationDetailView()); - const { onSelectAll } = result.current; + const { onToggleSelectAll } = result.current; - onSelectAll(); + onToggleSelectAll(); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SET_LOCATION_ITEMS', @@ -456,19 +422,19 @@ describe('useLocationDetailView', () => { fileKey: 'maybe-cool.png', }; - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, locationItems: { fileDataItems: [fileDataItemOne, fileDataItemTwo] }, }, mockDispatchStoreAction, ]); - useDataStateSpy.mockReturnValue([mockDataState, jest.fn()]); + mockUseList.mockReturnValue([mockDataState, jest.fn()]); const { result } = renderHook(() => useLocationDetailView()); - const { onSelectAll } = result.current; + const { onToggleSelectAll } = result.current; - onSelectAll(); + onToggleSelectAll(); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'RESET_LOCATION_ITEMS', @@ -476,7 +442,7 @@ describe('useLocationDetailView', () => { }); it('should handle adding files', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); @@ -504,7 +470,7 @@ describe('useLocationDetailView', () => { }); it('should handle adding folders', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); @@ -527,7 +493,7 @@ describe('useLocationDetailView', () => { }); it('should handle as files if adding files and folders', () => { - useStoreSpy.mockReturnValue([ + mockUseStore.mockReturnValue([ { ...testStoreState, location: mockLocation }, mockDispatchStoreAction, ]); @@ -554,15 +520,15 @@ describe('useLocationDetailView', () => { it('should handle search', () => { const handleStoreActionMock = jest.fn(); - useStoreSpy.mockReturnValue([testStoreState, handleStoreActionMock]); + mockUseStore.mockReturnValue([testStoreState, handleStoreActionMock]); const mockDataState = { data: { items: [], nextToken: undefined }, message: '', hasError: false, isLoading: false, }; - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); + const mockHandleList = jest.fn(); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); const { result } = renderHook(() => useLocationDetailView()); act(() => { @@ -574,8 +540,7 @@ describe('useLocationDetailView', () => { }); // search complete - expect(handleListMock).toHaveBeenCalledWith({ - config, + expect(mockHandleList).toHaveBeenCalledWith({ options: { ...DEFAULT_LIST_OPTIONS, delimiter: '/', @@ -592,7 +557,7 @@ describe('useLocationDetailView', () => { result.current.onSearchClear(); }); - expect(handleListMock).toHaveBeenCalledWith( + expect(mockHandleList).toHaveBeenCalledWith( expect.objectContaining({ options: expect.objectContaining({ refresh: true, @@ -603,15 +568,15 @@ describe('useLocationDetailView', () => { it('should handle search with subfolders', () => { const handleStoreActionMock = jest.fn(); - useStoreSpy.mockReturnValue([testStoreState, handleStoreActionMock]); + mockUseStore.mockReturnValue([testStoreState, handleStoreActionMock]); const mockDataState = { data: { items: [], nextToken: undefined }, message: '', hasError: false, isLoading: false, }; - const handleListMock = jest.fn(); - useDataStateSpy.mockReturnValue([mockDataState, handleListMock]); + const mockHandleList = jest.fn(); + mockUseList.mockReturnValue([mockDataState, mockHandleList]); const { result } = renderHook(() => useLocationDetailView()); act(() => { @@ -624,8 +589,7 @@ describe('useLocationDetailView', () => { }); // search complete - expect(handleListMock).toHaveBeenCalledWith({ - config, + expect(mockHandleList).toHaveBeenCalledWith({ options: { ...DEFAULT_LIST_OPTIONS, delimiter: undefined, @@ -642,7 +606,7 @@ describe('useLocationDetailView', () => { result.current.onSearchClear(); }); - expect(handleListMock).toHaveBeenCalledWith( + expect(mockHandleList).toHaveBeenCalledWith( expect.objectContaining({ options: expect.objectContaining({ refresh: true, @@ -654,7 +618,6 @@ describe('useLocationDetailView', () => { it('should handle action selection', () => { const mockOnActionSelect = jest.fn(); const actionType = 'action-type'; - useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); const { result } = renderHook(() => useLocationDetailView({ onActionSelect: mockOnActionSelect }) diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/getLocationDetailViewTableData.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/getLocationDetailViewTableData.ts index 94c5f70cd86..5d41043dc83 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/getLocationDetailViewTableData.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/getLocationDetailViewTableData.ts @@ -1,16 +1,17 @@ -import { DataTableProps } from '../../../composables/DataTable'; -import { LocationData } from '../../../actions'; import { + FileData, createFileDataItem, FileDataItem, LocationItemData, -} from '../../../actions/handlers'; + LocationData, +} from '../../../actions'; +import { DataTableProps } from '../../../composables/DataTable'; +import { LocationState } from '../../../providers/store/location'; + import { getFileRowContent } from './getFileRowContent'; import { getFolderRowContent } from './getFolderRowContent'; -import { FileData } from '../../../actions/handlers'; import { LOCATION_DETAIL_VIEW_HEADERS } from './constants'; -import { LocationState } from '../../../providers/store/location'; export const getLocationDetailViewTableData = ({ areAllFilesSelected, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts index 3074020d7ed..2535e328e92 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts @@ -4,43 +4,42 @@ import { LocationData, LocationItemData, } from '../../actions'; -import { ActionsListItem } from '../../composables/ActionsList'; +import { ActionListItem } from '../../composables/ActionsList'; import { LocationState } from '../../providers/store/location'; import { ListViewProps } from '../types'; export interface LocationDetailViewState { - actions: ActionsListItem[]; + actionItems: ActionListItem[]; + actionType: string | undefined; + downloadErrorMessage: string | undefined; + fileDataItems: FileDataItem[] | undefined; + hasDownloadError: boolean; hasError: boolean; + hasExhaustedSearch: boolean; hasNextPage: boolean; - hasDownloadError: boolean; highestPageVisited: number; isLoading: boolean; - isSearchingSubfolders: boolean; + isSearchSubfoldersEnabled: boolean; location: LocationState; - areAllFilesSelected: boolean; - fileDataItems: FileDataItem[] | undefined; - hasFiles: boolean; message: string | undefined; - downloadErrorMessage: string | undefined; - shouldShowEmptyMessage: boolean; - searchQuery: string; - hasExhaustedSearch: boolean; - pageItems: LocationItemData[]; - page: number; + onActionExit: () => void; onActionSelect: (actionType: string) => void; + onDownload: (fileItem: FileDataItem) => void; onDropFiles: (files: File[]) => void; - onRefresh: () => void; onNavigate: (location: LocationData, path?: string) => void; onNavigateHome: () => void; onPaginate: (page: number) => void; - onDownload: (fileItem: FileDataItem) => void; - onSelect: (isSelected: boolean, fileItem: FileData) => void; - onSelectAll: () => void; + onRefresh: () => void; onSearch: () => void; onSearchClear: () => void; onSearchQueryChange: (value: string) => void; + onSelect: (isSelected: boolean, fileItem: FileData) => void; onToggleSearchSubfolders: () => void; + onToggleSelectAll: () => void; + page: number; + pageItems: LocationItemData[]; + searchQuery: string; } export interface LocationDetailViewProps extends ListViewProps { 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 1ef69380f67..803da24b94b 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts @@ -1,26 +1,22 @@ import React from 'react'; import { isFunction, isUndefined } from '@aws-amplify/ui'; -import { useDataState } from '@aws-amplify/ui-react-core'; import { usePaginate } from '../hooks/usePaginate'; import { useStore } from '../../providers/store'; import { + DownloadHandlerData, + FileDataItem, FileData, LocationData, - listLocationItemsHandler, + useActionConfigs, } from '../../actions'; -import { createEnhancedListHandler } from '../../actions/useAction/createEnhancedListHandler'; -import { useGetActionInput } from '../../providers/configuration'; +import { useAction, useList } from '../../useAction'; + import { useSearch } from '../hooks/useSearch'; -import { Tasks, useProcessTasks } from '../../tasks'; -import { - downloadHandler, - DownloadHandlerData, - FileDataItem, - defaultActionViewConfigs, -} from '../../actions'; +import { Task } from '../../tasks'; + import { LocationDetailViewState, UseLocationDetailViewOptions } from './types'; const DEFAULT_PAGE_SIZE = 100; @@ -29,26 +25,19 @@ export const DEFAULT_LIST_OPTIONS = { pageSize: DEFAULT_PAGE_SIZE, }; -const listLocationItemsAction = createEnhancedListHandler( - listLocationItemsHandler -); - const getDownloadErrorMessageFromFailedDownloadTask = ( - tasks: Tasks + task: Task | undefined ): string | undefined => { - if (!tasks.length) { - return undefined; - } + if (!task) return; return `Failed to download ${ - tasks[0].data.fileKey ?? tasks[0].data.key - } due to error: ${tasks[0].message}.`; + task.data.fileKey ?? task.data.key + } due to error: ${task.message}.`; }; export const useLocationDetailView = ( options?: UseLocationDetailViewOptions ): LocationDetailViewState => { - const getConfig = useGetActionInput(); const { initialValues, onExit, onNavigate } = options ?? {}; const listOptionsRef = React.useRef({ @@ -58,18 +47,17 @@ export const useLocationDetailView = ( const listOptions = listOptionsRef.current; - const [{ location, locationItems }, dispatchStoreAction] = useStore(); + const [{ location, locationItems, actionType }, dispatchStoreAction] = + useStore(); const { current, key } = location; const { permissions, prefix } = current ?? {}; const { fileDataItems } = locationItems; const hasInvalidPrefix = isUndefined(prefix); - const [downloadTaskResult, handleDownload] = useProcessTasks(downloadHandler); + const [{ task }, handleDownload] = useAction('download'); - const [{ data, isLoading, hasError, message }, handleList] = useDataState( - listLocationItemsAction, - { items: [], nextToken: undefined } - ); + const [{ data, isLoading, hasError, message }, handleList] = + useList('locationItems'); // set up pagination const { items, nextToken, search } = data; @@ -79,7 +67,6 @@ export const useLocationDetailView = ( if (hasInvalidPrefix || !nextToken) return; dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); handleList({ - config: getConfig(), prefix: key, options: { ...listOptions, nextToken }, }); @@ -111,13 +98,14 @@ export const useLocationDetailView = ( }; handleReset(); - handleList({ config: getConfig(), prefix: key, options: searchOptions }); + handleList({ prefix: key, options: searchOptions }); + dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); }; const { searchQuery, - isSearchingSubfolders, + isSearchingSubfolders: isSearchSubfoldersEnabled, onSearchQueryChange, onSearchSubmit, onToggleSearchSubfolders, @@ -126,90 +114,79 @@ export const useLocationDetailView = ( const onRefresh = () => { if (hasInvalidPrefix) return; + handleReset(); resetSearch(); handleList({ - config: getConfig(), prefix: key, options: { ...listOptions, refresh: true }, }); + dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); }; React.useEffect(() => { if (hasInvalidPrefix) return; handleList({ - config: getConfig(), prefix: key, options: { ...listOptions, refresh: true }, }); handleReset(); - }, [ - handleList, - handleReset, - listOptions, - hasInvalidPrefix, - getConfig, - prefix, - key, - ]); + }, [handleList, handleReset, listOptions, hasInvalidPrefix, key]); - // Logic for Select All Files functionality - const fileItems = React.useMemo( - () => pageItems.filter((item): item is FileData => item.type === 'FILE'), - [pageItems] - ); - const areAllFilesSelected = fileDataItems?.length === fileItems.length; - const shouldShowEmptyMessage = - pageItems.length === 0 && !isLoading && !hasError; + const { actionConfigs } = useActionConfigs(); - const actions = React.useMemo(() => { + const actionItems = React.useMemo(() => { if (!permissions) { return []; } - return Object.entries(defaultActionViewConfigs).map( - ([actionType, config]) => { - const { actionsListItemConfig } = config ?? {}; - - const { icon, hide, disable, label } = actionsListItemConfig ?? {}; - - return { - actionType, - icon, - isDisabled: isFunction(disable) - ? disable(fileDataItems) - : disable ?? false, - isHidden: isFunction(hide) ? hide(permissions) : hide, - label, - }; - } - ); - }, [fileDataItems, permissions]); + return !actionConfigs + ? [] + : Object.entries(actionConfigs).map(([type, { actionListItem }]) => { + const { icon, hide, disable, label } = actionListItem ?? {}; + + return { + actionType: type, + icon, + isDisabled: isFunction(disable) + ? disable(fileDataItems) + : disable ?? false, + isHidden: isFunction(hide) ? hide(permissions) : hide, + label, + }; + }); + }, [actionConfigs, fileDataItems, permissions]); return { - actions, + actionItems, + actionType, page: currentPage, pageItems, location, - areAllFilesSelected, fileDataItems, - hasFiles: fileItems.length > 0, hasError, - hasDownloadError: downloadTaskResult.statusCounts.FAILED > 0, + hasDownloadError: task?.status === 'FAILED', hasNextPage: hasNextToken, highestPageVisited, message, - downloadErrorMessage: getDownloadErrorMessageFromFailedDownloadTask( - downloadTaskResult.tasks - ), - shouldShowEmptyMessage, + downloadErrorMessage: getDownloadErrorMessageFromFailedDownloadTask(task), isLoading, - isSearchingSubfolders, + isSearchSubfoldersEnabled, onPaginate, searchQuery, hasExhaustedSearch, onRefresh, + onActionExit: () => { + dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); + }, + onActionSelect: (nextActionType) => { + options?.onActionSelect?.(nextActionType); + dispatchStoreAction({ + type: 'SET_ACTION_TYPE', + actionType: nextActionType, + }); + }, onNavigate: (location: LocationData, path?: string) => { onNavigate?.(location, path); resetSearch(); @@ -224,14 +201,13 @@ export const useLocationDetailView = ( options?.onActionSelect?.(actionType); }, onDownload: (data: FileDataItem) => { - handleDownload({ config: getConfig(), data }); + handleDownload({ data }); }, onNavigateHome: () => { onExit?.(); dispatchStoreAction({ type: 'RESET_LOCATION' }); handleList({ - config: getConfig(), // @todo: prefix should not be required to refresh prefix: prefix ?? '', options: { reset: true }, @@ -239,10 +215,6 @@ export const useLocationDetailView = ( dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); }, - onActionSelect: (actionType) => { - options?.onActionSelect?.(actionType); - dispatchStoreAction({ type: 'SET_ACTION_TYPE', actionType }); - }, onSelect: (isSelected: boolean, fileItem: FileData) => { dispatchStoreAction( isSelected @@ -250,9 +222,12 @@ export const useLocationDetailView = ( : { type: 'SET_LOCATION_ITEMS', items: [fileItem] } ); }, - onSelectAll: () => { + onToggleSelectAll: () => { + const fileItems = pageItems.filter( + (item): item is FileData => item.type === 'FILE' + ); dispatchStoreAction( - areAllFilesSelected + fileItems.length === fileDataItems?.length ? { type: 'RESET_LOCATION_ITEMS' } : { type: 'SET_LOCATION_ITEMS', items: fileItems } ); @@ -262,7 +237,6 @@ export const useLocationDetailView = ( resetSearch(); if (hasInvalidPrefix) return; handleList({ - config: getConfig(), prefix: key, options: { ...listOptions, refresh: true }, }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsViewProvider.tsx index e1be11e3aa7..7314723b456 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsViewProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsViewProvider.tsx @@ -42,7 +42,7 @@ export function LocationsViewProvider({ const messageControlContent = getListLocationsResultMessage({ hasExhaustedSearch, isLoading, - locations: pageItems, + items: pageItems, hasError, message, }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx index 64bb0b0343c..dc12c1153d9 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx @@ -1,413 +1,68 @@ import React from 'react'; -import { act, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import * as ActionsModule from '../../../actions'; -import * as ConfigModule from '../../../providers/configuration'; -import * as StoreModule from '../../../providers/store'; - +import { render, screen } from '@testing-library/react'; import { LocationsView } from '../LocationsView'; -import { DEFAULT_LIST_OPTIONS } from '../useLocationsView'; -import { ActionInputConfig, LocationData } from '../../../actions'; -import { DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT } from '../../../displayText/libraries'; -import { useDisplayText } from '../../../displayText'; - -jest.mock('../../../displayText', () => { - const mockGetListLocationsResultMessage = jest.fn(); - return { - useDisplayText: () => ({ - LocationsView: { - ...DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT.LocationsView, - getListLocationsResultMessage: mockGetListLocationsResultMessage, - }, - }), - }; -}); - -const dispatchStoreAction = jest.fn(); -jest - .spyOn(StoreModule, 'useStore') - .mockReturnValue([{} as StoreModule.UseStoreState, dispatchStoreAction]); - -const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); -const useListLocationsSpy = jest.spyOn(ActionsModule, 'useListLocations'); -const mockUseDisplayText = jest.mocked(useDisplayText); -const mockGetListLocationsResultMessage = jest.mocked( - mockUseDisplayText().LocationsView.getListLocationsResultMessage -); - -const generateMockItems = (size: number, page: number): LocationData[] => { - return Array(size) - .fill(null) - .map((_, index) => { - index = index + size * (page - 1); - const type = page % 2 == 0 ? 'BUCKET' : 'PREFIX'; - return { - bucket: 'test-bucket', - prefix: `item-${index}/`, - permissions: ['delete', 'get', 'list', 'write'], - id: `identity-${index}`, - type, - }; - }); -}; - -const handleListLocations = jest.fn(); -const initialState: ActionsModule.UseListLocationsState = [ - { - data: { items: [], nextToken: undefined }, - hasError: false, - isLoading: false, - message: undefined, - }, - handleListLocations, -]; - -const loadingState: ActionsModule.UseListLocationsState = [ - { - data: { items: [], nextToken: undefined }, - hasError: false, - isLoading: true, - message: undefined, - }, - handleListLocations, -]; - -const EXPECTED_PAGE_SIZE = DEFAULT_LIST_OPTIONS.pageSize; -const items: LocationData[] = generateMockItems(EXPECTED_PAGE_SIZE, 1); - -const resolvedState: ActionsModule.UseListLocationsState = [ - { - data: { - items, - nextToken: 'some-token', - }, - hasError: false, - isLoading: false, - message: undefined, - }, - handleListLocations, -]; - -const nextPageitems = generateMockItems(EXPECTED_PAGE_SIZE, 2); - -const nextPageState: ActionsModule.UseListLocationsState = [ - { - data: { - items: [...items, ...nextPageitems], - nextToken: undefined, - }, - hasError: false, - isLoading: false, - message: undefined, - }, - handleListLocations, -]; - -const config: ActionInputConfig = { - bucket: 'bucky', - credentials: jest.fn(), - region: 'us-west-1', -}; -useGetActionSpy.mockReturnValue(() => config); +import { useLocationsView } from '../useLocationsView'; + +jest.mock('../../../controls/DataRefreshControl', () => ({ + DataRefreshControl: () =>
, +})); +jest.mock('../../../controls/DataTableControl', () => ({ + DataTableControl: () =>
, +})); +jest.mock('../../../controls/LoadingIndicatorControl', () => ({ + LoadingIndicatorControl: () => ( +
+ ), +})); +jest.mock('../../../controls/MessageControl', () => ({ + MessageControl: () =>
, +})); +jest.mock('../../../controls/PaginationControl', () => ({ + PaginationControl: () =>
, +})); +jest.mock('../../../controls/SearchFieldControl', () => ({ + SearchFieldControl: () =>
, +})); +jest.mock('../../../controls/TitleControl', () => ({ + TitleControl: () =>
, +})); +jest.mock('../LocationsViewProvider', () => ({ + LocationsViewProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); +jest.mock('../useLocationsView'); describe('LocationsView', () => { - afterEach(() => { - mockGetListLocationsResultMessage.mockClear(); - jest.clearAllMocks(); - }); - - it('has the expected composable components', () => { - expect(LocationsView.LoadingIndicator).toBeDefined(); - expect(LocationsView.LocationsTable).toBeDefined(); - expect(LocationsView.Message).toBeDefined(); - expect(LocationsView.Pagination).toBeDefined(); - expect(LocationsView.Refresh).toBeDefined(); - expect(LocationsView.Search).toBeDefined(); - expect(LocationsView.Title).toBeDefined(); - }); - - it('renders and calls appropriate hooks', () => { - useListLocationsSpy.mockReturnValue([ - { - data: { items, nextToken: undefined }, - hasError: true, - isLoading: false, - message: undefined, - }, - handleListLocations, - ]); - - render(); + const mockUseLocationsView = jest.mocked(useLocationsView); - expect(useListLocationsSpy).toHaveBeenCalled(); + beforeEach(() => { + // @ts-expect-error partial mock return value + mockUseLocationsView.mockReturnValue({ hasError: false }); }); - it('invokes getListLocationsResultMessage() with `errorMessage` param', () => { - const errorMessage = 'Something went wrong.'; - - useListLocationsSpy.mockReturnValue([ - { - data: { items, nextToken: undefined }, - hasError: true, - isLoading: false, - message: errorMessage, - }, - handleListLocations, - ]); - - render(); - - expect(mockGetListLocationsResultMessage).toHaveBeenCalledWith({ - locations: expect.any(Array), - isLoading: false, - hasError: true, - hasExhaustedSearch: false, - message: errorMessage, - }); - - // table doesn't render - const table = screen.queryByRole('table'); - expect(table).not.toBeInTheDocument(); - - // pagination disabled - const nextPage = screen.getByLabelText('Go to next page'); - expect(nextPage).toBeDisabled(); - const prevPage = screen.getByLabelText('Go to previous page'); - expect(prevPage).toBeDisabled(); - }); - - it('does not show Message when items are being loaded', () => { - useListLocationsSpy.mockReturnValue([ - { - data: { - items: [], - nextToken: undefined, - search: { hasExhaustedSearch: false }, - }, - hasError: false, - isLoading: true, - message: undefined, - }, - handleListLocations, - ]); - - render(); - - expect(mockGetListLocationsResultMessage).toHaveBeenCalledWith({ - locations: [], - isLoading: true, - hasError: false, - hasExhaustedSearch: false, - }); - }); - - it('renders a Locations View table', () => { - useListLocationsSpy.mockReturnValue(resolvedState); - - render(); - - const table = screen.getByRole('table'); - - expect(table).toBeInTheDocument(); - }); - - it.todo('handles failure from locations loading as expected'); - - it.todo('handles empty locations result data as expected'); - - it('behaves as expected on initial render', () => { - useListLocationsSpy - .mockReturnValueOnce(initialState) - .mockReturnValueOnce(loadingState) - .mockReturnValue(resolvedState); - - const { rerender } = render(); - - expect(handleListLocations).toHaveBeenCalledTimes(1); - expect(handleListLocations).toHaveBeenCalledWith({ - options: { - ...DEFAULT_LIST_OPTIONS, - refresh: true, - }, - }); - - rerender(); - - expect(handleListLocations).toHaveBeenCalledTimes(1); - - rerender(); - - expect(handleListLocations).toHaveBeenCalledTimes(1); + afterEach(() => { + mockUseLocationsView.mockReset(); }); - it('refreshes table when refresh button is clicked', async () => { - useListLocationsSpy.mockReturnValue(resolvedState); - + it('renders', () => { render(); - const refreshButton = screen.getByLabelText('Refresh data'); - expect(refreshButton).toBeEnabled(); - - await act(async () => { - await userEvent.click(refreshButton); - }); - - expect(handleListLocations).toHaveBeenCalledWith({ - options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - }); - }); - - it('refreshes locations on handleListLocations reference change', () => { - const updatedHandleListLocations = jest.fn(); - - useListLocationsSpy.mockReturnValue(initialState); - - // initial - const { rerender } = render(); - - useListLocationsSpy.mockReturnValue(loadingState); - - // loading - rerender(); - - useListLocationsSpy.mockReturnValueOnce(resolvedState); - - // resolved - rerender(); - - expect(handleListLocations).toHaveBeenCalledTimes(1); - expect(handleListLocations).toHaveBeenCalledWith({ - options: { - exclude: { exactPermissions: ['delete', 'write'] }, - pageSize: EXPECTED_PAGE_SIZE, - refresh: true, - }, - }); - expect(updatedHandleListLocations).not.toHaveBeenCalled(); - - useListLocationsSpy.mockReturnValue([ - { ...resolvedState[0] }, - updatedHandleListLocations, - ]); - - // reference change - rerender(); - - expect(handleListLocations).toHaveBeenCalledTimes(1); - expect(updatedHandleListLocations).toHaveBeenCalledTimes(1); - expect(updatedHandleListLocations).toHaveBeenCalledWith({ - options: { - exclude: { exactPermissions: ['delete', 'write'] }, - pageSize: EXPECTED_PAGE_SIZE, - refresh: true, - }, - }); + expect(screen.getByTestId('data-refresh-control')).toBeInTheDocument(); + expect(screen.getByTestId('data-table-control')).toBeInTheDocument(); + expect(screen.getByTestId('loading-indicator-control')).toBeInTheDocument(); + expect(screen.getByTestId('message-control')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-control')).toBeInTheDocument(); + expect(screen.getByTestId('search-field-control')).toBeInTheDocument(); + expect(screen.getByTestId('title-control')).toBeInTheDocument(); }); - it('can paginate forward and back', async () => { - useListLocationsSpy.mockReturnValue(resolvedState); - render(); - - // table renders - const table = screen.getByRole('table'); - expect(table).toBeInTheDocument(); - - // pagination enabled - const nextPage = await screen.findByLabelText('Go to next page'); - expect(nextPage).not.toBeDisabled(); - - // first page data matches input - expect(screen.queryByLabelText('Page 1')).toBeInTheDocument(); - expect(screen.queryByText('item-0/')).toBeInTheDocument(); - expect(screen.queryByText('item-101/')).not.toBeInTheDocument(); - - useListLocationsSpy.mockReturnValue(nextPageState); + it('does not render content on error', () => { + // @ts-expect-error partial mock return value + mockUseLocationsView.mockReturnValue({ hasError: true }); - // go forward - await act(async () => { - await userEvent.click(nextPage); - }); - - // second page data matches input - expect(screen.queryByLabelText('Page 2')).toBeInTheDocument(); - expect(screen.queryByText('item-0/')).not.toBeInTheDocument(); - expect(screen.queryByText('item-101/')).toBeInTheDocument(); - - // pagination enabled - const previousPage = await screen.findByLabelText('Go to previous page'); - expect(previousPage).not.toBeDisabled(); - - // go back - await act(async () => { - await userEvent.click(previousPage); - }); - - // first page data matches input - expect(screen.queryByLabelText('Page 1')).toBeInTheDocument(); - expect(screen.queryByText('item-0/')).toBeInTheDocument(); - expect(screen.queryByText('item-101/')).not.toBeInTheDocument(); - }); - - it('should navigate to detail page when folder is clicked', async () => { - useListLocationsSpy.mockReturnValue(resolvedState); render(); - const scopeButton = await screen.findByText('item-0/'); - await userEvent.click(scopeButton); - - expect(dispatchStoreAction).toHaveBeenCalledWith({ - type: 'NAVIGATE', - location: { - bucket: 'test-bucket', - id: 'identity-0', - prefix: 'item-0/', - type: 'PREFIX', - permissions: ['delete', 'get', 'list', 'write'], - }, - }); - }); - - it('allows searching for items', async () => { - const user = userEvent.setup(); - const { getByPlaceholderText, getByText, queryByText, getByLabelText } = - render(); - - const input = getByPlaceholderText('Filter folders and files'); - - expect(input).toBeInTheDocument(); - expect(queryByText('item-0/')).toBeInTheDocument(); - expect(queryByText('item-1/')).toBeInTheDocument(); - - input.focus(); - await act(async () => { - await user.keyboard('item-0'); - await user.click(getByText('Submit')); - }); - - // search initiated - expect(handleListLocations).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.objectContaining({ - search: { - filterBy: expect.any(Function), - query: 'item-0', - }, - }), - }) - ); - - // refresh - await act(async () => { - await user.click(getByLabelText('Refresh data')); - }); - - expect(handleListLocations).toHaveBeenCalledWith( - expect.objectContaining({ - options: expect.objectContaining({ - refresh: true, - }), - }) - ); + expect(screen.queryByTestId('data-table-control')).not.toBeInTheDocument(); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/useLocationsView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/useLocationsView.spec.tsx index 8ebc8030898..6ab3b50f575 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/useLocationsView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/useLocationsView.spec.tsx @@ -1,27 +1,20 @@ import { renderHook, act } from '@testing-library/react'; import { DataState } from '@aws-amplify/ui-react-core'; -import { useLocationsView, DEFAULT_LIST_OPTIONS } from '../useLocationsView'; - -import * as ActionsModule from '../../../actions'; -import * as StoreModule from '../../../providers/store'; -import * as TasksModule from '../../../tasks'; -import * as ConfigModule from '../../../providers/configuration'; +import { ListLocationsOutput, LocationData } from '../../../actions'; +import { getFileKey } from '../../../actions/handlers'; +import { UseStoreState, useStore } from '../../../providers/store'; +import { useAction, useList } from '../../../useAction'; -import { createFileDataItemFromLocation } from '../../../actions/handlers'; +import { useLocationsView, DEFAULT_LIST_OPTIONS } from '../useLocationsView'; +jest.mock('../../../actions/handlers'); +jest.mock('../../../providers/store'); +jest.mock('../../../useAction'); jest.useFakeTimers(); jest.setSystemTime(1); -const dispatchStoreAction = jest.fn(); -jest - .spyOn(StoreModule, 'useStore') - .mockReturnValue([{} as StoreModule.UseStoreState, dispatchStoreAction]); - -const useLocationsDataSpy = jest.spyOn(ActionsModule, 'useListLocations'); -const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); - -const mockData: ActionsModule.LocationData[] = [ +const mockData: LocationData[] = [ { bucket: 'test-bucket', prefix: `item-a/`, @@ -59,49 +52,36 @@ const mockData: ActionsModule.LocationData[] = [ }, ]; -const EXPECTED_PAGE_SIZE = 3; -function mockUseLocationsData( - returnValue: DataState -) { - const handleList = jest.fn(); - useLocationsDataSpy.mockReturnValue([returnValue, handleList]); - return handleList; -} - -const taskOne: TasksModule.Task = { - data: { - fileKey: 'key', - id: 'id', - key: 'key', - lastModified: new Date(1), - size: 0, - type: 'FILE', - }, - cancel: jest.fn(), - message: undefined, - progress: undefined, - status: 'QUEUED', -}; - -const handleDownload = jest.fn(); -jest.spyOn(TasksModule, 'useProcessTasks').mockReturnValue([ - { - isProcessing: false, - isProcessingComplete: false, - statusCounts: TasksModule.INITIAL_STATUS_COUNTS, - tasks: [taskOne], - }, - handleDownload, -]); - -const config: ActionsModule.ActionInputConfig = { - bucket: 'bucky', - credentials: jest.fn(), - region: 'us-weast-1', -}; -useGetActionSpy.mockReturnValue(() => config); - describe('useLocationsView', () => { + const EXPECTED_PAGE_SIZE = 3; + const mockId = 'intentionally-static-test-id'; + const fileKey = 'file-key'; + + const mockGetFileKey = jest.mocked(getFileKey); + const mockUseAction = jest.mocked(useAction); + const mockUseList = jest.mocked(useList); + const mockUseStore = jest.mocked(useStore); + const mockDispatchStoreAction = jest.fn(); + const mockHandleDownload = jest.fn(); + + function mockUseLocationsData(returnValue: DataState) { + const handleList = jest.fn(); + mockUseList.mockReturnValue([returnValue, handleList]); + return handleList; + } + + beforeAll(() => { + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: () => mockId }, + }); + mockUseStore.mockReturnValue([ + {} as UseStoreState, + mockDispatchStoreAction, + ]); + mockUseAction.mockReturnValue([{}, mockHandleDownload]); + mockGetFileKey.mockReturnValue(fileKey); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -238,7 +218,7 @@ describe('useLocationsView', () => { state.onNavigate(expectedLocation); }); - expect(dispatchStoreAction).toHaveBeenCalledWith({ + expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'NAVIGATE', location: expectedLocation, }); @@ -246,7 +226,7 @@ describe('useLocationsView', () => { it('should handle downloading a file', () => { const { result } = renderHook(() => useLocationsView()); - const location: ActionsModule.LocationData = { + const location: LocationData = { bucket: 'bucket', id: 'id', permissions: ['get'], @@ -255,10 +235,14 @@ describe('useLocationsView', () => { }; result.current.onDownload(location); - expect(handleDownload).toHaveBeenCalledTimes(1); - expect(handleDownload).toHaveBeenCalledWith({ - config, - data: createFileDataItemFromLocation(location), + expect(mockHandleDownload).toHaveBeenCalledTimes(1); + expect(mockHandleDownload).toHaveBeenCalledWith({ + data: { + fileKey, + id: mockId, + key: location.prefix, + }, + location, }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts index 5bd295b9f6f..5569f3e47cc 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts @@ -2,23 +2,22 @@ import { LocationData } from '../../actions'; import { ListViewProps } from '../types'; export interface LocationsViewState { - hasNextPage: boolean; hasError: boolean; + hasExhaustedSearch: boolean; + hasNextPage: boolean; highestPageVisited: number; isLoading: boolean; message: string | undefined; - shouldShowEmptyMessage: boolean; - pageItems: LocationData[]; - page: number; - searchQuery: string; - hasExhaustedSearch: boolean; onDownload: (item: LocationData) => void; onNavigate: (location: LocationData) => void; - onRefresh: () => void; onPaginate: (page: number) => void; + onRefresh: () => void; onSearch: () => void; - onSearchQueryChange: (value: string) => void; onSearchClear: () => void; + onSearchQueryChange: (value: string) => void; + page: number; + pageItems: LocationData[]; + searchQuery: string; } export interface LocationsViewProps extends ListViewProps {} @@ -28,12 +27,7 @@ export interface LocationsViewProviderProps extends LocationsViewState { } export interface LocationsViewType { - ( - props: { - children?: React.ReactNode; - className?: string; - } & LocationsViewProps - ): React.JSX.Element | null; + (props: LocationsViewProps): React.JSX.Element | null; displayName: string; Provider: (props: LocationsViewProviderProps) => React.JSX.Element; LoadingIndicator: () => React.JSX.Element | null; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts index d5c0d7cf620..9d48e35d27a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts @@ -1,18 +1,13 @@ import React from 'react'; -import { usePaginate } from '../hooks/usePaginate'; -import { - createFileDataItemFromLocation, - downloadHandler, - ListLocationsExcludeOptions, - LocationData, - useListLocations, -} from '../../actions'; +import { ListLocationsExcludeOptions, LocationData } from '../../actions'; import { useStore } from '../../providers/store'; +import { useAction, useList } from '../../useAction'; + +import { usePaginate } from '../hooks/usePaginate'; import { useSearch } from '../hooks/useSearch'; -import { useGetActionInput } from '../../providers/configuration'; -import { useProcessTasks } from '../../tasks'; import { LocationsViewState, UseLocationsViewOptions } from './types'; +import { getFileKey } from '../../actions/handlers'; const DEFAULT_EXCLUDE: ListLocationsExcludeOptions = { exactPermissions: ['delete', 'write'], @@ -26,13 +21,11 @@ export const DEFAULT_LIST_OPTIONS = { export const useLocationsView = ( options?: UseLocationsViewOptions ): LocationsViewState => { - const getConfig = useGetActionInput(); + const handleDownload = useAction('download')[1]; + const [state, handleList] = useList('locations'); + const dispatchStoreAction = useStore()[1]; - const [state, handleList] = useListLocations(); const { data, message, hasError, isLoading } = state; - - const [_, handleDownload] = useProcessTasks(downloadHandler); - const [, dispatchStoreAction] = useStore(); const { items, nextToken, search } = data; const hasNextToken = !!nextToken; const { hasExhaustedSearch = false } = search ?? {}; @@ -48,17 +41,13 @@ export const useLocationsView = ( // initial load React.useEffect(() => { - handleList({ - options: { ...listOptions, refresh: true }, - }); + handleList({ options: { ...listOptions, refresh: true } }); }, [handleList, listOptions]); // set up pagination const paginateCallback = () => { if (!nextToken) return; - handleList({ - options: { ...listOptions, nextToken }, - }); + handleList({ options: { ...listOptions, nextToken } }); }; const { @@ -92,9 +81,6 @@ export const useLocationsView = ( const { searchQuery, onSearchQueryChange, onSearchSubmit, resetSearch } = useSearch({ onSearch }); - const shouldShowEmptyMessage = - pageItems.length === 0 && !isLoading && !hasError; - return { isLoading, hasError, @@ -103,13 +89,17 @@ export const useLocationsView = ( hasNextPage: hasNextToken, highestPageVisited, pageItems, - shouldShowEmptyMessage, searchQuery, hasExhaustedSearch, onDownload: (location: LocationData) => { + const { prefix: key } = location; handleDownload({ - config: getConfig(location), - data: createFileDataItemFromLocation(location), + data: { + fileKey: getFileKey(key), + key, + id: crypto.randomUUID(), + }, + location, }); }, onNavigate: (location: LocationData) => { @@ -119,9 +109,7 @@ export const useLocationsView = ( onRefresh: () => { resetSearch(); handleReset(); - handleList({ - options: { ...listOptions, refresh: true }, - }); + handleList({ options: { ...listOptions, refresh: true } }); }, onPaginate, onSearch: onSearchSubmit, diff --git a/packages/react-storage/src/components/StorageBrowser/views/context.tsx b/packages/react-storage/src/components/StorageBrowser/views/context.tsx deleted file mode 100644 index db19d1421cf..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/context.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; - -import { - LocationActionView as LocationActionViewDefault, - LocationActionViewProps, -} from './LocationActionView'; -import { - LocationDetailView as LocationDetailViewDefault, - LocationDetailViewProps, -} from './LocationDetailView'; -import { - LocationsView as LocationsViewDefault, - LocationsViewProps, -} from './LocationsView'; - -const ERROR_MESSAGE = '`useViews` must be called from within a `ViewsProvider`'; - -export interface DefaultViews { - LocationActionView: ( - props: LocationActionViewProps - ) => React.JSX.Element | null; - LocationDetailView: ( - props: LocationDetailViewProps - ) => React.JSX.Element | null; - LocationsView: (props: LocationsViewProps) => React.JSX.Element | null; -} - -export interface Views extends Partial> {} - -const ViewsContext = React.createContext(undefined); - -export function ViewsProvider({ - children, - views, -}: { - children?: React.ReactNode; - views?: Views; -}): React.JSX.Element { - // destructure `views` to prevent extraneous rerender of components in the - // scenario of an unstable reference provided as `views` - const { LocationDetailView, LocationActionView, LocationsView } = views ?? {}; - const value = React.useMemo( - () => ({ - LocationActionView: LocationActionView ?? LocationActionViewDefault, - LocationDetailView: LocationDetailView ?? LocationDetailViewDefault, - LocationsView: LocationsView ?? LocationsViewDefault, - }), - [LocationDetailView, LocationActionView, LocationsView] - ); - - return ( - {children} - ); -} - -export function useViews(): DefaultViews { - const context = React.useContext(ViewsContext); - if (!context) { - throw new Error(ERROR_MESSAGE); - } - - return context; -} diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx b/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx new file mode 100644 index 00000000000..027fff9aa9b --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/actionViews.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { ActionViewsContextType } from './types'; +import { UploadView } from '../LocationActionView/UploadView'; +import { CreateFolderView } from '../LocationActionView/CreateFolderView'; +import { CopyView } from '../LocationActionView/CopyView'; +import { DeleteView } from '../LocationActionView/DeleteView'; + +import { DefaultActionViewsByActionName } from '../types'; + +export const DEFAULT_ACTION_VIEWS: DefaultActionViewsByActionName = { + createFolder: CreateFolderView, + copy: CopyView, + delete: DeleteView, + upload: UploadView, +}; + +export const ActionViewsContext = React.createContext({ + action: DEFAULT_ACTION_VIEWS, +}); + +export function useActionViews(): ActionViewsContextType { + return React.useContext(ActionViewsContext); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/getViews.ts b/packages/react-storage/src/components/StorageBrowser/views/context/getViews.ts new file mode 100644 index 00000000000..6d4177d4fa0 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/getViews.ts @@ -0,0 +1,45 @@ +import React from 'react'; +import { capitalize, isFunction, isObject } from '@aws-amplify/ui'; + +import { CustomActionConfigs } from '../../actions'; +import { DEFAULT_ACTION_VIEWS } from './actionViews'; +import { DEFAULT_PRIMARY_VIEWS } from './primaryViews'; +import { DefaultActionViewsByActionName, Views } from '../types'; +import { ViewsContextType } from './types'; + +export const getViews = ( + views?: Views, + customConfigs?: CustomActionConfigs +): ViewsContextType => { + const resolvedDefaultActionViews = Object.entries( + DEFAULT_ACTION_VIEWS + ).reduce((output, [actionName, component]) => { + // use viewName to lookup overrides for default action views + const viewName = capitalize(`${actionName}View` as keyof Views); + return { + ...output, + [actionName]: (views?.[viewName] ?? component) as React.ComponentType, + }; + }, {} as DefaultActionViewsByActionName); + + const customActionViews = !isObject(customConfigs) + ? {} + : Object.entries(customConfigs).reduce((acc, [key, config]) => { + // ignore custom actions that are only handlers + return !isObject(config) || isFunction(config) + ? acc + : { ...acc, [key]: views?.[config.viewName as keyof Views] }; + }, {}); + + return { + action: { ...resolvedDefaultActionViews, ...customActionViews }, + primary: { + LocationActionView: + views?.LocationActionView ?? DEFAULT_PRIMARY_VIEWS.LocationActionView, + LocationDetailView: + views?.LocationDetailView ?? DEFAULT_PRIMARY_VIEWS.LocationDetailView, + LocationsView: + views?.LocationsView ?? DEFAULT_PRIMARY_VIEWS.LocationsView, + }, + }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/index.ts b/packages/react-storage/src/components/StorageBrowser/views/context/index.ts new file mode 100644 index 00000000000..6f40950655d --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/index.ts @@ -0,0 +1 @@ +export { ViewsProvider, useViews } from './views'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/primaryViews.tsx b/packages/react-storage/src/components/StorageBrowser/views/context/primaryViews.tsx new file mode 100644 index 00000000000..91608b3341a --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/primaryViews.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { PrimaryViewsContextType } from './types'; + +import { LocationActionView as LocationActionViewDefault } from '../LocationActionView'; +import { LocationDetailView as LocationDetailViewDefault } from '../LocationDetailView'; +import { LocationsView as LocationsViewDefault } from '../LocationsView'; +import { PrimaryViews } from '../types'; + +export const DEFAULT_PRIMARY_VIEWS: PrimaryViews = { + LocationActionView: LocationActionViewDefault, + LocationDetailView: LocationDetailViewDefault, + LocationsView: LocationsViewDefault, +}; + +export const PrimaryViewsContext = React.createContext( + { + primary: DEFAULT_PRIMARY_VIEWS, + } +); + +export function usePrimaryViews(): PrimaryViewsContextType { + return React.useContext(PrimaryViewsContext); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/types.ts b/packages/react-storage/src/components/StorageBrowser/views/context/types.ts new file mode 100644 index 00000000000..fadccb1af90 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/types.ts @@ -0,0 +1,13 @@ +import { DefaultActionViewsByActionName, PrimaryViews } from '../types'; + +export interface PrimaryViewsContextType { + primary: PrimaryViews; +} + +export interface ActionViewsContextType { + action: DefaultActionViewsByActionName & T; +} + +export interface ViewsContextType + extends PrimaryViewsContextType, + ActionViewsContextType {} diff --git a/packages/react-storage/src/components/StorageBrowser/views/context/views.tsx b/packages/react-storage/src/components/StorageBrowser/views/context/views.tsx new file mode 100644 index 00000000000..6e050e10f06 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/context/views.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ExtendedActionConfigs } from '../../actions'; +import { Views } from '../types'; +import { ViewsContextType } from './types'; +import { getViews } from './getViews'; +import { ActionViewsContext } from './actionViews'; +import { PrimaryViewsContext } from './primaryViews'; + +export function ViewsProvider({ + children, + views, + actions, +}: { + children?: React.ReactNode; + actions?: ExtendedActionConfigs; + views?: Views; +}): React.JSX.Element { + const { custom } = actions ?? {}; + + const value = React.useMemo(() => getViews(views, custom), [custom, views]); + + return ( + + + {children} + + + ); +} + +export function useViews(): ViewsContextType { + return { + primary: React.useContext(PrimaryViewsContext).primary, + action: React.useContext(ActionViewsContext).action, + }; +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/createUseView.ts b/packages/react-storage/src/components/StorageBrowser/views/createUseView.ts deleted file mode 100644 index 1c616d3f92c..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/createUseView.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ActionConfigs } from '../actions'; - -import { - useCopyView, - useCreateFolderView, - useUploadView, - useDeleteView, - ActionViewState, - useActionView, -} from './LocationActionView'; -import { useLocationsView } from './LocationsView'; -import { useLocationDetailView } from './LocationDetailView'; - -const DEFAULT_USE_VIEWS = { - CopyView: useCopyView, - CreateFolderView: useCreateFolderView, - DeleteView: useDeleteView, - LocationDetailView: useLocationDetailView, - LocationsView: useLocationsView, - UploadView: useUploadView, -} as const; - -type DefaultUseViews = typeof DEFAULT_USE_VIEWS; - -export type ViewKey = T extends Record< - string, - { componentName: `${infer U}View` } -> - ? U - : never; - -type UseViewState = `${T}View` extends keyof DefaultUseViews - ? ReturnType - : ActionViewState; - -type UseView = >( - type: K -) => UseViewState; - -type CreateUseView = (configs: T) => UseView; - -const isDefaultUseViewName = ( - viewName?: string -): viewName is keyof DefaultUseViews => - Object.keys(DEFAULT_USE_VIEWS).some((key) => key === viewName); - -export const createUseView: CreateUseView = (configs) => { - const hooks: Record = Object.values(configs).reduce( - (out, { componentName }) => ({ - ...out, - [componentName.slice(0, -4)]: isDefaultUseViewName(componentName) - ? DEFAULT_USE_VIEWS[componentName] - : useActionView, - }), - {} - ); - - return function useView(type) { - // todo: add assertion here - - return hooks[type](type); - }; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/index.ts b/packages/react-storage/src/components/StorageBrowser/views/index.ts index 9246b602433..96296366e67 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/index.ts @@ -7,6 +7,7 @@ export { DeleteViewType, LocationActionView, LocationActionViewProps, + LocationActionViewType, UploadView, UploadViewType, } from './LocationActionView'; @@ -20,4 +21,5 @@ export { LocationsViewProps, LocationsViewType, } from './LocationsView'; -export * from './context'; + +export * from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/types.ts b/packages/react-storage/src/components/StorageBrowser/views/types.ts index e512a78405b..487e18f99b8 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/types.ts @@ -1,5 +1,16 @@ +import React from 'react'; import { LocationData } from '../actions'; +import { + LocationActionViewProps, + UploadViewProps, + CreateFolderViewProps, + CopyViewProps, + DeleteViewProps, +} from './LocationActionView'; +import { LocationDetailViewProps } from './LocationDetailView'; +import { LocationsViewProps } from './LocationsView'; + export interface ActionViewProps { className?: string; } @@ -14,3 +25,31 @@ export interface ListViewState { export interface ListViewProps extends ActionViewProps, Partial {} + +export interface PrimaryViews { + LocationActionView: ( + props: LocationActionViewProps + ) => React.JSX.Element | null; + LocationDetailView: ( + props: LocationDetailViewProps + ) => React.JSX.Element | null; + LocationsView: (props: LocationsViewProps) => React.JSX.Element | null; +} + +export interface DefaultActionViews { + CreateFolderView: (props: CreateFolderViewProps) => React.JSX.Element | null; + CopyView: (props: CopyViewProps) => React.JSX.Element | null; + DeleteView: (props: DeleteViewProps) => 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; + delete: (props: DeleteViewProps) => React.JSX.Element | null; + upload: (props: UploadViewProps) => React.JSX.Element | null; +} + +export type Views = Partial< + PrimaryViews & DefaultActionViews & K +>; diff --git a/packages/react-storage/src/components/StorageBrowser/views/useView.ts b/packages/react-storage/src/components/StorageBrowser/views/useView.ts new file mode 100644 index 00000000000..7560f1de271 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/useView.ts @@ -0,0 +1,48 @@ +import { + useCopyView, + useCreateFolderView, + useUploadView, + useDeleteView, +} from './LocationActionView'; +import { useLocationsView } from './LocationsView'; +import { useLocationDetailView } from './LocationDetailView'; + +const USE_VIEW_HOOKS = { + Copy: useCopyView, + CreateFolder: useCreateFolderView, + Delete: useDeleteView, + LocationDetail: useLocationDetailView, + Locations: useLocationsView, + Upload: useUploadView, +}; + +type DefaultUseViews = typeof USE_VIEW_HOOKS; +export type UseViewType = keyof DefaultUseViews; + +export type ViewKey = T extends Record< + string, + { componentName?: `${infer U}View` } +> + ? U + : T extends Record + ? K + : never; + +export type UseView = < + K extends keyof DefaultUseViews, + S extends DefaultUseViews[K], +>( + type: K +) => ReturnType; + +const isUseViewType = (value: unknown): value is UseViewType => + !!USE_VIEW_HOOKS?.[value as UseViewType]; + +// @ts-expect-error +export const useView: UseView = (type) => { + if (!isUseViewType(type)) { + throw new Error(`Value of \`${type}\` cannot be used to index \`useView\``); + } + + return USE_VIEW_HOOKS[type](); +};