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 696dceb46fd..d6b93e23d3a 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 @@ -1,16 +1,18 @@ import React from 'react'; import { + createManagedAuthAdapter, CreateStorageBrowserInput, createStorageBrowser, } from '@aws-amplify/ui-react-storage/browser'; -import { auth, managedAuthAdapter } from '../managedAuthAdapter'; +import { Auth } from '../managedAuthAdapter'; import { Button, Flex, Breadcrumbs } from '@aws-amplify/ui-react'; -import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react/styles/reset.css'; import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; const components: CreateStorageBrowserInput['components'] = { Navigation: ({ items }) => ( @@ -26,15 +28,64 @@ const components: CreateStorageBrowserInput['components'] = { ), }; -const { StorageBrowser } = createStorageBrowser({ +export const auth = new Auth({ persistCredentials: true }); + +const config = createManagedAuthAdapter({ + credentialsProvider: auth.credentialsProvider, + region: process.env.NEXT_PUBLIC_MANAGED_AUTH_REGION, + accountId: process.env.NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID, + registerAuthListener: auth.registerAuthListener, +}); + +const { StorageBrowser, useView } = createStorageBrowser({ components, - config: managedAuthAdapter, + config, }); -function LocationActionView() { +const { CreateFolderView, DeleteView, LocationActionView } = StorageBrowser; + +const MyCreateFolderView = () => { + const viewState = useView('CreateFolder'); + const { isProcessing } = viewState; + return ( + + {isProcessing ?

Folder creation in progress

