From 7df9f791a31f83f25926d5169f2910dffda02455 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Mon, 21 Oct 2024 13:33:22 -0700 Subject: [PATCH 1/6] feat(storage-browser): integrate browser navigation --- .../composable-playground/index.page.tsx | 12 +- .../StorageBrowser/ComponentsProvider.tsx | 27 ++ .../StorageBrowser/StorageBrowserDefault.tsx | 12 +- .../__tests__/StorageBrowserDefault.spec.tsx | 2 +- .../__tests__/createStorageBrowser.spec.tsx | 2 +- .../StorageBrowser/actions/configs/context.ts | 4 + .../StorageBrowser/actions/configs/types.ts | 4 +- .../actions/handlers/__tests__/utils.spec.ts | 95 ++++++- .../StorageBrowser/actions/handlers/copy.ts | 5 +- .../StorageBrowser/actions/handlers/delete.ts | 2 +- .../StorageBrowser/actions/handlers/index.ts | 2 + .../actions/handlers/listLocationItems.ts | 16 +- .../actions/handlers/listLocations.ts | 10 +- .../StorageBrowser/actions/handlers/types.ts | 15 ++ .../StorageBrowser/actions/handlers/upload.ts | 3 +- .../StorageBrowser/actions/handlers/utils.ts | 42 +++ .../StorageBrowser/actions/index.ts | 2 +- .../StorageBrowser/actions/types.ts | 5 +- .../adapters/createAmplifyAuthAdapter.ts | 2 +- .../adapters/createManagedAuthAdapter.ts | 1 + .../StorageBrowser/adapters/types.ts | 3 +- .../StorageBrowser/context/PaginateState.tsx | 109 -------- .../context/__tests__/PaginateState.spec.tsx | 103 -------- .../useGetCredentialsProvider.spec.ts | 70 ----- .../StorageBrowser/context/config.tsx | 73 ------ .../StorageBrowser/context/control.tsx | 49 ---- .../context/locationActions/index.ts | 7 - .../locationActions/locationActions.tsx | 86 ------ .../context/locationActions/utils.ts | 26 -- .../context/navigate/Navigate.tsx | 84 ------ .../navigate/__tests__/Navigate.spec.tsx | 188 -------------- .../context/navigate/__tests__/utils.spec.ts | 83 ------ .../StorageBrowser/context/navigate/index.ts | 5 - .../StorageBrowser/context/navigate/utils.ts | 39 --- .../context/useGetCredentialsProvider.ts | 34 --- .../StorageBrowser/controls/getTaskCounts.ts | 5 +- .../StorageBrowser/createProvider.tsx | 66 ----- .../StorageBrowser/createStorageBrowser.tsx | 89 +++++-- .../createUseActionStateContext.spec.tsx | 0 .../actions/__tests__/downloadAction.spec.tsx | 0 .../__tests__/listLocationItemsAction.spec.ts | 0 .../__tests__/listLocationsAction.spec.ts | 0 .../actions/actions.tsx | 19 +- .../actions/createActionStateContext.tsx | 0 .../actions/createFolderAction.ts | 4 +- .../actions/downloadAction.ts | 0 .../actions/index.ts | 1 - .../actions/listLocationItemsAction.ts | 33 +-- .../actions/listLocationsAction.ts | 26 +- .../actions/locationsData.tsx | 5 +- .../actions/testUtils.ts | 0 .../createTempActionsProvider.tsx | 64 +++++ .../__snapshots__/defaults.spec.ts.snap | 0 .../__tests__/defaults.spec.ts | 0 .../__tests__/locationActions.spec.tsx | 0 .../locationActions/defaults.tsx | 2 +- .../locationActions/index.ts | 2 + .../locationActions/types.ts | 7 +- .../types.ts | 37 +-- .../useGetActionInputCallback.spec.ts | 7 +- .../providers/configuration/context.tsx | 1 - .../providers/configuration/index.ts | 1 + .../useGetActionInputCallback.ts | 35 +-- .../StorageBrowser/providers/index.ts | 14 +- .../providers/store/StoreProvider.tsx | 11 +- .../providers/store/actionType/types.ts | 2 +- .../store/actionType/useActionTypeState.ts | 2 +- .../providers/store/files/index.ts | 2 +- .../providers/store/files/types.ts | 9 +- .../providers/store/files/utils.ts | 10 +- .../providers/store/history/context.tsx | 61 ++--- .../providers/store/history/index.ts | 8 +- .../StorageBrowser/providers/store/index.ts | 6 +- .../locationItems/__tests__/context.spec.ts | 6 +- .../providers/store/locationItems/context.tsx | 25 +- .../providers/store/locationItems/index.ts | 2 + .../providers/store/useStore.ts | 70 +++++ .../components/StorageBrowser/tasks/types.ts | 3 +- .../StorageBrowser/tasks/useProcessTasks.ts | 63 +++-- .../components/StorageBrowser/tasks/utils.ts | 11 +- .../validators/assertIsLocationData.ts | 22 ++ .../StorageBrowser/validators/index.ts | 1 + .../views/Controls/Download.tsx | 9 +- .../views/Controls/Navigate.tsx | 52 ++-- .../StorageBrowser/views/Controls/Table.tsx | 116 +++++---- .../Controls/__tests__/Download.spec.tsx | 2 +- .../Controls/__tests__/Navigate.spec.tsx | 2 +- .../views/Controls/__tests__/Table.spec.tsx | 2 +- .../LocationActionView/Controls/Title.tsx | 15 +- .../CreateFolderControls.tsx | 27 +- .../LocationActionView/LocationActionView.tsx | 19 +- .../LocationActionView/UploadControls.tsx | 163 +++++++----- .../__tests__/CreateFolderControls.spec.tsx | 4 +- .../__tests__/LocationActionView.spec.tsx | 2 +- .../__tests__/UploadControls.spec.tsx | 4 +- .../LocationActionView/useHandleUpload.ts | 244 ------------------ .../views/LocationDetailView/Controls.tsx | 118 ++++----- .../Controls/ActionsMenu.tsx | 29 ++- .../LocationDetailView/LocationDetailView.tsx | 14 +- .../__tests__/LocationDetailView.spec.tsx | 4 +- .../views/LocationDetailView/index.ts | 6 +- .../views/LocationDetailView/types.ts | 8 + .../LocationsView/Controls/DataTable.tsx | 71 +++-- .../Controls/__tests__/DataTable.spec.tsx | 4 +- .../views/LocationsView/LocationsView.tsx | 7 +- .../__tests__/LocationsView.spec.tsx | 4 +- .../StorageBrowser/views/context.tsx | 4 +- .../components/StorageBrowser/views/index.ts | 12 +- .../components/StorageBrowser/views/utils.ts | 2 +- 109 files changed, 1002 insertions(+), 1896 deletions(-) create mode 100644 packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/PaginateState.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/__tests__/PaginateState.spec.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/__tests__/useGetCredentialsProvider.spec.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/config.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/control.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/locationActions/index.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/locationActions/locationActions.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/locationActions/utils.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/navigate/Navigate.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/Navigate.spec.tsx delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/utils.spec.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/navigate/index.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/navigate/utils.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/context/useGetCredentialsProvider.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/createProvider.tsx rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/__tests__/createUseActionStateContext.spec.tsx (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/__tests__/downloadAction.spec.tsx (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/__tests__/listLocationItemsAction.spec.ts (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/__tests__/listLocationsAction.spec.ts (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/actions.tsx (85%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/createActionStateContext.tsx (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/createFolderAction.ts (95%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/downloadAction.ts (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/index.ts (78%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/listLocationItemsAction.ts (80%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/listLocationsAction.ts (76%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/locationsData.tsx (88%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/actions/testUtils.ts (100%) create mode 100644 packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/createTempActionsProvider.tsx rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/locationActions/__tests__/defaults.spec.ts (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/locationActions/__tests__/locationActions.spec.tsx (100%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/locationActions/defaults.tsx (93%) create mode 100644 packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/index.ts rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/locationActions/types.ts (89%) rename packages/react-storage/src/components/StorageBrowser/{context => do-not-import-from-here}/types.ts (61%) create mode 100644 packages/react-storage/src/components/StorageBrowser/providers/store/useStore.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/validators/assertIsLocationData.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/validators/index.ts delete mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useHandleUpload.ts create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts diff --git a/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx index 7b7564ca07f..8c58976d11b 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 @@ -9,7 +9,7 @@ import { Button, Flex } from '@aws-amplify/ui-react'; import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import '@aws-amplify/ui-react-storage/styles.css'; -const { StorageBrowser, useControl } = createStorageBrowser({ +const { StorageBrowser } = createStorageBrowser({ config: managedAuthAdapter, }); @@ -22,7 +22,7 @@ function LocationActionView() { } function MyStorageBrowser() { - const [{ selected }] = useControl('LOCATION_ACTIONS'); + const [type, setActionType] = React.useState(undefined); return ( @@ -30,9 +30,13 @@ function MyStorageBrowser() { - + { + setActionType(actionType); + }} + /> - {selected.type ? : null} + {type ? : null} ); } diff --git a/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx b/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx new file mode 100644 index 00000000000..676b2c666c3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { ElementsProvider } from '@aws-amplify/ui-react-core/elements'; + +import { ComposablesProvider } from './composables/context'; +import { ComposableTypes as Composables } from './composables/types'; +import { StorageBrowserElements } from './context/elements'; + +export interface ComponentsProviderProps { + children?: React.ReactNode; + // temp disablng of linting rule to note where `components`/"slots" context is provided + // eslint-disable-next-line react/no-unused-prop-types + components?: Composables; + elements?: Partial; +} + +export function ComponentsProvider( + props: ComponentsProviderProps +): React.JSX.Element { + const { children, elements } = props; + + return ( + + {children} + + ); +} diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx index a66718757d2..cbdfe1fb412 100644 --- a/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx +++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useViews } from './views'; -import { useControl } from './context/control'; +import { useStore } from './providers/store'; /** * Handles default `StorageBrowser` behavior: @@ -12,16 +12,14 @@ import { useControl } from './context/control'; export function StorageBrowserDefault(): React.JSX.Element { const { LocationActionView, LocationDetailView, LocationsView } = useViews(); - const [{ location }] = useControl('NAVIGATE'); - const [{ selected }] = useControl('LOCATION_ACTIONS'); + const [{ actionType, history }] = useStore(); + const { current } = history; - const { type } = selected; - - if (type) { + if (actionType) { return ; } - if (location) { + if (current) { return ; } diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx index c2ede7da757..61aa3b70684 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import * as ActionsModule from '../context/actions'; +import * as ActionsModule from '../do-not-import-from-here/actions'; import * as ControlsModule from '../context/control'; import { ViewsProvider } from '../views/context'; import { StorageBrowserDefault } from '../StorageBrowserDefault'; 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 02135cb7c5f..be891a442fa 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,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import * as ActionsModule from '../context/actions'; +import * as ActionsModule from '../do-not-import-from-here/actions'; import * as ControlsModule from '../context/control'; import { createStorageBrowser } from '../createStorageBrowser'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts index d5617fdbf0a..243bba670cc 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts @@ -7,5 +7,9 @@ export interface ActionConfigsProviderProps { const defaultValue: ActionConfigs = {}; +export interface ActionConfigsProviderProps { + actions?: ActionConfigs; + children?: React.ReactNode; +} export const { useActionConfigs, ActionConfigsProvider } = createContextUtilities({ contextName: 'ActionConfigs', defaultValue }); 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 809bede7f78..50bd1439460 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts @@ -4,7 +4,7 @@ import { IconVariant } from '../../context/elements'; import { ListLocationsHandler, ListLocationItemsHandler, - LocationItem, + LocationItemData, LocationItemType, UploadHandler, CreateFolderHandler, @@ -33,7 +33,7 @@ export interface ActionListItemConfig { * conditionally disable item selection based on currently selected values * @default false */ - disable?: (selectedValues: LocationItem[] | undefined) => boolean; + disable?: (selectedValues: LocationItemData[] | undefined) => boolean; /** * open native OS file picker with associated selection type on item select diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts index d719cfd9333..1cd4732a31b 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts @@ -1,6 +1,8 @@ import * as StorageModule from 'aws-amplify/storage'; -import { resolveHandlerResult } from '../utils'; +import { LocationAccess, LocationData } from '../types'; + +import { parseLocationAccess, resolveHandlerResult } from '../utils'; const isCancelErrorSpy = jest.spyOn(StorageModule, 'isCancelError'); @@ -83,3 +85,94 @@ describe('resolveHandlerResult', () => { expect(onCancel).toHaveBeenCalledWith(key); }); }); + +describe('parseLocationAccess', () => { + const bucket = 'test-bucket'; + const folderPrefix = 'test-prefix/'; + const filePath = 'some-file.jpeg2000'; + + const id = 'intentionally-static-test-id'; + beforeAll(() => { + Object.defineProperty(globalThis, 'crypto', { + value: { randomUUID: () => id }, + }); + }); + + it('throws if provided an invalid location scope', () => { + const invalidLocation: LocationAccess = { + scope: 'nope', + permission: 'READ', + type: 'BUCKET', + }; + + expect(() => parseLocationAccess(invalidLocation)).toThrow( + 'Invalid scope: nope' + ); + }); + + it('throws if provided an invalid location type', () => { + const invalidLocation: LocationAccess = { + scope: 's3://yes', + permission: 'READ', + // @ts-expect-error intentional coercing to allow unhappy path test + type: 'NOT_BUCKET', + }; + + expect(() => parseLocationAccess(invalidLocation)).toThrow( + 'Invalid location type: NOT_BUCKET' + ); + }); + + it('parses a BUCKET location as expected', () => { + const location: LocationAccess = { + permission: 'WRITE', + scope: `s3://${bucket}/*`, + type: 'BUCKET', + }; + const expected: LocationData = { + bucket, + id, + prefix: '', + permission: 'WRITE', + type: 'BUCKET', + }; + + expect(parseLocationAccess(location)).toStrictEqual(expected); + }); + + it('parses a PREFIX location as expected', () => { + const location: LocationAccess = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}*`, + type: 'PREFIX', + }; + + const expected: LocationData = { + bucket, + id, + prefix: folderPrefix, + permission: 'WRITE', + type: 'PREFIX', + }; + + expect(parseLocationAccess(location)).toStrictEqual(expected); + }); + + it('parses an OBJECT location as expected', () => { + const location: LocationAccess = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}${filePath}`, + type: 'OBJECT', + }; + + const expected: LocationData = { + bucket, + id, + prefix: `${folderPrefix}${filePath}`, + permission: 'WRITE', + type: 'OBJECT', + }; + + expect(parseLocationAccess(location)).toStrictEqual(expected); + }); +}); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts index afe0708a156..e314fa42cf0 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts @@ -19,9 +19,10 @@ export interface CopyHandlerOutput extends TaskHandlerOutput {} export interface CopyHandler extends TaskHandler {} -export const copyHandler: CopyHandler = ({ config, options, prefix, data }) => { +export const copyHandler: CopyHandler = (input) => { + const { config, key, options, prefix, data } = input; const { accountId, credentials } = config; - const { payload, key } = data; + const { payload } = data; const { destinationPrefix } = payload; const sourceKey = `${prefix}${key}`; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts index b7f63fab463..96ed610ed32 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts @@ -22,7 +22,7 @@ export interface DeleteHandler export const deleteHandler: DeleteHandler = ({ config, - data: { key }, + key, prefix, options, }): DeleteHandlerOutput => { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts index d9cc62845c2..8bfa8388ab3 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts @@ -5,3 +5,5 @@ export * from './download'; export * from './listLocationItems'; export * from './listLocations'; export * from './upload'; + +export * from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts index fc9baf390b2..9baa48b53fb 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts @@ -5,25 +5,23 @@ import { ListHandlerOutput, } from '../types'; -/** - * handler types - */ -export interface FolderItem { +export interface FolderData { key: string; + id: string; type: 'FOLDER'; } -export interface FileItem { +export interface FileData { key: string; - data?: File; lastModified: Date; + id: string; size: number; type: 'FILE'; } -export type LocationItem = FileItem | FolderItem; +export type LocationItemData = FileData | FolderData; -export type LocationItemType = LocationItem['type']; +export type LocationItemType = LocationItemData['type']; export interface ListLocationItemsHandlerOptions extends ListHandlerOptions { @@ -35,7 +33,7 @@ export interface ListLocationItemsHandlerInput extends ListHandlerInput {} export interface ListLocationItemsHandlerOutput - extends ListHandlerOutput {} + extends ListHandlerOutput {} export interface ListLocationItemsHandler extends ListHandler< 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 65ea2504080..7094871c9a5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts @@ -1,15 +1,7 @@ import { Permission } from '../../storage-internal'; import { ListHandlerOptions, ListHandlerOutput, ListHandler } from '../types'; - -export type LocationType = 'OBJECT' | 'PREFIX' | 'BUCKET'; - -export interface LocationData { - bucket: string; - permission: Permission; - prefix: string; - type: LocationType; -} +import { LocationData, LocationType } from './types'; type ExcludeType = Permission | LocationType; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts new file mode 100644 index 00000000000..cc8930c1b3d --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts @@ -0,0 +1,15 @@ +import { ListLocations, Permission } from '../../storage-internal'; + +export type LocationAccess = Awaited< + ReturnType +>['locations'][number]; + +export type LocationType = 'OBJECT' | 'PREFIX' | 'BUCKET'; + +export interface LocationData { + bucket: string; + id: string; + permission: Permission; + prefix: string; + type: LocationType; +} diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts index a5d5d3a53c3..0b1679c5833 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts @@ -36,11 +36,12 @@ export const UNDEFINED_CALLBACKS = { export const uploadHandler: UploadHandler = ({ config: _config, data: _data, + key, options: _options, prefix, }) => { const { accountId, credentials, ...config } = _config; - const { key, payload: data } = _data; + const { payload: data } = _data; const { onProgress, preventOverwrite, ...options } = _options ?? {}; const bucket = constructBucket(config); 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 2c82b0d0f1a..d1815e7e7a4 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts @@ -1,6 +1,8 @@ import { isCancelError } from 'aws-amplify/storage'; import { isFunction } from '@aws-amplify/ui'; +import { LocationAccess, LocationData } from './types'; + import { TaskHandlerOutput, CancelableTaskHandlerOutput, @@ -50,3 +52,43 @@ export const constructBucket = ({ bucketName: string; region: string; } => ({ bucketName, region }); + +export const parseLocationAccess = (location: LocationAccess): LocationData => { + const { permission, scope, type } = location; + if (!scope.startsWith('s3://')) { + throw new Error(`Invalid scope: ${scope}`); + } + + const id = crypto.randomUUID(); + + // remove default path + const sanitizedScope = scope.slice(5); + let bucket, prefix; + + switch (type) { + case 'BUCKET': { + // { scope: 's3://bucket/*', type: 'BUCKET', }, + bucket = sanitizedScope.slice(0, -2); + prefix = ''; + break; + } + case 'PREFIX': { + // { scope: 's3://bucket/path/*', type: 'PREFIX', }, + bucket = sanitizedScope.slice(0, sanitizedScope.indexOf('/')); + prefix = `${sanitizedScope.slice(bucket.length + 1, -1)}`; + + break; + } + case 'OBJECT': { + // { scope: 's3://bucket/path/to/object', type: 'OBJECT', }, + bucket = sanitizedScope.slice(0, sanitizedScope.indexOf('/')); + prefix = sanitizedScope.slice(bucket.length + 1); + break; + } + default: { + throw new Error(`Invalid location type: ${type}`); + } + } + + return { bucket, id, permission, prefix, type }; +}; diff --git a/packages/react-storage/src/components/StorageBrowser/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/index.ts index 7fff9dc3404..6e79eb957c5 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/index.ts @@ -19,7 +19,7 @@ export { ListLocationsHandlerOptions, ListLocationsHandlerOutput, LocationData, - LocationItem, + LocationItemData, LocationItemType, LocationType, uploadHandler, diff --git a/packages/react-storage/src/components/StorageBrowser/actions/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/types.ts index 200e99aaec2..27d7e60253e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/types.ts @@ -1,6 +1,6 @@ import { LocationCredentialsProvider } from '../storage-internal'; -import { ActionState } from '../context/actions/createActionStateContext'; +import { ActionState } from '../do-not-import-from-here/actions/createActionStateContext'; export interface ActionInputConfig { accountId?: string; @@ -22,7 +22,8 @@ export interface TaskHandlerOptions { export interface TaskHandlerInput extends ActionInput { - data: { key: string; payload: T }; + data: { id: string; payload: T }; + key: string; } export interface TaskHandlerOutput { diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter.ts index f84b7f9ca26..c2e86cc2f63 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter.ts @@ -3,7 +3,7 @@ import { Hub } from 'aws-amplify/utils'; import { AuthSession, fetchAuthSession } from 'aws-amplify/auth'; import { isFunction } from '@aws-amplify/ui'; -import { LocationAccess } from '../context/types'; +import { LocationAccess } from '../actions/handlers'; import { LocationCredentialsProvider } from '../storage-internal'; import { StorageBrowserAuthAdapter } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter.ts index 5b0b29f62d8..de3ad9a0090 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter.ts @@ -9,5 +9,6 @@ export const createManagedAuthAdapter = ({ ...input }: CreateManagedAuthAdapterInput): StorageBrowserAuthAdapter => ({ ...createManagedAuthConfigAdapter(input), + accountId: input.accountId, registerAuthListener, }); diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/types.ts b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts index 69220a27064..9c39a4fc47c 100644 --- a/packages/react-storage/src/components/StorageBrowser/adapters/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts @@ -1,10 +1,11 @@ -import { RegisterAuthListener } from '../context/useGetCredentialsProvider'; +import { RegisterAuthListener } from '../providers'; import { AuthConfigAdapter, CreateManagedAuthConfigAdapterInput, } from '../storage-internal'; export interface StorageBrowserAuthAdapter extends AuthConfigAdapter { + accountId?: string; registerAuthListener: RegisterAuthListener; } diff --git a/packages/react-storage/src/components/StorageBrowser/context/PaginateState.tsx b/packages/react-storage/src/components/StorageBrowser/context/PaginateState.tsx deleted file mode 100644 index a2b2fed0fb5..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/PaginateState.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import { DataState, useDataState } from '@aws-amplify/ui-react-core'; - -export type PaginateAction = - | { type: 'setStatus'; isPaginating: boolean } - | { hasNextToken: boolean; itemCount: number; type: 'next' } - | { type: 'previous' }; - -export interface PaginateState { - current: number; - hasNext: boolean; - hasPrevious: boolean; - isPaginating: boolean; - shouldPaginate: boolean; - // top level config propeeties - readonly lookAhead: number; - readonly pageSize: number; -} - -interface PaginateStateProviderProps { - children?: React.ReactNode; - initialState: PaginateState; -} - -export const updatePaginateStateAction = ( - prevState: PaginateState, - action: PaginateAction -): PaginateState => { - switch (action.type) { - case 'next': { - const { lookAhead, pageSize, current: _current } = prevState; - const { hasNextToken, itemCount } = action; - const prevDisplaySizeLimit = pageSize * _current; - const nextDisplaySizeLimit = pageSize * (_current + 1); - - const shouldPaginate = hasNextToken && nextDisplaySizeLimit < itemCount; - - const hasNext = - shouldPaginate || - itemCount - prevDisplaySizeLimit > pageSize * lookAhead; - - const current = hasNext ? _current + 1 : _current; - const hasPrevious = current >= 2; - - return { - current, - hasNext, - hasPrevious, - isPaginating: false, - lookAhead, - pageSize, - shouldPaginate, - }; - } - case 'previous': { - const { current: _current, ...nextState } = prevState; - const hasNext = _current >= 2; - const current = hasNext ? _current - 1 : _current; - const hasPrevious = current > 1; - return { - ...nextState, - current, - hasNext, - hasPrevious, - isPaginating: false, - shouldPaginate: false, - }; - } - case 'setStatus': { - const { isPaginating } = action; - return { ...prevState, isPaginating }; - } - default: - // @ts-expect-error - throw new Error(`Invalid value of ${action.type} provided as \`type\``); - } -}; - -export type UsePaginateState = [ - DataState, - (input: PaginateAction) => void, -]; - -const PaginateStateContext = React.createContext( - undefined -); - -export const usePaginateState = (): UsePaginateState => { - const context = React.useContext(PaginateStateContext); - if (!context) throw new Error('Must be called inside PROVIDER_NAME_HERE'); - return context; -}; - -export const PaginateStateProvider = ({ - children, - initialState, -}: PaginateStateProviderProps): JSX.Element => { - const state = useDataState( - (prev: PaginateState, action: PaginateAction) => - updatePaginateStateAction(prev, action), - initialState - ); - - return ( - - {children} - - ); -}; diff --git a/packages/react-storage/src/components/StorageBrowser/context/__tests__/PaginateState.spec.tsx b/packages/react-storage/src/components/StorageBrowser/context/__tests__/PaginateState.spec.tsx deleted file mode 100644 index 54d10623f8a..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/__tests__/PaginateState.spec.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { - updatePaginateStateAction, - PaginateAction, - PaginateState, -} from '../PaginateState'; - -const INITIAL_STATE: PaginateState = { - hasNext: true, - hasPrevious: false, - current: 1, - isPaginating: false, - shouldPaginate: false, - lookAhead: 2, - pageSize: 100, -}; - -describe('updatePaginateStateAction', () => { - it('handles a `next` action with previously loaded data and additional loadable data as expected', () => { - const action: PaginateAction = { - // has `nextToken` - hasNextToken: true, - itemCount: 300, - type: 'next', - }; - - const result = updatePaginateStateAction(INITIAL_STATE, action); - - const expected: PaginateState = { - ...INITIAL_STATE, - hasNext: true, - hasPrevious: true, - current: 2, - shouldPaginate: true, - }; - - expect(result).toStrictEqual(expected); - }); - - it('handles a `next` action with previously loaded data and without additional loadable data as expected', () => { - // start on page 2 - const initialState = { ...INITIAL_STATE, current: 2 }; - - const action: PaginateAction = { - // does not have `nextToken` - hasNextToken: false, - itemCount: 300, - type: 'next', - }; - - const result = updatePaginateStateAction(initialState, action); - - const expected: PaginateState = { - ...INITIAL_STATE, - hasNext: false, - hasPrevious: true, - current: 2, - }; - - expect(result).toStrictEqual(expected); - }); - - it('handles a `previous` action when there is a next page as expected', () => { - // start on page 2 - const initialState = { ...INITIAL_STATE, current: 2 }; - - const action: PaginateAction = { type: 'previous' }; - - const result = updatePaginateStateAction(initialState, action); - - const expected: PaginateState = { - ...INITIAL_STATE, - hasNext: true, - current: 1, - }; - - expect(result).toStrictEqual(expected); - }); - - it('handles a `previous` action when there is no next page as expected', () => { - // start on page 2 - const initialState = { ...INITIAL_STATE, hasNext: false, current: 1 }; - - const action: PaginateAction = { type: 'previous' }; - - const result = updatePaginateStateAction(initialState, action); - - const expected: PaginateState = { ...INITIAL_STATE, hasNext: false }; - - expect(result).toStrictEqual(expected); - }); - - it('throws when called with an invalid action', () => { - const type = 'NOOOOOOOO'; - // @ts-expect-error - const action: PaginateAction = { type }; - - expect(() => updatePaginateStateAction(INITIAL_STATE, action)).toThrow( - `Invalid value of ${type} provided as \`type\`` - ); - }); - - it.todo('sets isPaginating as expected'); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/context/__tests__/useGetCredentialsProvider.spec.ts b/packages/react-storage/src/components/StorageBrowser/context/__tests__/useGetCredentialsProvider.spec.ts deleted file mode 100644 index 23d4bfd5ff4..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/__tests__/useGetCredentialsProvider.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import * as StorageBrowserModule from '../../storage-internal'; - -import { useGetCredentialsProvider } from '../useGetCredentialsProvider'; - -class Subscription { - // intialize as noop - onStateChange = () => {}; - - registerAuthListener = (cb: () => void) => { - this.onStateChange = cb; - }; -} - -const createLocationCredentialsStoreSpy = jest.spyOn( - StorageBrowserModule, - 'createLocationCredentialsStore' -); - -const handler = jest.fn(); - -describe('useGetCredentialsProvider', () => { - beforeEach(() => { - createLocationCredentialsStoreSpy.mockClear(); - handler.mockClear(); - }); - - it('returns `getProvider` in the happy path', () => { - const { registerAuthListener } = new Subscription(); - const { result } = renderHook(() => - useGetCredentialsProvider(handler, registerAuthListener) - ); - - const { current: getProvider } = result; - expect(typeof getProvider).toBe('function'); - }); - - it('`getProvider` function maintains a stable reference between renders', () => { - const { registerAuthListener } = new Subscription(); - const { rerender, result } = renderHook(() => - useGetCredentialsProvider(handler, registerAuthListener) - ); - - const { current: initial } = result; - - rerender(); - - const { current: next } = result; - - expect(next).toBe(initial); - }); - - it('calls `destroy` and returns new `getProvider` when callback provided to `registerAuthListener` runs', () => { - // intentionally avoid destructuring to avoid stale reference of `onStateChange` - const subscriber = new Subscription(); - const { result } = renderHook(() => - useGetCredentialsProvider(handler, subscriber.registerAuthListener) - ); - - const { current: initial } = result; - - act(() => { - subscriber.onStateChange(); - }); - - const { current: next } = result; - - expect(next).not.toBe(initial); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/context/config.tsx b/packages/react-storage/src/components/StorageBrowser/context/config.tsx deleted file mode 100644 index 6794453fd6d..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/config.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import { GetLocationCredentials } from '../storage-internal'; - -import { LocationData } from './types'; - -import { useControl } from './control'; -import { - RegisterAuthListener, - useGetCredentialsProvider, -} from './useGetCredentialsProvider'; -import { LocationConfig } from './types'; -import { parseLocationAccess } from './navigate/utils'; - -export interface LocationConfigProviderProps { - accountId?: string; - children?: React.ReactNode; - getLocationCredentials: GetLocationCredentials; - registerAuthListener: RegisterAuthListener; - region: string; -} - -const ERROR_MESSAGE = - '`useGetLocationConfig` must be called within a `LocationConfigProvider`'; -const LocationConfigContext = React.createContext< - (() => LocationConfig) | undefined ->(undefined); - -export function LocationConfigProvider({ - accountId, - children, - getLocationCredentials, - registerAuthListener, - region, -}: LocationConfigProviderProps): React.JSX.Element { - const [{ location, path }] = useControl('NAVIGATE'); - - const { permission, scope: locationScope } = location ?? {}; - const { bucket } = location - ? parseLocationAccess(location) - : ({} as LocationData); - - const scope = !path - ? locationScope - : `${locationScope?.slice(0, -1)}${path}*`; - - const getCredentialsProvider = useGetCredentialsProvider( - getLocationCredentials, - registerAuthListener - ); - - const value: () => LocationConfig = React.useCallback(() => { - if (!permission || !scope) { - throw new Error('Failed to retrieve location config.'); - } - - const credentialsProvider = getCredentialsProvider({ permission, scope }); - return { accountId, bucket, credentialsProvider, region }; - }, [bucket, getCredentialsProvider, permission, region, scope, accountId]); - - return ( - - {children} - - ); -} - -export const useGetLocationConfig = (): (() => LocationConfig) => { - const context = React.useContext(LocationConfigContext); - if (!context) { - throw new Error(ERROR_MESSAGE); - } - return context; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/context/control.tsx b/packages/react-storage/src/components/StorageBrowser/context/control.tsx deleted file mode 100644 index d3a184bb896..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/control.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { NavigateProvider, NavigateContext } from './navigate'; - -import { - LocationActions, - LocationActionsContext, - LocationActionsProvider, -} from './locationActions'; - -const CONTEXTS = { - NAVIGATE: NavigateContext, - LOCATION_ACTIONS: LocationActionsContext, -}; - -type Contexts = typeof CONTEXTS; - -export function ControlProvider({ - actions, - children, -}: { - actions: LocationActions; - children?: React.ReactNode; -}): React.JSX.Element { - return ( - - - {children} - - - ); -} - -export function useControl< - T extends keyof Contexts, - K = Contexts[T] extends React.Context ? U : never, ->(type: T): K { - // TS does not handle the inference of the underlying `Context` type well - // but ultimately this is a safe lookup - // @ts-expect-error - const context = React.useContext(CONTEXTS[type]); - - if (!context) { - throw new Error( - '`useControl` can only be called from within `StorageBrowser.Provider`' - ); - } - - return context; -} diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/index.ts b/packages/react-storage/src/components/StorageBrowser/context/locationActions/index.ts deleted file mode 100644 index ce35925996f..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/locationActions/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { LocationActionsDefault, locationActionsDefault } from './defaults'; -export { - LocationActionsContext, - LocationActionsProvider, -} from './locationActions'; -export { LocationAction, LocationActions, LocationActionsState } from './types'; -export { resolveLocationActions, ResolveLocationActions } from './utils'; diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/locationActions.tsx b/packages/react-storage/src/components/StorageBrowser/context/locationActions/locationActions.tsx deleted file mode 100644 index eee05fcba8c..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/locationActions/locationActions.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; - -import { locationActionsDefault } from './defaults'; -import { - LocationActions, - LocationActionsAction, - LocationActionsProviderProps, - LocationActionsState, - LocationActionsStateContext, -} from './types'; - -export const INITIAL_STATE: Omit = { - selected: { type: undefined, items: undefined }, -}; - -const getInitialState = ( - actions: LocationActions = locationActionsDefault -): LocationActionsState => ({ - ...INITIAL_STATE, - actions, -}); - -export function locationActionsReducer( - state: LocationActionsState, - action: LocationActionsAction -): LocationActionsState { - switch (action.type) { - case 'SET_ACTION': { - const selected = { - ...state.selected, - type: action.actionType, - files: action.files, - }; - return { ...state, selected }; - } - case 'TOGGLE_SELECTED_ITEM': { - const hasItem = !!state.selected.items?.some( - (item) => item.key === action.item.key - ); - const selectedItems = hasItem - ? { - items: state.selected.items?.filter( - (item) => item.key !== action.item.key - ), - } - : { - items: [ - ...(state.selected.items ? state.selected.items : []), - action.item, - ], - }; - const selected = { ...state.selected, ...selectedItems }; - return { ...state, selected }; - } - case 'TOGGLE_SELECTED_ITEMS': { - const selected = { ...state.selected, items: [...(action.items ?? [])] }; - return { ...state, selected }; - } - case 'CLEAR': { - // reset state - return getInitialState(state.actions); - } - } - return state; -} - -export const LocationActionsContext = React.createContext< - LocationActionsStateContext | undefined ->(undefined); - -export function LocationActionsProvider({ - actions, - children, -}: LocationActionsProviderProps): React.JSX.Element { - const value = React.useReducer( - locationActionsReducer, - actions, - getInitialState - ); - - return ( - - {children} - - ); -} diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/utils.ts b/packages/react-storage/src/components/StorageBrowser/context/locationActions/utils.ts deleted file mode 100644 index 450cb96aab6..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/locationActions/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { LocationActionsDefault, locationActionsDefault } from './defaults'; -import { LocationActions } from './types'; - -export type ResolveLocationActions = - T extends LocationActionsDefault - ? LocationActionsDefault - : { - [K in keyof LocationActionsDefault]: K extends keyof T - ? T[K] - : LocationActionsDefault[K]; - } & Omit; - -export function resolveLocationActions( - actions?: T -): ResolveLocationActions { - return { - ...actions, - ...Object.entries(locationActionsDefault).reduce( - (output, [key, { options }]) => ({ - ...output, - [key]: actions?.[key as keyof LocationActionsDefault] ?? { options }, - }), - {} - ), - } as ResolveLocationActions; -} diff --git a/packages/react-storage/src/components/StorageBrowser/context/navigate/Navigate.tsx b/packages/react-storage/src/components/StorageBrowser/context/navigate/Navigate.tsx deleted file mode 100644 index 545d3321d59..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/navigate/Navigate.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; - -import { LocationAccess } from '../types'; -import { parseLocationAccess } from './utils'; - -export const INITIAL_STATE = { - location: undefined, - history: [], - path: undefined, -}; - -interface Entry { - position: number; - prefix: string; -} - -export type NavigateAction = - | { type: 'ACCESS_LOCATION'; location: LocationAccess } - | { type: 'NAVIGATE'; entry: Entry } - | { type: 'EXIT' }; - -export interface NavigateState { - location: LocationAccess | undefined; - history: Entry[]; - path: string | undefined; -} - -export type NavigateStateContext = [ - state: NavigateState, - handleUpdateState: (action: NavigateAction) => void, -]; - -export function navigateReducer( - state: NavigateState, - action: NavigateAction -): NavigateState { - switch (action.type) { - case 'ACCESS_LOCATION': { - const { location } = action; - const { prefix } = parseLocationAccess(location); - - return { location, history: [{ prefix, position: 0 }], path: prefix }; - } - case 'NAVIGATE': { - const { prefix, position } = action.entry; - - if (position === 0) - return { ...state, history: [{ prefix, position }], path: prefix }; - - const isExistingEntry = position <= state.history.length - 1; - const history = isExistingEntry - ? state.history.slice(0, position + 1) - : [...state.history, { prefix, position: state.history.length }]; - - const path = history.reduce( - (acc: string, entry) => `${acc}${entry.prefix}`, - '' - ); - - return { ...state, history, path }; - } - case 'EXIT': { - return INITIAL_STATE; - } - } -} - -export const NavigateContext = React.createContext< - NavigateStateContext | undefined ->(undefined); - -export function NavigateProvider({ - children, -}: { - children?: React.ReactNode; -}): React.JSX.Element { - const value = React.useReducer(navigateReducer, INITIAL_STATE); - - return ( - - {children} - - ); -} diff --git a/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/Navigate.spec.tsx b/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/Navigate.spec.tsx deleted file mode 100644 index f8c277618ea..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/Navigate.spec.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { LocationAccess, LocationData } from '../../types'; - -import { - INITIAL_STATE, - NavigateAction, - navigateReducer, - NavigateState, -} from '../Navigate'; - -const location: LocationData = { - bucket: 'bucket-name', - permission: 'READ', - type: 'BUCKET', - prefix: '', -}; - -const locationTwo: LocationData = { - bucket: 'bucket-name', - permission: 'READ', - type: 'PREFIX', - prefix: 'public/', -}; - -const locationAccess: LocationAccess = { - permission: location.permission, - scope: `s3://${location.bucket}/*`, - type: location.type, -}; - -describe('navigateReducer', () => { - it('ACCESS_LOCATION action sets the location and bucket as the initial entry value', () => { - const initialState: NavigateState = INITIAL_STATE; - - const action: NavigateAction = { - type: 'ACCESS_LOCATION', - location: locationAccess, - }; - - const expectedState: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - const newState = navigateReducer(initialState, action); - - expect(newState).toStrictEqual(expectedState); - }); - - it('handles a NAVIGATE action as expected when target prefix is root', () => { - const state: NavigateState = { - history: [ - { prefix: location.prefix, position: 0 }, - { prefix: locationTwo.prefix, position: 1 }, - ], - location: locationAccess, - path: `${location.prefix}${locationTwo.prefix}`, - }; - - const action: NavigateAction = { - type: 'NAVIGATE', - entry: { prefix: location.prefix, position: 0 }, - }; - - const expectedState: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - const newState = navigateReducer(state, action); - - expect(newState).toStrictEqual(expectedState); - }); - - it('handles a NAVIGATE action to a new prefix', () => { - const state: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - const action: NavigateAction = { - type: 'NAVIGATE', - entry: { prefix: locationTwo.prefix, position: 1 }, - }; - - const expectedState: NavigateState = { - history: [ - { prefix: location.prefix, position: 0 }, - { prefix: locationTwo.prefix, position: 1 }, - ], - location: locationAccess, - path: `${location.prefix}${locationTwo.prefix}`, - }; - - const newState = navigateReducer(state, action); - - expect(newState).toEqual(expectedState); - }); - - it('handles a NAVIGATE action between entries with the same prefix', () => { - const state: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - const initialAction: NavigateAction = { - type: 'NAVIGATE', - entry: { prefix: location.prefix, position: 1 }, - }; - - const initialExpectedState: NavigateState = { - history: [ - { prefix: location.prefix, position: 0 }, - { prefix: location.prefix, position: 1 }, - ], - location: locationAccess, - path: `${location.prefix}${location.prefix}`, - }; - - const newState = navigateReducer(state, initialAction); - - expect(newState).toEqual(initialExpectedState); - - const nextAction: NavigateAction = { - type: 'NAVIGATE', - entry: { prefix: location.prefix, position: 0 }, - }; - - const nextState = navigateReducer(initialExpectedState, nextAction); - - const nextExpectedState: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - expect(nextState).toEqual(nextExpectedState); - }); - - it('handles a NAVIGATE action to a previous prefix', () => { - const path = `${location.prefix}${locationTwo.prefix}public/folder1/public/folder1/nestedFolder/`; - const state: NavigateState = { - history: [ - { prefix: location.prefix, position: 0 }, - { prefix: locationTwo.prefix, position: 1 }, - { prefix: 'public/folder1/', position: 2 }, - { prefix: 'public/folder1/nestedFolder/', position: 3 }, - ], - location: locationAccess, - path, - }; - - const action: NavigateAction = { - type: 'NAVIGATE', - entry: { prefix: locationTwo.prefix, position: 1 }, - }; - - const expectedState: NavigateState = { - history: [ - { prefix: location.prefix, position: 0 }, - { prefix: locationTwo.prefix, position: 1 }, - ], - location: locationAccess, - path: `${location.prefix}${locationTwo.prefix}`, - }; - - const newState = navigateReducer(state, action); - - expect(newState).toStrictEqual(expectedState); - }); - - it('handles an EXIT action as expected', () => { - const state: NavigateState = { - history: [{ prefix: location.prefix, position: 0 }], - location: locationAccess, - path: location.prefix, - }; - - const action: NavigateAction = { type: 'EXIT' }; - - const newState = navigateReducer(state, action); - - expect(newState).toStrictEqual(INITIAL_STATE); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/utils.spec.ts deleted file mode 100644 index 5f3223c37c5..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/navigate/__tests__/utils.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { LocationAccess, LocationData } from '../../types'; -import { parseLocationAccess } from '../utils'; - -const bucket = 'test-bucket'; -const folderPrefix = 'test-prefix/'; -const filePath = 'some-file.jpeg2000'; - -describe('parseLocationAccess', () => { - it('throws if provided an invalid location scope', () => { - const invalidLocation: LocationAccess = { - scope: 'nope', - permission: 'READ', - type: 'BUCKET', - }; - - expect(() => parseLocationAccess(invalidLocation)).toThrow( - 'Invalid scope: nope' - ); - }); - - it('throws if provided an invalid location type', () => { - const invalidLocation: LocationAccess = { - scope: 's3://yes', - permission: 'READ', - // @ts-expect-error intentional coercing to allow unhappy path test - type: 'NOT_BUCKET', - }; - - expect(() => parseLocationAccess(invalidLocation)).toThrow( - 'Invalid location type: NOT_BUCKET' - ); - }); - - it('parses a BUCKET location as expected', () => { - const location: LocationAccess = { - permission: 'WRITE', - scope: `s3://${bucket}/*`, - type: 'BUCKET', - }; - const expected: LocationData = { - bucket, - prefix: '', - permission: 'WRITE', - type: 'BUCKET', - }; - - expect(parseLocationAccess(location)).toStrictEqual(expected); - }); - - it('parses a PREFIX location as expected', () => { - const location: LocationAccess = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}*`, - type: 'PREFIX', - }; - - const expected: LocationData = { - bucket, - prefix: folderPrefix, - permission: 'WRITE', - type: 'PREFIX', - }; - - expect(parseLocationAccess(location)).toStrictEqual(expected); - }); - - it('parses an OBJECT location as expected', () => { - const location: LocationAccess = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}${filePath}`, - type: 'OBJECT', - }; - - const expected: LocationData = { - bucket, - prefix: `${folderPrefix}${filePath}`, - permission: 'WRITE', - type: 'OBJECT', - }; - - expect(parseLocationAccess(location)).toStrictEqual(expected); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/context/navigate/index.ts b/packages/react-storage/src/components/StorageBrowser/context/navigate/index.ts deleted file mode 100644 index 0501e02305f..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/navigate/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - NavigateProvider, - NavigateContext, - NavigateStateContext, -} from './Navigate'; diff --git a/packages/react-storage/src/components/StorageBrowser/context/navigate/utils.ts b/packages/react-storage/src/components/StorageBrowser/context/navigate/utils.ts deleted file mode 100644 index 82cc08ffff5..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/navigate/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { LocationAccess, LocationData } from '../types'; - -export const parseLocationAccess = (location: LocationAccess): LocationData => { - const { permission, scope, type } = location; - if (!scope.startsWith('s3://')) { - throw new Error(`Invalid scope: ${scope}`); - } - - // remove default path - const sanitizedScope = scope.slice(5); - let bucket, prefix; - - switch (type) { - case 'BUCKET': { - // { scope: 's3://bucket/*', type: 'BUCKET', }, - bucket = sanitizedScope.slice(0, -2); - prefix = ''; - break; - } - case 'PREFIX': { - // { scope: 's3://bucket/path/*', type: 'PREFIX', }, - bucket = sanitizedScope.slice(0, sanitizedScope.indexOf('/')); - prefix = sanitizedScope.slice(bucket.length + 1, -1); - - break; - } - case 'OBJECT': { - // { scope: 's3://bucket/path/to/object', type: 'OBJECT', }, - bucket = sanitizedScope.slice(0, sanitizedScope.indexOf('/')); - prefix = sanitizedScope.slice(bucket.length + 1); - break; - } - default: { - throw new Error(`Invalid location type: ${type}`); - } - } - - return { bucket, permission, prefix, type }; -}; diff --git a/packages/react-storage/src/components/StorageBrowser/context/useGetCredentialsProvider.ts b/packages/react-storage/src/components/StorageBrowser/context/useGetCredentialsProvider.ts deleted file mode 100644 index 8861f04f1a3..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/context/useGetCredentialsProvider.ts +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { - createLocationCredentialsStore, - GetLocationCredentials, - LocationCredentialsStore, -} from '../storage-internal'; - -export type RegisterAuthListener = (onStateChange: () => void) => void; -export type GetCredentialsProvider = LocationCredentialsStore['getProvider']; - -export function useGetCredentialsProvider( - handler: GetLocationCredentials, - registerAuthListener: RegisterAuthListener, - options?: { onDestroy?: () => void } -): GetCredentialsProvider { - const [{ destroy, getProvider }, setStore] = React.useState(() => - createLocationCredentialsStore({ handler }) - ); - - const { onDestroy } = options ?? {}; - - React.useEffect(() => { - const handleAuthStatusChange = () => { - destroy(); - if (typeof onDestroy === 'function') onDestroy(); - - setStore(createLocationCredentialsStore({ handler })); - }; - - registerAuthListener(handleAuthStatusChange); - }, [destroy, handler, onDestroy, registerAuthListener]); - - return getProvider; -} diff --git a/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts b/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts index 315b3cfbd11..3118354c822 100644 --- a/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts +++ b/packages/react-storage/src/components/StorageBrowser/controls/getTaskCounts.ts @@ -1,8 +1,9 @@ import { INITIAL_STATUS_COUNTS } from '../views/LocationActionView/constants'; -import { CancelableTask } from '../views/LocationActionView/useHandleUpload'; + +import { Task } from '../tasks'; import { TaskCounts } from './types'; -export const getTaskCounts = (tasks: CancelableTask[] = []): TaskCounts => +export const getTaskCounts = (tasks: Task[] = []): TaskCounts => tasks.reduce( (counts, { status }) => ({ ...counts, [status]: counts[status] + 1 }), { ...INITIAL_STATUS_COUNTS, TOTAL: tasks.length } diff --git a/packages/react-storage/src/components/StorageBrowser/createProvider.tsx b/packages/react-storage/src/components/StorageBrowser/createProvider.tsx deleted file mode 100644 index ce43f94c651..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/createProvider.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; - -import { ElementsProvider } from '@aws-amplify/ui-react-core/elements'; - -import { ActionProvider, createListLocationsAction } from './context/actions'; -import { - LocationConfigProvider, - LocationConfigProviderProps, -} from './context/config'; -import { ControlProvider } from './context/control'; -import { StorageBrowserElements } from './context/elements'; -import { LocationActions } from './context/locationActions'; -import { ErrorBoundary } from './ErrorBoundary'; -import { ListLocations } from './storage-internal'; -import { ViewsProvider, Views } from './views'; - -import { ComposablesProvider } from './composables/context'; - -export interface Config - extends Pick< - LocationConfigProviderProps, - 'accountId' | 'getLocationCredentials' | 'region' | 'registerAuthListener' - > { - listLocations: ListLocations; -} - -export interface CreateProviderInput { - actions: LocationActions; - config: Config; - elements?: Partial; -} - -export default function createProvider({ - actions, - config, - elements, -}: CreateProviderInput): (props: { - children?: React.ReactNode; - views?: Views; -}) => React.JSX.Element { - const listLocationsAction = createListLocationsAction(config.listLocations); - - return function Provider({ - children, - views, - }: { - children?: React.ReactNode; - views?: Views; - }): React.JSX.Element { - return ( - - - - - - - {children} - - - - - - - ); - }; -} diff --git a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx index 1e1ba032d2e..c53c88afe22 100644 --- a/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx +++ b/packages/react-storage/src/components/StorageBrowser/createStorageBrowser.tsx @@ -1,17 +1,38 @@ import React from 'react'; import { MergeBaseElements } from '@aws-amplify/ui-react-core/elements'; +import { + LocationActions, + locationActionsDefault, +} from './do-not-import-from-here/locationActions'; import { StorageBrowserElements } from './context/elements'; -import createProvider, { CreateProviderInput } from './createProvider'; +import { ComponentsProvider } from './ComponentsProvider'; +import { createTempActionsProvider } from './do-not-import-from-here/createTempActionsProvider'; +import { ErrorBoundary } from './ErrorBoundary'; + +import { + createConfigurationProvider, + RegisterAuthListener, + StoreProvider, + StoreProviderProps, +} from './providers'; +import { GetLocationCredentials, ListLocations } from './storage-internal'; import { StorageBrowserDefault } from './StorageBrowserDefault'; import { Views, LocationActionView, LocationDetailView, LocationsView, + ViewsProvider, } from './views'; -import { useControl } from './context/control'; -import { locationActionsDefault } from './context/locationActions'; + +export interface Config { + accountId?: string; + getLocationCredentials: GetLocationCredentials; + listLocations: ListLocations; + registerAuthListener: RegisterAuthListener; + region: string; +} const validateRegisterAuthListener = (registerAuthListener: any) => { if (typeof registerAuthListener !== 'function') { @@ -21,17 +42,22 @@ const validateRegisterAuthListener = (registerAuthListener: any) => { } }; -export interface CreateStorageBrowserInput - extends Omit {} +export interface CreateStorageBrowserInput { + actions?: LocationActions; + config: Config; + elements?: Partial; +} export interface StorageBrowserProps { - views?: Views; + views?: Partial; } -export interface StorageBrowserComponent extends Required { - (props: StorageBrowserProps): React.JSX.Element; +export interface StorageBrowserComponent extends Views { + ( + props: StorageBrowserProps & Exclude + ): React.JSX.Element; displayName: string; - Provider: (props: { children?: React.ReactNode }) => React.JSX.Element; + Provider: (props: StoreProviderProps) => React.JSX.Element; } export interface ResolvedStorageBrowserElements< @@ -40,19 +66,52 @@ export interface ResolvedStorageBrowserElements< export function createStorageBrowser(input: CreateStorageBrowserInput): { StorageBrowser: StorageBrowserComponent; - useControl: typeof useControl; } { validateRegisterAuthListener(input.config.registerAuthListener); - const Provider = createProvider({ + const { accountId, registerAuthListener, getLocationCredentials, region } = + input.config; + + // will be replaced, contains the v0 actons API approach + const TempActionsProvider = createTempActionsProvider({ ...input, actions: locationActionsDefault, }); + const ConfigurationProvider = createConfigurationProvider({ + accountId, + displayName: 'ConfigurationProvider', + getLocationCredentials, + region, + registerAuthListener, + }); + + /** + * Provides state, configuration and action values that are shared between + * the primary View components + */ + function Provider({ children, ...props }: StoreProviderProps) { + return ( + + + + + {children} + + + + + ); + } + const StorageBrowser: StorageBrowserComponent = ({ views }) => ( - - - + + + + + + + ); StorageBrowser.LocationActionView = LocationActionView; @@ -63,5 +122,5 @@ export function createStorageBrowser(input: CreateStorageBrowserInput): { StorageBrowser.displayName = 'StorageBrowser'; - return { StorageBrowser, useControl }; + return { StorageBrowser }; } diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/createUseActionStateContext.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/createUseActionStateContext.spec.tsx similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/createUseActionStateContext.spec.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/createUseActionStateContext.spec.tsx diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/downloadAction.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/downloadAction.spec.tsx similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/downloadAction.spec.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/downloadAction.spec.tsx diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/listLocationItemsAction.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationItemsAction.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/listLocationItemsAction.spec.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationItemsAction.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/listLocationsAction.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationsAction.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/__tests__/listLocationsAction.spec.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/__tests__/listLocationsAction.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/actions.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx similarity index 85% rename from packages/react-storage/src/components/StorageBrowser/context/actions/actions.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx index e30a627e734..69dc7832295 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/actions.tsx +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/actions.tsx @@ -2,9 +2,6 @@ import React from 'react'; import { AsyncDataAction, DataAction } from '@aws-amplify/ui-react-core'; -import { Permission } from '../types'; -import { useGetLocationConfig } from '../config'; - import { ActionState, InitialValue, @@ -15,6 +12,7 @@ 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; @@ -55,23 +53,26 @@ export const useAction = ( type: T ): UseActionState => { const [state, handle] = useActionState({ type }); - const config = useGetLocationConfig(); + + const getConfig = useGetActionInput(); const handleAction = React.useCallback( - (input: Parameters[1]>) => - handle({ ...input, config }), - [config, handle] + (input: Parameters[1]>) => { + const { credentials: credentialsProvider, ...config } = getConfig(); + return handle({ ...input, config: { ...config, credentialsProvider } }); + }, + [getConfig, handle] ); return [state, handleAction] as UseActionState; }; -export function ActionProvider({ +export function ActionProvider({ children, listLocationsAction, }: { children?: React.ReactNode; - listLocationsAction: ListLocationsAction; + listLocationsAction: ListLocationsAction; }): React.JSX.Element { return ( diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/createActionStateContext.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createActionStateContext.tsx similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/createActionStateContext.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createActionStateContext.tsx diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/createFolderAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts similarity index 95% rename from packages/react-storage/src/components/StorageBrowser/context/actions/createFolderAction.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts index 4614442a05b..a1cf26e0643 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/createFolderAction.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/createFolderAction.ts @@ -30,7 +30,7 @@ export const createFolderAction = async ( } const { - accountId, + accountId: expectedBucketOwner, bucket: bucketName, credentialsProvider: locationCredentialsProvider, region, @@ -44,7 +44,7 @@ export const createFolderAction = async ( data: '', options: { bucket: { bucketName, region }, - expectedBucketOwner: accountId, + expectedBucketOwner, locationCredentialsProvider, }, }).result; diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/downloadAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/downloadAction.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/downloadAction.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/downloadAction.ts diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts similarity index 78% rename from packages/react-storage/src/components/StorageBrowser/context/actions/index.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts index 53a53e00bac..d0bd1842070 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/index.ts @@ -2,4 +2,3 @@ export { createListLocationsAction } from './listLocationsAction'; export { downloadAction } from './downloadAction'; export { ActionProvider, useAction } from './actions'; export { useLocationsData, LocationsDataState } from './locationsData'; -export { LocationData, LocationItem, Permission } from '../types'; diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/listLocationItemsAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts similarity index 80% rename from packages/react-storage/src/components/StorageBrowser/context/actions/listLocationItemsAction.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts index 7864596e1d0..a963af3e026 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/listLocationItemsAction.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationItemsAction.ts @@ -20,45 +20,38 @@ export interface ListLocationItemsActionOutput type ListOutputItem = ListOutput['items'][number]; -const parseResultItems = ( +const parseItems = ( items: ListOutputItem[], - path: string + excludedPath: string ): LocationItem[] => items - .filter((item): item is ListOutputItem => { - // filter out default prefix item - return item.path !== path; - }) - .map(({ path: _path, lastModified, size }) => { - const key = _path.slice(path.length); + .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, type: 'FOLDER' }; + return { key, id, type: 'FOLDER' }; } return { key, + id, lastModified: lastModified!, size: size!, type: 'FILE', }; }); -const parseResultExcludedPaths = ( - paths: string[] | undefined, - path: string -): LocationItem[] => - paths?.map((key) => ({ key: key.slice(path.length), type: 'FOLDER' })) ?? []; +const parseExcludedPaths = (paths: string[] | undefined): LocationItem[] => + paths?.map((key) => ({ key, id: crypto.randomUUID(), type: 'FOLDER' })) ?? []; export const parseResult = ( output: ListOutput, path: string -): LocationItem[] => { - return [ - ...parseResultExcludedPaths(output.excludedSubpaths, path), - ...parseResultItems(output.items, path), - ]; -}; +): LocationItem[] => [ + ...parseExcludedPaths(output.excludedSubpaths), + ...parseItems(output.items, path), +]; export async function listLocationItemsAction( prevState: ListLocationItemsActionOutput, diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/listLocationsAction.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts similarity index 76% rename from packages/react-storage/src/components/StorageBrowser/context/actions/listLocationsAction.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts index a0c9c81f0a1..ba151a9b76b 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/listLocationsAction.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/listLocationsAction.ts @@ -1,12 +1,9 @@ +import { LocationData } from '../../actions'; import { ListLocations, ListLocationsOutput } from '../../storage-internal'; +import { parseLocationAccess } from '../../actions/handlers/utils'; +import { Permission } from '../../storage-internal'; -import { - ListActionInput, - ListActionOptions, - ListActionOutput, - LocationAccess, - Permission, -} from '../types'; +import { ListActionInput, ListActionOptions, ListActionOutput } from '../types'; const PAGE_SIZE = 1000; @@ -19,13 +16,13 @@ export interface ListLocationsActionInput 'prefix' | 'config' > {} -export interface ListLocationsActionOutput - extends ListActionOutput> {} +export interface ListLocationsActionOutput + extends ListActionOutput {} -export type ListLocationsAction = ( +export type ListLocationsAction = ( prevState: ListLocationsActionOutput, input: ListLocationsActionInput -) => Promise>>; +) => Promise; const shouldExclude = ( permission: T, @@ -66,6 +63,7 @@ export const createListLocationsAction = ( nextToken: nextNextToken, pageSize: remainingPageSize, }); + nextNextToken = output.nextToken; locationsResult = [ @@ -77,9 +75,11 @@ export const createListLocationsAction = ( ]; } while (nextNextToken && locationsResult.length < pageSize); + const nextLocations = locationsResult.map(parseLocationAccess); + const result = refresh - ? locationsResult - : [...(prevState.result ?? []), ...locationsResult]; + ? nextLocations + : [...(prevState.result ?? []), ...nextLocations]; return { result, nextToken: nextNextToken }; }; diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/locationsData.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx similarity index 88% rename from packages/react-storage/src/components/StorageBrowser/context/actions/locationsData.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx index de0dbc99cce..7efac1819ab 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/actions/locationsData.tsx +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/locationsData.tsx @@ -8,7 +8,6 @@ import { ListLocationsActionInput, ListLocationsActionOutput, } from './listLocationsAction'; -import { Permission } from '../types'; export type LocationsDataState = ActionState< ListLocationsActionOutput, @@ -23,12 +22,12 @@ const LocationsDataContext = React.createContext< LocationsDataState | undefined >(undefined); -export function LocationsDataProvider({ +export function LocationsDataProvider({ children, listLocationsAction, }: { children?: React.ReactNode; - listLocationsAction: ListLocationsAction; + listLocationsAction: ListLocationsAction; }): React.JSX.Element { const value = useDataState(listLocationsAction, INITIAL_VALUE); diff --git a/packages/react-storage/src/components/StorageBrowser/context/actions/testUtils.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/actions/testUtils.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts 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 new file mode 100644 index 00000000000..abc7f11f171 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/createTempActionsProvider.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { createContextUtilities } from '@aws-amplify/ui-react-core'; + +import { ActionProvider, createListLocationsAction } from './actions'; +import { LocationActions } from './locationActions'; +import { ListLocations } from '../storage-internal'; + +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/context/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 similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/__snapshots__/defaults.spec.ts.snap diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/__tests__/defaults.spec.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/defaults.spec.ts similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/locationActions/__tests__/defaults.spec.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/defaults.spec.ts diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/__tests__/locationActions.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx similarity index 100% rename from packages/react-storage/src/components/StorageBrowser/context/locationActions/__tests__/locationActions.spec.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/defaults.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx similarity index 93% rename from packages/react-storage/src/components/StorageBrowser/context/locationActions/defaults.tsx rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx index 534d361cd54..90158079d0a 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/locationActions/defaults.tsx +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/defaults.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { IconElement } from '../elements/IconElement'; +import { IconElement } from '../../context/elements/IconElement'; import { LocationAction } from './types'; 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 new file mode 100644 index 00000000000..7a09fcbd040 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/index.ts @@ -0,0 +1,2 @@ +export { LocationActionsDefault, locationActionsDefault } from './defaults'; +export { LocationAction, LocationActions, LocationActionsState } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/context/locationActions/types.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts similarity index 89% rename from packages/react-storage/src/components/StorageBrowser/context/locationActions/types.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts index dd244fa1e7c..f37128b391e 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/locationActions/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/types.ts @@ -1,6 +1,7 @@ import React from 'react'; -import { Permission, PrefixTaskAction, LocationItem } from '../types'; +import { Permission } from '../../storage-internal'; +import { PrefixTaskAction, LocationItem } from '../types'; /** * open native OS file picker with associated selection type on action select @@ -33,7 +34,7 @@ export interface LocationActions { export type LocationActionsAction = | { type: 'CLEAR' } - | { type: 'SET_ACTION'; actionType: T; files?: File[] } + | { type: 'SET_ACTION'; actionType: T } | { type: 'TOGGLE_SELECTED_ITEM'; item: LocationItem } | { type: 'TOGGLE_SELECTED_ITEMS'; items?: LocationItem[] }; @@ -42,7 +43,6 @@ export interface LocationActionsState { selected: { type: T | undefined; items: LocationItem[] | undefined; - files?: File[]; }; } @@ -53,5 +53,6 @@ export type LocationActionsStateContext = [ export interface LocationActionsProviderProps { actions?: LocationActions; + actionType?: string; children?: React.ReactNode; } diff --git a/packages/react-storage/src/components/StorageBrowser/context/types.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts similarity index 61% rename from packages/react-storage/src/components/StorageBrowser/context/types.ts rename to packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts index 07853287141..1850762fe0f 100644 --- a/packages/react-storage/src/components/StorageBrowser/context/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/types.ts @@ -1,14 +1,14 @@ -import { UploadDataInput } from '../storage-internal'; import { LocationCredentialsProvider } from '../storage-internal'; -export interface FolderItem { +interface FolderItem { key: string; + id: string; type: 'FOLDER'; } -export interface FileItem { +interface FileItem { key: string; - data?: File; + id: string; lastModified: Date; size: number; type: 'FILE'; @@ -16,21 +16,6 @@ export interface FileItem { export type LocationItem = FileItem | FolderItem; -export type Permission = 'READ' | 'READWRITE' | 'WRITE'; -export type LocationType = 'OBJECT' | 'PREFIX' | 'BUCKET'; - -export interface LocationAccess { - type: LocationType; - permission: T; - scope: string; -} - -export interface LocationData - extends Pick, 'permission' | 'type'> { - bucket: string; - prefix: string; -} - export interface LocationConfig { accountId?: string; bucket: string; @@ -51,20 +36,6 @@ export interface TaskResult { status: T; } -interface CancelableTaskResult extends TaskResult { - cancel: (() => void) | undefined; -} - -interface UploadItemOptions - extends Pick, 'preventOverwrite'> {} - -interface UploadActionInput extends TaskActionInput {} - -interface UploadActionOutput extends TaskActionOutput {} - -export interface UploadAction - extends TaskAction {} - export interface TaskActionInput { prefix: string; config: LocationConfig | (() => LocationConfig); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/__tests__/useGetActionInputCallback.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/__tests__/useGetActionInputCallback.spec.ts index 729c530c371..efc5a65ea6b 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/__tests__/useGetActionInputCallback.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/__tests__/useGetActionInputCallback.spec.ts @@ -7,7 +7,7 @@ import { useGetActionInputCallback, } from '../useGetActionInputCallback'; -const useHistorySpy = jest.spyOn(StoreModule, 'useHistory'); +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); const useCredentialsSpy = jest.spyOn(CredentialsModule, 'useCredentials'); const credentials = jest.fn(); @@ -38,8 +38,9 @@ describe('useGetActionInputCallback', () => { getCredentials, }); - useHistorySpy.mockReturnValueOnce([ - { current: location, history: [location] }, + useStoreSpy.mockReturnValueOnce([ + // @ts-expect-error mocking out the entire store is unnecessary + { history: { current: location, previous: [location] } }, jest.fn(), ]); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx b/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx index 55c458ba968..2f087b1f9d0 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/context.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { createContextUtilities } from '@aws-amplify/ui-react-core'; import { GetActionInputProviderProps, GetActionInput } from './types'; -import { LocationData as _LocationData } from '../../actions'; import { useGetActionInputCallback } from './useGetActionInputCallback'; const ERROR_MESSAGE = diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts index 8532fc243a4..a0a4778832f 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/index.ts @@ -1,3 +1,4 @@ export { useGetActionInput } from './context'; export { createConfigurationProvider } from './createConfigurationProvider'; +export { CredentialsProviderProps, RegisterAuthListener } from './credentials'; export { GetActionInput } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts b/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts index b979af795d7..0726c3fba22 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/configuration/useGetActionInputCallback.ts @@ -1,9 +1,7 @@ import React from 'react'; -import { isObject, isString } from '@aws-amplify/ui'; - -import { LocationData as _LocationData } from '../../actions'; -import { useHistory } from '../store'; +import { assertIsLocationData } from '../../validators'; +import { useStore } from '../store'; import { useCredentials } from './credentials'; import { GetActionInput } from './types'; @@ -11,32 +9,6 @@ import { GetActionInput } from './types'; export const ERROR_MESSAGE = 'Unable to resolve credentials due to invalid `location`.'; -// temp: LocationData will be extended to include id during integration -interface LocationData extends _LocationData { - id: string; -} - -export const LocationDataKey = [ - 'bucket', - 'id', - 'permission', - 'prefix', - 'type', -] as const; - -// temp: move util to live with listLocations handler during integration -function assertIsLocationData( - value: LocationData | undefined, - message?: string -): asserts value is LocationData { - if ( - !isObject(value) || - LocationDataKey.some((key) => !isString(value[key])) - ) { - throw new Error(message ?? 'Invalid value provided as `location`.'); - } -} - export function useGetActionInputCallback({ accountId, region, @@ -45,7 +17,8 @@ export function useGetActionInputCallback({ region: string; }): GetActionInput { const { getCredentials } = useCredentials(); - const [{ current }] = useHistory(); + const [{ history }] = useStore(); + const { current } = history; return React.useCallback(() => { assertIsLocationData(current, ERROR_MESSAGE); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/index.ts index 5c56c2409c5..47a7f730164 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/index.ts @@ -1,3 +1,11 @@ -export { createConfigurationProvider } from './configuration'; -export { StoreProviderProps, StoreProvider } from './store/StoreProvider'; -export { CredentialsProviderProps } from './configuration/credentials'; +export { + createConfigurationProvider, + CredentialsProviderProps, + RegisterAuthListener, +} from './configuration'; +export { + FileItem, + FileItems, + StoreProviderProps, + StoreProvider, +} from './store'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/StoreProvider.tsx b/packages/react-storage/src/components/StorageBrowser/providers/store/StoreProvider.tsx index a1bd94f300c..30500ae1245 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/StoreProvider.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/StoreProvider.tsx @@ -14,16 +14,13 @@ export interface StoreProviderProps HistoryProviderProps, LocationItemsProviderProps {} -export function StoreProvider({ - actionType, - children, - location, - locationItems, -}: StoreProviderProps): React.JSX.Element { +export function StoreProvider(props: StoreProviderProps): React.JSX.Element { + const { actionType, children, location } = props; + return ( - + {children} diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/types.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/types.ts index e930eb8ec50..54c064428d9 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/types.ts @@ -1,6 +1,6 @@ export type ActionTypeAction = | { type: 'SET_ACTION_TYPE'; actionType: string } - | { type: 'RESET' }; + | { type: 'RESET_ACTION_TYPE' }; export type HandleActionTypeAction = (event: ActionTypeAction) => void; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/useActionTypeState.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/useActionTypeState.ts index 67a54a638f0..43456a183ed 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/useActionTypeState.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/useActionTypeState.ts @@ -11,7 +11,7 @@ const handleAction = (event: ActionTypeAction): string | undefined => { case 'SET_ACTION_TYPE': { return event.actionType; } - case 'RESET': { + case 'RESET_ACTION_TYPE': { return undefined; } } diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/index.ts index 1aa198648ac..da89fbd3b28 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/index.ts @@ -1,2 +1,2 @@ -export { FilesProvider, useFiles } from './context'; +export { FilesProvider, FilesContext, useFiles } from './context'; export * from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts index e3afea1febc..016b84723f1 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/types.ts @@ -3,13 +3,10 @@ import { SelectionType } from '../../../actions/configs'; export type FilesActionType = | { type: 'ADD_FILE_ITEMS'; files?: File[] } | { type: 'REMOVE_FILE_ITEM'; id: string } - | { type: 'RESET' }; + | { type: 'SELECT_FILES'; selectionType?: SelectionType } + | { type: 'RESET_FILE_ITEMS' }; -export type HandleFilesAction = ( - input: - | FilesActionType - | { type: 'SELECT_FILES'; selectionType?: SelectionType } -) => void; +export type HandleFilesAction = (input: FilesActionType) => void; export interface FileItem { id: string; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts index f9f2e8ce564..65327dfd418 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/utils.ts @@ -32,10 +32,10 @@ export const resolveFiles = ( return [...prevItems, ...nextItems]; }; -export const filesReducer: React.Reducer = ( - prevItems, - input -) => { +export const filesReducer: React.Reducer< + FileItems, + Exclude +> = (prevItems, input) => { switch (input.type) { case 'ADD_FILE_ITEMS': { return resolveFiles(prevItems, input.files); @@ -43,7 +43,7 @@ export const filesReducer: React.Reducer = ( case 'REMOVE_FILE_ITEM': { return prevItems.filter(({ id }) => id !== input.id); } - case 'RESET': { + case 'RESET_FILE_ITEMS': { return []; } } diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/history/context.tsx b/packages/react-storage/src/components/StorageBrowser/providers/store/history/context.tsx index 79211e26ef1..09993a19b5f 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/history/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/history/context.tsx @@ -1,56 +1,31 @@ import React from 'react'; import { createContextUtilities } from '@aws-amplify/ui-react-core'; -import { isObject, isString, noop } from '@aws-amplify/ui'; +import { noop } from '@aws-amplify/ui'; -import { LocationData as _LocationData } from '../../../actions'; +import { assertIsLocationData } from '../../../validators'; +import { LocationData } from '../../../actions'; export const ERROR_MESSAGE = 'Invalid `location` value provided as initial value to `HistoryProvider.'; -// temp: LocationData will be extended to include id during integration -interface LocationData extends _LocationData { - id: string; -} - -export const LocationDataKey = [ - 'bucket', - 'id', - 'permission', - 'prefix', - 'type', -] as const; - -// temp: move util to live with listLocations handler during integration -function assertIsLocationData( - value: LocationData | undefined, - message?: string -): asserts value is LocationData { - if ( - !isObject(value) || - LocationDataKey.some((key) => !isString(value[key])) - ) { - throw new Error(message ?? 'Invalid value provided as `location`.'); - } -} - export const DEFAULT_STATE: HistoryState = { current: undefined, - history: undefined, + previous: undefined, }; -export type HistoryAction = +export type HistoryActionType = | { type: 'NAVIGATE'; destination: LocationData } - | { type: 'RESET' }; + | { type: 'RESET_HISTORY' }; export interface HistoryState { current: LocationData | undefined; - history: LocationData[] | undefined; + previous: LocationData[] | undefined; } export type HistoryStateContext = [ HistoryState, - (action: HistoryAction) => void, + (action: HistoryActionType) => void, ]; export interface HistoryProviderProps { @@ -60,7 +35,7 @@ export interface HistoryProviderProps { function handleAction( state: HistoryState, - action: HistoryAction + action: HistoryActionType ): HistoryState { switch (action.type) { case 'NAVIGATE': { @@ -68,21 +43,21 @@ function handleAction( if (state.current?.id === destination.id) return state; - if (!state.history?.length) { - return { current: destination, history: [destination] }; + if (!state.previous?.length) { + return { current: destination, previous: [destination] }; } - const itemIndex = state.history.findIndex( + const itemIndex = state.previous.findIndex( ({ id }) => id === destination.id ); - const history = + const previous = itemIndex === -1 - ? [...state.history, destination] - : state.history.slice(0, itemIndex + 1); + ? [...state.previous, destination] + : state.previous.slice(0, itemIndex + 1); - return { current: destination, history }; + return { current: destination, previous }; } - case 'RESET': { + case 'RESET_HISTORY': { return DEFAULT_STATE; } } @@ -104,7 +79,7 @@ export function HistoryProvider({ const value = React.useReducer( handleAction, - current ? { current, history: [current] } : DEFAULT_STATE + current ? { current, previous: [current] } : DEFAULT_STATE ); return ( diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/history/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/history/index.ts index ccaae2e5a2e..687f418b2a7 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/history/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/history/index.ts @@ -1 +1,7 @@ -export { HistoryProvider, HistoryProviderProps, useHistory } from './context'; +export { + HistoryActionType, + HistoryProvider, + HistoryState, + HistoryProviderProps, + useHistory, +} from './context'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts index b07a3d64e46..342bfbdffd8 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts @@ -1,5 +1,3 @@ -export { useActionType } from './actionType'; -export { useHistory } from './history'; -export { useFiles } from './files'; -export { useLocationItems } from './locationItems'; +export { FileItem, FileItems } from './files'; export { StoreProvider, StoreProviderProps } from './StoreProvider'; +export { useStore } from './useStore'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts index 5aa3ddb165f..7014cddd6bb 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts @@ -31,7 +31,7 @@ describe('useLocationItems', () => { const fileDataItems: FileData[] = [fileDataItemOne]; act(() => { - handler({ type: 'SET_FILE_DATA', items: fileDataItems }); + handler({ type: 'SET_LOCATION_ITEMS', items: fileDataItems }); }); const [nextState] = result.current; @@ -42,7 +42,7 @@ describe('useLocationItems', () => { const additionalFileDataItems = [...fileDataItems, fileDataItemTwo]; act(() => { - handler({ type: 'SET_FILE_DATA', items: additionalFileDataItems }); + handler({ type: 'SET_LOCATION_ITEMS', items: additionalFileDataItems }); }); const [updatedState] = result.current; @@ -52,7 +52,7 @@ describe('useLocationItems', () => { const targetId = fileDataItemOne.id; act(() => { - handler({ type: 'REMOVE_FILE_DATA', id: targetId }); + handler({ type: 'REMOVE_LOCATION_ITEM', id: targetId }); }); const [removedState] = result.current; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx index ac191344b58..c6fa0e1f697 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/context.tsx @@ -3,23 +3,16 @@ import React from 'react'; import { createContextUtilities } from '@aws-amplify/ui-react-core'; import { noop } from '@aws-amplify/ui'; -// temp: to be replaced with listLocationItems data type during integration -export interface FileData { - key: string; - lastModified: Date; - id: string; - size: number; - type: 'FILE'; -} +import { FileData } from '../../../actions/handlers'; export const DEFAULT_STATE: LocationItemsState = { fileDataItems: undefined, }; export type LocationItemsAction = - | { type: 'RESET' } - | { type: 'SET_FILE_DATA'; items?: FileData[] } - | { type: 'REMOVE_FILE_DATA'; id: string }; + | { type: 'SET_LOCATION_ITEMS'; items?: FileData[] } + | { type: 'REMOVE_LOCATION_ITEM'; id: string } + | { type: 'RESET_LOCATION_ITEMS' }; export interface LocationItemsState { fileDataItems: FileData[] | undefined; @@ -33,7 +26,6 @@ export type LocationItemStateContext = [ ]; export interface LocationItemsProviderProps { - locationItems?: LocationItemsState; children?: React.ReactNode; } @@ -42,7 +34,7 @@ const locatonItemsReducer = ( event: LocationItemsAction ): LocationItemsState => { switch (event.type) { - case 'SET_FILE_DATA': { + case 'SET_LOCATION_ITEMS': { const { items } = event; if (!items?.length) return prevState; @@ -62,7 +54,7 @@ const locatonItemsReducer = ( fileDataItems: [...prevState.fileDataItems, ...nextFileDataItems], }; } - case 'REMOVE_FILE_DATA': { + case 'REMOVE_LOCATION_ITEM': { const { id } = event; if (!prevState.fileDataItems) return prevState; @@ -77,7 +69,7 @@ const locatonItemsReducer = ( return { fileDataItems }; } - case 'RESET': { + case 'RESET_LOCATION_ITEMS': { return DEFAULT_STATE; } } @@ -92,9 +84,8 @@ export const { LocationItemsContext, useLocationItems } = export function LocationItemsProvider({ children, - locationItems = DEFAULT_STATE, }: LocationItemsProviderProps): React.JSX.Element { - const value = React.useReducer(locatonItemsReducer, locationItems); + const value = React.useReducer(locatonItemsReducer, DEFAULT_STATE); return ( diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/index.ts index 5a83c100271..554ee4a6d41 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/index.ts @@ -1,5 +1,7 @@ export { + LocationItemsAction, LocationItemsProvider, LocationItemsProviderProps, + LocationItemsState, useLocationItems, } from './context'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/useStore.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/useStore.ts new file mode 100644 index 00000000000..d021a39f218 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/useStore.ts @@ -0,0 +1,70 @@ +import React from 'react'; + +import { ActionTypeAction, useActionType } from './actionType'; +import { FileItems, FilesActionType, useFiles } from './files'; +import { HistoryActionType, HistoryState, useHistory } from './history'; +import { + LocationItemsAction, + LocationItemsState, + useLocationItems, +} from './locationItems'; + +export interface UseStoreState { + actionType: string | undefined; + files: FileItems | undefined; + history: HistoryState; + locationItems: LocationItemsState; +} + +export type HandleStoreAction = ( + action: + | ActionTypeAction + | FilesActionType + | HistoryActionType + | LocationItemsAction +) => void; + +export function useStore(): [UseStoreState, HandleStoreAction] { + const [actionType, dispatchActionType] = useActionType(); + const [files, dispatchFilesAction] = useFiles(); + const [history, dispatchHistoryAction] = useHistory(); + const [locationItems, dispatchLocationItemsAction] = useLocationItems(); + + const dispatchHandler: HandleStoreAction = React.useCallback( + (action) => { + switch (action.type) { + case 'ADD_FILE_ITEMS': + case 'REMOVE_FILE_ITEM': + case 'SELECT_FILES': + case 'RESET_FILE_ITEMS': { + dispatchFilesAction(action); + break; + } + case 'NAVIGATE': + case 'RESET_HISTORY': { + dispatchHistoryAction(action); + break; + } + case 'SET_LOCATION_ITEMS': + case 'REMOVE_LOCATION_ITEM': + case 'RESET_LOCATION_ITEMS': { + dispatchLocationItemsAction(action); + break; + } + case 'SET_ACTION_TYPE': + case 'RESET_ACTION_TYPE': { + dispatchActionType(action); + break; + } + } + }, + [ + dispatchActionType, + dispatchFilesAction, + dispatchHistoryAction, + dispatchLocationItemsAction, + ] + ); + + return [{ actionType, files, history, locationItems }, dispatchHandler]; +} diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts index 1809caffcaa..e83fac6e2b2 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/types.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/types.ts @@ -14,6 +14,7 @@ export interface ProcessTasksOptions { export interface Task { cancel: undefined | (() => void); + id: string; item: T; key: string; message: string | undefined; @@ -22,7 +23,7 @@ export interface Task { } export type HandleProcessTasks = ( - ...input: Omit, 'data'>[] + ...input: Omit, 'data' | 'key'>[] ) => void; export type ProcessTasksState = [ diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts index fc3f350f50e..229e1c63002 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/useProcessTasks.ts @@ -12,7 +12,7 @@ import { ProcessTasksState, Task, } from './types'; -import { hasExistingTask, isCancelableOutput, updateTasks } from './utils'; +import { isCancelableOutput, updateTasks } from './utils'; const QUEUED_TASK_BASE = { cancel: undefined, @@ -24,7 +24,7 @@ export const useProcessTasks = ( handler: ( input: TaskHandlerInput ) => TaskHandlerOutput | CancelableTaskHandlerOutput, - items: { key: string; item: T }[] | undefined, + items: { key: string; id: string; item: T }[] | undefined, options?: ProcessTasksOptions ): ProcessTasksState => { const { concurrency } = options ?? {}; @@ -36,19 +36,26 @@ export const useProcessTasks = ( React.useEffect(() => { if (!items?.length) return; + const createRemove = (targetId: string) => + function remove() { + if (inflight.current.has(targetId)) return; + + setTasks((prevTasks) => prevTasks.filter(({ id }) => id !== targetId)); + }; + setTasks((prevTasks) => { - const nextTasks: Task[] = items.reduce((tasks, item) => { - const remove = () => { - if (inflight.current.has(item.key)) return; - - setTasks((prevTasks) => - prevTasks.filter(({ key }) => key !== item.key) - ); - }; - return hasExistingTask(prevTasks, item) - ? tasks - : [...tasks, { ...item, ...QUEUED_TASK_BASE, remove }]; - }, [] as Task[]); + const nextTasks: Task[] = items.reduce( + (tasks: Task[], item) => + prevTasks.some(({ id }) => id === item.id) + ? tasks + : [ + ...tasks, + { ...item, ...QUEUED_TASK_BASE, remove: createRemove(item.id) }, + ], + [] + ); + + if (!nextTasks.length) return prevTasks; return [...prevTasks, ...nextTasks]; }); @@ -57,19 +64,21 @@ export const useProcessTasks = ( const _processTasks: HandleProcessTasks = React.useCallback( (input) => { setTasks((prevTasks) => { - const nextTask = prevTasks.find( - ({ key: _key, status }) => status === 'QUEUED' - ); + const nextTask = prevTasks.find(({ status }) => status === 'QUEUED'); - if (!nextTask || inflight.current.has(nextTask.key)) { + if (!nextTask || inflight.current.has(nextTask.id)) { return prevTasks; } - const { item, key } = nextTask; + const { id, item: payload, key } = nextTask; - inflight.current.set(key, item); + inflight.current.set(id, payload); - const output = handler({ ...input, data: { key, payload: item } }); + const output = handler({ + ...input, + key, + data: { id, payload }, + }); const isCancelable = isCancelableOutput(output); @@ -77,9 +86,7 @@ export const useProcessTasks = ( ? undefined : () => { output.cancel?.(); - setTasks((prev) => - updateTasks(prev, { key, status: 'CANCELED' }) - ); + setTasks((prev) => updateTasks(prev, { id, status: 'CANCELED' })); }; const { result } = output; @@ -87,25 +94,25 @@ export const useProcessTasks = ( result .then((status) => { setTasks((prev) => - updateTasks(prev, { key, cancel: undefined, status }) + updateTasks(prev, { id, cancel: undefined, status }) ); }) .catch(({ message }: Error) => { setTasks((prev) => updateTasks(prev, { - key, cancel: undefined, + id, message, status: 'FAILED', }) ); }) .finally(() => { - inflight.current.delete(key); + inflight.current.delete(id); _processTasks(input); }); - return updateTasks(prevTasks, { key, cancel, status: 'PENDING' }); + return updateTasks(prevTasks, { cancel, id, status: 'PENDING' }); }); }, [handler] diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts index 5e0648594b7..c86aeab1053 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/utils.ts @@ -2,11 +2,11 @@ import { isFunction } from '@aws-amplify/ui'; import { CancelableTaskHandlerOutput, TaskHandlerOutput } from '../actions'; -export const updateTasks = ( +export const updateTasks = ( tasks: T[], - task: Partial + task: Partial & { id: string } ): T[] => { - const index = tasks.findIndex(({ key }) => key === task.key); + const index = tasks.findIndex(({ id }) => id === task.id); if (index === -1) return tasks; @@ -23,8 +23,3 @@ export const isCancelableOutput = ( output: TaskHandlerOutput | CancelableTaskHandlerOutput ): output is CancelableTaskHandlerOutput => isFunction((output as CancelableTaskHandlerOutput).cancel); - -export const hasExistingTask = ( - tasks: { key: string }[], - item: { key: string } -): boolean => tasks.some(({ key }) => key === item.key); diff --git a/packages/react-storage/src/components/StorageBrowser/validators/assertIsLocationData.ts b/packages/react-storage/src/components/StorageBrowser/validators/assertIsLocationData.ts new file mode 100644 index 00000000000..fcf74b548e5 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/validators/assertIsLocationData.ts @@ -0,0 +1,22 @@ +import { isObject, isString } from '@aws-amplify/ui'; +import { LocationData } from '../actions'; + +export const LocationDataKey = [ + 'bucket', + 'id', + 'permission', + 'prefix', + 'type', +] as const; + +export function assertIsLocationData( + value: LocationData | undefined, + message?: string +): asserts value is LocationData { + if ( + !isObject(value) || + LocationDataKey.some((key) => !isString(value[key])) + ) { + throw new Error(message ?? 'Invalid value provided as `location`.'); + } +} diff --git a/packages/react-storage/src/components/StorageBrowser/validators/index.ts b/packages/react-storage/src/components/StorageBrowser/validators/index.ts new file mode 100644 index 00000000000..11bdb61247a --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/validators/index.ts @@ -0,0 +1 @@ +export { assertIsLocationData } from './assertIsLocationData'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/Download.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/Download.tsx index b1a3f9f6f7b..49bce3aad44 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/Download.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/Download.tsx @@ -2,11 +2,11 @@ import React, { useEffect } from 'react'; import { useDataState } from '@aws-amplify/ui-react-core'; -import { downloadAction } from '../../context/actions'; +import { downloadAction } from '../../do-not-import-from-here/actions'; import { CLASS_BASE } from '../constants'; -import { useGetLocationConfig } from '../../context/config'; import { ButtonElement } from '../../context/elements/definitions'; import { IconElement } from '../../context/elements/IconElement'; +import { useGetActionInput } from '../../providers/configuration'; const BLOCK_NAME = `${CLASS_BASE}__download`; @@ -30,7 +30,7 @@ interface DownloadControlProps { export const DownloadControl = ({ fileKey, }: DownloadControlProps): React.JSX.Element => { - const getConfig = useGetLocationConfig(); + const getConfig = useGetActionInput(); const [{ data }, handleDownload] = useDataState(downloadAction, { signedUrl: '', }); @@ -52,8 +52,9 @@ export const DownloadControl = ({ className={BLOCK_NAME} variant="download" onClick={() => { + const config = getConfig(); handleDownload({ - config: getConfig, + config: { ...config, credentialsProvider: config.credentials }, key: fileKey, }); setShouldDownload(true); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/Navigate.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/Navigate.tsx index a2c7514d7a9..c586e770e33 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/Navigate.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/Navigate.tsx @@ -1,6 +1,6 @@ import React from 'react'; +import { isFunction } from '@aws-amplify/ui'; -import { useControl } from '../../context/control'; import { ButtonElement, ButtonElementProps, @@ -11,11 +11,11 @@ import { SpanElement, StorageBrowserElements, } from '../../context/elements'; -import { parseLocationAccess } from '../../context/navigate/utils'; import { CLASS_BASE } from '../constants'; -import { LocationData, useAction } from '../../context/actions'; +import { useAction } from '../../do-not-import-from-here/actions'; +import { useStore } from '../../providers/store'; interface NavigateItemProps extends ButtonElementProps { isCurrent?: boolean; @@ -85,47 +85,53 @@ function NavigateContainer({ ); } -export function NavigateControl(): React.JSX.Element { - const [{ history, location }, handleUpdateState] = useControl('NAVIGATE'); - const [{ isLoading }, handleUpdateList] = useAction('LIST_LOCATION_ITEMS'); - const [, handleLocationActionsState] = useControl('LOCATION_ACTIONS'); +export function NavigateControl({ + onExit, +}: { + onExit?: () => void; +}): React.JSX.Element { + const [{ history }, dispatchStoreAction] = useStore(); + const { current, previous } = history; - const { bucket } = location - ? parseLocationAccess(location) - : ({} as LocationData); + const [{ isLoading }, handleList] = useAction('LIST_LOCATION_ITEMS'); return ( { - handleUpdateState({ type: 'EXIT' }); - handleUpdateList({ prefix: '', options: { reset: true } }); - handleLocationActionsState({ type: 'CLEAR' }); + if (isFunction(onExit)) onExit(); + dispatchStoreAction({ type: 'RESET_HISTORY' }); + + handleList({ + // @todo: prefix should not be required to refresh + prefix: current?.prefix ?? '', + options: { reset: true }, + }); + dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); }} > {HOME_NAVIGATE_ITEM} - {history?.map((entry, index) => { - const { position, prefix: _prefix } = entry; + {previous?.map((destination, index) => { + const { bucket, id, prefix: _prefix } = destination; // remove trailing `/` from `prefix` - const prefix = _prefix.endsWith('/') ? _prefix.slice(0, -1) : _prefix; + const prefix = _prefix?.endsWith('/') ? _prefix.slice(0, -1) : _prefix; // if `position` is the first index: // - concatenate `bucket` and `prefix` // - if `prefix` is truthy, insert `/` between `bucket` and `prefix` const displayValue = - position === 0 - ? `${bucket}${prefix ? `/${prefix}` : prefix}` - : prefix; + index === 0 ? `${bucket}${prefix ? `/${prefix}` : prefix}` : prefix; - const isCurrent = index === history.length - 1; + const isCurrent = index === previous.length - 1; return ( { - handleUpdateState({ type: 'NAVIGATE', entry }); - handleLocationActionsState({ type: 'CLEAR' }); + dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); + dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); + dispatchStoreAction({ type: 'NAVIGATE', destination }); }} isCurrent={isCurrent} > diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/Table.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/Table.tsx index 554a6ade742..4be5c420c8d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/Table.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/Table.tsx @@ -1,17 +1,16 @@ import React from 'react'; -import { humanFileSize } from '@aws-amplify/ui'; +import { humanFileSize, isFunction } from '@aws-amplify/ui'; import { TABLE_HEADER_BUTTON_CLASS_NAME } from '../../components/DataTable'; -import { useAction } from '../../context/actions'; +import { useAction } from '../../do-not-import-from-here/actions'; import { SpanElementProps, StorageBrowserElements, TableDataCellElementProps, TableHeaderElementProps, } from '../../context/elements'; -import { useControl } from '../../context/control'; -import { FileItem, FolderItem, LocationItem } from '../../context/types'; +import { FolderData, LocationItemData } from '../../actions/handlers'; import { CLASS_BASE } from '../constants'; import { compareDates, compareNumbers, compareStrings } from '../utils'; @@ -19,6 +18,9 @@ import { DownloadControl } from './Download'; import { useDropZone } from '@aws-amplify/ui-react-core'; import { Checkbox } from '../../components/Checkbox'; +import { useStore } from '../../providers/store'; +import { FileData, LocationData } from '../../actions/handlers'; + export type SortDirection = 'ascending' | 'descending' | 'none'; export type SortState = { @@ -91,7 +93,7 @@ export function TableDataText({ return ; } -const LOCATION_DETAIL_VIEW_COLUMNS: Column[] = [ +const LOCATION_DETAIL_VIEW_COLUMNS: Column[] = [ { // @TODO: Fix me after refactor // @ts-ignore @@ -107,15 +109,15 @@ const LOCATION_DETAIL_VIEW_COLUMNS: Column[] = [ header: 'Type', }, { - key: 'lastModified' as keyof LocationItem, + key: 'lastModified' as keyof LocationItemData, header: 'Last Modified', }, { - key: 'size' as keyof LocationItem, + key: 'size' as keyof LocationItemData, header: 'Size', }, { - key: 'download' as keyof LocationItem, + key: 'download' as keyof LocationItemData, header: 'Download', }, ]; @@ -177,7 +179,7 @@ TableControl.TableRow = TableRow; TableControl.TableData = TableData; TableControl.TableHeader = TableHeader; -const LocationDetailViewColumnSortMap = { +const sortFns = { key: compareStrings, type: compareStrings, lastModified: compareDates, @@ -188,28 +190,31 @@ const LocationDetailViewColumnEmptyHeaderMap = ['download']; export const LocationDetailViewTable = ({ handleDroppedFiles, + onNavigate, range, }: { handleDroppedFiles: (files: File[]) => void; + onNavigate?: (location: LocationData) => void; range: [start: number, end: number]; }): JSX.Element | null => { const [start, end] = range; - const [{ history, path }, handleUpdateState] = useControl('NAVIGATE'); - const [{ selected }, handleLocationActionsState] = - useControl('LOCATION_ACTIONS'); + const [{ history, locationItems }, dispatchStoreAction] = useStore(); + const { current } = history; + const { fileDataItems } = locationItems; const [{ data, hasError }] = useAction('LIST_LOCATION_ITEMS'); - const currentPosition = history.length; const hasItems = !!data.result?.length; const showTable = hasItems && !hasError; const [compareFn, setCompareFn] = React.useState(() => compareStrings); - const [sortState, setSortState] = React.useState>({ - selection: 'key', - direction: 'ascending', - }); + const [sortState, setSortState] = React.useState>( + { + selection: 'key', + direction: 'ascending', + } + ); const { direction, selection } = sortState; @@ -222,12 +227,14 @@ export const LocationDetailViewTable = ({ : pagedData.sort((a, b) => compareFn(b[selection], a[selection])); // Logic for Select All Files functionality - const allFiles = pagedData.filter((item) => item.type === 'FILE'); - const areAllFilesSelected = selected.items?.length === allFiles.length; + const allFiles = pagedData.filter( + (item): item is FileData => item.type === 'FILE' + ); + const areAllFilesSelected = fileDataItems?.length === allFiles.length; const hasSelectableFiles = !!allFiles.length; const renderHeaderItem = React.useCallback( - (column: Column) => { + (column: Column) => { // Defining this function inside the `LocationDetailViewTable` to get access // to the current sort state @@ -238,18 +245,21 @@ export const LocationDetailViewTable = ({ key={header} variant={key} aria-label={ - key == ('download' as keyof LocationItem) + key == ('download' as keyof LocationItemData) ? column.header : undefined } aria-sort={selection === key ? direction : 'none'} > - {LocationDetailViewColumnSortMap[column.key] ? ( + {/* @ts-expect-error */} + {sortFns[column.key] ? ( {row.key.slice(folder, row.key.length)} @@ -171,38 +182,50 @@ const renderRowItem: RenderRowItem = ( ); }; -export const UploadControls = (): JSX.Element => { - const [{ history, path }] = useControl('NAVIGATE'); +const getFileSelectionType = ( + actionType?: string, + files?: FileItems +): 'FILE' | 'FOLDER' | undefined => { + if (files?.length ?? !actionType) return undefined; + + return actionType === 'UPLOAD_FILES' ? 'FILE' : 'FOLDER'; +}; + +export const UploadControls = ({ + onClose, +}: { + onClose?: () => void; +}): JSX.Element => { + const getInput = useGetActionInput(); const [preventOverwrite, setPreventOverwrite] = React.useState( DEFAULT_OVERWRITE_PROTECTION ); - const [{ selected }, handleUpdateState] = useControl('LOCATION_ACTIONS'); - const [tasks, handleUpload, handleFileSelect, handleCancel] = useHandleUpload( - { prefix: path, preventOverwrite } + const [{ actionType, files, history }, dispatchStoreAction] = useStore(); + const { current } = history; + + // launch native file picker on intiial render if no files are currently in state + const selectionTypeRef = React.useRef<'FILE' | 'FOLDER' | undefined>( + getFileSelectionType(actionType, files) ); - const handleFileInput = React.useRef(null); - const handleFolderInput = React.useRef(null); + React.useEffect(() => { + const selectionType = selectionTypeRef.current; + if (!selectionType) { + return; + } - // Noticed that in Safari, the file picker was not registering the on change event - // unless I made sure that the useEffect for clicking the file input was only clicked once - const initialRun = React.useRef(false); + dispatchStoreAction({ type: 'SELECT_FILES', selectionType }); - React.useEffect(() => { - if (!initialRun.current) { - if (selected.files) { - handleFileSelect(selected.files); - } else if (selected.type === 'UPLOAD_FILES') { - handleFileInput.current?.click(); - } else if (selected.type === 'UPLOAD_FOLDER') { - handleFolderInput.current?.click(); - } + return () => { + selectionTypeRef.current = undefined; + }; + }, [dispatchStoreAction]); - initialRun.current = true; - } - }, [handleFileSelect, selected.files, selected.type]); + const [tasks, handleProcess] = useProcessTasks(uploadHandler, files, { + concurrency: 4, + }); const [compareFn, setCompareFn] = React.useState<(a: any, b: any) => number>( () => compareStrings @@ -214,15 +237,20 @@ export const UploadControls = (): JSX.Element => { const { direction, selection } = sortState; const tableData = tasks - .map(({ data, ...task }) => { - const { webkitRelativePath, type = '-' } = data; + .map(({ key, id, item, remove: _remove, ...task }) => { + const { size, webkitRelativePath, type = '-' } = item; const folder = webkitRelativePath?.length > 0 ? webkitRelativePath.slice(0, webkitRelativePath.lastIndexOf('/') + 1) : '/'; - return { ...task, data, type, folder }; + const remove = () => { + dispatchStoreAction({ type: 'REMOVE_FILE_ITEM', id }); + _remove(); + }; + + return { ...task, folder, key, progress: 0, remove, size, type }; }) .sort((a, b) => direction === 'ascending' @@ -306,33 +334,27 @@ export const UploadControls = (): JSX.Element => { return ( - { - handleFileSelect([...(target.files ?? [])]); - }} - ref={handleFileInput} - /> - { - handleFileSelect([...(target.files ?? [])]); + { + if (isFunction(onClose)) onClose?.(); + // clear tasks state + tasks.forEach(({ remove }) => remove()); + // clear files state + dispatchStoreAction({ type: 'RESET_FILE_ITEMS' }); + // clear selected action + dispatchStoreAction({ type: 'RESET_ACTION_TYPE' }); }} - // @ts-expect-error webkitdirectory is not typed - webkitdirectory="" - ref={handleFolderInput} /> - handleUpdateState({ type: 'CLEAR' })} /> <Primary disabled={disablePrimary} onClick={() => { - handleUpload(); + if (!current?.prefix) return; + handleProcess({ + config: getInput(), + prefix: current.prefix, + options: { preventOverwrite }, + }); }} > Start @@ -342,7 +364,9 @@ export const UploadControls = (): JSX.Element => { disabled={disableCancel} className={`${CLASS_BASE}__cancel`} onClick={() => { - handleCancel(); + tasks.forEach((task) => { + task.cancel?.(); + }); }} > Cancel @@ -352,7 +376,10 @@ export const UploadControls = (): JSX.Element => { className={`${CLASS_BASE}__add-folder`} variant="add-folder" onClick={() => { - handleFolderInput?.current?.click(); + dispatchStoreAction({ + type: 'SELECT_FILES', + selectionType: 'FOLDER', + }); }} > Add folder @@ -362,7 +389,7 @@ export const UploadControls = (): JSX.Element => { className={`${CLASS_BASE}__add-files`} variant="add-files" onClick={() => { - handleFileInput?.current?.click(); + dispatchStoreAction({ type: 'SELECT_FILES', selectionType: 'FILE' }); }} > Add files @@ -372,7 +399,7 @@ export const UploadControls = (): JSX.Element => { descriptions={[ { term: `${displayText.actionDestination}:`, - details: history[history.length - 1].prefix, + details: current?.prefix, }, ]} /> @@ -390,7 +417,9 @@ export const UploadControls = (): JSX.Element => { <Table data={tableData} columns={LOCATION_ACTION_VIEW_COLUMNS} - handleDroppedFiles={handleFileSelect} + handleDroppedFiles={(files) => { + dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); + }} renderHeaderItem={renderHeaderItem} renderRowItem={renderRowItem} /> diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CreateFolderControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CreateFolderControls.spec.tsx index bd179ee495f..2e42e4fbb42 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CreateFolderControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/CreateFolderControls.spec.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, waitFor, screen, fireEvent } from '@testing-library/react'; -import createProvider from '../../../createProvider'; -import * as ActionsModule from '../../../context/actions'; +import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; +import * as ActionsModule from '../../../do-not-import-from-here/actions'; import * as ControlsModule from '../../../context/control'; import { 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 eb05354ba74..8631f860e61 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 @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import * as ControlsModule from '../../../context/control'; -import createProvider from '../../../createProvider'; +import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; import { LocationActionView } from '../LocationActionView'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx index 600657310f6..34f537db4c8 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx @@ -8,8 +8,8 @@ import { } from '@testing-library/react'; import * as ControlsModule from '../../../context/control'; -import createProvider from '../../../createProvider'; -import { LocationActionsState } from '../../../context/locationActions'; +import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; +import { LocationActionsState } from '../../../do-not-import-from-here/locationActions'; import { UploadControls, ActionIcon, ICON_CLASS } from '../UploadControls'; import userEvent from '@testing-library/user-event'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useHandleUpload.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useHandleUpload.ts deleted file mode 100644 index 3b67e170ffa..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/useHandleUpload.ts +++ /dev/null @@ -1,244 +0,0 @@ -import React from 'react'; -import { isCancelError } from 'aws-amplify/storage'; -import { uploadData, UploadDataInput } from '../../storage-internal'; -import { isFunction, isUndefined } from '@aws-amplify/ui'; - -import { useGetLocationConfig } from '../../context/config'; -import { TaskStatus } from '../../context/types'; - -// 5MB for multipart upload -// https://github.com/aws-amplify/amplify-js/blob/1a5366d113c9af4ce994168653df3aadb142c581/packages/storage/src/providers/s3/utils/constants.ts#L16 -export const MULTIPART_UPLOAD_THRESHOLD_BYTES = 5 * 1024 * 1024; - -/** - * Base `task` - */ -interface Task { - key: string; - data: File; - progress: number; - size: number; - status: TaskStatus; -} - -export interface CancelableTask extends Omit<Task, 'status'> { - cancel: (() => void) | undefined; - status: TaskStatus | 'CANCELED'; -} - -interface TaskUpdate extends Partial<CancelableTask> { - key: string; -} - -const getFileKey = (file: File) => { - const { name, webkitRelativePath } = file; - - return webkitRelativePath?.length > 0 ? webkitRelativePath : name; -}; - -const removeTask = <T extends Task | CancelableTask>( - tasks: T[], - key: string -): T[] => { - const index = tasks.findIndex(({ key: itemKey }) => key === itemKey); - - if (index === -1) { - return tasks; - } - - if (index === 0) { - return tasks.slice(1); - } - - if (index === tasks.length) { - return tasks.slice(-1); - } - - return [...tasks.slice(0, index), ...tasks.slice(index + 1)]; -}; - -const updateTasks = <T extends Task | CancelableTask>( - tasks: T[], - task: TaskUpdate -): T[] => { - const index = tasks.findIndex(({ key }) => key === task.key); - const updatedTask = { ...tasks[index], ...task }; - - if (index === 0) { - return [updatedTask, ...tasks.slice(1)]; - } - - if (index === tasks.length) { - return [...tasks.slice(-1), updatedTask]; - } - - return [...tasks.slice(0, index), updatedTask, ...tasks.slice(index + 1)]; -}; - -const mergeSelectedTasks = ( - prevTasks: CancelableTask[], - newTasks: CancelableTask[] -): CancelableTask[] => { - const tasks: CancelableTask[] = []; - const tasksSet = new Set<string>(); - - // Add new tasks so they appear on the top of the table - newTasks.forEach((file) => { - tasks.push(file); - tasksSet.add(file.key); - }); - - // Add back any of the older tasks that we previously had - prevTasks.forEach((file) => { - if (!tasksSet.has(file.key)) { - tasks.push(file); - } - }); - - return tasks; -}; - -export function useHandleUpload({ - prefix, - preventOverwrite, -}: { - prefix: string | undefined; - preventOverwrite: boolean; -}): [ - tasks: CancelableTask[], - handleUpload: () => void, - handleFileSelect: (files: File[]) => void, - handleCancel: () => void, -] { - const getConfig = useGetLocationConfig(); - - const [tasks, setTasks] = React.useState<CancelableTask[]>(() => []); - - const handleFileSelect = (newFiles: File[]) => { - setTasks((prevTasks) => { - // iterate over new files and create new tasks - const newTasks = newFiles.map((file) => { - const key = getFileKey(file); - - return { - cancel: () => { - setTasks((prev) => removeTask(prev, key)); - }, - key, - data: file, - size: file.size, - status: 'INITIAL' as const, - progress: 0, - }; - }); - - return mergeSelectedTasks(prevTasks, newTasks); - }); - }; - - const processUpload = React.useCallback( - async (_task?: CancelableTask) => { - const task = _task ?? tasks.find(({ status }) => status === 'INITIAL'); - - if (!task || isUndefined(prefix)) return; - - const { key, data } = task; - const { - accountId, - bucket: bucketName, - credentialsProvider, - region, - } = getConfig(); - - const input: UploadDataInput = { - path: `${prefix}${key}`, - data, - options: { - bucket: { bucketName, region }, - expectedBucketOwner: accountId, - locationCredentialsProvider: credentialsProvider, - onProgress: ({ totalBytes, transferredBytes }) => { - const progress = totalBytes ? transferredBytes / totalBytes : 0; - const nextTask: TaskUpdate = { - key, - progress, - ...(totalBytes === transferredBytes - ? { cancel: undefined } - : undefined), - }; - setTasks((curr) => updateTasks(curr, nextTask)); - }, - preventOverwrite, - }, - }; - - try { - const { cancel: _cancel, result } = uploadData(input); - - const cancel = - task.size < MULTIPART_UPLOAD_THRESHOLD_BYTES ? undefined : _cancel; - - setTasks((prevTasks) => - prevTasks.map(({ key: _key, status, ...rest }) => { - const isCurrent = _key === key; - if (isCurrent) { - return { ...rest, key, status: 'PENDING', cancel }; - } - - const isInitial = status === 'INITIAL'; - - if (isInitial) { - return { - ...rest, - key: _key, - status: 'QUEUED', - cancel: () => - setTasks((prev) => - updateTasks(prev, { - key: _key, - cancel: undefined, - status: 'CANCELED', - }) - ), - }; - } - - return { ...rest, key: _key, status }; - }) - ); - - await result; - - setTasks((prevTasks) => - updateTasks(prevTasks, { key, cancel: undefined, status: 'COMPLETE' }) - ); - } catch (error) { - const status = isCancelError(error) ? 'CANCELED' : 'FAILED'; - - setTasks((prevTasks) => - updateTasks(prevTasks, { key, cancel: undefined, status }) - ); - } - }, - [getConfig, tasks, prefix, preventOverwrite] - ); - - React.useEffect(() => { - const hasStarted = tasks.some(({ status }) => status !== 'INITIAL'); - const hasPendingTask = tasks.some(({ status }) => status === 'PENDING'); - - if (hasPendingTask || !hasStarted) return; - - const nextTask = tasks.find(({ status }) => status === 'QUEUED'); - - if (!nextTask) return; - - processUpload(nextTask); - }, [processUpload, tasks]); - - const handleCancelAll = () => { - tasks.forEach(({ cancel }) => (isFunction(cancel) ? cancel() : undefined)); - }; - - return [tasks, () => processUpload(), handleFileSelect, handleCancelAll]; -} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx index 4e6827db0de..199a5f73491 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { isString } from '@aws-amplify/ui'; -import { LocationData, useAction } from '../../context/actions'; -import { useControl } from '../../context/control'; -import { parseLocationAccess } from '../../context/navigate/utils'; +import { isFunction, isUndefined } from '@aws-amplify/ui'; + +import { useAction } from '../../do-not-import-from-here/actions'; +import { useStore } from '../../providers/store'; import { Controls, LocationDetailViewTable } from '../Controls'; import { usePaginate } from '../hooks/usePaginate'; -import { isFile, listViewHelpers } from '../utils'; +import { listViewHelpers } from '../utils'; import { ActionsMenuControl } from './Controls/ActionsMenu'; +import { LocationDetailViewProps } from './types'; export const DEFAULT_ERROR_MESSAGE = 'There was an error loading items.'; const DEFAULT_PAGE_SIZE = 100; @@ -31,13 +32,9 @@ const { } = Controls; export const Title = (): React.JSX.Element => { - const [{ history, location }] = useControl('NAVIGATE'); - - const { bucket } = location - ? parseLocationAccess(location) - : ({} as LocationData); - - const prefix = history?.slice(-1)[0]?.prefix; + const [{ history }] = useStore(); + const { current } = history; + const { bucket, prefix } = current ?? {}; return <TitleControl>{prefix ? prefix : bucket}</TitleControl>; }; @@ -74,75 +71,56 @@ const LocationDetailEmptyMessage = () => { ) : null; }; -export const LocationDetailViewControls = (): React.JSX.Element => { - const [state] = useControl('NAVIGATE'); - const { path } = state; - +export const LocationDetailViewControls = ({ + onActionSelect, + onNavigate, + onExit, +}: Omit< + LocationDetailViewProps, + 'children' | 'className' +>): React.JSX.Element => { const [{ data, isLoading, hasError }, handleList] = useAction( 'LIST_LOCATION_ITEMS' ); - const [, handleLocationActionsState] = useControl('LOCATION_ACTIONS'); - const [, handleUpdateState] = useControl('LOCATION_ACTIONS'); + const [{ history }, dispatchStoreAction] = useStore(); + const { current } = history; + const { prefix } = current ?? {}; + const hasInvalidPrefix = isUndefined(prefix); const handleDroppedFiles = (files: File[]) => { - if (isFile(files[0])) { - handleUpdateState({ - type: 'SET_ACTION', - actionType: 'UPLOAD_FILES', - files, - }); - } else { - handleUpdateState({ - type: 'SET_ACTION', - actionType: 'UPLOAD_FOLDER', - files, - }); - } + dispatchStoreAction({ type: 'ADD_FILE_ITEMS', files }); + dispatchStoreAction({ + type: 'SET_ACTION_TYPE', + actionType: 'UPLOAD_FILES', + }); + + if (isFunction(onActionSelect)) onActionSelect('UPLOAD_FILES'); }; const { result, nextToken } = data; const resultCount = result.length; const hasNextToken = !!nextToken; - const hasValidPath = isString(path); const onPaginateNext = () => { - if (!hasValidPath) return; - - handleLocationActionsState({ type: 'CLEAR' }); - handleList({ - prefix: path, - options: { ...DEFAULT_LIST_OPTIONS, nextToken }, - }); - }; + if (hasInvalidPrefix || !nextToken) return; - const onPaginatePrevious = () => { - if (!hasValidPath) return; - - handleLocationActionsState({ type: 'CLEAR' }); + handleList({ prefix, options: { ...DEFAULT_LIST_OPTIONS, nextToken } }); }; const { currentPage, handlePaginateNext, handlePaginatePrevious, - handleReset, - } = usePaginate({ - onPaginateNext, - onPaginatePrevious, - pageSize: DEFAULT_PAGE_SIZE, - }); + handleReset: handlePaginateReset, + } = usePaginate({ pageSize: DEFAULT_PAGE_SIZE, onPaginateNext }); React.useEffect(() => { - if (!hasValidPath) return; + if (hasInvalidPrefix) return; - handleReset(); - - handleList({ - prefix: path, - options: DEFAULT_REFRESH_OPTIONS, - }); - }, [path, handleList, handleReset, hasValidPath]); + handlePaginateReset(); + handleList({ prefix, options: DEFAULT_REFRESH_OPTIONS }); + }, [handleList, handlePaginateReset, hasInvalidPrefix, prefix]); const { disableActionsMenu, @@ -160,23 +138,24 @@ export const LocationDetailViewControls = (): React.JSX.Element => { hasError, }); + const handleRefresh = () => { + if (hasInvalidPrefix) return; + handlePaginateReset(); + handleList({ prefix, options: DEFAULT_REFRESH_OPTIONS }); + }; + return ( <> - <Navigate /> + <Navigate onExit={onExit} /> <Title /> <RefreshControl disableRefresh={disableRefresh} - handleRefresh={() => { - if (!hasValidPath) return; - handleReset(); - handleList({ - prefix: path, - options: DEFAULT_REFRESH_OPTIONS, - }); - handleLocationActionsState({ type: 'CLEAR' }); - }} + handleRefresh={handleRefresh} + /> + <ActionsMenuControl + onActionSelect={onActionSelect} + disabled={disableActionsMenu} /> - <ActionsMenuControl disabled={disableActionsMenu} /> <Paginate currentPage={currentPage} disableNext={disableNext} @@ -189,6 +168,7 @@ export const LocationDetailViewControls = (): React.JSX.Element => { <LocationDetailMessage /> <Loading show={renderLoading} /> <LocationDetailViewTable + onNavigate={onNavigate} handleDroppedFiles={handleDroppedFiles} range={range} /> diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx index 247dfcee87d..a74d69b415b 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx @@ -2,10 +2,12 @@ import React from 'react'; import { isEmptyObject } from '@aws-amplify/ui'; +import { LocationItemData } from '../../../actions'; import { ActionsMenu, ActionItemProps } from '../../../components/ActionsMenu'; -import { useControl } from '../../../context/control'; -import { LocationActions } from '../../../context/locationActions'; -import { LocationItem, Permission } from '../../../context/types'; +import { LocationActions } from '../../../do-not-import-from-here/locationActions'; +import { useTempActions } from '../../../do-not-import-from-here/createTempActionsProvider'; +import { useStore } from '../../../providers/store'; +import { Permission } from '../../../storage-internal'; const getKeyedFragments = (...nodes: React.ReactNode[]): React.ReactNode[] => nodes.map((child, key) => <React.Fragment key={key}>{child}</React.Fragment>); @@ -17,7 +19,7 @@ const getActionsMenuData = ({ permission, }: { actions: LocationActions; - items: LocationItem[] | undefined; + items: LocationItemData[] | undefined; onSelect: (type: string) => void; permission: Permission | undefined; }): ActionItemProps[] => @@ -32,7 +34,7 @@ const getActionsMenuData = ({ } const children = getKeyedFragments(icon, displayName); const disabled = - typeof disable === 'function' ? disable(items) : (disable ?? false); + typeof disable === 'function' ? disable(items) : disable ?? false; const onClick = () => onSelect(key); return [ @@ -45,26 +47,31 @@ const getActionsMenuData = ({ export function ActionsMenuControl({ disabled = false, + onActionSelect, }: { disabled?: boolean; + onActionSelect?: (type: string) => void; }): React.JSX.Element { - const [{ actions, selected }, handleUpdate] = useControl('LOCATION_ACTIONS'); - const [{ location }] = useControl('NAVIGATE'); + // leave in place until actions API is integrated + const actions = useTempActions(); + const [{ history, locationItems }, dispatchStoreAction] = useStore(); + const { fileDataItems } = locationItems; + const { current: location } = history; const { permission } = location ?? {}; - const { items } = selected; const data = React.useMemo( () => getActionsMenuData({ actions, - items, + items: fileDataItems, onSelect: (actionType) => { - handleUpdate({ type: 'SET_ACTION', actionType }); + onActionSelect?.(actionType); + dispatchStoreAction({ type: 'SET_ACTION_TYPE', actionType }); }, permission, }), - [actions, handleUpdate, items, permission] + [actions, dispatchStoreAction, fileDataItems, onActionSelect, permission] ); return <ActionsMenu data={data} disabled={disabled} />; 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 37b4a4e3581..0b6f286551c 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/LocationDetailView.tsx @@ -4,20 +4,24 @@ import { CLASS_BASE } from '../constants'; import { resolveClassName } from '../utils'; import { LocationDetailViewControls } from './Controls'; - -export interface LocationDetailViewProps { - className?: (defaultClassName: string) => string; -} +import { LocationDetailViewProps } from './types'; export function LocationDetailView({ className, + onActionSelect, + onNavigate, + onExit, }: LocationDetailViewProps): React.JSX.Element { return ( <div className={resolveClassName(CLASS_BASE, className)} data-testid="LOCATION_DETAIL_VIEW" > - <LocationDetailViewControls /> + <LocationDetailViewControls + onActionSelect={onActionSelect} + onNavigate={onNavigate} + onExit={onExit} + /> </div> ); } 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 de4dd9b0962..9209e764d32 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 @@ -8,8 +8,8 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import createProvider from '../../../createProvider'; -import * as ActionsModule from '../../../context/actions'; +import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; +import * as ActionsModule from '../../../do-not-import-from-here/actions'; import * as ControlsModule from '../../../context/control'; import * as PaginateModule from '../../hooks/usePaginate'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/index.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/index.ts index e6210a9b5d6..44a312e63fe 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/index.ts @@ -1,4 +1,2 @@ -export { - LocationDetailView, - LocationDetailViewProps, -} from './LocationDetailView'; +export { LocationDetailView } from './LocationDetailView'; +export { LocationDetailViewProps } from './types'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts new file mode 100644 index 00000000000..a9f7747cdd3 --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/types.ts @@ -0,0 +1,8 @@ +import { LocationData } from '../../actions'; + +export interface LocationDetailViewProps { + className?: (defaultClassName: string) => string; + onActionSelect?: (type: string) => void; + onExit?: () => void; + onNavigate?: (destination: LocationData) => void; +} diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/DataTable.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/DataTable.tsx index 3a23b1a425c..e2cea52bad4 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/DataTable.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/DataTable.tsx @@ -8,13 +8,13 @@ import { TABLE_HEADER_BUTTON_CLASS_NAME, TABLE_HEADER_CLASS_NAME, } from '../../../components/DataTable'; -import { useLocationsData } from '../../../context/actions'; -import { useControl } from '../../../context/control'; -import { LocationAccess } from '../../../context/types'; +import { useLocationsData } from '../../../do-not-import-from-here/actions'; + import { ButtonElement, IconElement } from '../../../context/elements'; -import { parseLocationAccess } from '../../../context/navigate/utils'; +import { useStore } from '../../../providers/store'; import { compareStrings } from '../../utils'; +import { LocationData } from '../../../actions'; export type SortDirection = 'ascending' | 'descending' | 'none'; @@ -77,8 +77,8 @@ const getLocationsData = ({ onTableHeaderClick, sortState, }: { - data: LocationAccess[]; - onLocationClick: (location: LocationAccess) => void; + data: LocationData[]; + onLocationClick: (location: LocationData) => void; onTableHeaderClick: (location: string) => void; sortState: SortState; }) => { @@ -93,7 +93,7 @@ const getLocationsData = ({ const compareFn = getCompareFn(selection); if (compareFn) { - const castSelection = selection as keyof LocationAccess; + const castSelection = selection as keyof LocationData; if (direction === 'ascending') { data.sort((a, b) => compareFn(a[castSelection], b[castSelection])); @@ -102,40 +102,35 @@ const getLocationsData = ({ } } - const rows = data.map((location, index) => { - const parsedLocation = parseLocationAccess(location); - const row = [ - { - key: `td-name-${index}`, - children: ( - <ButtonElement - className={TABLE_DATA_BUTTON_CLASS} - onClick={() => onLocationClick(location)} - variant="table-data" - > - {parsedLocation.prefix - ? parsedLocation.prefix - : `${parsedLocation.bucket}/`} - </ButtonElement> - ), - }, - { key: `td-bucket-${index}`, children: parsedLocation.bucket }, - { key: `td-permission-${index}`, children: location.permission }, - ]; - return row; - }); + const rows = data.map((location, index) => [ + { + key: `td-name-${index}`, + children: ( + <ButtonElement + className={TABLE_DATA_BUTTON_CLASS} + onClick={() => onLocationClick(location)} + variant="table-data" + > + {location.prefix.length ? location.prefix : location.bucket} + </ButtonElement> + ), + }, + { key: `td-bucket-${index}`, children: location.bucket }, + { key: `td-permission-${index}`, children: location.permission }, + ]); return { columns, rows }; }; export function DataTableControl({ + onNavigate, range, }: { + onNavigate?: (destination: LocationData) => void; range: [start: number, end: number]; }): React.JSX.Element | null { const [{ data, hasError }] = useLocationsData(); - - const [, handleUpdateState] = useControl('NAVIGATE'); + const dispatchStoreAction = useStore()[1]; const [sortState, setSortState] = React.useState<SortState>({ selection: 'prefix', @@ -149,21 +144,19 @@ export function DataTableControl({ getLocationsData({ data: data.result.slice(start, end), sortState, - onLocationClick: (location) => { - handleUpdateState({ - type: 'ACCESS_LOCATION', - location, - }); + onLocationClick: (destination) => { + onNavigate?.(destination); + dispatchStoreAction({ type: 'NAVIGATE', destination }); }, - onTableHeaderClick: (location: string) => { + onTableHeaderClick: (selection: string) => { setSortState((prevState) => ({ - selection: location, + selection, direction: prevState.direction === 'ascending' ? 'descending' : 'ascending', })); }, }), - [data.result, start, end, sortState, handleUpdateState] + [data.result, dispatchStoreAction, onNavigate, sortState, start, end] ); return hasError ? null : <DataTable data={locationsData} />; diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx index 89859942934..818235a8c30 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import * as UseLocationsDataModule from '../../../../context/actions'; +import * as UseLocationsDataModule from '../../../../do-not-import-from-here/actions'; import * as UseControlModule from '../../../../context/control'; -import { LocationAccess } from '../../../../context/types'; +import { LocationAccess } from '../../../../do-not-import-from-here/types'; import { DataTableControl } from '../DataTable'; 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 1953bd5a6e3..61bdde6b869 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/LocationsView.tsx @@ -2,15 +2,17 @@ import React from 'react'; import { CLASS_BASE } from '../constants'; import { Controls } from '../Controls'; -import { useLocationsData } from '../../context/actions'; +import { useLocationsData } from '../../do-not-import-from-here/actions'; import { usePaginate } from '../hooks/usePaginate'; import { listViewHelpers, resolveClassName } from '../utils'; import { DataTableControl } from './Controls/DataTable'; +import { LocationData } from '../../actions'; export interface LocationsViewProps { className?: (defaultClassName: string) => string; + onNavigate?: (destination: LocationData) => void; } const DEFAULT_PAGE_SIZE = 100; @@ -63,6 +65,7 @@ const LocationsEmptyMessage = () => { export function LocationsView({ className, + onNavigate, }: LocationsViewProps): React.JSX.Element { const [{ data, isLoading, hasError }, handleList] = useLocationsData(); @@ -125,7 +128,7 @@ export function LocationsView({ /> <LocationsMessage /> <Loading /> - <DataTableControl range={range} /> + <DataTableControl onNavigate={onNavigate} range={range} /> <LocationsEmptyMessage /> </div> ); 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 dc9793e3a3f..517432af431 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; -import createProvider from '../../../createProvider'; -import * as ActionsModule from '../../../context/actions'; +import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; +import * as ActionsModule from '../../../do-not-import-from-here/actions'; import * as ControlsModule from '../../../context/control'; import { LocationsView } from '..'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/context.tsx b/packages/react-storage/src/components/StorageBrowser/views/context.tsx index 20010789169..0eff0ea283d 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/context.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/context.tsx @@ -14,7 +14,9 @@ import { } from './LocationsView'; export interface Views { - LocationActionView?: (props: LocationActionViewProps) => React.JSX.Element; + LocationActionView?: ( + props: LocationActionViewProps + ) => React.JSX.Element | null; LocationDetailView?: (props: LocationDetailViewProps) => React.JSX.Element; LocationsView?: (props: LocationsViewProps) => React.JSX.Element; } diff --git a/packages/react-storage/src/components/StorageBrowser/views/index.ts b/packages/react-storage/src/components/StorageBrowser/views/index.ts index 599007ff46c..6a3c176875b 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/index.ts @@ -1,4 +1,10 @@ -export { LocationActionView } from './LocationActionView'; -export { LocationDetailView } from './LocationDetailView'; -export { LocationsView } from './LocationsView'; +export { + LocationActionView, + LocationActionViewProps, +} from './LocationActionView'; +export { + LocationDetailView, + LocationDetailViewProps, +} from './LocationDetailView'; +export { LocationsView, LocationsViewProps } from './LocationsView'; export * from './context'; diff --git a/packages/react-storage/src/components/StorageBrowser/views/utils.ts b/packages/react-storage/src/components/StorageBrowser/views/utils.ts index 8955fcd7619..3878a9c2a60 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/utils.ts +++ b/packages/react-storage/src/components/StorageBrowser/views/utils.ts @@ -34,7 +34,7 @@ export const listViewHelpers = ({ return { disableActionsMenu: isLoading, - disableRefresh: isLoading || hasEmptyResults, + disableRefresh: isLoading, disableNext: (!hasNextToken && isLastPage) || isLoading || hasEmptyResults || hasError, disablePrevious: From 65bb31834e575462a90e6edaba468f43857133a4 Mon Sep 17 00:00:00 2001 From: Caleb Pollman <cpollman@amazon.com> Date: Mon, 21 Oct 2024 13:41:58 -0700 Subject: [PATCH 2/6] feat(storage-browser): add routed example app --- examples/next/pages/_document.page.tsx | 2 - .../default-auth/routed/StorageBrowser.ts | 26 +++++++ .../[location-detail]/index.page.tsx | 58 +++++++++++++++ .../routed/[locations]/index.page.tsx | 41 +++++++++++ .../default-auth/routed/aws-exports.js | 2 + .../default-auth/routed/index.page.tsx | 23 ++++++ .../default-auth/routed/useIsSignedIn.ts | 71 +++++++++++++++++++ .../managed-auth/index.page.tsx | 43 ++--------- .../managed-auth/routed/StorageBrowser.ts | 16 +++++ .../[location-detail]/index.page.tsx | 52 ++++++++++++++ .../routed/[locations]/index.page.tsx | 37 ++++++++++ .../managed-auth/routed/components.tsx | 54 ++++++++++++++ .../managed-auth/routed/index.page.tsx | 21 ++++++ .../storage-browser/managedAuthAdapter.ts | 45 +++++++++--- 14 files changed, 443 insertions(+), 48 deletions(-) create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts create mode 100644 examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts create mode 100644 examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx create mode 100644 examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx diff --git a/examples/next/pages/_document.page.tsx b/examples/next/pages/_document.page.tsx index 4122e24f77a..52f07545e8c 100644 --- a/examples/next/pages/_document.page.tsx +++ b/examples/next/pages/_document.page.tsx @@ -20,8 +20,6 @@ class MyDocument extends Document { </Head> <body> <title>React Example App -

React Example App

-
diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts new file mode 100644 index 00000000000..6b6f2538710 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts @@ -0,0 +1,26 @@ +import { Amplify } from 'aws-amplify'; + +import { + createAmplifyAuthAdapter, + createStorageBrowser, + elementsDefault, +} from '@aws-amplify/ui-react-storage/browser'; +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +import config from './aws-exports'; + +Amplify.configure(config); + +const defaultPrefixes = [ + 'public/', + // intentionally added to test a prefix that should return 403 forbidden + 'forbidden/', + (identityId: string) => `protected/${identityId}/`, + (identityId: string) => `private/${identityId}/`, +]; + +export const { StorageBrowser } = createStorageBrowser({ + elements: elementsDefault, + config: createAmplifyAuthAdapter({ options: { defaultPrefixes } }), +}); diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx new file mode 100644 index 00000000000..509b7c40096 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -0,0 +1,58 @@ +import { useRouter } from 'next/router'; + +import { signOut } from 'aws-amplify/auth'; +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { StorageBrowser } from '../../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +export default function Page() { + const router = useRouter(); + + if (!router.query.bucket) return null; + + return ( + + + + { + router.replace({ query: { ...router.query, actionType } }); + }} + onExit={() => { + router.back(); + }} + /> + {typeof router.query.actionType === 'string' ? ( + + { + router.replace({ + query: { ...router.query, actionType: undefined }, + }); + }} + /> + + ) : null} + + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx new file mode 100644 index 00000000000..e43bc321b4a --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { signOut } from 'aws-amplify/auth'; +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { StorageBrowser } from '../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +function Locations() { + const router = useRouter(); + + return ( + + + + + { + router.push({ + pathname: `${router.pathname}/location-detail`, + query: { ...destination }, + }); + }} + /> + + + + ); +} + +export default Locations; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js new file mode 100644 index 00000000000..4245c81a219 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js @@ -0,0 +1,2 @@ +import awsExports from '@environments/storage/file-uploader/src/aws-exports'; +export default awsExports; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx new file mode 100644 index 00000000000..9cfc40de58b --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import useIsSignedIn from './useIsSignedIn'; + +import { Authenticator } from '@aws-amplify/ui-react'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +function Example() { + const router = useRouter(); + + useIsSignedIn({ + onSignIn: () => { + router.push(`${router.pathname}/locations`); + }, + }); + + return ; +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts new file mode 100644 index 00000000000..b3099704ccc --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts @@ -0,0 +1,71 @@ +import React from 'react'; + +import { fetchAuthSession } from 'aws-amplify/auth'; +import { Hub, HubCallback } from '@aws-amplify/core'; +import { isFunction } from '@aws-amplify/ui'; + +export interface UseIsSignedInParams { + onSignIn?: () => void; + onSignOut?: () => void; +} + +interface UseIsSignedIn { + isSignedIn: boolean; +} + +const INITIAL_STATE: UseIsSignedIn = { isSignedIn: false }; + +/** + * listens for `Auth` sign in and sign out events + * + * @param {UseIsSignedInParams} params `onSignIn` and `onSignOut` event callbacks + * @returns {UseIsSignedIn} Outputs `isSignedIn` + */ +export default function useIsSignedIn({ + onSignIn, + onSignOut, +}: UseIsSignedInParams): UseIsSignedIn { + const [output, setOutput] = React.useState( + () => INITIAL_STATE + ); + + React.useEffect(() => { + fetchAuthSession().then(() => { + if (isFunction(onSignIn)) onSignIn(); + }); + }, [onSignIn]); + + const handleEvent: HubCallback = React.useCallback( + ({ payload }) => { + switch (payload.event) { + case 'signIn': + case 'autoSignIn': { + if (isFunction(onSignIn)) { + onSignIn(); + } + setOutput({ isSignedIn: true }); + break; + } + case 'signOut': { + if (isFunction(onSignOut)) { + onSignOut(); + } + setOutput({ isSignedIn: false }); + break; + } + default: { + break; + } + } + }, + [onSignIn, onSignOut] + ); + + React.useEffect(() => { + const unsubscribe = Hub.listen('auth', handleEvent, 'useIsSignedIn'); + + return unsubscribe; + }, [handleEvent]); + + return output; +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx index b8e23faef8e..8ae7e54e41a 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx @@ -2,9 +2,8 @@ import React from 'react'; import { createStorageBrowser } from '@aws-amplify/ui-react-storage/browser'; -import { auth, managedAuthAdapter } from '../managedAuthAdapter'; - -import { Button, Flex } from '@aws-amplify/ui-react'; +import { managedAuthAdapter } from '../managedAuthAdapter'; +import { SignIn, SignOutButton } from './routed/components'; import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; import '@aws-amplify/ui-react-storage/styles.css'; @@ -12,43 +11,13 @@ import '@aws-amplify/ui-react-storage/styles.css'; const { StorageBrowser } = createStorageBrowser({ config: managedAuthAdapter }); function Example() { - const [authenticated, setAuthenticated] = React.useState(false); - const [isLoading, setIsLoading] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(); + const [showSignIn, setShowSignIn] = React.useState(false); - return !authenticated ? ( - - - {isLoading ? Authenticating... : null} - {errorMessage ? {errorMessage} : null} - + return !showSignIn ? ( + setShowSignIn(true)} /> ) : ( <> - + setShowSignIn(false)} /> ); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts new file mode 100644 index 00000000000..89a3b4d478b --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts @@ -0,0 +1,16 @@ +import { Auth } from '../../managedAuthAdapter'; +import { + createManagedAuthAdapter, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; + +export const routedAuth = new Auth({ persistCredentials: true }); + +const config = createManagedAuthAdapter({ + credentialsProvider: routedAuth.credentialsProvider, + region: process.env.NEXT_PUBLIC_MANAGED_AUTH_REGION, + accountId: process.env.NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID, + registerAuthListener: routedAuth.registerAuthListener, +}); + +export const { StorageBrowser } = createStorageBrowser({ config }); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx new file mode 100644 index 00000000000..d590ed7279e --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -0,0 +1,52 @@ +import { useRouter } from 'next/router'; + +import { Flex } from '@aws-amplify/ui-react'; + +import { SignOutButton } from '../../components'; +import { StorageBrowser } from '../../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +export default function Page() { + const { back, query, pathname, replace } = useRouter(); + + if (!query.bucket) return null; + + return ( + + { + replace(pathname.replace('[locations]/[location-detail]', '')); + }} + /> + + { + replace({ query: { ...query, actionType } }); + }} + onNavigate={(destination) => { + replace({ query: { ...destination } }); + }} + onExit={() => { + back(); + }} + /> + {typeof query.actionType === 'string' ? ( + + { + replace({ query: { ...query, actionType: undefined } }); + }} + /> + + ) : null} + + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx new file mode 100644 index 00000000000..60906f26802 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { Flex } from '@aws-amplify/ui-react'; + +import { SignOutButton } from '../components'; +import { StorageBrowser } from '../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +function Locations() { + const router = useRouter(); + + return ( + + { + router.replace(router.pathname.replace('[locations]', '')); + }} + /> + + + { + router.push({ + pathname: `${router.pathname}/location-detail`, + query: { ...destination }, + }); + }} + /> + + + ); +} + +export default Locations; diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx new file mode 100644 index 00000000000..62672c7ce3c --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { routedAuth } from './StorageBrowser'; + +export function SignIn({ onSignIn }: { onSignIn?: () => void }) { + const [isLoading, setIsLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + + return ( + + + + {isLoading ? Authenticating... : null} + {errorMessage ? {errorMessage} : null} + + ); +} + +export function SignOutButton({ onSignOut }: { onSignOut?: () => void }) { + return ( + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx new file mode 100644 index 00000000000..39451335171 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { SignIn } from './components'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +function Example() { + const router = useRouter(); + + return ( + { + router.push(`${router.pathname}/locations`); + }} + /> + ); +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts b/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts index d67cb457254..f6f8051c1df 100644 --- a/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts +++ b/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts @@ -6,21 +6,48 @@ import { type CredentialsProvider = CreateManagedAuthAdapterInput['credentialsProvider']; type Credentials = Awaited>; -class Auth { +export class Auth { + #persistCredentials: boolean; #credentials: Credentials | undefined; #onAuthStatusChange: () => void | undefined; + constructor(options?: { persistCredentials?: boolean }) { + const { persistCredentials = false } = options ?? {}; + this.#persistCredentials = persistCredentials; + } + + #clearCredentials() { + this.#onAuthStatusChange?.(); + this.#onAuthStatusChange = undefined; + localStorage.removeItem('creds'); + this.#credentials = undefined; + } + #getCredentials(): Credentials { + if (this.#persistCredentials) { + return JSON.parse(localStorage.getItem('creds')); + } + return this.#credentials; } #setCredentials(credentials: Credentials) { + if (this.#persistCredentials) { + localStorage.setItem('creds', JSON.stringify(credentials)); + } this.#credentials = credentials; } - async #fetchCredentials(): Promise { + async #fetchCredentials(options?: { + forceRefresh?: boolean; + }): Promise { + const { forceRefresh = false } = options ?? {}; + + if (forceRefresh) { + this.#clearCredentials(); + } const credentials = this.#getCredentials(); - if (credentials) { + if (!forceRefresh && credentials) { return credentials; } @@ -53,7 +80,7 @@ class Auth { } get credentialsProvider(): CredentialsProvider { - return async () => await this.#fetchCredentials(); + return (options) => this.#fetchCredentials(options); } registerAuthListener = (onAuthStatusChange: () => void) => { @@ -61,12 +88,13 @@ class Auth { }; async signIn(input?: { + forceRefresh?: boolean; onSignIn?: () => void; onError?: (e: Error) => void; }): Promise { - const { onError, onSignIn } = input ?? {}; + const { forceRefresh, onError, onSignIn } = input ?? {}; try { - await this.#fetchCredentials(); + await this.#fetchCredentials({ forceRefresh }); onSignIn?.(); } catch (e) { onError?.(e); @@ -74,9 +102,8 @@ class Auth { } signOut(input?: { onSignOut?: () => void }) { - this.#credentials = undefined; - this.#onAuthStatusChange?.(); - input?.onSignOut(); + this.#clearCredentials(); + input?.onSignOut?.(); } } From ff1ebdb87c8b95e57856c79ddcd825d2cede081a Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Wed, 23 Oct 2024 20:06:51 -0700 Subject: [PATCH 3/6] fix unit tests --- packages/react-storage/jest.config.ts | 8 +- .../__tests__/StorageBrowserDefault.spec.tsx | 156 +++----- .../__tests__/createStorageBrowser.spec.tsx | 14 - .../configs/__tests__/defaults.spec.ts | 1 + .../actions/handlers/__tests__/copy.spec.ts | 7 +- .../actions/handlers/__tests__/delete.spec.ts | 3 +- .../actions/handlers/__tests__/upload.spec.ts | 25 +- .../__tests__/listLocationItemsAction.spec.ts | 16 +- .../__tests__/listLocationsAction.spec.ts | 25 +- .../__tests__/defaults.spec.ts | 4 +- .../__tests__/locationActions.spec.tsx | 141 ------- .../store/__tests__/StoreProvider.spec.ts | 2 +- .../__tests__/useActionTypeState.spec.ts | 2 +- .../store/files/__tests__/utils.spec.ts | 2 +- .../store/history/__tests__/context.spec.tsx | 8 +- .../StorageBrowser/providers/store/index.ts | 2 +- .../locationItems/__tests__/context.spec.ts | 3 +- .../tasks/__tests__/useProcessTasks.spec.ts | 26 +- .../tasks/__tests__/utils.spec.ts | 38 +- .../Controls/__tests__/Download.spec.tsx | 13 +- .../Controls/__tests__/Navigate.spec.tsx | 134 ++++--- .../Controls/__tests__/Paginate.spec.tsx | 8 +- .../views/Controls/__tests__/Table.spec.tsx | 4 + .../LocationActionView/LocationActionView.tsx | 7 +- .../__tests__/CreateFolderControls.spec.tsx | 132 ++----- .../__tests__/LocationActionView.spec.tsx | 108 +++--- .../__tests__/UploadControls.spec.tsx | 167 ++------ .../views/LocationDetailView/Controls.tsx | 12 +- .../Controls/ActionsMenu.tsx | 3 +- .../Controls/__tests__/ActionMenu.spec.tsx | 49 ++- .../__tests__/Controls.spec.tsx | 184 +++++++++ .../__tests__/LocationDetailView.spec.tsx | 367 +----------------- .../Controls/__tests__/DataTable.spec.tsx | 68 ++-- .../__tests__/LocationsView.spec.tsx | 150 ++----- 34 files changed, 687 insertions(+), 1202 deletions(-) delete mode 100644 packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx create mode 100644 packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/Controls.spec.tsx diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts index cae67b73bc7..2a6d958345a 100644 --- a/packages/react-storage/jest.config.ts +++ b/packages/react-storage/jest.config.ts @@ -16,10 +16,10 @@ const config: Config = { // functions: 90, // lines: 95, // statements: 95, - branches: 81, - functions: 83, - lines: 91, - statements: 90, + branches: 80, + functions: 86, + lines: 92, + statements: 91, }, }, moduleNameMapper: { '^uuid$': '/../../node_modules/uuid' }, diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx index 61aa3b70684..86c8af3dea5 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx @@ -1,122 +1,66 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; -import * as ActionsModule from '../do-not-import-from-here/actions'; -import * as ControlsModule from '../context/control'; -import { ViewsProvider } from '../views/context'; +import * as StoreModule from '../providers/store'; +import * as ViewsModule from '../views/context'; import { StorageBrowserDefault } from '../StorageBrowserDefault'; -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 INITIAL_NAVIGATE_STATE = [ - { location: undefined, history: [], path: undefined }, - jest.fn(), -]; -const INITIAL_LOCATION_ACTIONS_STATE = [ - { selected: { type: undefined, items: undefined }, actions: {} }, - jest.fn(), -]; +jest.spyOn(ViewsModule, 'useViews').mockReturnValue({ + LocationsView: () =>
, + LocationDetailView: () =>
, + LocationActionView: () =>
, +}); -const useControlSpy = jest.spyOn(ControlsModule, 'useControl'); +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); -const WrappedStorageBrowser = () => ( - - - -); +const location = { + id: 'an-id-👍🏼', + bucket: 'test-bucket', + permission: 'READWRITE', + prefix: 'test-prefix/', + type: 'PREFIX', +}; describe('StorageBrowserDefault', () => { - beforeEach(() => { - useControlSpy.mockClear(); + afterEach(jest.clearAllMocks); + + it('renders the `LocationsView` by default', () => { + useStoreSpy.mockReturnValueOnce([ + { + actionType: undefined, + history: { current: undefined, previous: undefined }, + } as StoreModule.UseStoreState, + jest.fn(), + ]); + const { getByTestId } = render(); + + expect(getByTestId('LOCATIONS_VIEW')).toBeInTheDocument(); }); - it('renders the `LocationsView` by default', async () => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: INITIAL_LOCATION_ACTIONS_STATE, - NAVIGATE: INITIAL_NAVIGATE_STATE, - })[type] - ); + it('renders the `LocationDetailView` when a location is selected', () => { + useStoreSpy.mockReturnValueOnce([ + { + actionType: undefined, + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + jest.fn(), + ]); - await waitFor(() => { - render(); - }); + const { getByTestId } = render(); - expect(screen.getByTestId('LOCATIONS_VIEW')).toBeInTheDocument(); + expect(getByTestId('LOCATION_DETAIL_VIEW')).toBeInTheDocument(); }); - it('renders the `LocationDetailView` when a location is selected', async () => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: INITIAL_LOCATION_ACTIONS_STATE, - NAVIGATE: [ - { - location: { - scope: 's3://test-bucket/*', - permission: 'READWRITE', - type: 'BUCKET', - }, - history: [{ prefix: '', position: 0 }], - }, - ], - })[type] - ); - - await waitFor(() => { - render(); - }); - - expect(screen.getByTestId('LOCATION_DETAIL_VIEW')).toBeInTheDocument(); - }); - - it('renders the `LocationActionView` when an action is selected', async () => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: [ - { - actions: { CREATE_FOLDER: {} }, - selected: { type: 'CREATE_FOLDER', items: undefined }, - }, - jest.fn(), - ], - NAVIGATE: [ - { - location: { - scope: 's3://test-bucket/*', - permission: 'READWRITE', - type: 'BUCKET', - }, - history: [{ prefix: '', position: 0 }], - }, - ], - })[type] - ); - - render(); - - await waitFor(() => { - expect(screen.getByTestId('LOCATION_ACTION_VIEW')).toBeInTheDocument(); - }); + it('renders the `LocationActionView` when an action is selected', () => { + useStoreSpy.mockReturnValueOnce([ + { + actionType: 'super-coll-action-type', + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + jest.fn(), + ]); + const { getByTestId } = render(); + + expect(getByTestId('LOCATION_ACTION_VIEW')).toBeInTheDocument(); }); }); 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 be891a442fa..4879e49915e 100644 --- a/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx @@ -2,7 +2,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 ControlsModule from '../context/control'; import { createStorageBrowser } from '../createStorageBrowser'; @@ -35,8 +34,6 @@ const INITIAL_ACTION_STATE = [ jest.fn(), ]; -const useControlSpy = jest.spyOn(ControlsModule, 'useControl'); - const accountId = '012345678901'; const getLocationCredentials = jest.fn(); const listLocations = jest.fn(); @@ -53,10 +50,6 @@ const config = { const input = { config }; describe('createStorageBrowser', () => { - beforeEach(() => { - useControlSpy.mockClear(); - }); - it('throws when registerAuthListener is not a function', () => { const input = { config: { getLocationCredentials, listLocations, region }, @@ -69,13 +62,6 @@ describe('createStorageBrowser', () => { }); it('renders the `LocationsView` by default', async () => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: INITIAL_ACTION_STATE, - NAVIGATE: INITIAL_NAVIGATE_STATE, - })[type] - ); const { StorageBrowser } = createStorageBrowser(input); await waitFor(() => { 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 67d5bd17e3e..fce9084214b 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 @@ -8,6 +8,7 @@ import { const file = { key: 'key', + id: 'id', lastModified: new Date(), size: 100, type: 'FILE' as const, diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts index d6a1c3bc9ec..9753fc2de51 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts @@ -12,7 +12,8 @@ const baseInput: CopyHandlerInput = { credentials: jest.fn(), region: 'region', }, - data: { key: 'key', payload: { destinationPrefix: 'destination/' } }, + key: 'key', + data: { id: 'identity', payload: { destinationPrefix: 'destination/' } }, }; describe('copyHandler', () => { @@ -33,7 +34,7 @@ describe('copyHandler', () => { source: { expectedBucketOwner: `${baseInput.config.accountId}`, bucket, - path: `${baseInput.prefix}${baseInput.data.key}`, + path: `${baseInput.prefix}${baseInput.key}`, }, options: { locationCredentialsProvider: baseInput.config.credentials, @@ -41,6 +42,6 @@ describe('copyHandler', () => { }; expect(copySpy).toHaveBeenCalledWith(expected); - expect(key).toBe(baseInput.data.key); + expect(key).toBe(baseInput.key); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts index edc677c3e9a..e1a217e86b9 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts @@ -6,6 +6,7 @@ const removeSpy = jest.spyOn(StorageModule, 'remove'); const baseInput: DeleteHandlerInput = { prefix: 'prefix/', + key: 'key', config: { accountId: '012345678901', bucket: 'bucket', @@ -20,7 +21,7 @@ describe('deleteHandler', () => { const { key } = deleteHandler(baseInput); const expected: StorageModule.RemoveInput = { - path: `${baseInput.prefix}${baseInput.data.key}`, + path: `${baseInput.prefix}${baseInput.key}`, options: { expectedBucketOwner: baseInput.config.accountId, bucket: { diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts index 894ca87e25c..3f3588e6c1c 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts @@ -30,7 +30,8 @@ const onProgress = jest.fn(); const baseInput: UploadHandlerInput = { config, - data: { key: payload.name, payload }, + key: payload.name, + data: { id: 'an-id', payload }, prefix, }; @@ -61,9 +62,9 @@ describe('uploadHandler', () => { expect(await result).toBe('COMPLETE'); - expect(key).toBe(baseInput.data.key); + expect(key).toBe(baseInput.key); expect(onComplete).toHaveBeenCalledTimes(1); - expect(onComplete).toHaveBeenCalledWith(baseInput.data.key); + expect(onComplete).toHaveBeenCalledWith(baseInput.key); }); it('calls upload with the expected values', () => { @@ -108,9 +109,9 @@ describe('uploadHandler', () => { expect(await result).toBe('COMPLETE'); - expect(key).toBe(baseInput.data.key); + expect(key).toBe(baseInput.key); expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data.key, 1); + expect(onProgress).toHaveBeenCalledWith(baseInput.key, 1); }); it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => { @@ -134,9 +135,9 @@ describe('uploadHandler', () => { expect(await result).toBe('COMPLETE'); - expect(key).toBe(baseInput.data.key); + expect(key).toBe(baseInput.key); expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data.key, undefined); + expect(onProgress).toHaveBeenCalledWith(baseInput.key, undefined); }); it('returns the expected callback values for a file size greater than 5 mb', async () => { @@ -155,7 +156,8 @@ describe('uploadHandler', () => { const { key, result, ...callbacks } = uploadHandler({ ...baseInput, - data: { key: bigFile.name, payload: bigFile }, + key: bigFile.name, + data: { id: 'hi!', payload: bigFile }, }); expect(await result).toBe('COMPLETE'); @@ -177,7 +179,8 @@ describe('uploadHandler', () => { const { key, result, ...callbacks } = uploadHandler({ ...baseInput, - data: { key: smallFile.name, payload: smallFile }, + key: smallFile.name, + data: { id: 'ohh', payload: smallFile }, }); expect(await result).toBe('COMPLETE'); @@ -200,7 +203,7 @@ describe('uploadHandler', () => { expect(await result).toBe('FAILED'); expect(onError).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(baseInput.data.key, error.message); + expect(onError).toHaveBeenCalledWith(baseInput.key, error.message); }); it('handles a cancel failure as expected', async () => { @@ -220,6 +223,6 @@ describe('uploadHandler', () => { expect(await result).toBe('CANCELED'); expect(onCancel).toHaveBeenCalledTimes(1); - expect(onCancel).toHaveBeenCalledWith(baseInput.data.key); + expect(onCancel).toHaveBeenCalledWith(baseInput.key); }); }); 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 index d5b9277ef3c..8d4243cbb4f 100644 --- 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 @@ -4,6 +4,16 @@ import { 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', @@ -151,13 +161,13 @@ describe('parseResult', () => { const result = parseResult(output, prefix); expect(result).toHaveLength(3); // excludes prefix const subFolderWithObject = result[0]; - expect(subFolderWithObject.key).toBe('Cloudberry/'); + expect(subFolderWithObject.key).toBe(`${prefix}Cloudberry/`); expect(subFolderWithObject.type).toBe('FOLDER'); const zeroByteSubFolder = result[1]; - expect(zeroByteSubFolder.key).toBe('Banana/'); + expect(zeroByteSubFolder.key).toBe(`${prefix}Banana/`); expect(zeroByteSubFolder.type).toBe('FOLDER'); const file = result[2]; - expect(file.key).toBe('Orange.jpg'); + expect(file.key).toBe(`${prefix}Orange.jpg`); expect(file.type).toBe('FILE'); }); 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 index 78bd5b7a544..9ae727ce9d9 100644 --- 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 @@ -1,7 +1,14 @@ import { ListLocations } from '../../../storage-internal'; -import { LocationAccess } from '../../types'; +import { LocationAccess } from '../../../actions/handlers'; import { createListLocationsAction } from '../listLocationsAction'; +import { parseLocationAccess } from '../../../actions/handlers/utils'; + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => 'identifier!', + }, +}); const fakeLocation: LocationAccess = { scope: 's3://some-bucket/*', @@ -151,7 +158,7 @@ describe('createListLocationsAction', () => { expect(output.nextToken).toBeUndefined(); }); - it(`should filter out locations with write permission and 'OBJECT' type. Return desired pages`, async () => { + it(`should filter out locations with write permission and 'OBJECT' type`, async () => { const fakeReadLocations = [ getFakeLocation('READ', 'BUCKET'), getFakeLocation('READ', 'OBJECT'), @@ -193,12 +200,14 @@ describe('createListLocationsAction', () => { nextToken: 'next', }); - expect(output.result).toStrictEqual([ - getFakeLocation('READ', 'BUCKET'), - getFakeLocation('READ', 'PREFIX'), - getFakeLocation('READWRITE', 'BUCKET'), - getFakeLocation('READWRITE', 'PREFIX'), - ]); + expect(output.result).toStrictEqual( + [ + getFakeLocation('READ', 'BUCKET'), + getFakeLocation('READ', 'PREFIX'), + getFakeLocation('READWRITE', 'BUCKET'), + getFakeLocation('READWRITE', 'PREFIX'), + ].map(parseLocationAccess) + ); expect(output.nextToken).toBeUndefined(); }); 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 index 32172f9a7f7..2c65c806773 100644 --- 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 @@ -14,7 +14,9 @@ describe('default actions', () => { expect(typeof disable).toBe('function'); expect(disable([])).toBe(false); - expect(disable([{ type: 'FOLDER', key: 'something' }])).toBe(true); + expect(disable([{ type: 'FOLDER', key: 'something', id: 'an-id' }])).toBe( + true + ); const hide = OPTIONS_DEFAULT?.hide as Exclude< LocationActionOptions['hide'], diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx deleted file mode 100644 index c4dc6310e41..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/locationActions/__tests__/locationActions.spec.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { locationActionsReducer } from '../locationActions'; -import { LocationActionsState, LocationActionsAction } from '../types'; - -describe('locationActionsReducer', () => { - it('handles a SET_ACTION action as expected', () => { - const initialState: LocationActionsState = { - actions: {}, - selected: { - type: undefined, - items: undefined, - }, - }; - const action: LocationActionsAction = { - actionType: 'UPLOAD_FILES', - type: 'SET_ACTION', - }; - - const newState = locationActionsReducer(initialState, action); - const expectedState = { - actions: {}, - selected: { - type: 'UPLOAD_FILES', - items: undefined, - }, - }; - expect(newState).toEqual(expectedState); - }); - - it('handles a CLEAR action as expected', () => { - const initialState: LocationActionsState = { - actions: {}, - selected: { - type: 'UPLOAD_FILES', - items: [], - }, - }; - const action: LocationActionsAction = { type: 'CLEAR' }; - const newState = locationActionsReducer(initialState, action); - - const expectedState = { - actions: {}, - selected: { - actionType: undefined, - items: undefined, - }, - }; - expect(newState).toEqual(expectedState); - }); - - it('handles a TOGGLE_SELECT_ITEM as expected', () => { - const initialState: LocationActionsState = { - actions: {}, - selected: { - type: undefined, - items: [], - }, - }; - const item = { - key: 'key', - lastModified: new Date(), - size: 1000, - type: 'FILE' as const, - }; - const action: LocationActionsAction = { - type: 'TOGGLE_SELECTED_ITEM', - item, - }; - const newState = locationActionsReducer(initialState, action); - - const expectedState = { - actions: {}, - selected: { - type: undefined, - items: [item], - }, - }; - expect(newState).toEqual(expectedState); - - const unToggleAction: LocationActionsAction = { - type: 'TOGGLE_SELECTED_ITEM', - item, - }; - const unToggledState = locationActionsReducer(newState, unToggleAction); - - const unToggledExpectedState = { - actions: {}, - selected: { - type: undefined, - items: [], - }, - }; - expect(unToggledState).toEqual(unToggledExpectedState); - }); - - it('handles a TOGGLE_SELECT_ITEMS as expected', () => { - const initialState: LocationActionsState = { - actions: {}, - selected: { - type: undefined, - items: [], - }, - }; - const item = { - key: 'key', - lastModified: new Date(), - size: 1000, - type: 'FILE' as const, - }; - const action: LocationActionsAction = { - type: 'TOGGLE_SELECTED_ITEMS', - items: [item, item, item], - }; - const newState = locationActionsReducer(initialState, action); - - const expectedState = { - actions: {}, - selected: { - type: undefined, - items: [item, item, item], - }, - }; - expect(newState).toEqual(expectedState); - - const unselectAllAction: LocationActionsAction = { - type: 'TOGGLE_SELECTED_ITEMS', - }; - const unselectAllState = locationActionsReducer( - initialState, - unselectAllAction - ); - - const unselectAllExpectedState = { - actions: {}, - selected: { - type: undefined, - items: [], - }, - }; - expect(unselectAllState).toEqual(unselectAllExpectedState); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/StoreProvider.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/StoreProvider.spec.ts index bb551acb53b..73c347159b0 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/StoreProvider.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/__tests__/StoreProvider.spec.ts @@ -32,7 +32,7 @@ describe('StoreProvider', () => { expect(history[0]).toStrictEqual({ current: undefined, - history: undefined, + previous: undefined, }); expect(typeof history[1]).toBe('function'); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/__tests__/useActionTypeState.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/__tests__/useActionTypeState.spec.ts index 0d31e77e9c5..afaff1dfbb2 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/__tests__/useActionTypeState.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/actionType/__tests__/useActionTypeState.spec.ts @@ -21,7 +21,7 @@ describe('useActionTypeState', () => { expect(nextState).toBe(actionType); act(() => { - handler({ type: 'RESET' }); + handler({ type: 'RESET_ACTION_TYPE' }); }); const [finalState] = result.current; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/files/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/files/__tests__/utils.spec.ts index 97c80810635..66cf0fc377e 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/files/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/files/__tests__/utils.spec.ts @@ -115,7 +115,7 @@ describe('files context utils', () => { it('resets `fileItems` as expected', () => { const previous = [fileItemOne, fileItemTwo]; - const output = filesReducer(previous, { type: 'RESET' }); + const output = filesReducer(previous, { type: 'RESET_FILE_ITEMS' }); expect(output).toHaveLength(0); }); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/history/__tests__/context.spec.tsx b/packages/react-storage/src/components/StorageBrowser/providers/store/history/__tests__/context.spec.tsx index a50faa3bd13..6fb15b989a1 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/history/__tests__/context.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/history/__tests__/context.spec.tsx @@ -11,7 +11,7 @@ describe('useHistory', () => { const [state, handler] = result.current; - expect(state).toStrictEqual({ current: undefined, history: undefined }); + expect(state).toStrictEqual({ current: undefined, previous: undefined }); expect(handler).toStrictEqual(expect.any(Function)); }); @@ -32,7 +32,7 @@ describe('useHistory', () => { const state = result.current[0]; - expect(state).toStrictEqual({ current: location, history: [location] }); + expect(state).toStrictEqual({ current: location, previous: [location] }); }); it('updates `history` with a new `location` as expected', () => { @@ -54,7 +54,7 @@ describe('useHistory', () => { expect(initialState).toStrictEqual({ current: location, - history: [location], + previous: [location], }); const nextLocation = { @@ -73,7 +73,7 @@ describe('useHistory', () => { expect(nextState).toStrictEqual({ current: nextLocation, - history: [location, nextLocation], + previous: [location, nextLocation], }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts index 342bfbdffd8..c46701c8229 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/index.ts @@ -1,3 +1,3 @@ export { FileItem, FileItems } from './files'; export { StoreProvider, StoreProviderProps } from './StoreProvider'; -export { useStore } from './useStore'; +export { useStore, UseStoreState } from './useStore'; diff --git a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts index 7014cddd6bb..2574d736f29 100644 --- a/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/providers/store/locationItems/__tests__/context.spec.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; -import { FileData, useLocationItems, LocationItemsProvider } from '../context'; +import { FileData } from '../../../../actions/handlers'; +import { useLocationItems, LocationItemsProvider } from '../context'; const fileDataItemOne: FileData = { id: 'id-one', diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts index 90f10b58f28..05879bc07b1 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/useProcessTasks.spec.ts @@ -17,15 +17,14 @@ const config: ActionInputConfig = { const prefix = 'prefix'; -const items: { key: string; item: File }[] = [ - { key: '0', item: new File([], '0') }, - { key: '1', item: new File([], '1') }, - { key: '2', item: new File([], '2') }, +const items: { key: string; id: string; item: File }[] = [ + { key: '0', id: '0', item: new File([], '0') }, + { key: '1', id: '1', item: new File([], '1') }, + { key: '2', id: '2', item: new File([], '2') }, ]; const action = jest.fn( - ({ data }: TaskHandlerInput) => { - const { key } = data; + ({ key }: TaskHandlerInput) => { if (key === '0') { return { key: '0', @@ -115,12 +114,14 @@ describe('useProcessTasks', () => { expect(action).toHaveBeenCalledTimes(2); expect(action).toHaveBeenCalledWith({ config, - data: { key: items[0].key, payload: items[0].item }, + key: items[0].key, + data: { id: items[0].id, payload: items[0].item }, prefix, }); expect(action).toHaveBeenCalledWith({ config, - data: { key: items[1].key, payload: items[1].item }, + key: items[1].key, + data: { id: items[1].id, payload: items[1].item }, prefix, }); @@ -224,7 +225,8 @@ describe('useProcessTasks', () => { expect(action).toHaveBeenCalledTimes(1); expect(action).toHaveBeenCalledWith({ config, - data: { key: items[0].key, payload: items[0].item }, + key: items[0].key, + data: { id: items[0].id, payload: items[0].item }, options: { extraOption: true }, prefix, }); @@ -284,7 +286,7 @@ describe('useProcessTasks', () => { it('excludes adding an item with an existing task', () => { const { rerender, result } = renderHook( - (_items: { key: string; item: File }[] = items) => + (_items: { key: string; id: string; item: File }[] = items) => useProcessTasks(action, _items) ); @@ -303,14 +305,14 @@ describe('useProcessTasks', () => { it('returns the existing tasks when new items are empty', () => { const { rerender, result } = renderHook( - (_items: { key: string; item: File }[] = items) => + (_items: { key: string; id: string; item: File }[] = items) => useProcessTasks(action, _items) ); const initTasks = result.current[0]; expect(initTasks.length).toBe(3); - const nextItems: { key: string; item: File }[] = []; + const nextItems: { key: string; id: string; item: File }[] = []; act(() => { rerender(nextItems); diff --git a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts index 662ae3e8cd2..3e43c05a2f0 100644 --- a/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/tasks/__tests__/utils.spec.ts @@ -1,38 +1,38 @@ import { CancelableTaskHandlerOutput, TaskHandlerOutput } from '../../actions'; -import { updateTasks, hasExistingTask, isCancelableOutput } from '../utils'; +import { updateTasks, isCancelableOutput } from '../utils'; -const one = { key: 'one', status: 'running' }; -const two = { key: 'two', status: 'walking' }; -const three = { key: 'three', status: 'crawling' }; -const four = { key: 'four', status: 'running' }; +const one = { id: 'one', status: 'running' }; +const two = { id: 'two', status: 'walking' }; +const three = { id: 'three', status: 'crawling' }; +const four = { id: 'four', status: 'running' }; const tasks = [one, two, three, four]; describe('updateTasks', () => { it('handles updating a `task` in the middle of `tasks` as expected', () => { - const result = updateTasks(tasks, { key: 'two', status: 'crawling' }); + const result = updateTasks(tasks, { id: 'two', status: 'crawling' }); - expect(result[1].key).toBe('two'); + expect(result[1].id).toBe('two'); expect(result[1].status).toBe('crawling'); }); it('handles updating the first `task` of `tasks` as expected', () => { - const result = updateTasks(tasks, { key: 'one', status: 'crawling' }); + const result = updateTasks(tasks, { id: 'one', status: 'crawling' }); - expect(result[0].key).toBe('one'); + expect(result[0].id).toBe('one'); expect(result[0].status).toBe('crawling'); }); it('handles updating the last `task` of `tasks` as expected', () => { - const result = updateTasks(tasks, { key: 'four', status: 'crawling' }); + const result = updateTasks(tasks, { id: 'four', status: 'crawling' }); - expect(result[3].key).toBe('four'); + expect(result[3].id).toBe('four'); expect(result[3].status).toBe('crawling'); }); - it('returns unmodified `tasks` when provided `task.key` is not found', () => { + it('returns unmodified `tasks` when provided `task.keyid is not found', () => { const result = updateTasks(tasks, { - key: 'nooooooooo', + id: 'nooooooooo', status: 'crawling', }); @@ -74,15 +74,3 @@ describe('isCancelableOutput', () => { expect(result).toBe(false); }); }); - -describe('hasExistingTask', () => { - it('returns `true` for an existing `task`', () => { - const result = hasExistingTask(tasks, one); - expect(result).toBe(true); - }); - - it('returns `false` for a non-existint `task`', () => { - const result = hasExistingTask(tasks, { key: 'five' }); - expect(result).toBe(false); - }); -}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Download.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Download.spec.tsx index 94ed4981002..d47420ffbca 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Download.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Download.spec.tsx @@ -3,17 +3,14 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as StorageModule from '../../../storage-internal'; -import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; -import * as ConfigModule from '../../../context/config'; +import { createTempActionsProvider } from '../../../do-not-import-from-here/createTempActionsProvider'; +import * as ConfigModule from '../../../providers/configuration'; import { DownloadControl } from '../Download'; const getUrlSpy = jest.spyOn(StorageModule, 'getUrl'); -const useGetLocationConfigSpy = jest.spyOn( - ConfigModule, - 'useGetLocationConfig' -); +const useGetLocationConfigSpy = jest.spyOn(ConfigModule, 'useGetActionInput'); const listLocations = jest.fn(() => Promise.resolve({ locations: [], nextToken: undefined }) @@ -26,13 +23,13 @@ const config = { registerAuthListener: jest.fn(), }; -const Provider = createProvider({ actions: {}, config }); +const Provider = createTempActionsProvider({ actions: {}, config }); describe('DownloadControl', () => { beforeEach(() => { useGetLocationConfigSpy.mockReturnValue(() => ({ bucket: 'myBucket', - credentialsProvider: jest.fn(), + credentials: jest.fn(), region: 'region', })); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Navigate.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Navigate.spec.tsx index a3c8dd89bc3..805c4c82458 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Navigate.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Navigate.spec.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import userEvent, { UserEvent } from '@testing-library/user-event'; -import * as ControlsModule from '../../../context/control'; +import * as StoreModule from '../../../providers/store'; import * as ActionsModule from '../../../do-not-import-from-here/actions'; import { NavigateControl } from '../Navigate'; @@ -18,72 +18,115 @@ jest.spyOn(ActionsModule, 'useAction').mockReturnValue([ handleList, ]); -const handleUpdateState = jest.fn(); -const state = { - location: { scope: 's3://test-bucket/*', type: 'BUCKET' }, - history: [ - { prefix: '', position: 0 }, - { prefix: 'folder1/', position: 1 }, - { prefix: 'folder2/', position: 2 }, - { prefix: 'folder3/', position: 3 }, - ], +const initialLocation = { + bucket: 'test-bucket', + prefix: '', + id: '0', + permission: 'READWRITE' as const, + type: 'PREFIX' as const, }; -const useControlSpy = jest - .spyOn(ControlsModule, 'useControl') - .mockImplementation(() => [state, handleUpdateState]); -describe('NavigateControl', () => { - const user = userEvent.setup(); +const dispatchStoreAction = jest.fn(); +const state = { + history: { + current: initialLocation, + previous: [ + { + bucket: 'test-bucket', + prefix: '', + id: '0', + permission: 'READWRITE', + type: 'PREFIX', + }, + { + bucket: 'test-bucket', + prefix: 'folder1/', + id: '1', + permission: 'READWRITE', + type: 'PREFIX', + }, + { + bucket: 'test-bucket', + prefix: 'folder2/', + id: '2', + permission: 'READWRITE', + type: 'PREFIX', + }, + { + bucket: 'test-bucket', + prefix: 'folder3/', + id: '3', + permission: 'READWRITE', + type: 'PREFIX', + }, + ], + }, +} as StoreModule.UseStoreState; + +const useStoreSpy = jest + .spyOn(StoreModule, 'useStore') + .mockImplementation(() => [state, dispatchStoreAction]); +describe('NavigateControl', () => { + let user: UserEvent; beforeEach(() => { - handleList.mockClear(); - useControlSpy.mockClear(); + user = userEvent.setup(); }); + afterEach(jest.clearAllMocks); + it('renders the NavigateControl', () => { render(); - const nav = screen.getByRole('navigation', { - name: 'Breadcrumbs', - }); + const nav = screen.getByRole('navigation', { name: 'Breadcrumbs' }); + expect(nav).toBeInTheDocument(); const list = screen.getByRole('list'); - - expect(nav).toBeInTheDocument(); expect(list).toBeInTheDocument(); expect(screen.getByText('Home')).toBeInTheDocument(); }); - it('handles selecting "home" navigate item', async () => { - render(); + it('behaves as expected when the "home" navigate item is clicked', async () => { + const onExit = jest.fn(); + render(); await user.click(screen.getByText('Home')); - expect(handleUpdateState).toHaveBeenCalledWith({ - type: 'EXIT', - }); - }); + const { calls } = dispatchStoreAction.mock; + expect(calls).toHaveLength(2); - it('handles selecting the root bucket navigate item', async () => { - render(); + const [[one], [two]] = calls; - await user.click(screen.getByText('test-bucket')); + expect(one).toStrictEqual({ type: 'RESET_HISTORY' }); + expect(two).toStrictEqual({ type: 'RESET_ACTION_TYPE' }); - expect(handleUpdateState).toHaveBeenCalledWith({ - type: 'NAVIGATE', - entry: { prefix: '', position: 0 }, + expect(handleList).toHaveBeenCalledTimes(1); + expect(handleList).toHaveBeenCalledWith({ + prefix: '', + options: { reset: true }, }); + + expect(onExit).toHaveBeenCalledTimes(1); }); - it('handles selecting a folder navigate item', async () => { - render(); + it('handles selecting the initial location navigate item', async () => { + const onExit = jest.fn(); + render(); + + await user.click(screen.getByText('test-bucket')); - await user.click(screen.getByText('folder1')); + const { calls } = dispatchStoreAction.mock; - expect(handleUpdateState).toHaveBeenCalledWith({ + const [[one], [two], [three]] = calls; + + expect(one).toStrictEqual({ type: 'RESET_ACTION_TYPE' }); + expect(two).toStrictEqual({ type: 'RESET_LOCATION_ITEMS' }); + expect(three).toStrictEqual({ type: 'NAVIGATE', - entry: { prefix: 'folder1/', position: 1 }, + destination: initialLocation, }); + + expect(onExit).not.toHaveBeenCalled(); }); it('creates a separator between home and the prefix', () => { @@ -98,14 +141,17 @@ describe('NavigateControl', () => { }); it('renders a first entry with a non-empty `prefix` as expected', () => { - useControlSpy.mockReturnValue([ - { ...state, history: [{ prefix: 'initial/', position: 0 }] }, - handleUpdateState, + useStoreSpy.mockReturnValue([ + { + ...state, + history: { current: initialLocation, previous: [initialLocation] }, + }, + dispatchStoreAction, ]); render(); - const expected = 'test-bucket/initial'; + const expected = 'test-bucket'; expect(screen.getByText(expected)).toBeDefined(); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Paginate.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Paginate.spec.tsx index 8048fe43062..5c01f56e800 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Paginate.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Paginate.spec.tsx @@ -2,15 +2,9 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { PaginateControl } from '../Paginate'; -import { ControlProvider } from '../../../context/control'; - describe('PaginationControl', () => { it('renders the PaginationControl', async () => { - render( - - - - ); + render(); const nav = screen.getByRole('navigation', { name: 'Pagination', diff --git a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Table.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Table.spec.tsx index c883fb3a189..4fe330c2c82 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Table.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/Controls/__tests__/Table.spec.tsx @@ -8,24 +8,28 @@ import { Column, TableControl } from '../Table'; const locationItems: LocationItem[] = [ { key: 'test-key-1', + id: 'id-1', lastModified: new Date(), size: 1000, type: 'FILE', }, { key: 'test-key-2', + id: 'id-2', lastModified: new Date(), size: 1000, type: 'FILE', }, { key: 'test-key-3', + id: 'id-3', lastModified: new Date(), size: 1000, type: 'FILE', }, { key: 'test-folder-key-1', + id: 'id-4', type: 'FOLDER', }, ]; 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 7b00cd1e676..bf0ab7e9357 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/LocationActionView.tsx @@ -13,6 +13,11 @@ export interface LocationActionViewProps { onClose?: () => void; } +const ACTION_VIEW_TYPES = ['CREATE_FOLDER', 'UPLOAD_FILES', 'UPLOAD_FOLDER']; + +const isActionViewType = (value?: string) => + ACTION_VIEW_TYPES.some((type) => type === value); + export const LocationActionView = ({ actionType: _actionType, className, @@ -20,7 +25,7 @@ export const LocationActionView = ({ }: LocationActionViewProps): React.JSX.Element | null => { const [{ actionType = _actionType }] = useStore(); - if (!actionType) return null; + if (!isActionViewType(actionType)) return null; return (
- ({ - LOCATION_ACTIONS: [ - { - actions: TEST_ACTIONS, - selected: { type: 'CREATE_FOLDER', items: undefined }, - }, - jest.fn(), - ], - NAVIGATE: [ - { - location: { - scope: 's3://test-bucket/test-prefix/*', - permission: 'READ', - type: 'PREFIX', - }, - history: [{ prefix: 'test-prefix/', position: 0 }], - path: 'test-prefix/', - }, - jest.fn(), - ], - })[type] -); - -const config = { - getLocationCredentials: jest.fn(), - listLocations: jest.fn(), - region: 'region', - registerAuthListener: jest.fn(), -}; -const Provider = createProvider({ actions: TEST_ACTIONS, config }); +const storeMock: StoreModule.UseStoreState = { + history: { + current: location, + previous: [location], + }, +} as StoreModule.UseStoreState; +const dispatchStoreAction = jest.fn(); + +jest + .spyOn(StoreModule, 'useStore') + .mockReturnValue([storeMock, dispatchStoreAction]); describe('CreateFolderControls', () => { - it('handles folder creation in the happy path', async () => { - const handleAction = jest.fn(); - useActionSpy.mockReturnValue([ - { - isLoading: false, - data: { result: undefined }, - message: undefined, - hasError: false, - }, - handleAction, - ]); + afterEach(jest.clearAllMocks); + it('handles folder creation in the happy path', async () => { await waitFor(() => { - render( - - - - ); + render(); }); const input = screen.getByLabelText('Enter folder name:'); @@ -93,11 +71,7 @@ describe('CreateFolderControls', () => { it('shows a field error when invalid folder name is entered', async () => { await waitFor(() => { - render( - - - - ); + render(); }); const input = screen.getByLabelText('Enter folder name:'); @@ -110,11 +84,7 @@ describe('CreateFolderControls', () => { it('clears a field error as expected', async () => { await waitFor(() => { - render( - - - - ); + render(); }); const input = screen.getByLabelText('Enter folder name:'); @@ -144,11 +114,7 @@ describe('CreateFolderControls', () => { ]); await waitFor(() => { - render( - - - - ); + render(); }); const button = screen.getByRole('button', { name: 'Back' }); @@ -176,11 +142,7 @@ describe('CreateFolderControls', () => { ]); await waitFor(() => { - render( - - - - ); + render(); }); const successMessage = screen.getByText(RESULT_COMPLETE_MESSAGE); @@ -200,11 +162,7 @@ describe('CreateFolderControls', () => { ]); await waitFor(() => { - render( - - - - ); + render(); }); const successMessage = screen.getByText(RESULT_FAILED_MESSAGE); @@ -228,11 +186,7 @@ describe('CreateFolderControls', () => { ]); await waitFor(() => { - render( - - - - ); + render(); }); const successMessage = screen.getByText(errorMessage); @@ -255,11 +209,7 @@ describe('CreateFolderControls', () => { ]); await waitFor(() => { - render( - - - - ); + render(); }); const message = screen.queryByRole('alert'); 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 8631f860e61..f188ac16c2f 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 @@ -1,60 +1,68 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; -import * as ControlsModule from '../../../context/control'; -import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; +import * as StoreModule from '../../../providers/store'; import { LocationActionView } from '../LocationActionView'; -const TEST_ACTIONS = { - CREATE_FOLDER: { options: { displayName: 'Create Folder' } }, -}; +jest.mock('../CreateFolderControls', () => ({ + CreateFolderControls: () =>
, +})); +jest.mock('../UploadControls', () => ({ + UploadControls: () =>
, +})); -const useControlSpy = jest.spyOn(ControlsModule, 'useControl'); - -const config = { - getLocationCredentials: jest.fn(), - listLocations: jest.fn(), - region: 'region', - registerAuthListener: jest.fn(), -}; -const Provider = createProvider({ actions: TEST_ACTIONS, config }); +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); describe('LocationActionView', () => { - it('renders a `LocationActionView`', async () => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: [ - { - actions: TEST_ACTIONS, - selected: { type: 'CREATE_FOLDER', items: undefined }, - }, - jest.fn(), - ], - NAVIGATE: [ - { - location: { - scope: 's3://test-bucket/test-prefix/*', - permission: 'READ', - type: 'PREFIX', - }, - history: [{ prefix: 'test-prefix/', position: 0 }], - path: 'test-prefix/', - }, - jest.fn(), - ], - })[type] - ); - - await waitFor(() => { - render( - - - - ); - }); - - expect(screen.getByTestId('LOCATION_ACTION_VIEW')).toBeInTheDocument(); + it('returns `null` when no `actionType` is provided', () => { + const mockStore = { actionType: undefined } as StoreModule.UseStoreState; + useStoreSpy.mockReturnValueOnce([mockStore, jest.fn()]); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('returns `null` when `actionType` does not have a matching action view', () => { + const mockStore = { actionType: 'nope' } as StoreModule.UseStoreState; + useStoreSpy.mockReturnValueOnce([mockStore, jest.fn()]); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('returns `CreateFolderControls` when `actionType` is "CREATE_FOLDER"', () => { + const mockStore = { + actionType: 'CREATE_FOLDER', + } as StoreModule.UseStoreState; + useStoreSpy.mockReturnValueOnce([mockStore, jest.fn()]); + + const { getByTestId } = render(); + + expect(getByTestId('CREATE_FOLDER_CONTROLS')).toBeInTheDocument(); + }); + + it('returns `UploadControls` when `actionType` is "UPLOAD_FILES"', () => { + const mockStore = { + actionType: 'UPLOAD_FILES', + } as StoreModule.UseStoreState; + useStoreSpy.mockReturnValueOnce([mockStore, jest.fn()]); + + const { getByTestId } = render(); + + expect(getByTestId('UPLOAD_CONTROLS')).toBeInTheDocument(); + }); + + it('returns `UploadControls` when `actionType` is "UPLOAD_FOLDER"', () => { + const mockStore = { + actionType: 'UPLOAD_FOLDER', + } as StoreModule.UseStoreState; + useStoreSpy.mockReturnValueOnce([mockStore, jest.fn()]); + + const { getByTestId } = render(); + + expect(getByTestId('UPLOAD_CONTROLS')).toBeInTheDocument(); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx index 34f537db4c8..802f460fbab 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationActionView/__tests__/UploadControls.spec.tsx @@ -1,162 +1,59 @@ import React from 'react'; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { render } from '@testing-library/react'; -import * as ControlsModule from '../../../context/control'; -import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; -import { LocationActionsState } from '../../../do-not-import-from-here/locationActions'; +import * as ConfigModule from '../../../providers/configuration'; +import * as StoreModule from '../../../providers/store'; import { UploadControls, ActionIcon, ICON_CLASS } from '../UploadControls'; -import userEvent from '@testing-library/user-event'; -const TEST_ACTIONS: LocationActionsState['actions'] = { - UPLOAD_FILES: { options: { displayName: 'Upload Files' } }, -}; - -const useControlSpy = jest.spyOn(ControlsModule, 'useControl'); +jest.mock('../Controls/Title'); -const locationActionsState: LocationActionsState = { - actions: TEST_ACTIONS, - selected: { - items: [], - type: 'UPLOAD_FILES', - }, -}; +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); -const navigateState = { - location: { - permission: 'READWRITE', - scope: 's3://test-bucket/*', - type: 'BUCKET', - }, - path: 'path', - history: [ - { prefix: '', position: 0 }, - { prefix: 'folder1/', position: 1 }, - { prefix: 'folder2/', position: 2 }, - { prefix: 'folder3/', position: 3 }, - ], +const location = { + id: 'an-id-👍🏼', + bucket: 'test-bucket', + permission: 'READWRITE', + prefix: 'test-prefix/', + type: 'PREFIX', }; - -useControlSpy.mockImplementation((type) => { - if (type === 'LOCATION_ACTIONS') { - return [locationActionsState, jest.fn()]; - } - - if (type === 'NAVIGATE') { - return [navigateState]; - } -}); - -const config = { - getLocationCredentials: jest.fn(), - listLocations: jest.fn(), +const dispatchStoreAction = jest.fn(); +useStoreSpy.mockReturnValue([ + { + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + dispatchStoreAction, +]); + +const config: ConfigModule.GetActionInput = jest.fn(() => ({ + credentials: jest.fn(), + bucket: location.bucket, region: 'region', - registerAuthListener: jest.fn(), -}; -const Provider = createProvider({ actions: TEST_ACTIONS, config }); +})); + +jest.spyOn(ConfigModule, 'useGetActionInput').mockReturnValue(config); describe('UploadControls', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should render upload controls table', async () => { - await waitFor(() => { - render( - - - - ); - }); + it('should render upload controls table', () => { + const { getByRole } = render(); - const table = screen.getByRole('table'); + const table = getByRole('table'); expect(table).toBeInTheDocument(); }); - it('should render the destination folder', async () => { - await waitFor(() => { - render( - - - - ); - }); + it('should render the destination folder', () => { + const { getByText } = render(); - const destination = screen.getByText('Destination:'); - const destinationFolder = screen.getByText('folder3/'); + const destination = getByText('Destination:'); + const destinationFolder = getByText('test-prefix/'); expect(destination).toBeInTheDocument(); expect(destinationFolder).toBeInTheDocument(); }); - - it('opens the file picker when the add files button is clicked and uses the file selection dialog', async () => { - const user = userEvent.setup(); - const files = [ - new File(['content1'], 'file1.txt', { type: 'text/plain' }), - new File(['content2'], 'file2.txt', { type: 'text/plain' }), - new File(['content3'], 'file3.txt', { - type: 'text/plain', - }), - ]; - - render( - - - - ); - - const button = screen.getByRole('button', { name: 'Add files' }); - const input: HTMLInputElement = screen.getByTestId('amplify-file-select'); - - expect(input).toHaveAttribute('multiple'); - - await act(async () => { - await user.click(button); - await user.upload(input, files); - }); - - expect(input.files).toHaveLength(3); - }); - - it('adds files dragged into the drop zone to the file list', async () => { - const files = [new File(['content'], 'file.txt', { type: 'text/plain' })]; - - render( - - - - ); - - const dropzone = screen.getByTestId('storage-browser-table'); - - fireEvent.drop(dropzone, { - dataTransfer: { - files, - }, - }); - - await waitFor(() => { - expect(screen.getByText('file.txt')).toBeInTheDocument(); - }); - }); - - it('has the webkitdirectory attribute for the input select for folders', () => { - render( - - - - ); - - const input: HTMLInputElement = screen.getByTestId('amplify-folder-select'); - - expect(input).toHaveAttribute('webkitdirectory'); - }); }); describe('ActionIcon', () => { diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx index 199a5f73491..8dc163dae20 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls.tsx @@ -118,9 +118,16 @@ export const LocationDetailViewControls = ({ React.useEffect(() => { if (hasInvalidPrefix) return; - handlePaginateReset(); handleList({ prefix, options: DEFAULT_REFRESH_OPTIONS }); - }, [handleList, handlePaginateReset, hasInvalidPrefix, prefix]); + + handlePaginateReset(); + }, [ + dispatchStoreAction, + handleList, + handlePaginateReset, + hasInvalidPrefix, + prefix, + ]); const { disableActionsMenu, @@ -142,6 +149,7 @@ export const LocationDetailViewControls = ({ if (hasInvalidPrefix) return; handlePaginateReset(); handleList({ prefix, options: DEFAULT_REFRESH_OPTIONS }); + dispatchStoreAction({ type: 'RESET_LOCATION_ITEMS' }); }; return ( diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx index a74d69b415b..f6576c24c0a 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/ActionsMenu.tsx @@ -3,12 +3,13 @@ import React from 'react'; import { isEmptyObject } from '@aws-amplify/ui'; import { LocationItemData } from '../../../actions'; -import { ActionsMenu, ActionItemProps } from '../../../components/ActionsMenu'; import { LocationActions } from '../../../do-not-import-from-here/locationActions'; import { useTempActions } from '../../../do-not-import-from-here/createTempActionsProvider'; import { useStore } from '../../../providers/store'; import { Permission } from '../../../storage-internal'; +import { ActionsMenu, ActionItemProps } from '../../../components/ActionsMenu'; + const getKeyedFragments = (...nodes: React.ReactNode[]): React.ReactNode[] => nodes.map((child, key) => {child}); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/__tests__/ActionMenu.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/__tests__/ActionMenu.spec.tsx index c28c0f707e9..2136cd08914 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/__tests__/ActionMenu.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/Controls/__tests__/ActionMenu.spec.tsx @@ -1,31 +1,45 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; -import { ActionsMenuControl } from '../ActionsMenu'; -import * as ControlModule from '../../../../context/control'; +import * as TempActions from '../../../../do-not-import-from-here/createTempActionsProvider'; + +import * as StoreModule from '../../../../providers/store'; import { CLASS_BASE } from '../../../constants'; +import { ActionsMenuControl } from '../ActionsMenu'; + const TEST_ACTIONS = { - wild_crazy_guy: { options: { displayName: 'steve martin' } }, + wild_crazy_guy: { + options: { displayName: 'steve martin' }, + }, }; -const LOCATION_ACTIONS = [{ actions: TEST_ACTIONS, selected: {} }, jest.fn()]; -const NAVIGATE = [{ location: { permission: 'whatever' } }, jest.fn()]; -jest.spyOn(ControlModule, 'useControl').mockImplementation( - (type) => - ({ - LOCATION_ACTIONS, - NAVIGATE, - })[type] -); +jest.spyOn(TempActions, 'useTempActions').mockReturnValue(TEST_ACTIONS); + +const location = { + id: 'an-id-👍🏼', + bucket: 'test-bucket', + permission: 'READWRITE' as const, + prefix: 'test-prefix/', + type: 'PREFIX' as const, +}; +const dispatchStoreAction = jest.fn(); +jest.spyOn(StoreModule, 'useStore').mockReturnValue([ + { + locationItems: { fileDataItems: undefined }, + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + dispatchStoreAction, +]); describe('ActionsMenuControl', () => { + afterEach(jest.clearAllMocks); + it('renders a `ActionsMenuControl`', () => { const { getByRole } = render(); const toggle = getByRole('button', { name: 'Actions' }); - const menu = getByRole('menu', { - name: 'Actions', - }); + const menu = getByRole('menu', { name: 'Actions' }); + expect(menu).toBeInTheDocument(); expect(toggle).toBeInTheDocument(); }); @@ -33,11 +47,10 @@ describe('ActionsMenuControl', () => { it('applies correct classes when toggled', () => { const { getByRole } = render(); const toggle = getByRole('button', { name: 'Actions' }); - const menu = getByRole('menu', { - name: 'Actions', - }); + const menu = getByRole('menu', { name: 'Actions' }); fireEvent.click(toggle); + expect(menu.classList).toContain(`${CLASS_BASE}__actions-menu__menu--open`); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/Controls.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/Controls.spec.tsx new file mode 100644 index 00000000000..84f220ff0fa --- /dev/null +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationDetailView/__tests__/Controls.spec.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; + +import * as ActionsModule from '../../../do-not-import-from-here/actions'; +import * as StoreModule from '../../../providers/store'; + +import { LocationDetailView } from '../LocationDetailView'; +import { DEFAULT_LIST_OPTIONS, DEFAULT_ERROR_MESSAGE } from '../Controls'; +import { ListLocationItemsHandlerOutput } from '../../../actions'; + +jest.mock('../Controls/ActionsMenu'); +jest.mock('../../../providers/configuration'); +jest + .spyOn(ActionsModule, 'ActionProvider') + .mockImplementation(({ children }: { children?: React.ReactNode }) => ( + <>{children} + )); + +const handleList = jest.fn(); + +const prefix = 'b_prefix/'; +const getFolderPrefix = (index: number) => `a_prefix_${index}`; +const testFolder = { type: 'FOLDER', key: 'a_prefix_test/' }; + +let uuid = 0; +const generateMockItems = ( + size: number +): ListLocationItemsHandlerOutput['items'] => { + return Array.apply(0, new Array(size)).map((_, index) => { + const type = index % 2 == 0 ? 'FILE' : 'FOLDER'; + uuid++; + const id = uuid.toString(); + return type === 'FOLDER' + ? { key: getFolderPrefix(index), id, type: 'FOLDER' } + : { + key: `${prefix}key${index}`, + type: 'FILE', + id, + lastModified: new Date(), + size: Math.floor(Math.random() * 1000000), + }; + }); +}; + +const testResult = [testFolder, ...generateMockItems(200)]; + +const mockListItemsAction = ({ + hasError = false, + isLoading = false, + message, + result, + nextToken = undefined, +}: { + hasError?: boolean; + isLoading?: boolean; + message?: string; + result: any[]; + nextToken?: string; +}) => { + jest.spyOn(ActionsModule, 'useAction').mockReturnValue([ + { + data: { result, nextToken }, + hasError, + isLoading, + message, + }, + handleList, + ]); +}; + +const dispatchStoreAction = jest.fn(); +const useStoreSpy = jest.spyOn(StoreModule, 'useStore'); + +const location = { + id: 'an-id-👍🏼', + bucket: 'test-bucket', + permission: 'READWRITE', + prefix: 'test-prefix/', + type: 'PREFIX', +}; + +describe('LocationDetailView', () => { + let user: UserEvent; + beforeEach(() => { + user = userEvent.setup(); + }); + + afterEach(() => { + uuid = 0; + jest.clearAllMocks(); + }); + + it('shows a Loading element when first loaded', () => { + useStoreSpy.mockReturnValueOnce([ + { + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + dispatchStoreAction, + ]); + mockListItemsAction({ isLoading: true, result: [] }); + + const { getByText } = render(); + + const text = getByText('Loading'); + + expect(text).toBeInTheDocument(); + }); + + it('renders correct error state', () => { + const errorMessage = 'A network error occurred.'; + + mockListItemsAction({ + isLoading: false, + hasError: true, + message: errorMessage, + result: [{ key: 'test1', type: 'FOLDER' }], + nextToken: 'some-token', + }); + + const { getByRole, queryByTestId } = render(); + + const message = getByRole('alert'); + expect(message).toBeInTheDocument(); + + // table doesn't render + const table = queryByTestId('LOCATION_DETAIL_VIEW_TABLE'); + expect(table).not.toBeInTheDocument(); + }); + + it('renders a default error Message', () => { + mockListItemsAction({ result: [], hasError: true, message: undefined }); + + const { getByText } = render(); + + const messageText = getByText(DEFAULT_ERROR_MESSAGE); + expect(messageText).toBeInTheDocument(); + }); + + it('loads initial location items for a BUCKET location as expected', () => { + useStoreSpy.mockReturnValueOnce([ + { + history: { current: location, previous: [location] }, + } as StoreModule.UseStoreState, + dispatchStoreAction, + ]); + render(); + + expect(handleList).toHaveBeenCalledTimes(1); + expect(handleList).toHaveBeenCalledWith({ + prefix: location.prefix, + options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, + }); + }); + + it('refreshes table and clears selection state when refresh button is clicked', async () => { + useStoreSpy.mockReturnValue([ + { + history: { current: location, previous: [location] }, + locationItems: { fileDataItems: undefined }, + } as StoreModule.UseStoreState, + dispatchStoreAction, + ]); + + mockListItemsAction({ result: testResult }); + + const { getByLabelText } = render(); + + const refreshButton = getByLabelText('Refresh table'); + + await act(async () => { + await user.click(refreshButton); + }); + + expect(handleList).toHaveBeenCalledWith({ + prefix: location.prefix, + options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, + }); + + expect(dispatchStoreAction).toHaveBeenLastCalledWith({ + type: 'RESET_LOCATION_ITEMS', + }); + }); +}); 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 9209e764d32..2ddb7b45177 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 @@ -1,366 +1,19 @@ import React from 'react'; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; -import * as ActionsModule from '../../../do-not-import-from-here/actions'; -import * as ControlsModule from '../../../context/control'; -import * as PaginateModule from '../../hooks/usePaginate'; +import { render } from '@testing-library/react'; import { LocationDetailView } from '../LocationDetailView'; -import { DEFAULT_LIST_OPTIONS, DEFAULT_ERROR_MESSAGE } from '../Controls'; -import { ListLocationItemsHandlerOutput } from '../../../actions'; - -const config = { - getLocationCredentials: jest.fn(), - listLocations: jest.fn(), - region: 'region', - registerAuthListener: jest.fn(), -}; - -const Provider = createProvider({ actions: {}, config }); - -const handleList = jest.fn(); -const handleLocationActionsState = jest.fn(); -const handleUpdateState = jest.fn(); - -const prefix = 'b_prefix/'; -const getFolderPrefix = (index: number) => `a_prefix_${index}`; -const testFolder = { type: 'FOLDER', key: 'a_prefix_test/' }; - -const generateMockItems = ( - size: number -): ListLocationItemsHandlerOutput['items'] => { - return Array.apply(0, new Array(size)).map((_, index) => { - const type = index % 2 == 0 ? 'FILE' : 'FOLDER'; - return type === 'FOLDER' - ? { - key: getFolderPrefix(index), - type: 'FOLDER', - } - : { - key: `${prefix}key${index}`, - type: 'FILE', - lastModified: new Date(), - size: Math.floor(Math.random() * 1000000), - }; - }); -}; - -const testResult = [testFolder, ...generateMockItems(200)]; - -const mockListItemsAction = ({ - hasError = false, - isLoading = false, - message, - result, - nextToken = undefined, -}: { - hasError?: boolean; - isLoading?: boolean; - message?: string; - result: any[]; - nextToken?: string; -}) => { - jest.spyOn(ActionsModule, 'useAction').mockReturnValue([ - { - data: { - result, - nextToken, - }, - hasError, - isLoading, - message, - }, - handleList, - ]); -}; -const mockUseControl = ({ prefix = '' }: { prefix: string }) => { - jest.spyOn(ControlsModule, 'useControl').mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: [ - { - actions: {}, - selected: { type: undefined, items: [] }, - }, - handleLocationActionsState, - ], - NAVIGATE: [ - { - location: { - scope: 's3://test-bucket/*', - permission: 'READ', - type: 'BUCKET', - }, - history: prefix ? [{ prefix }] : [], - path: prefix, - }, - handleUpdateState, - ], - })[type] - ); -}; +jest.mock('../Controls', () => ({ + LocationDetailViewControls: () => ( +
+ ), +})); describe('LocationDetailView', () => { - beforeEach(() => { - jest.resetModules(); - }); - - it('renders a `LocationDetailView`', async () => { - mockListItemsAction({ result: testResult }); - mockUseControl({ prefix }); - - await waitFor(() => { - render( - - - - ); - }); - - expect(screen.getByTestId('LOCATION_DETAIL_VIEW')).toBeInTheDocument(); - }); - - it('shows a Loading element when first loaded', () => { - mockListItemsAction({ isLoading: true, result: [] }); - - render( - - - - ); - - const text = screen.getByText('Loading'); - - expect(text).toBeInTheDocument(); - }); - - it('renders correct error state', () => { - const errorMessage = 'A network error occurred.'; - - mockListItemsAction({ - isLoading: false, - hasError: true, - message: errorMessage, - result: [{ key: 'test1', type: 'FOLDER' }], - nextToken: 'some-token', - }); - - render( - - - - ); - - const message = screen.getByRole('alert'); - const messageText = screen.getByText(errorMessage); - expect(message).toBeInTheDocument(); - expect(messageText).toBeInTheDocument(); - - // table doesn't render - const table = screen.queryByRole('table'); - expect(table).not.toBeInTheDocument(); - - // pagination disabled - const nextPage = screen.getByLabelText('Go to next page'); - expect(nextPage).toBeDisabled(); - const prevPage = screen.getByLabelText('Go to previous page'); - expect(prevPage).toBeDisabled(); - }); - - it('renders a default error Message', () => { - mockListItemsAction({ result: [], hasError: true, message: undefined }); - - render( - - - - ); - - const messageText = screen.getByText(DEFAULT_ERROR_MESSAGE); - expect(messageText).toBeInTheDocument(); - }); - - it('loads initial location items for a BUCKET location as expected', () => { - const initialPrefix = ''; - mockUseControl({ prefix: initialPrefix }); - render( - - - - ); - - expect(handleList).toHaveBeenCalled(); - expect(handleList).toHaveBeenCalledWith({ - prefix: initialPrefix, - options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - }); - }); - - it('refreshes table and clears selection state when refresh button is clicked', async () => { - const user = userEvent.setup(); - mockUseControl({ prefix: prefix }); - mockListItemsAction({ result: testResult }); - - render( - - - - ); - - const refreshButton = screen.getByLabelText('Refresh table'); - - await act(async () => { - await user.click(refreshButton); - }); - - expect(handleList).toHaveBeenCalledWith({ - prefix: prefix, - options: { ...DEFAULT_LIST_OPTIONS, refresh: true }, - }); - expect(handleLocationActionsState).toHaveBeenLastCalledWith({ - type: 'CLEAR', - }); - }); - - it('sets the location action as UPLOAD_FILES and includes files dragged into drop zone', () => { - mockUseControl({ prefix: prefix }); - const files = [new File(['content'], 'file.txt', { type: 'text/plain' })]; - - render( - - - - ); - - const dropzone = screen.getByTestId('storage-browser-table'); - - jest.spyOn(ControlsModule, 'ControlProvider'); - - fireEvent.drop(dropzone, { - dataTransfer: { - files, - }, - }); - - expect(handleLocationActionsState).toHaveBeenCalledWith({ - actionType: 'UPLOAD_FILES', - type: 'SET_ACTION', - files: files, - }); - }); - - it('clear selection state on breadcrumb and regular navigation', async () => { - const user = userEvent.setup(); - mockUseControl({ prefix: prefix }); - mockListItemsAction({ result: testResult }); - - render( - - - - ); - const breadCrumbButton = screen.getByText(prefix); - - await act(async () => { - await user.click(breadCrumbButton); - }); - - expect(handleLocationActionsState).toHaveBeenCalledWith({ - type: 'CLEAR', - }); - }); - - it('can paginate forwards and clear selection state', async () => { - const user = userEvent.setup(); - const handlePaginateNext = jest.fn(); - const handlePaginatePrevious = jest.fn(); - jest - .spyOn( - PaginateModule, - 'usePaginate' - ) - .mockReturnValue({ - currentPage: 1, - handlePaginateNext, - handlePaginatePrevious, - handleReset: jest.fn(), - }); - mockListItemsAction({ result: testResult }); - - render( - - - - ); - const nextButton = screen.getByLabelText('Go to next page'); - - await act(async () => { - await user.click(nextButton); - }); - - expect(handlePaginateNext).toHaveBeenCalled(); - expect(handlePaginatePrevious).not.toHaveBeenCalled(); - expect(handleLocationActionsState).toHaveBeenCalledWith({ - type: 'CLEAR', - }); - }); - - it('can paginate to previous and clear selection state', async () => { - const user = userEvent.setup(); - - const handlePaginateNext = jest.fn(); - const handlePaginatePrevious = jest.fn(); - jest - .spyOn( - PaginateModule, - 'usePaginate' - ) - .mockReturnValue({ - currentPage: 2, - handlePaginateNext, - handlePaginatePrevious, - handleReset: jest.fn(), - }); - mockListItemsAction({ result: testResult }); - - render( - - - - ); - - const prevButton = screen.getByLabelText('Go to previous page'); - - await act(async () => { - await user.click(prevButton); - }); - - expect(handlePaginateNext).not.toHaveBeenCalled(); - expect(handlePaginatePrevious).toHaveBeenCalled(); - expect(handleLocationActionsState).toHaveBeenCalledWith({ - type: 'CLEAR', - }); - }); - - it('does not allow selection on Folder items', () => { - mockListItemsAction({ result: [testFolder] }); - - render( - - - - ); - const checkboxes = screen.queryByRole('checkbox'); + it('renders a `LocationDetailView`', () => { + const { getByTestId } = render(); - expect(checkboxes).not.toBeInTheDocument(); + expect(getByTestId('LOCATION_DETAIL_VIEW')).toBeInTheDocument(); + expect(getByTestId('LOCATION_DETAIL_VIEW_CONTROLS')).toBeInTheDocument(); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx index 818235a8c30..a85b1365012 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/Controls/__tests__/DataTable.spec.tsx @@ -1,29 +1,43 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import * as UseLocationsDataModule from '../../../../do-not-import-from-here/actions'; -import * as UseControlModule from '../../../../context/control'; -import { LocationAccess } from '../../../../do-not-import-from-here/types'; +import * as StoreModule from '../../../../providers/store'; import { DataTableControl } from '../DataTable'; const TEST_RANGE: [number, number] = [0, 100]; -const useControlModuleSpy = jest.spyOn(UseControlModule, 'useControl'); +const dispatchStoreAction = jest.fn(); +jest + .spyOn(StoreModule, 'useStore') + .mockReturnValue([{} as StoreModule.UseStoreState, dispatchStoreAction]); + const useLocationsDataSpy = jest.spyOn( UseLocationsDataModule, 'useLocationsData' ); -const mockData: LocationAccess[] = [ - { scope: 's3://Location A/*', type: 'BUCKET', permission: 'READ' }, - { scope: 's3://Location B/Folder B/*', type: 'PREFIX', permission: 'WRITE' }, +const mockData = [ + { + bucket: 'Location A', + id: 'A', + prefix: '', + type: 'BUCKET' as const, + permission: 'READ' as const, + }, + { + bucket: 'Location B', + id: 'B', + prefix: 'Folder B/', + type: 'PREFIX' as const, + permission: 'WRITE' as const, + }, ]; describe('LocationsViewTableControl', () => { beforeEach(() => { - useControlModuleSpy.mockReturnValue([{}, jest.fn()]); useLocationsDataSpy.mockReturnValue([ { data: { result: mockData, nextToken: undefined }, @@ -35,20 +49,29 @@ describe('LocationsViewTableControl', () => { ]); }); + afterEach(jest.clearAllMocks); + it('renders the table with data', () => { - const { getByText } = render(); + const { getAllByText, getByText } = render( + + ); expect(getByText('Folder')).toBeInTheDocument(); expect(getByText('Bucket')).toBeInTheDocument(); expect(getByText('Permission')).toBeInTheDocument(); - expect(getByText('Location A/')).toBeInTheDocument(); expect(getByText('Folder B/')).toBeInTheDocument(); + + // when prefix is an empty string the bucket value is used in both + // the "Bucket" and "Folder" columns + expect(getAllByText('Location A')).toHaveLength(2); }); it('renders the correct icon based on sort state', () => { - const { getByText } = render(); + const { getByRole, getByText } = render( + + ); - const folderTh = screen.getByRole('columnheader', { name: 'Folder' }); + const folderTh = getByRole('columnheader', { name: 'Folder' }); expect(folderTh).toHaveAttribute('aria-sort', 'ascending'); @@ -58,13 +81,15 @@ describe('LocationsViewTableControl', () => { }); it('updates sort state when other headers are clicked', () => { - const { getByText } = render(); + const { getByRole, getByText } = render( + + ); - const folderTh = screen.getByRole('columnheader', { name: 'Folder' }); + const folderTh = getByRole('columnheader', { name: 'Folder' }); expect(folderTh).toHaveAttribute('aria-sort', 'ascending'); - const bucketTh = screen.getByRole('columnheader', { name: 'Bucket' }); + const bucketTh = getByRole('columnheader', { name: 'Bucket' }); fireEvent.click(getByText('Bucket')); @@ -72,17 +97,14 @@ describe('LocationsViewTableControl', () => { }); it('triggers location click handler when a row is clicked', () => { - const mockHandleUpdateState = jest.fn(); - useControlModuleSpy.mockReturnValue([{}, mockHandleUpdateState]); - - render(); + const { getByRole } = render(); - const firstRowButton = screen.getByRole('button', { name: 'Folder B/' }); + const firstRowButton = getByRole('button', { name: 'Folder B/' }); fireEvent.click(firstRowButton); - expect(mockHandleUpdateState).toHaveBeenCalledWith({ - type: 'ACCESS_LOCATION', - location: mockData[1], + expect(dispatchStoreAction).toHaveBeenCalledWith({ + type: 'NAVIGATE', + destination: mockData[1], }); }); }); 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 517432af431..bb935078097 100644 --- a/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx +++ b/packages/react-storage/src/components/StorageBrowser/views/LocationsView/__tests__/LocationsView.spec.tsx @@ -1,35 +1,11 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; -import createProvider from '../../../do-not-import-from-here/createTempActionsProvider'; import * as ActionsModule from '../../../do-not-import-from-here/actions'; -import * as ControlsModule from '../../../context/control'; -import { LocationsView } from '..'; +import { LocationsView } from '../LocationsView'; import { DEFAULT_LIST_OPTIONS, DEFAULT_ERROR_MESSAGE } from '../LocationsView'; -const INITIAL_NAVIGATE_STATE = [ - { location: undefined, history: [], path: '' }, - jest.fn(), -]; -const INITIAL_ACTION_STATE = [ - { selected: { type: undefined, items: undefined }, actions: {} }, - jest.fn(), -]; - -const useControlSpy = jest.spyOn(ControlsModule, 'useControl'); - -const listLocations = jest.fn(() => - Promise.resolve({ locations: [], nextToken: undefined }) -); -const config = { - getLocationCredentials: jest.fn(), - listLocations, - region: 'region', - registerAuthListener: jest.fn(), -}; -const Provider = createProvider({ actions: {}, config }); - const useLocationsDataSpy = jest.spyOn(ActionsModule, 'useLocationsData'); const handleListLocations = jest.fn(); @@ -54,16 +30,17 @@ const loadingState: ActionsModule.LocationsDataState = [ handleListLocations, ]; +const location = { + bucket: 'tester', + prefix: '🏃‍♀️‍➡️/', + permission: 'READWRITE' as const, + id: 'identity', + type: 'BUCKET' as const, +}; const resolvedState: ActionsModule.LocationsDataState = [ { data: { - result: [ - { - permission: 'READWRITE', - scope: 's3://test-bucket/*', - type: 'BUCKET', - }, - ], + result: [location], nextToken: undefined, }, hasError: false, @@ -75,45 +52,16 @@ const resolvedState: ActionsModule.LocationsDataState = [ describe('LocationsListView', () => { beforeEach(() => { - useControlSpy.mockImplementation( - (type) => - ({ - LOCATION_ACTIONS: INITIAL_ACTION_STATE, - NAVIGATE: INITIAL_NAVIGATE_STATE, - })[type] - ); - handleListLocations.mockClear(); useLocationsDataSpy.mockClear(); }); - it('renders a `LocationsListView`', async () => { - await waitFor(() => { - expect( - render( - - - - ).container - ).toBeDefined(); - }); - }); - it('renders a returned error message for `LocationsListView`', () => { const errorMessage = 'Something went wrong.'; useLocationsDataSpy.mockReturnValue([ { - data: { - result: [ - { - permission: 'READWRITE', - scope: 's3://test-bucket/*', - type: 'BUCKET', - }, - ], - nextToken: 'some-token', - }, + data: { result: [location], nextToken: 'some-token' }, hasError: true, isLoading: false, message: errorMessage, @@ -121,11 +69,7 @@ describe('LocationsListView', () => { handleListLocations, ]); - render( - - - - ); + render(); const message = screen.getByRole('alert'); const messageText = screen.getByText(errorMessage); @@ -146,10 +90,7 @@ describe('LocationsListView', () => { it('renders a fallback error message for `LocationsListView`', () => { useLocationsDataSpy.mockReturnValue([ { - data: { - result: [], - nextToken: undefined, - }, + data: { result: [], nextToken: undefined }, hasError: true, isLoading: false, message: undefined, @@ -157,11 +98,7 @@ describe('LocationsListView', () => { handleListLocations, ]); - render( - - - - ); + render(); const messageText = screen.getByText(DEFAULT_ERROR_MESSAGE); expect(messageText).toBeInTheDocument(); @@ -170,16 +107,7 @@ describe('LocationsListView', () => { it('renders a Locations View table', () => { useLocationsDataSpy.mockReturnValue([ { - data: { - result: [ - { - permission: 'READWRITE', - scope: 's3://test-bucket/*', - type: 'BUCKET', - }, - ], - nextToken: undefined, - }, + data: { result: [location], nextToken: undefined }, hasError: false, isLoading: false, message: undefined, @@ -187,11 +115,7 @@ describe('LocationsListView', () => { handleListLocations, ]); - render( - - - - ); + render(); const table = screen.getByRole('table'); @@ -208,11 +132,7 @@ describe('LocationsListView', () => { .mockReturnValueOnce(loadingState) .mockReturnValue(resolvedState); - const { rerender } = render( - - - - ); + const { rerender } = render(); expect(handleListLocations).toHaveBeenCalledTimes(1); expect(handleListLocations).toHaveBeenCalledWith({ @@ -222,19 +142,11 @@ describe('LocationsListView', () => { }, }); - rerender( - - - - ); + rerender(); expect(handleListLocations).toHaveBeenCalledTimes(1); - rerender( - - - - ); + rerender(); expect(handleListLocations).toHaveBeenCalledTimes(1); }); @@ -245,29 +157,17 @@ describe('LocationsListView', () => { useLocationsDataSpy.mockReturnValue(initialState); // initial - const { rerender } = render( - - - - ); + const { rerender } = render(); useLocationsDataSpy.mockReturnValue(loadingState); // loading - rerender( - - - - ); + rerender(); useLocationsDataSpy.mockReturnValueOnce(resolvedState); // resolved - rerender( - - - - ); + rerender(); expect(handleListLocations).toHaveBeenCalledTimes(1); expect(handleListLocations).toHaveBeenCalledWith({ @@ -281,11 +181,7 @@ describe('LocationsListView', () => { ]); // reference change - rerender( - - - - ); + rerender(); expect(handleListLocations).toHaveBeenCalledTimes(1); expect(updatedHandleListLocations).toHaveBeenCalledTimes(1); From b4e4c4bc8bf9efc4a674e75b636d0721af740589 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Thu, 24 Oct 2024 14:10:12 -0700 Subject: [PATCH 4/6] remove unused test utilities --- .../actions/testUtils.ts | 189 ------------------ 1 file changed, 189 deletions(-) delete mode 100644 packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts diff --git a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts b/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts deleted file mode 100644 index 4dbe16a42cd..00000000000 --- a/packages/react-storage/src/components/StorageBrowser/do-not-import-from-here/actions/testUtils.ts +++ /dev/null @@ -1,189 +0,0 @@ -// @ts-nocheck -import { LocationItem, LocationData } from '../types'; - -type Permission = 'READ' | 'READWRITE' | 'WRITE'; - -const CHARACTERS = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; -const CHARACTERS_LENGTH = CHARACTERS.length; - -const EXTENSION_CONTENT = { - mp3: 'audio/mp3', - wav: 'audio/wav', - aac: 'audio/aac', - png: 'image/png', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - gif: 'image/gif', - bmp: 'image/bmp', - tiff: 'image/tiff', - svg: 'image/svg+xml', - txt: 'text/plain', - rtf: 'text/rtf', - css: 'text/css', - html: 'text/html', - csv: 'text/csv', - doc: 'application/msword', - zip: 'application/zip', - json: 'application/json', - js: 'application/javascript', - pdf: 'application/pdf', - gz: 'application/x-gzip', - gzip: 'application/x-gzip', - z: 'application/x-compressed', - '7z': 'application/x-7z-compressed', - apk: 'application/vnd.android.package-archive', - azw: 'application/vnd.amazon.ebook', - dmg: 'application/x-apple-diskimage', - mpkg: 'application/vnd.apple.installer+xml', - ppt: 'application/vnd.ms-powerpoint', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - xls: 'application/vnd.ms-excel', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - m3u8: 'application/x-mpegURL', - flv: 'video/x-flv', - mp4: 'video/mp4', - mpeg: 'video/mpeg', - ts: 'video/MP2T', - '3gp': 'video/3gpp', - mov: 'video/quicktime', - avi: 'video/x-msvideo', - wmv: 'video/x-ms-wmv', -}; - -export const EXTENSIONS = Object.keys(EXTENSION_CONTENT); - -export const PERMISSION_TYPES = ['READ', 'READWRITE', 'WRITE']; -export const LOCATION_TYPES = ['OBJECT', 'PREFIX', 'BUCKET'] as const; - -export const generateString = (length: number): string => { - let result = ''; - let counter = 0; - while (counter < length) { - result += CHARACTERS.charAt(Math.floor(Math.random() * CHARACTERS_LENGTH)); - counter += 1; - } - return result; -}; - -export const randomDate = (start: Date, end: Date): Date => - new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); - -export const randomNumberInRange = (min: number, max: number): number => - Math.round(Math.random() * (max - min) + min); - -export async function timeout(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const generateListLocationItemsData = ( - count: number -): LocationItem[] => { - const items: LocationItem[] = []; - - const startDate = new Date(2012, 0, 1); - const endDate = new Date(); - - while (count > items.length) { - // bias towards generating FILE over FOLDER - const type = randomNumberInRange(0, 12) > 8 ? 'FOLDER' : 'FILE'; - const key = `${generateString(randomNumberInRange(6, 23))}${ - type === 'FOLDER' - ? '' - : `.${EXTENSIONS[randomNumberInRange(0, EXTENSIONS.length - 1)]}` - }`; - - items.push({ - key, - lastModified: randomDate(startDate, endDate), - size: randomNumberInRange(120, 10000), - type, - }); - } - - return items; -}; - -// 1 as max range ensures WRITE is never returned -const getRandomPermission = (): Permission => - PERMISSION_TYPES[randomNumberInRange(0, 1)] as Permission; - -const generateBucketData = (count: number): LocationData[] => { - const result: LocationData[] = []; - // bucket: /* - const bucketName = `${generateString(randomNumberInRange(10, 24))}/`; - const hasPrefixes = !!((randomNumberInRange(0, 31) / 2) % 2); - const prefixCount = !hasPrefixes ? 0 : randomNumberInRange(1, 4); - - const prefixes = Array(prefixCount) - .fill('') - .map( - // prefix: /* - () => `${bucketName}/${generateString(randomNumberInRange(6, 20))}*` - ); - - const scope = `s3://${bucketName}/*`; - result.push({ - bucket: bucketName, - prefix: undefined, - permission: getRandomPermission(), - scope, - type: 'BUCKET', - }); - - while (result.length < count) { - if (!hasPrefixes) { - // object: // - result.push({ - bucket: bucketName, - prefix: undefined, - scope: `${scope}${generateString(randomNumberInRange(6, 20))}.${ - EXTENSIONS[randomNumberInRange(0, EXTENSIONS.length)] - }`, - permission: getRandomPermission(), - type: 'OBJECT', - }); - } else { - const selectedPrefix = - prefixes[randomNumberInRange(0, prefixes.length - 1)]; - - const hasPrefixBeenAdded = result.some( - ({ scope }) => scope === selectedPrefix - ); - - const scope = hasPrefixBeenAdded - ? // object: // - `${selectedPrefix}/${generateString(randomNumberInRange(6, 20))}.${ - EXTENSIONS[randomNumberInRange(0, EXTENSIONS.length)] - }` - : selectedPrefix; - const type = hasPrefixBeenAdded ? 'OBJECT' : 'PREFIX'; - - result.push({ - bucket: bucketName, - prefix: undefined, - scope, - permission: getRandomPermission(), - type, - }); - } - } - - return result; -}; - -export const generateLocationsData = (count = 100): LocationData[] => { - const locations: LocationData[] = []; - - while (count > locations.length) { - const remaining = count - locations.length; - const bucket = generateBucketData( - remaining === 1 - ? remaining - : randomNumberInRange(0, Math.round(remaining / 2)) - ); - locations.push(...bucket); - } - - return locations; -}; From 99be6e87f8cbe7ce24c2cbfd13f9202a817e09e7 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Thu, 24 Oct 2024 14:12:08 -0700 Subject: [PATCH 5/6] remove testUtils pattern from jest coverage exclusion --- packages/react-storage/jest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts index 2a6d958345a..240f4c6f579 100644 --- a/packages/react-storage/jest.config.ts +++ b/packages/react-storage/jest.config.ts @@ -5,7 +5,7 @@ const config: Config = { collectCoverageFrom: [ '/src/**/*.(ts|tsx)', // do not collect from index, testUtils or version files - '!/**/(index|testUtils|version).(ts|tsx)', + '!/**/(index|version).(ts|tsx)', // do not collect from top level styles directory '!/src/styles/*.ts', ], From a5b58161e49639fe45feb46e87374469900f0d87 Mon Sep 17 00:00:00 2001 From: Caleb Pollman Date: Thu, 24 Oct 2024 16:16:44 -0700 Subject: [PATCH 6/6] fix navigation e2e test --- .../navigate-locations.feature | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature b/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature index 554a5a36d1a..fb50a205b56 100644 --- a/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature +++ b/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature @@ -20,13 +20,13 @@ Feature: Storage Browser navigate breadcrumbs Then I type my password Then I click the "Sign in" button When I click the button containing "public" - Then I see the "Blueberry/" button - When I click the "Blueberry/" button - Then I see "Blackberry/" - When I click the "Blackberry/" button - Then I see "Blueberry/" - When I click the "Blueberry" button - Then I see "Blueberry/" + Then I see the "public/Blueberry/" button + When I click the "public/Blueberry/" button + Then I see "public/Blueberry/Blackberry/" + When I click the "public/Blueberry/Blackberry/" button + Then I see "public/Blueberry/" + When I click the "public/Blueberry" button + Then I see "public/Blueberry/" @react Scenario: Navigate to parent folder from nested child folder @@ -34,11 +34,11 @@ Feature: Storage Browser navigate breadcrumbs Then I type my password Then I click the "Sign in" button When I click the button containing "public" - Then I see the "Blueberry/" button - When I click the "Blueberry/" button - Then I see the "Acai/" button - When I click the "Acai/" button - Then I see "Acai/" - Then I see the "Blueberry" button - When I click the "Blueberry" button - Then I see "Blueberry/" + Then I see the "public/Blueberry/" button + When I click the "public/Blueberry/" button + Then I see the "public/Blueberry/Acai/" button + When I click the "public/Blueberry/Acai/" button + Then I see "public/Blueberry/Acai/" + Then I see the "public/Blueberry" button + When I click the "public/Blueberry" button + Then I see "public/Blueberry/"