diff --git a/.changeset/loud-walls-do.md b/.changeset/loud-walls-do.md new file mode 100644 index 00000000000..d165c85a04f --- /dev/null +++ b/.changeset/loud-walls-do.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/ui-react-storage': minor +--- + +Support for multiple buckets added to storage image and file uploader diff --git a/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketExact.tsx b/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketExact.tsx new file mode 100644 index 00000000000..ee307913098 --- /dev/null +++ b/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketExact.tsx @@ -0,0 +1,15 @@ +import { FileUploader } from '@aws-amplify/ui-react-storage'; + +export const App = () => { + return ( + + ); +}; diff --git a/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketFriendly.tsx b/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketFriendly.tsx new file mode 100644 index 00000000000..92403a00301 --- /dev/null +++ b/docs/src/pages/[platform]/connected-components/storage/fileuploader/examples/BucketFriendly.tsx @@ -0,0 +1,12 @@ +import { FileUploader } from '@aws-amplify/ui-react-storage'; + +export const App = () => { + return ( + + ); +}; diff --git a/docs/src/pages/[platform]/connected-components/storage/fileuploader/props.ts b/docs/src/pages/[platform]/connected-components/storage/fileuploader/props.ts index 7ff4ae22a7f..2fbc79b67c7 100644 --- a/docs/src/pages/[platform]/connected-components/storage/fileuploader/props.ts +++ b/docs/src/pages/[platform]/connected-components/storage/fileuploader/props.ts @@ -49,6 +49,12 @@ export const FILE_UPLOADER = [ 'Determines if the upload will automatically start after a file is selected. The default value is `true`', type: 'boolean', }, + { + name: 'bucket?', + description: + 'The S3 bucket which be will accessed. Allows either a string containing the user-assigned "friendly name" or an object containing a combination of the backend-assigned name on S3 and the S3 region.', + type: 'string | { bucketName: string, region: string }', + }, { name: `maxFileCount`, description: '', diff --git a/docs/src/pages/[platform]/connected-components/storage/fileuploader/react.mdx b/docs/src/pages/[platform]/connected-components/storage/fileuploader/react.mdx index 1d25a6d9700..49abab61979 100644 --- a/docs/src/pages/[platform]/connected-components/storage/fileuploader/react.mdx +++ b/docs/src/pages/[platform]/connected-components/storage/fileuploader/react.mdx @@ -168,6 +168,26 @@ You can limit what users upload with these 3 props: +## Setting a Bucket + +If you have [configured your Amplify project to use multiple S3 buckets](https://docs.amplify.aws/react/build-a-backend/storage/set-up-storage/#configure-additional-storage-buckets), you can use the `bucket` prop to choose which of the buckets the component will use: + + + + + ```jsx file=./examples/BucketFriendly.tsx + ``` + + + +Alternatively, you can specify the bucket using the name and region it is assigned within S3: + + + + ```jsx file=./examples/BucketExact.tsx + ``` + + ## Pausable / Resumable Uploads diff --git a/docs/src/pages/[platform]/connected-components/storage/storageimage/props.ts b/docs/src/pages/[platform]/connected-components/storage/storageimage/props.ts index 86a705020b7..3583fcac317 100644 --- a/docs/src/pages/[platform]/connected-components/storage/storageimage/props.ts +++ b/docs/src/pages/[platform]/connected-components/storage/storageimage/props.ts @@ -10,6 +10,12 @@ export const STORAGE_IMAGE = [ 'The path to the image in Storage, representing a full S3 object key. See https://docs.amplify.aws/react/build-a-backend/storage/download-files/', type: 'string | ((input: { identityId?: string }) => string);', }, + { + name: 'bucket?', + description: + 'The S3 bucket which be will accessed. Allows either a string containing the user-assigned "friendly name" or an object containing a combination of the backend-assigned name on S3 and the S3 region.', + type: 'string | { bucketName: string, region: string }', + }, { name: 'imgKey', description: diff --git a/examples/next/pages/ui/components/storage/storage-image/multi-bucket/amplify_outputs.js b/examples/next/pages/ui/components/storage/storage-image/multi-bucket/amplify_outputs.js new file mode 100644 index 00000000000..aa7b2dc0d65 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-image/multi-bucket/amplify_outputs.js @@ -0,0 +1,2 @@ +import amplifyOutputs from '@environments/storage/gen2/amplify_outputs'; +export default amplifyOutputs; diff --git a/examples/next/pages/ui/components/storage/storage-image/multi-bucket/index.page.tsx b/examples/next/pages/ui/components/storage/storage-image/multi-bucket/index.page.tsx new file mode 100644 index 00000000000..14dff8fd98a --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-image/multi-bucket/index.page.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; + +import { Amplify } from 'aws-amplify'; +import { Text, Loader } from '@aws-amplify/ui-react'; +import { StorageImage } from '@aws-amplify/ui-react-storage'; +import '@aws-amplify/ui-react/styles.css'; +import amplifyOutputs from './amplify_outputs'; + +Amplify.configure(amplifyOutputs); + +export function StorageImageExample() { + const [isFirstImgLoaded, setIsFirstImgLoaded] = React.useState(false); + const [isSecondImgLoaded, setIsSecondImgLoaded] = React.useState(false); + + return ( + <> + setIsFirstImgLoaded(true)} + /> + {isFirstImgLoaded ? ( + The first public image is loaded. + ) : ( + + )} + 'public/public-e2e.jpeg'} + onLoad={() => setIsSecondImgLoaded(true)} + /> + {isSecondImgLoaded ? ( + The second public image is loaded. + ) : ( + + )} + + ); +} +export default StorageImageExample; diff --git a/packages/e2e/features/ui/components/storage/storage-image/load-public-images-from-two-s3-buckets.feature b/packages/e2e/features/ui/components/storage/storage-image/load-public-images-from-two-s3-buckets.feature new file mode 100644 index 00000000000..5cf35c622f0 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-image/load-public-images-from-two-s3-buckets.feature @@ -0,0 +1,13 @@ +Feature: Load two images, each from a different S3 bucket with public access level settings + + Background: + Given I'm running the example "ui/components/storage/storage-image/multi-bucket" + + @react + Scenario: I successfully load two images from two buckets + Then I see "Loader1" element + Then I see "Loader2" element + Then I see the "public cat 1" image + Then I see the "public cat 2" image + Then I see "The first public image is loaded." + Then I see "The second public image is loaded." diff --git a/packages/react-storage/src/components/FileUploader/FileUploader.tsx b/packages/react-storage/src/components/FileUploader/FileUploader.tsx index 755e551891a..5487a35726d 100644 --- a/packages/react-storage/src/components/FileUploader/FileUploader.tsx +++ b/packages/react-storage/src/components/FileUploader/FileUploader.tsx @@ -45,6 +45,7 @@ const FileUploaderBase = React.forwardRef(function FileUploader( acceptedFileTypes = [], accessLevel, autoUpload = true, + bucket, components, defaultFiles, displayText: overrideDisplayText, @@ -142,6 +143,7 @@ const FileUploaderBase = React.forwardRef(function FileUploader( useUploadFiles({ accessLevel, + bucket, files, isResumable, maxFileCount, diff --git a/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx b/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx index 9a63d2b5603..53a54e9fad8 100644 --- a/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx +++ b/packages/react-storage/src/components/FileUploader/__tests__/FileUploader.test.tsx @@ -246,6 +246,40 @@ describe('FileUploader', () => { }); }); + it('passes a supplied bucket name to the options object', async () => { + const onUploadSuccess = jest.fn(); + render( + + ); + const hiddenInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + + expect(hiddenInput).toBeInTheDocument(); + const file = new File(['file content'], 'file.txt', { type: 'text/plain' }); + fireEvent.change(hiddenInput, { + target: { files: [file] }, + }); + + // Wait for the file to be uploaded + await waitFor(() => { + expect(uploadDataSpy).toHaveBeenCalledWith({ + data: file, + options: { + bucket: 'my-bucket', + contentType: 'text/plain', + onProgress: expect.any(Function), + }, + path: 'my-pathfile.txt', + }); + }); + }); + it('calls onUploadStart callback when file starts uploading', async () => { const onUploadStart = jest.fn(); render( diff --git a/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts index 26ad1ba119c..80a6d4b0b9f 100644 --- a/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts +++ b/packages/react-storage/src/components/FileUploader/hooks/useUploadFiles/useUploadFiles.ts @@ -5,6 +5,7 @@ import { isFunction } from '@aws-amplify/ui'; import { PathCallback, uploadFile } from '../../utils'; import { getInput } from '../../utils'; +import { StorageBucket } from '../../types'; import { FileStatus } from '../../types'; import { FileUploaderProps } from '../../types'; import { UseFileUploader } from '../useFileUploader'; @@ -25,11 +26,13 @@ export interface UseUploadFilesProps 'setUploadingFile' | 'setUploadProgress' | 'setUploadSuccess' | 'files' > { accessLevel?: FileUploaderProps['accessLevel']; + bucket?: StorageBucket; path?: string | PathCallback; } export function useUploadFiles({ accessLevel, + bucket, files, isResumable, maxFileCount, @@ -68,6 +71,7 @@ export function useUploadFiles({ if (file) { const input = getInput({ accessLevel, + bucket, file, key, onProgress, @@ -105,6 +109,7 @@ export function useUploadFiles({ }, [ files, accessLevel, + bucket, isResumable, setUploadProgress, setUploadingFile, diff --git a/packages/react-storage/src/components/FileUploader/types.ts b/packages/react-storage/src/components/FileUploader/types.ts index eaf9790a936..981ac3ae2d5 100644 --- a/packages/react-storage/src/components/FileUploader/types.ts +++ b/packages/react-storage/src/components/FileUploader/types.ts @@ -10,6 +10,15 @@ import { } from './ui'; import { FileUploaderDisplayText, PathCallback, UploadTask } from './utils'; +export interface BucketInfo { + bucketName: string; + region: string; +} + +// accepts either a 'friendly name' that the user has assigned +// or an object containing the region as well as the name generated by the backend +export type StorageBucket = string | BucketInfo; + export enum FileStatus { ADDED = 'added', QUEUED = 'queued', @@ -71,6 +80,7 @@ export interface FileUploaderProps { /** * Component overrides */ + bucket?: never; components?: { Container?: React.ComponentType; DropZone?: React.ComponentType; @@ -132,7 +142,7 @@ export interface FileUploaderProps { } export interface FileUploaderPathProps - extends Omit { + extends Omit { /** * S3 bucket key, allows either a `string` or a `PathCallback`: * - `string`: `path` is prefixed to the file `key` for each file @@ -141,5 +151,6 @@ export interface FileUploaderPathProps */ path: string | PathCallback; accessLevel?: never; + bucket?: StorageBucket; useAccelerateEndpoint?: boolean; } diff --git a/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts b/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts index bf9d4eacb09..2414b785e28 100644 --- a/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts +++ b/packages/react-storage/src/components/FileUploader/utils/__tests__/getInput.spec.ts @@ -60,6 +60,7 @@ describe('getInput', () => { const expected: UploadDataWithPathInput = { data: file, options: { + bucket: undefined, contentType: file.type, useAccelerateEndpoint: undefined, onProgress, @@ -78,6 +79,7 @@ describe('getInput', () => { const expected: UploadDataWithPathInput = { data: file, options: { + bucket: undefined, contentType: file.type, useAccelerateEndpoint: undefined, onProgress, @@ -97,6 +99,7 @@ describe('getInput', () => { data: file, options: { accessLevel, + bucket: undefined, contentType: file.type, useAccelerateEndpoint: undefined, onProgress, @@ -116,6 +119,7 @@ describe('getInput', () => { data: file, options: { accessLevel, + bucket: undefined, contentType: file.type, useAccelerateEndpoint: undefined, onProgress, @@ -134,6 +138,7 @@ describe('getInput', () => { const expected: UploadDataWithPathInput = { data: file, options: { + bucket: undefined, contentType: file.type, useAccelerateEndpoint: undefined, onProgress, @@ -218,6 +223,7 @@ describe('getInput', () => { const expected: UploadDataWithPathInput = { data, options: { + bucket: undefined, contentType: 'binary/octet-stream', useAccelerateEndpoint: undefined, onProgress, @@ -237,6 +243,7 @@ describe('getInput', () => { const expected: UploadDataWithPathInput = { data, options: { + bucket: undefined, contentType: 'binary/octet-stream', useAccelerateEndpoint: true, onProgress, diff --git a/packages/react-storage/src/components/FileUploader/utils/getInput.ts b/packages/react-storage/src/components/FileUploader/utils/getInput.ts index 4aa0e2910e7..9440a58a51c 100644 --- a/packages/react-storage/src/components/FileUploader/utils/getInput.ts +++ b/packages/react-storage/src/components/FileUploader/utils/getInput.ts @@ -3,12 +3,13 @@ import { UploadDataWithPathInput, UploadDataInput } from 'aws-amplify/storage'; import { isString, isFunction } from '@aws-amplify/ui'; -import { ProcessFile, StorageAccessLevel } from '../types'; +import { ProcessFile, StorageAccessLevel, StorageBucket } from '../types'; import { resolveFile } from './resolveFile'; import { PathCallback, PathInput } from './uploadFile'; export interface GetInputParams { accessLevel: StorageAccessLevel | undefined; + bucket?: StorageBucket; file: File; key: string; onProgress: NonNullable['onProgress']; @@ -19,6 +20,7 @@ export interface GetInputParams { export const getInput = ({ accessLevel, + bucket, file, key, onProgress, @@ -41,7 +43,13 @@ export const getInput = ({ const contentType = file.type || 'binary/octet-stream'; // IMPORTANT: always pass `...rest` here for backwards compatibility - const options = { contentType, onProgress, useAccelerateEndpoint, ...rest }; + const options = { + bucket, + contentType, + onProgress, + useAccelerateEndpoint, + ...rest, + }; let inputResult: PathInput | UploadDataInput; if (hasKeyInput) { diff --git a/packages/react-storage/src/components/StorageImage/StorageImage.tsx b/packages/react-storage/src/components/StorageImage/StorageImage.tsx index 56bfd573146..63ff2cd8d09 100644 --- a/packages/react-storage/src/components/StorageImage/StorageImage.tsx +++ b/packages/react-storage/src/components/StorageImage/StorageImage.tsx @@ -44,6 +44,7 @@ const getDeprecationMessage = ({ export const StorageImage = ({ accessLevel, + bucket, className, fallbackSrc, identityId, @@ -82,11 +83,20 @@ export const StorageImage = ({ onError, options: { accessLevel, + bucket, targetIdentityId: identityId, validateObjectExistence, }, }), - [accessLevel, imgKey, identityId, onError, path, validateObjectExistence] + [ + accessLevel, + bucket, + imgKey, + identityId, + onError, + path, + validateObjectExistence, + ] ); const { url } = useGetUrl(input); diff --git a/packages/react-storage/src/components/StorageImage/__tests__/StorageImage.test.tsx b/packages/react-storage/src/components/StorageImage/__tests__/StorageImage.test.tsx index b97808ef9c6..31a579c4c9e 100644 --- a/packages/react-storage/src/components/StorageImage/__tests__/StorageImage.test.tsx +++ b/packages/react-storage/src/components/StorageImage/__tests__/StorageImage.test.tsx @@ -170,6 +170,20 @@ describe('StorageImage', () => { ); }); + it('should pass bucket to getUrl when supplied', () => { + getUrlSpy.mockResolvedValue({ + url: new URL(imgURL), + expiresAt: new Date(), + }); + render(); + + expect(getUrlSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ bucket: 'my-bucket' }), + }) + ); + }); + describe('with `imgKey`', () => { it('should get the presigned URL and pass it to image src attribute', async () => { getUrlSpy.mockResolvedValue({ diff --git a/packages/react-storage/src/components/StorageImage/types.ts b/packages/react-storage/src/components/StorageImage/types.ts index ab9bcbef367..a02a808339f 100644 --- a/packages/react-storage/src/components/StorageImage/types.ts +++ b/packages/react-storage/src/components/StorageImage/types.ts @@ -1,4 +1,5 @@ import { ImageProps } from '@aws-amplify/ui-react'; +import { StorageBucket } from '../FileUploader/types'; type StorageAccessLevel = 'guest' | 'protected' | 'private'; @@ -16,6 +17,7 @@ export interface StorageImageProps extends Omit { * `accessLevel` will be replaced with `path` in a future major version of Amplify UI. See https://ui.docs.amplify.aws/react/connected-components/storage/storageimage#props */ accessLevel: StorageAccessLevel; + bucket?: never; /** * @deprecated * `identityId` will be replaced with `path` in a future major version of Amplify UI. See https://ui.docs.amplify.aws/react/connected-components/storage/storageimage#props @@ -37,6 +39,7 @@ export interface StorageImageProps extends Omit { type OmittedPropKey = | 'accessLevel' + | 'bucket' | 'imgKey' | 'identityId' | 'onStorageGetError' @@ -47,6 +50,7 @@ export interface StorageImagePathProps path: string | ((input: { identityId?: string }) => string); imgKey?: never; accessLevel?: never; + bucket?: StorageBucket; identityId?: never; onStorageGetError?: never; } diff --git a/packages/react-storage/src/components/StorageManager/StorageManager.tsx b/packages/react-storage/src/components/StorageManager/StorageManager.tsx index aacbc785759..811cf15c23d 100644 --- a/packages/react-storage/src/components/StorageManager/StorageManager.tsx +++ b/packages/react-storage/src/components/StorageManager/StorageManager.tsx @@ -46,6 +46,7 @@ const StorageManagerBase = React.forwardRef(function StorageManager( acceptedFileTypes = [], accessLevel, autoUpload = true, + bucket, components, defaultFiles, displayText: overrideDisplayText, @@ -149,6 +150,7 @@ const StorageManagerBase = React.forwardRef(function StorageManager( useUploadFiles({ accessLevel, + bucket, files, isResumable, maxFileCount, diff --git a/packages/react-storage/src/components/StorageManager/types.ts b/packages/react-storage/src/components/StorageManager/types.ts index b89ba652f01..0306d7e40f7 100644 --- a/packages/react-storage/src/components/StorageManager/types.ts +++ b/packages/react-storage/src/components/StorageManager/types.ts @@ -1,20 +1,14 @@ -import * as React from 'react'; +import { FileStatus, FileUploaderProps } from '../FileUploader/types'; +import { PathCallback, UploadTask } from '../FileUploader/utils'; -import { FileStatus, StorageAccessLevel } from '../FileUploader/types'; -import { - FileUploaderDisplayText as StorageManagerDisplayText, - PathCallback, - UploadTask, -} from '../FileUploader/utils'; +interface BucketInfo { + bucketName: string; + region: string; +} -import { - ContainerProps, - DropZoneProps, - FileListHeaderProps, - FileListFooterProps, - FileListProps, - FilePickerProps, -} from './ui'; +// accepts either a 'friendly name' that the user has assigned +// or an object containing the region as well as the name generated by the backend +export type StorageBucket = string | BucketInfo; export interface StorageFile { id: string; @@ -47,93 +41,17 @@ export interface StorageManagerHandle { clearFiles: () => void; } -export interface StorageManagerProps { - /** - * List of accepted File types, values of `['*']` or undefined allow any files - * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept - */ - acceptedFileTypes?: string[]; - /** - * Access level for file uploads - * @see https://docs.amplify.aws/lib/storage/configureaccess/q/platform/js/ - */ - accessLevel: StorageAccessLevel; - - /** - * Determines if the upload will automatically start after a file is selected, default value: true - */ - autoUpload?: boolean; - /** - * Component overrides - */ - components?: { - Container?: React.ComponentType; - DropZone?: React.ComponentType; - FileList?: React.ComponentType; - FilePicker?: React.ComponentType; - FileListHeader?: React.ComponentType; - FileListFooter?: React.ComponentType; - }; - /** - * List of default files already uploaded - */ - defaultFiles?: DefaultFile[]; - /** - * Overrides default display text - */ - displayText?: StorageManagerDisplayText; - /** - * Determines if upload can be paused / resumed - */ - isResumable?: boolean; - /** - * Maximum total files to upload in each batch - */ - maxFileCount: number; - /** - * Maximum file size in bytes - */ - maxFileSize?: number; - /** - * When a file is removed - */ - onFileRemove?: (file: { key: string }) => void; - /** - * Monitor upload errors - */ - onUploadError?: (error: string, file: { key: string }) => void; - /** - * Monitor upload success - */ - onUploadSuccess?: (event: { key?: string }) => void; - /** - * When a file begins uploading - */ - onUploadStart?: (event: { key?: string }) => void; - /** - * Process file before upload - */ - processFile?: ProcessFile; - /** - * Determines if thumbnails show for image files - */ - showThumbnails?: boolean; - /** - * Provided value is prefixed to the file `key` for each file - */ - path?: string; - - useAccelerateEndpoint?: boolean; -} +export interface StorageManagerProps extends FileUploaderProps {} export interface StorageManagerPathProps - extends Omit { + extends Omit { /** * S3 bucket key, allows either a `string` or a `PathCallback`: * - `string`: `path` is prefixed to the file `key` for each file * - `PathCallback`: callback provided an input containing the current `identityId`, * resolved value is prefixed to the file `key` for each file */ + bucket?: StorageBucket; path: string | PathCallback; accessLevel?: never; useAccelerateEndpoint?: boolean;