Repository: aws-amplify/amplify-ui (TypeScript)

Issue:
StorageManager does not respect `path` prop
### Before creating a new issue, please confirm:


- [X] I have [searched for duplicate or closed issues](https://github.com/aws-amplify/amplify-ui/issues?q=is%3Aissue+) and [discussions](https://github.com/aws-amplify/amplify-ui/discussions).
- [X] I have tried disabling all browser extensions or using a different browser
- [X] I have tried deleting the node_modules folder and reinstalling my dependencies
- [X] I have read the guide for [submitting bug reports](https://github.com/aws-amplify/amplify-ui/blob/main/CONTRIBUTING.md#bug-reports).

### On which framework/platform are you having an issue?

React

### Which UI component?

Storage (Storage Manager)

### How is your app built?

Next.js

### What browsers are you seeing the problem on?

Safari

### Which region are you seeing the problem in?

_No response_

### Please describe your bug.

I am using the `StorageManager` to upload files to S3. Specifically I am using the `path` prop, which according to [the docs](https://ui.docs.amplify.aws/react/connected-components/storage/storagemanager) is

```
A path to put files in the s3 bucket. This will be prepended to the key sent to s3 for each file.
```

However, when I upload files, I am not observing the file uploaded under the specified `path`.

### What's the expected behaviour?


Because `src/app/page.tsx` specifies `path={"folder"}`

```typescript
<StorageManager
  acceptedFileTypes={["image/*"]}
  path={"folder"}
  ...
/>
```

I expect the file uploaded to be uploaded to the S3 bucket nested under a folder named `folder`.

However, the file is uploaded to S3, but not nested under any path. The file is uploaded to the root of the s3 bucket.


### Help us reproduce the bug!

I've created a repro repo so you can see the bug in action: https://github.com/johnpc/feb13-storagemanager

Run the following bash commands to set up the environment

```bash
git clone https://github.com/johnpc/feb13-storagemanager
cd feb13-storagemanager
npm install
npx amplify sandbox
npm run dev
```

Then navigate to http://localhost:3000 and upload a file via the file manager. You'll then find your file at the root of your S3 bucket (not at the specified path)

### Code Snippet

```typescript
// Put your code below this line.

```


### Console log output

_No response_

### Additional information and screenshots

_No response_


Discussion:
Verified that `path` is not included in the `key` passed to `uploadFile`. Will be working on a fix
I am having this problem but today is the first time I have ever tried using the StorageManager. Is this bug present if I temporarily install an older version?
You can downgrade to v5 `npm i aws-amplify@^5 @aws-amplify/ui-react-storage@^2` but that's a different major version and might affect other parts of your app.
@johnpc @owenleej Fix has been released in `@aws-amplify/ui-react-storage@3.0.14`



Patch:
diff --git a/.changeset/healthy-rings-deny.md b/.changeset/healthy-rings-deny.md
new file mode 100644
index 00000000000..a679c147346
--- /dev/null
+++ b/.changeset/healthy-rings-deny.md
@@ -0,0 +1,5 @@
+---
+"@aws-amplify/ui-react-storage": patch
+---
+
+Fix/update storage manager path handling
diff --git a/docs/src/pages/[platform]/connected-components/storage/storagemanager/examples/StorageManagerPathPropExample.tsx b/docs/src/pages/[platform]/connected-components/storage/storagemanager/examples/StorageManagerPathPropExample.tsx
new file mode 100644
index 00000000000..9b7ebd3f30b
--- /dev/null
+++ b/docs/src/pages/[platform]/connected-components/storage/storagemanager/examples/StorageManagerPathPropExample.tsx
@@ -0,0 +1,13 @@
+import { StorageManager } from '@aws-amplify/ui-react-storage';
+
+export const StorageManagerPathPropExample = () => {
+  return (
+    <StorageManager
+      accessLevel="guest"
+      // uploaded files can be found in S3 inside `public/images/`
+      path="images/"
+      acceptedFileTypes={['image/*']}
+      maxFileCount={1}
+    />
+  );
+};
diff --git a/docs/src/pages/[platform]/connected-components/storage/storagemanager/react.mdx b/docs/src/pages/[platform]/connected-components/storage/storagemanager/react.mdx
index ab4c9b32d8c..2138fed4259 100644
--- a/docs/src/pages/[platform]/connected-components/storage/storagemanager/react.mdx
+++ b/docs/src/pages/[platform]/connected-components/storage/storagemanager/react.mdx
@@ -14,12 +14,13 @@ import {
   StorageManagerDisplayTextExample,
   StorageManagerEventExample,
   StorageManagerFileTypesExample,
+  StorageManagerHandleExample,
   StorageManagerHashExample,
   StorageManageri18nExample,
   StorageManagerMetadataExample,
+  StorageManagerPathPropExample,
   StorageManagerResumableExample,
   StorageManagerThemeExample,
-  StorageManagerHandleExample,
   StorageManagerUploadActionsExample,
 } from './examples'
 
@@ -140,6 +141,17 @@ The StorageManager component has several event handlers: `onUploadStart`, `onUpl
 Be careful setting state in the `onUploadSuccess` because that function is bound when the upload _starts_. Make sure to use the previous state argument rather than the current state in the component.
 </Alert>
 
+## `path` Usage
+
+The `path` prop of the `StorageManager` is prepended to the `key` value (resolved from either the file itself or the returned `key` of `processFile`) submitted to S3. Using a `'/'` as the last character of `path` allows uploading to a specific folder inside the provided `accessLevel` folder.
+
+<Example>
+  <ExampleCode>
+    ```jsx file=./examples/StorageManagerPathPropExample.tsx
+    ```
+  </ExampleCode>
+</Example>
+
 ## Adding metadata
 
 Metadata is added as an object with string key-value pairs. It is sent as custom HTTP headers with the name `x-amz-meta-[key]`. For example, if your metadata for a file was `{mode: 'night'}`, it would set the `x-amz-meta-mode` HTTP header to `night`.
diff --git a/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/useUploadFiles.ts b/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/useUploadFiles.ts
index 76728597096..ab6513589d3 100644
--- a/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/useUploadFiles.ts
+++ b/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/useUploadFiles.ts
@@ -1,6 +1,7 @@
 import * as React from 'react';
 
 import { TransferProgressEvent } from 'aws-amplify/storage';
+import { isFunction, isString } from '@aws-amplify/ui';
 
 import { uploadFile } from '../../utils/uploadFile';
 import { FileStatus } from '../../types';
@@ -37,7 +38,7 @@ export function useUploadFiles({
   onUploadStart,
   maxFileCount,
   processFile,
-  path = '',
+  path,
 }: UseUploadFilesProps): void {
   React.useEffect(() => {
     const filesReadyToUpload = files.filter(
@@ -50,7 +51,9 @@ export function useUploadFiles({
 
     for (const { file, key, id } of filesReadyToUpload) {
       const onComplete: (event: { key?: string }) => void = (event) => {
-        onUploadSuccess?.(event);
+        if (isFunction(onUploadSuccess)) {
+          onUploadSuccess(event);
+        }
         setUploadSuccess({ id });
       };
 
@@ -66,27 +69,32 @@ export function useUploadFiles({
         setUploadProgress({ id, progress: progressPercentage });
       };
 
-      const onError = (error: string) => {
-        onUploadError?.(error, { key });
-      };
-
       if (file) {
         resolveFile({ processFile, file, key }).then(
           ({ key: processedKey, ...rest }) => {
-            onUploadStart?.({ key: processedKey });
+            // prepend `path` to `processedKey`
+            const resolvedKey = isString(path)
+              ? `${path}${processedKey}`
+              : processedKey;
+
+            if (isFunction(onUploadStart)) {
+              onUploadStart({ key: resolvedKey });
+            }
+
             const uploadTask = uploadFile({
               ...rest,
-              key: processedKey,
+              key: resolvedKey,
               level: accessLevel,
               progressCallback: onProgress,
-              errorCallback: onError,
+              errorCallback: (error: string) => {
+                if (isFunction(onUploadError)) {
+                  onUploadError(error, { key: resolvedKey });
+                }
+              },
               completeCallback: onComplete,
             });
 
-            setUploadingFile({
-              id,
-              uploadTask,
-            });
+            setUploadingFile({ id, uploadTask });
           }
         );
       }


Test patch:
diff --git a/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts b/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts
index 4d478757d14..844decfc3ea 100644
--- a/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts
+++ b/packages/react-storage/src/components/StorageManager/hooks/useUploadFiles/__tests__/useUploadFiles.spec.ts
@@ -1,14 +1,22 @@
 import { renderHook } from '@testing-library/react-hooks';
 import { waitFor } from '@testing-library/react';
-import { setImmediate } from 'timers';
+
 import * as Storage from 'aws-amplify/storage';
 
 import { FileStatus, StorageFile, StorageManagerProps } from '../../../types';
 import { useUploadFiles, UseUploadFilesProps } from '../useUploadFiles';
 
-const uploadDataSpy = jest.spyOn(Storage, 'uploadData');
-
-const flushPromises = () => new Promise(setImmediate);
+const uploadDataSpy = jest
+  .spyOn(Storage, 'uploadData')
+  .mockImplementation((input: Storage.UploadDataInput) => {
+    return {
+      cancel: jest.fn(),
+      pause: jest.fn(),
+      resume: jest.fn(),
+      state: 'SUCCESS',
+      result: Promise.resolve({ key: input.key, data: input.data }),
+    };
+  });
 
 const mockUploadingFile: StorageFile = {
   id: 'uploading',
@@ -48,28 +56,15 @@ const props: Omit<UseUploadFilesProps, 'files'> = {
 
 describe('useUploadFiles', () => {
   beforeEach(() => {
-    uploadDataSpy.mockClear();
-    jest.resetAllMocks();
+    jest.clearAllMocks();
   });
 
   it('should upload all queued files', async () => {
-    uploadDataSpy.mockImplementationOnce((input: Storage.UploadDataInput) => {
-      return {
-        cancel: jest.fn(),
-        pause: jest.fn(),
-        resume: jest.fn(),
-        state: 'SUCCESS',
-        result: Promise.resolve({ key: input.key, data: input.data }),
-      };
-    });
-    renderHook(() =>
-      useUploadFiles({
-        ...props,
-        files: [mockUploadingFile, mockQueuedFile],
-      })
+    const { waitForNextUpdate } = renderHook(() =>
+      useUploadFiles({ ...props, files: [mockUploadingFile, mockQueuedFile] })
     );
 
-    await flushPromises();
+    waitForNextUpdate();
 
     await waitFor(() => {
       expect(mockSetUploadingFile).toHaveBeenCalledTimes(1);
@@ -92,23 +87,15 @@ describe('useUploadFiles', () => {
   });
 
   it('should upload all resumable queued files', async () => {
-    uploadDataSpy.mockImplementationOnce((input: Storage.UploadDataInput) => {
-      return {
-        cancel: jest.fn(),
-        pause: jest.fn(),
-        resume: jest.fn(),
-        state: 'SUCCESS',
-        result: Promise.resolve({ key: input.key, data: input.data }),
-      };
-    });
-    renderHook(() =>
+    const { waitForNextUpdate } = renderHook(() =>
       useUploadFiles({
         ...props,
         isResumable: true,
         files: [mockUploadingFile, mockQueuedFile],
       })
     );
-    await flushPromises();
+
+    waitForNextUpdate();
 
     await waitFor(() => {
       expect(mockSetUploadingFile).toHaveBeenCalledTimes(1);
@@ -123,19 +110,11 @@ describe('useUploadFiles', () => {
   });
 
   it('should do nothing if number of queued files exceeds max number of files', async () => {
-    uploadDataSpy.mockImplementationOnce((input: Storage.UploadDataInput) => {
-      return {
-        cancel: jest.fn(),
-        pause: jest.fn(),
-        resume: jest.fn(),
-        state: 'SUCCESS',
-        result: Promise.resolve({ key: input.key, data: input.data }),
-      };
-    });
-    renderHook(() =>
+    const { waitForNextUpdate } = renderHook(() =>
       useUploadFiles({ ...props, maxFileCount: 0, files: [mockQueuedFile] })
     );
-    await flushPromises();
+
+    waitForNextUpdate();
 
     expect(mockSetUploadingFile).not.toHaveBeenCalled();
 
@@ -155,9 +134,11 @@ describe('useUploadFiles', () => {
         result: Promise.reject(errorMessage),
       };
     });
-    renderHook(() => useUploadFiles({ ...props, files: [mockQueuedFile] }));
+    const { waitForNextUpdate } = renderHook(() =>
+      useUploadFiles({ ...props, files: [mockQueuedFile] })
+    );
 
-    await flushPromises();
+    waitForNextUpdate();
 
     await waitFor(() => {
       expect(mockOnUploadError).toHaveBeenCalledTimes(1);
@@ -166,22 +147,12 @@ describe('useUploadFiles', () => {
   });
 
   it('should start upload after processFile', async () => {
-    uploadDataSpy.mockImplementationOnce((input: Storage.UploadDataInput) => {
-      return {
-        cancel: jest.fn(),
-        pause: jest.fn(),
-        resume: jest.fn(),
-        state: 'SUCCESS',
-        result: Promise.resolve({ key: input.key, data: input.data }),
-      };
+    const processFile: StorageManagerProps['processFile'] = ({ file }) => ({
+      file,
+      key: 'test.png',
     });
-    const processFile: StorageManagerProps['processFile'] = ({ file }) => {
-      return {
-        file,
-        key: 'test.png',
-      };
-    };
-    renderHook(() =>
+
+    const { waitForNextUpdate } = renderHook(() =>
       useUploadFiles({
         ...props,
         isResumable: true,
@@ -190,33 +161,18 @@ describe('useUploadFiles', () => {
       })
     );
 
-    await flushPromises();
+    waitForNextUpdate();
 
     await waitFor(() => {
-      expect(mockOnUploadStart).toHaveBeenCalledWith({
-        key: 'test.png',
-      });
+      expect(mockOnUploadStart).toHaveBeenCalledWith({ key: 'test.png' });
     });
   });
 
   it('should start upload after processFile promise resolves', async () => {
-    uploadDataSpy.mockImplementationOnce((input: Storage.UploadDataInput) => {
-      return {
-        cancel: jest.fn(),
-        pause: jest.fn(),
-        resume: jest.fn(),
-        state: 'SUCCESS',
-        result: Promise.resolve({ key: input.key, data: input.data }),
-      };
-    });
-    const processFile: StorageManagerProps['processFile'] = ({ file }) => {
-      return new Promise((resolve) => {
-        setTimeout(() => {
-          resolve({ file, key: 'test.png' });
-        }, 100);
-      });
-    };
-    renderHook(() =>
+    const processFile: StorageManagerProps['processFile'] = ({ file }) =>
+      new Promise((resolve) => resolve({ file, key: 'test.png' }));
+
+    const { waitForNextUpdate } = renderHook(() =>
       useUploadFiles({
         ...props,
         isResumable: true,
@@ -225,17 +181,35 @@ describe('useUploadFiles', () => {
       })
     );
 
-    await flushPromises();
-
-    await waitFor(
-      () => {
-        expect(mockOnUploadStart).toHaveBeenCalledWith({
-          key: 'test.png',
-        });
-      },
-      {
-        timeout: 200,
-      }
+    waitForNextUpdate();
+
+    await waitFor(() => {
+      expect(mockOnUploadStart).toHaveBeenCalledWith({
+        key: 'test.png',
+      });
+    });
+  });
+
+  it('prepends valid provided `path` to `processedKey`', async () => {
+    const path = 'test-path/';
+    const { waitForNextUpdate } = renderHook(() =>
+      useUploadFiles({
+        ...props,
+        isResumable: true,
+        files: [mockQueuedFile],
+        path,
+      })
     );
+    const expected = { key: `${path}${mockQueuedFile.key}` };
+
+    waitForNextUpdate();
+
+    await waitFor(() => {
+      expect(mockOnUploadStart).toHaveBeenCalledWith(expected);
+      expect(uploadDataSpy).toHaveBeenCalledTimes(1);
+      expect(uploadDataSpy).toHaveBeenCalledWith(
+        expect.objectContaining(expected)
+      );
+    });
   });
 });