: null} + + +
+ ); +}; + +const MyDeleteView = () => { + const viewState = useView('Delete'); + const { isProcessing } = viewState; + return ( + + {isProcessing ?

Delete in progress

: null} + + +
+ ); +}; + +function MyLocationActionView({ type }: { type?: string }) { + let DialogContent = null; + if (!type) return DialogContent; + + switch (type) { + case 'createFolder': + DialogContent = MyCreateFolderView; + break; + case 'delete': + DialogContent = MyDeleteView; + break; + default: + DialogContent = LocationActionView; + } + return ( - + ); } @@ -50,11 +101,12 @@ function MyStorageBrowser() { { + console.log(actionType); setActionType(actionType); }} /> - {type ? : null} + ); } 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 5913451f2ab..2e3be8cc907 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 @@ -2,8 +2,15 @@ import React from 'react'; import { Amplify } from 'aws-amplify'; import { signOut } from 'aws-amplify/auth'; -import { Button, Flex, View, withAuthenticator } from '@aws-amplify/ui-react'; +import { + Button, + Flex, + IconsProvider, + View, + withAuthenticator, +} from '@aws-amplify/ui-react'; import { StorageBrowser } from '@aws-amplify/ui-react-storage'; + import '@aws-amplify/ui-react/styles/reset.css'; import '@aws-amplify/ui-react-storage/styles.css'; import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; @@ -12,6 +19,26 @@ import config from './aws-exports'; Amplify.configure(config); +const IndeterminateIcon = () => ( + + + + + +); + function Example() { return ( - + }, + }} + > + + ); diff --git a/packages/e2e/cypress/integration/common/shared.ts b/packages/e2e/cypress/integration/common/shared.ts index ff0202bc76a..d53ef3fdbc6 100644 --- a/packages/e2e/cypress/integration/common/shared.ts +++ b/packages/e2e/cypress/integration/common/shared.ts @@ -288,6 +288,12 @@ Then('I see the button containing {string}', (name: string) => { }).should('exist'); }); +Then('I do not see the button containing {string}', (name: string) => { + cy.findByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }).should('not.exist'); +}); + Then('I see the first button containing {string}', (name: string) => { cy.findAllByRole('button', { name: new RegExp(`${escapeRegExp(name)}`, 'i'), @@ -546,6 +552,13 @@ When('I type my new password', () => { cy.findInputField('New Password').type(Cypress.env('VALID_PASSWORD')); }); +When( + 'I see input with placeholder {string} and type {string}', + (name: string, value: string) => { + cy.findByPlaceholderText(name).type(value); + } +); + Then('I click the submit button', () => { /** * Submit button text differs on React/Vue vs Angular. Testing for both for diff --git a/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature b/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature index 9e15eb17adc..616dd8c9a20 100644 --- a/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature +++ b/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature @@ -10,7 +10,7 @@ Feature: Drag and drop files within Storage Browser Then I click the "Sign in" button When I click the first button containing "public" When I drag and drop a file into the storage browser with file name "test.txt" - Then I see "Upload Files" + Then I see "Upload" Then I see "test.txt" @react @@ -20,23 +20,22 @@ Feature: Drag and drop files within Storage Browser Then I click the "Sign in" button When I click the first button containing "public" When I drag and drop a folder into the storage browser with name "test" - Then I see "Upload Folder" + Then I see "Upload" Then I see "test" - """ - Comment out for now upload is integrated + @react Scenario: Drag and drop file into Upload Action view When I type my "email" with status "CONFIRMED" Then I type my password Then I click the "Sign in" button When I click the first button containing "public" - Then I see the "Actions" button - When I click the "Actions" button - Then I see the "Upload Files" menuitem - Then I click the "Upload Files" menuitem + Then I see the "Menu Toggle" button + When I click the "Menu Toggle" button + Then I see the "Upload" menuitem + Then I click the "Upload" menuitem # Close the file select menu Then I press the "{esc}" key When I drag and drop a file into the storage browser with file name "test.txt" Then I see "test.txt" - """ \ No newline at end of file + \ No newline at end of file diff --git a/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature b/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature new file mode 100644 index 00000000000..8fd101cbebe --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature @@ -0,0 +1,18 @@ +Feature: StorageBrowser Filter Locations + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Filter locations + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + Then I see the first button containing "private" + When I see input with placeholder "Filter folders and files" and type "pu" + Then I click the "Submit" button + Then I see the first button containing "public" + Then I do not see the button containing "private" + When I click the button containing "Clear search" + Then I see the first button containing "private" + diff --git a/packages/react-core/src/utils/createContextUtilities.tsx b/packages/react-core/src/utils/createContextUtilities.tsx index 4b424203b01..5df48495451 100644 --- a/packages/react-core/src/utils/createContextUtilities.tsx +++ b/packages/react-core/src/utils/createContextUtilities.tsx @@ -99,7 +99,11 @@ export default function createContextUtilities< throw new Error(INVALID_OPTIONS_MESSAGE); } + const contextDisplayName = `${contextName}Context`; + const providerDisplayName = `${contextName}Provider`; + const Context = React.createContext(defaultValue); + Context.displayName = contextDisplayName; function Provider(props: React.PropsWithChildren) { const { children, ...context } = props; @@ -113,8 +117,7 @@ export default function createContextUtilities< return {children}; } - Provider.displayName = `${contextName}Provider`; - + Provider.displayName = providerDisplayName; return { [`use${contextName}`]: function (params?: HookParams) { const context = React.useContext(Context); @@ -125,7 +128,7 @@ export default function createContextUtilities< return context; }, - [`${contextName}Provider`]: Provider, - [`${contextName}Context`]: Context, + [providerDisplayName]: Provider, + [contextDisplayName]: Context, } as CreateContextUtilitiesReturn; } diff --git a/packages/react-storage/package.json b/packages/react-storage/package.json index d9a251c0096..1dbfd2ce78c 100644 --- a/packages/react-storage/package.json +++ b/packages/react-storage/package.json @@ -70,7 +70,7 @@ "name": "createStorageBrowser", "path": "dist/esm/browser.mjs", "import": "{ createStorageBrowser }", - "limit": "30 kB", + "limit": "37 kB", "ignore": [ "@aws-amplify/storage" ] @@ -79,7 +79,7 @@ "name": "FileUploader", "path": "dist/esm/index.mjs", "import": "{ FileUploader }", - "limit": "21.6 kB" + "limit": "25.00 kB" }, { "name": "StorageImage", diff --git a/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx b/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx index afa19875b3d..3548bff11dd 100644 --- a/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx @@ -5,9 +5,7 @@ import { ElementsProvider } from '@aws-amplify/ui-react-core/elements'; import { ComposablesProvider, Composables } from './composables'; import { StorageBrowserElements } from './context/elements'; -export interface Components - // omitted values have not yet been integrated with views - extends Omit, 'Message'> {} +export interface Components extends Partial {} export interface ComponentsProviderProps { children?: React.ReactNode; diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx index 6a85c7986b7..74902d5dd8d 100644 --- a/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx +++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { elementsDefault } from './context/elements'; -import { - createStorageBrowser, - StorageBrowserProps as StorageBrowserPropsBase, -} from './createStorageBrowser'; +import { createStorageBrowser } from './createStorageBrowser'; +import { StorageBrowserProps as StorageBrowserPropsBase } from './types'; import { createAmplifyAuthAdapter } from './adapters'; import { TextField } from '@aws-amplify/ui-react'; diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createListLocationsHandler.test.ts b/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createListLocationsHandler.test.ts deleted file mode 100644 index ceaa8d864ae..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createListLocationsHandler.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createListLocationsHandler } from '../../../adapters/createManagedAuthAdapter/createListLocationsHandler'; -import { listCallerAccessGrants } from '../../../storage-internal'; - -jest.mock('../../../storage-internal'); - -jest.mocked(listCallerAccessGrants).mockResolvedValue({ - locations: [], -}); - -describe('createListLocationsHandler', () => { - it('should parse the underlying API with right parameters', async () => { - const mockAccountId = '1234567890'; - const mockRegion = 'us-foo-1'; - const mockCredentialsProvider = jest.fn(); - const mockCustomEndpoint = 'mock-endpoint'; - const mockNextToken = '123'; - const mockPageSize = 123; - const handler = createListLocationsHandler({ - accountId: mockAccountId, - customEndpoint: mockCustomEndpoint, - region: mockRegion, - credentialsProvider: mockCredentialsProvider, - }); - await handler({ nextToken: mockNextToken, pageSize: mockPageSize }); - expect(listCallerAccessGrants).toHaveBeenCalledWith({ - accountId: mockAccountId, - region: mockRegion, - credentialsProvider: mockCredentialsProvider, - customEndpoint: mockCustomEndpoint, - nextToken: mockNextToken, - pageSize: mockPageSize, - }); - }); -}); 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 62212ba7532..bfb6c7fbea3 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import * as ActionsModule from '../do-not-import-from-here/actions'; import * as ProvidersModule from '../providers'; import { createStorageBrowser } from '../createStorageBrowser'; @@ -12,26 +11,6 @@ const createConfigurationProviderSpy = jest.spyOn( 'createConfigurationProvider' ); -jest.spyOn(ActionsModule, 'useLocationsData').mockReturnValue([ - { - isLoading: false, - data: { result: [], nextToken: undefined }, - hasError: false, - message: undefined, - }, - jest.fn(), -]); - -jest.spyOn(ActionsModule, 'useAction').mockReturnValue([ - { - data: { result: [], nextToken: undefined }, - hasError: false, - isLoading: false, - message: undefined, - }, - jest.fn(), -]); - const accountId = '012345678901'; const customEndpoint = 'mock-endpoint'; const getLocationCredentials = jest.fn(); @@ -78,6 +57,7 @@ describe('createStorageBrowser', () => { region: config.region, registerAuthListener: config.registerAuthListener, actions: { + copy: expect.any(Object), createFolder: expect.any(Object), delete: expect.any(Object), listLocationItems: 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 b7f26d0cf29..eca8ae88ffc 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 @@ -2,6 +2,17 @@ exports[`defaultActionConfigs matches expected shape 1`] = ` { + "copy": { + "actionsListItemConfig": { + "disable": [Function], + "hide": [Function], + "icon": "download", + "label": "Copy Files", + }, + "componentName": "CopyView", + "displayName": "Copy", + "handler": [Function], + }, "createFolder": { "actionsListItemConfig": { "disable": [Function], @@ -18,7 +29,7 @@ exports[`defaultActionConfigs matches expected shape 1`] = ` "actionsListItemConfig": { "disable": [Function], "hide": [Function], - "icon": "delete", + "icon": "delete-file", "label": "Delete Files", }, "componentName": "DeleteView", @@ -36,22 +47,13 @@ exports[`defaultActionConfigs matches expected shape 1`] = ` "handler": [Function], }, "upload": { - "actionsListItemConfig": [ - { - "disable": [Function], - "fileSelection": "FILE", - "hide": [Function], - "icon": "upload-file", - "label": "Upload File", - }, - { - "disable": [Function], - "fileSelection": "FOLDER", - "hide": [Function], - "icon": "upload-folder", - "label": "Upload FOLDER", - }, - ], + "actionsListItemConfig": { + "disable": [Function], + "fileSelection": "FILE", + "hide": [Function], + "icon": "upload-file", + "label": "Upload", + }, "componentName": "UploadView", "displayName": "Upload", "handler": [Function], 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 b37f137a26d..67d151e73cb 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 @@ -65,8 +65,7 @@ describe('defaultActionConfigs', () => { describe('uploadActionConfig', () => { it('hides the action list item as expected', () => { - const [uploadFileListItem, uploadFolderListItem] = - uploadActionConfig.actionsListItemConfig as ActionListItemConfig[]; + const uploadFileListItem = uploadActionConfig.actionsListItemConfig!; for (const permissionsWithoutWrite of generateCombinations( permissionValuesWithoutWrite @@ -77,20 +76,14 @@ describe('defaultActionConfigs', () => { ]; expect(uploadFileListItem.hide?.(permissionsWithoutWrite)).toBe(true); expect(uploadFileListItem.hide?.(permissionsWithWrite)).toBe(false); - expect(uploadFolderListItem.hide?.(permissionsWithoutWrite)).toBe(true); - expect(uploadFolderListItem.hide?.(permissionsWithWrite)).toBe(false); } }); it('disables the action list item as expected', () => { - const [uploadFileListItem, uploadFolderListItem] = - uploadActionConfig.actionsListItemConfig as ActionListItemConfig[]; + const uploadFileListItem = uploadActionConfig.actionsListItemConfig!; expect(uploadFileListItem.disable?.([file])).toBe(true); expect(uploadFileListItem.disable?.(undefined)).toBe(false); - - expect(uploadFolderListItem.disable?.([file])).toBe(true); - expect(uploadFolderListItem.disable?.(undefined)).toBe(false); }); }); }); 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 ca419aada7e..c4e72a3f7ac 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx @@ -34,7 +34,7 @@ export const deleteActionConfig: DeleteActionConfig = { actionsListItemConfig: { disable: (selected) => !selected, hide: (permissions) => !permissions.includes('delete'), - icon: 'delete', + icon: 'delete-file', label: 'Delete Files', }, displayName: 'Delete', @@ -76,33 +76,39 @@ export const listLocationsActionConfig: ListLocationsActionConfig = { export const uploadActionConfig: UploadActionConfig = { componentName: 'UploadView', - actionsListItemConfig: [ - { - disable: (selectedValues) => !!selectedValues, - fileSelection: 'FILE', - hide: (permissions) => !permissions.includes('write'), - icon: 'upload-file', - label: 'Upload File', - }, - { - disable: (selectedValues) => !!selectedValues, - fileSelection: 'FOLDER', - hide: (permissions) => !permissions.includes('write'), - icon: 'upload-folder', - label: 'Upload FOLDER', - }, - ], + actionsListItemConfig: { + disable: (selectedValues) => !!selectedValues, + fileSelection: 'FILE', + hide: (permissions) => !permissions.includes('write'), + icon: 'upload-file', + label: 'Upload', + }, isCancelable: true, includeProgress: true, handler: uploadHandler, displayName: 'Upload', }; -export const defaultActionConfigs = { - // copy: copyActionConfig, +export const defaultActionViewConfigs = { + copy: copyActionConfig, createFolder: createFolderActionConfig, delete: deleteActionConfig, + upload: uploadActionConfig, +}; + +export type DefaultActionViewType = keyof typeof defaultActionViewConfigs; + +export const DEFAULT_ACTION_VIEW_TYPES = Object.keys( + defaultActionViewConfigs +) as DefaultActionViewType[]; + +export const isDefaultActionViewType = ( + value?: string +): value is DefaultActionViewType => + DEFAULT_ACTION_VIEW_TYPES.some((type) => type === value); + +export const defaultActionConfigs = { + ...defaultActionViewConfigs, listLocationItems: listLocationItemsActionConfig, listLocations: listLocationsActionConfig, - upload: uploadActionConfig, }; 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 90f657bb2f9..b94b3f540be 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts @@ -3,5 +3,9 @@ export { ActionConfigsProviderProps, useActionConfig, } from './context'; -export { defaultActionConfigs } from './defaults'; +export { + defaultActionConfigs, + defaultActionViewConfigs, + isDefaultActionViewType, +} from './defaults'; export * from './types'; 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 90bbbffd016..acf5a1988bf 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts @@ -64,7 +64,7 @@ export interface ActionListItemConfig { /** * list item icon */ - icon: IconVariant | Exclude; + icon: IconVariant; /** * list item label @@ -82,7 +82,7 @@ export interface TaskActionConfig * configure action list item behavior. provide multiple configs * to create additional list items for a single action */ - actionsListItemConfig?: ActionListItemConfig | ActionListItemConfig[]; + actionsListItemConfig?: ActionListItemConfig; /** * whether the provided `handler` allow inflight cancellation diff --git a/packages/react-storage/src/components/StorageBrowser/actions/createViews.tsx b/packages/react-storage/src/components/StorageBrowser/actions/createViews.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts index 31f53367992..60fd72da488 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts @@ -22,18 +22,17 @@ const generateMockLocations = (size: number, mockLocations: LocationAccess) => const accountId = 'account-id'; const credentials: LocationCredentialsProvider = jest.fn(); const region = 'region'; -const bucket = 'bucket'; + const customEndpoint = 'mock-endpoint'; const DEFAULT_PAGE_SIZE = 5; const input: ListLocationsHandlerInput = { - config: { accountId, credentials, customEndpoint, region, bucket }, + config: { accountId, credentials, customEndpoint, region }, options: { pageSize: DEFAULT_PAGE_SIZE, nextToken: undefined, exclude: { exactPermissions: ['get', 'list'] }, }, - prefix: 'prefix', }; describe('listLocationsHandler', () => { @@ -51,7 +50,7 @@ describe('listLocationsHandler', () => { it('should fetch a single page of results successfully', async () => { const mockOutput: ListLocationsOutput = { locations: [ - { scope: 's3://bucket/prefix', permission: 'READ', type: 'PREFIX' }, + { scope: 's3://bucket/prefix/*', permission: 'READ', type: 'PREFIX' }, ], nextToken: undefined, }; @@ -77,8 +76,8 @@ describe('listLocationsHandler', () => { it('should fetch multiple pages of results successfully', async () => { const mockLocation: LocationAccess = { - scope: 's3://bucket/prefix1', - permission: 'READ', + scope: 's3://bucket/prefix1/*', + permission: 'READWRITE', type: 'PREFIX', }; const mockOutputPage1: ListLocationsOutput = { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts index 3f1467851aa..b85ba3c3460 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts @@ -1,29 +1,54 @@ import { - ListLocationsOutput, listCallerAccessGrants, + LocationCredentialsProvider, } from '../../storage-internal'; import { assertAccountId } from '../../validators'; import { ListHandlerOptions, - ListHandlerInput, - ListHandlerOutput, ListHandler, + ListLocationsExcludeOptions, + LocationData, } from './types'; - -import { ListLocationsExcludeOptions, LocationData } from './types'; import { getFilteredLocations } from './utils'; const DEFAULT_PAGE_SIZE = 1000; +export interface ListLocationsOptions extends ListLocationsHandlerOptions {} + +export interface ListLocationsInput { + options?: ListLocationsOptions; +} + +export interface ListLocationsOutput { + items: LocationData[]; + nextToken: string | undefined; +} + +// `ListLocations` and its associated input/output types are the types +// used `Config` option of `CreateStorageBrowser` that do not require +// `config` values as they are provided through higher-order functions +// defined in the default and managed auth adapters +export interface ListLocations + extends ListHandler {} + export interface ListLocationsHandlerOptions extends ListHandlerOptions {} -export interface ListLocationsHandlerInput - extends ListHandlerInput {} +export interface ListLocationsHandlerInput { + options?: ListLocationsHandlerOptions; + config: { + accountId?: string; + credentials: LocationCredentialsProvider; + customEndpoint?: string; + region: string; + }; +} -export interface ListLocationsHandlerOutput - extends ListHandlerOutput {} +export interface ListLocationsHandlerOutput { + items: LocationData[]; + nextToken: string | undefined; +} export interface ListLocationsHandler extends ListHandler {} @@ -36,10 +61,7 @@ export const listLocationsHandler: ListLocationsHandler = async (input) => { const fetchLocations = async ( accumulatedItems: LocationData[], locationsNextToken: ListLocationsOutput['nextToken'] - ): Promise<{ - items: LocationData[]; - nextToken: ListLocationsOutput['nextToken']; - }> => { + ): Promise => { const remainingPageSize = pageSize - accumulatedItems.length; assertAccountId(accountId); @@ -61,7 +83,7 @@ export const listLocationsHandler: ListLocationsHandler = async (input) => { return fetchLocations(items, output.nextToken); } - return { items: items, nextToken: output.nextToken }; + return { items, nextToken: output.nextToken }; }; return fetchLocations([], 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 0835b51bbf8..887945ec64b 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts @@ -36,11 +36,6 @@ export interface LocationData { type: LocationType; } -export interface ListLocationsExcludeOptions { - exactPermissions?: LocationPermissions; - type?: LocationType | LocationType[]; -} - export interface FolderData { key: string; id: string; @@ -124,3 +119,8 @@ export interface ListHandlerOutput { } export type ListHandler = (input: T) => Promise; + +export interface ListLocationsExcludeOptions { + exactPermissions?: LocationPermissions; + type?: LocationType | LocationType[]; +} 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 a3d7568386b..99610f61541 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts @@ -1,12 +1,12 @@ import { TransferProgressEvent } from 'aws-amplify/storage'; import { LocationAccess as AccessGrantLocation } from '../../storage-internal'; +import { ListLocationsExcludeOptions } from './types'; import { ActionInputConfig, FileData, FileDataItem, FileItem, - ListLocationsExcludeOptions, LocationData, LocationPermissions, LocationType, @@ -20,32 +20,6 @@ export const constructBucket = ({ region: string; } => ({ bucketName, region }); -// FIXME: this may not need to be exported if do-not-import-from-here actions are migrated. -export const parseAccessGrantLocationScope = ( - scope: string, - type: LocationType -): { bucket: string; prefix: string } => { - const slicedScope = scope.slice(5); - if (type === 'BUCKET') { - // { scope: 's3://bucket/*', type: 'BUCKET', }, - const bucket = slicedScope.slice(0, -2); - const prefix = ''; - return { bucket, prefix }; - } else if (type === 'PREFIX') { - // { scope: 's3://bucket/path/*', type: 'PREFIX', }, - const bucket = slicedScope.slice(0, slicedScope.indexOf('/')); - const prefix = `${slicedScope.slice(bucket.length + 1, -1)}`; - return { bucket, prefix }; - } else if (type === 'OBJECT') { - // { scope: 's3://bucket/path/to/object', type: 'OBJECT', }, - const bucket = slicedScope.slice(0, slicedScope.indexOf('/')); - const prefix = slicedScope.slice(bucket.length + 1); - return { bucket, prefix }; - } else { - throw new Error(`Invalid location type: ${type}`); - } -}; - export const parseAccessGrantLocation = ( location: AccessGrantLocation ): LocationData => { @@ -128,11 +102,16 @@ const isSameType = ( export const shouldExcludeLocation = ( { permissions, type }: LocationData, exclude?: ListLocationsExcludeOptions -): boolean => - Boolean( +): boolean => { + const excludedByPermssions = !!( exclude?.exactPermissions && - isSamePermissions(exclude.exactPermissions, permissions) - ) || Boolean(exclude?.type && isSameType(exclude.type, type)); + isSamePermissions(exclude.exactPermissions, permissions) + ); + + const excludedByType = !!(exclude?.type && isSameType(exclude.type, type)); + + return excludedByPermssions || excludedByType; +}; export const getFilteredLocations = ( locations: AccessGrantLocation[], @@ -141,9 +120,19 @@ export const getFilteredLocations = ( locations.reduce( (filteredLocations: LocationData[], location: AccessGrantLocation) => { const parsedLocation = parseAccessGrantLocation(location); - if (shouldExcludeLocation(parsedLocation, exclude)) { + + const isNonFolderLikePrefix = + !parsedLocation.prefix.endsWith('/') && + parsedLocation.type === 'PREFIX'; + + if (isNonFolderLikePrefix) { + return filteredLocations; + } + + if (!shouldExcludeLocation(parsedLocation, exclude)) { filteredLocations.push(parsedLocation); } + return filteredLocations; }, [] @@ -164,6 +153,8 @@ export const createFileDataItemFromLocation = ( type: 'FILE', key: data.prefix, fileKey: getFileKey(data.prefix), + // `lastModified` and `size` included to satisfy + // expected shape of `FileDataItem` lastModified: new Date(), size: 0, }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/index.ts index 1218ae873ab..a6fb6f1f009 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/index.ts @@ -5,7 +5,9 @@ export { ComponentName, defaultActionConfigs, DefaultActionConfigs, + defaultActionViewConfigs, DefaultActionKey, + isDefaultActionViewType, SelectionType, TaskActionConfig, useActionConfig, @@ -18,6 +20,7 @@ export { CopyHandlerData, CopyHandlerInput, CopyHandlerOutput, + createFileDataItemFromLocation, createFileDataItem, createFolderHandler, CreateFolderHandler, @@ -51,6 +54,10 @@ export { ListLocationItemsHandlerInput, ListLocationItemsHandlerOptions, ListLocationItemsHandlerOutput, + ListLocationsExcludeOptions, + ListLocations, + ListLocationsInput, + ListLocationsOutput, listLocationsHandler, ListLocationsHandler, ListLocationsHandlerInput, @@ -72,3 +79,7 @@ export { UploadHandlerOptions, UploadHandlerOutput, } from './handlers'; + +export { ActionState } from './types'; + +export { useListLocations, UseListLocationsState } from './useAction'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts similarity index 99% rename from packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts rename to packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts index 6ac4660be1d..e50307394a5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/__tests__/createEnhancedListHandler.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts @@ -7,7 +7,7 @@ import { ListHandler, ListHandlerInput, ListHandlerOutput, -} from '../handlers'; +} from '../../handlers'; const mockAction = jest.fn(); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/__tests__/search.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/actions/__tests__/search.spec.ts rename to packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts similarity index 93% rename from packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts rename to packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts index 2d0eec52ddf..db4303ca23b 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/createEnhancedListHandler.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts @@ -5,7 +5,7 @@ import { ListHandlerOptions, ListHandlerInput, ListHandlerOutput, -} from './handlers'; +} from '../handlers'; type KeyWithStringValue = keyof { [P in keyof T as T[P] extends string ? P : never]: T[P]; @@ -36,14 +36,17 @@ export interface SearchOutput { hasExhaustedSearch: boolean; } -interface EnhancedListHandlerOutput extends ListHandlerOutput { +export interface EnhancedListHandlerOutput extends ListHandlerOutput { search?: SearchOutput; } -interface EnhancedListHandler +export interface EnhancedListHandlerInput + extends ListHandlerInput> {} + +export interface EnhancedListHandler extends AsyncDataAction< EnhancedListHandlerOutput, - ListHandlerInput> + EnhancedListHandlerInput > {} type ListItem = Action extends ListHandler< diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts new file mode 100644 index 00000000000..8b7ee5535f2 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts @@ -0,0 +1 @@ +export { useListLocations, UseListLocationsState } from './useListLocations'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts new file mode 100644 index 00000000000..9de7697950e --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts @@ -0,0 +1,43 @@ +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'; + +import { + createEnhancedListHandler, + EnhancedListHandlerInput, + EnhancedListHandlerOutput, +} from './createEnhancedListHandler'; + +// Utility type functioning as a shim to allow for the outputted +// enhanced `ListLocations` handler to not require `config` and `prefix` +// in usage, which are required by the signature of `createEnhancedListHandler` +type RemoveConfigAndPrefix = Omit; + +export interface UseListLocationsState + extends ActionState< + EnhancedListHandlerOutput, + RemoveConfigAndPrefix< + EnhancedListHandlerInput + > + > {} + +export const useListLocations = (): UseListLocationsState => { + const { handler } = useActionConfig('listLocations'); + const enhancedHandler = React.useMemo( + () => createEnhancedListHandler(handler as ListLocations), + [handler] + ); + + return useDataState(enhancedHandler, { + items: [], + nextToken: undefined, + }) as UseListLocationsState; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/permissionParsers.spec.ts b/packages/react-storage/src/components/StorageBrowser/adapters/__tests__/permissionParsers.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/__tests__/adapters/permissionParsers.spec.ts rename to packages/react-storage/src/components/StorageBrowser/adapters/__tests__/permissionParsers.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts index 35d89cddb2f..a1c51182774 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts @@ -1,7 +1,8 @@ -import { createAmplifyListLocationsHandler } from '../createAmplifyListLocationsHandler'; -import { getPaginatedLocations } from '../getPaginatedLocations'; +import { ListLocations, LocationData } from '../../../actions'; import { listPaths, ListPathsOutput } from '../../../storage-internal'; -import { LocationAccess, ListLocations } from '../../types'; + +import { getPaginatedLocations } from '../getPaginatedLocations'; +import { createAmplifyListLocationsHandler } from '../createAmplifyListLocationsHandler'; jest.mock('../../../storage-internal', () => ({ listPaths: jest.fn(), @@ -15,7 +16,14 @@ jest.mock( describe('createAmplifyListLocationsHandler', () => { const mockListPaths = jest.mocked(listPaths); - const mockGetPaginatedLocations = jest.mocked(getPaginatedLocations); + const mockGetPaginatedItems = jest.mocked(getPaginatedLocations); + const mockId = 'intentionally-static-test-id'; + + beforeAll(() => { + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: () => mockId }, + }); + }); beforeEach(() => { jest.clearAllMocks(); @@ -27,75 +35,79 @@ describe('createAmplifyListLocationsHandler', () => { { bucket: 'bucket1', permission: ['read'], - prefix: 'prefix1', + prefix: 'prefix1/*', type: 'PREFIX', }, ]; - const sanitizedLocation: LocationAccess[] = [ + const sanitizedLocations: LocationData[] = [ { - scope: 's3://bucket1/prefix1', + prefix: 'prefix1/', + bucket: 'bucket1', + id: mockId, permissions: ['get', 'list'], type: 'PREFIX', }, ]; - const input = { pageSize: 10, nextToken: undefined }; + const input = { options: { pageSize: 10, nextToken: undefined } }; const paginatedResult = { - locations: sanitizedLocation, + items: sanitizedLocations, nextToken: undefined, }; mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations }); - mockGetPaginatedLocations.mockReturnValueOnce(paginatedResult); + mockGetPaginatedItems.mockReturnValueOnce(paginatedResult); const result = await handler(input); expect(result).toEqual(paginatedResult); expect(mockListPaths).toHaveBeenCalledTimes(1); - expect(mockGetPaginatedLocations).toHaveBeenCalledWith({ - locations: sanitizedLocation, - pageSize: input.pageSize, - nextToken: input.nextToken, + expect(mockGetPaginatedItems).toHaveBeenCalledWith({ + items: sanitizedLocations, + pageSize: input.options.pageSize, + nextToken: input.options.nextToken, }); }); it('should fetch locations from the cache', async () => { const handler: ListLocations = createAmplifyListLocationsHandler(); - const input = { pageSize: 10, nextToken: undefined }; + const input = { options: { pageSize: 10, nextToken: undefined } }; const fetchedLocations: ListPathsOutput['locations'] = [ { bucket: 'bucket1', permission: ['read'], - prefix: 'prefix1', + prefix: 'prefix1/*', type: 'PREFIX', }, ]; mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations }); await handler(input); - const cachedLocations: LocationAccess[] = [ + const cachedItems: LocationData[] = [ { - scope: 's3://bucket1/prefix1', + prefix: 'prefix1/', + bucket: 'bucket1', + id: mockId, permissions: ['get', 'list'], type: 'PREFIX', }, ]; const paginatedResult = { - locations: cachedLocations, + items: cachedItems, nextToken: undefined, }; - mockGetPaginatedLocations.mockReturnValueOnce(paginatedResult); + mockGetPaginatedItems.mockReturnValueOnce(paginatedResult); const result = await handler(input); expect(result).toEqual(paginatedResult); - expect(mockGetPaginatedLocations).toHaveBeenCalledWith({ - locations: cachedLocations, - pageSize: input.pageSize, - nextToken: input.nextToken, + expect(mockGetPaginatedItems).toHaveBeenCalledWith({ + items: cachedItems, + pageSize: input.options.pageSize, + nextToken: input.options.nextToken, }); expect(mockListPaths).toHaveBeenCalledTimes(1); }); @@ -106,47 +118,51 @@ describe('createAmplifyListLocationsHandler', () => { { bucket: 'bucket1', permission: ['read'], - prefix: 'prefix1', + prefix: 'prefix1/*', type: 'PREFIX', }, { bucket: 'bucket2', permission: ['read'], - prefix: 'prefix2', + prefix: 'prefix2/*', type: 'PREFIX', }, ]; - const sanitizedLocation: LocationAccess[] = [ + const sanitizedLocations: LocationData[] = [ { - scope: 's3://bucket1/prefix1', + prefix: 'prefix1/', + bucket: 'bucket1', + id: mockId, permissions: ['get', 'list'], type: 'PREFIX', }, { - scope: 's3://bucket2/prefix2', + prefix: 'prefix2/', + bucket: 'bucket2', + id: mockId, permissions: ['get', 'list'], type: 'PREFIX', }, ]; - const input = { pageSize: 1, nextToken: undefined }; + const input = { options: { pageSize: 1, nextToken: undefined } }; const paginatedResult = { - locations: [sanitizedLocation[0]], + items: [{ ...sanitizedLocations }[0]], nextToken: 'token1', }; mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations }); - mockGetPaginatedLocations.mockReturnValueOnce(paginatedResult); + mockGetPaginatedItems.mockReturnValueOnce(paginatedResult); const result = await handler(input); - expect(result.locations).toEqual(paginatedResult.locations); + expect(result.items).toEqual(paginatedResult.items); expect(mockListPaths).toHaveBeenCalledTimes(1); - expect(mockGetPaginatedLocations).toHaveBeenCalledWith({ - locations: sanitizedLocation, - pageSize: input.pageSize, - nextToken: input.nextToken, + expect(mockGetPaginatedItems).toHaveBeenCalledWith({ + items: sanitizedLocations, + pageSize: input.options.pageSize, + nextToken: input.options.nextToken, }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts index f53c07eceba..057d6d1b84c 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts @@ -1,86 +1,92 @@ import { getPaginatedLocations } from '../getPaginatedLocations'; -import { ListLocationsOutput } from '../../types'; +import { ListLocationsHandlerOutput } from '../../../actions'; describe('getPaginatedLocations', () => { - const mockLocations: ListLocationsOutput['locations'] = [ + const mockItems: ListLocationsHandlerOutput['items'] = [ { type: 'PREFIX', permissions: ['list'], - scope: 's3://bucket1/path1/', + prefix: 'path1/', + bucket: 'bucket1', + id: '1', }, { type: 'PREFIX', permissions: ['get'], - scope: 's3://bucket2/path2/', + prefix: 'path2/', + bucket: 'bucket2', + id: '2', }, { type: 'PREFIX', permissions: ['write'], - scope: 's3://bucket3/path3/', + prefix: 'path3/', + bucket: 'bucket3', + id: '3', }, ]; it('should return all locations when no pagination is specified', () => { - const result = getPaginatedLocations({ locations: mockLocations }); - expect(result).toEqual({ locations: mockLocations }); + const result = getPaginatedLocations({ items: mockItems }); + expect(result).toEqual({ items: mockItems }); }); it('should return paginated locations when pageSize is specified', () => { const result = getPaginatedLocations({ - locations: mockLocations, + items: mockItems, pageSize: 2, }); expect(result).toEqual({ - locations: mockLocations.slice(0, 2), + items: mockItems.slice(0, 2), nextToken: '1', }); }); it('should return paginated locations when pageSize and nextToken are specified', () => { const result = getPaginatedLocations({ - locations: mockLocations, + items: mockItems, pageSize: 1, nextToken: '2', }); expect(result).toEqual({ - locations: mockLocations.slice(1, 2), + items: mockItems.slice(1, 2), nextToken: '1', }); }); it('should return empty locations when locations array is empty', () => { - const result = getPaginatedLocations({ locations: [], pageSize: 2 }); - expect(result).toEqual({ locations: [] }); + const result = getPaginatedLocations({ items: [], pageSize: 2 }); + expect(result).toEqual({ items: [] }); }); it('should return empty location when nextToken is beyond array length', () => { const result = getPaginatedLocations({ - locations: mockLocations, + items: mockItems, pageSize: 2, nextToken: '5', }); - expect(result).toEqual({ locations: [], nextToken: undefined }); + expect(result).toEqual({ items: [], nextToken: undefined }); }); it('should return all remaining location when page size is greater than remaining locations length', () => { const result = getPaginatedLocations({ - locations: mockLocations, + items: mockItems, pageSize: 5, nextToken: '2', }); expect(result).toEqual({ - locations: mockLocations.slice(-2), + items: mockItems.slice(-2), nextToken: undefined, }); }); it('should return undefined nextToken when end of array is reached', () => { const result = getPaginatedLocations({ - locations: mockLocations, + items: mockItems, pageSize: 5, }); expect(result).toEqual({ - locations: mockLocations.slice(0, 3), + items: mockItems.slice(0, 3), nextToken: undefined, }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts index 053ecec7d68..58c87c26af5 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts @@ -1,17 +1,20 @@ +import { LocationData } from '../../actions'; import { listPaths, ListPathsOutput } from '../../storage-internal'; +import { ListLocations, ListLocationsInput } from '../../actions'; + import { parseAmplifyAuthPermission } from '../permissionParsers'; -import { ListLocations, ListLocationsOutput } from '../types'; import { getPaginatedLocations } from './getPaginatedLocations'; export const createAmplifyListLocationsHandler = (): ListLocations => { - let cachedLocations: ListLocationsOutput['locations'] = []; + let cachedItems: LocationData[] = []; - return async function listLocations(input = {}) { - const { pageSize, nextToken } = input; + return async function listLocations(input: ListLocationsInput) { + const { options } = input ?? {}; + const { nextToken, pageSize } = options ?? {}; - if (cachedLocations.length > 0) { + if (cachedItems.length > 0) { return getPaginatedLocations({ - locations: cachedLocations, + items: cachedItems, pageSize, nextToken, }); @@ -20,20 +23,22 @@ export const createAmplifyListLocationsHandler = (): ListLocations => { const { locations }: { locations: ListPathsOutput['locations'] } = await listPaths(); - const sanitizedLocations = locations.map( + const sanitizedItems: LocationData[] = locations.map( ({ bucket, permission, prefix, type }) => { return { type, permissions: parseAmplifyAuthPermission(permission), - scope: `s3://${bucket}/${prefix}`, + bucket, + prefix: prefix.endsWith('*') ? prefix.slice(0, -1) : prefix, + id: crypto.randomUUID(), }; } ); - cachedLocations = sanitizedLocations; + cachedItems = sanitizedItems; return getPaginatedLocations({ - locations: cachedLocations, + items: cachedItems, pageSize, nextToken, }); diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts index e9eb5b4c4f9..c9d7f743fc1 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts @@ -1,38 +1,34 @@ -import { ListLocationsOutput } from '../types'; +import { ListLocationsHandlerOutput, LocationData } from '../../actions'; export const getPaginatedLocations = ({ - locations, + items, pageSize, nextToken, }: { - locations: ListLocationsOutput['locations']; + items: LocationData[]; pageSize?: number; nextToken?: string; -}): { locations: ListLocationsOutput['locations']; nextToken?: string } => { +}): ListLocationsHandlerOutput => { if (pageSize) { if (nextToken) { - if (Number(nextToken) > locations.length) { - return { locations: [], nextToken: undefined }; + if (Number(nextToken) > items.length) { + return { items: [], nextToken: undefined }; } const start = -nextToken; const end = start + pageSize < 0 ? start + pageSize : undefined; return { - locations: locations.slice(start, end), + items: items.slice(start, end), nextToken: end ? `${-end}` : undefined, }; } return { - locations: locations.slice(0, pageSize), + items: items.slice(0, pageSize), nextToken: - locations.length > pageSize - ? `${locations.length - pageSize}` - : undefined, + items.length > pageSize ? `${items.length - pageSize}` : undefined, }; } - return { - locations, - }; + return { items, nextToken: undefined }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createManagedAuthConfigAdapter.test.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts similarity index 76% rename from packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createManagedAuthConfigAdapter.test.ts rename to packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts index 728f959b00d..f01e4f9cd26 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/adapters/managedAuthConfigAdapter/createManagedAuthConfigAdapter.test.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts @@ -1,15 +1,10 @@ import { createManagedAuthAdapter } from '../../../adapters/createManagedAuthAdapter/createManagedAuthAdapter'; -import { createListLocationsHandler } from '../../../adapters/createManagedAuthAdapter/createListLocationsHandler'; import { createLocationCredentialsHandler } from '../../../adapters/createManagedAuthAdapter/createLocationCredentialsHandler'; -jest.mock( - '../../../adapters/createManagedAuthAdapter/createListLocationsHandler' -); jest.mock( '../../../adapters/createManagedAuthAdapter/createLocationCredentialsHandler' ); -const mockCreateListLocationsHandler = jest.mocked(createListLocationsHandler); const mockCreateLocationCredentialsHandler = jest.mocked( createLocationCredentialsHandler ); @@ -19,14 +14,10 @@ describe('createManagedAuthConfigAdapter', () => { const accountId = 'XXXXXXXXXXXX'; const credentialsProvider = jest.fn(); const customEndpoint = 'mock-endpoint'; - const mockCreatedListLocationsHandler = jest.fn(); const mockCreatedLocationCredentialsHandler = jest.fn(); const mockRegisterAuthListener = jest.fn(); beforeEach(() => { - mockCreateListLocationsHandler.mockReturnValue( - mockCreatedListLocationsHandler - ); mockCreateLocationCredentialsHandler.mockReturnValue( mockCreatedLocationCredentialsHandler ); @@ -60,13 +51,7 @@ describe('createManagedAuthConfigAdapter', () => { registerAuthListener: mockRegisterAuthListener, }) ).toMatchObject({ - listLocations: mockCreatedListLocationsHandler, - }); - expect(mockCreateListLocationsHandler).toHaveBeenCalledWith({ - region, - accountId, - credentialsProvider, - customEndpoint, + listLocations: expect.any(Function), }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createListLocationsHandler.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createListLocationsHandler.ts deleted file mode 100644 index 0ade0c82107..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createListLocationsHandler.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ListLocations } from '../types'; -import { - listCallerAccessGrants, - CredentialsProvider, -} from '../../storage-internal'; -import { parseAccessGrantPermission } from '../permissionParsers'; - -interface CreateListLocationsHandlerInput { - accountId: string; - credentialsProvider: CredentialsProvider; - region: string; - customEndpoint?: string; -} - -export const createListLocationsHandler = ( - handlerInput: CreateListLocationsHandlerInput -): ListLocations => { - return async function listLocations(input = {}) { - const { locations, nextToken } = await listCallerAccessGrants({ - ...input, - ...handlerInput, - }); - - return { - nextToken, - locations: locations.map((location) => ({ - ...location, - permissions: parseAccessGrantPermission(location.permission), - })), - }; - }; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts index cfb77772394..b242f42a4e8 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts @@ -1,9 +1,9 @@ -import { createListLocationsHandler } from './createListLocationsHandler'; import { createLocationCredentialsHandler } from './createLocationCredentialsHandler'; import { StorageBrowserAuthAdapter, CreateManagedAuthAdapterInput, } from '../types'; +import { listLocationsHandler, ListLocationsInput } from '../../actions'; /** * Create configuration including handlers to call S3 Access Grant APIs to list and get @@ -19,12 +19,15 @@ export const createManagedAuthAdapter = ({ region, registerAuthListener, }: CreateManagedAuthAdapterInput): StorageBrowserAuthAdapter => { - const listLocations = createListLocationsHandler({ - credentialsProvider, + const config = { accountId, + credentials: credentialsProvider, customEndpoint, region, - }); + }; + + const listLocations = ({ options }: ListLocationsInput = {}) => + listLocationsHandler({ config, options }); const getLocationCredentials = createLocationCredentialsHandler({ credentialsProvider, diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/types.ts b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts index e6d4af2b089..521c2fd5b49 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts @@ -4,12 +4,7 @@ import { CredentialsLocation, } from '../credentials/types'; import { CredentialsProvider } from '../storage-internal'; -import { LocationType } from '../actions'; - -export interface ListLocationsInput { - pageSize?: number; - nextToken?: string; -} +import { LocationType, ListLocations } from '../actions'; export interface LocationAccess extends CredentialsLocation { /** @@ -21,15 +16,6 @@ export interface LocationAccess extends CredentialsLocation { readonly type: LocationType; } -export interface ListLocationsOutput { - locations: LocationAccess[]; - nextToken?: string; -} - -export interface ListLocations { - (input: ListLocationsInput): Promise; -} - export interface CreateManagedAuthAdapterInput { accountId: string; region: string; diff --git a/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx b/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx index 6249ff6d9f0..328e9185435 100644 --- a/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx +++ b/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx @@ -10,6 +10,7 @@ import { import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants'; import { isFunction } from '@aws-amplify/ui'; +import { Separator } from './Separator'; export interface BreadcrumbProps { isCurrent?: boolean; @@ -19,19 +20,9 @@ export interface BreadcrumbProps { interface BreadcrumbNavigationProps { breadcrumbs: BreadcrumbProps[]; + role?: React.AriaRole; } -const Separator = () => { - return ( - - / - - ); -}; - export const Breadcrumb = ({ isCurrent, name, @@ -55,7 +46,7 @@ export const Breadcrumb = ({ ) : ( {name} @@ -66,13 +57,15 @@ export const Breadcrumb = ({ ); }; -export const BreadcrumbNavigation = ({ +export function BreadcrumbNavigation({ breadcrumbs, -}: BreadcrumbNavigationProps): React.JSX.Element => { + role = 'navigation', +}: BreadcrumbNavigationProps): React.JSX.Element { return ( ); -}; +} diff --git a/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx b/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx index 59d34226aa0..7cf95ce1d46 100644 --- a/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx +++ b/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx @@ -10,8 +10,8 @@ import { import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants'; export interface DescriptionItemProps { - term?: string; - details?: string | JSX.Element; + term?: string | string[]; + details?: string | string[]; } interface DescriptionProps { diff --git a/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx b/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx index 81fe1a14b10..8b4d8b5ece0 100644 --- a/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx +++ b/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx @@ -96,6 +96,7 @@ export function DropdownMenu({ icon={icon} label={label} onClick={() => { + setIsOpen(false); onItemSelect?.(id); }} /> diff --git a/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx b/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx new file mode 100644 index 00000000000..058ebbc899f --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { SpanElement } from '../context/elements'; + +import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants'; + +export function Separator(): React.JSX.Element { + return ( + + / + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx new file mode 100644 index 00000000000..d3a52d75fc9 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants'; +import { + DescriptionListElement, + DescriptionTermElement, + DescriptionDetailsElement, + SpanElement, + ViewElement, +} from '../context/elements'; +import { Separator } from '../components/Separator'; +import { NavigationProps } from './Navigation'; +import { BreadcrumbNavigation } from '../components/BreadcrumbNavigation'; + +export interface ActionDestinationProps { + isNavigable?: boolean; + items: NavigationProps['items']; + label?: string; +} + +export const ActionDestination = ({ + isNavigable, + items, + label, +}: ActionDestinationProps): React.JSX.Element | null => { + if (!items.length) { + return null; + } + + return ( + + {isNavigable ? ( + <> + {`${label}:`} + + + ) : ( + + + {`${label}:`} + + {items.map(({ name }, index) => { + return ( + + + {name} + + {index === items.length - 1 ? null : } + + ); + })} + + )} + + ); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx index a1e9a0255f5..5cdf32ff1b5 100644 --- a/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx +++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx @@ -15,7 +15,7 @@ export const ActionExit = ({ label, }: ActionExitProps): React.JSX.Element => ( ({ + BreadcrumbNavigation: () =>
, +})); + +describe('ActionDestination', () => { + const item = 'Destination item'; + const items = [ + { name: `${item} 1`, onNavigate: jest.fn() }, + { name: `${item} 2`, onNavigate: jest.fn(), isCurrent: true }, + ]; + const label = 'Destination label'; + + it('renders', () => { + render(); + + const list = screen.getByRole('list'); + const term = screen.getByRole('term'); + const definitions = screen.getAllByRole('definition'); + + expect(list).toBeInTheDocument(); + expect(term).toHaveTextContent(label); + expect(definitions[0]).toHaveTextContent(`${item} 1`); + expect(definitions[1]).toHaveTextContent(`${item} 2`); + }); + + it('renders a breadcrumbs navigation if destination should be navigable', () => { + render(); + + const navigation = screen.getByTestId('breadcrumb-navigation'); + + expect(navigation).toBeInTheDocument(); + expect(navigation.previousSibling).toHaveTextContent(label); + }); + + it('returns null if there are no navigation items', () => { + render(); + + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts b/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts index 867d3260ee9..1681570811a 100644 --- a/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts +++ b/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts @@ -1,4 +1,5 @@ import { ActionCancel } from './ActionCancel'; +import { ActionDestination } from './ActionDestination'; import { ActionExit } from './ActionExit'; import { ActionStart } from './ActionStart'; import { ActionsList } from './ActionsList'; @@ -22,6 +23,7 @@ import { Composables } from './types'; export const DEFAULT_COMPOSABLES: Composables = { ActionCancel, + ActionDestination, ActionExit, ActionStart, ActionsList, diff --git a/packages/react-storage/src/components/StorageBrowser/composables/types.ts b/packages/react-storage/src/components/StorageBrowser/composables/types.ts index 664884b18ee..d3a31a4be19 100644 --- a/packages/react-storage/src/components/StorageBrowser/composables/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/composables/types.ts @@ -1,4 +1,5 @@ import { ActionCancelProps } from './ActionCancel'; +import { ActionDestinationProps } from './ActionDestination'; import { ActionExitProps } from './ActionExit'; import { ActionStartProps } from './ActionStart'; import { ActionsListProps } from './ActionsList'; @@ -20,6 +21,7 @@ import { TitleProps } from './Title'; export interface Composables { ActionCancel: React.ComponentType; + ActionDestination: React.ComponentType; ActionExit: React.ComponentType; ActionStart: React.ComponentType; ActionsList: React.ComponentType; diff --git a/packages/react-storage/src/components/StorageBrowser/context/elements/IconElement.tsx b/packages/react-storage/src/components/StorageBrowser/context/elements/IconElement.tsx index 0ca9056a35f..80eabddd54e 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/elements/IconElement.tsx +++ b/packages/react-storage/src/components/StorageBrowser/context/elements/IconElement.tsx @@ -1,9 +1,8 @@ -import { - defineBaseElementWithRef, - withBaseElementProps, -} from '@aws-amplify/ui-react-core/elements'; import React from 'react'; +import { defineBaseElement } from '@aws-amplify/ui-react-core/elements'; +import { useIcons } from '@aws-amplify/ui-react/internal'; + export type IconElementProps = React.ComponentProps; export type IconVariant = @@ -136,11 +135,7 @@ const DEFAULT_ICON_ATTRIBUTES = { role: 'img', }; -export const BaseIconElement = defineBaseElementWithRef< - 'svg', - never, - IconVariant ->({ +export const BaseIconElement = defineBaseElement<'svg', never, IconVariant>({ type: 'svg', displayName: 'Icon', }); @@ -162,4 +157,14 @@ const getIconProps = ({ }; }; -export const IconElement = withBaseElementProps(BaseIconElement, getIconProps); +export const IconElement = (props: IconElementProps): React.JSX.Element => { + const { variant } = props; + const icons = useIcons('storageBrowser'); + + const icon = variant ? icons?.[variant] : undefined; + if (icon) { + return <>{icon}; + } + + return ; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/context/elements/defaults.tsx b/packages/react-storage/src/components/StorageBrowser/context/elements/defaults.tsx index 17fcaaf2377..9cfea0f4537 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/elements/defaults.tsx +++ b/packages/react-storage/src/components/StorageBrowser/context/elements/defaults.tsx @@ -130,7 +130,7 @@ function Heading(props: HeadingElementProps): React.JSX.Element { function Span(props: SpanElementProps): React.JSX.Element { const { variant } = props; - if (variant === 'navigate-text') { + if (variant === 'navigation-text' || variant === 'destination-text') { return ( <_View {...props} diff --git a/packages/react-storage/src/components/StorageBrowser/context/elements/definitions.ts b/packages/react-storage/src/components/StorageBrowser/context/elements/definitions.ts index 266bb3cf0f4..d7b2ab3879a 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/elements/definitions.ts +++ b/packages/react-storage/src/components/StorageBrowser/context/elements/definitions.ts @@ -79,7 +79,7 @@ export const LabelElement = defineBaseElement<'label', 'htmlFor'>({ export interface NavElementProps extends React.ComponentProps {} -export const NavElement = defineBaseElement({ +export const NavElement = defineBaseElement<'nav', 'role'>({ type: 'nav', displayName: 'Nav', }); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/ActionCancelControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/ActionCancelControl.tsx index bd01b4ff6e2..f028f5b9080 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/ActionCancelControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/ActionCancelControl.tsx @@ -1,15 +1,13 @@ import React from 'react'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { ActionCancel } from '../composables/ActionCancel'; + import { useActionCancel } from './hooks/useActionCancel'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; export const ActionCancelControl = (): React.JSX.Element => { const props = useActionCancel(); - const ResolvedActionCancel = useResolvedComposable( - ActionCancel, - 'ActionCancel' - ); + const Resolved = useResolvedComposable(ActionCancel, 'ActionCancel'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/ActionDestinationControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/ActionDestinationControl.tsx new file mode 100644 index 00000000000..b281b7f139f --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/ActionDestinationControl.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { ActionDestination } from '../composables/ActionDestination'; + +import { useActionDestination } from './hooks/useActionDestination'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; + +export const ActionDestinationControl = (): React.JSX.Element => { + const props = useActionDestination(); + + const Resolved = useResolvedComposable( + ActionDestination, + 'ActionDestination' + ); + + return ; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/ActionExitControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/ActionExitControl.tsx index af8d95b6b76..574131dd6e0 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/ActionExitControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/ActionExitControl.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { ActionExit } from '../composables/ActionExit'; + import { useActionExit } from './hooks/useActionExit'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; export const ActionExitControl = (): React.JSX.Element => { const props = useActionExit(); - const ResolvedActionExit = useResolvedComposable(ActionExit, 'ActionExit'); + const Resolved = useResolvedComposable(ActionExit, 'ActionExit'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/ActionStartControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/ActionStartControl.tsx index b0d4ad1a574..ccef982bb1b 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/ActionStartControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/ActionStartControl.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { ActionStart } from '../composables/ActionStart'; + import { useActionStart } from './hooks/useActionStart'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; export const ActionStartControl = (): React.JSX.Element => { const props = useActionStart(); - const ResolvedActionStart = useResolvedComposable(ActionStart, 'ActionStart'); + const Resolved = useResolvedComposable(ActionStart, 'ActionStart'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/ActionsListControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/ActionsListControl.tsx index aff538ef0f4..0b4abe94ab5 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/ActionsListControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/ActionsListControl.tsx @@ -1,20 +1,13 @@ import React from 'react'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { ActionsList } from '../composables/ActionsList'; + import { useActionsList } from './hooks/useActionsList'; -import { ViewElement } from '../context/elements'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const ActionsListControl = ({ - className, -}: ControlProps): React.JSX.Element => { +export const ActionsListControl = (): React.JSX.Element => { const props = useActionsList(); - const ResolvedActionsList = useResolvedComposable(ActionsList, 'ActionsList'); + const Resolved = useResolvedComposable(ActionsList, 'ActionsList'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/AddFilesControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/AddFilesControl.tsx index cfbd5c74ce9..b9f6fdfe8ce 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/AddFilesControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/AddFilesControl.tsx @@ -1,20 +1,13 @@ import React from 'react'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { AddFiles } from '../composables/AddFiles'; + import { useAddFiles } from './hooks/useAddFiles'; -import { ViewElement } from '../context/elements'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const AddFilesControl = ({ - className, -}: ControlProps): React.JSX.Element => { +export const AddFilesControl = (): React.JSX.Element => { const props = useAddFiles(); - const ResolvedAddFiles = useResolvedComposable(AddFiles, 'AddFiles'); + const Resolved = useResolvedComposable(AddFiles, 'AddFiles'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/AddFolderControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/AddFolderControl.tsx index ebe8e70665f..b22b41de476 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/AddFolderControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/AddFolderControl.tsx @@ -1,20 +1,13 @@ import React from 'react'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { AddFolder } from '../composables/AddFolder'; + import { useAddFolder } from './hooks/useAddFolder'; -import { ViewElement } from '../context/elements'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const AddFolderControl = ({ - className, -}: ControlProps): React.JSX.Element => { +export const AddFolderControl = (): React.JSX.Element => { const props = useAddFolder(); - const ResolvedAddFolder = useResolvedComposable(AddFolder, 'AddFolder'); + const Resolved = useResolvedComposable(AddFolder, 'AddFolder'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/DataRefreshControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/DataRefreshControl.tsx index b7d7bad6583..d2ff2019a94 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/DataRefreshControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/DataRefreshControl.tsx @@ -1,20 +1,13 @@ import React from 'react'; import { DataRefresh } from '../composables/DataRefresh'; -import { ViewElement } from '../context/elements'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; + import { useDataRefresh } from './hooks/useDataRefresh'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const DataRefreshControl = ({ - className, -}: ControlProps): React.JSX.Element => { +export const DataRefreshControl = (): React.JSX.Element => { const props = useDataRefresh(); - const ResolvedDataRefresh = useResolvedComposable(DataRefresh, 'DataRefresh'); + const Resolved = useResolvedComposable(DataRefresh, 'DataRefresh'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/DataTableControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/DataTableControl.tsx index 5d505d7874a..c297d0e9a00 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/DataTableControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/DataTableControl.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { DataTable } from '../composables/DataTable'; + import { useDataTable } from './hooks/useDataTable'; import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const DataTableControl = (): React.JSX.Element | null => { +export const DataTableControl = (): React.JSX.Element => { const props = useDataTable(); - const ResolvedDataTable = useResolvedComposable(DataTable, 'DataTable'); + const Resolved = useResolvedComposable(DataTable, 'DataTable'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/DropZoneControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/DropZoneControl.tsx index a0ea1d6a862..3cf4a5683f9 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/DropZoneControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/DropZoneControl.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { DropZone } from '../composables/DropZone'; + import { useDropZone } from './hooks/useDropZone'; import { useResolvedComposable } from './hooks/useResolvedComposable'; @@ -8,10 +9,10 @@ export const DropZoneControl = ({ children, }: { children: React.ReactNode; -}): React.JSX.Element | null => { +}): React.JSX.Element => { const props = useDropZone(); - const ResolvedDropZone = useResolvedComposable(DropZone, 'DropZone'); + const Resolved = useResolvedComposable(DropZone, 'DropZone'); - return {children}; + return {children}; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/FolderNameFieldControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/FolderNameFieldControl.tsx index 6def95a6afc..fc6b0739bb2 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/FolderNameFieldControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/FolderNameFieldControl.tsx @@ -1,21 +1,14 @@ import React from 'react'; import { FolderNameField } from '../composables/FolderNameField'; -import { ViewElement } from '../context/elements'; + import { useFolderNameField } from './hooks/useFolderNameField'; import { useResolvedComposable } from './hooks/useResolvedComposable'; -import { ControlProps } from './types'; -export const FolderNameFieldControl = ({ - className, -}: ControlProps): React.JSX.Element | null => { +export const FolderNameFieldControl = (): React.JSX.Element => { const props = useFolderNameField(); const Resolved = useResolvedComposable(FolderNameField, 'FolderNameField'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/LoadingIndicatorControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/LoadingIndicatorControl.tsx index 9fe41f9fcab..524931c2198 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/LoadingIndicatorControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/LoadingIndicatorControl.tsx @@ -1,21 +1,14 @@ import React from 'react'; import { LoadingIndicator } from '../composables/LoadingIndicator'; -import { ViewElement } from '../context/elements'; + import { useLoadingIndicator } from './hooks/useLoadingIndicator'; import { useResolvedComposable } from './hooks/useResolvedComposable'; -import { ControlProps } from './types'; -export const LoadingIndicatorControl = ({ - className, -}: ControlProps): React.JSX.Element | null => { +export const LoadingIndicatorControl = (): React.JSX.Element => { const props = useLoadingIndicator(); const Resolved = useResolvedComposable(LoadingIndicator, 'LoadingIndicator'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/MessageControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/MessageControl.tsx index 133aa09630b..bce2408bea1 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/MessageControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/MessageControl.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Message } from '../composables/Message'; + import { useMessage } from './hooks/useMessage'; import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const MessageControl = (): React.JSX.Element | null => { +export const MessageControl = (): React.JSX.Element => { const props = useMessage(); const Resolved = useResolvedComposable(Message, 'Message'); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/NavigationControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/NavigationControl.tsx index 692e7a1459d..4c8665e1217 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/NavigationControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/NavigationControl.tsx @@ -1,21 +1,14 @@ import React from 'react'; import { Navigation } from '../composables/Navigation'; -import { ViewElement } from '../context/elements'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; + import { useNavigation } from './hooks/useNavigation'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const NavigationControl = ({ - className, -}: ControlProps): React.JSX.Element | null => { +export const NavigationControl = (): React.JSX.Element => { const props = useNavigation(); - const ResolvedNavigation = useResolvedComposable(Navigation, 'Navigation'); + const Resolved = useResolvedComposable(Navigation, 'Navigation'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/OverwriteToggleControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/OverwriteToggleControl.tsx index c06875aeb30..9dd5eada0a1 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/OverwriteToggleControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/OverwriteToggleControl.tsx @@ -1,23 +1,13 @@ import React from 'react'; -import { ControlProps } from './types'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; import { OverwriteToggle } from '../composables/OverwriteToggle'; + import { useOverwriteToggle } from './hooks/useOverwriteToggle'; -import { ViewElement } from '../context/elements'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const OverwriteToggleControl = ({ - className, -}: ControlProps): React.JSX.Element => { +export const OverwriteToggleControl = (): React.JSX.Element => { const props = useOverwriteToggle(); - const ResolvedOverwriteToggle = useResolvedComposable( - OverwriteToggle, - 'OverwriteToggle' - ); + const Resolved = useResolvedComposable(OverwriteToggle, 'OverwriteToggle'); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx index 529bc39f82c..dc9cd66b5de 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/PaginationControl.tsx @@ -1,21 +1,13 @@ import React from 'react'; import { Pagination } from '../composables/Pagination'; -import { ViewElement } from '../context/elements'; -import { ControlProps } from './types'; import { useResolvedComposable } from './hooks/useResolvedComposable'; import { useControlsContext } from './context'; -export const PaginationControl = ({ - className, -}: ControlProps): React.JSX.Element | null => { +export const PaginationControl = (): React.JSX.Element => { const { data } = useControlsContext(); - const ResolvedPagination = useResolvedComposable(Pagination, 'Pagination'); + 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 index fc30d017ef4..22c8e75a434 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/SearchControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/SearchControl.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { SearchField } from '../composables/SearchField'; -import { useResolvedComposable } from './hooks/useResolvedComposable'; + import { useControlsContext } from './context'; +import { useResolvedComposable } from './hooks/useResolvedComposable'; -export const SearchControl = (): React.JSX.Element | null => { +export const SearchControl = (): React.JSX.Element => { const { data, onSearch, onSearchQueryChange, onSearchClear } = useControlsContext(); const { @@ -13,10 +14,10 @@ export const SearchControl = (): React.JSX.Element | null => { searchQuery, searchSubmitLabel, } = data; - const ResolvedSearch = useResolvedComposable(SearchField, 'SearchField'); + const Resolved = useResolvedComposable(SearchField, 'SearchField'); return ( - { +export const SearchSubfoldersToggleControl = (): React.JSX.Element => { const props = useSearchSubfoldersToggle(); - const ResolvedSearchSubfoldersToggle = useResolvedComposable( + const Resolved = useResolvedComposable( SearchSubfoldersToggle, 'SearchSubfoldersToggle' ); - return ( - - - - ); + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/StatusDisplayControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/StatusDisplayControl.tsx index 03240475e67..1f13ef7e0fb 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/StatusDisplayControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/StatusDisplayControl.tsx @@ -1,16 +1,14 @@ import React from 'react'; import { StatusDisplay } from '../composables/StatusDisplay'; + import { useResolvedComposable } from './hooks/useResolvedComposable'; import { useStatusDisplay } from './hooks/useStatusDisplay'; -export const StatusDisplayControl = (): React.JSX.Element | null => { +export const StatusDisplayControl = (): React.JSX.Element => { const props = useStatusDisplay(); - const ResolvedStatusDisplay = useResolvedComposable( - StatusDisplay, - 'StatusDisplay' - ); + const Resolved = useResolvedComposable(StatusDisplay, 'StatusDisplay'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/TitleControl.tsx b/packages/react-storage/src/components/StorageBrowser/controls/TitleControl.tsx index 58afcc84a72..90e44eb2373 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/TitleControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/TitleControl.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { Title } from '../composables/Title'; + import { useResolvedComposable } from './hooks/useResolvedComposable'; import { useTitle } from './hooks/useTitle'; export const TitleControl = (): React.JSX.Element => { const props = useTitle(); - const ResolvedTitle = useResolvedComposable(Title, 'Title'); + const Resolved = useResolvedComposable(Title, 'Title'); - return ; + return ; }; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/ActionDestinationControl.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/ActionDestinationControl.spec.tsx new file mode 100644 index 00000000000..778864d2583 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/ActionDestinationControl.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ActionDestinationControl } from '../ActionDestinationControl'; +import { useActionDestination } from '../hooks/useActionDestination'; +import { useResolvedComposable } from '../hooks/useResolvedComposable'; + +jest.mock('../hooks/useActionDestination'); +jest.mock('../hooks/useResolvedComposable'); +jest.mock('../../composables/ActionDestination', () => ({ + ActionDestination: () =>
, +})); + +describe('ActionDestinationControl', () => { + const mockUseActionDestination = jest.mocked(useActionDestination); + const mockUseResolvedComposable = jest.mocked(useResolvedComposable); + + beforeAll(() => { + mockUseResolvedComposable.mockImplementation( + (component) => component as () => React.JSX.Element + ); + }); + + afterEach(() => { + mockUseActionDestination.mockClear(); + }); + + it('renders', () => { + render(); + + const ActionDestination = screen.getByTestId('action-destination'); + + expect(ActionDestination).toBeInTheDocument(); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/StatusDisplayControl.spec.tsx b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/StatusDisplayControl.spec.tsx index 1256ba7d91e..9847230c7e1 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/__tests__/StatusDisplayControl.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/controls/__tests__/StatusDisplayControl.spec.tsx @@ -41,14 +41,4 @@ describe('StatusDisplayControl', () => { expect(bar).toHaveTextContent('2/6'); expect(qux).toHaveTextContent('3/6'); }); - - it('returns null without props', () => { - mockUseStatusDisplay.mockReturnValue({}); - - render(); - - expect(screen.queryByRole('list')).not.toBeInTheDocument(); - expect(screen.queryByRole('term')).not.toBeInTheDocument(); - expect(screen.queryByRole('definition')).not.toBeInTheDocument(); - }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareButtonData.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareButtonData.spec.ts deleted file mode 100644 index f91b817a414..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareButtonData.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SortDirection } from '../../../composables/DataTable'; -import { compareButtonData } from '../compareButtonData'; - -describe('compareButtonData', () => { - const emptyContent = { type: 'button' as const, content: {} }; - const a = { ...emptyContent, content: { label: 'a' } }; - const b = { ...emptyContent, content: { label: 'b' } }; - const getComparisonResults = (direction: SortDirection) => [ - compareButtonData(emptyContent, emptyContent, direction), - compareButtonData(emptyContent, b, direction), - compareButtonData(a, emptyContent, direction), - compareButtonData(a, a, direction), - compareButtonData(a, b, direction), - compareButtonData(b, a, direction), - ]; - - it('should compare button data in ascending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('ascending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeGreaterThan(0); - expect(bIsUndefined).toBeLessThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeLessThan(0); - expect(bIsBeforeA).toBeGreaterThan(0); - }); - - it('should compare button data in descending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('descending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeLessThan(0); - expect(bIsUndefined).toBeGreaterThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeGreaterThan(0); - expect(bIsBeforeA).toBeLessThan(0); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareDateData.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareDateData.spec.ts deleted file mode 100644 index a163c46f9b0..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareDateData.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SortDirection } from '../../../composables/DataTable'; -import { compareDateData } from '../compareDateData'; - -describe('compareDateData', () => { - const emptyContent = { type: 'date' as const, content: {} }; - const a = { ...emptyContent, content: { date: new Date(1600387200000) } }; - const b = { ...emptyContent, content: { date: new Date(1702339200000) } }; - const getComparisonResults = (direction: SortDirection) => [ - compareDateData(emptyContent, emptyContent, direction), - compareDateData(emptyContent, b, direction), - compareDateData(a, emptyContent, direction), - compareDateData(a, a, direction), - compareDateData(a, b, direction), - compareDateData(b, a, direction), - ]; - - it('should compare date data in ascending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('ascending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeGreaterThan(0); - expect(bIsUndefined).toBeLessThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeLessThan(0); - expect(bIsBeforeA).toBeGreaterThan(0); - }); - - it('should compare date data in descending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('descending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeLessThan(0); - expect(bIsUndefined).toBeGreaterThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeGreaterThan(0); - expect(bIsBeforeA).toBeLessThan(0); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareNumberData.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareNumberData.spec.ts deleted file mode 100644 index fad9118fef9..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareNumberData.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SortDirection } from '../../../composables/DataTable'; -import { compareNumberData } from '../compareNumberData'; - -describe('compareNumberData', () => { - const emptyContent = { type: 'number' as const, content: {} }; - const a = { ...emptyContent, content: { value: 1 } }; - const b = { ...emptyContent, content: { value: 2 } }; - const getComparisonResults = (direction: SortDirection) => [ - compareNumberData(emptyContent, emptyContent, direction), - compareNumberData(emptyContent, b, direction), - compareNumberData(a, emptyContent, direction), - compareNumberData(a, a, direction), - compareNumberData(a, b, direction), - compareNumberData(b, a, direction), - ]; - - it('should compare date data in ascending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('ascending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeGreaterThan(0); - expect(bIsUndefined).toBeLessThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeLessThan(0); - expect(bIsBeforeA).toBeGreaterThan(0); - }); - - it('should compare date data in descending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('descending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeLessThan(0); - expect(bIsUndefined).toBeGreaterThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeGreaterThan(0); - expect(bIsBeforeA).toBeLessThan(0); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareTextData.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareTextData.spec.ts deleted file mode 100644 index 12b2ce7eda6..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/__tests__/compareTextData.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SortDirection } from '../../../composables/DataTable'; -import { compareTextData } from '../compareTextData'; - -describe('compareTextData', () => { - const emptyContent = { type: 'text' as const, content: {} }; - const a = { ...emptyContent, content: { text: 'a' } }; - const b = { ...emptyContent, content: { text: 'b' } }; - const getComparisonResults = (direction: SortDirection) => [ - compareTextData(emptyContent, emptyContent, direction), - compareTextData(emptyContent, b, direction), - compareTextData(a, emptyContent, direction), - compareTextData(a, a, direction), - compareTextData(a, b, direction), - compareTextData(b, a, direction), - ]; - - it('should compare date data in ascending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('ascending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeGreaterThan(0); - expect(bIsUndefined).toBeLessThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeLessThan(0); - expect(bIsBeforeA).toBeGreaterThan(0); - }); - - it('should compare date data in descending direction', () => { - const [ - bothAreUndefined, - aIsUndefined, - bIsUndefined, - aEqualsB, - aIsBeforeB, - bIsBeforeA, - ] = getComparisonResults('descending'); - expect(bothAreUndefined).toBe(0); - expect(aIsUndefined).toBeLessThan(0); - expect(bIsUndefined).toBeGreaterThan(0); - expect(aEqualsB).toBe(0); - expect(aIsBeforeB).toBeGreaterThan(0); - expect(bIsBeforeA).toBeLessThan(0); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareButtonData.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareButtonData.ts deleted file mode 100644 index dea2f40f363..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareButtonData.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - DataTableButtonDataCell, - SortDirection, -} from '../../composables/DataTable'; - -const compareContent = ( - { label: a }: DataTableButtonDataCell['content'], - { label: b }: DataTableButtonDataCell['content'] -): number => { - if (a === undefined) { - return b === undefined ? 0 : 1; - } - return b === undefined ? -1 : a.localeCompare(b); -}; - -export const compareButtonData = ( - a: DataTableButtonDataCell, - b: DataTableButtonDataCell, - direction: SortDirection -): number => - direction === 'ascending' - ? compareContent(a.content, b.content) - : compareContent(b.content, a.content); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareDateData.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareDateData.ts deleted file mode 100644 index 0ba3e54a849..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareDateData.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - DataTableDateDataCell, - SortDirection, -} from '../../composables/DataTable'; - -export const compareContent = ( - { date: a }: DataTableDateDataCell['content'], - { date: b }: DataTableDateDataCell['content'] -): number => { - if (a === undefined) { - return b === undefined ? 0 : 1; - } - return b === undefined ? -1 : a.getTime() - b.getTime(); -}; - -export const compareDateData = ( - a: DataTableDateDataCell, - b: DataTableDateDataCell, - direction: SortDirection -): number => - direction === 'ascending' - ? compareContent(a.content, b.content) - : compareContent(b.content, a.content); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareNumberData.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareNumberData.ts deleted file mode 100644 index 75b9f8513c2..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareNumberData.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - DataTableNumberDataCell, - SortDirection, -} from '../../composables/DataTable'; - -export const compareContent = ( - { value: a }: DataTableNumberDataCell['content'], - { value: b }: DataTableNumberDataCell['content'] -): number => { - if (a === undefined) { - return b === undefined ? 0 : 1; - } - return b === undefined ? -1 : a - b; -}; - -export const compareNumberData = ( - a: DataTableNumberDataCell, - b: DataTableNumberDataCell, - direction: SortDirection -): number => - direction === 'ascending' - ? compareContent(a.content, b.content) - : compareContent(b.content, a.content); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareTextData.ts b/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareTextData.ts deleted file mode 100644 index 8a57b7d95ff..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/controls/compareFunctions/compareTextData.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - DataTableTextDataCell, - SortDirection, -} from '../../composables/DataTable'; - -export const compareContent = ( - { text: a }: DataTableTextDataCell['content'], - { text: b }: DataTableTextDataCell['content'] -): number => { - if (a === undefined) { - return b === undefined ? 0 : 1; - } - return b === undefined ? -1 : a.localeCompare(b); -}; - -export const compareTextData = ( - a: DataTableTextDataCell, - b: DataTableTextDataCell, - direction: SortDirection -): number => - direction === 'ascending' - ? compareContent(a.content, b.content) - : compareContent(b.content, a.content); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationItems.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationItems.spec.ts new file mode 100644 index 00000000000..6157d52f910 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationItems.spec.ts @@ -0,0 +1,74 @@ +import { LocationPermissions } from '../../../actions'; +import { getNavigationItems } from '../getNavigationItems'; + +describe('getNavigationItems', () => { + const uuid = 'uuid'; + const prefix = 'prefix'; + const partA = 'part-a'; + const partB = 'part-b'; + const destinationParts = [prefix, partA, partB]; + const location = { + bucket: 'bucket', + id: 'id', + permissions: ['delete', 'get', 'list', 'write'] as LocationPermissions, + prefix: `${prefix}/`, + type: 'PREFIX', + } as const; + const mockOnNavigate = jest.fn(); + const mockRandomUUID = jest.fn(); + + beforeAll(() => { + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: mockRandomUUID }, + }); + mockRandomUUID.mockReturnValue(uuid); + }); + + afterEach(() => { + mockOnNavigate.mockClear(); + }); + + it('returns navigation items', () => { + expect( + getNavigationItems({ + destinationParts, + location, + onNavigate: mockOnNavigate, + }) + ).toStrictEqual([ + { name: prefix, onNavigate: expect.any(Function) }, + { name: partA, onNavigate: expect.any(Function) }, + { isCurrent: true, name: partB, onNavigate: expect.any(Function) }, + ]); + }); + + it('calls onNavigate', () => { + const [item1, item2, item3] = getNavigationItems({ + destinationParts, + location, + onNavigate: mockOnNavigate, + }); + + item1.onNavigate?.(); + item2.onNavigate?.(); + item3.onNavigate?.(); + + expect(mockOnNavigate).toHaveBeenNthCalledWith( + 1, + { ...location, id: uuid }, + '' + ); + + expect(mockOnNavigate).toHaveBeenNthCalledWith( + 2, + { ...location, id: uuid }, + `${partA}/` + ); + + expect(mockOnNavigate).toHaveBeenNthCalledWith( + 3, + { ...location, id: uuid }, + `${partA}/${partB}/` + ); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationParts.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationParts.spec.ts new file mode 100644 index 00000000000..baa722c6575 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/getNavigationParts.spec.ts @@ -0,0 +1,77 @@ +import { LocationPermissions } from '../../../actions'; +import { getNavigationParts } from '../getNavigationParts'; + +describe('getNavigationParts', () => { + const bucket = 'bucket'; + const prefix = 'prefix'; + const partA = 'a'; + const partB = 'b'; + const path = `${partA}/${partB}/`; + const locationBase = { + bucket, + id: 'id', + permissions: ['delete', 'get', 'list', 'write'] as LocationPermissions, + prefix: `${prefix}/`, + }; + + describe('PREFIX type location', () => { + const location = { ...locationBase, type: 'PREFIX' } as const; + + it('creates a part for the prefix and each subpath', () => { + // prefix > a > b + expect(getNavigationParts({ location, path })).toStrictEqual([ + prefix, + partA, + partB, + ]); + }); + + it('does not split the prefix into separate items', () => { + const prefixWithSlashes = 'prefix/with/slashes'; + // prefix/with/slashes > a > b + expect( + getNavigationParts({ + location: { + ...location, + prefix: `${prefixWithSlashes}/`, + }, + path, + }) + ).toStrictEqual([prefixWithSlashes, partA, partB]); + }); + + it('can include the bucket as part of the prefix', () => { + // bucket/prefix > a > b + expect( + getNavigationParts({ location, path, includeBucketInPrefix: true }) + ).toStrictEqual([`${bucket}/${prefix}`, partA, partB]); + }); + }); + + describe('BUCKET type location', () => { + const location = { ...locationBase, type: 'BUCKET' } as const; + it('creates an item for the bucket, prefix and each subpath', () => { + // bucket > prefix > a > b + expect(getNavigationParts({ location, path })).toStrictEqual([ + bucket, + prefix, + partA, + partB, + ]); + }); + + it('does not split the prefix into separate items', () => { + const prefixWithSlashes = 'prefix/with/slashes'; + // Home > bucket > prefix/with/slashes > a > b + expect( + getNavigationParts({ + location: { + ...location, + prefix: `${prefixWithSlashes}/`, + }, + path, + }) + ).toStrictEqual([bucket, prefixWithSlashes, partA, partB]); + }); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useActionDestination.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useActionDestination.spec.ts new file mode 100644 index 00000000000..8758bc0c2de --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useActionDestination.spec.ts @@ -0,0 +1,81 @@ +import { renderHook } from '@testing-library/react'; +import { useControlsContext } from '../../../controls/context'; +import { useActionDestination } from '../useActionDestination'; +import { LocationPermissions } from '../../../actions'; +import { getNavigationItems } from '../getNavigationItems'; +import { getNavigationParts } from '../getNavigationParts'; + +jest.mock('../../../controls/context'); +jest.mock('../getNavigationItems'); +jest.mock('../getNavigationParts'); + +describe('useActionDestination', () => { + const actionDestinationLabel = 'action-destination-label'; + const bucket = 'bucket'; + const data = { + actionDestinationLabel, + destination: { + current: { + bucket, + id: 'id', + permissions: ['delete', 'get', 'list', 'write'] as LocationPermissions, + prefix: 'prefix/', + type: 'PREFIX', + }, + path: 'path/', + key: 'prefix/path/', + } as const, + }; + const mockGetNavigationItems = jest.mocked(getNavigationItems); + const mockGetNavigationParts = jest.mocked(getNavigationParts); + const mockUseControlsContext = jest.mocked(useControlsContext); + const mockOnSelectDestination = jest.fn(); + + beforeEach(() => { + mockUseControlsContext.mockReturnValue({ + data, + onSelectDestination: mockOnSelectDestination, + }); + mockGetNavigationItems.mockReturnValue([ + { name: bucket, onNavigate: mockOnSelectDestination, isCurrent: true }, + ]); + }); + + afterEach(() => { + mockUseControlsContext.mockReset(); + mockGetNavigationItems.mockClear(); + mockGetNavigationParts.mockClear(); + }); + + it('returns useActionDestination data', () => { + const { result } = renderHook(() => useActionDestination()); + + expect(result.current).toStrictEqual({ + label: actionDestinationLabel, + items: [ + { name: bucket, onNavigate: expect.any(Function), isCurrent: true }, + ], + isNavigable: undefined, + }); + }); + + it('returns empty items if current location is undefined', () => { + mockUseControlsContext.mockReturnValue({ data: {} }); + + const { result } = renderHook(() => useActionDestination()); + + expect(result.current).toStrictEqual({ items: [] }); + }); + + it('calls onSelectDestination', () => { + mockGetNavigationItems.mockReturnValue([ + { name: bucket, onNavigate: mockOnSelectDestination, isCurrent: true }, + ]); + const { result } = renderHook(() => useActionDestination()); + const [navigationItem] = result.current?.items ?? []; + + navigationItem?.onNavigate?.(); + + expect(mockOnSelectDestination).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useNavigation.spec.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useNavigation.spec.ts index 9b73515f02d..af8539907b5 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useNavigation.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/__tests__/useNavigation.spec.ts @@ -2,38 +2,33 @@ import { renderHook } from '@testing-library/react'; import { useControlsContext } from '../../../controls/context'; import { useNavigation } from '../useNavigation'; import { LocationPermissions } from '../../../actions'; +import { getNavigationItems } from '../getNavigationItems'; +import { getNavigationParts } from '../getNavigationParts'; jest.mock('../../../controls/context'); +jest.mock('../getNavigationItems'); +jest.mock('../getNavigationParts'); describe('useNavigation', () => { const bucket = 'bucket'; - const prefix = 'prefix'; - const path = 'path'; const data = { location: { current: { bucket, id: 'id', permissions: ['delete', 'get', 'list', 'write'] as LocationPermissions, - prefix: `${prefix}/`, + prefix: 'prefix/', type: 'PREFIX', }, - path: `${path}/`, - key: `${prefix}/${path}/`, + path: 'path/', + key: 'prefix/path/', } as const, }; - // assert mocks + const mockGetNavigationItems = jest.mocked(getNavigationItems); + const mockGetNavigationParts = jest.mocked(getNavigationParts); const mockUseControlsContext = jest.mocked(useControlsContext); - // create mocks const mockOnNavigate = jest.fn(); const mockOnNavigateHome = jest.fn(); - const mockRandomUUID = jest.fn(); - - beforeAll(() => { - Object.defineProperty(globalThis, 'crypto', { - value: { randomUUID: mockRandomUUID }, - }); - }); beforeEach(() => { mockUseControlsContext.mockReturnValue({ @@ -41,10 +36,15 @@ describe('useNavigation', () => { onNavigateHome: mockOnNavigateHome, onNavigate: mockOnNavigate, }); + mockGetNavigationItems.mockReturnValue([ + { name: bucket, onNavigate: mockOnNavigate, isCurrent: true }, + ]); }); afterEach(() => { mockUseControlsContext.mockReset(); + mockGetNavigationItems.mockClear(); + mockGetNavigationParts.mockClear(); mockOnNavigate.mockClear(); mockOnNavigateHome.mockClear(); }); @@ -55,8 +55,7 @@ describe('useNavigation', () => { expect(result.current).toStrictEqual({ items: [ { name: 'Home', onNavigate: expect.any(Function) }, - { name: `${bucket}/${prefix}`, onNavigate: expect.any(Function) }, - { name: path, onNavigate: expect.any(Function), isCurrent: true }, + { name: bucket, onNavigate: expect.any(Function), isCurrent: true }, ], }); }); @@ -86,177 +85,4 @@ describe('useNavigation', () => { expect(mockOnNavigate).toHaveBeenCalled(); }); - - describe('PREFIX type location', () => { - it('creates an item for the prefix and each subpath', () => { - mockUseControlsContext.mockReturnValue({ - data: { location: { ...data.location, path: 'a/b/c/' } }, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket/prefix > a > b > c - expect(result.current?.items).toHaveLength(5); - }); - - it('does not split the prefix into separate items', () => { - const prefixWithSlashes = 'prefix/with/slashes'; - mockUseControlsContext.mockReturnValue({ - data: { - location: { - ...data.location, - current: { ...data.location.current, prefix: prefixWithSlashes }, - }, - }, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket/prefix/with/slashes > path - expect(result.current?.items).toHaveLength(3); - expect(result.current?.items[1].name).toBe( - `${bucket}/${prefixWithSlashes}` - ); - }); - - it('creates navigation items correctly', () => { - const foo = 'foo'; - const bar = 'bar'; - const qux = 'qux'; - mockRandomUUID - .mockReturnValueOnce(1) - .mockReturnValueOnce(2) - .mockReturnValueOnce(3); - mockUseControlsContext.mockReturnValue({ - data: { location: { ...data.location, path: `${foo}/${bar}/${qux}/` } }, - onNavigate: mockOnNavigate, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket/prefix > foo > bar > qux - const [, prefixItem, fooItem, barItem] = result.current?.items ?? []; - - prefixItem?.onNavigate?.(); - fooItem?.onNavigate?.(); - barItem?.onNavigate?.(); - - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 1, - { ...data.location.current, id: 1 }, - '' - ); - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 2, - { ...data.location.current, id: 2 }, - `${foo}/` - ); - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 3, - { ...data.location.current, id: 3 }, - `${foo}/${bar}/` - ); - }); - }); - - describe('BUCKET type location', () => { - it('creates an item for the bucket, prefix and each subpath', () => { - mockUseControlsContext.mockReturnValue({ - data: { - location: { - ...data.location, - current: { - ...data.location.current, - type: 'BUCKET', - }, - path: 'a/b/c/', - }, - }, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket > prefix > a > b > c - expect(result.current?.items).toHaveLength(6); - }); - - it('does not split the prefix into separate items', () => { - const prefixWithSlashes = 'prefix/with/slashes'; - mockUseControlsContext.mockReturnValue({ - data: { - location: { - ...data.location, - current: { - ...data.location.current, - prefix: prefixWithSlashes, - type: 'BUCKET', - }, - }, - }, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket > prefix/with/slashes > path - expect(result.current?.items).toHaveLength(4); - expect(result.current?.items[1].name).toBe(bucket); - expect(result.current?.items[2].name).toBe(prefixWithSlashes); - }); - - it('creates navigation items correctly', () => { - const foo = 'foo'; - const bar = 'bar'; - const qux = 'qux'; - mockRandomUUID - .mockReturnValueOnce(1) - .mockReturnValueOnce(2) - .mockReturnValueOnce(3) - .mockReturnValueOnce(4); - mockUseControlsContext.mockReturnValue({ - data: { - location: { - ...data.location, - current: { - ...data.location.current, - type: 'BUCKET', - }, - path: `${foo}/${bar}/${qux}/`, - }, - }, - onNavigate: mockOnNavigate, - }); - - const { result } = renderHook(() => useNavigation()); - - // Home > bucket > prefix > foo > bar > qux - const [, bucketItem, prefixItem, fooItem, barItem] = - result.current?.items ?? []; - - bucketItem?.onNavigate?.(); - prefixItem?.onNavigate?.(); - fooItem?.onNavigate?.(); - barItem?.onNavigate?.(); - - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 1, - { ...data.location.current, type: 'BUCKET', id: 1 }, - '' - ); - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 2, - { ...data.location.current, type: 'BUCKET', id: 2 }, - `${prefix}/` - ); - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 3, - { ...data.location.current, type: 'BUCKET', id: 3 }, - `${prefix}/${foo}/` - ); - expect(mockOnNavigate).toHaveBeenNthCalledWith( - 4, - { ...data.location.current, type: 'BUCKET', id: 4 }, - `${prefix}/${foo}/${bar}/` - ); - }); - }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationItems.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationItems.ts new file mode 100644 index 00000000000..db9d0285f3d --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationItems.ts @@ -0,0 +1,43 @@ +import { LocationData } from '../../actions'; +import { NavigationProps } from '../../composables/Navigation'; + +interface GetNavigationItemsInput { + destinationParts: string[]; + location: LocationData; + onNavigate?: (location: LocationData, path?: string) => void; +} + +export const getNavigationItems = ({ + destinationParts, + location, + onNavigate, +}: GetNavigationItemsInput): NavigationProps['items'] => { + const { bucket, permissions, prefix = '', type } = location; + const destinationSubpaths: string[] = []; + + return destinationParts.map((part, index) => { + const isCurrent = index === destinationParts.length - 1; + + if (index !== 0) { + destinationSubpaths.push(part); + } + + const destinationPath = `${destinationSubpaths.concat('').join('/')}`; + + const destination = { + id: crypto.randomUUID(), + type, + permissions, + bucket, + prefix, + }; + + return { + name: part, + ...(isCurrent && { isCurrent }), + onNavigate: () => { + onNavigate?.(destination, destinationPath); + }, + }; + }); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationParts.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationParts.ts new file mode 100644 index 00000000000..f386a4038ff --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/getNavigationParts.ts @@ -0,0 +1,41 @@ +import { LocationData } from '../../actions'; + +interface GetNavigationPartsInput { + location: LocationData; + path: string; + includeBucketInPrefix?: boolean; +} + +export const getNavigationParts = ({ + location, + path, + includeBucketInPrefix, +}: GetNavigationPartsInput): string[] => { + const { bucket, prefix = '', type } = location; + + const trimmedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + const trimmedPath = path.endsWith('/') ? path.slice(0, -1) : path; + + const firstPrefixPart = []; + if (type !== 'BUCKET') { + if (includeBucketInPrefix) { + firstPrefixPart.push(bucket); + } + if (trimmedPrefix) { + if (includeBucketInPrefix) { + firstPrefixPart.push('/'); + } + firstPrefixPart.push(trimmedPrefix); + } + } + + const prefixParts = type === 'BUCKET' ? [bucket] : [firstPrefixPart.join('')]; + + if (type === 'BUCKET' && trimmedPrefix) { + prefixParts.push(trimmedPrefix); + } + + const pathParts = trimmedPath ? trimmedPath.split('/') : []; + + return prefixParts.concat(pathParts); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useActionDestination.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useActionDestination.ts new file mode 100644 index 00000000000..bd4ba1f4873 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useActionDestination.ts @@ -0,0 +1,41 @@ +// import { ActionDestinationProps } from '../../composables/ActionDestination'; +import React from 'react'; + +import { useControlsContext } from '../../controls/context'; +import { getNavigationItems } from './getNavigationItems'; +import { getNavigationParts } from './getNavigationParts'; +import { ActionDestinationProps } from '../../composables/ActionDestination'; + +export const useActionDestination = (): ActionDestinationProps => { + const { data, onSelectDestination } = useControlsContext(); + const { actionDestinationLabel, isActionDestinationNavigable, destination } = + data; + + return React.useMemo(() => { + if (!destination?.current) { + return { items: [] }; + } + + const { current, path } = destination; + + const destinationParts = getNavigationParts({ + location: current, + path, + }); + + return { + label: actionDestinationLabel, + items: getNavigationItems({ + location: current, + destinationParts, + onNavigate: onSelectDestination, + }), + isNavigable: isActionDestinationNavigable, + }; + }, [ + actionDestinationLabel, + isActionDestinationNavigable, + destination, + onSelectDestination, + ]); +}; diff --git a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useNavigation.ts b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useNavigation.ts index 40f46381793..94322858fac 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/hooks/useNavigation.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/hooks/useNavigation.ts @@ -1,6 +1,8 @@ import React from 'react'; import { NavigationProps } from '../../composables/Navigation'; import { useControlsContext } from '../../controls/context'; +import { getNavigationItems } from './getNavigationItems'; +import { getNavigationParts } from './getNavigationParts'; export const useNavigation = (): NavigationProps => { const { data, onNavigate, onNavigateHome } = useControlsContext(); @@ -12,52 +14,20 @@ export const useNavigation = (): NavigationProps => { } const { current, path } = location; - const { bucket, permissions, prefix = '', type } = current; - const trimmedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; - const trimmedPath = path.endsWith('/') ? path.slice(0, -1) : path; + const destinationParts = getNavigationParts({ + location: current, + path, + includeBucketInPrefix: true, + }); - const prefixParts = - type === 'BUCKET' - ? [''] - : [`${bucket}${trimmedPrefix && `/${trimmedPrefix}`}`]; + const homeItem: NavigationProps['items'] = [ + { name: 'Home', onNavigate: onNavigateHome }, + ]; - if (type === 'BUCKET' && trimmedPrefix) { - prefixParts.push(trimmedPrefix); - } - - const pathParts = trimmedPath ? trimmedPath.split('/') : []; - - const parts = prefixParts.concat(pathParts); - const destinationParts: string[] = []; return { - items: [{ name: 'Home', onNavigate: onNavigateHome }].concat( - parts.map((part, index) => { - const isCurrent = index === parts.length - 1; - const name = index === 0 && type === 'BUCKET' ? bucket : part; - - if (index !== 0) { - destinationParts.push(part); - } - - const destinationPath = `${destinationParts.concat('').join('/')}`; - - const destination = { - id: crypto.randomUUID(), - type, - permissions, - bucket, - prefix, - }; - - return { - name, - ...(isCurrent && { isCurrent }), - onNavigate: () => { - onNavigate?.(destination, destinationPath); - }, - }; - }) + items: homeItem.concat( + getNavigationItems({ location: current, destinationParts, onNavigate }) ), }; }, [location, onNavigate, onNavigateHome]); diff --git a/packages/react-storage/src/components/StorageBrowser/controls/types.ts b/packages/react-storage/src/components/StorageBrowser/controls/types.ts index 02cf758b19d..768e7058fc1 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/types.ts @@ -6,10 +6,6 @@ import { Composables } from '../composables/types'; import { LocationState } from '../providers/store/location'; import { StatusCounts } from '../tasks'; -export interface ControlProps { - className?: string; -} - export interface Controls { props: React.ComponentProps; } @@ -43,10 +39,12 @@ export interface ControlsContext { data: { actions?: ActionsListItem[]; actionCancelLabel?: string; + actionDestinationLabel?: string; actionExitLabel?: string; actionStartLabel?: string; addFilesLabel?: string; addFolderLabel?: string; + destination?: LocationState; folderNameId?: string; folderNameLabel?: string; folderNamePlaceholder?: string; @@ -57,6 +55,7 @@ export interface ControlsContext { isActionsListDisabled?: boolean; isAddFilesDisabled?: boolean; isAddFolderDisabled?: boolean; + isActionDestinationNavigable?: boolean; isOverwritingEnabled?: boolean; isDataRefreshDisabled?: boolean; isLoading?: boolean; @@ -96,6 +95,7 @@ export interface ControlsContext { onSearch?: () => void; onSearchClear?: () => void; onSearchQueryChange?: (value: string) => void; + onSelectDestination?: (location: LocationData, path?: string) => void; onToggleOverwrite?: () => void; onToggleSearchSubfolders?: () => void; onValidateFolderName?: (value: string) => void; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx index 0a51d9c901a..0c7300b592b 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx @@ -1,83 +1,36 @@ import React from 'react'; -import { MergeBaseElements } from '@aws-amplify/ui-react-core/elements'; - -import { - LocationActions, - locationActionsDefault, -} from './do-not-import-from-here/locationActions'; -import { createTempActionsProvider } from './do-not-import-from-here/createTempActionsProvider'; import { DEFAULT_COMPOSABLES } from './composables'; -import { StorageBrowserElements } from './context/elements'; -import { Components, ComponentsProvider } from './ComponentsProvider'; +import { elementsDefault } from './context/elements'; +import { ComponentsProvider } from './ComponentsProvider'; import { ErrorBoundary } from './ErrorBoundary'; -import { - createConfigurationProvider, - RegisterAuthListener, - StoreProvider, - StoreProviderProps, -} from './providers'; +import { createConfigurationProvider, StoreProvider } from './providers'; import { StorageBrowserDefault } from './StorageBrowserDefault'; import { assertRegisterAuthListener } from './validators'; import { - Views, + CopyView, + CreateFolderView, + DeleteView, LocationActionView, LocationDetailView, LocationsView, + UploadView, ViewsProvider, } from './views'; -import { GetLocationCredentials } from './credentials/types'; import { defaultActionConfigs } from './actions'; -import { createUseView } from './views/createUseView'; -import { DisplayTextProvider } from './displayText'; -import { ListLocations } from './adapters/types'; -import { StorageBrowserDisplayText } from './displayText/types'; - -export interface Config { - accountId?: string; - customEndpoint?: string; - getLocationCredentials: GetLocationCredentials; - listLocations: ListLocations; - registerAuthListener: RegisterAuthListener; - region: string; -} - -export interface CreateStorageBrowserInput { - actions?: LocationActions; - config: Config; - components?: Components; - elements?: Partial; -} - -export interface StorageBrowserProps { - views?: Views; - displayText?: StorageBrowserDisplayText; -} -export interface StorageBrowserComponent extends Views { - ( - props: StorageBrowserProps & Exclude - ): React.JSX.Element; - displayName: string; - Provider: (props: StorageBrowserProviderProps) => React.JSX.Element; -} - -export interface ResolvedStorageBrowserElements< - T extends Partial, -> extends MergeBaseElements {} - -export type ActionViewName = Exclude< - T, - 'listLocationItems' | 'listLocations' ->; +import { DisplayTextProvider } from './displayText'; +import { createUseView } from './views/createUseView'; -export interface StorageBrowserProviderProps extends StoreProviderProps { - displayText?: StorageBrowserDisplayText; -} +import { + CreateStorageBrowserInput, + StorageBrowserProviderProps, + StorageBrowserType, +} from './types'; export function createStorageBrowser(input: CreateStorageBrowserInput): { - StorageBrowser: StorageBrowserComponent< + StorageBrowser: StorageBrowserType< keyof Omit< typeof defaultActionConfigs, 'listLocationItems' | 'listLocations' @@ -95,15 +48,16 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { region, } = input.config; - // will be replaced, contains the v0 actions API approach - const TempActionsProvider = createTempActionsProvider({ - ...input, - actions: locationActionsDefault, - }); - const ConfigurationProvider = createConfigurationProvider({ accountId, - actions: defaultActionConfigs, + actions: { + ...defaultActionConfigs, + // @ts-expect-error To be addressed with line 40 + listLocations: { + componentName: 'LocationsView', + handler: input.config.listLocations, + }, + }, customEndpoint, displayName: 'ConfigurationProvider', getLocationCredentials, @@ -112,6 +66,7 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { }); const composables = { ...DEFAULT_COMPOSABLES, ...input.components }; + const elements = { ...elementsDefault, ...input.elements }; /** * Provides state, configuration and action values that are shared between @@ -122,21 +77,16 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { - - - {children} - - + + {children} + ); } - const StorageBrowser: StorageBrowserComponent = ({ views, displayText }) => ( + const StorageBrowser: StorageBrowserType = ({ views, displayText }) => ( @@ -150,6 +100,11 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { StorageBrowser.LocationDetailView = LocationDetailView; StorageBrowser.LocationsView = LocationsView; + StorageBrowser.CopyView = CopyView; + StorageBrowser.CreateFolderView = CreateFolderView; + StorageBrowser.DeleteView = DeleteView; + StorageBrowser.UploadView = UploadView; + StorageBrowser.Provider = Provider; StorageBrowser.displayName = 'StorageBrowser'; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test-d.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test-d.ts deleted file mode 100644 index fd663f65c22..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test-d.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { createStorageBrowser } from '..'; -import { DefaultActionConfigs } from '../../actions/configs'; -import { - ListLocationItemsActionViewSubComponents, - ListLocationsActionViewSubComponents, - TaskActionViewComponent, - ViewComponent, -} from '../types'; - -type Expect = T; -type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y - ? 1 - : 2 - ? true - : false; - -describe('createStorageBrowser() type generation', () => { - test('generate correct StorageBrowser type without any custom actions', () => { - const { StorageBrowser } = createStorageBrowser({ - config: {} as any, - }); - - type _ = Expect< - typeof StorageBrowser extends { - (props: Record): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; - readonly LocationDetailView: ViewComponent< - ListLocationItemsActionViewSubComponents, - {} - >; - readonly LocationsView: ViewComponent< - ListLocationsActionViewSubComponents, - {} - >; - readonly CreateFolderView: TaskActionViewComponent<{}>; - readonly UploadView: TaskActionViewComponent<{}>; - } - ? true - : false - >; - - expect('done').toBe('done'); - }); - - test('generate correct StorageBrowser type with custom actions', () => { - const { StorageBrowser } = createStorageBrowser({ - config: {} as any, - actions: { - Share: { - componentName: 'MyShareView', - handler: () => {}, - isCancelable: false, - displayName: 'Share', - includeProgress: false, - }, - CropAll: { - componentName: 'CropAllImagesView', - handler: () => {}, - isCancelable: false, - displayName: 'Crop All', - includeProgress: false, - }, - }, - }); - - type _ = Expect< - typeof StorageBrowser extends { - (props: Record): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; - readonly MyShareView: TaskActionViewComponent<{}>; - readonly CropAllImagesView: TaskActionViewComponent<{}>; - readonly LocationDetailView: ViewComponent< - ListLocationItemsActionViewSubComponents, - {} - >; - readonly LocationsView: ViewComponent< - ListLocationsActionViewSubComponents, - {} - >; - readonly CreateFolderView: TaskActionViewComponent<{}>; - readonly UploadView: TaskActionViewComponent<{}>; - } - ? true - : false - >; - - expect('done').toBe('done'); - }); - - test('generate correct StorageBrowser type with custom actions and overriding actions', () => { - const { StorageBrowser } = createStorageBrowser({ - config: {} as any, - actions: { - CreateFolder: { - componentName: 'CreateFolderView', - handler: () => { - throw new Error('Not implemented for testing'); - }, - isCancelable: false, - displayName: 'Create Folder', - }, - Share: { - componentName: 'MyShareView', - handler: () => {}, - isCancelable: false, - displayName: 'Share', - }, - someOtherAction: { - componentName: 'SomeOtherView', - handler: () => {}, - isCancelable: false, - displayName: 'Some Other Action', - }, - }, - }); - - type _ = Expect< - typeof StorageBrowser extends { - (props: Record): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; - readonly MyShareView: TaskActionViewComponent<{}>; - readonly SomeOtherView: TaskActionViewComponent<{}>; - readonly CreateFolderView: TaskActionViewComponent<{}>; - readonly LocationDetailView: ViewComponent< - ListLocationItemsActionViewSubComponents, - {} - >; - readonly LocationsView: ViewComponent< - ListLocationsActionViewSubComponents, - {} - >; - readonly UploadView: TaskActionViewComponent<{}>; - } - ? true - : false - >; - - expect('done').toBe('done'); - }); - - test('cannot override certain fields while specifying default action overrides', () => { - createStorageBrowser({ - config: {} as any, - actions: { - CreateFolder: { - // @ts-expect-error doesn't allow different componentName - componentName: 'SomeOtherComponentName', - handler: () => { - throw new Error('Not implemented for testing'); - }, - displayName: 'Create Folder', - }, - Upload: { - // @ts-expect-error doesn't allow different componentName - componentName: 'SomeOtherComponentName', - handler: () => { - throw new Error('Not implemented for testing'); - }, - displayName: 'Upload', - }, - ListLocationItems: { - // @ts-expect-error doesn't allow different componentName - componentName: 'SomeOtherComponentName', - handler: () => { - throw new Error('Not implemented for testing'); - }, - // @ts-expect-error doesn't allow different displayName - displayName: 'LocationItems', - }, - ListLocations: { - // @ts-expect-error doesn't allow different componentName - componentName: 'SomeOtherComponentName', - handler: () => { - throw new Error('Not implemented for testing'); - }, - displayName: 'Locations', - }, - }, - }); - - expect('done').toBe('done'); - }); - - test('should ignore custom actions with non-capitalized key', () => { - const { StorageBrowser } = createStorageBrowser({ - config: {} as any, - actions: { - nonCapitalizedKey: { - // @ts-expect-error cannot specify non-defined property - randomValues: 'random', - }, - }, - }); - - type _ = Expect< - typeof StorageBrowser extends { - (props: Record): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; - readonly LocationDetailView: ViewComponent< - ListLocationItemsActionViewSubComponents, - {} - >; - readonly LocationsView: ViewComponent< - ListLocationsActionViewSubComponents, - {} - >; - readonly CreateFolderView: TaskActionViewComponent<{}>; - readonly UploadView: TaskActionViewComponent<{}>; - } - ? true - : false - >; - - expect('done').toBe('done'); - }); - - test('generate correct type for created `useAction`', () => { - const { useAction } = createStorageBrowser({ - config: {} as any, - actions: { - Share: { - componentName: 'MyShareView', - handler: () => {}, - isCancelable: false, - displayName: 'Share', - includeProgress: false, - }, - }, - }); - - type ParamType = Parameters[0]; - type _ = Expect>; - - expect('done').toBe('done'); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test.ts deleted file mode 100644 index df15df33c5c..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowser.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expectTypeTestsToPassAsync } from 'jest-tsd'; - -// evaluates type defs in corresponding test-d.ts file -it('should not produce static type errors', async () => { - await expectTypeTestsToPassAsync(__filename); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test-d.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test-d.tsx deleted file mode 100644 index bb943b573df..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test-d.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; -import { createStorageBrowser } from '../createStorageBrowser'; -import { - ListLocationItemsHandlerOutput, - TaskHandler, - TaskHandlerInput, - TaskHandlerOutput, - UploadHandler, - createFolderHandler, - listLocationItemsHandler, -} from '../../actions'; - -type MyHandler = TaskHandler; -const myHandler = null as unknown as MyHandler; - -describe('createStorageBrowser() created React components type generation', () => { - const { StorageBrowser } = createStorageBrowser({ - actions: { - MyAction: { - displayName: 'Custom Name', - isCancelable: true, - handler: myHandler, - componentName: 'MyComponentView', - }, - SingleTaskAction: { - handler: createFolderHandler, - displayName: 'Create Folder', - componentName: 'SingleTaskActionView', - }, - ListLocationItems: { - handler: (_input) => - null as unknown as Promise, - componentName: 'LocationDetailView', - displayName: () => '', - }, - Upload: { - handler: null as unknown as UploadHandler, - componentName: 'UploadView', - displayName: 'Upload', - isCancelable: true, - }, - whatever: { - // `ListHandler` actions should be disallowed unless - // they match the default componentName, that they are - // directly related to, e.g. "ListLocationItems" -> listLocationItemsHandler - handler: listLocationItemsHandler, - componentName: 'SillyView', - displayName: 'Silly', - isCancelable: true, - }, - }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - config: {} as any, - }); - - it('can render default action views', () => { - function _Container() { - return ( - <> - - - - - - - - - ); - } - - expect('done').toBe('done'); - }); - - it('can render custom action views', () => { - function _Container() { - return ( - <> - - - - - - - ); - } - - expect('done').toBe('done'); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test.tsx deleted file mode 100644 index df15df33c5c..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/__tests__/createStorageBrowserReact.test.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { expectTypeTestsToPassAsync } from 'jest-tsd'; - -// evaluates type defs in corresponding test-d.ts file -it('should not produce static type errors', async () => { - await expectTypeTestsToPassAsync(__filename); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.ts deleted file mode 100644 index f6f8a1b585b..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/createStorageBrowser.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CreateStorageBrowser } from './types'; - -export const createStorageBrowser: CreateStorageBrowser = (_) => { - // TODO: implement this function - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return {} as any; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/index.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/index.ts deleted file mode 100644 index 217378eec17..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createStorageBrowser } from './createStorageBrowser'; diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts deleted file mode 100644 index 41cd97ef872..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser/types.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - ActionConfigs, - ComponentName, - DefaultActionConfigs, - DefaultActionKey, -} from '../actions/configs'; -import { createUseAction } from '../actions/createUseAction'; -import { StorageBrowserElements } from '../context/elements'; -import { GetLocationCredentials } from '../credentials/types'; -import { RegisterAuthListener } from '../providers'; -import { ListLocations } from '../storage-internal'; - -interface Config { - accountId?: string; - getLocationCredentials: GetLocationCredentials; - listLocations: ListLocations; - registerAuthListener: RegisterAuthListener; - region: string; -} - -interface CreateStorageBrowserInput { - config: Config; - elements?: Partial; - actions?: T; -} - -interface CreateStorageBrowserOutput { - StorageBrowser: { - (props: Record): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; - } & DerivedViews; // & the action derived views components - useAction: ReturnType>; -} - -export interface CreateStorageBrowser { - < - T extends ActionConfigs & - Partial = Partial, - >( - input: CreateStorageBrowserInput - ): CreateStorageBrowserOutput>; -} - -interface LocationActionViewProps { - type?: T; -} - -type LocationActionViewComponent = ( - props: LocationActionViewProps -) => React.JSX.Element; - -// Custom actions derived views -type CustomActionViews = { - readonly [K in keyof T as K extends DefaultActionKey - ? never - : T[K] extends { componentName: ComponentName } - ? T[K]['componentName'] - : never]: TaskActionViewComponent; -}; - -export type ViewComponent = { - (props: K): React.JSX.Element; - displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; -} & T; - -/** - * task action view component & sub-components interface - */ -export type TaskActionViewComponent = ViewComponent< - TaskActionViewSubComponents & T ->; - -interface DefaultActionViews { - ListLocationItems: { - componentName: 'LocationDetailView'; - subComponents: ViewComponent; - }; - ListLocations: { - componentName: 'LocationsView'; - subComponents: ViewComponent; - }; - Upload: { - componentName: 'UploadView'; - // TODO: pass in the generic parameter - subComponents: TaskActionViewComponent; - }; - // temp: needs full subcomp defintions - CreateFolder: { - componentName: 'CreateFolderView'; - subComponents: TaskActionViewComponent; - }; -} - -/** - * Create derived views from both custom actions and default actions. - * - * One can override default actions, but the view interface of the default actions - * remain the same. - */ -type DerivedViews = CustomActionViews & { - readonly [K in keyof T as K extends keyof DefaultActionViews - ? DefaultActionViews[K]['componentName'] - : never]: K extends keyof DefaultActionViews - ? DefaultActionViews[K]['subComponents'] - : never; -} & { - readonly LocationActionView: LocationActionViewComponent< - // exclude list view actions - Exclude - >; -}; - -interface DefaultViewSubComponentProps { - className?: string; -} - -export interface TaskActionViewSubComponents { - Title: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Trigger: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Cancel: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Table: (props: DefaultViewSubComponentProps) => React.JSX.Element; - StatusDisplay: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Destination: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Exit: (props: DefaultViewSubComponentProps) => React.JSX.Element; -} - -export interface ListLocationsActionViewSubComponents { - Title: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Table: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Search: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Refresh: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Paginate: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Message: (props: DefaultViewSubComponentProps) => React.JSX.Element; -} - -export interface ListLocationItemsActionViewSubComponents - extends ListLocationsActionViewSubComponents { - ActionList: (props: DefaultViewSubComponentProps) => React.JSX.Element; - Navigate: (props: DefaultViewSubComponentProps) => React.JSX.Element; -} diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/copyView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/copyView.spec.ts.snap index e1808ddba16..95b05aff3e5 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/copyView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/copyView.spec.ts.snap @@ -82,11 +82,10 @@ exports[`CopyView display text values \`getListFoldersResultsMessage\` returns t exports[`CopyView display text values should match snapshot values 1`] = ` { "actionCancelLabel": "Cancel", - "actionDestinationLabel": "Copy destination:", + "actionDestinationLabel": "Copy destination", "actionExitLabel": "Exit", "actionStartLabel": "Copy", "getActionCompleteMessage": [Function], - "getFolderSelectedMessage": [Function], "getListFoldersResultsMessage": [Function], "loadingIndicatorLabel": "Loading", "overwriteWarningMessage": "Copied files will overwrite existing files at selected destination.", diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationDetailView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationDetailView.spec.ts.snap index ea0dd403eeb..03dda25d017 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationDetailView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationDetailView.spec.ts.snap @@ -21,6 +21,8 @@ exports[`LocationDetailView display text \`getListResultsMessage\` returns the e } `; +exports[`LocationDetailView display text \`getListResultsMessage\` returns the expected values in the loading scenario 1`] = `undefined`; + exports[`LocationDetailView display text \`getListResultsMessage\` returns the expected values in the search exhausted scenario 1`] = ` { "content": "Showing results for up to the first 10,000 items.", diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationsView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationsView.spec.ts.snap index d9f8f66e235..adceaffcfa3 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationsView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/locationsView.spec.ts.snap @@ -21,6 +21,8 @@ exports[`LocationsView display text \`getListLocationsResultMessage\` returns th } `; +exports[`LocationsView display text \`getListLocationsResultMessage\` returns the expected values in the loading scenario 1`] = `undefined`; + exports[`LocationsView display text \`getListLocationsResultMessage\` returns the expected values in the search failed scenario 1`] = ` { "content": "Network got confused", diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/uploadView.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/uploadView.spec.ts.snap index fc7ba3645aa..272f67f6885 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/uploadView.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/__snapshots__/uploadView.spec.ts.snap @@ -1,55 +1,122 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all failed scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the all failed scenario 1`] = ` { "content": "All files failed to upload.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all prevented overwrites scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the all overwrite prevented or failed scenario 1`] = ` +{ + "content": "Overwrite prevented for 6 files, 5 files failed to upload.", + "type": "error", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the all overwrite prevented scenario 1`] = ` +{ + "content": "Overwrite prevented for all files.", + "type": "warning", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the all prevented overwrites scenario 1`] = ` { "content": "Overwrite prevented for all files.", "type": "warning", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the all success scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the all success scenario 1`] = ` { "content": "All files uploaded.", "type": "success", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the no failures, some prevented overwrites, some success scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the multiple overwrite prevented scenario 1`] = ` +{ + "content": "Overwrite prevented for 3 files, 8 files uploaded.", + "type": "warning", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the multiple overwrite prevented with a failure scenario 1`] = ` +{ + "content": "Overwrite prevented for 3 files, 1 file failed to upload, 7 files uploaded.", + "type": "error", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the multiple overwrite prevented with failures scenario 1`] = ` +{ + "content": "Overwrite prevented for 3 files, 3 files failed to upload, 5 files uploaded.", + "type": "error", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the no failures, some prevented overwrites, some success scenario 1`] = ` { "content": "Overwrite prevented for 3 files, 2 files uploaded.", "type": "warning", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failed scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the single overwrite prevented scenario 1`] = ` +{ + "content": "Overwrite prevented for 1 file, 10 files uploaded.", + "type": "warning", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the single overwrite prevented with a failure scenario 1`] = ` +{ + "content": "Overwrite prevented for 1 file, 1 file failed to upload, 9 files uploaded.", + "type": "error", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the single overwrite prevented with failures scenario 1`] = ` +{ + "content": "Overwrite prevented for 1 file, 3 files failed to upload, 7 files uploaded.", + "type": "error", +} +`; + +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the some failed scenario 1`] = ` { "content": "3 files failed to upload, 8 files uploaded.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, no success scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, no success scenario 1`] = ` { "content": "Overwrite prevented for 3 files, 2 files failed to upload.", "type": "error", } `; -exports[`CopyView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, some success scenario 1`] = ` +exports[`UploadView display text values \`getActionCompleteMessage\` returns the expected values in the some failures, some prevented overwrites, some success scenario 1`] = ` { "content": "Overwrite prevented for 3 files, 2 files failed to upload, 8 files uploaded.", "type": "error", } `; -exports[`CopyView display text values should match snapshot values 1`] = ` +exports[`UploadView display text values \`getFilesValidationMessage\` returns expected values in the empty file items scenario 1`] = `undefined`; + +exports[`UploadView display text values \`getFilesValidationMessage\` returns expected values in the no files scenario 1`] = `undefined`; + +exports[`UploadView display text values \`getFilesValidationMessage\` returns expected values in the too large file scenario 1`] = ` +{ + "content": "Files larger than 160GB cannot be added to the upload queue: file1", + "type": "warning", +} +`; + +exports[`UploadView display text values should match snapshot values 1`] = ` { "actionCancelLabel": "Cancel", "actionDestinationLabel": "Destination", @@ -58,6 +125,7 @@ exports[`CopyView display text values should match snapshot values 1`] = ` "addFilesLabel": "Add files", "addFolderLabel": "Add folder", "getActionCompleteMessage": [Function], + "getFilesValidationMessage": [Function], "loadingIndicatorLabel": "Loading", "overwriteToggleLabel": "Overwrite existing files", "statusDisplayCanceledLabel": "Canceled", 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 89567e9cb04..9d0c8869794 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 @@ -3,7 +3,9 @@ import { LocationData, LocationItemData, } from '../../../../actions'; +import { FileItems } from '../../../../providers'; import { INITIAL_STATUS_COUNTS, StatusCounts } from '../../../../tasks'; +import { UPLOAD_FILE_SIZE_LIMIT } from '../../../../validators/isFileTooBig'; export const ACTION_SCENARIOS: [string, StatusCounts][] = [ ['all failed', { ...INITIAL_STATUS_COUNTS, FAILED: 11, TOTAL: 11 }], @@ -60,6 +62,31 @@ export const CREATE_FOLDER_ACTION_SCENARIOS: [string, StatusCounts][] = [ ['success', { ...INITIAL_STATUS_COUNTS, COMPLETE: 1, TOTAL: 1 }], ]; +export const UPLOAD_FILES_VALIDATION_SCENARIOS: [ + string, + FileItems | undefined, +][] = [ + ['no files', undefined], + ['empty file items', []], + [ + 'too large file', + [ + { + // @ts-expect-error: mock file + file: { name: 'file1', size: UPLOAD_FILE_SIZE_LIMIT + 1 }, + key: 'file1', + id: 'file1-id', + }, + { + // @ts-expect-error: mock file + file: { name: 'file2', size: UPLOAD_FILE_SIZE_LIMIT }, + key: 'file2', + id: 'file2-id', + }, + ], + ], +]; + export const UPLOAD_ACTION_SCENARIOS: [string, StatusCounts][] = [ ...ACTION_SCENARIOS, [ @@ -183,6 +210,8 @@ export const LIST_LOCATIONS_SCENARIOS: [ query?: string; hasError?: boolean; message?: string; + isLoading?: boolean; + hasExhaustedSearch?: boolean; }, ][] = [ ['empty results', { locations: [] }], @@ -215,6 +244,14 @@ export const LIST_LOCATIONS_SCENARIOS: [ hasExhaustedSearch: true, }, ], + [ + 'loading', + { + locations: [], + isLoading: true, + hasExhaustedSearch: false, + }, + ], ]; export const LIST_ITEMS_SCENARIOS: [ @@ -224,6 +261,7 @@ export const LIST_ITEMS_SCENARIOS: [ query?: string; hasError?: boolean; message?: string; + isLoading?: boolean; hasExhaustedSearch?: boolean; }, ][] = [ @@ -265,4 +303,12 @@ export const LIST_ITEMS_SCENARIOS: [ hasExhaustedSearch: true, }, ], + [ + 'loading', + { + items: [], + isLoading: true, + hasExhaustedSearch: false, + }, + ], ]; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/uploadView.spec.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/uploadView.spec.ts index c85699946ba..52cc1e394f8 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/uploadView.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/__tests__/uploadView.spec.ts @@ -1,12 +1,15 @@ -import { ACTION_SCENARIOS } from './scenarios'; +import { + UPLOAD_ACTION_SCENARIOS, + UPLOAD_FILES_VALIDATION_SCENARIOS, +} from './scenarios'; import { DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT } from '../uploadView'; -describe('CopyView display text values', () => { +describe('UploadView display text values', () => { it('should match snapshot values', () => { expect(DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT).toMatchSnapshot(); }); - it.each(ACTION_SCENARIOS)( + it.each(UPLOAD_ACTION_SCENARIOS)( '`getActionCompleteMessage` returns the expected values in the %s scenario', (_, counts) => { const { getActionCompleteMessage } = DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT; @@ -14,4 +17,13 @@ describe('CopyView display text values', () => { expect(getActionCompleteMessage({ counts })).toMatchSnapshot(); } ); + + it.each(UPLOAD_FILES_VALIDATION_SCENARIOS)( + '`getFilesValidationMessage` returns expected values in the %s scenario', + (_, invalidFiles) => { + const { getFilesValidationMessage } = DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT; + + expect(getFilesValidationMessage({ invalidFiles })).toMatchSnapshot(); + } + ); }); diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/copyView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/copyView.ts index 71f85006e34..69ec21e5c00 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/copyView.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/copyView.ts @@ -5,7 +5,7 @@ export const DEFAULT_COPY_VIEW_DISPLAY_TEXT: DefaultCopyViewDisplayText = { ...DEFAULT_ACTION_VIEW_DISPLAY_TEXT, title: 'Copy', actionStartLabel: 'Copy', - actionDestinationLabel: 'Copy destination:', + actionDestinationLabel: 'Copy destination', getListFoldersResultsMessage: ({ folders, query, message, hasError }) => { if (!folders?.length) { return { @@ -48,9 +48,6 @@ export const DEFAULT_COPY_VIEW_DISPLAY_TEXT: DefaultCopyViewDisplayText = { type: 'error', }; }, - getFolderSelectedMessage: (key: string) => { - return `Current folder selected: ${key}. There are no additional folders under this path.`; - }, searchSubmitLabel: 'Submit', searchClearLabel: 'Clear search', }; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts index 5352eff1156..e760476c471 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/locationDetailView.ts @@ -12,8 +12,13 @@ export const DEFAULT_LOCATION_DETAIL_VIEW_DISPLAY_TEXT: DefaultLocationDetailVie hasExhaustedSearch, hasError = false, message, + isLoading, } = data ?? {}; + if (isLoading) { + return undefined; + } + if (hasError) { return { type: 'error', 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 7901170c105..57d8ba3068d 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 @@ -11,6 +11,7 @@ export const DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT: DefaultLocationsViewDisplayTex searchPlaceholder: 'Filter folders and files', getListLocationsResultMessage: (data) => { const { + isLoading, locations, query, hasExhaustedSearch, @@ -18,6 +19,10 @@ export const DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT: DefaultLocationsViewDisplayTex message, } = data ?? {}; + if (isLoading) { + return undefined; + } + if (hasError) { return { type: 'error', diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/uploadView.ts b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/uploadView.ts index 639e22003c3..54051daa1b1 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/uploadView.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/libraries/en/uploadView.ts @@ -1,5 +1,6 @@ import { DEFAULT_ACTION_VIEW_DISPLAY_TEXT } from './shared'; import { DefaultUploadViewDisplayText } from '../../types'; +import { isFileTooBig } from '../../../validators'; export const DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT: DefaultUploadViewDisplayText = { ...DEFAULT_ACTION_VIEW_DISPLAY_TEXT, @@ -79,6 +80,22 @@ export const DEFAULT_UPLOAD_VIEW_DISPLAY_TEXT: DefaultUploadViewDisplayText = { return { content: 'All files uploaded.', type }; }, + getFilesValidationMessage: (data) => { + if (!data?.invalidFiles) { + return undefined; + } + const tooBigFileNames = data.invalidFiles + .filter(({ file }) => isFileTooBig(file)) + .map(({ file }) => file.name) + .join(', '); + if (tooBigFileNames) { + return { + content: `Files larger than 160GB cannot be added to the upload queue: ${tooBigFileNames}`, + type: 'warning', + }; + } + return undefined; + }, statusDisplayOverwritePreventedLabel: 'Overwrite prevented', overwriteToggleLabel: 'Overwrite existing files', }; diff --git a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts index 2edd9de2abf..5fafa744150 100644 --- a/packages/react-storage/src/components/StorageBrowser/displayText/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/displayText/types.ts @@ -1,6 +1,7 @@ import { StatusCounts, Tasks } from '../tasks'; import { CopyHandlerData, + CreateFolderHandlerData, DeleteHandlerData, FolderData, LocationData, @@ -11,7 +12,7 @@ import { } from '../actions'; import { LocationState } from '../providers/store/location'; import { MessageType } from '../composables/Message'; -import { CreateFolderHandlerData } from '../actions'; +import { FileItems } from '../providers'; /** * Common list view display text values @@ -28,6 +29,7 @@ interface ListMessageData { message?: string; hasExhaustedSearch?: boolean; query?: string; + isLoading?: boolean; } interface ListLocationsMessageData extends ListMessageData { @@ -120,7 +122,6 @@ export interface DefaultCopyViewDisplayText getListFoldersResultsMessage: ( data: ListFoldersMessageData ) => { content?: string; type?: MessageType } | undefined; - getFolderSelectedMessage: (path: string) => string; loadingIndicatorLabel: 'Loading'; overwriteWarningMessage: string; searchPlaceholder: string; @@ -137,6 +138,9 @@ export interface DefaultUploadViewDisplayText addFolderLabel: string; statusDisplayOverwritePreventedLabel: string; overwriteToggleLabel: string; + getFilesValidationMessage: (data?: { + invalidFiles?: FileItems; + }) => { content?: string; type?: MessageType } | undefined; } export interface DefaultStorageBrowserDisplayText { diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/createUseActionStateContext.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/createUseActionStateContext.spec.tsx deleted file mode 100644 index 6b025b2976a..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/createUseActionStateContext.spec.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; - -import { createActionStateContext } from '../createActionStateContext'; -import { AsyncDataAction } from '@aws-amplify/ui-react-core'; - -const actionOne: AsyncDataAction = jest.fn( - ( - _: { value: string | undefined }, - { inputValue }: { inputValue: string } - ): Promise<{ value: string | undefined }> => - Promise.resolve({ value: inputValue }) -); - -const actionTwo: AsyncDataAction = jest.fn( - ( - _: { anotherValue: boolean }, - { inputValue }: { inputValue: boolean } - ): Promise<{ anotherValue: boolean }> => - Promise.resolve({ anotherValue: inputValue }) -); - -const actions = { actionOne, actionTwo }; -const initialValue = { - actionOne: { value: undefined }, - actionTwo: { anotherValue: false }, -}; - -describe('createActionStateContext', () => { - it('creates a composed `Provider`', async () => { - const [Provider, useAction] = createActionStateContext( - actions, - 'context error' - ); - const Wrapper = (props: { children?: React.ReactNode }) => ( - - ); - - const { result, waitForNextUpdate } = renderHook( - () => useAction({ type: 'actionOne' }), - { - wrapper: Wrapper, - } - ); - - const [initialState, handleAction] = result.current; - expect(initialState.isLoading).toBe(false); - expect(initialState.hasError).toBe(false); - expect(initialState.message).toBeUndefined(); - expect(initialState.data).toBe(initialValue['actionOne']); - - act(() => { - handleAction({ inputValue: 'new value' }); - }); - - const [loadingState] = result.current; - expect(loadingState.isLoading).toBe(true); - expect(loadingState.hasError).toBe(false); - expect(loadingState.message).toBeUndefined(); - expect(loadingState.data).toBe(initialValue['actionOne']); - - await waitForNextUpdate(); - - const [doneState] = result.current; - expect(doneState.isLoading).toBe(false); - expect(doneState.hasError).toBe(false); - expect(doneState.message).toBeUndefined(); - expect(doneState.data).toStrictEqual({ value: 'new value' }); - }); - - it('`useAction` throws when used outside a `Provider`', () => { - const [, useAction] = createActionStateContext(actions, 'context error'); - - const { result } = renderHook(() => useAction({ type: 'actionOne' })); - - expect(result.error?.message).toBe('context error'); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/downloadAction.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/downloadAction.spec.tsx deleted file mode 100644 index ab3d76cada0..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/downloadAction.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as StorageModule from '../../../storage-internal'; -import { downloadAction } from '../downloadAction'; - -const getUrlSpy = jest.spyOn(StorageModule, 'getUrl'); -const config = { - accountId: '012345678901', - bucket: 'bucket', - credentialsProvider: jest.fn(), - region: 'region', -}; -const initialValue = { signedUrl: '' }; - -describe('downloadAction', () => { - beforeEach(() => { - getUrlSpy.mockClear(); - }); - - it('returns the expected output in the happy path', async () => { - getUrlSpy.mockResolvedValueOnce({ - url: new URL('https://docs.amplify.aws/'), - expiresAt: new Date(), - }); - - const { signedUrl } = await downloadAction(initialValue, { - config, - key: 'a_prefix', - }); - - expect(signedUrl).toEqual('https://docs.amplify.aws/'); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationItemsAction.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationItemsAction.spec.ts deleted file mode 100644 index 8d4243cbb4f..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationItemsAction.spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as StorageModule from '../../../storage-internal'; -import { - listLocationItemsAction, - parseResult, -} from '../listLocationItemsAction'; - -let uuid = 0; -Object.defineProperty(globalThis, 'crypto', { - value: { - randomUUID: () => { - uuid++; - return uuid.toString(); - }, - }, -}); - -const listSpy = jest.spyOn(StorageModule, 'list'); -const config = { - bucket: 'bucket', - credentialsProvider: jest.fn(), - region: 'region', -}; - -const options = { delimiter: '/' }; -const prefix = 'a_prefix/'; -const initialValue = { nextToken: undefined, result: [] }; - -const generateMockItems = (size: number): StorageModule.ListOutput['items'] => { - return Array.apply(0, new Array(size)).map((_, index) => ({ - path: `${prefix}key${index}`, - lastModified: new Date(), - size: 1, - })); -}; - -const generateMockSubpaths = ( - size: number -): StorageModule.ListOutput['excludedSubpaths'] => - Array.apply(0, new Array(size)).map((_, index) => { - return `subpath${index}`; - }); - -describe('listLocationItemsAction', () => { - beforeEach(() => { - listSpy.mockClear(); - }); - - it('returns the expected output shape in the happy path', async () => { - listSpy.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' }); - - const { result, nextToken } = await listLocationItemsAction(initialValue, { - config, - prefix, - }); - - expect(result).toHaveLength(0); - expect(nextToken).toBeDefined(); - }); - - it('merges the current action result with the previous action result', async () => { - listSpy - .mockResolvedValueOnce({ - items: generateMockItems(100), - excludedSubpaths: generateMockSubpaths(10), - nextToken: 'first', - }) - .mockResolvedValueOnce({ - items: generateMockItems(100), - excludedSubpaths: generateMockSubpaths(10), - nextToken: 'second', - }); - - const { result, nextToken } = await listLocationItemsAction(initialValue, { - config, - options, - prefix: 'a_prefix', - }); - - expect(result).toHaveLength(110); - expect(nextToken).toBeDefined(); - - const { result: nextResult, nextToken: nextNextToken } = - await listLocationItemsAction( - { nextToken, result }, - { config, options, prefix: 'a_prefix' } - ); - - expect(nextResult).toHaveLength(220); - expect(nextNextToken).not.toBe(nextToken); - expect(nextToken).toBeDefined(); - }); - - it('provides expected `pageSize` to `list` on `refresh`', async () => { - listSpy.mockResolvedValueOnce({ items: [] }); - - const input = { - config, - options: { refresh: true, pageSize: 10 }, - prefix: 'a_prefix', - }; - - await listLocationItemsAction(initialValue, input); - - expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith({ - path: input.prefix, - options: { - bucket: { - bucketName: input.config.bucket, - region: input.config.region, - }, - locationCredentialsProvider: input.config.credentialsProvider, - nextToken: undefined, - pageSize: input.options.pageSize + 1, - subpathStrategy: { delimiter: undefined, strategy: 'include' }, - }, - }); - }); - - it('provides expected `pageSize` to `list` on initial load', async () => { - listSpy.mockResolvedValueOnce({ items: [] }); - - const input = { config, options: { pageSize: 10 }, prefix: 'a_prefix' }; - - await listLocationItemsAction(initialValue, input); - - expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith({ - path: input.prefix, - options: { - bucket: { - bucketName: input.config.bucket, - region: input.config.region, - }, - locationCredentialsProvider: input.config.credentialsProvider, - nextToken: undefined, - pageSize: input.options.pageSize + 1, - subpathStrategy: { delimiter: undefined, strategy: 'include' }, - }, - }); - }); - - it.todo('handles a search action as expected'); - it.todo('handles a paginate action as expected'); -}); - -describe('parseResult', () => { - it('outputs correct list with items: prefix, zero byte folder, object and excludedSubpaths', () => { - const output = { - items: [ - // Current prefix - { path: prefix, lastModified: new Date(), size: 0 }, - // Zero byte subfolder: - { path: `${prefix}Banana/`, lastModified: new Date(), size: 0 }, - // Image file: - { path: `${prefix}Orange.jpg`, lastModified: new Date(), size: 56984 }, - ], - // subfolder with objects in it - excludedSubpaths: [`${prefix}Cloudberry/`], - }; - const result = parseResult(output, prefix); - expect(result).toHaveLength(3); // excludes prefix - const subFolderWithObject = result[0]; - expect(subFolderWithObject.key).toBe(`${prefix}Cloudberry/`); - expect(subFolderWithObject.type).toBe('FOLDER'); - const zeroByteSubFolder = result[1]; - expect(zeroByteSubFolder.key).toBe(`${prefix}Banana/`); - expect(zeroByteSubFolder.type).toBe('FOLDER'); - const file = result[2]; - expect(file.key).toBe(`${prefix}Orange.jpg`); - expect(file.type).toBe('FILE'); - }); - - it('should return empty array for empty zero byte folder', () => { - // empty folders will just show the current prefix as the path - const output = { - items: [{ path: prefix, lastModified: new Date(), size: 0 }], - }; - const result = parseResult(output, prefix); - expect(result).toHaveLength(0); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationsAction.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationsAction.spec.ts deleted file mode 100644 index 6829991daf7..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationsAction.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { - createListLocationsAction, - parseAccessGrantLocation, -} from '../listLocationsAction'; -import { ListLocations, LocationAccess } from '../../../adapters/types'; -import { generateCombinations } from '../../../actions/__testUtils__/permissions'; -import { LocationPermissions } from '../../../actions'; - -Object.defineProperty(globalThis, 'crypto', { - value: { - randomUUID: () => 'identifier!', - }, -}); - -const fakeLocation: LocationAccess = { - scope: 's3://some-bucket/*', - permissions: ['list'], - type: 'BUCKET', -}; - -const getFakeLocation = ( - permissions: LocationAccess['permissions'] = ['list'], - type: LocationAccess['type'] = 'BUCKET' -) => { - if (type === 'PREFIX') { - return { - ...fakeLocation, - scope: 's3://some-bucket/prefix1/*', - permissions, - type, - }; - } else if (type === 'OBJECT') { - return { - ...fakeLocation, - scope: 's3://some-bucket/my.pdf', - permissions, - type, - }; - } - - return { ...fakeLocation, permissions, type }; -}; - -const generateMockLocations = (size: number) => - Array(size).fill(fakeLocation); - -const listLocations: ListLocations = ({ pageSize } = {}) => { - return Promise.resolve({ - locations: generateMockLocations(pageSize!), - nextToken: undefined, - }); -}; - -const mockListLocations = jest.fn(listLocations); - -describe('createListLocationsAction', () => { - beforeEach(() => { - mockListLocations.mockClear(); - }); - it('returns the expected output shape in the happy path', async () => { - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(100), - nextToken: 'next', - }); - const listLocationsAction = createListLocationsAction(mockListLocations); - - const output = await listLocationsAction( - { nextToken: undefined, result: [] }, - { options: { pageSize: 100 } } - ); - - expect(output.result).toHaveLength(100); - expect(output.nextToken).toBe('next'); - }); - - it('merges the current action result with the previous action result', async () => { - mockListLocations - .mockResolvedValueOnce({ - locations: generateMockLocations(100), - nextToken: 'next', - }) - .mockResolvedValueOnce({ - locations: generateMockLocations(100), - nextToken: 'next-oooo', - }); - - const listLocationsAction = createListLocationsAction(mockListLocations); - const { result, nextToken } = await listLocationsAction( - { nextToken: undefined, result: [] }, - { options: { pageSize: 100 } } - ); - - expect(result).toHaveLength(100); - expect(nextToken).toBe('next'); - - const { result: nextResult, nextToken: nextNextToken } = - await listLocationsAction( - { result, nextToken }, - { options: { pageSize: 100 } } - ); - - expect(nextResult).toHaveLength(200); - expect(nextNextToken).not.toBe(nextToken); - expect(nextNextToken).toBe('next-oooo'); - }); - - it('should paginate with default page limit and provide next token', async () => { - // assume, total items: 1500; default page limit: 1000 - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(600), - nextToken: 'next-1', - }); - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(200), - nextToken: 'next-2', - }); - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(200), - nextToken: 'next-3', - }); - - const listLocationsAction = createListLocationsAction(mockListLocations); - - const output = await listLocationsAction( - { nextToken: undefined, result: [] }, - {} - ); - - expect(mockListLocations).toHaveBeenCalledTimes(3); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 1000, - nextToken: undefined, - }); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 400, - nextToken: 'next-1', - }); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 200, - nextToken: 'next-2', - }); - - expect(output.result).toHaveLength(1000); - expect(output.nextToken).toBe('next-3'); - }); - - it('should paginate with input page limit and conclude', async () => { - // assume, total items: 70; requested page limit: 100 - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(50), - nextToken: 'next', - }); - mockListLocations.mockResolvedValueOnce({ - locations: generateMockLocations(20), - nextToken: undefined, - }); - - const listLocationsAction = createListLocationsAction(mockListLocations); - - const output = await listLocationsAction( - { nextToken: undefined, result: [] }, - { options: { pageSize: 100 } } - ); - - expect(mockListLocations).toHaveBeenCalledTimes(2); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 100, - nextToken: undefined, - }); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 50, - nextToken: 'next', - }); - - expect(output.result).toHaveLength(70); - expect(output.nextToken).toBeUndefined(); - }); - - it('should filter out all WRITE permission grants and invalid prefix grants (prefix*)', async () => { - const invalidPrefixLocation: LocationAccess = { - permissions: ['list', 'write'], - scope: 's3://some-bucket/invalid-prefix*', - type: 'PREFIX', - }; - // Expect 9 total combinations and 0 will be filtered out. - // 3 different combinations of ['get', 'list'] permissions * 3 different location types. - const fakeReadLocations = generateCombinations([ - 'get', - 'list', - ] as LocationPermissions) - .map((permissions) => [ - getFakeLocation(permissions, 'OBJECT'), - getFakeLocation(permissions, 'BUCKET'), - getFakeLocation(permissions, 'PREFIX'), - ]) - .flat(); - // Expect 9 total combinations and 9 will be filtered out. - // 3 different combinations of ['write', 'delete'] permissions * 3 different location types. - const fakeWriteLocations = generateCombinations([ - 'write', - 'delete', - ] as LocationPermissions) - .map((permissions) => [ - getFakeLocation(permissions, 'OBJECT'), - getFakeLocation(permissions, 'BUCKET'), - getFakeLocation(permissions, 'PREFIX'), - ]) - .flat(); - // expect 27 total combinations and 0 will be filtered out. - // 3 different combinations of ['get', 'list'] permissions * 3 different combinations of - // ['write', 'delete'] permissions * 3 different location types. - const fakeReadWriteLocations: LocationAccess[] = []; - for (const fakeReadLocation of fakeReadLocations) { - for (const fakeWriteLocation of fakeWriteLocations) { - if (fakeWriteLocation.type === fakeReadLocation.type) { - fakeReadWriteLocations.push({ - permissions: [ - ...fakeReadLocation.permissions, - ...fakeWriteLocation.permissions, - ], - type: fakeReadLocation.type, - scope: fakeWriteLocation.scope, - }); - } - } - } - - mockListLocations.mockResolvedValueOnce({ - locations: [...fakeWriteLocations, ...fakeReadLocations], - nextToken: 'next', - }); - mockListLocations.mockResolvedValueOnce({ - locations: [invalidPrefixLocation, ...fakeReadWriteLocations], - nextToken: undefined, - }); - - const listLocationsAction = createListLocationsAction(mockListLocations); - const output = await listLocationsAction( - { nextToken: undefined, result: [] }, - { options: { pageSize: 36, exclude: 'WRITE' } } - ); - - expect(mockListLocations).toHaveBeenCalledTimes(2); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 36, - nextToken: undefined, - }); - expect(mockListLocations).toHaveBeenCalledWith({ - pageSize: 27, - nextToken: 'next', - }); - - expect(output.result).toStrictEqual( - [...fakeReadLocations, ...fakeReadWriteLocations].map( - parseAccessGrantLocation - ) - ); - expect(output.nextToken).toBeUndefined(); - }); - - it.todo('handles a search action as expected'); - it.todo('handles a refresh action as expected'); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx deleted file mode 100644 index 69dc7832295..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; - -import { AsyncDataAction, DataAction } from '@aws-amplify/ui-react-core'; - -import { - ActionState, - InitialValue, - createActionStateContext, -} from './createActionStateContext'; - -import { createFolderAction } from './createFolderAction'; -import { listLocationItemsAction } from './listLocationItemsAction'; -import { ListLocationsAction } from './listLocationsAction'; -import { LocationsDataProvider } from './locationsData'; -import { useGetActionInput } from '../../providers/configuration'; - -export type ActionsWithConfig = { - [K in keyof DefaultActions]: WithLocationConfig; -}; - -export type DefaultActions = typeof DEFAULT_ACTIONS; -export type WithLocationConfig = T extends AsyncDataAction - ? AsyncDataAction> - : T extends DataAction - ? DataAction> - : never; - -export type UseActionState = T extends - | AsyncDataAction - | DataAction - ? ActionState - : never; - -export const ERROR_MESSAGE = - '`useAction` must be called from within `StorageBrowser.Provider`'; - -export const DEFAULT_ACTIONS = { - CREATE_FOLDER: createFolderAction, - LIST_LOCATION_ITEMS: listLocationItemsAction, -}; - -export const INITIAL_VALUE: InitialValue = { - CREATE_FOLDER: { result: undefined }, - LIST_LOCATION_ITEMS: { result: [], nextToken: undefined }, -}; - -const [ActionStateProvider, useActionState] = createActionStateContext( - DEFAULT_ACTIONS, - ERROR_MESSAGE -); - -export const useAction = ( - type: T -): UseActionState => { - const [state, handle] = useActionState({ type }); - - const getConfig = useGetActionInput(); - - const handleAction = React.useCallback( - (input: Parameters[1]>) => { - const { credentials: credentialsProvider, ...config } = getConfig(); - return handle({ ...input, config: { ...config, credentialsProvider } }); - }, - [getConfig, handle] - ); - - return [state, handleAction] as UseActionState; -}; - -export function ActionProvider({ - children, - listLocationsAction, -}: { - children?: React.ReactNode; - listLocationsAction: ListLocationsAction; -}): React.JSX.Element { - return ( - - - {children} - - - ); -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createActionStateContext.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createActionStateContext.tsx deleted file mode 100644 index a3f19373fcd..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createActionStateContext.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; - -import { - AsyncDataAction, - DataAction, - DataState, - useDataState, -} from '@aws-amplify/ui-react-core'; - -type SyncOrAsyncAction = - | DataAction - | AsyncDataAction; - -type ContextProvider = (props: { - children?: React.ReactNode; -}) => React.JSX.Element; - -export type ActionState = [ - state: DataState, - handleAction: (...input: K[]) => void, -]; - -interface ActionContext { - Context: React.Context | undefined>; - Provider: ContextProvider; -} - -type DataActions = { [key: string]: AsyncDataAction | DataAction }; - -type ActionContexts = { - [K in keyof T]: T[K] extends SyncOrAsyncAction - ? ActionContext - : never; -}; - -export type InitialValue = { - [K in keyof T]: T[K] extends SyncOrAsyncAction ? X : never; -}; - -type ActionsState = { - [K in keyof T]: T[K] extends SyncOrAsyncAction - ? ActionState - : never; -}; - -export type UseAction = (input: { - type: U; -}) => ActionsState[U]; - -export interface ActionProviderProps { - children?: React.ReactNode; - initialValue: T; -} - -type ActionProvider = (props: ActionProviderProps) => React.JSX.Element; - -const InitialValue = React.createContext | undefined>( - undefined -); -function InitialValueProvider>({ - children, - initialValue, -}: ActionProviderProps) { - return ( - - {children} - - ); -} - -function createActionContext( - action: AsyncDataAction | DataAction, - type: string -) { - const ActionContext = React.createContext | undefined>( - undefined - ); - - function Provider(props: { children?: React.ReactNode }) { - const initialValue = React.useContext(InitialValue); - const value = useDataState(action, initialValue?.[type]); - return ; - } - - return { Provider, Context: ActionContext }; -} - -export function createActionProvider( - contexts: ActionContexts -): ActionProvider> { - const ComposedActionProvider = Object.values(contexts).reduce( - (Wrapper, { Provider }) => - function ActionProvider({ - children, - }: { - children?: React.ReactNode; - }): React.JSX.Element { - return ( - - {children} - - ); - }, - ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - <>{children} - ) - ); - - return function ActionProvider({ - children, - ...props - }: ActionProviderProps>): React.JSX.Element { - return ( - - {children} - - ); - }; -} - -export function createUseAction( - contexts: ActionContexts, - errorMessage: string -): UseAction { - return function useAction({ type }: { type: K }) { - const context = React.useContext(contexts[type].Context); - if (!context) { - throw new Error(errorMessage); - } - return context as ActionsState[K]; - }; -} - -const createContexts = (actions: T) => - Object.entries(actions).reduce( - (acc, [type, action]) => ({ - ...acc, - [type]: createActionContext(action, type), - }), - {} as ActionContexts - ); - -export type ActionStateContext = [ - Provider: ActionProvider>, - useAction: UseAction, -]; - -export function createActionStateContext( - actions: T, - errorMessage: string -): ActionStateContext { - const contexts = createContexts(actions); - - return [ - createActionProvider(contexts), - createUseAction(contexts, errorMessage), - ]; -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts deleted file mode 100644 index 5f2f0f59429..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { uploadData } from '../../storage-internal'; - -import { TaskAction, TaskActionInput, TaskActionOutput } from '../types'; - -export interface CreateFolderActionInput - extends Omit< - TaskActionInput<{ reset?: boolean; preventOverwrite?: boolean }>, - 'data' - > {} - -export interface CreateFolderActionInputV2 - extends Omit< - TaskActionInput<{ reset?: boolean; preventOverwrite?: boolean }>, - 'data' - > {} - -export interface CreateFolderActionOutput extends TaskActionOutput {} - -export interface CreateFolderAction - extends TaskAction {} - -export const createFolderAction = async ( - _: CreateFolderActionOutput, - input: CreateFolderActionInput -): Promise => { - const { prefix, config, options } = input; - - if (options?.reset) { - return { result: undefined }; - } - - const { - accountId: expectedBucketOwner, - bucket: bucketName, - credentialsProvider: locationCredentialsProvider, - region, - customEndpoint, - } = typeof config === 'object' ? config : config(); - - let result: CreateFolderActionOutput['result'] | undefined; - - try { - await uploadData({ - path: prefix, - data: '', - options: { - bucket: { bucketName, region }, - expectedBucketOwner, - locationCredentialsProvider, - customEndpoint, - }, - }).result; - result = { key: prefix, status: 'COMPLETE', message: undefined }; - } catch (e) { - result = { key: prefix, status: 'FAILED', message: (e as Error).message }; - } - - return { result }; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/downloadAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/downloadAction.ts deleted file mode 100644 index e0232421426..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/downloadAction.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getUrl } from '../../storage-internal'; - -import { DownloadActionInput, DownloadActionOutput } from '../types'; - -export async function downloadAction( - _: DownloadActionOutput, - input: DownloadActionInput -): Promise { - const { config, key: path } = input ?? {}; - const { - accountId, - bucket: bucketName, - credentialsProvider, - region, - customEndpoint, - } = (typeof config === 'function' ? config() : config) ?? {}; - - const bucket = bucketName && region ? { bucketName, region } : undefined; - - try { - const signedUrl = await getUrl({ - path, - options: { - bucket, - expectedBucketOwner: accountId, - locationCredentialsProvider: credentialsProvider, - validateObjectExistence: true, - contentDisposition: 'attachment', - customEndpoint, - }, - }); - - return { signedUrl: signedUrl.url.toString() }; - } catch (e) { - // @TODO: update UI to let user know that the file no longer exists? - return Promise.reject(e); - } -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts deleted file mode 100644 index d0bd1842070..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { createListLocationsAction } from './listLocationsAction'; -export { downloadAction } from './downloadAction'; -export { ActionProvider, useAction } from './actions'; -export { useLocationsData, LocationsDataState } from './locationsData'; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts deleted file mode 100644 index dc1c53ec7be..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - StorageSubpathStrategy, - list, - ListPaginateInput, - ListOutput, -} from '../../storage-internal'; - -import { - ListActionInput, - ListActionOptions, - ListActionOutput, - LocationItem, -} from '../types'; - -export interface ListLocationItemsActionInput - extends ListActionInput {} - -export interface ListLocationItemsActionOutput - extends ListActionOutput {} - -type ListOutputItem = ListOutput['items'][number]; - -const parseItems = ( - items: ListOutputItem[], - excludedPath: string -): LocationItem[] => - items - .filter(({ path }) => path !== excludedPath) - .map(({ path: key, lastModified, size }) => { - const id = crypto.randomUUID(); - // Mark zero byte files as Folders - if (size === 0 && key.endsWith('/')) { - return { key, id, type: 'FOLDER' }; - } - - return { - key, - id, - lastModified: lastModified!, - size: size!, - type: 'FILE', - }; - }); - -const parseExcludedPaths = (paths: string[] | undefined): LocationItem[] => - paths?.map((key) => ({ key, id: crypto.randomUUID(), type: 'FOLDER' })) ?? []; - -export const parseResult = ( - output: ListOutput, - path: string -): LocationItem[] => [ - ...parseExcludedPaths(output.excludedSubpaths), - ...parseItems(output.items, path), -]; - -export async function listLocationItemsAction( - prevState: ListLocationItemsActionOutput, - input: ListLocationItemsActionInput -): Promise { - const { config, options, prefix: path } = input ?? {}; - const { delimiter, nextToken, pageSize, refresh, reset } = options ?? {}; - - if (reset) { - return { result: [], nextToken: undefined }; - } - - const { - accountId, - bucket: bucketName, - credentialsProvider, - region, - customEndpoint, - } = (typeof config === 'function' ? config() : config) ?? {}; - - const bucket = { bucketName, region }; - const subpathStrategy: StorageSubpathStrategy = { - delimiter, - 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`. - // To mitigate, if a `pageSize` is provided and there are no previous `results` - // or `refresh` is `true` increment the provided `pageSize` by `1` - const hasPrevResults = !!prevState.result.length; - const resolvedPageSize = - pageSize && (!hasPrevResults || refresh) ? pageSize + 1 : pageSize; - - const listInput: ListPaginateInput = { - path, - options: { - bucket, - expectedBucketOwner: accountId, - locationCredentialsProvider: credentialsProvider, - // ignore provided `nextToken` on `refresh` - nextToken: refresh ? undefined : nextToken, - pageSize: resolvedPageSize, - subpathStrategy, - customEndpoint, - }, - }; - - const output = await list(listInput); - - const result = [ - ...(refresh ? [] : prevState.result), - ...parseResult(output, path), - ]; - - return { result, nextToken: output.nextToken }; -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts deleted file mode 100644 index b6b38035356..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LocationData } from '../../actions'; -import { - ListLocations, - ListLocationsOutput, - LocationAccess, -} from '../../adapters/types'; -import { Permission } from '../../storage-internal'; - -import { ListActionInput, ListActionOptions, ListActionOutput } from '../types'; -import { toAccessGrantPermission } from '../../adapters/permissionParsers'; -import { parseAccessGrantLocationScope } from '../../actions/handlers'; - -const PAGE_SIZE = 1000; - -export interface ListLocationsActionOptions - extends Omit, 'delimiter'> {} - -export interface ListLocationsActionInput - extends Omit< - ListActionInput>, - 'prefix' | 'config' - > {} - -export interface ListLocationsActionOutput - extends ListActionOutput {} - -export type ListLocationsAction = ( - prevState: ListLocationsActionOutput, - input: ListLocationsActionInput -) => Promise; - -const shouldExclude = ( - permission: T, - exclude?: T | T[] -) => - !exclude - ? false - : typeof exclude === 'string' - ? exclude === permission - : exclude.includes(permission); - -// FIXME: temporary fix until we use the list action in actions folder -export const parseAccessGrantLocation = ( - location: LocationAccess -): LocationData => { - const { scope, type } = location; - if (!scope.startsWith('s3://')) { - throw new Error(`Invalid scope: ${scope}`); - } - const id = crypto.randomUUID(); - const { bucket, prefix } = parseAccessGrantLocationScope(scope, type); - return { id, ...location, bucket, prefix }; -}; - -export const createListLocationsAction = ( - listLocations: ListLocations -): ListLocationsAction => - async function listLocationsAction(prevState, input) { - const { options } = input ?? {}; - const { - exclude, - nextToken, - pageSize = PAGE_SIZE, - refresh, - reset, - } = options ?? {}; - - if (reset) { - return { result: [], nextToken: undefined }; - } - - let locationsResult: ListLocationsOutput['locations'] = []; - let nextNextToken: ListLocationsOutput['nextToken'] = refresh - ? undefined - : nextToken; - - do { - const remainingPageSize = pageSize - locationsResult.length; - - const output = await listLocations({ - nextToken: nextNextToken, - pageSize: remainingPageSize, - }); - - nextNextToken = output.nextToken; - locationsResult = [ - ...locationsResult, - ...output.locations.filter( - ({ permissions, type, scope }) => - !( - shouldExclude(toAccessGrantPermission(permissions), exclude) || - // filter out PREFIX/BUCKET types with scopes that don't end with /*, e.g. /prefix* - (type !== 'OBJECT' && !scope.endsWith('/*')) - ) - ), - ]; - } while (nextNextToken && locationsResult.length < pageSize); - - const nextLocations = locationsResult.map(parseAccessGrantLocation); - - const result = refresh - ? nextLocations - : [...(prevState.result ?? []), ...nextLocations]; - - return { result, nextToken: nextNextToken }; - }; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx deleted file mode 100644 index 7efac1819ab..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; - -import { useDataState } from '@aws-amplify/ui-react-core'; - -import { ActionState } from './createActionStateContext'; -import { - ListLocationsAction, - ListLocationsActionInput, - ListLocationsActionOutput, -} from './listLocationsAction'; - -export type LocationsDataState = ActionState< - ListLocationsActionOutput, - ListLocationsActionInput ->; - -const INITIAL_VALUE = { result: [], nextToken: undefined }; -const ERROR_MESSAGE = - '`useLocationsData` must be called from with `LocationsDataProvider'; - -const LocationsDataContext = React.createContext< - LocationsDataState | undefined ->(undefined); - -export function LocationsDataProvider({ - children, - listLocationsAction, -}: { - children?: React.ReactNode; - listLocationsAction: ListLocationsAction; -}): React.JSX.Element { - const value = useDataState(listLocationsAction, INITIAL_VALUE); - - return ( - - {children} - - ); -} - -export function useLocationsData(): LocationsDataState { - const context = React.useContext(LocationsDataContext); - if (!context) { - throw new Error(ERROR_MESSAGE); - } - - return context; -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/createTempActionsProvider.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/createTempActionsProvider.tsx deleted file mode 100644 index d876d4eed86..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/createTempActionsProvider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { createContextUtilities } from '@aws-amplify/ui-react-core'; - -import { ActionProvider, createListLocationsAction } from './actions'; -import { LocationActions } from './locationActions'; -import { ListLocations } from '../adapters/types'; - -export const { useTempActions, TempActionsContext } = createContextUtilities< - LocationActions, - 'TempActions' ->({ - contextName: 'TempActions', - errorMessage: 'Call to useTempActions must be wrapped in TempActionsProvider', -}); - -function TempActionsProvider({ - actions, - children, -}: { - actions: LocationActions; - children?: React.ReactNode; -}): React.JSX.Element { - return ( - - {children} - - ); -} - -interface Config { - accountId?: string; - listLocations: ListLocations; - region: string; -} - -interface CreateTempActionsProviderInput { - actions: LocationActions; - config: Config; -} - -export function createTempActionsProvider({ - actions, - config, -}: CreateTempActionsProviderInput): (props: { - children?: React.ReactNode; -}) => React.JSX.Element { - const listLocationsAction = createListLocationsAction(config.listLocations); - - function Provider({ - children, - }: { - children?: React.ReactNode; - }): React.JSX.Element { - return ( - - - {children} - - - ); - } - - return Provider; -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap deleted file mode 100644 index 8fe03d3e083..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap +++ /dev/null @@ -1,58 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`default actions contains the expected properties 1`] = ` -{ - "COPY_FILES": { - "options": { - "disable": [Function], - "displayName": "Copy files", - "hide": [Function], - "icon": , - }, - }, - "CREATE_FOLDER": { - "options": { - "disable": [Function], - "displayName": "Create folder", - "hide": [Function], - "icon": , - }, - }, - "DELETE_FILES": { - "options": { - "disable": [Function], - "displayName": "Delete files", - "hide": [Function], - "icon": , - }, - }, - "UPLOAD_FILES": { - "options": { - "disable": [Function], - "displayName": "Upload files", - "hide": [Function], - "icon": , - "selectionData": "file", - }, - }, - "UPLOAD_FOLDER": { - "options": { - "disable": [Function], - "displayName": "Upload folder", - "hide": [Function], - "icon": , - "selectionData": "folder", - }, - }, -} -`; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/defaults.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/defaults.spec.ts deleted file mode 100644 index 2c65c806773..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/defaults.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { locationActionsDefault, OPTIONS_DEFAULT } from '../defaults'; -import { LocationActionOptions } from '../types'; - -describe('default actions', () => { - it('contains the expected properties', () => { - expect(locationActionsDefault).toMatchSnapshot(); - }); - - it('has the expected behavior for the values of default options', () => { - const disable = OPTIONS_DEFAULT?.disable as Exclude< - LocationActionOptions['disable'], - undefined | boolean - >; - - expect(typeof disable).toBe('function'); - expect(disable([])).toBe(false); - expect(disable([{ type: 'FOLDER', key: 'something', id: 'an-id' }])).toBe( - true - ); - - const hide = OPTIONS_DEFAULT?.hide as Exclude< - LocationActionOptions['hide'], - undefined | boolean - >; - - expect(typeof hide).toBe('function'); - expect(hide('READWRITE')).toBe(false); - expect(hide('WRITE')).toBe(false); - expect(hide('READ')).toBe(true); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx deleted file mode 100644 index 401494f45a4..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import { IconElement } from '../../context/elements/IconElement'; - -import { LocationAction } from './types'; -import { displayText } from '../../displayText/en'; -const { - createFolderTitle, - deleteFilesTitle, - uploadFilesTitle, - uploadFolderTitle, -} = displayText; - -export const OPTIONS_DEFAULT: LocationAction['options'] = { - disable: (items) => !!items.length, - hide: (permission) => permission === 'READ', -}; - -const COPY_FILES: LocationAction = { - options: { - ...OPTIONS_DEFAULT, - disable: (selectedItems) => selectedItems.length < 1, - displayName: 'Copy files', - icon: , - }, -}; - -const CREATE_FOLDER: LocationAction = { - options: { - ...OPTIONS_DEFAULT, - displayName: createFolderTitle, - icon: , - }, -}; - -const DELETE_FILES: LocationAction = { - options: { - ...OPTIONS_DEFAULT, - disable: (selectedItems) => selectedItems.length < 1, - displayName: deleteFilesTitle, - icon: , - }, -}; - -const UPLOAD_FOLDER: LocationAction = { - options: { - ...OPTIONS_DEFAULT, - displayName: uploadFolderTitle, - icon: , - selectionData: 'folder', - }, -}; - -const UPLOAD_FILES: LocationAction = { - options: { - ...OPTIONS_DEFAULT, - displayName: uploadFilesTitle, - icon: , - selectionData: 'file', - }, -}; - -export const locationActionsDefault = { - COPY_FILES, - CREATE_FOLDER, - DELETE_FILES, - UPLOAD_FILES, - UPLOAD_FOLDER, -}; - -export type LocationActionsDefault = typeof locationActionsDefault; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/index.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/index.ts deleted file mode 100644 index 7a09fcbd040..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LocationActionsDefault, locationActionsDefault } from './defaults'; -export { LocationAction, LocationActions, LocationActionsState } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts deleted file mode 100644 index f37128b391e..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -import { Permission } from '../../storage-internal'; -import { PrefixTaskAction, LocationItem } from '../types'; - -/** - * open native OS file picker with associated selection type on action select - */ -export type SelectionType = - | ('file' | 'folder') - | ['file' | 'folder', ...string[]]; - -export interface LocationActionOptions { - /** - * disable menu - */ - disable?: boolean | ((selectedItems: LocationItem[]) => boolean); - displayName?: string; - hide?: boolean | ((permission: T) => boolean); - icon?: React.ReactNode | string; - selectionData?: SelectionType; -} - -interface _LocationAction { - readonly handler: PrefixTaskAction; - options?: LocationActionOptions; -} -export interface LocationAction - extends Omit<_LocationAction, 'handler'> {} - -export interface LocationActions { - [key: string]: Omit, 'handler'>; -} - -export type LocationActionsAction = - | { type: 'CLEAR' } - | { type: 'SET_ACTION'; actionType: T } - | { type: 'TOGGLE_SELECTED_ITEM'; item: LocationItem } - | { type: 'TOGGLE_SELECTED_ITEMS'; items?: LocationItem[] }; - -export interface LocationActionsState { - actions: LocationActions; - selected: { - type: T | undefined; - items: LocationItem[] | undefined; - }; -} - -export type LocationActionsStateContext = [ - state: LocationActionsState, - handleUpdateState: (action: LocationActionsAction) => void, -]; - -export interface LocationActionsProviderProps { - actions?: LocationActions; - actionType?: string; - children?: React.ReactNode; -} diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts deleted file mode 100644 index 1e456e2893f..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { LocationCredentialsProvider } from '../storage-internal'; - -interface FolderItem { - key: string; - id: string; - type: 'FOLDER'; -} - -interface FileItem { - key: string; - id: string; - lastModified: Date; - size: number; - type: 'FILE'; -} - -export type LocationItem = FileItem | FolderItem; - -export interface LocationConfig { - accountId?: string; - bucket: string; - credentialsProvider: LocationCredentialsProvider; - customEndpoint?: string; - region: string; -} - -export type TaskStatus = - | 'INITIAL' - | 'QUEUED' - | 'PENDING' - | 'FAILED' - | 'COMPLETE'; - -export interface TaskResult { - key: string; - message: string | undefined; - status: T; -} - -export interface TaskActionInput { - prefix: string; - config: LocationConfig | (() => LocationConfig); - data: File; - options?: T; -} - -export interface TaskActionOutput { - result: T | undefined; -} - -export type PrefixTaskAction = ( - input: T -) => Promise; - -export type TaskAction = ( - input: T -) => Promise; - -export interface ListActionOptions { - delimiter?: string; - exclude?: T | T[]; - nextToken?: string; - pageSize?: number; - refresh?: boolean; - reset?: boolean; -} - -export interface ListActionInput { - prefix: string; - config: (() => LocationConfig) | LocationConfig; - options?: K; -} - -export interface ListActionOutput { - result: T[]; - nextToken: string | undefined; -} - -export interface DownloadActionInput { - key: string; - config?: (() => LocationConfig) | LocationConfig; -} - -export interface DownloadActionOutput { - signedUrl: string; -} diff --git a/packages/react-storage/src/components/StorageBrowser/index.ts b/packages/react-storage/src/components/StorageBrowser/index.ts index 825a2c1fb4e..ce7bae3a708 100644 --- a/packages/react-storage/src/components/StorageBrowser/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/index.ts @@ -1,13 +1,9 @@ export { StorageBrowserElements, elementsDefault } from './context/elements'; -export { - createStorageBrowser, - CreateStorageBrowserInput, - StorageBrowserComponent, - ResolvedStorageBrowserElements, -} from './createStorageBrowser'; +export { createStorageBrowser } from './createStorageBrowser'; export { createAmplifyAuthAdapter, createManagedAuthAdapter, CreateManagedAuthAdapterInput, StorageBrowserAuthAdapter, } from './adapters'; +export { CreateStorageBrowserInput, StorageBrowserType } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/types.ts b/packages/react-storage/src/components/StorageBrowser/types.ts new file mode 100644 index 00000000000..57746bfdf4b --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/types.ts @@ -0,0 +1,62 @@ +import React from 'react'; + +import { ListLocations } from './actions'; + +import { StorageBrowserElements } from './context/elements'; +import { Components } from './ComponentsProvider'; + +import { RegisterAuthListener, StoreProviderProps } from './providers'; + +import { + CopyViewType, + CreateFolderViewType, + DeleteViewType, + UploadViewType, + Views, +} from './views'; + +import { GetLocationCredentials } from './credentials/types'; +import { StorageBrowserDisplayText } from './displayText'; + +export interface Config { + accountId?: string; + customEndpoint?: string; + getLocationCredentials: GetLocationCredentials; + listLocations: ListLocations; + registerAuthListener: RegisterAuthListener; + region: string; +} + +export interface CreateStorageBrowserInput { + // to be updated + actions?: never; + config: Config; + components?: Components; + elements?: Partial; +} + +export interface StorageBrowserProps { + views?: Views; + displayText?: StorageBrowserDisplayText; +} + +export interface StorageBrowserType extends Views { + ( + props: StorageBrowserProps & Exclude + ): React.JSX.Element; + displayName: string; + Provider: (props: StorageBrowserProviderProps) => React.JSX.Element; + CopyView: CopyViewType; + CreateFolderView: CreateFolderViewType; + DeleteView: DeleteViewType; + UploadView: UploadViewType; +} + +export type ActionViewName = Exclude< + T, + 'listLocationItems' | 'listLocations' +>; + +export interface StorageBrowserProviderProps extends StoreProviderProps { + displayText?: StorageBrowserDisplayText; +} diff --git a/packages/react-storage/src/components/StorageBrowser/validators/index.ts b/packages/react-storage/src/components/StorageBrowser/validators/index.ts index 1c58c4de74d..669e5f32a15 100644 --- a/packages/react-storage/src/components/StorageBrowser/validators/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/validators/index.ts @@ -1,3 +1,4 @@ export { assertLocationData } from './assertLocationData'; export { assertRegisterAuthListener } from './assertRegisterAuthListener'; export { assertAccountId } from './assertAccountId'; +export { isFileTooBig } from './isFileTooBig'; diff --git a/packages/react-storage/src/components/StorageBrowser/validators/isFileTooBig.ts b/packages/react-storage/src/components/StorageBrowser/validators/isFileTooBig.ts new file mode 100644 index 00000000000..1a7869ca34e --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/validators/isFileTooBig.ts @@ -0,0 +1,4 @@ +export const UPLOAD_FILE_SIZE_LIMIT = 160 * 1000 * 1000 * 1000; + +export const isFileTooBig = (file: File): boolean => + file.size > UPLOAD_FILE_SIZE_LIMIT; diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/Controls.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/Controls.tsx index 2d70cca498b..33d132b22d5 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/Controls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/Controls.tsx @@ -1,12 +1,9 @@ -import { EmptyMessageControl } from './EmptyMessage'; import { MessageControl } from './Message'; export interface Controls { - EmptyMessage: typeof EmptyMessageControl; Message: typeof MessageControl; } export const Controls: Controls = { - EmptyMessage: EmptyMessageControl, Message: MessageControl, }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/EmptyMessage.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/EmptyMessage.tsx deleted file mode 100644 index 50dafddb588..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/EmptyMessage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { ViewElement } from '../../context/elements/definitions'; -import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../../constants'; - -interface EmptyMessageControlProps { - children?: React.ReactNode; -} - -export const EmptyMessageControl = ({ - children, -}: EmptyMessageControlProps): React.JSX.Element => ( - - {children} - -); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/EmptyMessage.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/EmptyMessage.spec.tsx deleted file mode 100644 index 9d02cffedf1..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/EmptyMessage.spec.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { EmptyMessageControl } from '../EmptyMessage'; - -describe('EmptyMessageControl', () => { - it('renders the EmptyMessageControl', () => { - const message = 'No items to show.'; - render({message}); - - const title = screen.getByText(message); - expect(title).toBeInTheDocument(); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/index.ts b/packages/react-storage/src/components/StorageBrowser/views/Controls/index.ts index 1085fc603f8..fee01746c71 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/index.ts @@ -1,3 +1,2 @@ -export { EmptyMessageControl } from './EmptyMessage'; export { MessageControl } from './Message'; export { Controls } from './Controls'; 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 80c799ec890..df2f28c7b14 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 @@ -17,20 +17,17 @@ import { SearchControl } from '../../../controls/SearchControl'; import { StatusDisplayControl } from '../../../controls/StatusDisplayControl'; import { TitleControl } from '../../../controls/TitleControl'; -import { resolveClassName } from '../../utils'; - import { CopyViewProvider } from './CopyViewProvider'; -import { DestinationControl } from './DestinationControl'; import { FoldersMessageControl } from './FoldersMessageControl'; import { FoldersPaginationControl } from './FoldersPaginationControl'; import { FoldersTableControl } from './FoldersTableControl'; -import { CopyViewProps } from './types'; +import { ActionDestinationControl } from '../../../controls/ActionDestinationControl'; + +import { CopyViewType } from './types'; import { useCopyView } from './useCopyView'; +import { classNames } from '@aws-amplify/ui'; -export function CopyView({ - className, - ...props -}: CopyViewProps): React.JSX.Element { +export const CopyView: CopyViewType = ({ className, ...props }) => { const state = useCopyView(props); const { isProcessing, @@ -43,7 +40,7 @@ export function CopyView({ }, [onInitialize]); return ( -
+ @@ -55,7 +52,7 @@ export function CopyView({ {isProcessing || isProcessingComplete ? null : ( <> )} - + {!(isProcessing || isProcessingComplete) ? null : ( )} @@ -87,24 +84,24 @@ export function CopyView({ -
+ ); -} +}; CopyView.displayName = 'CopyView'; CopyView.Provider = CopyViewProvider; CopyView.Cancel = ActionCancelControl; -CopyView.Destination = DestinationControl; +CopyView.Destination = ActionDestinationControl; CopyView.Exit = ActionExitControl; -CopyView.Folders = FoldersTableControl; -CopyView.FoldersLoading = LoadingIndicatorControl; +CopyView.FoldersLoadingIndicator = LoadingIndicatorControl; CopyView.FoldersMessage = FoldersMessageControl; CopyView.FoldersPagination = FoldersPaginationControl; CopyView.FoldersSearch = SearchControl; +CopyView.FoldersTable = FoldersTableControl; CopyView.Message = MessageControl; CopyView.Start = ActionStartControl; CopyView.Statuses = StatusDisplayControl; -CopyView.Tasks = DataTableControl; +CopyView.TasksTable = DataTableControl; CopyView.Title = TitleControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyViewProvider.tsx index 442b07ba1fb..c9982001f58 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyViewProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/CopyViewProvider.tsx @@ -5,7 +5,6 @@ import { useDisplayText } from '../../../displayText'; import { getActionViewTableData } from '../getActionViewTableData'; -import { DestinationProvider } from './DestinationControl'; import { FoldersMessageProvider } from './FoldersMessageControl'; import { FoldersPaginationProvider } from './FoldersPaginationControl'; import { FoldersTableProvider } from './FoldersTableControl'; @@ -23,7 +22,6 @@ export function CopyViewProvider({ actionExitLabel, actionStartLabel, getActionCompleteMessage, - getListFoldersResultsMessage, overwriteWarningMessage, searchPlaceholder, searchSubmitLabel, @@ -35,18 +33,18 @@ export function CopyViewProvider({ } = displayText; const { - destinationList, + destination, folders, isProcessing, isProcessingComplete, location, + statusCounts, + tasks, onActionCancel, onActionExit, onActionStart, - onDestinationChange, + onSelectDestination, onTaskRemove, - statusCounts, - tasks, } = props; const { @@ -55,19 +53,17 @@ export function CopyViewProvider({ hasError: hasFoldersError, message: foldersErrorMessage, query, - hasInitialized: hasFoldersInitialized, - onQuery, - onSearchClear, - onSearch, - onSelect, - onPaginate, isLoading, page, pageItems, + onPaginate, + onQuery, + onSearchClear, + onSearch, + onSelectFolder, } = folders; - const { current, key: locationKey } = location ?? {}; - const { bucket } = current ?? {}; + const { key: locationKey } = location ?? {}; const tableData = getActionViewTableData({ tasks, @@ -78,7 +74,7 @@ export function CopyViewProvider({ }); const isActionStartDisabled = - isProcessing || isProcessingComplete || destinationList.length === 0; + isProcessing || isProcessingComplete || !destination?.current; const isActionCancelDisabled = !isProcessing || isProcessingComplete; @@ -89,22 +85,16 @@ export function CopyViewProvider({ } : getActionCompleteMessage({ counts: statusCounts }); - const foldersMessage = !hasFoldersInitialized - ? undefined - : getListFoldersResultsMessage({ - hasError: hasFoldersError, - message: foldersErrorMessage, - folders: pageItems, - query, - }); - return ( - - - - - {children} - - - - + + {children} + + + ); } diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/DestinationControl.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/DestinationControl.tsx deleted file mode 100644 index f30308adb8e..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/DestinationControl.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import { createContextUtilities } from '@aws-amplify/ui-react-core'; - -import { Breadcrumb } from '../../../components/BreadcrumbNavigation'; -import { DescriptionList } from '../../../components/DescriptionList'; - -export interface DestinationProps { - bucket?: string; - label?: string; - isDisabled?: boolean; - destinationList?: string[]; - onDestinationChange?: (destination: string[]) => void; -} - -const defaultValue: DestinationProps = {}; -export const { useDestination, DestinationProvider } = createContextUtilities({ - contextName: 'Destination', - defaultValue, -}); - -/** - * Temporary `Destination` for `CopyView` only - */ -export function DestinationControl(): React.JSX.Element { - const { bucket, destinationList, isDisabled, label, onDestinationChange } = - useDestination(); - - const handleNavigatePath = (index: number) => { - const newPath = destinationList?.slice(0, index + 1); - if (!newPath) return; - - onDestinationChange?.(newPath); - }; - - return ( - - {destinationList.map((key, index) => ( - handleNavigatePath(index) - } - // If bucket level access, show bucket name as root breadcrumb - name={key === '' ? bucket : key.replace('/', '')} - /> - ))} - - ) : ( - '-' - ), - }, - ]} - /> - ); -} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersMessageControl.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersMessageControl.tsx index 9290ac8204b..4fc5eb1f0da 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersMessageControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersMessageControl.tsx @@ -2,22 +2,39 @@ import React from 'react'; import { createContextUtilities } from '@aws-amplify/ui-react-core'; import { ControlsContextProvider } from '../../../controls/context'; -import { MessageProps } from '../../../composables/Message'; + import { MessageControl } from '../../../controls/MessageControl'; import { ViewElement } from '../../../context/elements'; import { STORAGE_BROWSER_BLOCK } from '../../../constants'; +import { FolderData } from '../../../actions'; +import { useDisplayText } from '../../../displayText'; -export interface FoldersMessageProps extends MessageProps {} +export interface FoldersMessageProps { + hasError?: boolean; + message?: string; + folders?: FolderData[]; + query?: string; +} const defaultValue: FoldersMessageProps = {}; export const { useFoldersMessage, FoldersMessageProvider } = createContextUtilities({ contextName: 'FoldersMessage', defaultValue }); export const FoldersMessageControl = (): React.JSX.Element => { - const message = useFoldersMessage(); + const { + CopyView: { getListFoldersResultsMessage }, + } = useDisplayText(); + const { hasError, folders, message, query } = useFoldersMessage(); + + const messageContent = getListFoldersResultsMessage({ + hasError, + folders, + message, + query, + }); return ( - + diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersTableControl.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersTableControl.tsx index 90cea25a3a2..02b6b17a66e 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersTableControl.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/FoldersTableControl.tsx @@ -5,11 +5,13 @@ import { FolderData } from '../../../actions'; import { ControlsContextProvider } from '../../../controls/context'; import { DataTableControl } from '../../../controls/DataTableControl'; -import { getDestinationPickerTableData } from './utils'; +import { getDestinationPickerTableData } from './getDestinationPickerTableData'; +import { LocationState } from '../../../providers/store/location'; export interface FoldersTableProps { + destination?: LocationState; folders?: FolderData[]; - onSelect?: (value: string) => void; + onSelectFolder?: (id: string, folderLocationPath: string) => void; } const defaultValue: FoldersTableProps = {}; @@ -18,11 +20,15 @@ export const { useFoldersTable, FoldersTableProvider } = createContextUtilities( ); export const FoldersTableControl = (): React.JSX.Element => { - const { folders, onSelect } = useFoldersTable(); + const { destination, folders, onSelectFolder } = useFoldersTable(); + + const { current, path = '' } = destination ?? {}; const tableData = getDestinationPickerTableData({ + prefix: current?.prefix ?? '', + path, folders, - onSelect, + onSelectFolder, }); return ( 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 6b08b28d4b4..1bf813b2d4f 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 @@ -15,6 +15,11 @@ jest.mock('../CopyViewProvider', () => ({ jest.mock('../../../../controls/ActionCancelControl', () => ({ ActionCancelControl: () =>
, })); +jest.mock('../../../../controls/ActionDestinationControl', () => ({ + ActionDestinationControl: () => ( +
+ ), +})); jest.mock('../../../../controls/ActionExitControl', () => ({ ActionExitControl: () =>
, })); @@ -39,10 +44,6 @@ jest.mock('../../../../controls/StatusDisplayControl', () => ({ jest.mock('../../../../controls/TitleControl', () => ({ TitleControl: () =>
, })); - -jest.mock('../DestinationControl', () => ({ - DestinationControl: () =>
, -})); jest.mock('../FoldersMessageControl', () => ({ FoldersMessageControl: () =>
, })); @@ -75,15 +76,15 @@ describe('CopyView', () => { expect(CopyView.Cancel).toBeDefined(); expect(CopyView.Destination).toBeDefined(); expect(CopyView.Exit).toBeDefined(); - expect(CopyView.Folders).toBeDefined(); - expect(CopyView.FoldersLoading).toBeDefined(); + expect(CopyView.FoldersLoadingIndicator).toBeDefined(); expect(CopyView.FoldersMessage).toBeDefined(); expect(CopyView.FoldersPagination).toBeDefined(); expect(CopyView.FoldersSearch).toBeDefined(); + expect(CopyView.FoldersTable).toBeDefined(); expect(CopyView.Message).toBeDefined(); expect(CopyView.Start).toBeDefined(); expect(CopyView.Statuses).toBeDefined(); - expect(CopyView.Tasks).toBeDefined(); + expect(CopyView.TasksTable).toBeDefined(); expect(CopyView.Title).toBeDefined(); }); @@ -95,7 +96,9 @@ describe('CopyView', () => { expect(screen.queryByTestId('ActionExitControl')).toBeInTheDocument(); expect(screen.queryByTestId('ActionStartControl')).toBeInTheDocument(); expect(screen.queryByTestId('DataTableControl')).toBeInTheDocument(); - expect(screen.queryByTestId('DestinationControl')).toBeInTheDocument(); + expect( + screen.queryByTestId('ActionDestinationControl') + ).toBeInTheDocument(); expect(screen.queryByTestId('FoldersMessageControl')).toBeInTheDocument(); expect( screen.queryByTestId('FoldersPaginationControl') @@ -126,7 +129,9 @@ describe('CopyView', () => { expect(screen.queryByTestId('ActionExitControl')).toBeInTheDocument(); expect(screen.queryByTestId('ActionStartControl')).toBeInTheDocument(); expect(screen.queryByTestId('DataTableControl')).toBeInTheDocument(); - expect(screen.queryByTestId('DestinationControl')).toBeInTheDocument(); + expect( + screen.queryByTestId('ActionDestinationControl') + ).toBeInTheDocument(); expect(screen.queryByTestId('MessageControl')).toBeInTheDocument(); expect(screen.queryByTestId('StatusDisplayControl')).toBeInTheDocument(); @@ -160,7 +165,9 @@ describe('CopyView', () => { expect(screen.queryByTestId('ActionExitControl')).toBeInTheDocument(); expect(screen.queryByTestId('ActionStartControl')).toBeInTheDocument(); expect(screen.queryByTestId('DataTableControl')).toBeInTheDocument(); - expect(screen.queryByTestId('DestinationControl')).toBeInTheDocument(); + expect( + screen.queryByTestId('ActionDestinationControl') + ).toBeInTheDocument(); expect(screen.queryByTestId('MessageControl')).toBeInTheDocument(); expect(screen.queryByTestId('StatusDisplayControl')).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 028971cbd2b..060c71eba30 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 @@ -73,8 +73,7 @@ const location: LocationData = { const onActionCancel = jest.fn(); const onActionExit = jest.fn(); const onActionStart = jest.fn(); - -const onDestinationChange = jest.fn(); +const onSelectDestination = jest.fn(); const onTaskRemove = jest.fn(); const actionCallbacks = { @@ -85,11 +84,9 @@ const actionCallbacks = { const defaultViewState: CopyViewState = { ...actionCallbacks, - onTaskRemove, - destinationList: [], + destination: { current: undefined, path: '', key: '' }, folders: { hasError: false, - hasInitialized: false, hasNextPage: false, highestPageVisited: 1, page: 1, @@ -102,14 +99,15 @@ const defaultViewState: CopyViewState = { onSearch: jest.fn(), onInitialize: jest.fn(), onSearchClear: jest.fn(), - onSelect: jest.fn(), + onSelectFolder: jest.fn(), }, isProcessingComplete: false, isProcessing: false, location: { current: location, path: '', key: `itsa-prefix/` }, - onDestinationChange, statusCounts: { ...INITIAL_STATUS_COUNTS, QUEUED: 1, TOTAL: 1 }, tasks: [taskOne], + onTaskRemove, + onSelectDestination, }; describe('CopyViewProvider', () => { @@ -125,6 +123,7 @@ describe('CopyViewProvider', () => { isActionCancelDisabled: true, isActionStartDisabled: true, isActionExitDisabled: false, + isActionDestinationNavigable: true, statusCounts: defaultViewState.statusCounts, }, ...actionCallbacks, @@ -134,7 +133,7 @@ describe('CopyViewProvider', () => { it('provides the expected values to `ControlsContextProvider` on destination change', () => { const preprocessingViewState: CopyViewState = { ...defaultViewState, - destinationList: ['some-prefix'], + destination: { current: location, path: '', key: `itsa-prefix/` }, }; render(); @@ -146,6 +145,7 @@ describe('CopyViewProvider', () => { isActionCancelDisabled: true, isActionStartDisabled: false, isActionExitDisabled: false, + isActionDestinationNavigable: true, statusCounts: defaultViewState.statusCounts, }, ...actionCallbacks, @@ -155,7 +155,7 @@ describe('CopyViewProvider', () => { it('provides the expected values to `ControlsContextProvider` while processing', () => { const processingViewState: CopyViewState = { ...defaultViewState, - destinationList: ['some-prefix'], + destination: { current: location, path: '', key: `itsa-prefix/` }, isProcessing: true, tasks: [{ ...taskOne, status: 'PENDING' }], statusCounts: { ...defaultViewState.statusCounts, PENDING: 1, QUEUED: 0 }, @@ -170,6 +170,7 @@ describe('CopyViewProvider', () => { isActionCancelDisabled: false, isActionStartDisabled: true, isActionExitDisabled: true, + isActionDestinationNavigable: false, statusCounts: processingViewState.statusCounts, }, ...actionCallbacks, @@ -179,7 +180,7 @@ describe('CopyViewProvider', () => { it('provides the expected values to `ControlsContextProvider` post processing in the happy path', () => { const postProcessingViewState: CopyViewState = { ...defaultViewState, - destinationList: ['some-prefix'], + destination: { current: location, path: '', key: `itsa-prefix/` }, isProcessingComplete: true, tasks: [{ ...taskOne, status: 'COMPLETE' }], statusCounts: { @@ -198,6 +199,7 @@ describe('CopyViewProvider', () => { isActionCancelDisabled: true, isActionStartDisabled: true, isActionExitDisabled: false, + isActionDestinationNavigable: false, statusCounts: postProcessingViewState.statusCounts, }, ...actionCallbacks, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useFolders.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useFolders.spec.ts.snap index 69e1301b5d9..c22f4564572 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useFolders.spec.ts.snap +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/__snapshots__/useFolders.spec.ts.snap @@ -3,7 +3,6 @@ exports[`useFolders should return the correct initial state 1`] = ` { "hasError": false, - "hasInitialized": false, "hasNextPage": true, "highestPageVisited": 1, "isLoading": false, @@ -13,7 +12,7 @@ exports[`useFolders should return the correct initial state 1`] = ` "onQuery": [Function], "onSearch": [Function], "onSearchClear": [Function], - "onSelect": [Function], + "onSelectFolder": [Function], "page": 1, "pageItems": [ { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/getDestinationPickerTableData.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/getDestinationPickerTableData.spec.ts new file mode 100644 index 00000000000..f6c98a41652 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/getDestinationPickerTableData.spec.ts @@ -0,0 +1,38 @@ +import { getDestinationPickerTableData } from '../getDestinationPickerTableData'; + +describe('getDestinationPickerTableData', () => { + const prefix = 'test/prefix/'; + const path = 'path/'; + const key = `${prefix}${path}folder/`; + it('returns the expected values', () => { + const output = getDestinationPickerTableData({ + prefix, + path, + folders: [{ key, id: 'id' }], + onSelectFolder: jest.fn(), + }); + + expect(output).toStrictEqual({ + headers: [ + { content: { label: 'Folder name' }, key: 'name', type: 'sort' }, + ], + rows: [ + { + content: [ + { + content: { + icon: 'folder', + ariaLabel: 'folder/', + label: 'folder/', + onClick: expect.any(Function), + }, + key: 'name-id', + type: 'button', + }, + ], + key: 'id', + }, + ], + }); + }); +}); 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 bf130b5766c..d6c902850f6 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 @@ -12,22 +12,24 @@ describe('useCopyView', () => { const mockDispatchStoreAction = jest.fn(); const mockCancel = jest.fn(); + const location = { + current: { + prefix: 'test-prefix/', + bucket: 'bucket', + id: 'id', + permissions: ['delete', 'get', 'list', 'write'], + type: 'PREFIX', + } as LocationData, + path: '', + key: 'test-prefix/', + }; + beforeEach(() => { jest.spyOn(Store, 'useStore').mockReturnValue([ { actionType: 'COPY', files: [], - location: { - current: { - prefix: 'test-prefix/', - bucket: 'bucket', - id: 'id', - permissions: ['delete', 'get', 'list', 'write'], - type: 'PREFIX', - } as LocationData, - path: '', - key: 'test-prefix/', - }, + location, locationItems: { fileDataItems: [ { @@ -96,7 +98,6 @@ describe('useCopyView', () => { expect(result.current).toEqual( expect.objectContaining({ - destinationList: ['test-prefix'], isProcessing: false, isProcessingComplete: false, onActionCancel: expect.any(Function), @@ -180,4 +181,26 @@ describe('useCopyView', () => { type: 'RESET_ACTION_TYPE', }); }); + + it('should set a destination', () => { + const { result } = renderHook(() => useCopyView()); + + expect(result.current.destination).toStrictEqual(location); + + const destinationLocation = { + ...location.current, + id: 'id-2', + }; + const destinationPath = 'test-path/'; + + act(() => { + result.current.onSelectDestination(destinationLocation, destinationPath); + }); + + expect(result.current.destination).toStrictEqual({ + current: destinationLocation, + path: destinationPath, + key: `${location.current.prefix}${destinationPath}`, + }); + }); }); 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 e91697dc6de..435f4bec355 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 @@ -6,6 +6,7 @@ import { LocationData } from '../../../../actions'; import * as Store from '../../../../providers/store'; import * as Config from '../../../../providers/configuration'; import { DEFAULT_LIST_OPTIONS, useFolders } from '../useFolders'; +import { LocationState } from '../../../../providers/store/location'; const mockDispatchStoreAction = jest.fn(); const mockHandleList = jest.fn(); @@ -37,6 +38,20 @@ const mockItems = [ ]; describe('useFolders', () => { + const location = { + current: { + prefix: 'prefix1/', + bucket: 'bucket', + id: 'id', + permissions: ['get', 'list'], + type: 'PREFIX', + } as LocationData, + path: '', + key: 'prefix1/', + }; + + const mockSetDestination = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); @@ -44,17 +59,7 @@ describe('useFolders', () => { { actionType: 'COPY', files: [], - location: { - current: { - prefix: 'test-prefix/', - bucket: 'bucket', - id: 'id', - permissions: ['get', 'list'], - type: 'PREFIX', - } as LocationData, - path: '', - key: 'test-prefix/', - }, + location, locationItems: { fileDataItems: [ { @@ -89,9 +94,7 @@ describe('useFolders', () => { ]); const { result } = renderHook(() => - useFolders({ - destinationList: ['prefix1'], - }) + useFolders({ destination: location, setDestination: mockSetDestination }) ); await waitFor(() => { @@ -99,7 +102,7 @@ describe('useFolders', () => { }); }); - it('should update the reference of onInitialize on destinationList change', () => { + it('should update the reference of onInitialize on destination change', () => { jest.spyOn(AmplifyReactCore, 'useDataState').mockReturnValue([ { data: { @@ -115,15 +118,22 @@ describe('useFolders', () => { const { rerender, result } = renderHook( ( - props: { destinationList: string[] } = { - destinationList: ['prefix1'], + props: { destination: LocationState; setDestination: () => void } = { + destination: location, + setDestination: mockSetDestination, } ) => useFolders(props) ); const initial = result.current.onInitialize; rerender({ - destinationList: ['prefix1', 'subfolder1'], + destination: { + ...location, + current: { ...location.current }, + path: 'subfolder1/', + key: `${location.current.prefix}subfolder1/`, + }, + setDestination: mockSetDestination, }); const next = result.current.onInitialize; @@ -145,9 +155,7 @@ describe('useFolders', () => { mockHandleList, ]); const { result } = renderHook(() => - useFolders({ - destinationList: ['prefix1'], - }) + useFolders({ destination: location, setDestination: mockSetDestination }) ); act(() => { @@ -198,7 +206,7 @@ describe('useFolders', () => { ]); const { result } = renderHook(() => - useFolders({ destinationList: ['prefix1'] }) + useFolders({ destination: location, setDestination: mockSetDestination }) ); act(() => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/utils.spec.ts deleted file mode 100644 index ac72f4c4543..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/__tests__/utils.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - getDestinationListFullPrefix, - getDestinationPickerTableData, -} from '../utils'; - -describe('getDestinationListFullPrefix', () => { - it('returns an empty string when provided an empty array', () => { - const output = getDestinationListFullPrefix([]); - - expect(output).toBe(''); - }); - - it('returns an empty string when provided an single length array with an empty string value', () => { - const output = getDestinationListFullPrefix(['']); - - expect(output).toBe(''); - }); - - it('filters empty string values', () => { - const output = getDestinationListFullPrefix(['', 'prefix', 'nested/']); - - expect(output).toBe('prefix/nested/'); - }); - - it('postfixes a / character when missing from the last value', () => { - const output = getDestinationListFullPrefix(['', 'prefix', 'nested']); - - expect(output).toBe('prefix/nested/'); - }); -}); - -describe('getDestinationPickerTableData', () => { - it('returns the expected values', () => { - const onSelect = jest.fn(); - const output = getDestinationPickerTableData({ - onSelect, - folders: [{ key: 'folder1/key', id: 'id' }], - }); - - expect(output).toStrictEqual({ - headers: [ - { content: { label: 'Folder name' }, key: 'key', type: 'sort' }, - ], - rows: [ - { - content: [ - { - content: { - icon: 'folder', - ariaLabel: 'folder1', - label: 'folder1', - onClick: expect.any(Function), - }, - key: 'id', - type: 'button', - }, - ], - key: 'id', - }, - ], - }); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/getDestinationPickerTableData.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/getDestinationPickerTableData.ts new file mode 100644 index 00000000000..dd23804d410 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/getDestinationPickerTableData.ts @@ -0,0 +1,49 @@ +import { WithKey } from '../../../components/types'; +import { DataTableProps } from '../../../composables/DataTable'; +import { DataTableRow } from '../../../composables/DataTable/DataTable'; + +const DESTINATION_PICKER_COLUMNS: DataTableProps['headers'] = [ + { key: 'name', type: 'sort', content: { label: 'Folder name' } }, +]; + +export const getDestinationPickerTableData = ({ + prefix, + path, + folders, + onSelectFolder, +}: { + prefix: string; + path: string; + folders?: { key: string; id: string }[]; + onSelectFolder?: (id: string, folderLocationPath: string) => void; +}): DataTableProps => { + const rows: DataTableProps['rows'] = !folders + ? [] + : folders.map(({ id, key }) => { + const folderSubPath = key.slice(`${prefix ?? ''}${path}`.length); + const folderLocationPath = key.slice(prefix.length); + const row: WithKey = { + key: id, + content: [ + { + key: `${DESTINATION_PICKER_COLUMNS[0].key}-${id}`, + type: 'button', + content: { + icon: 'folder', + ariaLabel: folderSubPath, + label: folderSubPath, + onClick: () => { + onSelectFolder?.(id, folderLocationPath); + }, + }, + }, + ], + }; + return row; + }); + + return { + headers: DESTINATION_PICKER_COLUMNS, + rows, + }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts index 33c16a234ec..33598c7a9ce 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/types.ts @@ -1,14 +1,11 @@ import { FolderData, CopyHandlerData, LocationData } from '../../../actions'; -import { - ActionViewComponent, - ActionViewProps, - ActionViewState, -} from '../types'; +import { LocationState } from '../../../providers/store/location'; +import { ActionViewType, ActionViewProps, ActionViewState } from '../types'; export interface CopyViewState extends ActionViewState { folders: FoldersState; - destinationList: string[]; - onDestinationChange: (destination: string[]) => void; + destination: LocationState; + onSelectDestination: (location: LocationData, path?: string) => void; } export interface CopyViewProviderProps extends CopyViewState { @@ -17,8 +14,23 @@ export interface CopyViewProviderProps extends CopyViewState { export interface CopyViewProps extends ActionViewProps {} -export interface CopyViewComponent - extends ActionViewComponent {} +export interface CopyViewType + extends ActionViewType { + Provider: (props: CopyViewProviderProps) => React.JSX.Element; + Cancel: () => React.JSX.Element | null; + Destination: () => React.JSX.Element | null; + Exit: () => React.JSX.Element | null; + FoldersLoadingIndicator: () => React.JSX.Element | null; + FoldersMessage: () => React.JSX.Element | null; + FoldersPagination: () => React.JSX.Element | null; + FoldersSearch: () => React.JSX.Element | null; + FoldersTable: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Start: () => React.JSX.Element | null; + Statuses: () => React.JSX.Element | null; + TasksTable: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} export interface UseCopyViewOptions { onExit?: (location?: LocationData) => void; @@ -26,18 +38,17 @@ export interface UseCopyViewOptions { export interface FoldersState { hasError: boolean; - hasInitialized: boolean; hasNextPage: boolean; highestPageVisited: number; isLoading: boolean; message: string | undefined; page: number; - onInitialize: () => void; pageItems: FolderData[]; query: string; - onSelect: (name: string) => void; + onInitialize: () => void; onPaginate: (page: number) => void; onQuery: (value: string) => void; onSearch: () => void; onSearchClear: () => void; + onSelectFolder: (id: string, folderLocationPath: string) => void; } 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 2ce434530da..0d72948d32c 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 @@ -2,26 +2,13 @@ import React, { useState } from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { copyHandler } from '../../../actions/handlers'; +import { copyHandler, LocationData } from '../../../actions/handlers'; import { Task, useProcessTasks } from '../../../tasks'; import { useGetActionInput } from '../../../providers/configuration'; import { useStore } from '../../../providers/store'; import { CopyViewState, UseCopyViewOptions } from './types'; import { useFolders } from './useFolders'; -import { getDestinationListFullPrefix } from './utils'; - -const getInitialDestinationList = (key: string, prefix?: string) => - // handle root bucket access grant - key === '' - ? [''] - : // handle subfolder inside root access grant - key && prefix == '' - ? ['', ...key.split('/').slice(0, -1)] - : // regular access that starts at prefix (not root bucket) - key.includes('/') - ? key.split('/').slice(0, -1) - : []; export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { const { onExit } = options ?? {}; @@ -32,7 +19,7 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { }, dispatchStoreAction, ] = useStore(); - const { key, current } = location; + const { current } = location; const getInput = useGetActionInput(); @@ -45,14 +32,12 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { const { isProcessing, isProcessingComplete, statusCounts, tasks } = processState; - const [destinationList, onDestinationChange] = useState(() => - getInitialDestinationList(key, current?.prefix) - ); + const [destination, setDestination] = useState(location); const onActionStart = () => { handleProcess({ config: getInput(), - destinationPrefix: getDestinationListFullPrefix(destinationList), + destinationPrefix: destination.key, }); }; @@ -77,10 +62,21 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { [dispatchStoreAction] ); - const folders = useFolders({ destinationList, onDestinationChange }); + const folders = useFolders({ destination, setDestination }); + + const onSelectDestination = ( + selectedDestination: LocationData, + path?: string + ) => { + setDestination({ + current: selectedDestination, + path: path ?? '', + key: `${selectedDestination.prefix ?? ''}${path}`, + }); + }; return { - destinationList, + destination, isProcessing, isProcessingComplete, folders, @@ -89,8 +85,8 @@ export const useCopyView = (options?: UseCopyViewOptions): CopyViewState => { tasks, onActionCancel, onActionStart, - onDestinationChange, onActionExit, + onSelectDestination, onTaskRemove, }; }; 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 3ccb814b11b..9bd34c12696 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,19 @@ import React from 'react'; -import { useDataState, useHasValueUpdated } from '@aws-amplify/ui-react-core'; +import { useDataState } from '@aws-amplify/ui-react-core'; import { usePaginate } from '../../hooks/usePaginate'; import { listLocationItemsHandler, FolderData } from '../../../actions'; import { useGetActionInput } from '../../../providers/configuration'; -import { createEnhancedListHandler } from '../../../actions/createEnhancedListHandler'; +import { createEnhancedListHandler } from '../../../actions/useAction/createEnhancedListHandler'; import { useSearch } from '../../hooks/useSearch'; -import { getDestinationListFullPrefix } from './utils'; 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 = { @@ -28,20 +28,20 @@ export type ListFoldersAction = ( input: ListLocationItemsHandlerInput ) => Promise>; +interface UseFoldersInput { + destination: LocationState; + setDestination: (destination: LocationState) => void; +} + const listLocationItemsAction = createEnhancedListHandler( listLocationItemsHandler as ListFoldersAction ); export const useFolders = ({ - destinationList, - onDestinationChange, -}: { - destinationList?: string[]; - onDestinationChange?: (destinationList: string[]) => void; -}): FoldersState => { - const prefix = !destinationList - ? '' - : getDestinationListFullPrefix(destinationList); + destination, + setDestination, +}: UseFoldersInput): FoldersState => { + const { current, key } = destination; const [{ data, hasError, isLoading, message }, handleList] = useDataState( listLocationItemsAction, @@ -52,19 +52,13 @@ export const useFolders = ({ const { items, nextToken } = data; - const hasInitializedRef = React.useRef(false); - const hasItemsChanged = useHasValueUpdated(items, true); - if (hasItemsChanged) { - hasInitializedRef.current = true; - } - const onInitialize = React.useCallback(() => { handleList({ config: getInput(), - prefix, + prefix: key, options: { ...DEFAULT_REFRESH_OPTIONS }, }); - }, [getInput, handleList, prefix]); + }, [getInput, handleList, key]); const hasNextToken = !!nextToken; @@ -73,7 +67,7 @@ export const useFolders = ({ handleList({ config: getInput(), - prefix, + prefix: key, options: { ...DEFAULT_LIST_OPTIONS, nextToken }, }); }; @@ -95,7 +89,7 @@ export const useFolders = ({ handleReset(); handleList({ config: getInput(), - prefix, + prefix: key, options: { ...DEFAULT_LIST_OPTIONS, search: { query, filterBy: 'key' }, @@ -103,14 +97,16 @@ export const useFolders = ({ }); }; - const onSelect = (name: string) => { - const newPath = !destinationList - ? undefined - : [...destinationList, name.replace('/', '')]; + const onSelectFolder = (id: string, folderLocationPath: string) => { + if (!current) { + return; + } - if (!newPath) return; - - onDestinationChange?.(newPath); + setDestination({ + current: { ...current, id }, + path: folderLocationPath, + key: `${current.prefix ?? ''}${folderLocationPath}`, + }); }; const { @@ -122,7 +118,6 @@ export const useFolders = ({ return { hasError, - hasInitialized: hasInitializedRef.current, hasNextPage: hasNextToken, highestPageVisited, isLoading, @@ -139,10 +134,10 @@ export const useFolders = ({ resetSearch(); handleList({ config: getInput(), - prefix, + prefix: key, options: { ...DEFAULT_REFRESH_OPTIONS }, }); }, - onSelect, + onSelectFolder, }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/utils.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/utils.ts deleted file mode 100644 index b0fb17e9400..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CopyView/utils.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { WithKey } from '../../../components/types'; -import { DataTableProps } from '../../../composables/DataTable'; -import { DataTableRow } from '../../../composables/DataTable/DataTable'; - -const DESTINATION_PICKER_COLUMNS: DataTableProps['headers'] = [ - { key: 'key', type: 'sort', content: { label: 'Folder name' } }, -]; - -const getFolderNameFromKey = (key: string): string => { - if (key === '') return 'root'; - const lastFolder = key.split('/').at(-2); - return lastFolder ? lastFolder : ''; -}; - -export const getDestinationListFullPrefix = ( - destinationList: string[] -): string => { - if ( - destinationList.length < 1 || - (destinationList.length === 1 && destinationList[0] === '') - ) { - return ''; - } - // filter out root bucket "" - const destination = destinationList.filter((item) => item !== '').join('/'); - return destination.endsWith('/') ? destination : `${destination}/`; -}; - -export const getDestinationPickerTableData = ({ - folders, - onSelect, -}: { - folders?: { key: string; id: string }[]; - onSelect?: (name: string) => void; -}): DataTableProps => { - const rows: DataTableProps['rows'] = !folders - ? [] - : folders.map((item) => { - const name = getFolderNameFromKey(item.key); - const row: WithKey = { - key: item.id, - content: [ - { - key: item.id, - type: 'button', - content: { - ariaLabel: name, - label: name, - icon: 'folder', - onClick: () => { - onSelect?.(name); - }, - }, - }, - ], - }; - return row; - }); - - const tableData: DataTableProps = { - headers: DESTINATION_PICKER_COLUMNS, - rows, - }; - return tableData; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderView.tsx index 504e2ae9f80..b3cecd9492b 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderView.tsx @@ -1,93 +1,27 @@ import React from 'react'; +import { STORAGE_BROWSER_BLOCK } from '../../../constants'; +import { ViewElement } from '../../../context/elements'; import { ActionStartControl } from '../../../controls/ActionStartControl'; import { ActionExitControl } from '../../../controls/ActionExitControl'; import { FolderNameFieldControl } from '../../../controls/FolderNameFieldControl'; import { MessageControl } from '../../../controls/MessageControl'; import { TitleControl } from '../../../controls/TitleControl'; -import { ControlsContextProvider } from '../../../controls/context'; -import { useDisplayText } from '../../../displayText'; -import { resolveClassName } from '../../utils'; -import { CreateFolderViewProps } from './types'; + +import { CreateFolderViewProvider } from './CreateFolderViewProvider'; +import { CreateFolderViewType } from './types'; import { useCreateFolderView } from './useCreateFolderView'; -import { isValidFolderName } from './utils'; -import { STORAGE_BROWSER_BLOCK } from '../../../constants'; -import { ViewElement } from '../../../context/elements'; -import { LoadingIndicator } from '../../../composables/LoadingIndicator'; +import { classNames } from '@aws-amplify/ui'; -export function CreateFolderView({ +export const CreateFolderView: CreateFolderViewType = ({ className, ...props -}: CreateFolderViewProps): React.JSX.Element { - const { - CreateFolderView: { - actionExitLabel, - actionStartLabel, - folderNameLabel, - folderNamePlaceholder, - getActionCompleteMessage, - getValidationMessage, - loadingIndicatorLabel, - title, - }, - } = useDisplayText(); - - const { - folderName, - folderNameId, - isProcessing, - isProcessingComplete, - onActionStart, - onActionExit, - onFolderNameChange, - statusCounts, - } = useCreateFolderView(props); - - const loadingIndicator = ( - - ); - - const [validationMessage, setValidationMessage] = React.useState< - string | undefined - >(); - - const message = isProcessingComplete - ? getActionCompleteMessage({ counts: statusCounts }) - : undefined; - - const onValidateFolderName = (value: string) => { - setValidationMessage(() => - isValidFolderName(value) ? undefined : getValidationMessage(value) - ); - }; - - const isActionStartDisabled = - !folderName.length || - !!validationMessage || - isProcessing || - isProcessingComplete; +}) => { + const state = useCreateFolderView(props); return ( -
- + + @@ -99,7 +33,17 @@ export function CreateFolderView({ - -
+ + ); -} +}; + +CreateFolderView.displayName = 'CreateFolderView'; + +CreateFolderView.Provider = CreateFolderViewProvider; + +CreateFolderView.Exit = ActionExitControl; +CreateFolderView.NameField = FolderNameFieldControl; +CreateFolderView.Message = MessageControl; +CreateFolderView.Start = ActionStartControl; +CreateFolderView.Title = TitleControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderViewProvider.tsx new file mode 100644 index 00000000000..0a911a50849 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/CreateFolderViewProvider.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../../controls/context'; +import { useDisplayText } from '../../../displayText'; + +import { CreateFolderViewProviderProps } from './types'; +import { isValidFolderName } from './utils'; + +export function CreateFolderViewProvider({ + children, + ...props +}: CreateFolderViewProviderProps): React.JSX.Element { + const { + CreateFolderView: { + actionExitLabel, + actionStartLabel, + folderNameLabel, + folderNamePlaceholder, + getActionCompleteMessage, + getValidationMessage, + title, + }, + } = useDisplayText(); + + const { + folderName, + folderNameId, + isProcessing, + isProcessingComplete, + onActionStart, + onActionExit, + onFolderNameChange, + statusCounts, + } = props; + + const [validationMessage, setValidationMessage] = React.useState< + string | undefined + >(); + + const message = isProcessingComplete + ? getActionCompleteMessage({ counts: statusCounts }) + : undefined; + + const onValidateFolderName = (value: string) => { + setValidationMessage(() => + isValidFolderName(value) ? undefined : getValidationMessage(value) + ); + }; + + const isActionStartDisabled = + !folderName.length || + !!validationMessage || + isProcessing || + isProcessingComplete; + + return ( + + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/CreateFolderView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/CreateFolderView.spec.tsx index dcac1040ab7..407be56779a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/CreateFolderView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/CreateFolderView/__tests__/CreateFolderView.spec.tsx @@ -100,6 +100,14 @@ const useCreateFolderViewSpy = jest describe('CreateFolderView', () => { afterEach(jest.clearAllMocks); + it('has the expected composable components', () => { + expect(CreateFolderView.Exit).toBeDefined(); + expect(CreateFolderView.NameField).toBeDefined(); + expect(CreateFolderView.Message).toBeDefined(); + expect(CreateFolderView.Start).toBeDefined(); + expect(CreateFolderView.Title).toBeDefined(); + }); + it('provides the expected values to `ControlsContextProvider` on initial render', () => { render(); 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 de54a3d1a77..47ed8650782 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,10 +1,10 @@ -import { CopyHandlerData, CreateFolderHandlerData } from '../../../actions'; - import { - ActionViewComponent, - ActionViewState, - ActionViewProps, -} from '../types'; + CopyHandlerData, + CreateFolderHandlerData, + LocationData, +} from '../../../actions'; + +import { ActionViewType, ActionViewState, ActionViewProps } from '../types'; export interface CreateFolderViewState extends Omit, 'onActionCancel'> { @@ -17,5 +17,20 @@ export interface CreateFolderViewProps extends ActionViewProps, Partial {} -export interface CreateFolderViewComponent - extends ActionViewComponent {} +export interface CreateFolderViewProviderProps extends CreateFolderViewState { + children?: React.ReactNode; +} + +export interface CreateFolderViewType + extends ActionViewType { + Provider: (props: CreateFolderViewProviderProps) => React.JSX.Element; + Exit: () => React.JSX.Element | null; + NameField: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Start: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} + +export interface UseCreateFolderViewOptions { + onExit?: (location?: LocationData) => void; +} 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 f8de233aad6..3c0bb3f66b8 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,19 +1,17 @@ import React from 'react'; import { isFunction } from '@aws-amplify/ui'; -import { LocationData } from '../../../actions'; - -import { useStore } from '../../../providers/store'; - -import { useProcessTasks } from '../../../tasks'; import { createFolderHandler } from '../../../actions'; import { useGetActionInput } from '../../../providers/configuration'; -import { CreateFolderViewState } from './types'; +import { useStore } from '../../../providers/store'; +import { useProcessTasks } from '../../../tasks'; + +import { CreateFolderViewState, UseCreateFolderViewOptions } from './types'; -export const useCreateFolderView = (params?: { - onExit?: (location: LocationData) => void; -}): CreateFolderViewState => { - const { onExit } = params ?? {}; +export const useCreateFolderView = ( + options?: UseCreateFolderViewOptions +): CreateFolderViewState => { + const { onExit } = options ?? {}; const [folderName, setFolderName] = React.useState(''); const folderNameId = React.useRef(crypto.randomUUID()).current; @@ -41,7 +39,7 @@ export const useCreateFolderView = (params?: { }); }, onActionExit: () => { - if (isFunction(onExit)) onExit(current!); + if (isFunction(onExit)) onExit(current); dipatchStoreAction({ type: 'RESET_ACTION_TYPE' }); }, onFolderNameChange: (value) => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteView.tsx index 179588e62cb..8e6f2d8a44c 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteView.tsx @@ -1,99 +1,30 @@ import React from 'react'; -import { ControlsContextProvider } from '../../../controls/context'; import { ViewElement } from '../../../context/elements'; import { ActionCancelControl } from '../../../controls/ActionCancelControl'; import { ActionExitControl } from '../../../controls/ActionExitControl'; import { ActionStartControl } from '../../../controls/ActionStartControl'; import { DataTableControl } from '../../../controls/DataTableControl'; +import { MessageControl } from '../../../controls/MessageControl'; import { StatusDisplayControl } from '../../../controls/StatusDisplayControl'; import { TitleControl } from '../../../controls/TitleControl'; -import { useDisplayText } from '../../../displayText'; import { STORAGE_BROWSER_BLOCK } from '../../../constants'; -import { resolveClassName } from '../../utils'; -import { getActionViewTableData } from '../getActionViewTableData'; +import { DeleteViewProvider } from './DeleteViewProvider'; import { useDeleteView } from './useDeleteView'; -import { DeleteViewProps } from './types'; -import { LoadingIndicator } from '../../../composables/LoadingIndicator'; -import { MessageControl } from '../../Controls'; +import { DeleteViewType } from './types'; +import { classNames } from '@aws-amplify/ui'; -export function DeleteView({ - className, - ...props -}: DeleteViewProps): React.JSX.Element { - const { DeleteView: displayText } = useDisplayText(); - const { - actionCancelLabel, - actionExitLabel, - actionStartLabel, - loadingIndicatorLabel, - title, - statusDisplayCanceledLabel, - statusDisplayCompletedLabel, - statusDisplayFailedLabel, - statusDisplayQueuedLabel, - getActionCompleteMessage, - } = displayText; - - const { - isProcessing, - isProcessingComplete, - location, - statusCounts, - tasks, - onActionCancel, - onActionStart, - onActionExit, - onTaskRemove, - } = useDeleteView(props); - - const message = isProcessingComplete - ? getActionCompleteMessage({ counts: statusCounts }) - : undefined; - - const tableData = getActionViewTableData({ - tasks, - locationKey: location.key, - isProcessing, - displayText, - onTaskRemove, - }); - - const loadingIndicator = ( - - ); +export const DeleteView: DeleteViewType = ({ className, ...props }) => { + const state = useDeleteView(props); return ( -
- + + - - @@ -106,7 +37,19 @@ export function DeleteView({ - -
+ + ); -} +}; + +DeleteView.displayName = 'DeleteView'; + +DeleteView.Provider = DeleteViewProvider; + +DeleteView.Cancel = ActionCancelControl; +DeleteView.Exit = ActionExitControl; +DeleteView.Message = MessageControl; +DeleteView.Start = ActionStartControl; +DeleteView.Statuses = StatusDisplayControl; +DeleteView.TasksTable = DataTableControl; +DeleteView.Title = TitleControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx new file mode 100644 index 00000000000..f17f595e0b6 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/DeleteViewProvider.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../../controls/context'; +import { useDisplayText } from '../../../displayText'; +import { getActionViewTableData } from '../getActionViewTableData'; +import { DeleteViewProviderProps } from './types'; + +export function DeleteViewProvider({ + children, + ...props +}: DeleteViewProviderProps): React.JSX.Element { + const { DeleteView: displayText } = useDisplayText(); + const { + actionCancelLabel, + actionExitLabel, + actionStartLabel, + title, + statusDisplayCanceledLabel, + statusDisplayCompletedLabel, + statusDisplayFailedLabel, + statusDisplayQueuedLabel, + getActionCompleteMessage, + } = displayText; + + const { + isProcessing, + isProcessingComplete, + location, + statusCounts, + tasks, + onActionCancel, + onActionStart, + onActionExit, + onTaskRemove, + } = props; + + const message = isProcessingComplete + ? getActionCompleteMessage({ counts: statusCounts }) + : undefined; + + const tableData = getActionViewTableData({ + tasks, + locationKey: location.key, + isProcessing, + displayText, + onTaskRemove, + }); + + return ( + + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/DeleteView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/DeleteView.spec.tsx index c947c0ce33a..c7cf74a1553 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/DeleteView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/__tests__/DeleteView.spec.tsx @@ -118,6 +118,16 @@ const useDeleteViewSpy = jest describe('DeleteView', () => { afterEach(jest.clearAllMocks); + it('has the expected composable components', () => { + expect(DeleteView.Cancel).toBeDefined(); + expect(DeleteView.Exit).toBeDefined(); + expect(DeleteView.Message).toBeDefined(); + expect(DeleteView.Start).toBeDefined(); + expect(DeleteView.Statuses).toBeDefined(); + expect(DeleteView.TasksTable).toBeDefined(); + expect(DeleteView.Title).toBeDefined(); + }); + it('provides the expected values to `ControlsContextProvider` on initial render', () => { render(); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts index f29406dd667..afc8d0d52df 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/DeleteView/types.ts @@ -1,16 +1,25 @@ import { DeleteHandlerData, LocationData } from '../../../actions'; -import { - ActionViewComponent, - ActionViewProps, - ActionViewState, -} from '../types'; +import { ActionViewType, ActionViewProps, ActionViewState } from '../types'; export interface DeleteViewState extends ActionViewState {} export interface DeleteViewProps extends ActionViewProps {} -export interface DeleteViewComponent - extends ActionViewComponent {} +export interface DeleteViewProviderProps extends DeleteViewState { + children?: React.ReactNode; +} + +export interface DeleteViewType + extends ActionViewType { + Provider: (props: DeleteViewProviderProps) => React.JSX.Element; + Cancel: () => React.JSX.Element | null; + Exit: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Start: () => React.JSX.Element | null; + Statuses: () => React.JSX.Element | null; + TasksTable: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} export interface UseDeleteViewOptions { onExit?: (location?: LocationData) => void; 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 9fff594fba0..631b6002403 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx @@ -1,45 +1,36 @@ import React from 'react'; +import { isDefaultActionViewType } from '../../actions'; +import { useStore } from '../../providers/store'; + import { CreateFolderView } from './CreateFolderView'; import { CopyView } from './CopyView'; import { DeleteView } from './DeleteView'; import { UploadView } from './UploadView'; -import { useStore } from '../../providers/store'; export interface LocationActionViewProps { onExit?: () => void; type?: T; } -const ACTION_VIEW_TYPES = [ - 'COPY_FILES', - 'CREATE_FOLDER', - 'DELETE_FILES', - 'UPLOAD_FILES', - 'UPLOAD_FOLDER', -]; - -const isActionViewType = (value?: string) => - ACTION_VIEW_TYPES.some((type) => type === value); - export const LocationActionView = ({ - onExit, type, + ...props }: LocationActionViewProps): React.JSX.Element | null => { const [{ actionType = type }] = useStore(); - if (!isActionViewType(actionType)) return null; + if (!isDefaultActionViewType(actionType)) return null; return ( <> - {actionType === 'CREATE_FOLDER' ? ( - - ) : actionType === 'DELETE_FILES' ? ( - - ) : actionType === 'COPY_FILES' ? ( - + {actionType === 'createFolder' ? ( + + ) : actionType === 'delete' ? ( + + ) : actionType === 'copy' ? ( + ) : ( - + )} ); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadView.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadView.tsx index daae84c0e4b..951d0999776 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadView.tsx @@ -1,14 +1,13 @@ import React from 'react'; -import { DescriptionList } from '../../../components/DescriptionList'; +import { STORAGE_BROWSER_BLOCK } from '../../../constants'; import { ViewElement } from '../../../context/elements'; - import { ActionCancelControl } from '../../../controls/ActionCancelControl'; +import { ActionDestinationControl } from '../../../controls/ActionDestinationControl'; import { ActionExitControl } from '../../../controls/ActionExitControl'; import { ActionStartControl } from '../../../controls/ActionStartControl'; import { AddFilesControl } from '../../../controls/AddFilesControl'; import { AddFolderControl } from '../../../controls/AddFolderControl'; -import { ControlsContextProvider } from '../../../controls/context'; import { DataTableControl } from '../../../controls/DataTableControl'; import { DropZoneControl } from '../../../controls/DropZoneControl'; import { OverwriteToggleControl } from '../../../controls/OverwriteToggleControl'; @@ -16,116 +15,17 @@ import { MessageControl } from '../../../controls/MessageControl'; import { StatusDisplayControl } from '../../../controls/StatusDisplayControl'; import { TitleControl } from '../../../controls/TitleControl'; -import { useDisplayText } from '../../../displayText'; -import { STORAGE_BROWSER_BLOCK } from '../../../constants'; -import { resolveClassName } from '../../utils'; -import { getActionViewTableData } from '../getActionViewTableData'; +import { UploadViewProvider } from './UploadViewProvider'; +import { UploadViewType } from './types'; import { useUploadView } from './useUploadView'; -import { UploadViewProps } from './types'; -import { Breadcrumb } from '../../../components/BreadcrumbNavigation'; -import { LoadingIndicator } from '../../../composables/LoadingIndicator'; +import { classNames } from '@aws-amplify/ui'; -export function UploadView({ - className, - ...props -}: UploadViewProps): React.JSX.Element { - const { UploadView: displayText } = useDisplayText(); - const { - actionCancelLabel, - actionDestinationLabel, - actionExitLabel, - actionStartLabel, - addFilesLabel, - addFolderLabel, - loadingIndicatorLabel, - statusDisplayCanceledLabel, - statusDisplayCompletedLabel, - statusDisplayFailedLabel, - statusDisplayQueuedLabel, - overwriteToggleLabel, - title, - getActionCompleteMessage, - } = displayText; - - const { - isOverwritingEnabled, - isProcessing, - isProcessingComplete, - location, - tasks, - statusCounts, - onActionStart, - onActionCancel, - onDropFiles, - onActionExit, - onTaskRemove, - onSelectFiles, - onToggleOverwrite, - } = useUploadView(props); - - const loadingIndicator = ( - - ); - - const isActionStartDisabled = - isProcessing || isProcessingComplete || statusCounts.TOTAL === 0; - const isActionCancelDisabled = !isProcessing || isProcessingComplete; - const isAddFilesDisabled = isProcessing || isProcessingComplete; - const isAddFolderDisabled = isProcessing || isProcessingComplete; - const isActionExitDisabled = isProcessing; - const destinationList = (location.key || '/').split('/'); - - const message = isProcessingComplete - ? getActionCompleteMessage({ - counts: statusCounts, - }) - : undefined; +export const UploadView: UploadViewType = ({ className, ...props }) => { + const state = useUploadView(props); return ( -
- { - onSelectFiles('FILE'); - }} - onAddFolder={() => { - onSelectFiles('FOLDER'); - }} - onDropFiles={onDropFiles} - onToggleOverwrite={onToggleOverwrite} - > + + @@ -139,25 +39,7 @@ export function UploadView({ - - {destinationList.map((key, index) => ( - - ))} - - ), - }, - ]} - /> + @@ -169,7 +51,24 @@ export function UploadView({ - -
+ + ); -} +}; + +UploadView.displayName = 'UploadView'; + +UploadView.Provider = UploadViewProvider; + +UploadView.AddFiles = AddFilesControl; +UploadView.AddFolder = AddFolderControl; +UploadView.Cancel = ActionCancelControl; +UploadView.Destination = ActionDestinationControl; +UploadView.DropZone = DropZoneControl; +UploadView.Exit = ActionExitControl; +UploadView.Message = MessageControl; +UploadView.OverwriteToggle = OverwriteToggleControl; +UploadView.Start = ActionStartControl; +UploadView.Statuses = StatusDisplayControl; +UploadView.TasksTable = DataTableControl; +UploadView.Title = TitleControl; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadViewProvider.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadViewProvider.tsx new file mode 100644 index 00000000000..2a4cb52b15f --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/UploadViewProvider.tsx @@ -0,0 +1,115 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../../controls/context'; +import { useDisplayText } from '../../../displayText'; + +import { getActionViewTableData } from '../getActionViewTableData'; + +import { UploadViewProviderProps } from './types'; + +export function UploadViewProvider({ + children, + ...props +}: UploadViewProviderProps): React.JSX.Element { + const { UploadView: displayText } = useDisplayText(); + const { + actionCancelLabel, + actionDestinationLabel, + actionExitLabel, + actionStartLabel, + addFilesLabel, + addFolderLabel, + statusDisplayCanceledLabel, + statusDisplayCompletedLabel, + statusDisplayFailedLabel, + statusDisplayQueuedLabel, + overwriteToggleLabel, + title, + getActionCompleteMessage, + getFilesValidationMessage, + } = displayText; + + const { + isOverwritingEnabled, + isProcessing, + isProcessingComplete, + location, + tasks, + statusCounts, + invalidFiles, + onActionStart, + onActionCancel, + onDropFiles, + onActionExit, + onTaskRemove, + onSelectFiles, + onToggleOverwrite, + } = props; + + const isActionStartDisabled = + isProcessing || isProcessingComplete || statusCounts.TOTAL === 0; + const isActionCancelDisabled = !isProcessing || isProcessingComplete; + const isAddFilesDisabled = isProcessing || isProcessingComplete; + const isAddFolderDisabled = isProcessing || isProcessingComplete; + const isActionExitDisabled = isProcessing; + + const actionCompleteMessage = isProcessingComplete + ? getActionCompleteMessage({ + counts: statusCounts, + }) + : undefined; + const filesValidationMessage = + invalidFiles && !isProcessing + ? getFilesValidationMessage({ invalidFiles }) + : undefined; + + return ( + { + onSelectFiles('FILE'); + }} + onAddFolder={() => { + onSelectFiles('FOLDER'); + }} + onDropFiles={onDropFiles} + onToggleOverwrite={onToggleOverwrite} + > + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/UploadView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/UploadView.spec.tsx index 099a8ed67da..c5e81daef5a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/UploadView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/__tests__/UploadView.spec.tsx @@ -10,7 +10,10 @@ import { UploadView } from '../UploadView'; jest.mock('../../../../displayText', () => ({ useDisplayText: () => ({ - UploadView: { getActionCompleteMessage: jest.fn() }, + UploadView: { + getActionCompleteMessage: jest.fn(), + getFilesValidationMessage: jest.fn(), + }, }), })); @@ -42,6 +45,11 @@ const statusCounts = { ...INITIAL_STATUS_COUNTS }; const testFile = new File([], 'test-ooo'); const data = { id: 'some-uuid', file: testFile, key: testFile.name }; +const invalidFileData = { + file: new File([], 'very-big-file'), + id: 'uuid', + key: 'very-big-file', +}; const taskOne = { data, @@ -67,6 +75,7 @@ const initialViewState: UploadViewState = { isProcessingComplete: false, isProcessing: false, tasks: [], + invalidFiles: undefined, statusCounts, }; @@ -74,6 +83,7 @@ const preprocessingViewState: UploadViewState = { ...initialViewState, tasks: [taskOne], statusCounts: { ...statusCounts, QUEUED: 1, TOTAL: 1 }, + invalidFiles: [invalidFileData], }; const processingViewState: UploadViewState = { @@ -97,6 +107,21 @@ const useUploadViewSpy = jest describe('UploadView', () => { afterEach(jest.clearAllMocks); + it('has the expected composable components', () => { + expect(UploadView.AddFiles).toBeDefined(); + expect(UploadView.AddFolder).toBeDefined(); + expect(UploadView.Cancel).toBeDefined(); + expect(UploadView.Destination).toBeDefined(); + expect(UploadView.DropZone).toBeDefined(); + expect(UploadView.Exit).toBeDefined(); + expect(UploadView.Message).toBeDefined(); + expect(UploadView.OverwriteToggle).toBeDefined(); + expect(UploadView.Start).toBeDefined(); + expect(UploadView.Statuses).toBeDefined(); + expect(UploadView.TasksTable).toBeDefined(); + expect(UploadView.Title).toBeDefined(); + }); + it('provides the expected boolean flags to `ControlsContextProvider` prior to processing when tasks is empty', () => { render(); 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 63883f432a0..cbb2f680723 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 @@ -4,6 +4,7 @@ import { LocationData } from '../../../../actions'; import * as ConfigModule from '../../../../providers/configuration'; import * as StoreModule from '../../../../providers/store'; import * as TasksModule from '../../../../tasks'; +import { UPLOAD_FILE_SIZE_LIMIT } from '../../../../validators/isFileTooBig'; const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); @@ -16,13 +17,12 @@ const rootLocation: LocationData = { type: 'BUCKET', }; +const mockUserStoreState = { + location: { current: rootLocation, path: '', key: '' }, + files: undefined, +} as StoreModule.UseStoreState; const dispatchStoreAction = jest.fn(); -useStoreSpy.mockReturnValue([ - { - location: { current: rootLocation, path: '', key: '' }, - } as StoreModule.UseStoreState, - dispatchStoreAction, -]); +useStoreSpy.mockReturnValue([mockUserStoreState, dispatchStoreAction]); const credentials = jest.fn(); const config: ConfigModule.GetActionInput = jest.fn(() => ({ @@ -43,6 +43,15 @@ const fileItemTwo = { file: testFileTwo, key: testFileTwo.name, }; +const invalidFile = { + ...new File([], 'invalid-file'), + size: UPLOAD_FILE_SIZE_LIMIT + 1, +}; +const invalidFileItem = { + id: 'invalid-file-uuid', + file: invalidFile, + key: invalidFile.name, +}; jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(config); const handleProcessTasks = jest.fn(); @@ -77,6 +86,7 @@ const useProcessTasksSpy = jest describe('useUploadView', () => { afterEach(() => { + mockUserStoreState.files = undefined; jest.clearAllMocks(); }); @@ -94,6 +104,13 @@ describe('useUploadView', () => { }); }); + it('should show invalid files if exists', () => { + mockUserStoreState.files = [invalidFileItem]; + const { result } = renderHook(() => useUploadView()); + + expect(result.current.invalidFiles).toEqual([invalidFileItem]); + }); + it('should dispatchStoreAction when onSelectFiles is invoked with different types', () => { const { result } = renderHook(() => useUploadView()); @@ -119,6 +136,19 @@ describe('useUploadView', () => { }); it('should call handleProcessTasks with the expected values', () => { + mockUserStoreState.files = [invalidFileItem]; + const { result } = renderHook(() => useUploadView()); + act(() => { + result.current.onActionStart(); + }); + expect(dispatchStoreAction).toHaveBeenCalledTimes(1); + expect(dispatchStoreAction).toHaveBeenCalledWith({ + type: 'REMOVE_FILE_ITEM', + id: invalidFileItem.id, + }); + }); + + it('should remove any invalid files action is started', () => { const { result } = renderHook(() => useUploadView()); act(() => { result.current.onActionStart(); @@ -134,6 +164,7 @@ describe('useUploadView', () => { destinationPrefix: '', }); }); + it('should call cancel on each pending task when onCancel is invoked', () => { const tasks: TasksModule.Task[] = [ { ...taskOne, status: 'PENDING' }, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts index 167c9669153..c6a08fa7c35 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/UploadView/types.ts @@ -1,24 +1,39 @@ import { LocationData, UploadHandlerData } from '../../../actions'; -import { FileItem } from '../../../providers'; -import { - ActionViewComponent, - ActionViewProps, - ActionViewState, -} from '../types'; +import { FileItem, FileItems } from '../../../providers'; +import { ActionViewType, ActionViewProps, ActionViewState } from '../types'; export interface UploadViewState extends ActionViewState { isOverwritingEnabled: boolean; onDropFiles: (files: File[]) => void; onSelectFiles: (type: 'FILE' | 'FOLDER') => void; onToggleOverwrite: () => void; + invalidFiles: FileItems | undefined; } export interface UploadViewProps extends ActionViewProps, Partial {} -export interface UploadViewComponent - extends ActionViewComponent {} +export interface UploadViewProviderProps extends UploadViewState { + children?: React.ReactNode; +} + +export interface UploadViewType + extends ActionViewType { + Provider: (props: UploadViewProviderProps) => React.JSX.Element; + AddFiles: () => React.JSX.Element | null; + AddFolder: () => React.JSX.Element | null; + Cancel: () => React.JSX.Element | null; + DropZone: (props: { children: React.ReactNode }) => React.JSX.Element | null; + Destination: () => React.JSX.Element | null; + Exit: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + OverwriteToggle: () => React.JSX.Element | null; + Start: () => React.JSX.Element | null; + Statuses: () => React.JSX.Element | null; + TasksTable: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} export interface UseUploadViewOptions { onExit?: (location?: LocationData) => void; 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 674fa9cb4a3..1d7e6313b0c 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 @@ -3,12 +3,14 @@ import React from 'react'; import { uploadHandler } from '../../../actions'; import { useGetActionInput } from '../../../providers/configuration'; -import { useStore } from '../../../providers/store'; +import { FileItems, useStore } from '../../../providers/store'; import { Task, useProcessTasks } from '../../../tasks'; 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'; export const useUploadView = ( options?: UseUploadViewOptions @@ -18,6 +20,30 @@ export const useUploadView = ( const [{ files, location }, dispatchStoreAction] = useStore(); const { current, key } = location; + const { invalidFiles, validFiles } = React.useMemo( + () => + (files ?? [])?.reduce( + (curr, file) => { + if (isFileTooBig(file.file)) { + curr.invalidFiles = isUndefined(curr.invalidFiles) + ? [file] + : curr.invalidFiles.concat(file); + } else { + curr.validFiles = isUndefined(curr.validFiles) + ? [file] + : curr.validFiles.concat(file); + } + + return curr; + }, + {} as { + invalidFiles: FileItems | undefined; + validFiles: FileItems | undefined; + } + ), + [files] + ); + const [isOverwritingEnabled, setIsOverwritingEnabled] = React.useState( DEFAULT_OVERWRITE_ENABLED ); @@ -25,7 +51,7 @@ export const useUploadView = ( const [ { isProcessing, isProcessingComplete, statusCounts, tasks }, handleProcess, - ] = useProcessTasks(uploadHandler, files, { + ] = useProcessTasks(uploadHandler, validFiles, { concurrency: DEFAULT_ACTION_CONCURRENCY, }); @@ -46,12 +72,23 @@ export const useUploadView = ( ); const onActionStart = React.useCallback(() => { + invalidFiles?.forEach((file) => { + dispatchStoreAction({ type: 'REMOVE_FILE_ITEM', id: file.id }); + }); + handleProcess({ config: getInput(), destinationPrefix: key, options: { preventOverwrite: !isOverwritingEnabled }, }); - }, [isOverwritingEnabled, key, getInput, handleProcess]); + }, [ + isOverwritingEnabled, + key, + getInput, + handleProcess, + invalidFiles, + dispatchStoreAction, + ]); const onActionCancel = React.useCallback(() => { tasks.forEach((task) => task.cancel?.()); @@ -81,6 +118,7 @@ export const useUploadView = ( isProcessingComplete, isOverwritingEnabled, location, + invalidFiles, statusCounts, tasks, onActionCancel, diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/LocationActionView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/LocationActionView.spec.tsx index 46e5e28b2a4..2b266e33106 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/LocationActionView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/LocationActionView.spec.tsx @@ -42,27 +42,22 @@ describe('LocationActionView', () => { it.each([ { view: 'CreateFolderView', - actionType: 'CREATE_FOLDER', + actionType: 'createFolder', testId: 'create-folder-view', }, { view: 'CopyView', - actionType: 'COPY_FILES', + actionType: 'copy', testId: 'copy-view', }, { view: 'DeleteView', - actionType: 'DELETE_FILES', + actionType: 'delete', testId: 'delete-view', }, { view: 'UploadView', - actionType: 'UPLOAD_FILES', - testId: 'upload-view', - }, - { - view: 'UploadView', - actionType: 'UPLOAD_FOLDER', + actionType: 'upload', testId: 'upload-view', }, ])( diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getPercentValue.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getPercentValue.spec.ts new file mode 100644 index 00000000000..72d27ac1f15 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/getPercentValue.spec.ts @@ -0,0 +1,9 @@ +import { getPercentValue } from '../getPercentValue'; + +describe('getPercentValue', () => { + it('calculates the percentage of a number', () => { + expect(getPercentValue(0.01)).toBe(1); + expect(getPercentValue(0.5)).toBe(50); + expect(getPercentValue(1)).toBe(100); + }); +}); 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 5d0970f85a9..7d727005b2d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getActionViewTableData.ts @@ -6,7 +6,7 @@ import { Task, TaskStatus } from '../../tasks'; import { isFileItem, isFileDataItem, TaskData } from '../../actions'; import { getActionIcon } from './getActionIcon'; import { getFileTypeDisplayValue } from './getFileTypeDisplayValue'; -import { getPercentValue } from '../utils'; +import { getPercentValue } from './getPercentValue'; import { getDefaultActionViewHeaders } from './getDefaultActionViewHeaders'; import { ActionViewHeaders } from './types'; import { DefaultActionViewDisplayText } from '../../displayText/types'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getPercentValue.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getPercentValue.ts new file mode 100644 index 00000000000..4a6588ba881 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/getPercentValue.ts @@ -0,0 +1,2 @@ +export const getPercentValue = (value: number): number => + Math.round(value * 100); 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 98fe0f09e0a..ca7f5952ca4 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/index.ts @@ -1,7 +1,22 @@ -export { CopyViewState, useCopyView } from './CopyView'; -export { CreateFolderViewState, useCreateFolderView } from './CreateFolderView'; -export { DeleteViewState, useDeleteView } from './DeleteView'; -export { UploadViewState, useUploadView } from './UploadView'; +export { CopyView, CopyViewType, CopyViewState, useCopyView } from './CopyView'; +export { + CreateFolderView, + CreateFolderViewType, + CreateFolderViewState, + useCreateFolderView, +} from './CreateFolderView'; +export { + DeleteView, + DeleteViewType, + DeleteViewState, + useDeleteView, +} from './DeleteView'; +export { + UploadView, + UploadViewType, + UploadViewState, + useUploadView, +} from './UploadView'; export { useActionView } from './useActionView'; export { 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 32b76ff9331..860ad3a0f4b 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/types.ts @@ -40,23 +40,16 @@ export interface LocationActionViewProps< type?: T; } -export type LocationActionViewComponent< +export type LocationActionViewType< T = string, K extends TaskData = TaskData, > = (props: LocationActionViewProps) => React.JSX.Element | null; -export interface ActionViewComponent { +export interface ActionViewType { ( props: ActionViewProps & Partial> & K ): React.JSX.Element | null; displayName: string; - Cancel: () => React.JSX.Element | null; - Destination: () => React.JSX.Element | null; - Exit: () => React.JSX.Element | null; - Start: () => React.JSX.Element | null; - StatusDisplay: () => React.JSX.Element | null; - Table: () => React.JSX.Element | null; - Title: () => React.JSX.Element | null; } // Custom actions derived views @@ -65,7 +58,7 @@ export type DerivedActionViews = { ? never : T[K] extends { componentName: ComponentName } ? T[K]['componentName'] - : never]: ActionViewComponent< + : never]: ActionViewType< T[K] extends TaskActionConfig>> ? X : never 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 97cd3fff4f8..fd64dcb26eb 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx @@ -1,27 +1,26 @@ import React from 'react'; +import { + STORAGE_BROWSER_BLOCK, + STORAGE_BROWSER_BLOCK_TO_BE_UPDATED, +} from '../../constants'; import { ViewElement } from '../../context/elements'; import { ActionsListControl } from '../../controls/ActionsListControl'; import { DataTableControl } from '../../controls/DataTableControl'; import { DataRefreshControl } from '../../controls/DataRefreshControl'; import { DropZoneControl } from '../../controls/DropZoneControl'; +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 { SearchSubfoldersToggleControl } from '../../controls/SearchSubfoldersToggleControl'; import { TitleControl } from '../../controls/TitleControl'; -import { ControlsContextProvider } from '../../controls/context'; -import { useDisplayText } from '../../displayText'; -import { - STORAGE_BROWSER_BLOCK, - STORAGE_BROWSER_BLOCK_TO_BE_UPDATED, -} from '../../constants'; -import { resolveClassName } from '../utils'; -import { getLocationDetailViewTableData } from './getLocationDetailViewTableData'; + +import { LocationDetailViewType } from './types'; import { useLocationDetailView } from './useLocationDetailView'; -import { LocationDetailViewProps } from './types'; -import { LoadingIndicator } from '../../composables/LoadingIndicator'; +import { LocationDetailViewProvider } from './LocationDetailViewProvider'; +import { classNames } from '@aws-amplify/ui'; const DEFAULT_PAGE_SIZE = 100; export const DEFAULT_LIST_OPTIONS = { @@ -29,123 +28,20 @@ export const DEFAULT_LIST_OPTIONS = { delimiter: '/', }; -export function LocationDetailView({ +export const LocationDetailView: LocationDetailViewType = ({ className, ...props -}: LocationDetailViewProps): React.JSX.Element { - const { - LocationDetailView: { - loadingIndicatorLabel, - searchSubfoldersToggleLabel, - selectFileLabel, - selectAllFilesLabel, - searchPlaceholder, - searchSubmitLabel, - searchClearLabel, - getTitle, - getListItemsResultMessage, - }, - } = useDisplayText(); - - const loadingIndicator = ( - - ); - - const { - actions, - page, - pageItems, - hasNextPage, - highestPageVisited, - isLoading, - isSearchingSubfolders, - location, - areAllFilesSelected, - fileDataItems, - hasFiles, - hasError, - hasDownloadError, - message, - downloadErrorMessage, - searchQuery, - hasExhaustedSearch, - onActionSelect, - onDropFiles, - onRefresh, - onPaginate, - onDownload, - onNavigate, - onNavigateHome, - onSelect, - onSelectAll, - onSearch, - onSearchQueryChange, - onSearchClear, - onToggleSearchSubfolders, - } = useLocationDetailView(props); - - const messageControlContent = getListItemsResultMessage({ - items: pageItems, - hasError: hasError || hasDownloadError, - hasExhaustedSearch, - message: hasError ? message : downloadErrorMessage, - }); +}) => { + const state = useLocationDetailView(props); + const { hasError } = state; return ( -
- - + + - + - - + + {hasError ? null : ( @@ -171,10 +61,27 @@ export function LocationDetailView({ )} - + + - -
+ + ); -} +}; + +LocationDetailView.displayName = 'LocationDetailView'; + +LocationDetailView.Provider = LocationDetailViewProvider; + +LocationDetailView.ActionsList = ActionsListControl; +LocationDetailView.DropZone = DropZoneControl; +LocationDetailView.LoadingIndicator = LoadingIndicatorControl; +LocationDetailView.LocationItemsTable = DataTableControl; +LocationDetailView.Message = MessageControl; +LocationDetailView.Navigation = NavigationControl; +LocationDetailView.Pagination = PaginationControl; +LocationDetailView.Refresh = DataRefreshControl; +LocationDetailView.Search = SearchControl; +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 new file mode 100644 index 00000000000..53616d138c1 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailViewProvider.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../controls/context'; +import { useDisplayText } from '../../displayText'; + +import { LocationDetailViewProviderProps } from './types'; +import { getLocationDetailViewTableData } from './getLocationDetailViewTableData'; + +export function LocationDetailViewProvider({ + children, + ...props +}: LocationDetailViewProviderProps): React.JSX.Element { + const { + LocationDetailView: { + loadingIndicatorLabel, + searchSubfoldersToggleLabel, + selectFileLabel, + selectAllFilesLabel, + searchPlaceholder, + searchSubmitLabel, + searchClearLabel, + getTitle, + getListItemsResultMessage, + }, + } = useDisplayText(); + + const { + actions, + page, + pageItems, + hasNextPage, + highestPageVisited, + isLoading, + isSearchingSubfolders, + location, + areAllFilesSelected, + fileDataItems, + hasFiles, + hasError, + hasDownloadError, + message, + downloadErrorMessage, + searchQuery, + hasExhaustedSearch, + onActionSelect, + onDropFiles, + onRefresh, + onPaginate, + onDownload, + onNavigate, + onNavigateHome, + onSelect, + onSelectAll, + onSearch, + onSearchQueryChange, + onSearchClear, + onToggleSearchSubfolders, + } = props; + + const messageControlContent = getListItemsResultMessage({ + isLoading, + items: pageItems, + hasError: hasError || hasDownloadError, + hasExhaustedSearch, + message: hasError ? message : downloadErrorMessage, + }); + + return ( + + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/LocationDetailView.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/LocationDetailView.spec.tsx index 0c84da29c19..299379e96fd 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/LocationDetailView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/LocationDetailView.spec.tsx @@ -16,13 +16,7 @@ import { import { useProcessTasks } from '../../../tasks/useProcessTasks'; import { INITIAL_STATUS_COUNTS } from '../../../tasks'; import { useDisplayText } from '../../../displayText'; -import { SearchOutput } from '../../../actions/createEnhancedListHandler'; - -// FIXME: Temporarily mock... 😎 temp actions hook -import { useTempActions } from '../../../do-not-import-from-here/createTempActionsProvider'; -jest.mock('../../../do-not-import-from-here/createTempActionsProvider'); -const mockUseTempActions = useTempActions as jest.Mock; -mockUseTempActions.mockReturnValue({}); +import { SearchOutput } from '../../../actions/useAction/createEnhancedListHandler'; jest.mock('../../../displayText', () => { const mockGetListItemsResultMessage = jest.fn(); @@ -160,6 +154,37 @@ describe('LocationDetailView', () => { 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.'; @@ -175,12 +200,30 @@ describe('LocationDetailView', () => { expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ items: expect.any(Array), + isLoading: false, hasError: true, message: errorMessage, hasExhaustedSearch: false, }); }); + it('invokes getListItemsResultMessage() with `isLoading` param', () => { + mockListItemsAction({ + isLoading: true, + hasError: false, + result: [], + }); + + render(); + + expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ + items: [], + isLoading: true, + hasError: false, + hasExhaustedSearch: false, + }); + }); + it('invokes getListItemsResultMessage() with expected params when there is a download error', () => { mockUseProcessTasks.mockReturnValueOnce([ { @@ -214,6 +257,7 @@ describe('LocationDetailView', () => { expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ items: expect.any(Array), hasError: true, + isLoading: false, message: 'Failed to download test-key due to error: NotFound.', hasExhaustedSearch: false, }); @@ -296,6 +340,7 @@ describe('LocationDetailView', () => { expect(mockGetListItemsResultMessage).toHaveBeenCalledWith({ items: expect.any(Array), hasExhaustedSearch: true, + isLoading: false, hasError: false, message: undefined, }); 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 60f453be34e..0d9a6d53f80 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 @@ -21,12 +21,6 @@ import { DEFAULT_LIST_OPTIONS, } from '../useLocationDetailView'; -// FIXME: Temporarily mock... 😎 temp actions hook -import { useTempActions } from '../../../do-not-import-from-here/createTempActionsProvider'; -jest.mock('../../../do-not-import-from-here/createTempActionsProvider'); -const mockUseTempActions = useTempActions as jest.Mock; -mockUseTempActions.mockReturnValue({}); - const useDataStateSpy = jest.spyOn(AmplifyReactCore, 'useDataState'); const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); @@ -505,7 +499,7 @@ describe('useLocationDetailView', () => { }); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SET_ACTION_TYPE', - actionType: 'UPLOAD_FILES', + actionType: 'upload', }); }); @@ -528,7 +522,7 @@ describe('useLocationDetailView', () => { }); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SET_ACTION_TYPE', - actionType: 'UPLOAD_FOLDER', + actionType: 'upload', }); }); @@ -554,7 +548,7 @@ describe('useLocationDetailView', () => { }); expect(mockDispatchStoreAction).toHaveBeenCalledWith({ type: 'SET_ACTION_TYPE', - actionType: 'UPLOAD_FILES', + actionType: 'upload', }); }); @@ -661,7 +655,6 @@ describe('useLocationDetailView', () => { const mockOnActionSelect = jest.fn(); const actionType = 'action-type'; useStoreSpy.mockReturnValue([testStoreState, mockDispatchStoreAction]); - mockUseTempActions.mockReturnValueOnce({}); const { result } = renderHook(() => useLocationDetailView({ onActionSelect: mockOnActionSelect }) diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/__tests__/getLocationDetailViewTableData.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/__tests__/getLocationDetailViewTableData.spec.ts index c0e0155e43b..e8d2ee59ba2 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/__tests__/getLocationDetailViewTableData.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/__tests__/getLocationDetailViewTableData.spec.ts @@ -111,7 +111,7 @@ describe('getLocationDetailViewTableData', () => { }), expect.objectContaining({ content: { label: 'Name' } }), expect.objectContaining({ content: { label: 'Type' } }), - expect.objectContaining({ content: { label: 'Last Modified' } }), + expect.objectContaining({ content: { label: 'Last modified' } }), expect.objectContaining({ content: { label: 'Size' } }), expect.objectContaining({ content: { text: '' } }), ], diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/constants.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/constants.ts index c61327d023d..f18a883fa87 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/constants.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/getLocationDetailViewTableData/constants.ts @@ -4,7 +4,7 @@ export const LOCATION_DETAIL_VIEW_HEADERS: LocationDetailViewHeaders = [ { key: 'checkbox', type: 'text', content: { text: '' } }, { key: 'name', type: 'sort', content: { label: 'Name' } }, { key: 'type', type: 'sort', content: { label: 'Type' } }, - { key: 'last-modified', type: 'sort', content: { label: 'Last Modified' } }, + { key: 'last-modified', type: 'sort', content: { label: 'Last modified' } }, { key: 'size', type: 'sort', content: { label: 'Size' } }, { key: 'download', type: 'text', content: { text: '' } }, ]; 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 ad6f9c299e3..3074020d7ed 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts @@ -1,6 +1,88 @@ +import { + FileData, + FileDataItem, + LocationData, + LocationItemData, +} from '../../actions'; +import { ActionsListItem } from '../../composables/ActionsList'; +import { LocationState } from '../../providers/store/location'; + import { ListViewProps } from '../types'; +export interface LocationDetailViewState { + actions: ActionsListItem[]; + hasError: boolean; + hasNextPage: boolean; + hasDownloadError: boolean; + highestPageVisited: number; + isLoading: boolean; + isSearchingSubfolders: 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; + onActionSelect: (actionType: string) => 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; + onSearch: () => void; + onSearchClear: () => void; + onSearchQueryChange: (value: string) => void; + onToggleSearchSubfolders: () => void; +} + export interface LocationDetailViewProps extends ListViewProps { onActionSelect?: (type: string) => void; onExit?: () => void; } + +export interface LocationDetailViewProviderProps + extends LocationDetailViewState { + children?: React.ReactNode; +} + +export interface LocationDetailViewType { + ( + props: { + children?: React.ReactNode; + className?: string; + } & LocationDetailViewProps + ): React.JSX.Element | null; + displayName: string; + Provider: (props: LocationDetailViewProviderProps) => React.JSX.Element; + ActionsList: () => React.JSX.Element | null; + DropZone: (props: { children: React.ReactNode }) => React.JSX.Element | null; + LoadingIndicator: () => React.JSX.Element | null; + LocationItemsTable: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Navigation: () => React.JSX.Element | null; + Pagination: () => React.JSX.Element | null; + Refresh: () => React.JSX.Element | null; + Search: () => React.JSX.Element | null; + SearchSubfoldersToggle: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} + +interface InitialValues { + pageSize?: number; + delimiter?: string; +} + +export interface UseLocationDetailViewOptions { + initialValues?: InitialValues; + onActionSelect?: (actionType: string) => void; + onExit?: () => void; + onNavigate?: (location: LocationData, path?: string) => void; +} 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 a2d572cbdb6..1ef69380f67 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/useLocationDetailView.ts @@ -8,80 +8,20 @@ import { useStore } from '../../providers/store'; import { FileData, LocationData, - LocationItemData, listLocationItemsHandler, } from '../../actions'; -import { isFile } from '../utils'; -import { createEnhancedListHandler } from '../../actions/createEnhancedListHandler'; +import { createEnhancedListHandler } from '../../actions/useAction/createEnhancedListHandler'; import { useGetActionInput } from '../../providers/configuration'; -import { LocationState } from '../../providers/store/location'; import { useSearch } from '../hooks/useSearch'; -import { ActionsListItem } from '../../composables/ActionsList'; -import { useTempActions } from '../../do-not-import-from-here/createTempActionsProvider'; -import { IconVariant } from '../../context/elements'; -import { toAccessGrantPermission } from '../../adapters/permissionParsers'; + import { Tasks, useProcessTasks } from '../../tasks'; import { downloadHandler, DownloadHandlerData, FileDataItem, -} from '../../actions/handlers'; - -interface UseLocationDetailView { - actions: ActionsListItem[]; - hasError: boolean; - hasNextPage: boolean; - hasDownloadError: boolean; - highestPageVisited: number; - isLoading: boolean; - isSearchingSubfolders: 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; - onActionSelect: (actionType: string) => 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; - onSearch: () => void; - onSearchClear: () => void; - onSearchQueryChange: (value: string) => void; - onToggleSearchSubfolders: () => void; -} - -export type LocationDetailViewActionType = - | { type: 'REFRESH_DATA' } // refresh data only - | { type: 'RESET' } // reset view to initial state - | { type: 'PAGINATE'; page: number } - | { type: 'ACCESS_ITEM'; key: string } - | { type: 'NAVIGATE'; location: LocationData; path: string } - | { type: 'ADD_FILES'; files: File[] } - | { type: 'SEARCH'; query: string; includeSubfolders?: boolean }; - -interface InitialValues { - pageSize?: number; - delimiter?: string; -} - -export interface UseLocationDetailViewOptions { - initialValues?: InitialValues; - onDispatch?: React.Dispatch; - onActionSelect?: (actionType: string) => void; - onExit?: () => void; - onNavigate?: (location: LocationData, path?: string) => void; -} + defaultActionViewConfigs, +} from '../../actions'; +import { LocationDetailViewState, UseLocationDetailViewOptions } from './types'; const DEFAULT_PAGE_SIZE = 100; export const DEFAULT_LIST_OPTIONS = { @@ -105,9 +45,9 @@ const getDownloadErrorMessageFromFailedDownloadTask = ( } due to error: ${tasks[0].message}.`; }; -export function useLocationDetailView( +export const useLocationDetailView = ( options?: UseLocationDetailViewOptions -): UseLocationDetailView { +): LocationDetailViewState => { const getConfig = useGetActionInput(); const { initialValues, onExit, onNavigate } = options ?? {}; @@ -118,8 +58,6 @@ export function useLocationDetailView( const listOptions = listOptionsRef.current; - const tempActions = useTempActions(); - const [{ location, locationItems }, dispatchStoreAction] = useStore(); const { current, key } = location; const { permissions, prefix } = current ?? {}; @@ -130,10 +68,7 @@ export function useLocationDetailView( const [{ data, isLoading, hasError, message }, handleList] = useDataState( listLocationItemsAction, - { - items: [], - nextToken: undefined, - } + { items: [], nextToken: undefined } ); // set up pagination @@ -228,26 +163,29 @@ export function useLocationDetailView( const shouldShowEmptyMessage = pageItems.length === 0 && !isLoading && !hasError; - // FIXME: Temporarily get from... 😎 temp actions hook const actions = React.useMemo(() => { if (!permissions) { return []; } - return Object.entries(tempActions).map(([actionType, { options }]) => { - const { icon, hide, disable, displayName } = options ?? {}; - return { - actionType, - icon: (icon as { props: { variant: IconVariant } }).props.variant, - isDisabled: isFunction(disable) - ? disable(fileDataItems ?? []) - : disable ?? false, - isHidden: isFunction(hide) - ? hide(toAccessGrantPermission(permissions)) - : hide, - label: displayName, - }; - }); - }, [fileDataItems, permissions, tempActions]); + + 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 { actions, @@ -280,13 +218,9 @@ export function useLocationDetailView( }, onDropFiles: (files: File[]) => { dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); - const actionType = files.some((file) => isFile(file)) - ? 'UPLOAD_FILES' - : 'UPLOAD_FOLDER'; - dispatchStoreAction({ - type: 'SET_ACTION_TYPE', - actionType, - }); + + const actionType = 'upload'; + dispatchStoreAction({ type: 'SET_ACTION_TYPE', actionType }); options?.onActionSelect?.(actionType); }, onDownload: (data: FileDataItem) => { @@ -337,4 +271,4 @@ export function useLocationDetailView( onSearchQueryChange, onToggleSearchSubfolders, }; -} +}; 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 1fab2b24982..c149ef5d738 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx @@ -1,162 +1,33 @@ import React from 'react'; +import { + STORAGE_BROWSER_BLOCK, + STORAGE_BROWSER_BLOCK_TO_BE_UPDATED, +} from '../../constants'; import { ViewElement } from '../../context/elements'; import { DataRefreshControl } from '../../controls/DataRefreshControl'; 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 { TitleControl } from '../../controls/TitleControl'; -import { ControlsContextProvider } from '../../controls/context'; -import { useDisplayText } from '../../displayText'; -import { - STORAGE_BROWSER_BLOCK, - STORAGE_BROWSER_BLOCK_TO_BE_UPDATED, -} from '../../constants'; -import { resolveClassName } from '../utils'; -import { getLocationsViewTableData } from './getLocationsViewTableData'; -import { LocationViewHeaders } from './getLocationsViewTableData/types'; -import { useLocationsView } from './useLocationsView'; -import { LocationsViewProps } from './types'; -import { LoadingIndicator } from '../../composables/LoadingIndicator'; -import { MessageControl } from '../../controls/MessageControl'; - -const getHeaders = ({ - hasObjectLocations, - tableColumnBucketHeader, - tableColumnFolderHeader, - tableColumnPermissionsHeader, - tableColumnActionsHeader, -}: { - hasObjectLocations: boolean; - tableColumnBucketHeader: string; - tableColumnFolderHeader: string; - tableColumnPermissionsHeader: string; - tableColumnActionsHeader: string; -}): LocationViewHeaders => { - const headers: LocationViewHeaders = [ - { - key: 'folder', - type: 'sort', - content: { label: tableColumnFolderHeader }, - }, - { - key: 'bucket', - type: 'sort', - content: { label: tableColumnBucketHeader }, - }, - { - key: 'permission', - type: 'sort', - content: { label: tableColumnPermissionsHeader }, - }, - ]; - if (hasObjectLocations) { - headers.push({ - key: 'action', - type: 'sort', - content: { label: tableColumnActionsHeader }, - }); - } - - return headers; -}; - -export function LocationsView({ - className, - ...props -}: LocationsViewProps): React.JSX.Element { - const { - LocationsView: { - loadingIndicatorLabel, - title, - tableColumnBucketHeader, - tableColumnFolderHeader, - tableColumnPermissionsHeader, - tableColumnActionsHeader, - searchPlaceholder, - searchSubmitLabel, - searchClearLabel, - getDownloadLabel, - getPermissionName, - getListLocationsResultMessage, - }, - } = useDisplayText(); - - const { - hasError, - hasNextPage, - highestPageVisited, - page, - isLoading, - searchQuery, - pageItems, - message, - onDownload, - onRefresh, - onPaginate, - onNavigate, - onSearch, - onSearchQueryChange, - onSearchClear, - } = useLocationsView(props); - - const loadingIndicator = ( - - ); - - // TODO: add hasExhaustedSearch + query param - const messageControlContent = getListLocationsResultMessage({ - locations: pageItems, - hasError, - message, - }); +import { LocationsViewProvider } from './LocationsViewProvider'; +import { LocationsViewType } from './types'; +import { useLocationsView } from './useLocationsView'; +import { classNames } from '@aws-amplify/ui'; - const headers = getHeaders({ - hasObjectLocations: pageItems.some(({ type }) => type === 'OBJECT'), - tableColumnBucketHeader, - tableColumnFolderHeader, - tableColumnPermissionsHeader, - tableColumnActionsHeader, - }); +export const LocationsView: LocationsViewType = ({ className, ...props }) => { + const state = useLocationsView(props); + const { hasError } = state; return ( - -
+ - - + + {hasError ? null : } + -
-
+ + ); -} +}; + +LocationsView.displayName = 'LocationsView'; + +LocationsView.Provider = LocationsViewProvider; + +LocationsView.LoadingIndicator = LoadingIndicatorControl; +LocationsView.LocationsTable = DataTableControl; +LocationsView.Message = MessageControl; +LocationsView.Pagination = PaginationControl; +LocationsView.Refresh = DataRefreshControl; +LocationsView.Search = SearchControl; +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 new file mode 100644 index 00000000000..0c87f6c8721 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsViewProvider.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { ControlsContextProvider } from '../../controls/context'; +import { useDisplayText } from '../../displayText'; + +import { LocationsViewProviderProps } from './types'; +import { getLocationsViewTableData } from './getLocationsViewTableData'; + +export function LocationsViewProvider({ + children, + ...props +}: LocationsViewProviderProps): React.JSX.Element { + const { LocationsView: displayText } = useDisplayText(); + const { + loadingIndicatorLabel, + title, + searchPlaceholder, + searchSubmitLabel, + searchClearLabel, + getListLocationsResultMessage, + } = displayText; + + const { + hasError, + hasNextPage, + highestPageVisited, + page, + isLoading, + searchQuery, + pageItems, + message, + onDownload, + onRefresh, + onPaginate, + onNavigate, + onSearch, + onSearchQueryChange, + onSearchClear, + } = props; + + // TODO: add hasExhaustedSearch + query param + const messageControlContent = getListLocationsResultMessage({ + isLoading, + locations: pageItems, + hasError, + message, + }); + + return ( + + {children} + + ); +} 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 d5e29c2aac0..de9fae161a5 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 @@ -2,7 +2,7 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import * as ActionsModule from '../../../do-not-import-from-here/actions'; +import * as ActionsModule from '../../../actions'; import * as ConfigModule from '../../../providers/configuration'; import * as StoreModule from '../../../providers/store'; @@ -30,7 +30,7 @@ jest .mockReturnValue([{} as StoreModule.UseStoreState, dispatchStoreAction]); const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); -const useLocationsDataSpy = jest.spyOn(ActionsModule, 'useLocationsData'); +const useListLocationsSpy = jest.spyOn(ActionsModule, 'useListLocations'); const mockUseDisplayText = jest.mocked(useDisplayText); const mockGetListLocationsResultMessage = jest.mocked( mockUseDisplayText().LocationsView.getListLocationsResultMessage @@ -53,9 +53,9 @@ const generateMockItems = (size: number, page: number): LocationData[] => { }; const handleListLocations = jest.fn(); -const initialState: ActionsModule.LocationsDataState = [ +const initialState: ActionsModule.UseListLocationsState = [ { - data: { result: [], nextToken: undefined }, + data: { items: [], nextToken: undefined }, hasError: false, isLoading: false, message: undefined, @@ -63,9 +63,9 @@ const initialState: ActionsModule.LocationsDataState = [ handleListLocations, ]; -const loadingState: ActionsModule.LocationsDataState = [ +const loadingState: ActionsModule.UseListLocationsState = [ { - data: { result: [], nextToken: undefined }, + data: { items: [], nextToken: undefined }, hasError: false, isLoading: true, message: undefined, @@ -74,12 +74,12 @@ const loadingState: ActionsModule.LocationsDataState = [ ]; const EXPECTED_PAGE_SIZE = DEFAULT_LIST_OPTIONS.pageSize; -const results: LocationData[] = generateMockItems(EXPECTED_PAGE_SIZE, 1); +const items: LocationData[] = generateMockItems(EXPECTED_PAGE_SIZE, 1); -const resolvedState: ActionsModule.LocationsDataState = [ +const resolvedState: ActionsModule.UseListLocationsState = [ { data: { - result: results, + items, nextToken: 'some-token', }, hasError: false, @@ -89,12 +89,12 @@ const resolvedState: ActionsModule.LocationsDataState = [ handleListLocations, ]; -const nextPageResults = generateMockItems(EXPECTED_PAGE_SIZE, 2); +const nextPageitems = generateMockItems(EXPECTED_PAGE_SIZE, 2); -const nextPageState: ActionsModule.LocationsDataState = [ +const nextPageState: ActionsModule.UseListLocationsState = [ { data: { - result: [...results, ...nextPageResults], + items: [...items, ...nextPageitems], nextToken: undefined, }, hasError: false, @@ -111,16 +111,26 @@ const config: ActionInputConfig = { }; useGetActionSpy.mockReturnValue(() => config); -describe('LocationsListView', () => { +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', () => { - useLocationsDataSpy.mockReturnValue([ + useListLocationsSpy.mockReturnValue([ { - data: { result: results, nextToken: undefined }, + data: { items, nextToken: undefined }, hasError: true, isLoading: false, message: undefined, @@ -130,15 +140,15 @@ describe('LocationsListView', () => { render(); - expect(useLocationsDataSpy).toHaveBeenCalled(); + expect(useListLocationsSpy).toHaveBeenCalled(); }); it('invokes getListLocationsResultMessage() with `errorMessage` param', () => { const errorMessage = 'Something went wrong.'; - useLocationsDataSpy.mockReturnValue([ + useListLocationsSpy.mockReturnValue([ { - data: { result: results, nextToken: undefined }, + data: { items, nextToken: undefined }, hasError: true, isLoading: false, message: errorMessage, @@ -150,6 +160,7 @@ describe('LocationsListView', () => { expect(mockGetListLocationsResultMessage).toHaveBeenCalledWith({ locations: expect.any(Array), + isLoading: false, hasError: true, message: errorMessage, }); @@ -165,8 +176,32 @@ describe('LocationsListView', () => { 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, + }); + }); + it('renders a Locations View table', () => { - useLocationsDataSpy.mockReturnValue(resolvedState); + useListLocationsSpy.mockReturnValue(resolvedState); render(); @@ -180,7 +215,7 @@ describe('LocationsListView', () => { it.todo('handles empty locations result data as expected'); it('behaves as expected on initial render', () => { - useLocationsDataSpy + useListLocationsSpy .mockReturnValueOnce(initialState) .mockReturnValueOnce(loadingState) .mockReturnValue(resolvedState); @@ -205,7 +240,7 @@ describe('LocationsListView', () => { }); it('refreshes table when refresh button is clicked', async () => { - useLocationsDataSpy.mockReturnValue(resolvedState); + useListLocationsSpy.mockReturnValue(resolvedState); render(); @@ -224,17 +259,17 @@ describe('LocationsListView', () => { it('refreshes locations on handleListLocations reference change', () => { const updatedHandleListLocations = jest.fn(); - useLocationsDataSpy.mockReturnValue(initialState); + useListLocationsSpy.mockReturnValue(initialState); // initial const { rerender } = render(); - useLocationsDataSpy.mockReturnValue(loadingState); + useListLocationsSpy.mockReturnValue(loadingState); // loading rerender(); - useLocationsDataSpy.mockReturnValueOnce(resolvedState); + useListLocationsSpy.mockReturnValueOnce(resolvedState); // resolved rerender(); @@ -242,15 +277,14 @@ describe('LocationsListView', () => { expect(handleListLocations).toHaveBeenCalledTimes(1); expect(handleListLocations).toHaveBeenCalledWith({ options: { - // FIXME: update the exclude type after migration to new actions - exclude: 'WRITE', + exclude: { exactPermissions: ['delete', 'write'] }, pageSize: EXPECTED_PAGE_SIZE, refresh: true, }, }); expect(updatedHandleListLocations).not.toHaveBeenCalled(); - useLocationsDataSpy.mockReturnValue([ + useListLocationsSpy.mockReturnValue([ { ...resolvedState[0] }, updatedHandleListLocations, ]); @@ -262,8 +296,7 @@ describe('LocationsListView', () => { expect(updatedHandleListLocations).toHaveBeenCalledTimes(1); expect(updatedHandleListLocations).toHaveBeenCalledWith({ options: { - // FIXME: update the exclude type after migration to new actions - exclude: 'WRITE', + exclude: { exactPermissions: ['delete', 'write'] }, pageSize: EXPECTED_PAGE_SIZE, refresh: true, }, @@ -271,7 +304,7 @@ describe('LocationsListView', () => { }); it('can paginate forward and back', async () => { - useLocationsDataSpy.mockReturnValue(resolvedState); + useListLocationsSpy.mockReturnValue(resolvedState); render(); // table renders @@ -287,7 +320,7 @@ describe('LocationsListView', () => { expect(screen.queryByText('item-0/')).toBeInTheDocument(); expect(screen.queryByText('item-101/')).not.toBeInTheDocument(); - useLocationsDataSpy.mockReturnValue(nextPageState); + useListLocationsSpy.mockReturnValue(nextPageState); // go forward await act(async () => { @@ -315,7 +348,7 @@ describe('LocationsListView', () => { }); it('should navigate to detail page when folder is clicked', async () => { - useLocationsDataSpy.mockReturnValue(resolvedState); + useListLocationsSpy.mockReturnValue(resolvedState); render(); const scopeButton = await screen.findByText('item-0/'); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/getLocationsViewTableData.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/getLocationsViewTableData.spec.ts index f75f1d07fc6..574cfaf501c 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/getLocationsViewTableData.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/getLocationsViewTableData.spec.ts @@ -1,9 +1,10 @@ -import { getLocationsViewTableData } from '../getLocationsViewTableData'; -import { LocationViewHeaders } from '../getLocationsViewTableData/types'; -import { DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT } from '../../../displayText/libraries/en/locationsView'; import { LocationData } from '../../../actions'; +import { DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT } from '../../../displayText/libraries/en/locationsView'; + +import { getLocationsViewTableData } from '../getLocationsViewTableData'; +import { getHeaders } from '../getLocationsViewTableData/getHeaders'; -const { getPermissionName } = DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT; +jest.mock('../getLocationsViewTableData/getHeaders'); describe('getLocationsViewTableData', () => { const folderLocation1: LocationData = { @@ -31,33 +32,35 @@ describe('getLocationsViewTableData', () => { permissions: ['list'], }; - const headers: LocationViewHeaders = [ - { - key: 'folder', - type: 'sort', - content: { label: 'Folder' }, - }, - { - key: 'bucket', - type: 'sort', - content: { label: 'Bucket' }, - }, - { - key: 'permission', - type: 'sort', - content: { label: 'Permission' }, - }, - { - key: 'action', - type: 'sort', - content: { label: 'Actions' }, - }, - ]; - - // create mocks + const mockGetHeaders = jest.mocked(getHeaders); const mockOnNavigate = jest.fn(); const mockOnDownload = jest.fn(); + beforeAll(() => { + mockGetHeaders.mockReturnValue([ + { + key: 'folder', + type: 'sort', + content: { label: 'Folder' }, + }, + { + key: 'bucket', + type: 'sort', + content: { label: 'Bucket' }, + }, + { + key: 'permission', + type: 'sort', + content: { label: 'Permission' }, + }, + { + key: 'action', + type: 'sort', + content: { label: 'Actions' }, + }, + ]); + }); + afterEach(() => { mockOnNavigate.mockClear(); }); @@ -68,9 +71,7 @@ describe('getLocationsViewTableData', () => { onDownload: mockOnDownload, pageItems: [folderLocation1], onNavigate: mockOnNavigate, - headers, - getDownloadLabel: () => 'download', - getPermissionName, + displayText: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, }) ).toStrictEqual({ headers: [ @@ -114,9 +115,7 @@ describe('getLocationsViewTableData', () => { onDownload: mockOnDownload, pageItems: [objectLocation], onNavigate: mockOnNavigate, - headers, - getDownloadLabel: () => 'download', - getPermissionName, + displayText: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, }) ).toMatchObject({ rows: [ @@ -154,9 +153,7 @@ describe('getLocationsViewTableData', () => { onDownload: mockOnDownload, pageItems: [listOnlyObjectLocation], onNavigate: mockOnNavigate, - headers, - getDownloadLabel: () => 'download', - getPermissionName, + displayText: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, }) ).toMatchObject({ rows: [ @@ -195,9 +192,7 @@ describe('getLocationsViewTableData', () => { pageItems: [{ ...folderLocation1, prefix: '' }], onNavigate: mockOnNavigate, onDownload: mockOnDownload, - headers, - getDownloadLabel: () => 'download', - getPermissionName, + displayText: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, }) ).toStrictEqual( expect.objectContaining({ @@ -222,9 +217,7 @@ describe('getLocationsViewTableData', () => { pageItems: [folderLocation1, folderLocation2], onNavigate: mockOnNavigate, onDownload: mockOnDownload, - headers, - getDownloadLabel: () => 'download', - getPermissionName, + displayText: DEFAULT_LOCATIONS_VIEW_DISPLAY_TEXT, }); const [row1FirstContent] = tableData.rows[0].content; const [row2FirstContent] = tableData.rows[1].content; 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 f98ce491ce5..442577099b2 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 @@ -2,17 +2,12 @@ import { renderHook, act } from '@testing-library/react'; import { DataState } from '@aws-amplify/ui-react-core'; import { useLocationsView, DEFAULT_LIST_OPTIONS } from '../useLocationsView'; -import { - ActionInputConfig, - FileDataItem, - LocationData, -} from '../../../actions'; -import * as ActionsModule from '../../../do-not-import-from-here/actions'; + +import * as ActionsModule from '../../../actions'; import * as StoreModule from '../../../providers/store'; import * as TasksModule from '../../../tasks'; import * as ConfigModule from '../../../providers/configuration'; -import { ListLocationsActionOutput } from '../../../do-not-import-from-here/actions/listLocationsAction'; import { createFileDataItemFromLocation } from '../../../actions/handlers'; jest.useFakeTimers(); @@ -23,10 +18,10 @@ jest .spyOn(StoreModule, 'useStore') .mockReturnValue([{} as StoreModule.UseStoreState, dispatchStoreAction]); -const useLocationsDataSpy = jest.spyOn(ActionsModule, 'useLocationsData'); +const useLocationsDataSpy = jest.spyOn(ActionsModule, 'useListLocations'); const useGetActionSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); -const mockData: LocationData[] = [ +const mockData: ActionsModule.LocationData[] = [ { bucket: 'test-bucket', prefix: `item-a/`, @@ -66,14 +61,14 @@ const mockData: LocationData[] = [ const EXPECTED_PAGE_SIZE = 3; function mockUseLocationsData( - returnValue: DataState + returnValue: DataState ) { const handleList = jest.fn(); useLocationsDataSpy.mockReturnValue([returnValue, handleList]); return handleList; } -const taskOne: TasksModule.Task = { +const taskOne: TasksModule.Task = { data: { fileKey: 'key', id: 'id', @@ -99,7 +94,7 @@ jest.spyOn(TasksModule, 'useProcessTasks').mockReturnValue([ handleDownload, ]); -const config: ActionInputConfig = { +const config: ActionsModule.ActionInputConfig = { bucket: 'bucky', credentials: jest.fn(), region: 'us-weast-1', @@ -113,7 +108,7 @@ describe('useLocationsView', () => { it('should fetch and set location data on mount', () => { const mockDataState = { - data: { result: mockData, nextToken: undefined }, + data: { items: mockData, nextToken: undefined }, message: '', hasError: false, isLoading: false, @@ -140,7 +135,7 @@ describe('useLocationsView', () => { // empty state mockUseLocationsData({ data: { - result: [], + items: [], nextToken: undefined, }, message: '', @@ -158,7 +153,7 @@ describe('useLocationsView', () => { // mock first page data const mockDataState = { data: { - result: mockData.slice(0, EXPECTED_PAGE_SIZE), + items: mockData.slice(0, EXPECTED_PAGE_SIZE), nextToken: 'token123', }, message: '', @@ -176,7 +171,7 @@ describe('useLocationsView', () => { // mock next page mockUseLocationsData({ - data: { result: mockData, nextToken: undefined }, + data: { items: mockData, nextToken: undefined }, message: '', hasError: false, isLoading: false, @@ -207,7 +202,7 @@ describe('useLocationsView', () => { it('should handle refreshing location data', () => { const mockDataState = { - data: { result: [], nextToken: 'token123' }, + data: { items: [], nextToken: 'token123' }, message: '', hasError: false, isLoading: false, @@ -251,7 +246,7 @@ describe('useLocationsView', () => { it('should handle downloading a file', () => { const { result } = renderHook(() => useLocationsView()); - const location: LocationData = { + const location: ActionsModule.LocationData = { bucket: 'bucket', id: 'id', permissions: ['get'], @@ -269,7 +264,7 @@ describe('useLocationsView', () => { it('should handle search', () => { const mockDataState = { - data: { result: mockData, nextToken: undefined }, + data: { items: mockData, nextToken: undefined }, message: '', hasError: false, isLoading: false, @@ -289,7 +284,7 @@ describe('useLocationsView', () => { expect(result.current.pageItems).toEqual([ { bucket: 'test-bucket', - prefix: `item-b/`, + prefix: 'item-b/', permissions: ['get', 'list'], id: '2', type: 'PREFIX', diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getHeaders.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getHeaders.ts new file mode 100644 index 00000000000..9cd959bc381 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getHeaders.ts @@ -0,0 +1,43 @@ +import { LocationViewHeaders } from './types'; + +export const getHeaders = ({ + hasObjectLocations, + tableColumnActionsHeader, + tableColumnBucketHeader, + tableColumnFolderHeader, + tableColumnPermissionsHeader, +}: { + hasObjectLocations: boolean; + tableColumnActionsHeader: string; + tableColumnBucketHeader: string; + tableColumnFolderHeader: string; + tableColumnPermissionsHeader: string; +}): LocationViewHeaders => { + const headers: LocationViewHeaders = [ + { + key: 'folder', + type: 'sort', + content: { label: tableColumnFolderHeader }, + }, + { + key: 'bucket', + type: 'sort', + content: { label: tableColumnBucketHeader }, + }, + { + key: 'permission', + type: 'sort', + content: { label: tableColumnPermissionsHeader }, + }, + ]; + + if (hasObjectLocations) { + headers.push({ + key: 'action', + type: 'sort', + content: { label: tableColumnActionsHeader }, + }); + } + + return headers; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getLocationsViewTableData.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getLocationsViewTableData.ts index 3cadea8b7fc..dec6d2231bc 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getLocationsViewTableData.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/getLocationsViewTableData/getLocationsViewTableData.ts @@ -1,22 +1,35 @@ import { DataTableProps } from '../../../composables/DataTable'; -import { LocationData, LocationPermissions } from '../../../actions'; -import { LocationViewHeaders } from './types'; +import { LocationData } from '../../../actions'; +import { getHeaders } from './getHeaders'; +import { DefaultLocationsViewDisplayText } from '../../../displayText/types'; export const getLocationsViewTableData = ({ + displayText, pageItems, onNavigate, onDownload, - headers, - getDownloadLabel, - getPermissionName, }: { + displayText: DefaultLocationsViewDisplayText; pageItems: LocationData[]; onNavigate: (location: LocationData) => void; - headers: LocationViewHeaders; onDownload: (location: LocationData) => void; - getDownloadLabel: (fileName: string) => string; - getPermissionName: (permissions: LocationPermissions) => string; }): DataTableProps => { + const { + tableColumnActionsHeader, + tableColumnBucketHeader, + tableColumnFolderHeader, + tableColumnPermissionsHeader, + getDownloadLabel, + getPermissionName, + } = displayText; + const headers = getHeaders({ + hasObjectLocations: pageItems.some(({ type }) => type === 'OBJECT'), + tableColumnActionsHeader, + tableColumnBucketHeader, + tableColumnFolderHeader, + tableColumnPermissionsHeader, + }); + const rows: DataTableProps['rows'] = pageItems.map((location) => { const { bucket, id, permissions, prefix } = location; return { 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 66e26f3e51f..91b1429ae20 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/types.ts @@ -1,3 +1,54 @@ +import { LocationData } from '../../actions'; import { ListViewProps } from '../types'; +export interface LocationsViewState { + hasNextPage: boolean; + hasError: boolean; + highestPageVisited: number; + isLoading: boolean; + message: string | undefined; + shouldShowEmptyMessage: boolean; + pageItems: LocationData[]; + page: number; + searchQuery: string; + onDownload: (item: LocationData) => void; + onNavigate: (location: LocationData) => void; + onRefresh: () => void; + onPaginate: (page: number) => void; + onSearch: () => void; + onSearchQueryChange: (value: string) => void; + onSearchClear: () => void; +} + export interface LocationsViewProps extends ListViewProps {} + +export interface LocationsViewProviderProps extends LocationsViewState { + children?: React.ReactNode; +} + +export interface LocationsViewType { + ( + props: { + children?: React.ReactNode; + className?: string; + } & LocationsViewProps + ): React.JSX.Element | null; + displayName: string; + Provider: (props: LocationsViewProviderProps) => React.JSX.Element; + LoadingIndicator: () => React.JSX.Element | null; + LocationsTable: () => React.JSX.Element | null; + Message: () => React.JSX.Element | null; + Pagination: () => React.JSX.Element | null; + Refresh: () => React.JSX.Element | null; + Search: () => React.JSX.Element | null; + Title: () => React.JSX.Element | null; +} + +interface InitialValues { + pageSize?: number; +} + +export interface UseLocationsViewOptions { + initialValues?: InitialValues; + onNavigate?: (location: LocationData) => void; +} 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 4ea48ecafeb..c9a6fdf9ca8 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/useLocationsView.ts @@ -1,66 +1,40 @@ import React from 'react'; -import { useLocationsData } from '../../do-not-import-from-here/actions'; import { usePaginate } from '../hooks/usePaginate'; -import { downloadHandler, LocationData } from '../../actions'; +import { + createFileDataItemFromLocation, + downloadHandler, + ListLocationsExcludeOptions, + LocationData, + useListLocations, +} from '../../actions'; import { useStore } from '../../providers/store'; import { useSearch } from '../hooks/useSearch'; import { useGetActionInput } from '../../providers/configuration'; import { useProcessTasks } from '../../tasks'; -import { createFileDataItemFromLocation } from '../../actions/handlers'; - -interface UseLocationsView { - hasNextPage: boolean; - hasError: boolean; - highestPageVisited: number; - isLoading: boolean; - message: string | undefined; - shouldShowEmptyMessage: boolean; - pageItems: LocationData[]; - page: number; - searchQuery: string; - onDownload: (item: LocationData) => void; - onNavigate: (location: LocationData) => void; - onRefresh: () => void; - onPaginate: (page: number) => void; - onSearch: () => void; - onSearchQueryChange: (value: string) => void; - onSearchClear: () => void; -} - -interface InitialValues { - pageSize?: number; -} - -export type LocationsViewActionType = - | { type: 'REFRESH_DATA' } - | { type: 'RESET' } - | { type: 'PAGINATE'; page: number } - | { type: 'NAVIGATE'; location: LocationData } - | { type: 'SEARCH'; query: string }; - -export interface UseLocationsViewOptions { - initialValues?: InitialValues; - onDispatch?: React.Dispatch; - onNavigate?: (location: LocationData) => void; -} +import { LocationsViewState, UseLocationsViewOptions } from './types'; +const DEFAULT_EXCLUDE: ListLocationsExcludeOptions = { + exactPermissions: ['delete', 'write'], +}; const DEFAULT_PAGE_SIZE = 100; export const DEFAULT_LIST_OPTIONS = { - exclude: 'WRITE' as const, // FIXME: update exclude type after migration to new actions + exclude: DEFAULT_EXCLUDE, pageSize: DEFAULT_PAGE_SIZE, }; -export function useLocationsView( +export const useLocationsView = ( options?: UseLocationsViewOptions -): UseLocationsView { +): LocationsViewState => { const getConfig = useGetActionInput(); - const [state, handleList] = useLocationsData(); + + const [state, handleList] = useListLocations(); + const { data, message, hasError, isLoading } = state; + const [_, handleDownload] = useProcessTasks(downloadHandler); const [, dispatchStoreAction] = useStore(); const [term, setTerm] = React.useState(''); - const { data, message, hasError, isLoading } = state; - const { result, nextToken } = data; + const { items, nextToken } = data; const hasNextToken = !!nextToken; const onNavigate = options?.onNavigate; @@ -101,7 +75,7 @@ export function useLocationsView( highestPageVisited, pageItems, } = usePaginate({ - items: result, + items, paginateCallback, pageSize: listOptions.pageSize, hasNextToken, @@ -152,4 +126,4 @@ export function useLocationsView( resetSearch(); }, }; -} +}; diff --git a/packages/react-storage/src/components/StorageBrowser/views/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/views/__tests__/utils.spec.ts deleted file mode 100644 index fa3effedd78..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/__tests__/utils.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { isFile, getPercentValue, resolveClassName } from '../utils'; - -describe('view utils', () => { - it('uses isFile util to discern between files and folders', () => { - const file = new File(['file contents'], 'test file', { - type: 'text/plain', - }); - const folder = new File([], 'test folder'); - - expect(isFile(file)).toBe(true); - expect(isFile(folder)).toBe(false); - }); - - it('uses getPercentValue util to calculate the percentage of a number', () => { - expect(getPercentValue(0.01)).toBe(1); - expect(getPercentValue(0.5)).toBe(50); - expect(getPercentValue(1)).toBe(100); - }); - - it('uses resolveClassName util to resolve a className', () => { - const stringClassName = 'test-class'; - const resolvedStringClassName = resolveClassName( - 'default', - stringClassName - ); - - expect(resolvedStringClassName).toBe('default test-class'); - - const functionClassName = () => 'test-class'; - const resolvedFunctionClassName = resolveClassName( - 'default', - functionClassName - ); - - expect(resolvedFunctionClassName).toBe('test-class'); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/context.tsx b/packages/react-storage/src/components/StorageBrowser/views/context.tsx index f4fc38e82b0..db19d1421cf 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/context.tsx @@ -19,8 +19,10 @@ export interface DefaultViews { LocationActionView: ( props: LocationActionViewProps ) => React.JSX.Element | null; - LocationDetailView: (props: LocationDetailViewProps) => React.JSX.Element; - LocationsView: (props: LocationsViewProps) => React.JSX.Element; + LocationDetailView: ( + props: LocationDetailViewProps + ) => React.JSX.Element | null; + LocationsView: (props: LocationsViewProps) => React.JSX.Element | null; } export interface Views extends Partial> {} diff --git a/packages/react-storage/src/components/StorageBrowser/views/index.ts b/packages/react-storage/src/components/StorageBrowser/views/index.ts index 9edbe3bdce0..7a231a2f061 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/index.ts @@ -1,4 +1,14 @@ -export { LocationActionView } from './LocationActionView'; +export { + CopyView, + CopyViewType, + CreateFolderView, + CreateFolderViewType, + DeleteView, + DeleteViewType, + LocationActionView, + UploadView, + UploadViewType, +} from './LocationActionView'; export { LocationDetailView, LocationDetailViewProps, diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils.ts b/packages/react-storage/src/components/StorageBrowser/views/utils.ts deleted file mode 100644 index 68646cf8262..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { isFunction, isString } from '@aws-amplify/ui'; - -// checks if a dropped item is a file or a folder, as a folder will not have a type -export const isFile = (file: File): boolean => file.type !== ''; - -export const getPercentValue = (value: number): number => - Math.round(value * 100); - -export const resolveClassName = ( - defaultClassName: string, - className?: string | ((defaultClassName: string) => string) -): string => { - if (isString(className)) return `${defaultClassName} ${className}`; - - if (isFunction(className)) return className(defaultClassName); - - return defaultClassName; -}; diff --git a/packages/react-storage/src/styles/storage-browser.css b/packages/react-storage/src/styles/storage-browser.css index 66ce5e314bf..618d4a4565f 100644 --- a/packages/react-storage/src/styles/storage-browser.css +++ b/packages/react-storage/src/styles/storage-browser.css @@ -172,12 +172,24 @@ border-width: 0; } +.storage-browser__action-destination, +.storage-browser__action-destination .storage-browser__description-list { + display: flex; + gap: var(--storage-browser-gap-small); + align-items: center; +} + +.storage-browser__action-destination > span { + font-weight: var(--amplify-font-weights-bold); +} + /* Base component styles */ /** DescriptionList **/ .storage-browser__description-list { margin: 0; display: flex; gap: var(--storage-browser-gap-large); + align-items: center; } .storage-browser__description { @@ -204,7 +216,8 @@ justify-content: center; } -.storage-browser__loading-indicator-icon { +.storage-browser__loading-indicator-icon, +.storage-browser__table-text-data-cell-icon--action-progress { animation: var(--storage-browser-loading-animation); } @@ -369,13 +382,6 @@ flex: 1; } -.storage-browser__action-destination { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; -} - .storage-browser__copy-destination-picker { width: 100%; flex: 1; @@ -405,6 +411,14 @@ width: 100%; } +/* Copy view styles */ +.storage-browser__copy-destination-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + /* Search view styles */ .storage-browser__search { display: flex; diff --git a/packages/react/src/primitives/Icon/context/IconsContext.tsx b/packages/react/src/primitives/Icon/context/IconsContext.tsx index 6ea5714ce29..29d254c3da6 100644 --- a/packages/react/src/primitives/Icon/context/IconsContext.tsx +++ b/packages/react/src/primitives/Icon/context/IconsContext.tsx @@ -1,5 +1,38 @@ import * as React from 'react'; +type StorageBrowserIconType = + | 'action-canceled' + | 'action-error' + | 'action-initial' + | 'action-progress' + | 'action-queued' + | 'action-success' + | 'cancel' + | 'create-folder' + | 'copy-file' + | 'delete-file' + | 'dismiss' + | 'download' + | 'error' + | 'exit' + | 'file' + | 'folder' + | 'info' + | 'loading' + | 'menu' + | 'paginate-next' + | 'paginate-previous' + | 'refresh' + | 'search' + | 'sort-ascending' + | 'sort-descending' + | 'sort-indeterminate' + | 'success' + | 'upload-file' + | 'upload-folder' + | 'vertical-kebab' + | 'warning'; + type ComponentIcons = { [Key in Keys]?: React.ReactNode; }; @@ -21,6 +54,7 @@ export type IconsContextInterface = { searchField?: ComponentIcons<'search'>; select?: ComponentIcons<'expand'>; stepperField?: ComponentIcons<'add' | 'remove'>; + storageBrowser?: ComponentIcons; storageManager?: ComponentIcons< 'upload' | 'remove' | 'error' | 'success' | 'file' >;