diff --git a/.changeset/honest-beans-suffer.md b/.changeset/honest-beans-suffer.md new file mode 100644 index 00000000000..08d934dfd6b --- /dev/null +++ b/.changeset/honest-beans-suffer.md @@ -0,0 +1,16 @@ +--- +"@aws-amplify/ui-react-ai": minor +"@aws-amplify/ui": patch +--- + +feat(ai) add attachment validations + +The current limitations on the Amplify AI kit for attachments is 400kb (of base64'd size) per image, and 20 images per message are now being enforced before the message is sent. +These limits can be adjusted via props as well. + +```tsx + +``` diff --git a/docs/src/components/ComponentsMetadata.ts b/docs/src/components/ComponentsMetadata.ts index 7948e9c9b07..6ab6b509cb0 100644 --- a/docs/src/components/ComponentsMetadata.ts +++ b/docs/src/components/ComponentsMetadata.ts @@ -135,6 +135,11 @@ export const ComponentsMetadata: ComponentClassNameItems = { components: ['AIConversation'], description: 'Class applied to the form element', }, + AIConversationFormError: { + className: ComponentClassName.AIConversationFormError, + components: ['AIConversation'], + description: 'Class applied to the error message of the form', + }, AIConversationFormAttach: { className: ComponentClassName.AIConversationFormAttach, components: ['AIConversation'], diff --git a/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx b/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx new file mode 100644 index 00000000000..51d7857ffb1 --- /dev/null +++ b/examples/next/pages/ui/components/ai/ai-conversation/attachments.page.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Amplify } from 'aws-amplify'; +import { signOut } from 'aws-amplify/auth'; +import { createAIHooks, AIConversation } from '@aws-amplify/ui-react-ai'; +import { generateClient } from 'aws-amplify/api'; +import '@aws-amplify/ui-react/styles.css'; + +import outputs from './amplify_outputs'; +import type { Schema } from '@environments/ai/gen2/amplify/data/resource'; +import { Authenticator, Button, Card, Flex } from '@aws-amplify/ui-react'; + +const client = generateClient({ authMode: 'userPool' }); +const { useAIConversation } = createAIHooks(client); + +Amplify.configure(outputs); + +function Chat() { + const [ + { + data: { messages }, + isLoading, + }, + sendMessage, + ] = useAIConversation('pirateChat'); + + return ( + + ); +} + +export default function Example() { + return ( + + + + + + + + + ); +} 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-ai/jest.config.ts b/packages/react-ai/jest.config.ts index 02ef2da2db1..746f800dcf7 100644 --- a/packages/react-ai/jest.config.ts +++ b/packages/react-ai/jest.config.ts @@ -14,9 +14,9 @@ const config: Config = { coverageThreshold: { global: { branches: 68, - functions: 78, - lines: 87, - statements: 87, + functions: 77, + lines: 86, + statements: 86, }, }, testPathIgnorePatterns: [], diff --git a/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx b/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx index 99482b042de..36ab17e2d4b 100644 --- a/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx +++ b/packages/react-ai/src/components/AIConversation/AIConversationProvider.tsx @@ -37,6 +37,8 @@ export const AIConversationProvider = ({ displayText, handleSendMessage, isLoading, + maxAttachmentSize, + maxAttachments, messages, messageRenderer, responseComponents, @@ -60,7 +62,11 @@ export const AIConversationProvider = ({ - + { @@ -57,3 +58,91 @@ describe('getImageTypeFromMimeType', () => { expect(getImageTypeFromMimeType('image/webp')).toBe('webp'); }); }); + +describe('attachmentsValidator', () => { + // Helper function to create mock files + const createMockFile = (size: number, name = 'test.txt'): File => { + const buffer = new ArrayBuffer(size); + File.prototype.arrayBuffer = jest.fn().mockResolvedValueOnce(buffer); + return new File([buffer], name, { type: 'text/plain' }); + }; + + it('should accept files within size limit', async () => { + const files = [createMockFile(100)]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(1); + expect(result.rejectedFiles).toHaveLength(0); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should reject files exceeding size limit', async () => { + const files = [createMockFile(2000)]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(0); + expect(result.rejectedFiles).toHaveLength(1); + expect(result.hasMaxAttachmentSizeError).toBeTruthy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should handle mixed valid and invalid file sizes', async () => { + const files = [ + createMockFile(500), + createMockFile(2000), + createMockFile(800), + ]; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(2); + expect(result.rejectedFiles).toHaveLength(1); + expect(result.hasMaxAttachmentSizeError).toBeTruthy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); + + it('should enforce maximum number of attachments', async () => { + const files = [ + createMockFile(100), + createMockFile(200), + createMockFile(300), + createMockFile(400), + ]; + const result = await attachmentsValidator({ + files, + maxAttachments: 2, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(2); + expect(result.rejectedFiles).toHaveLength(2); + expect(result.hasMaxAttachmentsError).toBeTruthy(); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + }); + + it('should handle empty file list', async () => { + const files: File[] = []; + const result = await attachmentsValidator({ + files, + maxAttachments: 3, + maxAttachmentSize: 1000, + }); + + expect(result.acceptedFiles).toHaveLength(0); + expect(result.rejectedFiles).toHaveLength(0); + expect(result.hasMaxAttachmentSizeError).toBeFalsy(); + expect(result.hasMaxAttachmentsError).toBeFalsy(); + }); +}); diff --git a/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx b/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx index 8a51e7dd928..3195ad6c3c8 100644 --- a/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/AttachmentContext.tsx @@ -1,16 +1,36 @@ import * as React from 'react'; +import { AIConversationInput } from '../types'; -export const AttachmentContext = React.createContext(false); +export interface AttachmentContextProps + extends Pick< + AIConversationInput, + 'allowAttachments' | 'maxAttachments' | 'maxAttachmentSize' + > {} + +export const AttachmentContext = React.createContext< + Required +>({ + allowAttachments: false, + // We save attachments as base64 strings into dynamodb for conversation history + // DynamoDB has a max size of 400kb for records + // This can be overridden so cutsomers could provide a lower number + // or a higher number if in the future we support larger sizes. + maxAttachmentSize: 400_000, + maxAttachments: 20, +}); export const AttachmentProvider = ({ children, - allowAttachments, -}: { - children?: React.ReactNode; - allowAttachments?: boolean; -}): JSX.Element => { + allowAttachments = false, + maxAttachmentSize = 400_000, + maxAttachments = 20, +}: React.PropsWithChildren): JSX.Element => { + const providerValue = React.useMemo( + () => ({ maxAttachmentSize, maxAttachments, allowAttachments }), + [maxAttachmentSize, maxAttachments, allowAttachments] + ); return ( - + {children} ); diff --git a/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx b/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx index eeba5252acb..717dd38d5d7 100644 --- a/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/ControlsContext.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ConversationInputContext } from './ConversationInputContext'; +import { ConversationInputContextProps } from './ConversationInputContext'; import { SuggestedPrompt } from '../types'; import { ConversationMessage } from '../../../types'; @@ -9,12 +9,13 @@ export interface ControlsContextProps { handleSubmit: (e: React.FormEvent) => void; allowAttachments?: boolean; isLoading?: boolean; - } & Required + onValidate: (files: File[]) => Promise; + } & ConversationInputContextProps >; MessageList?: React.ComponentType<{ messages: ConversationMessage[] }>; PromptList?: React.ComponentType<{ suggestedPrompts?: SuggestedPrompt[]; - setInput: ConversationInputContext['setInput']; + setInput: ConversationInputContextProps['setInput']; }>; } diff --git a/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx b/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx index f6c2805ba50..dd56f6350b2 100644 --- a/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx +++ b/packages/react-ai/src/components/AIConversation/context/ConversationInputContext.tsx @@ -5,15 +5,17 @@ export interface ConversationInput { files?: File[]; } -export interface ConversationInputContext { +export interface ConversationInputContextProps { input?: ConversationInput; setInput?: React.Dispatch< React.SetStateAction >; + error?: string; + setError?: React.Dispatch>; } export const ConversationInputContext = - React.createContext({}); + React.createContext({}); export const ConversationInputContextProvider = ({ children, @@ -21,10 +23,11 @@ export const ConversationInputContextProvider = ({ children?: React.ReactNode; }): JSX.Element => { const [input, setInput] = React.useState(); + const [error, setError] = React.useState(); const providerValue = React.useMemo( - () => ({ input, setInput }), - [input, setInput] + () => ({ input, setInput, error, setError }), + [input, setInput, error, setError] ); return ( diff --git a/packages/react-ai/src/components/AIConversation/context/index.ts b/packages/react-ai/src/components/AIConversation/context/index.ts index 05dc6506245..083cd7c4b39 100644 --- a/packages/react-ai/src/components/AIConversation/context/index.ts +++ b/packages/react-ai/src/components/AIConversation/context/index.ts @@ -2,6 +2,7 @@ export { AIContextContext, AIContextProvider } from './AIContextContext'; export { ActionsContext, ActionsProvider } from './ActionsContext'; export { AvatarsContext, AvatarsProvider } from './AvatarsContext'; export { + ConversationInputContextProps, ConversationInputContext, ConversationInput, ConversationInputContextProvider, @@ -40,7 +41,11 @@ export { MessageRendererContext, useMessageRenderer, } from './MessageRenderContext'; -export { AttachmentProvider, AttachmentContext } from './AttachmentContext'; +export { + AttachmentProvider, + AttachmentContext, + AttachmentContextProps, +} from './AttachmentContext'; export { WelcomeMessageContext, WelcomeMessageProvider, diff --git a/packages/react-ai/src/components/AIConversation/displayText.ts b/packages/react-ai/src/components/AIConversation/displayText.ts index 20737b5603a..3b2d055cddc 100644 --- a/packages/react-ai/src/components/AIConversation/displayText.ts +++ b/packages/react-ai/src/components/AIConversation/displayText.ts @@ -3,11 +3,21 @@ import { formatDate } from './utils'; export type ConversationDisplayText = { getMessageTimestampText?: (date: Date) => string; + getMaxAttachmentErrorText?: (count: number) => string; + getAttachmentSizeErrorText?: (sizeText: string) => string; }; export const defaultAIConversationDisplayTextEn: Required = { getMessageTimestampText: (date: Date) => formatDate(date), + getMaxAttachmentErrorText(count: number): string { + return `Cannot choose more than ${count} ${ + count === 1 ? 'file' : 'files' + }. `; + }, + getAttachmentSizeErrorText(sizeText: string): string { + return `File size must be below ${sizeText}.`; + }, }; export type AIConversationDisplayText = diff --git a/packages/react-ai/src/components/AIConversation/types.ts b/packages/react-ai/src/components/AIConversation/types.ts index f229c16e79e..69aef506b39 100644 --- a/packages/react-ai/src/components/AIConversation/types.ts +++ b/packages/react-ai/src/components/AIConversation/types.ts @@ -37,6 +37,8 @@ export interface AIConversationInput { variant?: MessageVariant; controls?: ControlsContextProps; allowAttachments?: boolean; + maxAttachments?: number; + maxAttachmentSize?: number; messageRenderer?: MessageRenderer; } diff --git a/packages/react-ai/src/components/AIConversation/utils.ts b/packages/react-ai/src/components/AIConversation/utils.ts index 023cec0be22..4eff9c9e6e5 100644 --- a/packages/react-ai/src/components/AIConversation/utils.ts +++ b/packages/react-ai/src/components/AIConversation/utils.ts @@ -16,27 +16,26 @@ export function formatDate(date: Date): string { } function arrayBufferToBase64(buffer: ArrayBuffer) { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); + // Use node-based buffer if available + // fall back on browser if not + if (typeof Buffer !== 'undefined') { + return Buffer.from(new Uint8Array(buffer)).toString('base64'); + } else { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); } - return window.btoa(binary); } export function convertBufferToBase64( buffer: ArrayBuffer, format: ImageContentBlock['format'] ): string { - let base64string = ''; - // Use node-based buffer if available - // fall back on browser if not - if (typeof Buffer !== 'undefined') { - base64string = Buffer.from(new Uint8Array(buffer)).toString('base64'); - } else { - base64string = arrayBufferToBase64(buffer); - } + const base64string = arrayBufferToBase64(buffer); return `data:image/${format};base64,${base64string}`; } @@ -45,3 +44,47 @@ export function getImageTypeFromMimeType( ): 'png' | 'jpeg' | 'gif' | 'webp' { return mimeType.split('/')[1] as 'png' | 'jpeg' | 'gif' | 'webp'; } + +export async function attachmentsValidator({ + files, + maxAttachments, + maxAttachmentSize, +}: { + files: File[]; + maxAttachments: number; + maxAttachmentSize: number; +}): Promise<{ + acceptedFiles: File[]; + rejectedFiles: File[]; + hasMaxAttachmentSizeError: boolean; + hasMaxAttachmentsError: boolean; +}> { + const acceptedFiles: File[] = []; + const rejectedFiles: File[] = []; + let hasMaxSizeError = false; + + for (const file of files) { + const arrayBuffer = await file.arrayBuffer(); + const base64 = arrayBufferToBase64(arrayBuffer); + if (base64.length < maxAttachmentSize) { + acceptedFiles.push(file); + } else { + rejectedFiles.push(file); + hasMaxSizeError = true; + } + } + if (acceptedFiles.length > maxAttachments) { + return { + acceptedFiles: acceptedFiles.slice(0, maxAttachments), + rejectedFiles: [...acceptedFiles.slice(maxAttachments), ...rejectedFiles], + hasMaxAttachmentsError: true, + hasMaxAttachmentSizeError: hasMaxSizeError, + }; + } + return { + acceptedFiles, + rejectedFiles, + hasMaxAttachmentsError: false, + hasMaxAttachmentSizeError: hasMaxSizeError, + }; +} diff --git a/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx b/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx index b0837fed2d4..18b8aad45f4 100644 --- a/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx +++ b/packages/react-ai/src/components/AIConversation/views/Controls/FormControl.tsx @@ -1,7 +1,11 @@ import React from 'react'; import { withBaseElementProps } from '@aws-amplify/ui-react-core/elements'; -import { AIContextContext, ConversationInputContext } from '../../context'; +import { + AIContextContext, + ConversationInputContext, + useConversationDisplayText, +} from '../../context'; import { AIConversationElements } from '../../context/elements'; import { AttachFileControl } from './AttachFileControl'; import { MessagesContext } from '../../context'; @@ -13,10 +17,10 @@ import { ResponseComponentsContext, } from '../../context/ResponseComponentsContext'; import { ControlsContext } from '../../context/ControlsContext'; -import { getImageTypeFromMimeType } from '../../utils'; +import { attachmentsValidator, getImageTypeFromMimeType } from '../../utils'; import { LoadingContext } from '../../context/LoadingContext'; import { AttachmentContext } from '../../context/AttachmentContext'; -import { isFunction } from '@aws-amplify/ui'; +import { humanFileSize, isFunction } from '@aws-amplify/ui'; const { Button, @@ -148,9 +152,13 @@ const InputContainer = withBaseElementProps(View, { }); export const FormControl: FormControl = () => { - const { input, setInput } = React.useContext(ConversationInputContext); + const { input, setInput, error, setError } = React.useContext( + ConversationInputContext + ); const handleSendMessage = React.useContext(SendMessageContext); - const allowAttachments = React.useContext(AttachmentContext); + const { allowAttachments, maxAttachmentSize, maxAttachments } = + React.useContext(AttachmentContext); + const displayText = useConversationDisplayText(); const responseComponents = React.useContext(ResponseComponentsContext); const isLoading = React.useContext(LoadingContext); const aiContext = React.useContext(AIContextContext); @@ -213,14 +221,57 @@ export const FormControl: FormControl = () => { } }; + const onValidate = React.useCallback( + async (files: File[]) => { + const previousFiles = input?.files ?? []; + const { + acceptedFiles, + hasMaxAttachmentsError, + hasMaxAttachmentSizeError, + } = await attachmentsValidator({ + files: [...files, ...previousFiles], + maxAttachments, + maxAttachmentSize, + }); + + if (hasMaxAttachmentsError || hasMaxAttachmentSizeError) { + const errors = []; + if (hasMaxAttachmentsError) { + errors.push(displayText.getMaxAttachmentErrorText(maxAttachments)); + } + if (hasMaxAttachmentSizeError) { + errors.push( + displayText.getAttachmentSizeErrorText( + // base64 size is about 137% that of the file size + // https://en.wikipedia.org/wiki/Base64#MIME + humanFileSize((maxAttachmentSize - 814) / 1.37, true) + ) + ); + } + setError?.(errors.join(' ')); + } else { + setError?.(undefined); + } + + setInput?.((prevValue) => ({ + ...prevValue, + files: acceptedFiles, + })); + }, + [setInput, input, displayText, maxAttachmentSize, maxAttachments, setError] + ); + if (controls?.Form) { return ( ); } diff --git a/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx b/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx index 0ad1798942a..764702cbd7e 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/Attachments.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Button, Image, Text, View } from '@aws-amplify/ui-react'; import { IconClose, useIcons } from '@aws-amplify/ui-react/internal'; -import { ConversationInputContext } from '../../context'; +import { ConversationInputContextProps } from '../../context'; import { ComponentClassName, humanFileSize } from '@aws-amplify/ui'; const Attachment = ({ @@ -47,7 +47,7 @@ export const Attachments = ({ setInput, }: { files?: File[]; - setInput: ConversationInputContext['setInput']; + setInput: ConversationInputContextProps['setInput']; }): JSX.Element | null => { if (!files || files.length < 1) { return null; diff --git a/packages/react-ai/src/components/AIConversation/views/default/Form.tsx b/packages/react-ai/src/components/AIConversation/views/default/Form.tsx index e1fb0a8c39e..da32054596e 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/Form.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/Form.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Button, DropZone, + Message, TextAreaField, View, VisuallyHidden, @@ -10,7 +11,6 @@ import { IconAttach, IconSend, useIcons } from '@aws-amplify/ui-react/internal'; import { ComponentClassName } from '@aws-amplify/ui'; import { ControlsContextProps } from '../../context/ControlsContext'; import { Attachments } from './Attachments'; -import { ConversationInputContext } from '../../context'; function isHTMLFormElement(target: EventTarget): target is HTMLFormElement { return 'form' in target; @@ -23,21 +23,18 @@ function isHTMLFormElement(target: EventTarget): target is HTMLFormElement { const FormWrapper = ({ children, allowAttachments, - setInput, + onValidate, }: { children: React.ReactNode; allowAttachments?: boolean; - setInput: ConversationInputContext['setInput']; + onValidate: (files: File[]) => Promise; }) => { if (allowAttachments) { return ( { - setInput?.((prevInput) => ({ - ...prevInput, - files: [...(prevInput?.files ?? []), ...acceptedFiles], - })); + onValidate(acceptedFiles); }} > {children} @@ -53,7 +50,9 @@ export const Form: Required['Form'] = ({ input, handleSubmit, allowAttachments, + onValidate, isLoading, + error, }) => { const icons = useIcons('aiConversation'); const sendIcon = icons?.send ?? ; @@ -63,7 +62,7 @@ export const Form: Required['Form'] = ({ const isInputEmpty = !input?.text?.length && !input?.files?.length; return ( - + ['Form'] = ({ tabIndex={-1} ref={hiddenInput} onChange={(e) => { - const { files } = e.target; - if (!files || files.length === 0) { + if (!e.target.files || e.target.files.length === 0) { return; } - setInput((prevValue) => ({ - ...prevValue, - files: [...(prevValue?.files ?? []), ...Array.from(files)], - })); + onValidate(Array.from(e.target.files)); }} multiple - accept="*" + accept=".jpeg,.png,.webp,.gif" data-testid="hidden-file-input" /> @@ -123,7 +118,7 @@ export const Form: Required['Form'] = ({ } }} onChange={(e) => { - setInput((prevValue) => ({ + setInput?.((prevValue) => ({ ...prevValue, text: e.target.value, })); @@ -141,6 +136,15 @@ export const Form: Required['Form'] = ({ {sendIcon} + {error ? ( + + {error} + + ) : null} ); diff --git a/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx b/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx index ed166875a8c..121d2e23131 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/__tests__/Form.spec.tsx @@ -5,6 +5,15 @@ import { Form } from '../Form'; const setInput = jest.fn(); const input = {}; const handleSubmit = jest.fn(); +const onValidate = jest.fn(); + +const defaultProps = { + allowAttachments: true, + setInput, + input, + handleSubmit, + onValidate, +}; describe('Form', () => { beforeEach(() => { @@ -13,14 +22,7 @@ describe('Form', () => { }); it('renders a Form component with the correct elements', () => { - const result = render( -
- ); + const result = render(); expect(result.container).toBeDefined(); const form = screen.findByRole('form'); @@ -35,14 +37,7 @@ describe('Form', () => { }); it('can upload files to the input', async () => { - const result = render( - - ); + const result = render(); expect(result.container).toBeDefined(); const fileInput: HTMLInputElement = screen.getByTestId('hidden-file-input'); @@ -50,12 +45,15 @@ describe('Form', () => { type: 'text/plain', }); File.prototype.text = jest.fn().mockResolvedValueOnce('foo.txt'); + File.prototype.arrayBuffer = jest + .fn() + .mockResolvedValueOnce(Buffer.from([])); await waitFor(() => fireEvent.change(fileInput, { target: { files: [testFile] }, }) ); - expect(setInput).toHaveBeenCalledTimes(1); + expect(onValidate).toHaveBeenCalledTimes(1); expect(fileInput.files).not.toBeNull(); expect(fileInput.files![0]).toStrictEqual(testFile); }); diff --git a/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx b/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx index 70c2e80ff06..5a5910a6913 100644 --- a/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx +++ b/packages/react-ai/src/components/AIConversation/views/default/__tests__/PromptList.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import { PromptList } from '../PromptList'; import { ComponentClassName } from '@aws-amplify/ui'; -import { ConversationInputContext } from '../../../context'; +import { ConversationInputContextProps } from '../../../context'; describe('PromptList', () => { const mockSetInput = jest.fn< - ReturnType['setInput']>, - Parameters['setInput']> + ReturnType['setInput']>, + Parameters['setInput']> >(); it('renders without crashing', () => { 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 8b385f86b5e..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 @@ -1,11 +1,9 @@ -import * as StorageModule from '../../../storage-internal'; - +import { copy, CopyInput } from '../../../storage-internal'; import { copyHandler, CopyHandlerInput } from '../copy'; -const copySpy = jest.spyOn(StorageModule, 'copy'); +jest.mock('../../../storage-internal'); const baseInput: CopyHandlerInput = { - destinationPrefix: 'destination/', config: { accountId: '012345678901', bucket: 'bucket', @@ -15,38 +13,45 @@ 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 }); + }); + afterEach(() => { - copySpy.mockClear(); + mockCopy.mockReset(); }); it('calls `copy` wth the expected values', () => { copyHandler(baseInput); const bucket = { - bucketName: `${baseInput.config.bucket}`, - region: `${baseInput.config.region}`, + bucketName: baseInput.config.bucket, + region: baseInput.config.region, }; - const expected: StorageModule.CopyInput = { + 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, }, @@ -56,7 +61,7 @@ describe('copyHandler', () => { }, }; - expect(copySpy).toHaveBeenCalledWith(expected); + expect(mockCopy).toHaveBeenCalledWith(expected); }); it('provides eTag and notModifiedSince to copy for durableness', () => { @@ -67,11 +72,11 @@ describe('copyHandler', () => { region: `${baseInput.config.region}`, }; - const copyInput = copySpy.mock.lastCall?.[0]; + const copyInput = mockCopy.mock.lastCall?.[0]; 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, }); @@ -88,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({ @@ -100,6 +102,23 @@ describe('copyHandler', () => { }), }); - expect(copySpy).toHaveBeenCalledWith(expected); + expect(mockCopy).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = copyHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE', value: { key: path } }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockCopy.mockRejectedValue(new Error(errorMessage)); + const { result } = copyHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); 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 04fb557ab44..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 @@ -1,8 +1,8 @@ import { createFolderHandler, CreateFolderHandlerInput } from '../createFolder'; -import * as InternalStorageModule from '../../../storage-internal'; +import { uploadData, UploadDataInput } from '../../../storage-internal'; -const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData'); +jest.mock('../../../storage-internal'); const credentials = jest.fn(); @@ -18,35 +18,47 @@ const onProgress = jest.fn(); const baseInput: CreateFolderHandlerInput = { config, - data: { key: '', id: 'an-id' }, - destinationPrefix: 'prefix/', + data: { key: 'prefix/', id: 'an-id' }, }; -const error = new Error('Failed!'); - describe('createFolderHandler', () => { + const path = 'path'; + const mockUploadDataReturnValue = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + result: Promise.resolve({ path }), + state: 'SUCCESS' as const, + }; + const mockUploadData = jest.mocked(uploadData); + beforeEach(() => { jest.clearAllMocks(); + mockUploadData.mockReturnValue(mockUploadDataReturnValue); }); - it('behaves as expected in the happy path', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }); + afterEach(() => { + mockUploadData.mockReset(); + }); + beforeEach(() => {}); + + 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: InternalStorageModule.UploadDataInput = { + const expected: UploadDataInput = { data: '', options: { expectedBucketOwner: config.accountId, @@ -59,24 +71,17 @@ describe('createFolderHandler', () => { onProgress: expect.any(Function), preventOverwrite: true, }, - path: `${baseInput.destinationPrefix}${baseInput.data.key}`, + path: baseInput.data.key, }; - expect(uploadDataSpy).toHaveBeenCalledWith(expected); + expect(mockUploadData).toHaveBeenCalledWith(expected); }); it('calls provided onProgress callback as expected in the happy path', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ totalBytes: 23, transferredBytes: 23 }); - - return { - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ totalBytes: 23, transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = createFolderHandler({ @@ -84,24 +89,20 @@ 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); }); it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ transferredBytes: 23 }); - - return { - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = createFolderHandler({ @@ -109,25 +110,28 @@ 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); }); it('handles a failure as expected', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.reject(error), + const errorMessage = 'error-message'; + + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, + result: Promise.reject(new Error(errorMessage)), state: 'ERROR', }); const { result } = createFolderHandler(baseInput); expect(await result).toStrictEqual({ - message: error.message, + message: errorMessage, status: 'FAILED', }); }); @@ -137,10 +141,8 @@ describe('createFolderHandler', () => { const overwritePreventedError = new Error(message); overwritePreventedError.name = 'PreconditionFailed'; - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(overwritePreventedError), state: 'ERROR', }); 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 7e99f1cc3f4..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 @@ -1,8 +1,8 @@ -import * as StorageModule from '../../../storage-internal'; +import { remove, RemoveInput } from '../../../storage-internal'; import { deleteHandler, DeleteHandlerInput } from '../delete'; -const removeSpy = jest.spyOn(StorageModule, 'remove'); +jest.mock('../../../storage-internal'); const baseInput: DeleteHandlerInput = { config: { @@ -16,17 +16,26 @@ 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 }); + }); + + afterEach(() => { + mockRemove.mockReset(); + }); + it('calls `remove` and returns the expected `key`', () => { deleteHandler(baseInput); - const expected: StorageModule.RemoveInput = { + const expected: RemoveInput = { path: baseInput.data.key, options: { expectedBucketOwner: baseInput.config.accountId, @@ -39,6 +48,23 @@ describe('deleteHandler', () => { }, }; - expect(removeSpy).toHaveBeenCalledWith(expected); + expect(mockRemove).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = deleteHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE', value: { key: path } }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockRemove.mockRejectedValue(new Error(errorMessage)); + const { result } = deleteHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); 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 cf602feaf34..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 @@ -1,8 +1,8 @@ -import * as StorageModule from '../../../storage-internal'; +import { getUrl, GetUrlInput } from '../../../storage-internal'; import { downloadHandler, DownloadHandlerInput } from '../download'; -const downloadSpy = jest.spyOn(StorageModule, 'getUrl'); +jest.mock('../../../storage-internal'); const baseInput: DownloadHandlerInput = { config: { @@ -16,17 +16,27 @@ const baseInput: DownloadHandlerInput = { id: 'id', key: 'prefix/file-name', fileKey: 'file-name', - lastModified: new Date(), - size: 1000022, - type: 'FILE', }, }; describe('downloadHandler', () => { + const url = new URL('mock://fake.url'); + const mockGetUrl = jest.mocked(getUrl); + + beforeEach(() => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + mockGetUrl.mockResolvedValue({ expiresAt, url }); + }); + + afterEach(() => { + mockGetUrl.mockReset(); + }); + it('calls `getUrl` with the expected values', () => { downloadHandler(baseInput); - const expected: StorageModule.GetUrlInput = { + const expected: GetUrlInput = { path: baseInput.data.key, options: { bucket: { @@ -41,6 +51,23 @@ describe('downloadHandler', () => { }, }; - expect(downloadSpy).toHaveBeenCalledWith(expected); + expect(mockGetUrl).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = downloadHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE', value: { url } }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockGetUrl.mockRejectedValue(new Error(errorMessage)); + const { result } = downloadHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts index 84f51da6e81..7689e273192 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts @@ -1,4 +1,4 @@ -import * as StorageModule from '../../../storage-internal'; +import { list } from '../../../storage-internal'; import { listLocationItemsHandler, @@ -16,9 +16,7 @@ Object.defineProperty(globalThis, 'crypto', { }, }); -const listSpy = jest - .spyOn(StorageModule, 'list') - .mockImplementation(() => Promise.resolve({ items: [], nextToken: '' })); +jest.mock('../../../storage-internal'); const baseInput: ListLocationItemsHandlerInput = { prefix: 'prefix/', @@ -34,12 +32,18 @@ const baseInput: ListLocationItemsHandlerInput = { const prefix = 'prefix1/'; describe('listLocationItemsHandler', () => { + const mockList = jest.mocked(list); + beforeEach(() => { - listSpy.mockClear(); + mockList.mockResolvedValue({ items: [], nextToken: '' }); + }); + + afterEach(() => { + mockList.mockReset(); }); it('returns the expected output shape in the happy path', async () => { - listSpy.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' }); + mockList.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' }); const { items, nextToken } = await listLocationItemsHandler(baseInput); @@ -48,7 +52,7 @@ describe('listLocationItemsHandler', () => { }); it('provides expected `pageSize` to `list` on initial load', async () => { - listSpy.mockResolvedValueOnce({ items: [] }); + mockList.mockResolvedValueOnce({ items: [] }); const input = { ...baseInput, @@ -58,8 +62,8 @@ describe('listLocationItemsHandler', () => { await listLocationItemsHandler(input); - expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith({ + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith({ path: input.prefix, options: { bucket: { @@ -75,8 +79,9 @@ describe('listLocationItemsHandler', () => { }, }); }); + it('provides `pageSize` number of items after removing items that match / or . or ..', async () => { - listSpy + mockList .mockResolvedValueOnce({ items: [ { path: `/`, lastModified: new Date(), size: 0 }, @@ -102,7 +107,48 @@ describe('listLocationItemsHandler', () => { const listItems = await listLocationItemsHandler(input); expect(listItems.items).toHaveLength(input.options.pageSize); - expect(listSpy).toHaveBeenCalledTimes(2); + expect(mockList).toHaveBeenCalledTimes(2); + }); + + it('can exclude by type', async () => { + mockList.mockResolvedValueOnce({ + items: [ + { path: `someFolder/`, lastModified: new Date(), size: 0 }, + { path: `someFile`, lastModified: new Date(), size: 56984 }, + ], + }); + + const input = { ...baseInput, options: { exclude: 'FOLDER' as const } }; + + const listItems = await listLocationItemsHandler(input); + expect(listItems.items).toHaveLength(1); + }); + + it('uses appropriate subpathStrategy when delimiter is present', async () => { + const input = { ...baseInput, options: { delimiter: '/' } }; + + await listLocationItemsHandler(input); + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + subpathStrategy: { delimiter: '/', strategy: 'exclude' }, + }), + }) + ); + }); + + it('can list with an offset', async () => { + const input = { + ...baseInput, + options: { nextToken: 'some-token', pageSize: 3 }, + }; + + await listLocationItemsHandler(input); + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ pageSize: 3 }), + }) + ); }); }); 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 2e123f8dae1..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 @@ -1,5 +1,5 @@ -import * as InternalStorageModule from '../../../storage-internal'; -import * as StorageModule from 'aws-amplify/storage'; +import { uploadData, UploadDataInput } from '../../../storage-internal'; +import { isCancelError } from 'aws-amplify/storage'; import { MULTIPART_UPLOAD_THRESHOLD_BYTES, @@ -8,8 +8,8 @@ import { UNDEFINED_CALLBACKS, } from '../upload'; -const isCancelErrorSpy = jest.spyOn(StorageModule, 'isCancelError'); -const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData'); +jest.mock('aws-amplify/storage'); +jest.mock('../../../storage-internal'); const credentials = jest.fn(); @@ -23,43 +23,51 @@ const config: UploadHandlerInput['config'] = { const file = new File([], 'test-o'); -const onProgress = jest.fn(); - const baseInput: UploadHandlerInput = { config, - data: { key: file.name, id: 'an-id', file }, - destinationPrefix: 'prefix/', + data: { key: `'prefix/'${file.name}`, id: 'an-id', file }, }; -const cancel = jest.fn(); -const pause = jest.fn(); -const resume = jest.fn(); - const error = new Error('Failed!'); describe('uploadHandler', () => { + const mockUploadDataReturnValue = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + result: Promise.resolve({ path: file.name }), + state: 'SUCCESS' as const, + }; + const mockIsCancelError = jest.mocked(isCancelError); + const mockUploadData = jest.mocked(uploadData); + const mockOnProgress = jest.fn(); + beforeEach(() => { - jest.clearAllMocks(); + mockUploadData.mockReturnValue(mockUploadDataReturnValue); }); - it('behaves as expected in the happy path', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); + afterEach(() => { + mockOnProgress.mockClear(); + mockIsCancelError.mockReset(); + mockUploadData.mockReset(); + }); + 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: InternalStorageModule.UploadDataInput = { + const expected: UploadDataInput = { data: file, options: { expectedBucketOwner: config.accountId, @@ -73,60 +81,52 @@ describe('uploadHandler', () => { preventOverwrite: true, checksumAlgorithm: 'crc-32', }, - path: `${baseInput.destinationPrefix}${baseInput.data.key}`, + path: baseInput.data.key, }; - expect(uploadDataSpy).toHaveBeenCalledWith(expected); + expect(mockUploadData).toHaveBeenCalledWith(expected); }); it('calls provided onProgress callback as expected in the happy path', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ totalBytes: 23, transferredBytes: 23 }); + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ totalBytes: 23, transferredBytes: 23 }); - return { - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }; + return mockUploadDataReturnValue; }); const { result } = uploadHandler({ ...baseInput, - options: { onProgress }, + options: { onProgress: mockOnProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); - expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data, 1); + expect(mockOnProgress).toHaveBeenCalledTimes(1); + expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, 1); }); it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ transferredBytes: 23 }); + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ transferredBytes: 23 }); - return { - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }; + return mockUploadDataReturnValue; }); const { result } = uploadHandler({ ...baseInput, - options: { onProgress }, + options: { onProgress: mockOnProgress }, }); - expect(await result).toStrictEqual({ status: 'COMPLETE' }); + expect(await result).toStrictEqual({ + status: 'COMPLETE', + value: { key: file.name }, + }); - expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data, undefined); + expect(mockOnProgress).toHaveBeenCalledTimes(1); + expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, undefined); }); it('returns the expected callback values for a file size greater than 5 mb', async () => { @@ -135,50 +135,42 @@ describe('uploadHandler', () => { '😅' ); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); - const { result, ...callbacks } = uploadHandler({ ...baseInput, 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, pause, resume }); + expect(callbacks).toStrictEqual({ + cancel: expect.any(Function), + pause: expect.any(Function), + resume: expect.any(Function), + }); }); it('returns undefined callback values for a file size less than 5 mb', async () => { const smallFile = new File([], '😅'); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); - const { result, ...callbacks } = uploadHandler({ ...baseInput, 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); }); it('handles a failure as expected', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(error), state: 'ERROR', }); @@ -194,11 +186,9 @@ describe('uploadHandler', () => { it('handles a cancel failure as expected', async () => { // turn off console.warn in test output jest.spyOn(console, 'warn').mockReturnValueOnce(); - isCancelErrorSpy.mockReturnValue(true); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockIsCancelError.mockReturnValue(true); + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(error), state: 'ERROR', }); @@ -215,17 +205,15 @@ describe('uploadHandler', () => { const preconditionError = new Error('Failed!'); preconditionError.name = 'PreconditionFailed'; - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(preconditionError), state: 'ERROR', }); 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 f9cec8851ae..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 @@ -5,136 +5,210 @@ import { shouldExcludeLocation, getFileKey, parseAccessGrantLocation, + getFilteredLocations, + isFileItem, + isFileDataItem, + createFileDataItem, } from '../utils'; -describe('parseLocationAccess', () => { +describe('utils', () => { const bucket = 'test-bucket'; const folderPrefix = 'test-prefix/'; - const filePath = 'some-file.jpeg2000'; - + const fileKey = 'some-file.jpeg2000'; const id = 'intentionally-static-test-id'; + beforeAll(() => { Object.defineProperty(globalThis, 'crypto', { value: { randomUUID: () => id }, }); }); - it('throws if provided an invalid location scope', () => { - const invalidLocation: AccessGrantLocation = { - scope: 'nope', - permission: 'READ', - type: 'BUCKET', - }; + describe('parseAccessGrantLocation', () => { + it('throws if provided an invalid location scope', () => { + const invalidLocation: AccessGrantLocation = { + scope: 'nope', + permission: 'READ', + type: 'BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid scope: nope' + ); + }); - expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( - 'Invalid scope: nope' - ); - }); + it('throws if provided an invalid location type', () => { + const invalidLocation: AccessGrantLocation = { + scope: 's3://yes', + permission: 'READ', + // @ts-expect-error intentional coercing to allow unhappy path test + type: 'NOT_BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid location type: NOT_BUCKET' + ); + }); - it('throws if provided an invalid location type', () => { - const invalidLocation: AccessGrantLocation = { - scope: 's3://yes', - permission: 'READ', - // @ts-expect-error intentional coercing to allow unhappy path test - type: 'NOT_BUCKET', - }; + it('throws if provided an invalid location permission', () => { + const invalidLocation: AccessGrantLocation = { + scope: `s3://${bucket}/*`, + // @ts-expect-error force unhandled permission + permission: 'INVALID', + type: 'BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid location permission' + ); + }); - expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( - 'Invalid location type: NOT_BUCKET' - ); - }); + it('parses a BUCKET location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/*`, + type: 'BUCKET', + }; + const expected: LocationData = { + bucket, + id, + prefix: '', + permissions: ['delete', 'write'], + type: 'BUCKET', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); - it('parses a BUCKET location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/*`, - type: 'BUCKET', - }; - const expected: LocationData = { - bucket, - id, - prefix: '', - permissions: ['delete', 'write'], - type: 'BUCKET', - }; + it('parses a PREFIX location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}*`, + type: 'PREFIX', + }; + + const expected: LocationData = { + bucket, + id, + prefix: folderPrefix, + permissions: ['delete', 'write'], + type: 'PREFIX', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + it('parses an OBJECT location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}${fileKey}`, + type: 'OBJECT', + }; + + const expected: LocationData = { + bucket, + id, + prefix: `${folderPrefix}${fileKey}`, + permissions: ['delete', 'write'], + type: 'OBJECT', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); }); - it('parses a PREFIX location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}*`, - type: 'PREFIX', - }; - - const expected: LocationData = { - bucket, - id, - prefix: folderPrefix, - permissions: ['delete', 'write'], - type: 'PREFIX', - }; + describe('getFileKey', () => { + it('should return the filename without the path', () => { + expect(getFileKey('/path/to/file.txt')).toBe('file.txt'); + expect(getFileKey('document.pdf')).toBe('document.pdf'); + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + it('should handle paths with multiple slashes', () => { + expect(getFileKey('/path//to///file.txt')).toBe('file.txt'); + }); }); - it('parses an OBJECT location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}${filePath}`, - type: 'OBJECT', + describe('shouldExcludeLocation', () => { + const location: LocationData = { + bucket: 'bucket', + id: 'id', + permissions: ['list', 'get'], + prefix: 'prefix/', + type: 'PREFIX', }; - const expected: LocationData = { - bucket, - id, - prefix: `${folderPrefix}${filePath}`, - permissions: ['delete', 'write'], - type: 'OBJECT', - }; + it('returns true when the provided location permissions match excluded permissions', () => { + const output = shouldExcludeLocation(location, { + exactPermissions: ['list', 'get'], + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); - }); -}); + expect(output).toBe(true); + }); -describe('getFileKey', () => { - it('should return the filename without the path', () => { - expect(getFileKey('/path/to/file.txt')).toBe('file.txt'); - expect(getFileKey('document.pdf')).toBe('document.pdf'); - }); + it('returns true when the provided location type match excluded type', () => { + const output = shouldExcludeLocation(location, { type: 'PREFIX' }); - it('should handle paths with multiple slashes', () => { - expect(getFileKey('/path//to///file.txt')).toBe('file.txt'); - }); -}); + expect(output).toBe(true); + }); -describe('shouldExcludeLocation', () => { - const location: LocationData = { - bucket: 'bucket', - id: 'id', - permissions: ['list', 'get'], - prefix: 'prefix/', - type: 'PREFIX', - }; + it('returns true when the provided location type is included in excluded types', () => { + const output = shouldExcludeLocation(location, { type: ['PREFIX'] }); - it('returns true when the provided location permissions match excluded permissions', () => { - const output = shouldExcludeLocation(location, { - exactPermissions: ['list', 'get'], + expect(output).toBe(true); }); - expect(output).toBe(true); + it('returns false when provided a location without an exclude value', () => { + const output = shouldExcludeLocation(location); + + expect(output).toBe(false); + }); }); - it('returns true when the provided location type match excluded type', () => { - const output = shouldExcludeLocation(location, { type: 'PREFIX' }); + describe('getFilteredLocations', () => { + it('should filter out non-folder-like prefix locations', () => { + const locations: AccessGrantLocation[] = [ + { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}*`, + type: 'PREFIX', + }, + { + permission: 'WRITE', + scope: `s3://${bucket}/non-folder-like-prefix*`, + type: 'PREFIX', + }, + ]; + + expect(getFilteredLocations(locations)).toStrictEqual([ + expect.objectContaining({ prefix: folderPrefix }), + ]); + }); + }); - expect(output).toBe(true); + describe('createFileDataItem', () => { + it('creates a FileDataItem from FileData', () => { + expect( + createFileDataItem({ + key: `prefix/${fileKey}`, + lastModified: new Date(1), + id, + size: 0, + type: 'FILE' as const, + }) + ).toStrictEqual(expect.objectContaining({ fileKey })); + }); }); - it('returns false when provided a location without an exclude value', () => { - const output = shouldExcludeLocation(location); + describe('isFileItem', () => { + it('should return true if object is FileItem', () => { + expect(isFileItem({ file: {} })).toBe(true); + expect(isFileItem({})).toBe(false); + }); + }); - expect(output).toBe(false); + describe('isFileDataItem', () => { + it('should return true if object is FileDataItem', () => { + expect(isFileDataItem({ fileKey: 'file-key' })).toBe(true); + expect(isFileDataItem({})).toBe(false); + }); }); }); 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/listLocationItems.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts index 2fe3c8f38dd..29608d2fc27 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts @@ -116,9 +116,9 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async ( strategy: delimiter ? 'exclude' : 'include', }; - // `ListObjectsV2` returns the root `key` on initial request, which is from - // filtered from `results` by `parseResult`, creatimg a scenario where the - // return count of `results` to be one item less than provided the `pageSize`. + // `ListObjectsV2` returns the root `key` on initial request which, when from + // filtered from `results` by `parseResult`, creates a scenario where the + // return count of `results` is one item less than the provided `pageSize`. // To mitigate, if a `pageSize` is provided and there are no previous `results` // or `refresh` is `true` increment the provided `pageSize` by `1` const hasOffset = !nextToken; 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/PaginationControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx index dc9cd66b5de..a9e3dfd93a0 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { Pagination } from '../composables/Pagination'; import { useResolvedComposable } from './hooks/useResolvedComposable'; -import { useControlsContext } from './context'; +import { usePagination } from './hooks/usePagination'; export const PaginationControl = (): React.JSX.Element => { - const { data } = useControlsContext(); + const props = usePagination(); const Resolved = useResolvedComposable(Pagination, 'Pagination'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/SearchControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/SearchControl.tsx deleted file mode 100644 index 22c8e75a434..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/SearchControl.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; - -import { SearchField } from '../composables/SearchField'; - -import { useControlsContext } from './context'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; - -export const SearchControl = (): React.JSX.Element => { - const { data, onSearch, onSearchQueryChange, onSearchClear } = - useControlsContext(); - const { - searchPlaceholder, - searchClearLabel, - searchQuery, - searchSubmitLabel, - } = data; - const Resolved = useResolvedComposable(SearchField, 'SearchField'); - - return ( - - ); -}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/SearchFieldControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/SearchFieldControl.tsx new file mode 100644 index 00000000000..fd3527441e8 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/SearchFieldControl.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { SearchField } from '../composables/SearchField'; + +import { useResolvedComposable } from './hooks/useResolvedComposable'; +import { useSearchField } from './hooks/useSearchField'; + +export const SearchFieldControl = (): React.JSX.Element => { + const props = useSearchField(); + const Resolved = useResolvedComposable(SearchField, 'SearchField'); + + return ; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/PaginationControl.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/PaginationControl.spec.tsx index 137255fff58..5bcd6653567 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/PaginationControl.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/PaginationControl.spec.tsx @@ -1,60 +1,34 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { PaginationControl } from '../PaginationControl'; -import { useControlsContext } from '../context'; +import { usePagination } from '../hooks/usePagination'; import { useResolvedComposable } from '../hooks/useResolvedComposable'; -jest.mock('../context'); +jest.mock('../hooks/usePagination'); jest.mock('../hooks/useResolvedComposable'); +jest.mock('../../composables/Pagination', () => ({ + Pagination: () =>
, +})); describe('PaginationControl', () => { - // assert mocks - const mockUseControlsContext = useControlsContext as jest.Mock; - const mockUseResolvedComposable = useResolvedComposable as jest.Mock; + const mockUsePagination = jest.mocked(usePagination); + const mockUseResolvedComposable = jest.mocked(useResolvedComposable); beforeAll(() => { mockUseResolvedComposable.mockImplementation( - (component: React.JSX.Element) => component + (component) => component as () => React.JSX.Element ); }); afterEach(() => { - mockUseControlsContext.mockReset(); - mockUseResolvedComposable.mockReset(); + mockUsePagination.mockClear(); }); - it('renders the PaginationControl', async () => { - mockUseControlsContext.mockReturnValue({ - data: { - paginationData: { - hasNextPage: true, - highestPageVisited: 1, - onPaginate: jest.fn(), - page: 1, - }, - }, - }); - + it('renders', () => { render(); - const nav = screen.getByRole('navigation'); - const list = screen.getByRole('list'); - const listItems = await screen.findAllByRole('listitem'); - const nextButton = screen.getByRole('button', { name: 'Go to next page' }); - const prevButton = screen.getByRole('button', { - name: 'Go to previous page', - }); - const nextIcon = nextButton.querySelector('svg'); - const prevIcon = nextButton.querySelector('svg'); + const pagination = screen.getByTestId('pagination'); - expect(nextButton).toBeInTheDocument(); - expect(prevButton).toBeInTheDocument(); - expect(nextIcon).toBeInTheDocument(); - expect(prevIcon).toBeInTheDocument(); - expect(nextIcon).toHaveAttribute('aria-hidden', 'true'); - expect(prevIcon).toHaveAttribute('aria-hidden', 'true'); - expect(nav).toBeInTheDocument(); - expect(list).toBeInTheDocument(); - expect(listItems).toHaveLength(3); + expect(pagination).toBeInTheDocument(); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchControl.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchControl.spec.tsx deleted file mode 100644 index e9fdcf5d5e1..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchControl.spec.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { SearchControl } from '../SearchControl'; -import { useResolvedComposable } from '../hooks/useResolvedComposable'; -import { useControlsContext } from '../context'; - -jest.mock('../context'); -jest.mock('../hooks/useResolvedComposable'); - -describe('SearchControl', () => { - // assert mocks - const mockUseControlsContext = useControlsContext as jest.Mock; - const mockUseResolvedComposable = useResolvedComposable as jest.Mock; - - beforeAll(() => { - mockUseResolvedComposable.mockImplementation( - (component: React.JSX.Element) => component - ); - }); - - afterEach(() => { - mockUseControlsContext.mockReset(); - mockUseResolvedComposable.mockReset(); - }); - - it('renders the SearchControl', () => { - mockUseControlsContext.mockReturnValue({ - data: { searchPlaceholder: 'Placeholder', searchSubmitLabel: 'Submit' }, - onSearch: jest.fn(), - }); - - render(); - - const field = screen.getByPlaceholderText('Placeholder'); - const button = screen.getByRole('button', { name: 'Submit' }); - - expect(button).toBeInTheDocument(); - expect(field).toBeInTheDocument(); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchFieldControl.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchFieldControl.spec.tsx new file mode 100644 index 00000000000..bf4c7d5deea --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/SearchFieldControl.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SearchFieldControl } from '../SearchFieldControl'; +import { useSearchField } from '../hooks/useSearchField'; +import { useResolvedComposable } from '../hooks/useResolvedComposable'; + +jest.mock('../hooks/useSearchField'); +jest.mock('../hooks/useResolvedComposable'); +jest.mock('../../composables/SearchField', () => ({ + SearchField: () =>
, +})); + +describe('SearchFieldControl', () => { + const mockUseSearchField = jest.mocked(useSearchField); + const mockUseResolvedComposable = jest.mocked(useResolvedComposable); + + beforeAll(() => { + mockUseResolvedComposable.mockImplementation( + (component) => component as () => React.JSX.Element + ); + }); + + afterEach(() => { + mockUseSearchField.mockClear(); + }); + + it('renders', () => { + render(); + + const searchField = screen.getByTestId('search-field'); + + expect(searchField).toBeInTheDocument(); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/usePagination.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/usePagination.spec.ts new file mode 100644 index 00000000000..2e4334ef4eb --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/usePagination.spec.ts @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react'; +import { PaginationProps } from '../../../composables/Pagination'; +import { useControlsContext } from '../../../controls/context'; +import { usePagination } from '../usePagination'; + +jest.mock('../../../controls/context'); + +describe('usePagination', () => { + const data = { + paginationData: { hasNextPage: true, highestPageVisited: 1, page: 1 }, + }; + + const mockUseControlsContext = jest.mocked(useControlsContext); + + beforeEach(() => { + mockUseControlsContext.mockReturnValue({ data, onPaginate: jest.fn() }); + }); + + afterEach(() => { + mockUseControlsContext.mockReset(); + }); + + it('returns Pagination props', () => { + const { result } = renderHook(() => usePagination()); + + const expected: PaginationProps = { + hasNextPage: data.paginationData.hasNextPage, + highestPageVisited: data.paginationData.highestPageVisited, + page: data.paginationData.page, + onPaginate: expect.any(Function), + }; + + expect(result.current).toStrictEqual(expected); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useSearchField.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useSearchField.spec.ts new file mode 100644 index 00000000000..9f72c16f2e3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useSearchField.spec.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react'; +import { SearchFieldProps } from '../../../composables/SearchField'; +import { useControlsContext } from '../../../controls/context'; +import { useSearchField } from '../useSearchField'; + +jest.mock('../../../controls/context'); + +describe('useSearchField', () => { + const data = { + searchClearLabel: 'search-clear-label', + searchPlaceholder: 'search-placeholder', + searchSubmitLabel: 'search-submit-label', + searchQuery: 'search-query', + }; + + const mockUseControlsContext = jest.mocked(useControlsContext); + + beforeEach(() => { + mockUseControlsContext.mockReturnValue({ + data, + onSearch: jest.fn(), + onSearchClear: jest.fn(), + onSearchQueryChange: jest.fn(), + }); + }); + + afterEach(() => { + mockUseControlsContext.mockReset(); + }); + + it('returns useSearchField data', () => { + const { result } = renderHook(() => useSearchField()); + + const expected: SearchFieldProps = { + clearLabel: data.searchClearLabel, + placeholder: data.searchPlaceholder, + query: data.searchQuery, + submitLabel: data.searchSubmitLabel, + onClear: expect.any(Function), + onQueryChange: expect.any(Function), + onSearch: expect.any(Function), + }; + + expect(result.current).toStrictEqual(expected); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/usePagination.tsx b/packages/react-storage/src/components/StorageBrowser/controls/hooks/usePagination.tsx new file mode 100644 index 00000000000..b7407e6ffb6 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/usePagination.tsx @@ -0,0 +1,9 @@ +import { PaginationProps } from '../../composables/Pagination'; +import { useControlsContext } from '../../controls/context'; + +export const usePagination = (): PaginationProps => { + const { data, onPaginate } = useControlsContext(); + const { paginationData } = data; + + return { ...paginationData, onPaginate }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useSearchField.tsx b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useSearchField.tsx new file mode 100644 index 00000000000..cac5961d850 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useSearchField.tsx @@ -0,0 +1,23 @@ +import { SearchFieldProps } from '../../composables/SearchField'; +import { useControlsContext } from '../../controls/context'; + +export const useSearchField = (): SearchFieldProps => { + const { data, onSearch, onSearchClear, onSearchQueryChange } = + useControlsContext(); + const { + searchPlaceholder, + searchClearLabel, + searchQuery, + searchSubmitLabel, + } = data; + + return { + clearLabel: searchClearLabel, + placeholder: searchPlaceholder, + query: searchQuery, + submitLabel: searchSubmitLabel, + onClear: onSearchClear, + onQueryChange: onSearchQueryChange, + onSearch, + }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/types.ts b/packages/react-storage/src/components/StorageBrowser/controls/types.ts index 1ebd86acb75..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'; @@ -31,13 +31,12 @@ interface TableData { interface PaginationData { hasNextPage: boolean; highestPageVisited: number; - onPaginate: (page: number) => void; page: number; } export interface ControlsContext { data: { - actions?: ActionsListItem[]; + actions?: ActionListItem[]; actionCancelLabel?: string; actionDestinationLabel?: string; actionExitLabel?: string; @@ -90,6 +89,7 @@ export interface ControlsContext { onFolderNameChange?: (value: string) => void; onNavigate?: (location: LocationData, path?: string) => void; onNavigateHome?: () => void; + onPaginate?: (page: number) => void; onRefresh?: () => void; onSearch?: () => void; onSearchClear?: () => void; 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 a5f67364443..f1e4e0d7311 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/__tests__/useStore.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/useStore.spec.ts new file mode 100644 index 00000000000..f76d07891b3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/useStore.spec.ts @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react'; +import { useActionType } from '../actionType'; +import { useFiles } from '../files'; +import { useLocation } from '../location'; +import { useLocationItems } from '../locationItems'; +import { HandleStoreAction, useStore } from '../useStore'; +import { FileItem } from '../../../actions'; + +jest.mock('../../../controls/context'); +jest.mock('../actionType'); +jest.mock('../files'); +jest.mock('../location'); +jest.mock('../locationItems'); + +describe('useStore', () => { + const locationData = { + bucket: 'bucket', + id: 'id', + permissions: ['write' as const], + prefix: '', + type: 'PREFIX' as const, + }; + const actionTypeState = 'action-type'; + const filesState: FileItem[] = []; + const locationState = { current: locationData, path: '', key: '' }; + const locationItemsState = { fileDataItems: [] }; + + const mockUseActionType = jest.mocked(useActionType); + const mocktUseFiles = jest.mocked(useFiles); + const mocktUseLocation = jest.mocked(useLocation); + const mocktUseLocationItems = jest.mocked(useLocationItems); + const mockDispatchActionType = jest.fn(); + const mockDispatchFilesAction = jest.fn(); + const mockDispatchLocationAction = jest.fn(); + const mockDispatchLocationItemsAction = jest.fn(); + + const expectActions = (actions: Parameters[number][]) => { + const { result } = renderHook(() => useStore()); + const [, dispatchStoreAction] = result.current; + return { + toHaveBeenDispatchedBy: (dispatcher: HandleStoreAction) => { + actions.forEach((action, index) => { + // dispatch each action as a store action + dispatchStoreAction(action); + // assert that that the correct sub-dispatcher was invoked + expect(dispatcher).toHaveBeenNthCalledWith(index + 1, action); + }); + }, + }; + }; + + beforeEach(() => { + mockUseActionType.mockReturnValue([ + actionTypeState, + mockDispatchActionType, + ]); + mocktUseFiles.mockReturnValue([filesState, mockDispatchFilesAction]); + mocktUseLocation.mockReturnValue([ + locationState, + mockDispatchLocationAction, + ]); + mocktUseLocationItems.mockReturnValue([ + locationItemsState, + mockDispatchLocationItemsAction, + ]); + }); + + afterEach(() => { + mockUseActionType.mockClear(); + mocktUseFiles.mockClear(); + mocktUseLocation.mockClear(); + mocktUseLocationItems.mockClear(); + }); + + it('returns store state', () => { + const { result } = renderHook(() => useStore()); + + expect(result.current).toStrictEqual([ + { + actionType: actionTypeState, + files: filesState, + location: locationState, + locationItems: locationItemsState, + }, + expect.any(Function), + ]); + }); + + it('dispatches actionType action', () => { + expectActions([ + { type: 'SET_ACTION_TYPE', actionType: 'new-action-type' }, + { type: 'RESET_ACTION_TYPE' }, + ]).toHaveBeenDispatchedBy(mockDispatchActionType); + }); + + it('dispatches files action', () => { + expectActions([ + { type: 'ADD_FILE_ITEMS' }, + { type: 'REMOVE_FILE_ITEM', id: 'file-id' }, + { type: 'SELECT_FILES' }, + { type: 'RESET_FILE_ITEMS' }, + ]).toHaveBeenDispatchedBy(mockDispatchFilesAction); + }); + + it('dispatches location action', () => { + expectActions([ + { type: 'NAVIGATE', location: locationData }, + { type: 'RESET_LOCATION' }, + ]).toHaveBeenDispatchedBy(mockDispatchLocationAction); + }); + + it('dispatches locationItems action', () => { + expectActions([ + { type: 'SET_LOCATION_ITEMS' }, + { type: 'REMOVE_LOCATION_ITEM', id: 'file-id' }, + { type: 'RESET_LOCATION_ITEMS' }, + ]).toHaveBeenDispatchedBy(mockDispatchLocationItemsAction); + }); +}); 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/CopyView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyView.tsx index 01ee8580843..be3db0d2610 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyView.tsx @@ -10,7 +10,7 @@ import { ActionStartControl } from '../../../controls/ActionStartControl'; import { DataTableControl } from '../../../controls/DataTableControl'; import { LoadingIndicatorControl } from '../../../controls/LoadingIndicatorControl'; import { MessageControl } from '../../../controls/MessageControl'; -import { SearchControl } from '../../../controls/SearchControl'; +import { SearchFieldControl } from '../../../controls/SearchFieldControl'; import { StatusDisplayControl } from '../../../controls/StatusDisplayControl'; import { TitleControl } from '../../../controls/TitleControl'; @@ -40,7 +40,7 @@ export const CopyView: CopyViewType = ({ className, ...props }) => { <> - + @@ -77,7 +77,7 @@ CopyView.Exit = ActionExitControl; CopyView.FoldersLoadingIndicator = LoadingIndicatorControl; CopyView.FoldersMessage = FoldersMessageControl; CopyView.FoldersPagination = FoldersPaginationControl; -CopyView.FoldersSearch = SearchControl; +CopyView.FoldersSearch = SearchFieldControl; CopyView.FoldersTable = FoldersTableControl; CopyView.Message = MessageControl; CopyView.Start = ActionStartControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyView.spec.tsx index 7c34aa19cb4..e431de16cf5 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/CopyView.spec.tsx @@ -35,8 +35,8 @@ jest.mock('../../../../controls/LoadingIndicatorControl', () => ({ jest.mock('../../../../controls/MessageControl', () => ({ MessageControl: () =>
, })); -jest.mock('../../../../controls/SearchControl', () => ({ - SearchControl: () =>
, +jest.mock('../../../../controls/SearchFieldControl', () => ({ + SearchFieldControl: () =>
, })); jest.mock('../../../../controls/StatusDisplayControl', () => ({ StatusDisplayControl: () =>
, @@ -106,7 +106,7 @@ describe('CopyView', () => { expect(screen.queryByTestId('FoldersTableControl')).toBeInTheDocument(); expect(screen.queryByTestId('LoadingIndicatorControl')).toBeInTheDocument(); expect(screen.queryByTestId('MessageControl')).toBeInTheDocument(); - expect(screen.queryByTestId('SearchControl')).toBeInTheDocument(); + expect(screen.queryByTestId('SearchFieldControl')).toBeInTheDocument(); expect(screen.queryByTestId('TitleControl')).toBeInTheDocument(); }); @@ -136,7 +136,7 @@ describe('CopyView', () => { expect( screen.queryByTestId('FoldersPaginationControl') ).not.toBeInTheDocument(); - expect(screen.queryByTestId('SearchControl')).not.toBeInTheDocument(); + expect(screen.queryByTestId('SearchFieldControl')).not.toBeInTheDocument(); expect( screen.queryByTestId('FoldersMessageControl') ).not.toBeInTheDocument(); @@ -172,7 +172,7 @@ describe('CopyView', () => { expect( screen.queryByTestId('FoldersPaginationControl') ).not.toBeInTheDocument(); - expect(screen.queryByTestId('SearchControl')).not.toBeInTheDocument(); + expect(screen.queryByTestId('SearchFieldControl')).not.toBeInTheDocument(); expect( screen.queryByTestId('FoldersMessageControl') ).not.toBeInTheDocument(); 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/LocationDetailView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx index 2f8947edece..0c8b792e8e6 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx @@ -10,7 +10,7 @@ import { LoadingIndicatorControl } from '../../controls/LoadingIndicatorControl' import { MessageControl } from '../../controls/MessageControl'; import { NavigationControl } from '../../controls/NavigationControl'; import { PaginationControl } from '../../controls/PaginationControl'; -import { SearchControl } from '../../controls/SearchControl'; +import { SearchFieldControl } from '../../controls/SearchFieldControl'; import { SearchSubfoldersToggleControl } from '../../controls/SearchSubfoldersToggleControl'; import { TitleControl } from '../../controls/TitleControl'; @@ -42,7 +42,7 @@ export const LocationDetailView: LocationDetailViewType = ({ - + @@ -77,6 +77,6 @@ LocationDetailView.Message = MessageControl; LocationDetailView.Navigation = NavigationControl; LocationDetailView.Pagination = PaginationControl; LocationDetailView.Refresh = DataRefreshControl; -LocationDetailView.Search = SearchControl; +LocationDetailView.Search = SearchFieldControl; LocationDetailView.SearchSubfoldersToggle = SearchSubfoldersToggleControl; LocationDetailView.Title = TitleControl; 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 c165b57e219..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,24 +79,25 @@ 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/LocationsView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx index f3337089b1b..c9082e875d6 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx @@ -7,7 +7,7 @@ import { DataTableControl } from '../../controls/DataTableControl'; import { LoadingIndicatorControl } from '../../controls/LoadingIndicatorControl'; import { MessageControl } from '../../controls/MessageControl'; import { PaginationControl } from '../../controls/PaginationControl'; -import { SearchControl } from '../../controls/SearchControl'; +import { SearchFieldControl } from '../../controls/SearchFieldControl'; import { TitleControl } from '../../controls/TitleControl'; import { LocationsViewProvider } from './LocationsViewProvider'; @@ -28,7 +28,7 @@ export const LocationsView: LocationsViewType = ({ className, ...props }) => { - + @@ -54,5 +54,5 @@ LocationsView.LocationsTable = DataTableControl; LocationsView.Message = MessageControl; LocationsView.Pagination = PaginationControl; LocationsView.Refresh = DataRefreshControl; -LocationsView.Search = SearchControl; +LocationsView.Search = SearchFieldControl; LocationsView.Title = TitleControl; 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 ef661e0b7ca..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, }); @@ -62,7 +62,6 @@ export function LocationsViewProvider({ page, hasNextPage, highestPageVisited, - onPaginate, }, title, searchPlaceholder, @@ -72,8 +71,9 @@ export function LocationsViewProvider({ message: messageControlContent, isLoading, }} - onSearch={onSearch} + onPaginate={onPaginate} onRefresh={onRefresh} + onSearch={onSearch} onSearchQueryChange={onSearchQueryChange} onSearchClear={onSearchClear} > 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](); +}; diff --git a/packages/ui/src/theme/components/aiConverstion.ts b/packages/ui/src/theme/components/aiConverstion.ts index 1bd747c910c..2de1ba95379 100644 --- a/packages/ui/src/theme/components/aiConverstion.ts +++ b/packages/ui/src/theme/components/aiConverstion.ts @@ -18,6 +18,7 @@ export type AIConversationTheme = form__dropzone?: ComponentStyles; form__attatch?: ComponentStyles; form__send?: ComponentStyles; + form_error?: ComponentStyles; form_field?: ComponentStyles; attachment?: ComponentStyles; diff --git a/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss b/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss index 5825133bf5f..a5946054eec 100644 --- a/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss +++ b/packages/ui/src/theme/css/component/AIConversation/aiConversation.scss @@ -127,12 +127,19 @@ flex-direction: row; align-items: flex-start; gap: var(--amplify-components-ai-conversation-form-gap); - padding: var(--amplify-components-ai-conversation-form-padding); &__dropzone { text-align: initial; border: none; + padding: var(--amplify-components-ai-conversation-form-padding); + } + + &__error { padding: 0; + padding-block-start: var( + --amplify-components-ai-conversation-attachment-list-padding-block-start + ); + gap: var(--amplify-components-ai-conversation-attachment-gap); } &__attach { @@ -172,7 +179,7 @@ flex-wrap: wrap; gap: var(--amplify-components-ai-conversation-attachment-list-gap); padding-block-start: var( - --amplify-components-ai-conversation-attachment-padding-block-start + --amplify-components-ai-conversation-attachment-list-padding-block-start ); } diff --git a/packages/ui/src/types/primitives/componentClassName.ts b/packages/ui/src/types/primitives/componentClassName.ts index 0875fb0aa89..d14fdece61e 100644 --- a/packages/ui/src/types/primitives/componentClassName.ts +++ b/packages/ui/src/types/primitives/componentClassName.ts @@ -30,6 +30,7 @@ export const ComponentClassName = { AIConversationAttachmentRemove: 'amplify-ai-conversation__attachment__remove', AIConversationForm: 'amplify-ai-conversation__form', AIConversationFormAttach: 'amplify-ai-conversation__form__attach', + AIConversationFormError: 'amplify-ai-conversation__form__error', AIConversationFormSend: 'amplify-ai-conversation__form__send', AIConversationFormField: 'amplify-ai-conversation__form__field', AIConversationFormDropzone: 'amplify-ai-conversation__form__dropzone',