): BaseElement {
+ const { displayName, type } = input;
+
+ const Element = ({ variant, ...props }: Omit
) => {
+ const Element = React.useContext(ElementsContext)?.[displayName];
+
+ if (Element) {
+ // only pass `variant` to provided `Element` values
+ return ;
+ }
+
+ return React.createElement(type, props);
+ };
+
+ Element.displayName = displayName;
+
+ return Element;
+}
+
+/**
+ * @internal @unstable
+ *
+ * Defines a `ElementsContext` aware `BaseElement` UI component of the
+ * provided `type` with an assigned `displayName` and element `ref`.
+ *
+ * If `BaseElement` is used as a child of an `ElementsProvider`, returns the
+ * `BaseElement` value of the provided `displayName` of `ElementsContext`.
+ *
+ * When used outside of a parent `ElementsProvider` or no `BaseElement`
+ * of `displayName` is found in the `ElementsContext`, returns a stateless,
+ * unstyled HTML element of the provided `type`.
+ *
+ * @param {DefineBaseElementInput} input `BaseElement` parameters
+ * @returns {BaseElementWithRefProps} `ElementsContext` aware UI component
+ */
+export function defineBaseElementWithRef<
+ // element type
+ T extends ReactElementType,
+ // string union of base element props to include
+ K extends keyof U = never,
+ // variant string union
+ V = string,
+ // available props of base element type
+ U extends ReactElementProps = ReactElementProps,
+ // control element props
+ P extends BaseElementWithRefProps = BaseElementWithRefProps,
+>(input: DefineBaseElementInput): BaseElementWithRef> {
const { displayName, type } = input;
const Element = React.forwardRef, P>(
diff --git a/packages/react-core/src/elements/index.ts b/packages/react-core/src/elements/index.ts
index b3bf08b7a45..712b05d57b1 100644
--- a/packages/react-core/src/elements/index.ts
+++ b/packages/react-core/src/elements/index.ts
@@ -1,3 +1,9 @@
-export { default as defineBaseElement } from './defineBaseElement';
+export * from './ControlsContext';
+export {
+ defineBaseElement,
+ defineBaseElementWithRef,
+} from './defineBaseElement';
export { default as withBaseElementProps } from './withBaseElementProps';
export { ElementsProvider } from './ElementsContext';
+export { isComponent, isForwardRefExoticComponent } from './utils';
+export * from './types';
diff --git a/packages/react-core/src/elements/types.ts b/packages/react-core/src/elements/types.ts
index a01057829cd..455ea5d752b 100644
--- a/packages/react-core/src/elements/types.ts
+++ b/packages/react-core/src/elements/types.ts
@@ -13,16 +13,31 @@ import React from 'react';
* are always optional at the interface level, allowing for additional `props`
* to be added to existing `BaseElement` interfaces as needed.
*/
-export type BaseElement = React.ForwardRefExoticComponent<
+export type BaseElement = (props: T) => React.JSX.Element;
+
+/**
+ * @internal @unstable
+ *
+ * see @type {BaseElement}
+ *
+ * `BaseElement` with a `ref` corresponding to the `element` type
+ */
+export type BaseElementWithRef<
+ T = {},
+ K = {},
+> = React.ForwardRefExoticComponent<
React.PropsWithoutRef & React.RefAttributes
>;
type ListElementSubType = 'Ordered' | 'Unordered';
-type ListElementDisplayName = 'List' | `${ListElementSubType}List`;
+type ListElementDisplayName = `${ListElementSubType}List`;
-type TableElementSubType = 'Body' | 'Data' | 'Row' | 'Head' | 'Header';
+type TableElementSubType = 'Body' | 'DataCell' | 'Row' | 'Head' | 'Header';
type TableElementDisplayName = 'Table' | `Table${TableElementSubType}`;
+type DescriptionElementSubType = 'Details' | 'List' | 'Term';
+type DescriptionElementDisplayName = `Description${DescriptionElementSubType}`;
+
/**
* @internal @unstable
*
@@ -30,7 +45,6 @@ type TableElementDisplayName = 'Table' | `Table${TableElementSubType}`;
*/
export type ElementDisplayName =
| 'Button'
- | 'Divider'
| 'Heading' // h1, h2, etc
| 'Icon'
| 'Image'
@@ -44,6 +58,7 @@ export type ElementDisplayName =
| 'TextArea'
| 'Title'
| 'View'
+ | DescriptionElementDisplayName
| ListElementDisplayName
| TableElementDisplayName;
@@ -68,7 +83,7 @@ export type ReactElementType = keyof React.JSX.IntrinsicElements;
* @internal @unstable
*/
export type ReactElementProps =
- React.JSX.IntrinsicElements[T];
+ React.ComponentProps;
/**
* @internal @unstable
@@ -85,5 +100,98 @@ export type BaseElementProps<
V = string,
K extends Record, any> = Record,
> = React.AriaAttributes &
- React.RefAttributes> &
+ React.Attributes &
Pick> & { testId?: string; variant?: V };
+
+/**
+ * @internal @unstable
+ */
+export type BaseElementWithRefProps<
+ T extends keyof K,
+ V = string,
+ K extends Record, any> = Record,
+> = BaseElementProps & React.RefAttributes>;
+
+/**
+ * @internal @unstable
+ */
+export type ElementWithAndWithoutRef<
+ T extends ReactElementType,
+ K extends React.ComponentType> = React.ComponentType<
+ React.ComponentProps
+ >,
+> = K extends React.ComponentType
+ ? React.ForwardRefExoticComponent
+ : never;
+
+/**
+ * @internal @unstable
+ *
+ * Merge `BaseElement` defintions with `elements` types provided by
+ * consumers, for use with top level connected component function
+ * signatures.
+ *
+ * Example:
+ *
+ * ```tsx
+ * export function createStorageBrowser<
+ * T extends Partial,
+ * >({ elements }: CreateStorageBrowserInput = {}): {
+ * StorageBrowser: StorageBrowser>
+ * } {
+ * // ...do create stuff
+ * };
+ * ```
+ */
+export type MergeBaseElements> = {
+ [U in keyof T]: K[U] extends T[U] ? K[U] : T[U];
+};
+
+/**
+ * @internal @unstable
+ *
+ * Extend the defintion of a `BaseElement` with additional `props`.
+ *
+ * Use cases are restricted to scenarios where additional `props`
+ * are required for a `ControlElement` interface, for example:
+ *
+ * @example
+ * ```tsx
+ * const FieldInput = defineBaseElementWithRef({
+ * type: 'input',
+ * displayName: 'Input'
+ * });
+ *
+ * type InputWithSearchCallback =
+ * ExtendBaseElement<
+ * typeof FieldInput,
+ * { onSearch?: (event: { value: string }) => void }
+ * >
+ *
+ * const SearchInput = React.forwardRef((
+ * { onSearch, ...props }
+ * ref
+ * ) => {
+ * // ...do something with onSearch
+ *
+ * return ;
+ * });
+ * ```
+ *
+ * Caveats:
+ * - additional `props` should not be passed directly to
+ * `BaseElement` components, the outputted interface should be
+ * applied to a wrapping element that handles the additional `props`
+ *
+ * - additional `props` that share a key with existing `props`
+ * are omitted from the outputted interface to adhere to `BaseElement`
+ * type contracts
+ *
+ */
+export type ExtendBaseElement<
+ // `BaseElement` to extend
+ T extends React.ComponentType,
+ // additional `props`
+ K = {},
+ U extends React.ComponentPropsWithRef = React.ComponentPropsWithRef,
+> = BaseElementWithRef, U>;
diff --git a/packages/react-core/src/elements/utils.ts b/packages/react-core/src/elements/utils.ts
new file mode 100644
index 00000000000..aebab72b2a8
--- /dev/null
+++ b/packages/react-core/src/elements/utils.ts
@@ -0,0 +1,20 @@
+import React from 'react';
+
+export function isComponent(
+ component?: React.ComponentType | React.ForwardRefExoticComponent
+): component is React.ComponentType {
+ return typeof component === 'function';
+}
+
+export function isForwardRefExoticComponent(
+ component: React.ComponentType | React.ForwardRefExoticComponent
+): component is React.ForwardRefExoticComponent {
+ return (
+ typeof component === 'object' &&
+ typeof (component as React.ForwardRefExoticComponent).$$typeof ===
+ 'symbol' &&
+ ['react.memo', 'react.forward_ref'].includes(
+ (component as React.ForwardRefExoticComponent).$$typeof.description!
+ )
+ );
+}
diff --git a/packages/react-core/src/elements/withBaseElementProps.tsx b/packages/react-core/src/elements/withBaseElementProps.tsx
index 4389fd35d01..4ac9708e80b 100644
--- a/packages/react-core/src/elements/withBaseElementProps.tsx
+++ b/packages/react-core/src/elements/withBaseElementProps.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { BaseElement, ElementRefType } from './types';
+import { BaseElementWithRef, ElementRefType } from './types';
/**
* @internal @unstable
@@ -16,7 +16,7 @@ import { BaseElement, ElementRefType } from './types';
* type InputElementPropKey = 'onChange' | 'type';
*
* // create `InputElement` base with `type` generic and extended `props` key
- * export const InputElement = defineBaseElement<"input", InputElementPropKey>({
+ * export const InputElement = defineBaseElementWithRef<"input", InputElementPropKey>({
* type: "input",
* displayName: "Input",
* });
@@ -38,7 +38,7 @@ export default function withBaseElementProps<
>(
Target: React.ForwardRefExoticComponent,
defaultProps: K
-): BaseElement> {
+): BaseElementWithRef> {
const Component = React.forwardRef, T>((props, ref) => (
@@ -6,19 +6,26 @@ const asyncAction = jest.fn((_prev: string, next: string) =>
);
const syncAction = jest.fn((_prev: string, next: string) => next);
+const errorMessage = 'Unhappy!';
+const unhappyAction = jest.fn((_, isUnhappy: boolean) =>
+ isUnhappy ? Promise.reject(new Error(errorMessage)) : Promise.resolve()
+);
+
+const initData = 'initial-data';
+const nextData = 'next-data';
+
describe('useDataState', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it.each([
{ type: 'async', action: asyncAction },
{ type: 'sync', action: syncAction },
])(
'handles a $type action as expected in the happy path',
async ({ action }) => {
- const initData = 'initial-data';
- const nextData = 'next-data';
-
- const { result, waitForNextUpdate } = renderHook(() =>
- useDataState(action, 'initial-data')
- );
+ const { result } = renderHook(() => useDataState(action, initData));
// first render
const [initState, handleAction] = result.current;
@@ -46,31 +53,71 @@ describe('useDataState', () => {
expect(loadingState.isLoading).toBe(true);
expect(loadingState.message).toBeUndefined();
- await waitForNextUpdate();
-
- // action complete
- const [nextState] = result.current;
+ await waitFor(() => {
+ // action complete
+ const [nextState] = result.current;
- expect(action).toHaveBeenCalledTimes(1);
- expect(action).toHaveBeenCalledWith(initData, nextData);
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(action).toHaveBeenCalledWith(initData, nextData);
- expect(nextState.data).toBe(nextData);
- expect(nextState.hasError).toBe(false);
- expect(nextState.isLoading).toBe(false);
- expect(nextState.message).toBeUndefined();
+ expect(nextState.data).toBe(nextData);
+ expect(nextState.hasError).toBe(false);
+ expect(nextState.isLoading).toBe(false);
+ expect(nextState.message).toBeUndefined();
+ });
}
);
- it('handles an error and resets error state on the next call to handleAction', async () => {
- const errorMessage = 'Unhappy!';
- const unhappyAction = jest.fn((_, isUnhappy: boolean) =>
- isUnhappy ? Promise.reject(new Error(errorMessage)) : Promise.resolve()
+ it('calls `onSuccess` callback as expected', async () => {
+ const onSuccess = jest.fn();
+ const { result } = renderHook(() =>
+ useDataState(asyncAction, initData, { onSuccess })
);
- const { result, waitForNextUpdate } = renderHook(() =>
- useDataState(unhappyAction, undefined)
+ // first render
+ const [_, handleAction] = result.current;
+
+ // call action
+ act(() => {
+ handleAction(nextData);
+ });
+
+ await waitFor(() => {
+ expect(asyncAction).toHaveBeenCalledTimes(1);
+ expect(asyncAction).toHaveBeenCalledWith(initData, nextData);
+ });
+
+ expect(onSuccess).toHaveBeenCalledTimes(1);
+ expect(onSuccess).toHaveBeenCalledWith(nextData);
+ });
+
+ it('calls `onError` callback as expected', async () => {
+ const onError = jest.fn();
+ const { result } = renderHook(() =>
+ useDataState(unhappyAction, undefined, { onError })
);
+ const [_, handleAction] = result.current;
+
+ act(() => {
+ handleAction(true);
+ });
+
+ await waitFor(() => {
+ const [errorState] = result.current;
+
+ expect(errorState.hasError).toBe(true);
+ expect(errorState.isLoading).toBe(false);
+ expect(errorState.message).toBe(errorMessage);
+ });
+
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(errorMessage);
+ });
+
+ it('handles an error and resets error state on the next call to handleAction', async () => {
+ const { result } = renderHook(() => useDataState(unhappyAction, undefined));
+
const [initialState, handleAction] = result.current;
expect(unhappyAction).not.toHaveBeenCalled();
@@ -89,26 +136,25 @@ describe('useDataState', () => {
expect(loadingState.isLoading).toBe(true);
expect(loadingState.message).toBeUndefined();
- await waitForNextUpdate();
+ await waitFor(() => {
+ const [errorState] = result.current;
- const [errorState] = result.current;
-
- expect(errorState.hasError).toBe(true);
- expect(errorState.isLoading).toBe(false);
- expect(errorState.message).toBe(errorMessage);
+ expect(errorState.hasError).toBe(true);
+ expect(errorState.isLoading).toBe(false);
+ expect(errorState.message).toBe(errorMessage);
+ });
act(() => {
handleAction(false);
});
- const [nextLoadingState] = result.current;
+ await waitFor(() => {
+ const [nextLoadingState] = result.current;
- expect(nextLoadingState.hasError).toBe(false);
- expect(nextLoadingState.isLoading).toBe(true);
- expect(nextLoadingState.message).toBeUndefined();
-
- // cleanup
- await waitForNextUpdate();
+ expect(nextLoadingState.hasError).toBe(false);
+ expect(nextLoadingState.isLoading).toBe(true);
+ expect(nextLoadingState.message).toBeUndefined();
+ });
});
it.todo('only returns the value of the last call to handleAction');
diff --git a/packages/react-core/src/hooks/index.ts b/packages/react-core/src/hooks/index.ts
index b941809a63e..5eb30109f0a 100644
--- a/packages/react-core/src/hooks/index.ts
+++ b/packages/react-core/src/hooks/index.ts
@@ -1,4 +1,9 @@
-export { default as useDataState, DataState } from './useDataState';
+export {
+ default as useDataState,
+ AsyncDataAction,
+ DataAction,
+ DataState,
+} from './useDataState';
export {
default as useDeprecationWarning,
diff --git a/packages/react-core/src/hooks/useDataState.ts b/packages/react-core/src/hooks/useDataState.ts
index 7ccece3630d..986d64468ac 100644
--- a/packages/react-core/src/hooks/useDataState.ts
+++ b/packages/react-core/src/hooks/useDataState.ts
@@ -1,3 +1,4 @@
+import { isFunction } from '@aws-amplify/ui';
import React from 'react';
export interface DataState {
@@ -7,6 +8,13 @@ export interface DataState {
message: string | undefined;
}
+export type DataAction = (prevData: T, input: K) => T;
+
+export type AsyncDataAction = (
+ prevData: T,
+ input: K
+) => Promise;
+
// default state
const INITIAL_STATE = { hasError: false, isLoading: false, message: undefined };
const LOADING_STATE = { hasError: false, isLoading: true, message: undefined };
@@ -20,9 +28,13 @@ const resolveMaybeAsync = async (
};
export default function useDataState(
- action: (prevData: T, ...input: K[]) => T | Promise,
- initialData: T
-): [state: DataState, handleAction: (...input: K[]) => void] {
+ action: DataAction | AsyncDataAction,
+ initialData: T,
+ options?: {
+ onSuccess?: (data: T) => void;
+ onError?: (message: string) => void;
+ }
+): [state: DataState, handleAction: (input: K) => void] {
const [dataState, setDataState] = React.useState>(() => ({
...INITIAL_STATE,
data: initialData,
@@ -30,20 +42,26 @@ export default function useDataState(
const prevData = React.useRef(initialData);
- const handleAction: (...input: K[]) => void = React.useCallback(
- (...input) => {
+ const { onSuccess, onError } = options ?? {};
+
+ const handleAction: (input: K) => void = React.useCallback(
+ (input) => {
setDataState(({ data }) => ({ ...LOADING_STATE, data }));
- resolveMaybeAsync(action(prevData.current, ...input))
+ resolveMaybeAsync(action(prevData.current, input))
.then((data: T) => {
+ if (isFunction(onSuccess)) onSuccess(data);
+
prevData.current = data;
setDataState({ ...INITIAL_STATE, data });
})
.catch(({ message }: Error) => {
+ if (isFunction(onError)) onError(message);
+
setDataState(({ data }) => ({ ...ERROR_STATE, data, message }));
});
},
- [action]
+ [action, onError, onSuccess]
);
return [dataState, handleAction];
diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts
index c445ae0c8a3..ebe4e012b38 100644
--- a/packages/react-core/src/index.ts
+++ b/packages/react-core/src/index.ts
@@ -35,6 +35,8 @@ export {
} from './components';
export {
+ AsyncDataAction,
+ DataAction,
useDeprecationWarning,
UseDeprecationWarning,
useGetUrl,
diff --git a/packages/react-core/src/utils/createContextUtilities.tsx b/packages/react-core/src/utils/createContextUtilities.tsx
index 77d2975e35d..5df48495451 100644
--- a/packages/react-core/src/utils/createContextUtilities.tsx
+++ b/packages/react-core/src/utils/createContextUtilities.tsx
@@ -36,7 +36,7 @@ type CreateContextUtilitiesReturn = {
: Key extends `use${string}`
? (params?: HookParams) => ContextType
: Key extends `${string}Context`
- ? React.Context
+ ? React.Context
: never;
};
@@ -89,7 +89,7 @@ type CreateContextUtilitiesReturn = {
export default function createContextUtilities<
ContextType,
ContextName extends string = string,
- Message extends string | undefined = string | undefined
+ Message extends string | undefined = string | undefined,
>(
options: ContextOptions
): CreateContextUtilitiesReturn {
@@ -99,7 +99,11 @@ export default function createContextUtilities<
throw new Error(INVALID_OPTIONS_MESSAGE);
}
+ const contextDisplayName = `${contextName}Context`;
+ const providerDisplayName = `${contextName}Provider`;
+
const Context = React.createContext(defaultValue);
+ Context.displayName = contextDisplayName;
function Provider(props: React.PropsWithChildren) {
const { children, ...context } = props;
@@ -113,8 +117,7 @@ export default function createContextUtilities<
return {children} ;
}
- Provider.displayName = `${contextName}Provider`;
-
+ Provider.displayName = providerDisplayName;
return {
[`use${contextName}`]: function (params?: HookParams) {
const context = React.useContext(Context);
@@ -125,7 +128,7 @@ export default function createContextUtilities<
return context;
},
- [`${contextName}Provider`]: Provider,
- [`${contextName}Context`]: Context,
+ [providerDisplayName]: Provider,
+ [contextDisplayName]: Context,
} as CreateContextUtilitiesReturn;
}
diff --git a/packages/react-geo/package.json b/packages/react-geo/package.json
index cdfca551800..8b4b7653f31 100644
--- a/packages/react-geo/package.json
+++ b/packages/react-geo/package.json
@@ -47,13 +47,13 @@
"tslib": "^2.5.2"
},
"peerDependencies": {
- "@aws-amplify/geo": "^3.0.47",
- "aws-amplify": "^6.6.5",
+ "@aws-amplify/geo": "unstable",
+ "aws-amplify": "unstable",
"react": "^16.14.0 || ^17.0 || ^18.0",
"react-dom": "^16.14.0 || ^17.0 || ^18.0"
},
"devDependencies": {
- "@aws-amplify/geo": "^3.0.40",
+ "@aws-amplify/geo": "unstable",
"@types/mapbox__mapbox-gl-draw": "^1.3.3"
},
"sideEffects": [
diff --git a/packages/react-liveness/package.json b/packages/react-liveness/package.json
index 29c34c99010..29f563a239e 100644
--- a/packages/react-liveness/package.json
+++ b/packages/react-liveness/package.json
@@ -43,7 +43,7 @@
},
"peerDependencies": {
"@aws-amplify/core": "*",
- "aws-amplify": "^6.6.5",
+ "aws-amplify": "unstable",
"react": "^16.14.0 || ^17.0 || ^18.0",
"react-dom": "^16.14.0 || ^17.0 || ^18.0"
},
diff --git a/packages/react-native-auth/package.json b/packages/react-native-auth/package.json
index e45f2b7504c..0ff5f00d88e 100644
--- a/packages/react-native-auth/package.json
+++ b/packages/react-native-auth/package.json
@@ -34,7 +34,7 @@
"qrcode": "1.5.0"
},
"peerDependencies": {
- "aws-amplify": "^6.6.5",
+ "aws-amplify": "unstable",
"react": "^18",
"react-native": "^0.70 || ^0.71 || ^0.72"
},
diff --git a/packages/react-native/package.json b/packages/react-native/package.json
index bc3737fc165..2baa81e747a 100644
--- a/packages/react-native/package.json
+++ b/packages/react-native/package.json
@@ -35,7 +35,7 @@
"@aws-amplify/ui-react-core-notifications": "2.0.30"
},
"peerDependencies": {
- "aws-amplify": "^6.6.5",
+ "aws-amplify": "unstable",
"react": "*",
"react-native": "^0.70 || ^0.71 || ^0.72 || ^0.73 || ^0.74 || ^0.75",
"react-native-safe-area-context": "^4.2.5"
diff --git a/packages/react-notifications/package.json b/packages/react-notifications/package.json
index 2aec8732146..41f371bfad5 100644
--- a/packages/react-notifications/package.json
+++ b/packages/react-notifications/package.json
@@ -46,7 +46,7 @@
"tinycolor2": "1.4.2"
},
"peerDependencies": {
- "aws-amplify": "^6.6.5",
+ "aws-amplify": "unstable",
"react": "^16.14.0 || ^17.0 || ^18.0",
"react-dom": "^16.14.0 || ^17.0 || ^18.0"
},
diff --git a/packages/react-storage/browser/package.json b/packages/react-storage/browser/package.json
new file mode 100644
index 00000000000..0c783973601
--- /dev/null
+++ b/packages/react-storage/browser/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@aws-amplify/ui-react-storage/browser",
+ "main": "../dist/browser.js",
+ "module": "../dist/esm/browser.mjs",
+ "types": "../dist/types/components/StorageBrowser/index.d.ts",
+ "private": true,
+ "sideEffects": false
+}
diff --git a/packages/react-storage/jest.config.ts b/packages/react-storage/jest.config.ts
index 09a528f0a3f..294793ecdc1 100644
--- a/packages/react-storage/jest.config.ts
+++ b/packages/react-storage/jest.config.ts
@@ -4,21 +4,30 @@ const config: Config = {
collectCoverage: true,
collectCoverageFrom: [
'/src/**/*.(ts|tsx)',
- // do not collect from export files
- '!/**/index.(ts|tsx)',
- // do not collect from top level version and styles files
- '!/src/(styles|version).(ts|tsx)',
+ // do not collect from index, testUtils or version files
+ '!/**/(index|version).(ts|tsx)',
+ // do not collect from top level styles directory
+ '!/src/styles/*.ts',
],
coverageThreshold: {
global: {
- branches: 87,
- functions: 86.5,
- lines: 93.5,
+ // TEMP REDUCE COVERAGE
+ // branches: 87,
+ // functions: 90,
+ // lines: 95,
+ // statements: 95,
+ branches: 84,
+ functions: 88,
+ lines: 94,
statements: 94,
},
},
moduleNameMapper: { '^uuid$': '/../../node_modules/uuid' },
- modulePathIgnorePatterns: ['/dist/'],
+ modulePathIgnorePatterns: ['c/dist/'],
+ testPathIgnorePatterns: [
+ '/src/components/StorageBrowser/displayText/libraries/en/__tests__/scenarios.ts',
+ '__testUtils__/',
+ ],
preset: 'ts-jest',
setupFilesAfterEnv: ['./jest.setup.ts'],
testEnvironment: 'jsdom',
diff --git a/packages/react-storage/package.json b/packages/react-storage/package.json
index 458f89b14ba..9a509efe9a1 100644
--- a/packages/react-storage/package.json
+++ b/packages/react-storage/package.json
@@ -9,10 +9,17 @@
"import": "./dist/esm/index.mjs",
"require": "./dist/index.js"
},
- "./styles.css": "./dist/styles.css"
+ "./browser": {
+ "main": "./dist/browser.js",
+ "types": "./dist/types/components/StorageBrowser/index.d.ts",
+ "import": "./dist/esm/browser.mjs"
+ },
+ "./styles.css": "./dist/styles.css",
+ "./storage-browser-styles.css": "./dist/storage-browser-styles.css"
},
"browser": {
- "./styles.css": "./dist/styles.css"
+ "./styles.css": "./dist/styles.css",
+ "./storage-browser-styles.css": "./dist/storage-browser-styles.css"
},
"types": "dist/types/index.d.ts",
"license": "Apache-2.0",
@@ -23,7 +30,8 @@
},
"files": [
"dist",
- "LICENSE"
+ "LICENSE",
+ "browser"
],
"scripts": {
"build": "yarn build:rollup",
@@ -45,14 +53,28 @@
"tslib": "^2.5.2"
},
"peerDependencies": {
- "aws-amplify": "^6.6.5",
+ "aws-amplify": "unstable",
"react": "^16.14.0 || ^17.0 || ^18.0",
"react-dom": "^16.14.0 || ^17.0 || ^18.0"
},
+ "devDependencies": {
+ "jest-tsd": "^0.2.2",
+ "@tsd/typescript": "^5.1.6",
+ "@types/node": "^18.19.50"
+ },
"sideEffects": [
"dist/**/*.css"
],
"size-limit": [
+ {
+ "name": "createStorageBrowser",
+ "path": "dist/esm/browser.mjs",
+ "import": "{ createStorageBrowser }",
+ "limit": "60 kB",
+ "ignore": [
+ "@aws-amplify/storage"
+ ]
+ },
{
"name": "FileUploader",
"path": "dist/esm/index.mjs",
diff --git a/packages/react-storage/rollup.config.mjs b/packages/react-storage/rollup.config.mjs
index ca3323488ed..46760e761d8 100644
--- a/packages/react-storage/rollup.config.mjs
+++ b/packages/react-storage/rollup.config.mjs
@@ -4,7 +4,10 @@ import styles from 'rollup-plugin-styles';
import externals from 'rollup-plugin-node-externals';
// common config settings
-const input = ['src/index.ts'];
+const input = {
+ index: 'src/index.ts',
+ browser: 'src/components/StorageBrowser/index.ts',
+};
const sourceMap = false;
const tsconfig = 'tsconfig.dist.json';
@@ -51,8 +54,21 @@ const config = defineConfig([
},
// CSS config
{
- input: 'src/styles.ts',
- output: { dir: 'dist', format: 'cjs', assetFileNames: '[name][extname]' },
+ input: 'src/styles/styles.ts',
+ output: {
+ dir: 'dist',
+ format: 'cjs',
+ assetFileNames: '[name][extname]',
+ },
+ plugins: [styles({ mode: ['extract'] })],
+ },
+ {
+ input: 'src/styles/storage-browser-styles.ts',
+ output: {
+ dir: 'dist',
+ format: 'cjs',
+ assetFileNames: '[name][extname]',
+ },
plugins: [styles({ mode: ['extract'] })],
},
]);
diff --git a/packages/react-storage/src/__tests__/__snapshots__/exports.spec.ts.snap b/packages/react-storage/src/__tests__/__snapshots__/exports.spec.ts.snap
new file mode 100644
index 00000000000..ae89c4d72b4
--- /dev/null
+++ b/packages/react-storage/src/__tests__/__snapshots__/exports.spec.ts.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`@aws-amplify/ui-react-storage exports should match snapshot 1`] = `
+[
+ "FileUploader",
+ "StorageBrowser",
+ "StorageImage",
+ "StorageManager",
+]
+`;
+
+exports[`@aws-amplify/ui-react-storage/browser exports should match snapshot 1`] = `
+[
+ "componentsDefault",
+ "createAmplifyAuthAdapter",
+ "createManagedAuthAdapter",
+ "createStorageBrowser",
+]
+`;
diff --git a/packages/react-storage/src/__tests__/exports.spec.ts b/packages/react-storage/src/__tests__/exports.spec.ts
new file mode 100644
index 00000000000..790f48489b4
--- /dev/null
+++ b/packages/react-storage/src/__tests__/exports.spec.ts
@@ -0,0 +1,22 @@
+import * as exported from '../';
+import * as browser from '../../browser';
+
+describe('@aws-amplify/ui-react-storage', () => {
+ describe('exports', () => {
+ it('should match snapshot', () => {
+ const sortedExports = Object.keys(exported).sort();
+
+ expect(sortedExports).toMatchSnapshot();
+ });
+ });
+});
+
+describe('@aws-amplify/ui-react-storage/browser', () => {
+ describe('exports', () => {
+ it('should match snapshot', () => {
+ const sortedBrowserExports = Object.keys(browser).sort();
+
+ expect(sortedBrowserExports).toMatchSnapshot();
+ });
+ });
+});
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..3548bff11dd
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/ComponentsProvider.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { ElementsProvider } from '@aws-amplify/ui-react-core/elements';
+
+import { ComposablesProvider, Composables } from './composables';
+import { StorageBrowserElements } from './context/elements';
+
+export interface Components extends Partial {}
+
+export interface ComponentsProviderProps {
+ children?: React.ReactNode;
+ composables?: Composables;
+ elements?: Partial;
+}
+
+export function ComponentsProvider(
+ props: ComponentsProviderProps
+): React.JSX.Element {
+ const { children, composables, elements } = props;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/ErrorBoundary.tsx b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 00000000000..312e551fef1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/ErrorBoundary.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+}
+
+const Fallback = (): React.JSX.Element => (
+
+
+ Something went wrong.
+
+
+);
+
+export class ErrorBoundary extends React.Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_error: Error): ErrorBoundaryState {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ render(): React.ReactNode {
+ const { hasError } = this.state;
+ const { children } = this.props;
+ if (hasError) {
+ return ;
+ }
+
+ return children;
+ }
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/__tests__/ErrorBoundary.spec.tsx b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/__tests__/ErrorBoundary.spec.tsx
new file mode 100644
index 00000000000..7d4ff20341a
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/__tests__/ErrorBoundary.spec.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ErrorBoundary } from '../ErrorBoundary';
+
+const ThrowErrorComponent: React.FC = () => {
+ React.useEffect(() => {
+ throw new Error('Test Error Boundary');
+ }, []);
+
+ return This should throw an error
;
+};
+
+describe('ErrorBoundary', () => {
+ it('should render the fallback component when an error is thrown', () => {
+ // Mock implementation for console.error to prevent logging during tests
+ jest.spyOn(console, 'error').mockImplementation(() => null);
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong.')).toBeInTheDocument();
+ });
+
+ it('should render children when no error is thrown', () => {
+ render(
+
+ No error here
+
+ );
+
+ expect(screen.getByText('No error here')).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/index.ts b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/index.ts
new file mode 100644
index 00000000000..8d337a3dcc4
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/ErrorBoundary/index.ts
@@ -0,0 +1 @@
+export { ErrorBoundary } from './ErrorBoundary';
diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx
new file mode 100644
index 00000000000..db84a3de1ee
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserAmplify.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { createStorageBrowser } from './createStorageBrowser';
+import { StorageBrowserProps as StorageBrowserPropsBase } from './types';
+import { createAmplifyAuthAdapter } from './adapters';
+import { componentsDefault } from './componentsDefault';
+
+export interface StorageBrowserProps extends StorageBrowserPropsBase {}
+
+export const StorageBrowser = ({
+ views,
+ displayText,
+}: StorageBrowserProps): React.JSX.Element => {
+ const { StorageBrowser } = React.useRef(
+ createStorageBrowser({
+ components: componentsDefault,
+ config: createAmplifyAuthAdapter(),
+ })
+ ).current;
+
+ return ;
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx
new file mode 100644
index 00000000000..cf74af8127f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/StorageBrowserDefault.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+import { useViews } from './views';
+import { useStore } from './providers/store';
+
+/**
+ * Handles default `StorageBrowser` behavior:
+ * - render `LocationsView` on init
+ * - render `LocationDetailView` on location selection
+ * - render `ActionView` on action selection
+ */
+export function StorageBrowserDefault(): React.JSX.Element {
+ const { LocationActionView, LocationDetailView, LocationsView } = useViews();
+
+ const [{ actionType, location }] = useStore();
+ const { current } = location;
+
+ if (actionType) {
+ return ;
+ }
+
+ if (current) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx
new file mode 100644
index 00000000000..5d6b33b7440
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserAmplify.spec.tsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import { render, waitFor, screen } from '@testing-library/react';
+import { Amplify } from 'aws-amplify';
+import * as CreateStorageBrowserModule from '../createStorageBrowser';
+import * as CreateAmplifyAuthAdapter from '../adapters/createAmplifyAuthAdapter';
+import { StorageBrowser } from '../StorageBrowserAmplify';
+import { StorageBrowserDisplayText } from '../displayText/types';
+
+const createStorageBrowserSpy = jest.spyOn(
+ CreateStorageBrowserModule,
+ 'createStorageBrowser'
+);
+
+jest.spyOn(Amplify, 'getConfig').mockReturnValue({
+ Storage: {
+ S3: {
+ bucket: 'XXXXXX',
+ region: 'region',
+ },
+ },
+});
+
+const createAmplifyAuthAdapterSpy = jest.spyOn(
+ CreateAmplifyAuthAdapter,
+ 'createAmplifyAuthAdapter'
+);
+
+describe('StorageBrowser', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('calls `createStorageBrowser`', async () => {
+ await waitFor(() => {
+ render( );
+ });
+
+ expect(createStorageBrowserSpy).toHaveBeenCalledTimes(1);
+ expect(createStorageBrowserSpy).toHaveBeenCalledWith({
+ components: expect.anything(),
+ config: expect.anything(),
+ });
+
+ expect(createAmplifyAuthAdapterSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('support passing custom displayText', async () => {
+ const displayText: StorageBrowserDisplayText = {
+ LocationsView: { title: 'Hello' },
+ };
+
+ await waitFor(() => {
+ render( );
+ });
+
+ const Title = screen.getByText('Hello');
+ expect(Title).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx
new file mode 100644
index 00000000000..b3e24e3c09f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/__tests__/StorageBrowserDefault.spec.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import * as StoreModule from '../providers/store';
+import * as ViewsModule from '../views/context';
+import { StorageBrowserDefault } from '../StorageBrowserDefault';
+import { LocationData } from '../actions';
+
+jest.spyOn(ViewsModule, 'useViews').mockReturnValue({
+ LocationsView: () =>
,
+ LocationDetailView: () =>
,
+ LocationActionView: () =>
,
+});
+
+const useStoreSpy = jest.spyOn(StoreModule, 'useStore');
+
+const location: LocationData = {
+ id: 'an-id-👍🏼',
+ bucket: 'test-bucket',
+ permissions: ['list'],
+ prefix: 'test-prefix/',
+ type: 'PREFIX',
+};
+
+describe('StorageBrowserDefault', () => {
+ afterEach(jest.clearAllMocks);
+
+ it('renders the `LocationsView` by default', () => {
+ useStoreSpy.mockReturnValueOnce([
+ {
+ actionType: undefined,
+ location: { current: undefined },
+ } as StoreModule.UseStoreState,
+ jest.fn(),
+ ]);
+ const { getByTestId } = render( );
+
+ expect(getByTestId('LOCATIONS_VIEW')).toBeInTheDocument();
+ });
+
+ it('renders the `LocationDetailView` when a location is selected', () => {
+ useStoreSpy.mockReturnValueOnce([
+ {
+ actionType: undefined,
+ location: { current: location },
+ } as StoreModule.UseStoreState,
+ jest.fn(),
+ ]);
+
+ const { getByTestId } = render( );
+
+ expect(getByTestId('LOCATION_DETAIL_VIEW')).toBeInTheDocument();
+ });
+
+ it('renders the `LocationActionView` when an action is selected', () => {
+ useStoreSpy.mockReturnValueOnce([
+ {
+ actionType: 'super-coll-action-type',
+ location: { current: 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
new file mode 100644
index 00000000000..bfb6c7fbea3
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/__tests__/createStorageBrowser.spec.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+
+import * as ProvidersModule from '../providers';
+
+import { createStorageBrowser } from '../createStorageBrowser';
+import { StorageBrowserDisplayText } from '../displayText/types';
+
+const createConfigurationProviderSpy = jest.spyOn(
+ ProvidersModule,
+ 'createConfigurationProvider'
+);
+
+const accountId = '012345678901';
+const customEndpoint = 'mock-endpoint';
+const getLocationCredentials = jest.fn();
+const listLocations = jest.fn();
+const region = 'region';
+
+const config = {
+ accountId,
+ customEndpoint,
+ getLocationCredentials,
+ listLocations,
+ region,
+ registerAuthListener: jest.fn(),
+};
+
+const input = { config };
+
+describe('createStorageBrowser', () => {
+ it('throws when registerAuthListener is not a function', () => {
+ const input = {
+ config: { getLocationCredentials, listLocations, region },
+ };
+
+ // @ts-expect-error intentionally omit registerAuthListener
+ expect(() => createStorageBrowser(input)).toThrow(
+ 'StorageBrowser: `registerAuthListener` must be a function.'
+ );
+ });
+
+ it('renders the `LocationsView` by default', async () => {
+ const { StorageBrowser } = createStorageBrowser(input);
+
+ await waitFor(() => {
+ render( );
+ });
+
+ expect(screen.getByTestId('LOCATIONS_VIEW')).toBeInTheDocument();
+
+ expect(createConfigurationProviderSpy).toHaveBeenCalledWith({
+ accountId: config.accountId,
+ displayName: 'ConfigurationProvider',
+ customEndpoint: config.customEndpoint,
+ getLocationCredentials: config.getLocationCredentials,
+ region: config.region,
+ registerAuthListener: config.registerAuthListener,
+ actions: {
+ copy: expect.any(Object),
+ createFolder: expect.any(Object),
+ delete: expect.any(Object),
+ listLocationItems: expect.any(Object),
+ listLocations: expect.any(Object),
+ upload: expect.any(Object),
+ },
+ });
+ });
+
+ it('support passing custom displayText', async () => {
+ const { StorageBrowser } = createStorageBrowser(input);
+ const displayText: StorageBrowserDisplayText = {
+ LocationsView: { title: 'Hello' },
+ };
+ await waitFor(() => {
+ render( );
+ });
+
+ const Title = screen.getByText('Hello');
+ expect(Title).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/__testUtils__/permissions.ts b/packages/react-storage/src/components/StorageBrowser/actions/__testUtils__/permissions.ts
new file mode 100644
index 00000000000..57a52f71c4d
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/__testUtils__/permissions.ts
@@ -0,0 +1,20 @@
+import { LocationPermissions } from '../handlers';
+
+export const generateCombinations = (values: T[]): T[][] => {
+ if (values.length === 1) {
+ return [values];
+ }
+
+ const [first, ...rest] = values;
+ const restPermutations = generateCombinations(rest);
+
+ const firstPermutations = restPermutations.map((value) => [first, ...value]);
+ return [...restPermutations, ...firstPermutations, [first]];
+};
+
+export const LOCATION_PERMISSION_VALUES: LocationPermissions = [
+ 'get',
+ 'list',
+ 'delete',
+ 'write',
+];
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap
new file mode 100644
index 00000000000..d2894ca6058
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/__snapshots__/defaults.spec.ts.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`defaultActionConfigs matches expected shape 1`] = `
+{
+ "copy": {
+ "actionsListItemConfig": {
+ "disable": [Function],
+ "hide": [Function],
+ "icon": "copy-file",
+ "label": "Copy",
+ },
+ "componentName": "CopyView",
+ "displayName": "Copy",
+ "handler": [Function],
+ },
+ "createFolder": {
+ "actionsListItemConfig": {
+ "disable": [Function],
+ "hide": [Function],
+ "icon": "create-folder",
+ "label": "Create folder",
+ },
+ "componentName": "CreateFolderView",
+ "displayName": "Create Folder",
+ "handler": [Function],
+ "isCancelable": false,
+ },
+ "delete": {
+ "actionsListItemConfig": {
+ "disable": [Function],
+ "hide": [Function],
+ "icon": "delete-file",
+ "label": "Delete",
+ },
+ "componentName": "DeleteView",
+ "displayName": "Delete",
+ "handler": [Function],
+ },
+ "listLocationItems": {
+ "componentName": "LocationDetailView",
+ "displayName": [Function],
+ "handler": [Function],
+ },
+ "listLocations": {
+ "componentName": "LocationsView",
+ "displayName": "Home",
+ "handler": [Function],
+ },
+ "upload": {
+ "actionsListItemConfig": {
+ "disable": [Function],
+ "fileSelection": "FILE",
+ "hide": [Function],
+ "icon": "upload-file",
+ "label": "Upload",
+ },
+ "componentName": "UploadView",
+ "displayName": "Upload",
+ "handler": [Function],
+ "includeProgress": true,
+ "isCancelable": true,
+ },
+}
+`;
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx
new file mode 100644
index 00000000000..9008ffc0c21
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/context.spec.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react';
+
+import { defaultActionConfigs } from '../defaults';
+import { ActionConfigs } from '../types';
+
+import { ActionConfigsProvider, useActionConfigs } from '../context';
+
+describe('useActionConfigs', () => {
+ it('returns default and custom config values passed to `ActionConfigsProvider`', () => {
+ const someCoolHandler = jest.fn();
+ const configs: ActionConfigs = {
+ ...defaultActionConfigs,
+ SomeCoolAction: {
+ componentName: 'SomeCoolView',
+ handler: someCoolHandler,
+ isCancelable: false,
+ displayName: 'Do Cool Action',
+ },
+ };
+
+ const { result } = renderHook(useActionConfigs, {
+ wrapper: (props) => (
+
+ ),
+ });
+
+ expect(result.current.actions).toStrictEqual(configs);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts
new file mode 100644
index 00000000000..d5e2689c1cd
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/__tests__/defaults.spec.ts
@@ -0,0 +1,143 @@
+import { ActionListItemConfig } from '../types';
+import {
+ createFolderActionConfig,
+ defaultActionConfigs,
+ listLocationItemsActionConfig,
+ uploadActionConfig,
+} from '../defaults';
+import {
+ generateCombinations,
+ LOCATION_PERMISSION_VALUES,
+} from '../../__testUtils__/permissions';
+
+const file = {
+ key: 'key',
+ id: 'id',
+ lastModified: new Date(),
+ size: 100,
+ type: 'FILE' as const,
+};
+
+const permissionValuesWithoutWrite = LOCATION_PERMISSION_VALUES.filter(
+ (value) => value !== 'write'
+);
+
+describe('defaultActionConfigs', () => {
+ it('matches expected shape', () => {
+ expect(defaultActionConfigs).toMatchSnapshot();
+ });
+
+ describe('createFolderActionConfig', () => {
+ it('hides the action list item as expected', () => {
+ const hide =
+ (createFolderActionConfig.actionsListItemConfig as ActionListItemConfig)!
+ .hide!;
+ for (const permissionsWithoutWrite of generateCombinations(
+ permissionValuesWithoutWrite
+ )) {
+ expect(hide(permissionsWithoutWrite)).toBe(true);
+ expect(hide([...permissionValuesWithoutWrite, 'write'])).toBe(false);
+ }
+ });
+
+ it('is never disabled', () => {
+ const disable =
+ (createFolderActionConfig.actionsListItemConfig as ActionListItemConfig)!
+ .disable!;
+
+ expect(disable([])).toBe(false);
+ expect(disable([file])).toBe(false);
+ expect(disable(undefined)).toBe(false);
+ });
+ });
+
+ describe('listLocationItemsActionConfig', () => {
+ it('returns the expected value of title', () => {
+ const { displayName } = listLocationItemsActionConfig;
+
+ expect(displayName(undefined, undefined)).toBe('-');
+ expect(displayName('bucket', undefined)).toBe('bucket');
+ expect(displayName('bucket', 'prefix/')).toBe('bucket: prefix/');
+ expect(displayName('bucket', 'prefix/nested/')).toBe(
+ 'bucket: ../nested/'
+ );
+ });
+ });
+
+ describe('uploadActionConfig', () => {
+ it('hides the action list item as expected', () => {
+ const uploadFileListItem = uploadActionConfig.actionsListItemConfig!;
+
+ for (const permissionsWithoutWrite of generateCombinations(
+ permissionValuesWithoutWrite
+ )) {
+ const permissionsWithWrite = [
+ ...permissionValuesWithoutWrite,
+ 'write' as const,
+ ];
+ expect(uploadFileListItem.hide?.(permissionsWithoutWrite)).toBe(true);
+ expect(uploadFileListItem.hide?.(permissionsWithWrite)).toBe(false);
+ }
+ });
+
+ it('is never disabled', () => {
+ const uploadFileListItem = uploadActionConfig.actionsListItemConfig!;
+
+ expect(uploadFileListItem.disable?.([])).toBe(false);
+ expect(uploadFileListItem.disable?.([file])).toBe(false);
+ expect(uploadFileListItem.disable?.(undefined)).toBe(false);
+ });
+ });
+
+ describe('deleteActionConfig', () => {
+ it('hides the action list item as expected', () => {
+ const deleteFileListItem =
+ defaultActionConfigs.delete.actionsListItemConfig!;
+
+ for (const permissionsWithoutDelete of generateCombinations(
+ LOCATION_PERMISSION_VALUES.filter((value) => value !== 'delete')
+ )) {
+ const permissionsWithDelete = [
+ ...permissionsWithoutDelete,
+ 'delete' as const,
+ ];
+ expect(deleteFileListItem.hide?.(permissionsWithoutDelete)).toBe(true);
+ expect(deleteFileListItem.hide?.(permissionsWithDelete)).toBe(false);
+ }
+ });
+
+ it('is disabled when no files are selected', () => {
+ const deleteFileListItem =
+ defaultActionConfigs.delete.actionsListItemConfig!;
+
+ expect(deleteFileListItem.disable?.(undefined)).toBe(true);
+ expect(deleteFileListItem.disable?.([])).toBe(true);
+ expect(deleteFileListItem.disable?.([file])).toBe(false);
+ });
+ });
+
+ describe('copyActionConfig', () => {
+ it('hides the action list item as expected', () => {
+ const copyFileListItem = defaultActionConfigs.copy.actionsListItemConfig!;
+
+ for (const permissionsWithoutWrite of generateCombinations(
+ permissionValuesWithoutWrite
+ )) {
+ const permissionsWithWrite = [
+ ...permissionValuesWithoutWrite,
+ 'write' as const,
+ ];
+ expect(copyFileListItem.hide?.(permissionsWithoutWrite)).toBe(true);
+ expect(copyFileListItem.hide?.(permissionsWithWrite)).toBe(false);
+ }
+ });
+
+ it('is disabled when no files are selected', () => {
+ const copyFileListItem = defaultActionConfigs.copy.actionsListItemConfig!;
+
+ expect(copyFileListItem.disable?.(undefined)).toBe(true);
+ expect(copyFileListItem.disable?.([])).toBe(true);
+ expect(copyFileListItem.disable?.([file])).toBe(false);
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts
new file mode 100644
index 00000000000..2bde82f1c50
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/context.ts
@@ -0,0 +1,23 @@
+import { createContextUtilities } from '@aws-amplify/ui-react-core';
+import { ActionConfigs } from './types';
+
+export interface ActionConfigsProviderProps {
+ actions?: ActionConfigs;
+ children?: React.ReactNode;
+}
+
+const defaultValue: { actions?: ActionConfigs } = { actions: undefined };
+export const { useActionConfigs, ActionConfigsProvider } =
+ createContextUtilities({ contextName: 'ActionConfigs', defaultValue });
+
+export function useActionConfig(
+ type?: T
+): ActionConfigs[T] {
+ const { actions } = useActionConfigs();
+
+ const config = type && actions?.[type];
+
+ if (!config) throw new Error('No action!');
+
+ return config;
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx
new file mode 100644
index 00000000000..a58ec847424
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/defaults.tsx
@@ -0,0 +1,113 @@
+import {
+ listLocationItemsHandler,
+ listLocationsHandler,
+ createFolderHandler,
+ uploadHandler,
+ copyHandler,
+ deleteHandler,
+} from '../handlers';
+
+import {
+ CopyActionConfig,
+ CreateFolderActionConfig,
+ DeleteActionConfig,
+ ListLocationItemsActionConfig,
+ ListLocationsActionConfig,
+ UploadActionConfig,
+} from './types';
+
+export const copyActionConfig: CopyActionConfig = {
+ componentName: 'CopyView',
+ actionsListItemConfig: {
+ disable: (selected) => !selected || selected.length === 0,
+ hide: (permissions) => !permissions.includes('write'),
+ icon: 'copy-file',
+ label: 'Copy',
+ },
+ displayName: 'Copy',
+ handler: copyHandler,
+};
+
+export const deleteActionConfig: DeleteActionConfig = {
+ componentName: 'DeleteView',
+ actionsListItemConfig: {
+ disable: (selected) => !selected || selected.length === 0,
+ hide: (permissions) => !permissions.includes('delete'),
+ icon: 'delete-file',
+ label: 'Delete',
+ },
+ displayName: 'Delete',
+ handler: deleteHandler,
+};
+
+export const createFolderActionConfig: CreateFolderActionConfig = {
+ componentName: 'CreateFolderView',
+ actionsListItemConfig: {
+ disable: () => false,
+ hide: (permissions) => !permissions.includes('write'),
+ icon: 'create-folder',
+ label: 'Create folder',
+ },
+ handler: createFolderHandler,
+ isCancelable: false,
+ displayName: 'Create Folder',
+};
+
+export const listLocationItemsActionConfig: ListLocationItemsActionConfig = {
+ componentName: 'LocationDetailView',
+ handler: listLocationItemsHandler,
+ displayName: (bucket, prefix) => {
+ if (bucket && prefix) {
+ const prefixes = prefix.split('/');
+ return `${bucket}: ${
+ prefixes.length > 2 ? `../${prefixes[prefixes.length - 2]}/` : prefix
+ }`;
+ }
+ return !bucket ? '-' : bucket;
+ },
+};
+
+export const listLocationsActionConfig: ListLocationsActionConfig = {
+ componentName: 'LocationsView',
+ handler: listLocationsHandler,
+ displayName: 'Home',
+};
+
+export const uploadActionConfig: UploadActionConfig = {
+ componentName: 'UploadView',
+ actionsListItemConfig: {
+ disable: () => false,
+ fileSelection: 'FILE',
+ hide: (permissions) => !permissions.includes('write'),
+ icon: 'upload-file',
+ label: 'Upload',
+ },
+ isCancelable: true,
+ includeProgress: true,
+ handler: uploadHandler,
+ displayName: 'Upload',
+};
+
+export const defaultActionViewConfigs = {
+ copy: copyActionConfig,
+ createFolder: createFolderActionConfig,
+ delete: deleteActionConfig,
+ upload: uploadActionConfig,
+};
+
+export type DefaultActionViewType = keyof typeof defaultActionViewConfigs;
+
+export const DEFAULT_ACTION_VIEW_TYPES = Object.keys(
+ defaultActionViewConfigs
+) as DefaultActionViewType[];
+
+export const isDefaultActionViewType = (
+ value?: string
+): value is DefaultActionViewType =>
+ DEFAULT_ACTION_VIEW_TYPES.some((type) => type === value);
+
+export const defaultActionConfigs = {
+ ...defaultActionViewConfigs,
+ listLocationItems: listLocationItemsActionConfig,
+ listLocations: listLocationsActionConfig,
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts
new file mode 100644
index 00000000000..b94b3f540be
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/index.ts
@@ -0,0 +1,11 @@
+export {
+ ActionConfigsProvider,
+ ActionConfigsProviderProps,
+ useActionConfig,
+} from './context';
+export {
+ defaultActionConfigs,
+ defaultActionViewConfigs,
+ isDefaultActionViewType,
+} from './defaults';
+export * from './types';
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts
new file mode 100644
index 00000000000..52ae4082997
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/configs/types.ts
@@ -0,0 +1,167 @@
+import { StorageBrowserIconType } from '../../context/elements';
+import { LocationPermissions } from '../../actions';
+
+import {
+ ListLocationsHandler,
+ ListLocationItemsHandler,
+ LocationItemData,
+ LocationItemType,
+ UploadHandler,
+ CreateFolderHandler,
+ DeleteHandler,
+ CopyHandler,
+ TaskHandler,
+} from '../handlers';
+
+type StringWithoutSpaces = Exclude<
+ T,
+ ` ${string}` | `${string} ` | `${string} ${string}`
+>;
+
+export type ComponentName = Capitalize<`${string}View`>;
+type ActionName = StringWithoutSpaces;
+
+/**
+ * native OS file picker type. to restrict selectable file types, define the picker types
+ * followed by accepted file types as strings
+ * @example
+ * ```ts
+ * type JPEGOnly = ['FOLDER', '.jpeg'];
+ * ```
+ */
+export type SelectionType = LocationItemType | [LocationItemType, ...string[]];
+
+export interface ActionConfigTemplate {
+ /**
+ * The name of the component associated with the action
+ */
+ componentName: ComponentName;
+
+ /**
+ * action handler
+ */
+ handler: T;
+}
+
+export interface ActionListItemConfig {
+ /**
+ * conditionally disable item selection based on currently selected values
+ * @default false
+ */
+ disable?: (selectedValues: LocationItemData[] | undefined) => boolean;
+
+ /**
+ * open native OS file picker with associated selection type on item select
+ */
+ fileSelection?: SelectionType;
+
+ /**
+ * conditionally render list item based on location permission
+ * @default false
+ */
+ hide?: (permissions: LocationPermissions) => boolean;
+
+ /**
+ * list item icon
+ */
+ icon: StorageBrowserIconType;
+
+ /**
+ * list item label
+ */
+ label: string;
+}
+
+/**
+ * defines an action to be included in the actions list of the `LocationDetailView` with
+ * a dedicated subcomponent of the `LocationActionView`
+ */
+export interface TaskActionConfig
+ extends ActionConfigTemplate {
+ /**
+ * configure action list item behavior. provide multiple configs
+ * to create additional list items for a single action
+ */
+ actionsListItemConfig?: ActionListItemConfig;
+
+ /**
+ * whether the provided `handler` allow inflight cancellation
+ * @default false
+ */
+ isCancelable?: boolean;
+
+ /**
+ * show per task progress in the action task table
+ * @default false
+ */
+ includeProgress?: boolean;
+
+ /**
+ * default display name value displayed on action view
+ */
+ displayName: string;
+}
+
+export interface ListActionConfig extends ActionConfigTemplate {}
+
+export interface UploadActionConfig extends TaskActionConfig {
+ componentName: 'UploadView';
+}
+
+export interface DeleteActionConfig extends TaskActionConfig {
+ componentName: 'DeleteView';
+}
+
+export interface CopyActionConfig extends TaskActionConfig {
+ componentName: 'CopyView';
+}
+
+export interface CreateFolderActionConfig
+ extends TaskActionConfig {
+ componentName: 'CreateFolderView';
+}
+
+export interface ListLocationsActionConfig
+ extends ListActionConfig {
+ componentName: 'LocationsView';
+ displayName: string;
+}
+
+export interface ListLocationItemsActionConfig
+ extends ListActionConfig {
+ componentName: 'LocationDetailView';
+ displayName: (
+ bucket: string | undefined,
+ prefix: string | undefined
+ ) => string;
+}
+
+export interface DefaultActionConfigs {
+ ListLocationItems: ListLocationItemsActionConfig;
+ ListLocations: ListLocationsActionConfig;
+ CreateFolder: CreateFolderActionConfig;
+ Upload: UploadActionConfig;
+ Delete: DeleteActionConfig;
+ Copy: CopyActionConfig;
+}
+
+export type DefaultActionKey = keyof DefaultActionConfigs;
+
+export type ActionConfigs = Record<
+ ActionsKeys,
+ | ListLocationItemsActionConfig
+ | ListLocationsActionConfig
+ | CreateFolderActionConfig
+ | UploadActionConfig
+ | TaskActionConfig
+>;
+
+export type ResolveActionHandler = T extends
+ | TaskActionConfig
+ | ListActionConfig
+ ? K
+ : never;
+
+export type ResolveActionHandlers = {
+ [K in keyof T]: ResolveActionHandler;
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts b/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts
new file mode 100644
index 00000000000..58e47777a8c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/createUseAction.ts
@@ -0,0 +1,6 @@
+import { CreateUseAction } from './types';
+
+export const createUseAction: CreateUseAction = (_) => {
+ // TODO: implement this function
+ throw new Error('Not implemented');
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts
new file mode 100644
index 00000000000..8b385f86b5e
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts
@@ -0,0 +1,105 @@
+import * as StorageModule from '../../../storage-internal';
+
+import { copyHandler, CopyHandlerInput } from '../copy';
+
+const copySpy = jest.spyOn(StorageModule, 'copy');
+
+const baseInput: CopyHandlerInput = {
+ destinationPrefix: 'destination/',
+ config: {
+ accountId: '012345678901',
+ bucket: 'bucket',
+ credentials: jest.fn(),
+ customEndpoint: 'mock-endpoint',
+ region: 'region',
+ },
+ data: {
+ id: 'identity',
+ key: 'some-prefixfix/some-key.hehe',
+ fileKey: 'some-key.hehe',
+ lastModified: new Date(),
+ size: 100000000,
+ eTag: 'etag',
+ type: 'FILE',
+ },
+};
+
+describe('copyHandler', () => {
+ afterEach(() => {
+ copySpy.mockClear();
+ });
+
+ it('calls `copy` wth the expected values', () => {
+ copyHandler(baseInput);
+
+ const bucket = {
+ bucketName: `${baseInput.config.bucket}`,
+ region: `${baseInput.config.region}`,
+ };
+
+ const expected: StorageModule.CopyInput = {
+ destination: {
+ expectedBucketOwner: baseInput.config.accountId,
+ bucket,
+ path: `${baseInput.destinationPrefix}${baseInput.data.fileKey}`,
+ },
+ source: {
+ expectedBucketOwner: `${baseInput.config.accountId}`,
+ bucket,
+ path: baseInput.data.key,
+ eTag: baseInput.data.eTag,
+ notModifiedSince: baseInput.data.lastModified,
+ },
+ options: {
+ locationCredentialsProvider: baseInput.config.credentials,
+ customEndpoint: baseInput.config.customEndpoint,
+ },
+ };
+
+ expect(copySpy).toHaveBeenCalledWith(expected);
+ });
+
+ it('provides eTag and notModifiedSince to copy for durableness', () => {
+ copyHandler(baseInput);
+
+ const bucket = {
+ bucketName: `${baseInput.config.bucket}`,
+ region: `${baseInput.config.region}`,
+ };
+
+ const copyInput = copySpy.mock.lastCall?.[0];
+ expect(copyInput).toHaveProperty('source', {
+ expectedBucketOwner: `${baseInput.config.accountId}`,
+ bucket,
+ path: baseInput.data.key,
+ eTag: baseInput.data.eTag,
+ notModifiedSince: baseInput.data.lastModified,
+ });
+ });
+
+ it.each([
+ ['unicode', 'bucket/path/☺️', 'bucket/path/%E2%98%BA%EF%B8%8F'],
+ ['already encoded', 'bucket/path/%20', 'bucket/path/%2520'],
+ [
+ 'characters to be uri encoded',
+ 'bucket/path/&$@=;:+,?',
+ 'bucket/path/%26%24%40%3D%3B%3A%2B%2C%3F',
+ ],
+ ])('encodes the source path that is %s', (_, sourcePath, expectedPath) => {
+ copyHandler({
+ ...baseInput,
+ data: {
+ ...baseInput.data,
+ key: sourcePath,
+ },
+ });
+
+ const expected = expect.objectContaining({
+ source: expect.objectContaining({
+ path: expectedPath,
+ }),
+ });
+
+ expect(copySpy).toHaveBeenCalledWith(expected);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts
new file mode 100644
index 00000000000..04fb557ab44
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts
@@ -0,0 +1,155 @@
+import { createFolderHandler, CreateFolderHandlerInput } from '../createFolder';
+
+import * as InternalStorageModule from '../../../storage-internal';
+
+const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData');
+
+const credentials = jest.fn();
+
+const config: CreateFolderHandlerInput['config'] = {
+ accountId: '012345678901',
+ bucket: 'bucket',
+ credentials,
+ customEndpoint: 'mock-endpoint',
+ region: 'region',
+};
+
+const onProgress = jest.fn();
+
+const baseInput: CreateFolderHandlerInput = {
+ config,
+ data: { key: '', id: 'an-id' },
+ destinationPrefix: 'prefix/',
+};
+
+const error = new Error('Failed!');
+
+describe('createFolderHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('behaves as expected in the happy path', async () => {
+ uploadDataSpy.mockReturnValueOnce({
+ cancel: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ result: Promise.resolve({ path: '' }),
+ state: 'SUCCESS',
+ });
+
+ const { result } = createFolderHandler(baseInput);
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+ });
+
+ it('calls `uploadData` with the expected values', () => {
+ createFolderHandler({ ...baseInput, options: { preventOverwrite: true } });
+
+ const expected: InternalStorageModule.UploadDataInput = {
+ data: '',
+ options: {
+ expectedBucketOwner: config.accountId,
+ bucket: {
+ bucketName: config.bucket,
+ region: config.region,
+ },
+ customEndpoint: config.customEndpoint,
+ locationCredentialsProvider: credentials,
+ onProgress: expect.any(Function),
+ preventOverwrite: true,
+ },
+ path: `${baseInput.destinationPrefix}${baseInput.data.key}`,
+ };
+
+ expect(uploadDataSpy).toHaveBeenCalledWith(expected);
+ });
+
+ it('calls provided onProgress callback as expected in the happy path', async () => {
+ uploadDataSpy.mockImplementation(({ options }) => {
+ // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface
+ options.onProgress({ totalBytes: 23, transferredBytes: 23 });
+
+ return {
+ cancel: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ result: Promise.resolve({ path: '' }),
+ state: 'SUCCESS',
+ };
+ });
+
+ const { result } = createFolderHandler({
+ ...baseInput,
+ options: { onProgress },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(onProgress).toHaveBeenCalledTimes(1);
+ expect(onProgress).toHaveBeenCalledWith(baseInput.data, 1);
+ });
+
+ it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => {
+ uploadDataSpy.mockImplementation(({ options }) => {
+ // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface
+ options.onProgress({ transferredBytes: 23 });
+
+ return {
+ cancel: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ result: Promise.resolve({ path: '' }),
+ state: 'SUCCESS',
+ };
+ });
+
+ const { result } = createFolderHandler({
+ ...baseInput,
+ options: { onProgress },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(onProgress).toHaveBeenCalledTimes(1);
+ expect(onProgress).toHaveBeenCalledWith(baseInput.data, undefined);
+ });
+
+ it('handles a failure as expected', async () => {
+ uploadDataSpy.mockReturnValueOnce({
+ cancel: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ result: Promise.reject(error),
+ state: 'ERROR',
+ });
+
+ const { result } = createFolderHandler(baseInput);
+
+ expect(await result).toStrictEqual({
+ message: error.message,
+ status: 'FAILED',
+ });
+ });
+
+ it('returns "OVERWRITE_PREVENTED" on `PreconditionFailed` error', async () => {
+ const message = 'No overwrite!';
+ const overwritePreventedError = new Error(message);
+ overwritePreventedError.name = 'PreconditionFailed';
+
+ uploadDataSpy.mockReturnValueOnce({
+ cancel: jest.fn(),
+ pause: jest.fn(),
+ resume: jest.fn(),
+ result: Promise.reject(overwritePreventedError),
+ state: 'ERROR',
+ });
+
+ const { result } = createFolderHandler(baseInput);
+
+ expect(await result).toStrictEqual({
+ message,
+ status: 'OVERWRITE_PREVENTED',
+ });
+ });
+});
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
new file mode 100644
index 00000000000..7e99f1cc3f4
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts
@@ -0,0 +1,44 @@
+import * as StorageModule from '../../../storage-internal';
+
+import { deleteHandler, DeleteHandlerInput } from '../delete';
+
+const removeSpy = jest.spyOn(StorageModule, 'remove');
+
+const baseInput: DeleteHandlerInput = {
+ config: {
+ accountId: '012345678901',
+ bucket: 'bucket',
+ credentials: jest.fn(),
+ customEndpoint: 'mock-endpoint',
+ region: 'region',
+ },
+ data: {
+ id: 'id',
+ key: 'prefix/key.png',
+ fileKey: 'key.png',
+ lastModified: new Date(),
+ size: 829292,
+ type: 'FILE',
+ },
+};
+
+describe('deleteHandler', () => {
+ it('calls `remove` and returns the expected `key`', () => {
+ deleteHandler(baseInput);
+
+ const expected: StorageModule.RemoveInput = {
+ path: baseInput.data.key,
+ options: {
+ expectedBucketOwner: baseInput.config.accountId,
+ bucket: {
+ bucketName: baseInput.config.bucket,
+ region: baseInput.config.region,
+ },
+ customEndpoint: baseInput.config.customEndpoint,
+ locationCredentialsProvider: baseInput.config.credentials,
+ },
+ };
+
+ expect(removeSpy).toHaveBeenCalledWith(expected);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts
new file mode 100644
index 00000000000..cf602feaf34
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts
@@ -0,0 +1,46 @@
+import * as StorageModule from '../../../storage-internal';
+
+import { downloadHandler, DownloadHandlerInput } from '../download';
+
+const downloadSpy = jest.spyOn(StorageModule, 'getUrl');
+
+const baseInput: DownloadHandlerInput = {
+ config: {
+ accountId: 'accountId',
+ bucket: 'bucket',
+ credentials: jest.fn(),
+ customEndpoint: 'mock-endpoint',
+ region: 'region',
+ },
+ data: {
+ id: 'id',
+ key: 'prefix/file-name',
+ fileKey: 'file-name',
+ lastModified: new Date(),
+ size: 1000022,
+ type: 'FILE',
+ },
+};
+
+describe('downloadHandler', () => {
+ it('calls `getUrl` with the expected values', () => {
+ downloadHandler(baseInput);
+
+ const expected: StorageModule.GetUrlInput = {
+ path: baseInput.data.key,
+ options: {
+ bucket: {
+ bucketName: baseInput.config.bucket,
+ region: baseInput.config.region,
+ },
+ customEndpoint: baseInput.config.customEndpoint,
+ locationCredentialsProvider: baseInput.config.credentials,
+ validateObjectExistence: true,
+ contentDisposition: 'attachment',
+ expectedBucketOwner: baseInput.config.accountId,
+ },
+ };
+
+ expect(downloadSpy).toHaveBeenCalledWith(expected);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts
new file mode 100644
index 00000000000..84f51da6e81
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts
@@ -0,0 +1,162 @@
+import * as StorageModule from '../../../storage-internal';
+
+import {
+ listLocationItemsHandler,
+ ListLocationItemsHandlerInput,
+ parseResult,
+} from '../listLocationItems';
+
+let uuid = 0;
+Object.defineProperty(globalThis, 'crypto', {
+ value: {
+ randomUUID: () => {
+ uuid++;
+ return uuid.toString();
+ },
+ },
+});
+
+const listSpy = jest
+ .spyOn(StorageModule, 'list')
+ .mockImplementation(() => Promise.resolve({ items: [], nextToken: '' }));
+
+const baseInput: ListLocationItemsHandlerInput = {
+ prefix: 'prefix/',
+ config: {
+ accountId: '012345678901',
+ bucket: 'bucket',
+ customEndpoint: 'mock-endpoint',
+ credentials: jest.fn(),
+ region: 'region',
+ },
+};
+
+const prefix = 'prefix1/';
+
+describe('listLocationItemsHandler', () => {
+ beforeEach(() => {
+ listSpy.mockClear();
+ });
+
+ it('returns the expected output shape in the happy path', async () => {
+ listSpy.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' });
+
+ const { items, nextToken } = await listLocationItemsHandler(baseInput);
+
+ expect(items).toHaveLength(0);
+ expect(nextToken).toBeDefined();
+ });
+
+ it('provides expected `pageSize` to `list` on initial load', async () => {
+ listSpy.mockResolvedValueOnce({ items: [] });
+
+ const input = {
+ ...baseInput,
+ options: { pageSize: 10 },
+ prefix: 'a_prefix',
+ };
+
+ await listLocationItemsHandler(input);
+
+ expect(listSpy).toHaveBeenCalledTimes(1);
+ expect(listSpy).toHaveBeenCalledWith({
+ path: input.prefix,
+ options: {
+ bucket: {
+ bucketName: input.config.bucket,
+ region: input.config.region,
+ },
+ customEndpoint: input.config.customEndpoint,
+ expectedBucketOwner: input.config.accountId,
+ locationCredentialsProvider: input.config.credentials,
+ nextToken: undefined,
+ pageSize: input.options.pageSize + 1,
+ subpathStrategy: { delimiter: undefined, strategy: 'include' },
+ },
+ });
+ });
+ it('provides `pageSize` number of items after removing items that match / or . or ..', async () => {
+ listSpy
+ .mockResolvedValueOnce({
+ items: [
+ { path: `/`, lastModified: new Date(), size: 0 },
+ { path: `.`, lastModified: new Date(), size: 0 },
+ { path: `..`, lastModified: new Date(), size: 0 },
+ { path: `${prefix}-1`, lastModified: new Date(), size: 0 },
+ ],
+ nextToken: '1',
+ })
+ .mockResolvedValueOnce({
+ items: [
+ { path: `${prefix}-2`, lastModified: new Date(), size: 0 },
+ { path: `${prefix}-3`, lastModified: new Date(), size: 0 },
+ ],
+ nextToken: undefined,
+ });
+
+ const input = {
+ ...baseInput,
+ options: { pageSize: 3 },
+ prefix: 'a_prefix',
+ };
+
+ const listItems = await listLocationItemsHandler(input);
+ expect(listItems.items).toHaveLength(input.options.pageSize);
+ expect(listSpy).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('parseResult', () => {
+ it('outputs correct list with items: prefix, zero byte folder, object and excludedSubpaths', () => {
+ const output = {
+ items: [
+ // Current prefix
+ { path: prefix, lastModified: new Date(), size: 0 },
+ // Zero byte subfolder:
+ { path: `${prefix}Banana/`, lastModified: new Date(), size: 0 },
+ // Image file:
+ { path: `${prefix}Orange.jpg`, lastModified: new Date(), size: 56984 },
+ ],
+ // subfolder with objects in it
+ excludedSubpaths: [`${prefix}Cloudberry/`],
+ };
+ const result = parseResult(output, prefix);
+ expect(result).toHaveLength(3);
+ const subFolderWithObject = result[0];
+ expect(subFolderWithObject.key).toBe(`${prefix}Cloudberry/`);
+ expect(subFolderWithObject.type).toBe('FOLDER');
+ const zeroByteSubFolder = result[1];
+ expect(zeroByteSubFolder.key).toBe(`${prefix}Banana/`);
+ expect(zeroByteSubFolder.type).toBe('FOLDER');
+ const file = result[2];
+ expect(file.key).toBe(`${prefix}Orange.jpg`);
+ expect(file.type).toBe('FILE');
+ });
+
+ it('should return empty array for empty zero byte folder', () => {
+ // empty folders will just show the current prefix as the path
+ const output = {
+ items: [{ path: prefix, lastModified: new Date(), size: 0 }],
+ };
+ const result = parseResult(output, prefix);
+ expect(result).toHaveLength(0);
+ });
+
+ describe('filterDotItems', () => {
+ it('should filter out invalid keys: "/", "." etc', () => {
+ const output = {
+ items: [
+ { path: ` / `, lastModified: new Date(), size: 0 },
+ { path: ` ./ `, lastModified: new Date(), size: 0 },
+ { path: ` ../ `, lastModified: new Date(), size: 0 },
+ { path: ` . `, lastModified: new Date(), size: 0 },
+ { path: ` .. `, lastModified: new Date(), size: 0 },
+ { path: `${prefix}visible`, lastModified: new Date(), size: 0 },
+ ],
+ };
+ const result = parseResult(output, prefix);
+ expect(result).toHaveLength(1);
+ expect(result[0].key).toBe(`${prefix}visible`);
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts
new file mode 100644
index 00000000000..60fd72da488
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocations.spec.ts
@@ -0,0 +1,130 @@
+import {
+ listCallerAccessGrants,
+ ListLocationsOutput,
+ LocationCredentialsProvider,
+ LocationAccess,
+} from '../../../storage-internal';
+
+import { getFilteredLocations } from '../utils';
+
+import {
+ listLocationsHandler,
+ ListLocationsHandlerInput,
+} from '../listLocations';
+
+jest.mock('../../../storage-internal');
+
+const mockListCallerAccessGrants = jest.mocked(listCallerAccessGrants);
+
+const generateMockLocations = (size: number, mockLocations: LocationAccess) =>
+ Array(size).fill(mockLocations);
+
+const accountId = 'account-id';
+const credentials: LocationCredentialsProvider = jest.fn();
+const region = 'region';
+
+const customEndpoint = 'mock-endpoint';
+const DEFAULT_PAGE_SIZE = 5;
+
+const input: ListLocationsHandlerInput = {
+ config: { accountId, credentials, customEndpoint, region },
+ options: {
+ pageSize: DEFAULT_PAGE_SIZE,
+ nextToken: undefined,
+ exclude: { exactPermissions: ['get', 'list'] },
+ },
+};
+
+describe('listLocationsHandler', () => {
+ // TODO(@AllanZhengYP): add unit test for more permissions permutations
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'crypto', {
+ value: { randomUUID: () => 'intentionally-static-test-id' },
+ });
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should fetch a single page of results successfully', async () => {
+ const mockOutput: ListLocationsOutput = {
+ locations: [
+ { scope: 's3://bucket/prefix/*', permission: 'READ', type: 'PREFIX' },
+ ],
+ nextToken: undefined,
+ };
+
+ mockListCallerAccessGrants.mockResolvedValueOnce(mockOutput);
+
+ const result = await listLocationsHandler(input);
+
+ expect(result.items).toEqual(
+ getFilteredLocations(mockOutput.locations, input.options?.exclude)
+ );
+ expect(result.nextToken).toBeUndefined();
+ expect(mockListCallerAccessGrants).toHaveBeenCalledTimes(1);
+ expect(mockListCallerAccessGrants).toHaveBeenCalledWith({
+ accountId: input.config.accountId,
+ credentialsProvider: input.config.credentials,
+ customEndpoint: input.config.customEndpoint,
+ nextToken: input.options?.nextToken,
+ pageSize: input.options?.pageSize,
+ region: input.config.region,
+ });
+ });
+
+ it('should fetch multiple pages of results successfully', async () => {
+ const mockLocation: LocationAccess = {
+ scope: 's3://bucket/prefix1/*',
+ permission: 'READWRITE',
+ type: 'PREFIX',
+ };
+ const mockOutputPage1: ListLocationsOutput = {
+ locations: [mockLocation],
+ nextToken: 'token1',
+ };
+
+ const mockOutputPage2: ListLocationsOutput = {
+ locations: [...generateMockLocations(2, mockLocation)],
+ nextToken: 'token2',
+ };
+
+ const mockOutputPage3: ListLocationsOutput = {
+ locations: [...generateMockLocations(2, mockLocation)],
+ nextToken: undefined,
+ };
+
+ mockListCallerAccessGrants
+ .mockResolvedValueOnce(mockOutputPage1)
+ .mockResolvedValueOnce(mockOutputPage2)
+ .mockResolvedValueOnce(mockOutputPage3);
+
+ const result = await listLocationsHandler(input);
+
+ expect(result.items).toEqual([
+ ...getFilteredLocations(
+ mockOutputPage1.locations,
+ input.options?.exclude
+ ),
+ ...getFilteredLocations(
+ mockOutputPage2.locations,
+ input.options?.exclude
+ ),
+ ...getFilteredLocations(
+ mockOutputPage3.locations,
+ input.options?.exclude
+ ),
+ ]);
+ expect(result.nextToken).toBeUndefined();
+ expect(mockListCallerAccessGrants).toHaveBeenCalledTimes(3);
+ expect(result.items.length).toEqual(DEFAULT_PAGE_SIZE);
+ });
+
+ it('should throw when accountId is not present to fetch Locations', async () => {
+ input.config.accountId = undefined;
+ await expect(listLocationsHandler(input)).rejects.toThrow(
+ 'Storage Browser: Must provide accountId to `listCallerAccessGrants`.'
+ );
+ });
+});
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
new file mode 100644
index 00000000000..2e123f8dae1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts
@@ -0,0 +1,236 @@
+import * as InternalStorageModule from '../../../storage-internal';
+import * as StorageModule from 'aws-amplify/storage';
+
+import {
+ MULTIPART_UPLOAD_THRESHOLD_BYTES,
+ uploadHandler,
+ UploadHandlerInput,
+ UNDEFINED_CALLBACKS,
+} from '../upload';
+
+const isCancelErrorSpy = jest.spyOn(StorageModule, 'isCancelError');
+const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData');
+
+const credentials = jest.fn();
+
+const config: UploadHandlerInput['config'] = {
+ accountId: '012345678901',
+ bucket: 'bucket',
+ credentials,
+ customEndpoint: 'mock-endpoint',
+ region: 'region',
+};
+
+const file = new File([], 'test-o');
+
+const onProgress = jest.fn();
+
+const baseInput: UploadHandlerInput = {
+ config,
+ data: { key: file.name, id: 'an-id', file },
+ destinationPrefix: 'prefix/',
+};
+
+const cancel = jest.fn();
+const pause = jest.fn();
+const resume = jest.fn();
+
+const error = new Error('Failed!');
+
+describe('uploadHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('behaves as expected in the happy path', async () => {
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.resolve({ path: file.name }),
+ state: 'SUCCESS',
+ });
+
+ const { result } = uploadHandler(baseInput);
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+ });
+
+ it('calls upload with the expected values', () => {
+ uploadHandler({ ...baseInput, options: { preventOverwrite: true } });
+
+ const expected: InternalStorageModule.UploadDataInput = {
+ data: file,
+ options: {
+ expectedBucketOwner: config.accountId,
+ bucket: {
+ bucketName: config.bucket,
+ region: config.region,
+ },
+ customEndpoint: config.customEndpoint,
+ locationCredentialsProvider: credentials,
+ onProgress: expect.any(Function),
+ preventOverwrite: true,
+ checksumAlgorithm: 'crc-32',
+ },
+ path: `${baseInput.destinationPrefix}${baseInput.data.key}`,
+ };
+
+ expect(uploadDataSpy).toHaveBeenCalledWith(expected);
+ });
+
+ it('calls provided onProgress callback as expected in the happy path', async () => {
+ uploadDataSpy.mockImplementation(({ options }) => {
+ // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface
+ options.onProgress({ totalBytes: 23, transferredBytes: 23 });
+
+ return {
+ cancel,
+ pause,
+ resume,
+ result: Promise.resolve({ path: file.name }),
+ state: 'SUCCESS',
+ };
+ });
+
+ const { result } = uploadHandler({
+ ...baseInput,
+ options: { onProgress },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(onProgress).toHaveBeenCalledTimes(1);
+ expect(onProgress).toHaveBeenCalledWith(baseInput.data, 1);
+ });
+
+ it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => {
+ uploadDataSpy.mockImplementation(({ options }) => {
+ // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface
+ options.onProgress({ transferredBytes: 23 });
+
+ return {
+ cancel,
+ pause,
+ resume,
+ result: Promise.resolve({ path: file.name }),
+ state: 'SUCCESS',
+ };
+ });
+
+ const { result } = uploadHandler({
+ ...baseInput,
+ options: { onProgress },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(onProgress).toHaveBeenCalledTimes(1);
+ expect(onProgress).toHaveBeenCalledWith(baseInput.data, undefined);
+ });
+
+ it('returns the expected callback values for a file size greater than 5 mb', async () => {
+ const bigFile = new File(
+ [new ArrayBuffer(MULTIPART_UPLOAD_THRESHOLD_BYTES * 2)],
+ '😅'
+ );
+
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.resolve({ path: file.name }),
+ state: 'SUCCESS',
+ });
+
+ const { result, ...callbacks } = uploadHandler({
+ ...baseInput,
+ data: { key: bigFile.name, id: 'hi!', file: bigFile },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(callbacks).toStrictEqual({ cancel, pause, resume });
+ });
+
+ it('returns undefined callback values for a file size less than 5 mb', async () => {
+ const smallFile = new File([], '😅');
+
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.resolve({ path: file.name }),
+ state: 'SUCCESS',
+ });
+
+ const { result, ...callbacks } = uploadHandler({
+ ...baseInput,
+ data: { key: smallFile.name, id: 'ohh', file: smallFile },
+ });
+
+ expect(await result).toStrictEqual({ status: 'COMPLETE' });
+
+ expect(callbacks).toStrictEqual(UNDEFINED_CALLBACKS);
+ });
+
+ it('handles a failure as expected', async () => {
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.reject(error),
+ state: 'ERROR',
+ });
+
+ const { result } = uploadHandler(baseInput);
+
+ expect(await result).toStrictEqual({
+ message: error.message,
+ status: 'FAILED',
+ });
+ });
+
+ it('handles a cancel failure as expected', async () => {
+ // turn off console.warn in test output
+ jest.spyOn(console, 'warn').mockReturnValueOnce();
+ isCancelErrorSpy.mockReturnValue(true);
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.reject(error),
+ state: 'ERROR',
+ });
+
+ const { result } = uploadHandler(baseInput);
+
+ expect(await result).toStrictEqual({
+ message: 'Failed!',
+ status: 'CANCELED',
+ });
+ });
+
+ it('handles an overwrite failure as expected', async () => {
+ const preconditionError = new Error('Failed!');
+ preconditionError.name = 'PreconditionFailed';
+
+ uploadDataSpy.mockReturnValueOnce({
+ cancel,
+ pause,
+ resume,
+ result: Promise.reject(preconditionError),
+ state: 'ERROR',
+ });
+
+ const { result } = uploadHandler({
+ ...baseInput,
+ options: { preventOverwrite: true },
+ });
+
+ expect(await result).toStrictEqual({
+ message: 'Failed!',
+ status: 'OVERWRITE_PREVENTED',
+ });
+ });
+});
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
new file mode 100644
index 00000000000..f9cec8851ae
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts
@@ -0,0 +1,140 @@
+import { LocationAccess as AccessGrantLocation } from '../../../storage-internal';
+import { LocationData } from '../types';
+
+import {
+ shouldExcludeLocation,
+ getFileKey,
+ parseAccessGrantLocation,
+} from '../utils';
+
+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: AccessGrantLocation = {
+ scope: 'nope',
+ permission: 'READ',
+ type: 'BUCKET',
+ };
+
+ expect(() => parseAccessGrantLocation(invalidLocation)).toThrow(
+ 'Invalid scope: nope'
+ );
+ });
+
+ it('throws if provided an invalid location type', () => {
+ const invalidLocation: AccessGrantLocation = {
+ scope: 's3://yes',
+ permission: 'READ',
+ // @ts-expect-error intentional coercing to allow unhappy path test
+ type: 'NOT_BUCKET',
+ };
+
+ expect(() => parseAccessGrantLocation(invalidLocation)).toThrow(
+ 'Invalid location type: NOT_BUCKET'
+ );
+ });
+
+ it('parses a BUCKET location as expected', () => {
+ const location: AccessGrantLocation = {
+ permission: 'WRITE',
+ scope: `s3://${bucket}/*`,
+ type: 'BUCKET',
+ };
+ const expected: LocationData = {
+ bucket,
+ id,
+ prefix: '',
+ permissions: ['delete', 'write'],
+ type: 'BUCKET',
+ };
+
+ expect(parseAccessGrantLocation(location)).toStrictEqual(expected);
+ });
+
+ it('parses a PREFIX location as expected', () => {
+ const location: AccessGrantLocation = {
+ permission: 'WRITE',
+ scope: `s3://${bucket}/${folderPrefix}*`,
+ type: 'PREFIX',
+ };
+
+ const expected: LocationData = {
+ bucket,
+ id,
+ prefix: folderPrefix,
+ permissions: ['delete', 'write'],
+ type: 'PREFIX',
+ };
+
+ expect(parseAccessGrantLocation(location)).toStrictEqual(expected);
+ });
+
+ it('parses an OBJECT location as expected', () => {
+ const location: AccessGrantLocation = {
+ permission: 'WRITE',
+ scope: `s3://${bucket}/${folderPrefix}${filePath}`,
+ type: 'OBJECT',
+ };
+
+ const expected: LocationData = {
+ bucket,
+ id,
+ prefix: `${folderPrefix}${filePath}`,
+ permissions: ['delete', 'write'],
+ type: 'OBJECT',
+ };
+
+ expect(parseAccessGrantLocation(location)).toStrictEqual(expected);
+ });
+});
+
+describe('getFileKey', () => {
+ it('should return the filename without the path', () => {
+ expect(getFileKey('/path/to/file.txt')).toBe('file.txt');
+ expect(getFileKey('document.pdf')).toBe('document.pdf');
+ });
+
+ it('should handle paths with multiple slashes', () => {
+ expect(getFileKey('/path//to///file.txt')).toBe('file.txt');
+ });
+});
+
+describe('shouldExcludeLocation', () => {
+ const location: LocationData = {
+ bucket: 'bucket',
+ id: 'id',
+ permissions: ['list', 'get'],
+ prefix: 'prefix/',
+ type: 'PREFIX',
+ };
+
+ it('returns true when the provided location permissions match excluded permissions', () => {
+ const output = shouldExcludeLocation(location, {
+ exactPermissions: ['list', 'get'],
+ });
+
+ expect(output).toBe(true);
+ });
+
+ it('returns true when the provided location type match excluded type', () => {
+ const output = shouldExcludeLocation(location, { type: 'PREFIX' });
+
+ expect(output).toBe(true);
+ });
+
+ it('returns false when provided a location without an exclude value', () => {
+ const output = shouldExcludeLocation(location);
+
+ expect(output).toBe(false);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts
new file mode 100644
index 00000000000..a214f44cfa0
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/copy.ts
@@ -0,0 +1,67 @@
+import { copy, CopyInput } from '../../storage-internal';
+import {
+ FileDataItem,
+ TaskHandler,
+ TaskHandlerInput,
+ TaskHandlerOptions,
+ TaskHandlerOutput,
+} from './types';
+
+import { constructBucket } from './utils';
+
+export interface CopyHandlerData extends FileDataItem {}
+
+export interface CopyHandlerInput
+ extends TaskHandlerInput {
+ destinationPrefix: string;
+}
+export interface CopyHandlerOutput extends TaskHandlerOutput {}
+
+export interface CopyHandler
+ extends TaskHandler {}
+
+export const copyHandler: CopyHandler = (input) => {
+ const { config, destinationPrefix: path, data } = input;
+ const {
+ accountId: expectedBucketOwner,
+ credentials,
+ customEndpoint,
+ } = config;
+ const { key: sourcePath, fileKey, lastModified, eTag } = data;
+
+ const bucket = constructBucket(config);
+
+ const destinationPath = `${path}${fileKey}`;
+ const source: CopyInput['source'] = {
+ bucket,
+ expectedBucketOwner,
+ /**
+ * Per S3 requirement, copy source must the URI encoded.
+ * This is NOT added to Amplify JS v6 because it will be a breaking
+ * change to suddenly introduce URI encode to copy API source.
+ *
+ * see: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html#API_CopyObject_RequestSyntax
+ */
+ path: sourcePath.split('/').map(encodeURIComponent).join('/'),
+ notModifiedSince: lastModified,
+ eTag,
+ };
+
+ const destination: CopyInput['destination'] = {
+ bucket,
+ expectedBucketOwner,
+ path: destinationPath,
+ };
+
+ const result = copy({
+ source,
+ destination,
+ options: { locationCredentialsProvider: credentials, customEndpoint },
+ });
+
+ return {
+ result: result
+ .then(() => ({ status: 'COMPLETE' as const }))
+ .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })),
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts
new file mode 100644
index 00000000000..4ba599a96c4
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/createFolder.ts
@@ -0,0 +1,64 @@
+import { uploadData } from '../../storage-internal';
+import { isFunction } from '@aws-amplify/ui';
+
+import {
+ TaskData,
+ TaskHandler,
+ TaskHandlerInput,
+ TaskHandlerOutput,
+ TaskHandlerOptions,
+} from './types';
+import { constructBucket, getProgress } from './utils';
+
+export interface CreateFolderHandlerData extends TaskData {}
+export interface CreateFolderHandlerOptions extends TaskHandlerOptions {
+ preventOverwrite?: boolean;
+}
+
+export interface CreateFolderHandlerInput
+ extends TaskHandlerInput<
+ CreateFolderHandlerData,
+ CreateFolderHandlerOptions
+ > {
+ destinationPrefix: string;
+}
+
+export interface CreateFolderHandlerOutput extends TaskHandlerOutput {}
+
+export interface CreateFolderHandler
+ extends TaskHandler {}
+
+export const createFolderHandler: CreateFolderHandler = (input) => {
+ const { destinationPrefix, config, data, options } = input;
+ const { accountId, credentials, customEndpoint } = config;
+ const { onProgress, preventOverwrite } = options ?? {};
+ const { key } = data;
+
+ const bucket = constructBucket(config);
+
+ const { result } = uploadData({
+ path: `${destinationPrefix}${key}`,
+ data: '',
+ options: {
+ bucket,
+ expectedBucketOwner: accountId,
+ locationCredentialsProvider: credentials,
+ customEndpoint,
+ onProgress: (event) => {
+ if (isFunction(onProgress)) onProgress(data, getProgress(event));
+ },
+ preventOverwrite,
+ },
+ });
+
+ return {
+ result: result
+ .then(() => ({ status: 'COMPLETE' as const }))
+ .catch(({ message, name }: Error) => {
+ if (name === 'PreconditionFailed') {
+ return { message, status: 'OVERWRITE_PREVENTED' } as const;
+ }
+ return { message, status: 'FAILED' as const };
+ }),
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts
new file mode 100644
index 00000000000..7ce1b4aaf22
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/delete.ts
@@ -0,0 +1,46 @@
+import { remove } from '../../storage-internal';
+
+import {
+ TaskHandler,
+ TaskHandlerOptions,
+ TaskHandlerInput,
+ TaskHandlerOutput,
+ FileDataItem,
+} from './types';
+
+import { constructBucket } from './utils';
+
+export interface DeleteHandlerOptions extends TaskHandlerOptions {}
+
+export interface DeleteHandlerData extends FileDataItem {}
+
+export interface DeleteHandlerInput
+ extends TaskHandlerInput {}
+
+export interface DeleteHandlerOutput extends TaskHandlerOutput {}
+
+export interface DeleteHandler
+ extends TaskHandler {}
+
+export const deleteHandler: DeleteHandler = ({
+ config,
+ data: { key },
+}): DeleteHandlerOutput => {
+ const { accountId, credentials, customEndpoint } = config;
+
+ const result = remove({
+ path: key,
+ options: {
+ bucket: constructBucket(config),
+ locationCredentialsProvider: credentials,
+ expectedBucketOwner: accountId,
+ customEndpoint,
+ },
+ });
+
+ return {
+ result: result
+ .then(() => ({ status: 'COMPLETE' as const }))
+ .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })),
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts
new file mode 100644
index 00000000000..63cb0f7e3ac
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts
@@ -0,0 +1,64 @@
+import { getUrl } from '../../storage-internal';
+import {
+ FileDataItem,
+ TaskHandler,
+ TaskHandlerInput,
+ TaskHandlerOptions,
+ TaskHandlerOutput,
+} from './types';
+
+import { constructBucket } from './utils';
+
+export interface DownloadHandlerData extends FileDataItem {}
+export interface DownloadHandlerOptions extends TaskHandlerOptions {}
+
+export interface DownloadHandlerInput
+ extends TaskHandlerInput {}
+
+export interface DownloadHandlerOutput extends TaskHandlerOutput {}
+
+export interface DownloadHandler
+ extends TaskHandler {}
+
+function downloadFromUrl(fileName: string, url: string) {
+ const a = document.createElement('a');
+
+ a.href = url;
+ a.download = fileName;
+ a.target = '_blank';
+ document.body.appendChild(a);
+
+ a.click();
+
+ document.body.removeChild(a);
+}
+
+export const downloadHandler: DownloadHandler = ({
+ config,
+ data: { key },
+}): DownloadHandlerOutput => {
+ const { accountId, credentials, customEndpoint } = config;
+
+ const result = getUrl({
+ path: key,
+ options: {
+ bucket: constructBucket(config),
+ customEndpoint,
+ locationCredentialsProvider: credentials,
+ validateObjectExistence: true,
+ contentDisposition: 'attachment',
+ expectedBucketOwner: accountId,
+ },
+ }).then((result) => {
+ return result;
+ });
+
+ return {
+ result: result
+ .then(({ url }) => {
+ downloadFromUrl(key, url.toString());
+ return { status: 'COMPLETE' as const };
+ })
+ .catch(({ message }: Error) => ({ message, status: 'FAILED' as const })),
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts
new file mode 100644
index 00000000000..c093f1ba0d7
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts
@@ -0,0 +1,9 @@
+export * from './copy';
+export * from './createFolder';
+export * from './delete';
+export * from './download';
+export * from './listLocationItems';
+export * from './listLocations';
+export * from './upload';
+export * from './utils';
+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
new file mode 100644
index 00000000000..2fe3c8f38dd
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts
@@ -0,0 +1,156 @@
+import {
+ list,
+ StorageSubpathStrategy,
+ ListPaginateInput,
+ ListOutput,
+} from '../../storage-internal';
+import {
+ ListHandler,
+ ListHandlerInput,
+ ListHandlerOptions,
+ ListHandlerOutput,
+ LocationItemData,
+} from './types';
+
+const DEFAULT_PAGE_SIZE = 1000;
+
+type ListOutputItem = ListOutput['items'][number];
+
+export type LocationItemType = LocationItemData['type'];
+
+export interface ListLocationItemsHandlerOptions
+ extends ListHandlerOptions {
+ delimiter?: string;
+ query?: string;
+}
+
+export interface ListLocationItemsHandlerInput
+ extends ListHandlerInput {}
+
+export interface ListLocationItemsHandlerOutput
+ extends ListHandlerOutput {}
+
+export interface ListLocationItemsHandler
+ extends ListHandler<
+ ListLocationItemsHandlerInput,
+ ListLocationItemsHandlerOutput
+ > {}
+
+const parseItems = (
+ items: ListOutputItem[],
+ excludedPath: string
+): LocationItemData[] =>
+ items
+ // remove root `key` from results
+ .filter(({ path }) => path !== excludedPath)
+ .map(({ path: key, lastModified, size, eTag }) => {
+ const id = crypto.randomUUID();
+ // Mark zero byte files as Folders
+ if (size === 0 && key.endsWith('/')) {
+ return { key, id, type: 'FOLDER' };
+ }
+
+ return {
+ key,
+ id,
+ eTag,
+ lastModified: lastModified!,
+ size: size!,
+ type: 'FILE',
+ };
+ });
+
+const parseExcludedPaths = (paths: string[] | undefined): LocationItemData[] =>
+ paths?.map((key) => ({ key, id: crypto.randomUUID(), type: 'FOLDER' })) ?? [];
+
+export const filterDotItems = (
+ items: LocationItemData[],
+ prefix: string
+): LocationItemData[] =>
+ items.filter((item) => {
+ const key = (
+ item.key.startsWith(prefix) ? item.key.substring(prefix.length) : item.key
+ ).trim();
+ // matches object keys that would cause problems either as folder names in navigation (`/`, `./`, `../`) or as objects (`.`, `..`)
+ return !(
+ key === '/' ||
+ key === './' ||
+ key === '../' ||
+ key === '.' ||
+ key === '..'
+ );
+ });
+
+export const parseResult = (
+ { excludedSubpaths, items }: ListOutput,
+ prefix: string
+): LocationItemData[] =>
+ filterDotItems(
+ [...parseExcludedPaths(excludedSubpaths), ...parseItems(items, prefix)],
+ prefix
+ );
+
+export const listLocationItemsHandler: ListLocationItemsHandler = async (
+ input
+) => {
+ const { config, prefix, options } = input;
+ const {
+ bucket: _bucket,
+ credentials,
+ customEndpoint,
+ region,
+ accountId,
+ } = config;
+
+ const {
+ exclude,
+ delimiter,
+ nextToken,
+ pageSize: _pageSize = DEFAULT_PAGE_SIZE,
+ ..._options
+ } = options ?? {};
+
+ const bucket = { bucketName: _bucket, region };
+ const subpathStrategy: StorageSubpathStrategy = {
+ delimiter,
+ strategy: delimiter ? 'exclude' : 'include',
+ };
+
+ // `ListObjectsV2` returns the root `key` on initial request, which is from
+ // filtered from `results` by `parseResult`, creatimg a scenario where the
+ // return count of `results` to be one item less than provided the `pageSize`.
+ // To mitigate, if a `pageSize` is provided and there are no previous `results`
+ // or `refresh` is `true` increment the provided `pageSize` by `1`
+ const hasOffset = !nextToken;
+ const pageSize = hasOffset ? _pageSize + 1 : _pageSize;
+
+ const result: LocationItemData[] = [];
+ let nextNextToken = nextToken;
+
+ do {
+ const listInput: ListPaginateInput = {
+ path: prefix,
+ options: {
+ nextToken: nextNextToken,
+ ..._options,
+ bucket,
+ customEndpoint,
+ expectedBucketOwner: accountId,
+ locationCredentialsProvider: credentials,
+ pageSize,
+ subpathStrategy,
+ },
+ };
+
+ const output = await list(listInput);
+ nextNextToken = output.nextToken;
+
+ const items = parseResult(output, prefix);
+
+ result.push(
+ ...(exclude ? items.filter((item) => item.type !== exclude) : items)
+ );
+ } while (nextNextToken && result.length < pageSize);
+
+ return { items: result, nextToken: nextNextToken };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts
new file mode 100644
index 00000000000..b85ba3c3460
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocations.ts
@@ -0,0 +1,90 @@
+import {
+ listCallerAccessGrants,
+ LocationCredentialsProvider,
+} from '../../storage-internal';
+import { assertAccountId } from '../../validators';
+
+import {
+ ListHandlerOptions,
+ ListHandler,
+ ListLocationsExcludeOptions,
+ LocationData,
+} from './types';
+import { getFilteredLocations } from './utils';
+
+const DEFAULT_PAGE_SIZE = 1000;
+
+export interface ListLocationsOptions extends ListLocationsHandlerOptions {}
+
+export interface ListLocationsInput {
+ options?: ListLocationsOptions;
+}
+
+export interface ListLocationsOutput {
+ items: LocationData[];
+ nextToken: string | undefined;
+}
+
+// `ListLocations` and its associated input/output types are the types
+// used `Config` option of `CreateStorageBrowser` that do not require
+// `config` values as they are provided through higher-order functions
+// defined in the default and managed auth adapters
+export interface ListLocations
+ extends ListHandler {}
+
+export interface ListLocationsHandlerOptions
+ extends ListHandlerOptions {}
+
+export interface ListLocationsHandlerInput {
+ options?: ListLocationsHandlerOptions;
+ config: {
+ accountId?: string;
+ credentials: LocationCredentialsProvider;
+ customEndpoint?: string;
+ region: string;
+ };
+}
+
+export interface ListLocationsHandlerOutput {
+ items: LocationData[];
+ nextToken: string | undefined;
+}
+
+export interface ListLocationsHandler
+ extends ListHandler {}
+
+export const listLocationsHandler: ListLocationsHandler = async (input) => {
+ const { config, options } = input;
+ const { accountId, credentials, customEndpoint, region } = config;
+ const { exclude, nextToken, pageSize = DEFAULT_PAGE_SIZE } = options ?? {};
+
+ const fetchLocations = async (
+ accumulatedItems: LocationData[],
+ locationsNextToken: ListLocationsOutput['nextToken']
+ ): Promise => {
+ const remainingPageSize = pageSize - accumulatedItems.length;
+
+ assertAccountId(accountId);
+
+ const output = await listCallerAccessGrants({
+ accountId,
+ credentialsProvider: credentials,
+ customEndpoint,
+ nextToken: locationsNextToken,
+ pageSize: remainingPageSize,
+ region,
+ });
+
+ const parsedOutput = getFilteredLocations(output.locations, exclude);
+
+ const items = [...accumulatedItems, ...parsedOutput];
+
+ if (output.nextToken && items.length < pageSize) {
+ return fetchLocations(items, output.nextToken);
+ }
+
+ return { items, nextToken: output.nextToken };
+ };
+
+ return fetchLocations([], nextToken);
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts
new file mode 100644
index 00000000000..25fd7df1abc
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/types.ts
@@ -0,0 +1,127 @@
+import { LocationCredentialsProvider } from '../../storage-internal';
+
+export type LocationType = 'OBJECT' | 'PREFIX' | 'BUCKET';
+
+export type LocationPermissions = ('delete' | 'get' | 'list' | 'write')[];
+
+/**
+ * `location` metadata
+ */
+export interface LocationData {
+ /**
+ * `location` s3 bucket
+ */
+ bucket: string;
+
+ /**
+ * Unique identifier
+ */
+ id: string;
+
+ /**
+ * `location` permission granted to user
+ */
+ permissions: LocationPermissions;
+
+ /**
+ * `location` base prefix, delimited by `'/'`. Empty string indicates bucket root
+ */
+ prefix: string;
+
+ /**
+ * `location` grant scope
+ *
+ * @type "OBJECT" | "PREFIX" | "BUCKET"
+ */
+ type: LocationType;
+}
+
+export interface FolderData {
+ key: string;
+ id: string;
+ type: 'FOLDER';
+}
+
+export interface FileData {
+ eTag?: string;
+ key: string;
+ lastModified: Date;
+ id: string;
+ size: number;
+ type: 'FILE';
+}
+
+export type LocationItemData = FileData | FolderData;
+
+export interface FileDataItem extends FileData, TaskData {
+ fileKey: string;
+}
+
+export interface FileItem extends TaskData {
+ file: File;
+}
+
+export interface ActionInputConfig {
+ accountId?: string;
+ bucket: string;
+ credentials: LocationCredentialsProvider;
+ customEndpoint?: string;
+ region: string;
+}
+
+interface ActionInput {
+ config: ActionInputConfig;
+ prefix: string;
+ options?: T;
+}
+
+export interface TaskData {
+ key: string;
+ id: string;
+}
+
+export interface TaskHandlerOptions {
+ onProgress?: (
+ data: { key: string; id: string },
+ progress: number | undefined
+ ) => void;
+}
+
+export interface TaskHandlerInput<
+ T extends TaskData = TaskData,
+ K extends TaskHandlerOptions = TaskHandlerOptions,
+> {
+ config: ActionInputConfig;
+ data: T;
+ options?: K;
+}
+
+export interface TaskHandlerOutput {
+ cancel?: () => void;
+ result: Promise<{
+ message?: string;
+ status: 'CANCELED' | 'COMPLETE' | 'FAILED' | 'OVERWRITE_PREVENTED';
+ }>;
+}
+
+export type TaskHandler = (input: T) => K;
+
+export interface ListHandlerOptions {
+ exclude?: T;
+ nextToken?: string;
+ pageSize?: number;
+}
+
+export interface ListHandlerInput extends ActionInput {}
+
+export interface ListHandlerOutput {
+ nextToken: string | undefined;
+ items: T[];
+}
+
+export type ListHandler = (input: T) => Promise;
+
+export interface ListLocationsExcludeOptions {
+ exactPermissions?: LocationPermissions;
+ type?: LocationType | LocationType[];
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts
new file mode 100644
index 00000000000..d42b7b7ef38
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/upload.ts
@@ -0,0 +1,89 @@
+import { isCancelError } from 'aws-amplify/storage';
+import { isFunction } from '@aws-amplify/ui';
+
+import { uploadData, UploadDataInput } from '../../storage-internal';
+
+import {
+ TaskData,
+ TaskHandler,
+ TaskHandlerInput,
+ TaskHandlerOutput,
+ TaskHandlerOptions,
+} from './types';
+
+import { constructBucket, getProgress } from './utils';
+
+export interface UploadHandlerOptions extends TaskHandlerOptions {
+ preventOverwrite?: boolean;
+}
+
+export interface UploadHandlerData extends TaskData {
+ file: File;
+}
+
+export interface UploadHandlerInput
+ extends TaskHandlerInput {
+ destinationPrefix: string;
+}
+
+export interface UploadHandlerOutput extends TaskHandlerOutput {}
+
+export interface UploadHandler
+ extends TaskHandler {}
+
+// 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;
+
+export const UNDEFINED_CALLBACKS = {
+ cancel: undefined,
+ pause: undefined,
+ resume: undefined,
+};
+
+export const uploadHandler: UploadHandler = ({
+ config,
+ data,
+ destinationPrefix,
+ options,
+}) => {
+ const { accountId, credentials, customEndpoint } = config;
+ const { key, file } = data;
+ const { onProgress, preventOverwrite } = options ?? {};
+
+ const input: UploadDataInput = {
+ path: `${destinationPrefix}${key}`,
+ data: file,
+ options: {
+ bucket: constructBucket(config),
+ expectedBucketOwner: accountId,
+ locationCredentialsProvider: credentials,
+ onProgress: (event) => {
+ if (isFunction(onProgress)) onProgress(data, getProgress(event));
+ },
+ preventOverwrite,
+ customEndpoint,
+ checksumAlgorithm: 'crc-32',
+ },
+ };
+
+ const { cancel, pause, resume, result } = uploadData(input);
+
+ return {
+ ...(file.size > MULTIPART_UPLOAD_THRESHOLD_BYTES
+ ? { cancel, pause, resume }
+ : UNDEFINED_CALLBACKS),
+ result: result
+ .then(() => ({ status: 'COMPLETE' as const }))
+ .catch((error: Error) => {
+ const { message } = error;
+ if (error.name === 'PreconditionFailed') {
+ return { message, status: 'OVERWRITE_PREVENTED' as const };
+ }
+ return {
+ message,
+ status: isCancelError(error) ? 'CANCELED' : 'FAILED',
+ };
+ }),
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts
new file mode 100644
index 00000000000..99610f61541
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/utils.ts
@@ -0,0 +1,172 @@
+import { TransferProgressEvent } from 'aws-amplify/storage';
+import { LocationAccess as AccessGrantLocation } from '../../storage-internal';
+import { ListLocationsExcludeOptions } from './types';
+
+import {
+ ActionInputConfig,
+ FileData,
+ FileDataItem,
+ FileItem,
+ LocationData,
+ LocationPermissions,
+ LocationType,
+} from './types';
+
+export const constructBucket = ({
+ bucket: bucketName,
+ region,
+}: Pick): {
+ bucketName: string;
+ region: string;
+} => ({ bucketName, region });
+
+export const parseAccessGrantLocation = (
+ location: AccessGrantLocation
+): LocationData => {
+ const { permission, scope, type } = location;
+ if (!scope.startsWith('s3://')) {
+ throw new Error(`Invalid scope: ${scope}`);
+ }
+
+ const id = crypto.randomUUID();
+
+ // remove default path
+ const slicedScope = scope.slice(5);
+ let bucket, prefix;
+
+ switch (type) {
+ case 'BUCKET': {
+ // { scope: 's3://bucket/*', type: 'BUCKET', },
+ bucket = slicedScope.slice(0, -2);
+ prefix = '';
+ break;
+ }
+ case 'PREFIX': {
+ // { scope: 's3://bucket/path/*', type: 'PREFIX', },
+ bucket = slicedScope.slice(0, slicedScope.indexOf('/'));
+ prefix = `${slicedScope.slice(bucket.length + 1, -1)}`;
+ break;
+ }
+ case 'OBJECT': {
+ // { scope: 's3://bucket/path/to/object', type: 'OBJECT', },
+ bucket = slicedScope.slice(0, slicedScope.indexOf('/'));
+ prefix = slicedScope.slice(bucket.length + 1);
+ break;
+ }
+ default: {
+ throw new Error(`Invalid location type: ${type}`);
+ }
+ }
+
+ let permissions: LocationPermissions;
+ switch (permission) {
+ case 'READ':
+ permissions = ['get', 'list'];
+ break;
+ case 'READWRITE':
+ permissions = ['delete', 'get', 'list', 'write'];
+ break;
+ case 'WRITE':
+ permissions = ['delete', 'write'];
+ break;
+ default:
+ throw new Error(`Invalid location permission: ${permission}`);
+ }
+
+ return { bucket, id, permissions: permissions, prefix, type };
+};
+
+const isSamePermissions = (
+ permissionsToExclude: LocationPermissions,
+ locationPermissions: LocationPermissions
+) => {
+ if (permissionsToExclude.length !== locationPermissions.length) {
+ return false;
+ }
+ const sortedLocationPermissions = locationPermissions.sort();
+ return permissionsToExclude
+ .sort()
+ .every(
+ (permission, index) => permission === sortedLocationPermissions[index]
+ );
+};
+
+const isSameType = (
+ typeToExclude: LocationType | LocationType[],
+ locationType: LocationType
+) =>
+ typeof typeToExclude === 'string'
+ ? typeToExclude === locationType
+ : typeToExclude.includes(locationType);
+
+export const shouldExcludeLocation = (
+ { permissions, type }: LocationData,
+ exclude?: ListLocationsExcludeOptions
+): boolean => {
+ const excludedByPermssions = !!(
+ exclude?.exactPermissions &&
+ isSamePermissions(exclude.exactPermissions, permissions)
+ );
+
+ const excludedByType = !!(exclude?.type && isSameType(exclude.type, type));
+
+ return excludedByPermssions || excludedByType;
+};
+
+export const getFilteredLocations = (
+ locations: AccessGrantLocation[],
+ exclude?: ListLocationsExcludeOptions
+): LocationData[] =>
+ locations.reduce(
+ (filteredLocations: LocationData[], location: AccessGrantLocation) => {
+ const parsedLocation = parseAccessGrantLocation(location);
+
+ const isNonFolderLikePrefix =
+ !parsedLocation.prefix.endsWith('/') &&
+ parsedLocation.type === 'PREFIX';
+
+ if (isNonFolderLikePrefix) {
+ return filteredLocations;
+ }
+
+ if (!shouldExcludeLocation(parsedLocation, exclude)) {
+ filteredLocations.push(parsedLocation);
+ }
+
+ return filteredLocations;
+ },
+ []
+ );
+
+export const getFileKey = (key: string): string =>
+ key.slice(key.lastIndexOf('/') + 1, key.length);
+
+export const createFileDataItem = (data: FileData): FileDataItem => ({
+ ...data,
+ fileKey: getFileKey(data.key),
+});
+
+export const createFileDataItemFromLocation = (
+ data: LocationData
+): FileDataItem => ({
+ id: data.id,
+ type: 'FILE',
+ key: data.prefix,
+ fileKey: getFileKey(data.prefix),
+ // `lastModified` and `size` included to satisfy
+ // expected shape of `FileDataItem`
+ lastModified: new Date(),
+ size: 0,
+});
+
+export const isFileItem = (value: unknown): value is FileItem =>
+ !!(value as FileItem).file;
+
+export const isFileDataItem = (item: unknown): item is FileDataItem =>
+ !!(item as FileDataItem).fileKey;
+
+export const getProgress = ({
+ totalBytes,
+ transferredBytes,
+}: TransferProgressEvent): number | undefined =>
+ totalBytes ? transferredBytes / totalBytes : undefined;
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/index.ts
new file mode 100644
index 00000000000..a6fb6f1f009
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/index.ts
@@ -0,0 +1,85 @@
+export {
+ ActionConfigs,
+ ActionConfigsProvider,
+ ActionConfigsProviderProps,
+ ComponentName,
+ defaultActionConfigs,
+ DefaultActionConfigs,
+ defaultActionViewConfigs,
+ DefaultActionKey,
+ isDefaultActionViewType,
+ SelectionType,
+ TaskActionConfig,
+ useActionConfig,
+} from './configs';
+
+export {
+ ActionInputConfig,
+ copyHandler,
+ CopyHandler,
+ CopyHandlerData,
+ CopyHandlerInput,
+ CopyHandlerOutput,
+ createFileDataItemFromLocation,
+ createFileDataItem,
+ createFolderHandler,
+ CreateFolderHandler,
+ CreateFolderHandlerData,
+ CreateFolderHandlerInput,
+ CreateFolderHandlerOptions,
+ CreateFolderHandlerOutput,
+ downloadHandler,
+ DownloadHandler,
+ DownloadHandlerData,
+ DownloadHandlerInput,
+ DownloadHandlerOptions,
+ DownloadHandlerOutput,
+ DeleteHandler,
+ DeleteHandlerData,
+ DeleteHandlerInput,
+ DeleteHandlerOptions,
+ DeleteHandlerOutput,
+ FileData,
+ FileDataItem,
+ FileItem,
+ FolderData,
+ isFileDataItem,
+ isFileItem,
+ ListHandler,
+ ListHandlerInput,
+ ListHandlerOptions,
+ ListHandlerOutput,
+ listLocationItemsHandler,
+ ListLocationItemsHandler,
+ ListLocationItemsHandlerInput,
+ ListLocationItemsHandlerOptions,
+ ListLocationItemsHandlerOutput,
+ ListLocationsExcludeOptions,
+ ListLocations,
+ ListLocationsInput,
+ ListLocationsOutput,
+ listLocationsHandler,
+ ListLocationsHandler,
+ ListLocationsHandlerInput,
+ ListLocationsHandlerOptions,
+ ListLocationsHandlerOutput,
+ LocationData,
+ LocationItemData,
+ LocationPermissions,
+ LocationType,
+ TaskData,
+ TaskHandler,
+ TaskHandlerInput,
+ TaskHandlerOptions,
+ TaskHandlerOutput,
+ uploadHandler,
+ UploadHandler,
+ UploadHandlerData,
+ UploadHandlerInput,
+ UploadHandlerOptions,
+ UploadHandlerOutput,
+} from './handlers';
+
+export { ActionState } from './types';
+
+export { useListLocations, UseListLocationsState } from './useAction';
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/types.ts b/packages/react-storage/src/components/StorageBrowser/actions/types.ts
new file mode 100644
index 00000000000..7a09f7e1089
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/types.ts
@@ -0,0 +1,18 @@
+import { DataState } from '@aws-amplify/ui-react-core';
+
+import { ListHandler, TaskHandler } from './handlers';
+
+export type ActionState = [
+ state: DataState,
+ handleAction: (...input: K[]) => void,
+];
+
+export type UseAction = T extends
+ | ListHandler
+ | TaskHandler
+ ? ActionState
+ : never;
+
+export type CreateUseAction = (
+ actions: T
+) => (key: K) => UseAction;
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts
new file mode 100644
index 00000000000..217a169a317
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/createEnhancedListHandler.spec.ts
@@ -0,0 +1,255 @@
+import {
+ createEnhancedListHandler,
+ SEARCH_LIMIT,
+} from '../createEnhancedListHandler';
+import {
+ ActionInputConfig,
+ ListHandler,
+ ListHandlerInput,
+ ListHandlerOutput,
+} from '../../handlers';
+
+const mockAction = jest.fn();
+
+const config: ActionInputConfig = {
+ bucket: 'bucky',
+ credentials: jest.fn(),
+ region: 'us-west-1',
+};
+type Output = ListHandlerOutput<{
+ name: string;
+ alt: string;
+ id: number;
+}>;
+
+type Handler = ListHandler;
+
+describe('createEnhancedListHandler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return an empty state when reset is true', async () => {
+ const handler = createEnhancedListHandler(mockAction);
+ const prevState = { items: [{ id: 1 }], nextToken: 'abc' };
+ const options = { reset: true };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'a_prefix',
+ options,
+ });
+
+ expect(result).toEqual({ items: [], nextToken: undefined });
+ });
+
+ it('should collect and filter results when search and prefix is provided', async () => {
+ mockAction
+ .mockResolvedValueOnce({
+ items: [{ name: 'a_prefix/apple' }, { name: 'a_prefix/banana' }],
+ nextToken: 'next',
+ })
+ .mockResolvedValueOnce({
+ items: [{ name: 'a_prefix/cherry' }, { name: 'a_prefix/date' }],
+ nextToken: null,
+ });
+
+ const handler = createEnhancedListHandler(mockAction as Handler);
+ const prevState = { items: [], nextToken: undefined };
+ const options = {
+ search: { query: 'a', filterBy: 'name' as const },
+ };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'a_prefix',
+ options,
+ });
+
+ expect(result.items).toEqual([
+ { name: 'a_prefix/apple' },
+ { name: 'a_prefix/banana' },
+ { name: 'a_prefix/date' },
+ ]);
+ expect(result.nextToken).toBeUndefined();
+ });
+
+ it('should collect and filter results when search and empty prefix is provided', async () => {
+ mockAction
+ .mockResolvedValueOnce({
+ items: [{ name: 'foo/bar/apple' }, { name: 'banana' }],
+ nextToken: 'next',
+ })
+ .mockResolvedValueOnce({
+ items: [{ name: 'foo/bar/cherry' }, { name: 'date' }],
+ nextToken: null,
+ });
+
+ const handler = createEnhancedListHandler(mockAction as Handler);
+ const prevState = { items: [], nextToken: undefined };
+ const options = {
+ search: { query: 'a', filterBy: 'name' as const },
+ };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: '',
+ options,
+ });
+
+ expect(result.items).toEqual([
+ { name: 'foo/bar/apple' },
+ { name: 'banana' },
+ { name: 'foo/bar/cherry' },
+ { name: 'date' },
+ ]);
+ expect(result.nextToken).toBeUndefined();
+ expect(result.search?.hasExhaustedSearch).toBeFalsy();
+ });
+
+ it('should collect and filter results using a filter key function', async () => {
+ mockAction
+ .mockResolvedValueOnce({
+ items: [
+ { name: 'foo/bar/apple', alt: 'cosmic-crisp' },
+ { name: 'banana', alt: '' },
+ ],
+ nextToken: 'next',
+ })
+ .mockResolvedValueOnce({
+ items: [
+ { name: 'melon', alt: 'aka-melon' },
+ { name: 'foo/bar/cherry', alt: '' },
+ { name: 'date', alt: '' },
+ ],
+ nextToken: null,
+ });
+
+ const handler = createEnhancedListHandler(mockAction as Handler);
+ const prevState = { items: [], nextToken: undefined };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: '',
+ options: {
+ search: {
+ query: 'a',
+ filterBy: (item) => {
+ return item.alt ? 'alt' : 'name';
+ },
+ },
+ },
+ });
+
+ expect(result.items).toEqual([
+ expect.objectContaining({ name: 'banana' }),
+ expect.objectContaining({ name: 'melon', alt: 'aka-melon' }),
+ expect.objectContaining({ name: 'foo/bar/cherry' }),
+ expect.objectContaining({ name: 'date' }),
+ ]);
+
+ expect(result.nextToken).toBeUndefined();
+ expect(result.search?.hasExhaustedSearch).toBeFalsy();
+ });
+
+ it('should collect and filter results when search and duplicate prefix is provided', async () => {
+ mockAction.mockResolvedValueOnce({
+ items: [{ name: 'foo/bar/cherry' }, { name: 'foo/date/foo' }],
+ nextToken: null,
+ });
+
+ const handler = createEnhancedListHandler(mockAction as Handler);
+ const prevState = { items: [], nextToken: undefined };
+ const options = {
+ search: { query: 'foo', filterBy: 'name' as const },
+ };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'foo/',
+ options,
+ });
+
+ expect(result.items).toEqual([{ name: 'foo/date/foo' }]);
+ expect(result.nextToken).toBeUndefined();
+ });
+
+ it('should stop collecting results when SEARCH_LIMIT is reached', async () => {
+ const mockItems = new Array(SEARCH_LIMIT).fill({ name: 'a_prefix/item' });
+ mockAction
+ .mockResolvedValueOnce({
+ items: mockItems.slice(0, SEARCH_LIMIT / 2),
+ nextToken: 'token',
+ })
+ .mockResolvedValueOnce({
+ items: mockItems.slice(SEARCH_LIMIT / 2),
+ nextToken: 'token2',
+ })
+ .mockResolvedValueOnce({ items: mockItems, nextToken: 'token3' });
+
+ const handler = createEnhancedListHandler(mockAction as Handler);
+ const prevState = { items: [], nextToken: undefined };
+ const options = {
+ search: { query: 'item', filterBy: 'name' as const },
+ };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'a_prefix',
+ options,
+ });
+
+ expect(mockAction).toHaveBeenCalledTimes(2);
+ expect(result.items.length).toBe(SEARCH_LIMIT);
+ expect(result.nextToken).toBeUndefined();
+ expect(result.search?.hasExhaustedSearch).toBeTruthy();
+
+ mockAction.mockReset();
+ });
+
+ it('should ignore provided nextToken on refresh', async () => {
+ mockAction.mockResolvedValue({
+ items: [{ id: 1 }, { id: 2 }],
+ nextToken: 'next',
+ });
+
+ const handler = createEnhancedListHandler(mockAction);
+ const prevState = { items: [{ id: 0 }], nextToken: 'abc' };
+ const options = { refresh: true, nextToken: 'token' };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'a_prefix',
+ options,
+ });
+
+ expect(mockAction).toHaveBeenCalledWith({
+ config,
+ prefix: 'a_prefix',
+ options: { nextToken: undefined },
+ });
+
+ expect(result.items).toEqual([{ id: 1 }, { id: 2 }]);
+ expect(result.nextToken).toBe('next');
+ });
+
+ it('should append items when refresh is false', async () => {
+ mockAction.mockResolvedValue({
+ items: [{ id: 2 }, { id: 3 }],
+ nextToken: 'next',
+ });
+
+ const handler = createEnhancedListHandler(mockAction);
+ const prevState = { items: [{ id: 1 }], nextToken: 'abc' };
+ const options = { refresh: false };
+
+ const result = await handler(prevState, {
+ config,
+ prefix: 'a_prefix',
+ options,
+ });
+
+ expect(result.items).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ expect(result.nextToken).toBe('next');
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts
new file mode 100644
index 00000000000..78a8af4b6eb
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/__tests__/search.spec.ts
@@ -0,0 +1,430 @@
+import { searchItems } from '../createEnhancedListHandler';
+
+let uuid = 0;
+Object.defineProperty(globalThis, 'crypto', {
+ value: {
+ randomUUID: () => {
+ uuid++;
+ return uuid.toString();
+ },
+ },
+});
+
+interface Item {
+ key: string;
+}
+
+describe('Search', () => {
+ it('should handle empty lists', () => {
+ const result = searchItems({
+ prefix: '',
+ list: [],
+ options: {
+ query: 'test',
+ filterBy: 'path',
+ groupBy: '/',
+ },
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('should return all items matching the prefix when the query is empty', () => {
+ const mockData = [{ key: 'folder/file1.txt' }, { key: 'folder/file2.txt' }];
+
+ const result = searchItems({
+ prefix: 'folder/',
+ list: mockData,
+ options: {
+ query: '',
+ filterBy: 'key',
+ groupBy: '/',
+ },
+ });
+
+ expect(result).toEqual([
+ { id: expect.any(String), key: 'folder/file1.txt', type: 'FILE' },
+ { id: expect.any(String), key: 'folder/file2.txt', type: 'FILE' },
+ ]);
+ });
+
+ it('should return an empty array when no items match the query', () => {
+ const list = [{ key: '/folder/file1.txt' }, { key: '/folder/file2.txt' }];
+
+ const result = searchItems({
+ prefix: '/folder',
+ list,
+ options: {
+ query: 'nonexistent',
+ filterBy: 'key',
+ groupBy: '/',
+ },
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ describe('group by', () => {
+ it('should handle root level objects', () => {
+ const mockData = [
+ {
+ key: 'photo1.jpg',
+ },
+ {
+ key: 'photos/',
+ },
+ {
+ key: 'pic.jpg',
+ },
+ ];
+
+ const output = searchItems- ({
+ list: mockData,
+ prefix: '',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photo',
+ },
+ });
+ expect(output).toEqual([
+ { id: expect.any(String), key: 'photo1.jpg', type: 'FILE' },
+ { id: expect.any(String), key: 'photos/', type: 'FOLDER' },
+ ]);
+ });
+
+ it('should handle single level structures', () => {
+ const mockData = [
+ {
+ key: 'collections/photo1.jpg',
+ },
+ {
+ key: 'collections/photo2.jpg',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photo',
+ },
+ });
+
+ expect(output).toEqual([
+ { id: expect.any(String), key: 'collections/photo1.jpg', type: 'FILE' },
+ { id: expect.any(String), key: 'collections/photo2.jpg', type: 'FILE' },
+ ]);
+ });
+
+ it('should handle multi level structures', () => {
+ const mockData = [
+ {
+ key: 'collections/photos/beach.jpg',
+ },
+ {
+ key: 'collections/photos/pic.jpg',
+ },
+ {
+ key: 'collections/photos/hawaii/beaches/beach.jpg',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'beach',
+ },
+ });
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections/photos/beach.jpg',
+ type: 'FILE',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/photos/hawaii/beaches/',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/photos/hawaii/beaches/beach.jpg',
+ type: 'FILE',
+ },
+ ]);
+ });
+
+ it('de-dupes paths', () => {
+ const mockData = [
+ {
+ key: 'collections/photos/beach.jpg',
+ },
+ {
+ key: 'collections/photos/pic.jpg',
+ },
+ {
+ key: 'collections/photos/hawaii/beaches/beach.jpg',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photos',
+ },
+ });
+ expect(output).toEqual([
+ { id: expect.any(String), key: 'collections/photos/', type: 'FOLDER' },
+ ]);
+ });
+
+ it('handles complex group-by delimiters', () => {
+ const mockData = [
+ {
+ key: 'collections<.>photos<.>beach.jpg',
+ },
+ {
+ key: 'collections<.>photos<.>pic.jpg',
+ },
+ {
+ key: 'collections<.>beaches<.>beach<.>',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '<.>',
+ query: 'beach',
+ },
+ });
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections<.>photos<.>beach.jpg',
+ type: 'FILE',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections<.>beaches<.>',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections<.>beaches<.>beach<.>',
+ type: 'FOLDER',
+ },
+ ]);
+ });
+
+ it('should only match paths ahead of prefix', () => {
+ const mockData = [
+ {
+ key: 'photos/album/random-photos/photo1.jpg',
+ },
+ {
+ key: 'photos/album/cats/pic.jpg',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'photos/album/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photo',
+ },
+ });
+
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'photos/album/random-photos/',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'photos/album/random-photos/photo1.jpg',
+ type: 'FILE',
+ },
+ ]);
+ });
+
+ it('should handle consecutive delimiters', () => {
+ const mockData = [
+ {
+ key: 'collections////photo1.jpg',
+ },
+ {
+ key: 'collections/photos///',
+ },
+ {
+ key: 'collections//animals//',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photo',
+ },
+ });
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections////photo1.jpg',
+ type: 'FILE',
+ },
+ { id: expect.any(String), key: 'collections/photos/', type: 'FOLDER' },
+ ]);
+ });
+
+ it('should handle special characters', () => {
+ const mockData = [
+ {
+ key: 'collections/special!@#$/photo1.jpg',
+ },
+ {
+ key: 'collections/ photo folder with spaces /',
+ },
+ {
+ key: 'collections/ wildlife\x00photos.jpg ',
+ },
+ {
+ key: 'collections/nomatch',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'photo',
+ },
+ });
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections/special!@#$/photo1.jpg',
+ type: 'FILE',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/ photo folder with spaces /',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/ wildlife\x00photos.jpg ',
+ type: 'FILE',
+ },
+ ]);
+ });
+
+ it('should be case insensitive', () => {
+ const mockData = [
+ {
+ key: 'collections/seattle/Cafè.jpg',
+ },
+ {
+ key: 'collections/ Ca\uFB00e\x01photos.jpg ',
+ },
+ {
+ key: 'collections/album/CaFe/',
+ },
+ {
+ key: 'collections/album/random/cafe-vita.png',
+ },
+ {
+ key: 'collections/album/random/pic.jpg',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'càf',
+ },
+ });
+
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections/seattle/Cafè.jpg',
+ type: 'FILE',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/ Ca\uFB00e\x01photos.jpg ',
+ type: 'FILE',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/album/CaFe/',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/album/random/cafe-vita.png',
+ type: 'FILE',
+ },
+ ]);
+ });
+ it('ignores diacritics', () => {
+ const mockData = [
+ {
+ key: 'collections/São Paulo/',
+ },
+ {
+ key: 'collections/random/Sãopaulino.jpg',
+ },
+ {
+ key: 'collections/random/photos.jpg ',
+ },
+ ];
+
+ const output = searchItems
- ({
+ list: mockData,
+ prefix: 'collections/',
+ options: {
+ filterBy: 'key',
+ groupBy: '/',
+ query: 'sao',
+ },
+ });
+
+ expect(output).toEqual([
+ {
+ id: expect.any(String),
+ key: 'collections/São Paulo/',
+ type: 'FOLDER',
+ },
+ {
+ id: expect.any(String),
+ key: 'collections/random/Sãopaulino.jpg',
+ type: 'FILE',
+ },
+ ]);
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts
new file mode 100644
index 00000000000..abb909eec2b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/createEnhancedListHandler.ts
@@ -0,0 +1,218 @@
+import { AsyncDataAction } from '@aws-amplify/ui-react-core';
+
+import {
+ ListHandler,
+ ListHandlerOptions,
+ ListHandlerInput,
+ ListHandlerOutput,
+} from '../handlers';
+
+type KeyWithStringValue
= keyof {
+ [P in keyof T as T[P] extends string ? P : never]: T[P];
+};
+
+interface SearchOptions {
+ query: string;
+ /**
+ * The key of the object in the list to filter by, which must have a string value.
+ * Also accepts a function which takes an item and returns the appropriate key.
+ * This determines where the `query` will be searched.
+ */
+ filterBy: KeyWithStringValue | ((item: T) => KeyWithStringValue);
+
+ /**
+ * Optional delimiter to group item keys.
+ */
+ groupBy?: string;
+}
+
+export interface EnhancedListHandlerOptions
+ extends ListHandlerOptions {
+ refresh?: boolean;
+ reset?: boolean;
+ search?: SearchOptions;
+}
+
+export interface SearchOutput {
+ hasExhaustedSearch: boolean;
+}
+
+export interface EnhancedListHandlerOutput extends ListHandlerOutput {
+ search?: SearchOutput;
+}
+
+export interface EnhancedListHandlerInput
+ extends ListHandlerInput> {}
+
+export interface EnhancedListHandler
+ extends AsyncDataAction<
+ EnhancedListHandlerOutput,
+ EnhancedListHandlerInput
+ > {}
+
+type ListItem = Action extends ListHandler<
+ any,
+ ListHandlerOutput
+>
+ ? T
+ : never;
+
+type Options = Action extends ListHandler<
+ ListHandlerInput>
+>
+ ? E
+ : never;
+
+export const SEARCH_LIMIT = 10000;
+export const SEARCH_PAGE_SIZE = 1000;
+
+/**
+ * Normalizes and converts a string to lower case,
+ * handling Unicode characters and locale-specific case mappings.
+ * Uses NFKD to fully decompose unicode: https://unicode.org/reports/tr15/#Normalization_Forms_Table
+ */
+function normalize(input: string) {
+ return input
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '') // remove diacritic modifiers
+ .toLocaleLowerCase();
+}
+
+/**
+ * Performs a case-insensitive check to determine if a string includes another string,
+ * handling Unicode characters and locale-specific case mappings.
+ *
+ * @param {string} input - The string to search within.
+ * @param {string} query - The substring to search for.
+ * @returns {boolean} - Returns `true` if `query` is found in `input` (case-insensitively), otherwise `false`.
+ *
+ * @example
+ * caseInsensitiveIncludes("Photos", "photo"); // true
+ * caseInsensitiveIncludes("Hello", "HELLO"); // true
+ * caseInsensitiveIncludes("\uFB00", "\u0046\u0046"); // ff = FF true
+ * caseInsensitiveIncludes("Cafè", "cafe"); // true
+ */
+function caseInsensitiveIncludes(input: string, query: string): boolean {
+ return normalize(input).includes(normalize(query));
+}
+
+interface Search {
+ prefix?: string;
+ list: T[];
+ options: SearchOptions;
+}
+
+export function searchItems({ prefix = '', list, options }: Search): T[] {
+ const { query, filterBy, groupBy } = options;
+
+ // filter keys that match `filterBy` search option
+ const filteredItems = list.filter((item) => {
+ const key = typeof filterBy === 'function' ? filterBy(item) : filterBy;
+ const path = item[key] as string;
+ const suffix = path.slice(prefix.length);
+ return caseInsensitiveIncludes(suffix, query);
+ });
+
+ if (!groupBy) {
+ return filteredItems;
+ }
+
+ // group items using the provided grouping delimiter
+ const uniquePaths = new Map();
+
+ for (const item of filteredItems) {
+ const key = typeof filterBy === 'function' ? filterBy(item) : filterBy;
+ const path = item[key] as string;
+ const components = path.split(groupBy);
+
+ for (const [i, component] of components.entries()) {
+ if (!caseInsensitiveIncludes(component, query)) {
+ continue;
+ }
+
+ // list of components ending with match
+ const matchedPathSegments = components.slice(0, i + 1);
+
+ // create new path
+ let matchedPath = matchedPathSegments.join(groupBy);
+ const isFolder = matchedPath !== path;
+ if (isFolder) {
+ matchedPath += groupBy;
+ }
+
+ // ignore anything below the prefix for matching
+ if (matchedPath.length > prefix.length && !uniquePaths.has(matchedPath)) {
+ // add a new item
+ uniquePaths.set(matchedPath, {
+ ...item,
+ id: crypto.randomUUID(),
+ [key]: matchedPath,
+ type: isFolder ? 'FOLDER' : 'FILE',
+ });
+ }
+ }
+ }
+
+ return Array.from(uniquePaths.values());
+}
+
+export const createEnhancedListHandler = (
+ action: Action
+): EnhancedListHandler, Options> => {
+ return async function listActionHandler(prevState, { options, ...input }) {
+ const {
+ nextToken: _nextToken,
+ refresh,
+ reset,
+ search,
+ ...rest
+ } = options ?? {};
+
+ if (reset) {
+ return { items: [], nextToken: undefined };
+ }
+
+ // collect and filter results on `search`
+ if (search) {
+ const result = [];
+ let nextNextToken = undefined;
+ do {
+ const output = (await action({
+ ...input,
+ options: {
+ ...rest,
+ pageSize: SEARCH_PAGE_SIZE,
+ nextToken: nextNextToken,
+ },
+ })) as ListHandlerOutput>;
+ result.push(...output.items);
+ nextNextToken = output.nextToken;
+ } while (nextNextToken && result.length < SEARCH_LIMIT);
+
+ return {
+ items: searchItems({
+ list: result,
+ prefix: input.prefix,
+ options: search,
+ }),
+ search: {
+ // search limit reached but we still have a next token
+ hasExhaustedSearch: !!nextNextToken,
+ },
+ nextToken: undefined,
+ };
+ }
+
+ // ignore provided `nextToken` on `refresh`
+ const nextToken = refresh ? undefined : _nextToken;
+ const output = (await action({
+ ...input,
+ options: { ...rest, nextToken },
+ })) as ListHandlerOutput>;
+
+ return {
+ items: [...(refresh ? [] : prevState.items), ...output.items],
+ nextToken: output.nextToken,
+ };
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts
new file mode 100644
index 00000000000..8b7ee5535f2
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/index.ts
@@ -0,0 +1 @@
+export { useListLocations, UseListLocationsState } from './useListLocations';
diff --git a/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts b/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts
new file mode 100644
index 00000000000..9de7697950e
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/actions/useAction/useListLocations.ts
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { useDataState } from '@aws-amplify/ui-react-core';
+
+import { useActionConfig } from '../configs';
+import {
+ ListLocations,
+ LocationData,
+ ListLocationsExcludeOptions,
+} from '../handlers';
+import { ActionState } from '../types';
+
+import {
+ createEnhancedListHandler,
+ EnhancedListHandlerInput,
+ EnhancedListHandlerOutput,
+} from './createEnhancedListHandler';
+
+// Utility type functioning as a shim to allow for the outputted
+// enhanced `ListLocations` handler to not require `config` and `prefix`
+// in usage, which are required by the signature of `createEnhancedListHandler`
+type RemoveConfigAndPrefix = Omit;
+
+export interface UseListLocationsState
+ extends ActionState<
+ EnhancedListHandlerOutput,
+ RemoveConfigAndPrefix<
+ EnhancedListHandlerInput
+ >
+ > {}
+
+export const useListLocations = (): UseListLocationsState => {
+ const { handler } = useActionConfig('listLocations');
+ const enhancedHandler = React.useMemo(
+ () => createEnhancedListHandler(handler as ListLocations),
+ [handler]
+ );
+
+ return useDataState(enhancedHandler, {
+ items: [],
+ nextToken: undefined,
+ }) as UseListLocationsState;
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/__tests__/permissionParsers.spec.ts b/packages/react-storage/src/components/StorageBrowser/adapters/__tests__/permissionParsers.spec.ts
new file mode 100644
index 00000000000..0e8f371a1d8
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/__tests__/permissionParsers.spec.ts
@@ -0,0 +1,92 @@
+import {
+ parseAccessGrantPermission,
+ toAccessGrantPermission,
+ parseAmplifyAuthPermission,
+} from '../../adapters/permissionParsers';
+import { StorageAccess } from '../../storage-internal';
+
+import { generateCombinations } from '../../actions/__testUtils__/permissions';
+
+describe('parseAccessGrantPermission', () => {
+ it.each([
+ ['READ' as const, ['get', 'list']],
+ ['WRITE' as const, ['delete', 'write']],
+ ['READWRITE' as const, ['delete', 'get', 'list', 'write']],
+ ])('should parse %s to %s', (permission, expected) => {
+ expect(parseAccessGrantPermission(permission)).toEqual(expected);
+ });
+
+ it('should throw an error if the permission is invalid', () => {
+ // @ts-expect-error: test invalid permission
+ expect(() => parseAccessGrantPermission('INVALID')).toThrow(
+ 'Improper Permission: Please provide correct permission.'
+ );
+ });
+});
+
+describe('toAccessGrantPermission', () => {
+ const readAccessCombinations = generateCombinations([
+ 'read',
+ 'list',
+ 'get',
+ ] as StorageAccess[]);
+ const writeAccessCombinations = generateCombinations([
+ 'write',
+ 'delete',
+ ] as StorageAccess[]);
+ const readWriteCombinations = [] as StorageAccess[][];
+ for (const readAccess of readAccessCombinations) {
+ for (const writeAccess of writeAccessCombinations) {
+ readWriteCombinations.push([...readAccess, ...writeAccess]);
+ }
+ }
+ it.each([
+ ...readAccessCombinations.map(
+ (permissions) => [permissions, 'READ'] as const
+ ),
+ ...writeAccessCombinations.map(
+ (permissions) => [permissions, 'WRITE'] as const
+ ),
+ ...readWriteCombinations.map(
+ (permissions) => [permissions, 'READWRITE'] as const
+ ),
+ ])('should parse %s to %s', (permissions, expected) => {
+ expect(toAccessGrantPermission(permissions)).toEqual(expected);
+ });
+
+ it('should throw an error if the permission is invalid', () => {
+ // @ts-expect-error: test invalid permission
+ expect(() => toAccessGrantPermission(['INVALID'])).toThrow(
+ 'Improper Permission: Please provide correct permission.'
+ );
+ });
+});
+
+describe('parseAmplifyAuthPermission', () => {
+ it.each([
+ [['read'], ['get', 'list']],
+ [
+ ['list', 'get'],
+ ['get', 'list'],
+ ],
+ [
+ ['list', 'get', 'delete'],
+ ['delete', 'get', 'list'],
+ ],
+ [
+ ['delete', 'write'],
+ ['delete', 'write'],
+ ],
+ ])('should parse %s to %s', (permission, expected) => {
+ expect(parseAmplifyAuthPermission(permission as StorageAccess[])).toEqual(
+ expected
+ );
+ });
+
+ it('should throw an error if the permission is invalid', () => {
+ // @ts-expect-error: test invalid permission
+ expect(() => parseAmplifyAuthPermission(['INVALID'])).toThrow(
+ 'Improper Permission: Please provide correct permission.'
+ );
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyAuthAdapter.spec.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyAuthAdapter.spec.ts
new file mode 100644
index 00000000000..a5c9a56dd72
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyAuthAdapter.spec.ts
@@ -0,0 +1,107 @@
+import { Amplify } from 'aws-amplify';
+import { Hub } from 'aws-amplify/utils';
+import {
+ createAmplifyAuthAdapter,
+ MISSING_BUCKET_OR_REGION_ERROR,
+ MISSING_IDENTITY_ID_ERROR,
+ MISSING_TEMPORARY_CREDENTIALS_ERROR,
+} from '../createAmplifyAuthAdapter';
+
+const credentials = {
+ accessKeyId: 'accessKeyId',
+ secretAccessKey: 'secretAccessKey',
+ sessionToken: 'sessionToken',
+ expiration: new Date(Date.now() + 60 * 60_1000),
+};
+const mockFetchAuthSession = jest.fn(async (_: { forceRefresh?: boolean }) =>
+ Promise.resolve({ credentials, identityId: 'identityId' })
+);
+
+jest.mock('aws-amplify/auth', () => ({
+ fetchAuthSession: (input: { forceRefresh?: boolean }) =>
+ mockFetchAuthSession(input),
+}));
+
+describe('createAmplifyAuthAdapter', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(Amplify, 'getConfig').mockReturnValue({
+ Storage: { S3: { bucket: 'XXXXXX', region: 'region' } },
+ });
+ });
+
+ it('should create an auth adapter with the correct properties', () => {
+ const authAdapter = createAmplifyAuthAdapter();
+
+ expect(authAdapter).toHaveProperty('getLocationCredentials');
+ expect(authAdapter).toHaveProperty('listLocations');
+ expect(authAdapter).toHaveProperty('region');
+ expect(authAdapter.region).toEqual('region');
+ expect(authAdapter).toHaveProperty('registerAuthListener');
+ });
+
+ it('should throw if no bucket is returned from getConfig', () => {
+ jest
+ .spyOn(Amplify, 'getConfig')
+ .mockReturnValue({ Storage: { S3: { region: 'region' } } });
+
+ expect(createAmplifyAuthAdapter).toThrow(MISSING_BUCKET_OR_REGION_ERROR);
+ });
+
+ it('should throw if no region is returned from getConfig', () => {
+ jest
+ .spyOn(Amplify, 'getConfig')
+ .mockReturnValue({ Storage: { S3: { bucket: 'bucket' } } });
+
+ expect(createAmplifyAuthAdapter).toThrow(MISSING_BUCKET_OR_REGION_ERROR);
+ });
+
+ it('should set Hub listener when registerAuthListener is called', () => {
+ const hubListenSpy = jest.spyOn(Hub, 'listen');
+
+ const authAdapter = createAmplifyAuthAdapter();
+ const onStateChange = () => {
+ /* clear location store */
+ };
+ authAdapter.registerAuthListener(onStateChange);
+ expect(hubListenSpy).toHaveBeenCalled();
+ });
+
+ it('should call onStateChange if Hub signedOut event is called', () => {
+ const authAdapter = createAmplifyAuthAdapter();
+ const onStateChange = jest.fn();
+ authAdapter.registerAuthListener(onStateChange);
+
+ Hub.dispatch('auth', { event: 'signedOut' });
+
+ expect(onStateChange).toHaveBeenCalled();
+ });
+
+ it('throws when getLocationCredentials is called and fetchAuthSession does not return temporary credentials', async () => {
+ // @ts-expect-error intentionally return empty credentials
+ mockFetchAuthSession.mockResolvedValueOnce({ credentials: {} });
+
+ const { getLocationCredentials } = createAmplifyAuthAdapter();
+
+ await expect(
+ getLocationCredentials({
+ permissions: ['delete', 'get', 'list', 'write'],
+ scope: 's3://bucket/prefix/*',
+ })
+ ).rejects.toThrow(MISSING_TEMPORARY_CREDENTIALS_ERROR);
+ });
+
+ it('throws when getLocationCredentials is called and fetchAuthSession does not return an identityId', async () => {
+ // @ts-expect-error intentionally return missing identityId
+ mockFetchAuthSession.mockResolvedValueOnce({ credentials });
+
+ const { getLocationCredentials } = createAmplifyAuthAdapter();
+
+ await expect(
+ getLocationCredentials({
+ permissions: ['delete', 'get', 'list', 'write'],
+ scope: 's3://bucket/prefix/*',
+ })
+ ).rejects.toThrow(MISSING_IDENTITY_ID_ERROR);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts
new file mode 100644
index 00000000000..a1c51182774
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/createAmplifyListLocationsHandler.spec.ts
@@ -0,0 +1,168 @@
+import { ListLocations, LocationData } from '../../../actions';
+import { listPaths, ListPathsOutput } from '../../../storage-internal';
+
+import { getPaginatedLocations } from '../getPaginatedLocations';
+import { createAmplifyListLocationsHandler } from '../createAmplifyListLocationsHandler';
+
+jest.mock('../../../storage-internal', () => ({
+ listPaths: jest.fn(),
+}));
+jest.mock(
+ '../../../adapters/createAmplifyAuthAdapter/getPaginatedLocations',
+ () => ({
+ getPaginatedLocations: jest.fn(),
+ })
+);
+
+describe('createAmplifyListLocationsHandler', () => {
+ const mockListPaths = jest.mocked(listPaths);
+ const mockGetPaginatedItems = jest.mocked(getPaginatedLocations);
+ const mockId = 'intentionally-static-test-id';
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'crypto', {
+ value: { randomUUID: () => mockId },
+ });
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch locations when the cache is empty', async () => {
+ const handler = createAmplifyListLocationsHandler();
+ const fetchedLocations: ListPathsOutput['locations'] = [
+ {
+ bucket: 'bucket1',
+ permission: ['read'],
+ prefix: 'prefix1/*',
+ type: 'PREFIX',
+ },
+ ];
+ const sanitizedLocations: LocationData[] = [
+ {
+ prefix: 'prefix1/',
+ bucket: 'bucket1',
+ id: mockId,
+ permissions: ['get', 'list'],
+ type: 'PREFIX',
+ },
+ ];
+
+ const input = { options: { pageSize: 10, nextToken: undefined } };
+ const paginatedResult = {
+ items: sanitizedLocations,
+ nextToken: undefined,
+ };
+
+ mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations });
+ mockGetPaginatedItems.mockReturnValueOnce(paginatedResult);
+
+ const result = await handler(input);
+
+ expect(result).toEqual(paginatedResult);
+ expect(mockListPaths).toHaveBeenCalledTimes(1);
+ expect(mockGetPaginatedItems).toHaveBeenCalledWith({
+ items: sanitizedLocations,
+ pageSize: input.options.pageSize,
+ nextToken: input.options.nextToken,
+ });
+ });
+
+ it('should fetch locations from the cache', async () => {
+ const handler: ListLocations = createAmplifyListLocationsHandler();
+ const input = { options: { pageSize: 10, nextToken: undefined } };
+
+ const fetchedLocations: ListPathsOutput['locations'] = [
+ {
+ bucket: 'bucket1',
+ permission: ['read'],
+ prefix: 'prefix1/*',
+ type: 'PREFIX',
+ },
+ ];
+ mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations });
+ await handler(input);
+
+ const cachedItems: LocationData[] = [
+ {
+ prefix: 'prefix1/',
+ bucket: 'bucket1',
+ id: mockId,
+ permissions: ['get', 'list'],
+ type: 'PREFIX',
+ },
+ ];
+
+ const paginatedResult = {
+ items: cachedItems,
+ nextToken: undefined,
+ };
+
+ mockGetPaginatedItems.mockReturnValueOnce(paginatedResult);
+
+ const result = await handler(input);
+
+ expect(result).toEqual(paginatedResult);
+ expect(mockGetPaginatedItems).toHaveBeenCalledWith({
+ items: cachedItems,
+ pageSize: input.options.pageSize,
+ nextToken: input.options.nextToken,
+ });
+ expect(mockListPaths).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle pagination', async () => {
+ const handler = createAmplifyListLocationsHandler();
+ const fetchedLocations: ListPathsOutput['locations'] = [
+ {
+ bucket: 'bucket1',
+ permission: ['read'],
+ prefix: 'prefix1/*',
+ type: 'PREFIX',
+ },
+ {
+ bucket: 'bucket2',
+ permission: ['read'],
+ prefix: 'prefix2/*',
+ type: 'PREFIX',
+ },
+ ];
+
+ const sanitizedLocations: LocationData[] = [
+ {
+ prefix: 'prefix1/',
+ bucket: 'bucket1',
+ id: mockId,
+ permissions: ['get', 'list'],
+ type: 'PREFIX',
+ },
+ {
+ prefix: 'prefix2/',
+ bucket: 'bucket2',
+ id: mockId,
+ permissions: ['get', 'list'],
+ type: 'PREFIX',
+ },
+ ];
+
+ const input = { options: { pageSize: 1, nextToken: undefined } };
+ const paginatedResult = {
+ items: [{ ...sanitizedLocations }[0]],
+ nextToken: 'token1',
+ };
+
+ mockListPaths.mockResolvedValueOnce({ locations: fetchedLocations });
+ mockGetPaginatedItems.mockReturnValueOnce(paginatedResult);
+
+ const result = await handler(input);
+
+ expect(result.items).toEqual(paginatedResult.items);
+ expect(mockListPaths).toHaveBeenCalledTimes(1);
+ expect(mockGetPaginatedItems).toHaveBeenCalledWith({
+ items: sanitizedLocations,
+ pageSize: input.options.pageSize,
+ nextToken: input.options.nextToken,
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts
new file mode 100644
index 00000000000..057d6d1b84c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/getPaginatedLocations.test.ts
@@ -0,0 +1,93 @@
+import { getPaginatedLocations } from '../getPaginatedLocations';
+import { ListLocationsHandlerOutput } from '../../../actions';
+
+describe('getPaginatedLocations', () => {
+ const mockItems: ListLocationsHandlerOutput['items'] = [
+ {
+ type: 'PREFIX',
+ permissions: ['list'],
+ prefix: 'path1/',
+ bucket: 'bucket1',
+ id: '1',
+ },
+ {
+ type: 'PREFIX',
+ permissions: ['get'],
+ prefix: 'path2/',
+ bucket: 'bucket2',
+ id: '2',
+ },
+ {
+ type: 'PREFIX',
+ permissions: ['write'],
+ prefix: 'path3/',
+ bucket: 'bucket3',
+ id: '3',
+ },
+ ];
+
+ it('should return all locations when no pagination is specified', () => {
+ const result = getPaginatedLocations({ items: mockItems });
+ expect(result).toEqual({ items: mockItems });
+ });
+
+ it('should return paginated locations when pageSize is specified', () => {
+ const result = getPaginatedLocations({
+ items: mockItems,
+ pageSize: 2,
+ });
+ expect(result).toEqual({
+ items: mockItems.slice(0, 2),
+ nextToken: '1',
+ });
+ });
+
+ it('should return paginated locations when pageSize and nextToken are specified', () => {
+ const result = getPaginatedLocations({
+ items: mockItems,
+ pageSize: 1,
+ nextToken: '2',
+ });
+ expect(result).toEqual({
+ items: mockItems.slice(1, 2),
+ nextToken: '1',
+ });
+ });
+
+ it('should return empty locations when locations array is empty', () => {
+ const result = getPaginatedLocations({ items: [], pageSize: 2 });
+ expect(result).toEqual({ items: [] });
+ });
+
+ it('should return empty location when nextToken is beyond array length', () => {
+ const result = getPaginatedLocations({
+ items: mockItems,
+ pageSize: 2,
+ nextToken: '5',
+ });
+ expect(result).toEqual({ items: [], nextToken: undefined });
+ });
+
+ it('should return all remaining location when page size is greater than remaining locations length', () => {
+ const result = getPaginatedLocations({
+ items: mockItems,
+ pageSize: 5,
+ nextToken: '2',
+ });
+ expect(result).toEqual({
+ items: mockItems.slice(-2),
+ nextToken: undefined,
+ });
+ });
+
+ it('should return undefined nextToken when end of array is reached', () => {
+ const result = getPaginatedLocations({
+ items: mockItems,
+ pageSize: 5,
+ });
+ expect(result).toEqual({
+ items: mockItems.slice(0, 3),
+ nextToken: undefined,
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/mapAmplifyPermissions.test.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/mapAmplifyPermissions.test.ts
new file mode 100644
index 00000000000..b785f8ec86c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/__tests__/mapAmplifyPermissions.test.ts
@@ -0,0 +1,30 @@
+import { toAccessGrantPermission } from '../../permissionParsers';
+import { Permission, StorageAccess } from '../../../storage-internal';
+
+describe('toAccessGrantPermission', () => {
+ it('should map either of read, get, list - storage access to READ', () => {
+ const input: StorageAccess[] = ['read', 'get', 'list'];
+ const expectedOutput: Permission = 'READ';
+ expect(toAccessGrantPermission(input)).toBe(expectedOutput);
+ });
+
+ it('should map write, delete - storage access to WRITE', () => {
+ const input: StorageAccess[] = ['write', 'delete'];
+ const expectedOutput: Permission = 'WRITE';
+ expect(toAccessGrantPermission(input)).toBe(expectedOutput);
+ });
+
+ it('should map get, list, and write permissions to READWRITE', () => {
+ const input: StorageAccess[] = ['get', 'list', 'write'];
+ const expectedOutput: Permission = 'READWRITE';
+ expect(toAccessGrantPermission(input)).toBe(expectedOutput);
+ });
+
+ it('should return an empty string for unknown permissions', () => {
+ // @ts-expect-error
+ const input: StorageAccess[] = ['unknown'];
+ expect(() => toAccessGrantPermission(input)).toThrow(
+ 'Improper Permission: Please provide correct permission.'
+ );
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyAuthAdapter.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyAuthAdapter.ts
new file mode 100644
index 00000000000..1efb5acd400
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyAuthAdapter.ts
@@ -0,0 +1,59 @@
+import { Amplify } from 'aws-amplify';
+import { AuthSession, fetchAuthSession } from 'aws-amplify/auth';
+import { Hub } from 'aws-amplify/utils';
+
+import { AWSTemporaryCredentials } from '../../storage-internal';
+import { StorageBrowserAuthAdapter } from '../types';
+import { createAmplifyListLocationsHandler } from './createAmplifyListLocationsHandler';
+import { RegisterAuthListener } from '../../providers';
+
+export const MISSING_BUCKET_OR_REGION_ERROR =
+ 'Amplify Storage configuration not found. Did you run `Amplify.configure` from your project root?';
+export const MISSING_IDENTITY_ID_ERROR = '`identityId` not found.';
+export const MISSING_TEMPORARY_CREDENTIALS_ERROR =
+ 'Temporary Auth `credentials` not found.';
+
+interface AWSCredentials extends NonNullable {}
+
+const isTemporaryCredentials = (
+ value?: AWSCredentials | AWSTemporaryCredentials
+): value is AWSTemporaryCredentials =>
+ !!value?.sessionToken || !!value?.expiration;
+
+export const createAmplifyAuthAdapter = (): StorageBrowserAuthAdapter => {
+ const { bucket, region } = Amplify.getConfig()?.Storage?.S3 ?? {};
+ if (!bucket || !region) {
+ throw new Error(MISSING_BUCKET_OR_REGION_ERROR);
+ }
+ const listLocations = createAmplifyListLocationsHandler();
+
+ const getLocationCredentials = async (): Promise<{
+ credentials: AWSTemporaryCredentials;
+ identityId: string;
+ }> => {
+ const { credentials, identityId } = await fetchAuthSession();
+ if (!isTemporaryCredentials(credentials)) {
+ throw new Error(MISSING_TEMPORARY_CREDENTIALS_ERROR);
+ }
+ if (!identityId) {
+ throw new Error(MISSING_IDENTITY_ID_ERROR);
+ }
+ return { credentials, identityId };
+ };
+
+ const registerAuthListener: RegisterAuthListener = (onStateChange) => {
+ const remove = Hub.listen('auth', (data) => {
+ if (data.payload.event === 'signedOut') {
+ onStateChange();
+ remove();
+ }
+ });
+ };
+
+ return {
+ getLocationCredentials,
+ listLocations,
+ registerAuthListener,
+ region,
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts
new file mode 100644
index 00000000000..58c87c26af5
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/createAmplifyListLocationsHandler.ts
@@ -0,0 +1,46 @@
+import { LocationData } from '../../actions';
+import { listPaths, ListPathsOutput } from '../../storage-internal';
+import { ListLocations, ListLocationsInput } from '../../actions';
+
+import { parseAmplifyAuthPermission } from '../permissionParsers';
+import { getPaginatedLocations } from './getPaginatedLocations';
+
+export const createAmplifyListLocationsHandler = (): ListLocations => {
+ let cachedItems: LocationData[] = [];
+
+ return async function listLocations(input: ListLocationsInput) {
+ const { options } = input ?? {};
+ const { nextToken, pageSize } = options ?? {};
+
+ if (cachedItems.length > 0) {
+ return getPaginatedLocations({
+ items: cachedItems,
+ pageSize,
+ nextToken,
+ });
+ }
+
+ const { locations }: { locations: ListPathsOutput['locations'] } =
+ await listPaths();
+
+ const sanitizedItems: LocationData[] = locations.map(
+ ({ bucket, permission, prefix, type }) => {
+ return {
+ type,
+ permissions: parseAmplifyAuthPermission(permission),
+ bucket,
+ prefix: prefix.endsWith('*') ? prefix.slice(0, -1) : prefix,
+ id: crypto.randomUUID(),
+ };
+ }
+ );
+
+ cachedItems = sanitizedItems;
+
+ return getPaginatedLocations({
+ items: cachedItems,
+ pageSize,
+ nextToken,
+ });
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts
new file mode 100644
index 00000000000..c9d7f743fc1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/getPaginatedLocations.ts
@@ -0,0 +1,34 @@
+import { ListLocationsHandlerOutput, LocationData } from '../../actions';
+
+export const getPaginatedLocations = ({
+ items,
+ pageSize,
+ nextToken,
+}: {
+ items: LocationData[];
+ pageSize?: number;
+ nextToken?: string;
+}): ListLocationsHandlerOutput => {
+ if (pageSize) {
+ if (nextToken) {
+ if (Number(nextToken) > items.length) {
+ return { items: [], nextToken: undefined };
+ }
+ const start = -nextToken;
+ const end = start + pageSize < 0 ? start + pageSize : undefined;
+
+ return {
+ items: items.slice(start, end),
+ nextToken: end ? `${-end}` : undefined,
+ };
+ }
+
+ return {
+ items: items.slice(0, pageSize),
+ nextToken:
+ items.length > pageSize ? `${items.length - pageSize}` : undefined,
+ };
+ }
+
+ return { items, nextToken: undefined };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/index.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/index.ts
new file mode 100644
index 00000000000..6b71b6b512d
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createAmplifyAuthAdapter/index.ts
@@ -0,0 +1 @@
+export { createAmplifyAuthAdapter } from './createAmplifyAuthAdapter';
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts
new file mode 100644
index 00000000000..f01e4f9cd26
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/__tests__/createManagedAuthConfigAdapter.test.ts
@@ -0,0 +1,77 @@
+import { createManagedAuthAdapter } from '../../../adapters/createManagedAuthAdapter/createManagedAuthAdapter';
+import { createLocationCredentialsHandler } from '../../../adapters/createManagedAuthAdapter/createLocationCredentialsHandler';
+
+jest.mock(
+ '../../../adapters/createManagedAuthAdapter/createLocationCredentialsHandler'
+);
+
+const mockCreateLocationCredentialsHandler = jest.mocked(
+ createLocationCredentialsHandler
+);
+
+describe('createManagedAuthConfigAdapter', () => {
+ const region = 'us-foo-2';
+ const accountId = 'XXXXXXXXXXXX';
+ const credentialsProvider = jest.fn();
+ const customEndpoint = 'mock-endpoint';
+ const mockCreatedLocationCredentialsHandler = jest.fn();
+ const mockRegisterAuthListener = jest.fn();
+
+ beforeEach(() => {
+ mockCreateLocationCredentialsHandler.mockReturnValue(
+ mockCreatedLocationCredentialsHandler
+ );
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should pass region and accountId to the adapter', () => {
+ expect(
+ createManagedAuthAdapter({
+ region,
+ credentialsProvider: jest.fn(),
+ accountId,
+ registerAuthListener: mockRegisterAuthListener,
+ })
+ ).toMatchObject({
+ region,
+ accountId,
+ });
+ });
+
+ it('should create list locations handler', () => {
+ expect(
+ createManagedAuthAdapter({
+ region,
+ accountId,
+ credentialsProvider,
+ customEndpoint,
+ registerAuthListener: mockRegisterAuthListener,
+ })
+ ).toMatchObject({
+ listLocations: expect.any(Function),
+ });
+ });
+
+ it('should create get location credentials handler', () => {
+ expect(
+ createManagedAuthAdapter({
+ region,
+ accountId,
+ credentialsProvider,
+ customEndpoint,
+ registerAuthListener: mockRegisterAuthListener,
+ })
+ ).toMatchObject({
+ getLocationCredentials: mockCreatedLocationCredentialsHandler,
+ });
+ expect(mockCreateLocationCredentialsHandler).toHaveBeenCalledWith({
+ region,
+ accountId,
+ credentialsProvider,
+ customEndpoint,
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createLocationCredentialsHandler.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createLocationCredentialsHandler.ts
new file mode 100644
index 00000000000..4ebe408aa7a
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createLocationCredentialsHandler.ts
@@ -0,0 +1,39 @@
+import {
+ GetLocationCredentials,
+ GetLocationCredentialsInput,
+} from '../../credentials/types';
+import { getDataAccess, CredentialsProvider } from '../../storage-internal';
+import { toAccessGrantPermission } from '../permissionParsers';
+interface CreateLocationCredentialsHandlerInput {
+ accountId: string;
+ credentialsProvider: CredentialsProvider;
+ region: string;
+ customEndpoint?: string;
+}
+
+export const createLocationCredentialsHandler = (
+ handlerInput: CreateLocationCredentialsHandlerInput
+): GetLocationCredentials => {
+ const { accountId, region, credentialsProvider, customEndpoint } =
+ handlerInput;
+
+ /**
+ * Retrieves credentials for the specified scope & permission.
+ *
+ * @param input - An object specifying the requested scope & permission.
+ *
+ * @returns A promise which will resolve with the requested credentials.
+ */
+ return (input: GetLocationCredentialsInput) => {
+ const { scope, permissions } = input;
+
+ return getDataAccess({
+ accountId,
+ credentialsProvider,
+ permission: toAccessGrantPermission(permissions),
+ region,
+ scope,
+ customEndpoint,
+ });
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts
new file mode 100644
index 00000000000..b242f42a4e8
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/createManagedAuthAdapter.ts
@@ -0,0 +1,46 @@
+import { createLocationCredentialsHandler } from './createLocationCredentialsHandler';
+import {
+ StorageBrowserAuthAdapter,
+ CreateManagedAuthAdapterInput,
+} from '../types';
+import { listLocationsHandler, ListLocationsInput } from '../../actions';
+
+/**
+ * Create configuration including handlers to call S3 Access Grant APIs to list and get
+ * credentials for different locations.
+ *
+ * @param options - Configuration options for the adapter.
+ * @returns - An object containing the handlers to call S3 Access Grant APIs and region
+ */
+export const createManagedAuthAdapter = ({
+ accountId,
+ credentialsProvider,
+ customEndpoint,
+ region,
+ registerAuthListener,
+}: CreateManagedAuthAdapterInput): StorageBrowserAuthAdapter => {
+ const config = {
+ accountId,
+ credentials: credentialsProvider,
+ customEndpoint,
+ region,
+ };
+
+ const listLocations = ({ options }: ListLocationsInput = {}) =>
+ listLocationsHandler({ config, options });
+
+ const getLocationCredentials = createLocationCredentialsHandler({
+ credentialsProvider,
+ accountId,
+ customEndpoint,
+ region,
+ });
+
+ return {
+ accountId,
+ listLocations,
+ getLocationCredentials,
+ region,
+ registerAuthListener,
+ };
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/index.ts b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/index.ts
new file mode 100644
index 00000000000..7125c56c5ce
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/createManagedAuthAdapter/index.ts
@@ -0,0 +1 @@
+export { createManagedAuthAdapter } from './createManagedAuthAdapter';
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/index.ts b/packages/react-storage/src/components/StorageBrowser/adapters/index.ts
new file mode 100644
index 00000000000..0a9a681ee39
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/index.ts
@@ -0,0 +1,6 @@
+export { createAmplifyAuthAdapter } from './createAmplifyAuthAdapter';
+export { createManagedAuthAdapter } from './createManagedAuthAdapter';
+export {
+ CreateManagedAuthAdapterInput,
+ StorageBrowserAuthAdapter,
+} from './types';
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts b/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts
new file mode 100644
index 00000000000..b29d4d0990d
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/permissionParsers.ts
@@ -0,0 +1,68 @@
+import { LocationPermissions } from '../actions/handlers/types';
+import { Permission, StorageAccess } from '../storage-internal';
+
+export const parseAccessGrantPermission = (
+ accessGrantPermission: Permission
+): LocationPermissions => {
+ if (accessGrantPermission === 'READ') {
+ return ['get', 'list'];
+ } else if (accessGrantPermission === 'WRITE') {
+ return ['delete', 'write'];
+ } else if (accessGrantPermission === 'READWRITE') {
+ return ['delete', 'get', 'list', 'write'];
+ }
+ throw new Error('Improper Permission: Please provide correct permission.');
+};
+
+export const toAccessGrantPermission = (
+ permission: StorageAccess[]
+): Permission => {
+ let result: string = '';
+
+ permission.forEach((access: StorageAccess) => {
+ if (['read', 'get', 'list'].includes(access as string)) {
+ if (!result.includes('READ')) {
+ result = 'READ' + result;
+ }
+ }
+ if (['write', 'delete'].includes(access as string)) {
+ if (!result.includes('WRITE')) {
+ result += 'WRITE';
+ }
+ }
+ });
+
+ if (result === '') {
+ throw new Error('Improper Permission: Please provide correct permission.');
+ }
+
+ return result as Permission;
+};
+
+export const parseAmplifyAuthPermission = (
+ permissions: StorageAccess[]
+): LocationPermissions => {
+ const result: LocationPermissions = [];
+
+ permissions.forEach((access: StorageAccess) => {
+ if (access === 'read') {
+ if (!result.includes('list')) {
+ result.push('list');
+ }
+ if (!result.includes('get')) {
+ result.push('get');
+ }
+ } else if (
+ ['delete', 'get', 'list', 'write'].includes(access) &&
+ !result.includes(access)
+ ) {
+ result.push(access);
+ }
+ });
+
+ if (result.length === 0) {
+ throw new Error('Improper Permission: Please provide correct permission.');
+ }
+
+ return result.sort();
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/adapters/types.ts b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts
new file mode 100644
index 00000000000..521c2fd5b49
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/adapters/types.ts
@@ -0,0 +1,33 @@
+import { RegisterAuthListener } from '../providers';
+import {
+ GetLocationCredentials,
+ CredentialsLocation,
+} from '../credentials/types';
+import { CredentialsProvider } from '../storage-internal';
+import { LocationType, ListLocations } from '../actions';
+
+export interface LocationAccess extends CredentialsLocation {
+ /**
+ * Parse location type parsed from scope format:
+ * * BUCKET: `'s3:///*'`
+ * * PREFIX: `'s3:///*'`
+ * * OBJECT: `'s3:////'`
+ */
+ readonly type: LocationType;
+}
+
+export interface CreateManagedAuthAdapterInput {
+ accountId: string;
+ region: string;
+ credentialsProvider: CredentialsProvider;
+ registerAuthListener: RegisterAuthListener;
+ customEndpoint?: string;
+}
+
+export interface StorageBrowserAuthAdapter {
+ accountId?: string;
+ listLocations: ListLocations;
+ getLocationCredentials: GetLocationCredentials;
+ region: string;
+ registerAuthListener: RegisterAuthListener;
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx b/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx
new file mode 100644
index 00000000000..328e9185435
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/BreadcrumbNavigation.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+
+import {
+ ButtonElement,
+ ListItemElement,
+ NavElement,
+ OrderedListElement,
+ SpanElement,
+} from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+import { isFunction } from '@aws-amplify/ui';
+import { Separator } from './Separator';
+
+export interface BreadcrumbProps {
+ isCurrent?: boolean;
+ name?: string;
+ onNavigate?: () => void;
+}
+
+interface BreadcrumbNavigationProps {
+ breadcrumbs: BreadcrumbProps[];
+ role?: React.AriaRole;
+}
+
+export const Breadcrumb = ({
+ isCurrent,
+ name,
+ onNavigate,
+}: BreadcrumbProps): React.JSX.Element => {
+ const isNavigable = isFunction(onNavigate);
+ return (
+
+ <>
+ {!isCurrent && isNavigable ? (
+
+ {name}
+
+ ) : (
+
+ {name}
+
+ )}
+ {!isCurrent ? : null}
+ >
+
+ );
+};
+
+export function BreadcrumbNavigation({
+ breadcrumbs,
+ role = 'navigation',
+}: BreadcrumbNavigationProps): React.JSX.Element {
+ return (
+
+
+ {breadcrumbs.map(({ isCurrent, name, onNavigate }, index) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/Checkbox.tsx b/packages/react-storage/src/components/StorageBrowser/components/Checkbox.tsx
new file mode 100644
index 00000000000..ed36a72375f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/Checkbox.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+import { ViewElement, LabelElement, InputElement } from '../context/elements';
+
+interface CheckboxProps {
+ checked?: boolean;
+ disabled?: boolean;
+ id?: string;
+ labelHidden?: boolean;
+ labelText?: string;
+ onSelect?: () => void;
+}
+
+export const Checkbox = ({
+ checked,
+ disabled,
+ id,
+ labelHidden = false,
+ labelText,
+ onSelect,
+}: CheckboxProps): React.JSX.Element => (
+
+
+
+ {labelText}
+
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx b/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx
new file mode 100644
index 00000000000..e096b7fffc9
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/DescriptionList.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+
+import {
+ DescriptionDetailsElement,
+ DescriptionListElement,
+ DescriptionTermElement,
+ ViewElement,
+} from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface DescriptionItemProps {
+ term?: string | string[];
+ details?: string | string[];
+}
+
+interface DescriptionProps {
+ descriptions: DescriptionItemProps[];
+ className?: string;
+}
+
+const Description = ({ term, details }: DescriptionItemProps) => {
+ return (
+
+
+ {term}
+
+
+ {details}
+
+
+ );
+};
+
+export const DescriptionList = ({
+ descriptions,
+ className = '',
+}: DescriptionProps): React.JSX.Element => {
+ return (
+
+ {descriptions.map(({ term, details }, index) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx b/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx
new file mode 100644
index 00000000000..843fd93a5ba
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/DropdownMenu.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+
+import {
+ ButtonElement,
+ IconElement,
+ StorageBrowserIconType,
+ ViewElement,
+} from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+interface MenuItem {
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ icon?: StorageBrowserIconType;
+ id: string;
+ label?: string;
+}
+
+interface MenuItemProps extends Omit {
+ onClick?: () => void;
+}
+
+export interface MenuProps {
+ isDisabled?: boolean;
+ items: MenuItem[];
+ onItemSelect?: (id: string) => void;
+}
+
+export function MenuItem({
+ isDisabled,
+ icon,
+ label,
+ onClick,
+}: MenuItemProps): React.JSX.Element {
+ return (
+
+ {icon && (
+
+ )}
+ {label}
+
+ );
+}
+
+export function DropdownMenu({
+ isDisabled = false,
+ items,
+ onItemSelect,
+}: MenuProps): React.JSX.Element {
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ return (
+
+ {
+ setIsOpen((prev) => !prev);
+ }}
+ variant="menu-toggle"
+ >
+
+
+
+ {items
+ .filter(({ isHidden }) => !isHidden)
+ .map(({ icon, id, isDisabled, label }) => {
+ return (
+ {
+ setIsOpen(false);
+ onItemSelect?.(id);
+ }}
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/EmptyMessage.tsx b/packages/react-storage/src/components/StorageBrowser/components/EmptyMessage.tsx
new file mode 100644
index 00000000000..de680156839
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/EmptyMessage.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+import { ViewElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+interface EmptyMessageProps {
+ children?: React.ReactNode;
+}
+
+export const EmptyMessage = ({
+ children,
+}: EmptyMessageProps): React.JSX.Element => (
+
+ {children}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/components/Field.tsx b/packages/react-storage/src/components/StorageBrowser/components/Field.tsx
new file mode 100644
index 00000000000..0415c512270
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/Field.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import {
+ InputElementProps,
+ ViewElement,
+ InputElement,
+ LabelElement,
+} from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+interface FieldProps extends InputElementProps {
+ className?: string;
+ icon?: React.ReactNode;
+ label?: string;
+}
+export function Field({
+ 'aria-describedby': ariaDescribedBy = 'fieldError',
+ className = `${STORAGE_BROWSER_BLOCK_TO_BE_UPDATED}__field`,
+ children,
+ label,
+ icon = null,
+ id,
+ ...props
+}: FieldProps): React.JSX.Element {
+ return (
+
+ {icon}
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ {children}
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/PaginationButton.tsx b/packages/react-storage/src/components/StorageBrowser/components/PaginationButton.tsx
new file mode 100644
index 00000000000..2ff31291c72
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/PaginationButton.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+import {
+ ButtonElement,
+ IconElement,
+ InputElementProps,
+} from '../context/elements';
+
+type ButtonType = 'previous' | 'next';
+
+export interface PaginationButtonProps extends InputElementProps {
+ isDisabled?: boolean;
+ onClick?: () => void;
+ type?: ButtonType;
+}
+
+export function PaginationButton({
+ isDisabled,
+ onClick,
+ type,
+}: PaginationButtonProps): React.JSX.Element | null {
+ if (!type) return null;
+
+ const buttonType: `paginate-${ButtonType}` = `paginate-${type}`;
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx b/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx
new file mode 100644
index 00000000000..058ebbc899f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/Separator.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { SpanElement } from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export function Separator(): React.JSX.Element {
+ return (
+
+ /
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/components/Table.tsx b/packages/react-storage/src/components/StorageBrowser/components/Table.tsx
new file mode 100644
index 00000000000..781e16ac9fb
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/Table.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+
+import {
+ TableBodyElement,
+ TableDataCellElement,
+ TableElement,
+ TableHeadElement,
+ TableHeaderElement,
+ TableRowElement,
+} from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+import { WithKey } from './types';
+
+interface TableItem {
+ type?: 'dataCell' | 'header';
+ content: React.ReactNode;
+}
+
+interface TableRow {
+ content: WithKey[];
+}
+
+interface TableProps {
+ headers: WithKey[];
+ rows: WithKey[];
+}
+
+export const Table = ({ headers, rows }: TableProps): React.JSX.Element => {
+ return (
+
+
+ {headers.length ? (
+
+ {headers.map(({ key, content }) => (
+
+ {content}
+
+ ))}
+
+ ) : null}
+
+
+ {rows?.map(({ key, content }) => (
+
+ {content.map(({ key, content, type }) => {
+ return type === 'header' ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/BreadcrumbNavigation.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/BreadcrumbNavigation.spec.tsx
new file mode 100644
index 00000000000..03f47a40961
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/BreadcrumbNavigation.spec.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { BreadcrumbNavigation } from '../BreadcrumbNavigation';
+
+describe('BreadcrumbNavigation', () => {
+ const breadcrumb = 'Breadcrumb';
+
+ // create mocks
+ const mockOnNavigate = jest.fn();
+
+ const breadcrumbs = [
+ { name: `${breadcrumb} 1`, onNavigate: mockOnNavigate },
+ { name: `${breadcrumb} 2`, onNavigate: mockOnNavigate, isCurrent: true },
+ ];
+
+ afterEach(() => {
+ mockOnNavigate.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const navigation = screen.getByRole('navigation');
+ const list = screen.getByRole('list');
+ const listItems = screen.getAllByRole('listitem');
+
+ expect(navigation).toBeInTheDocument();
+ expect(list).toBeInTheDocument();
+ expect(listItems[0]).toHaveTextContent(`${breadcrumb} 1`);
+ expect(listItems[1]).toHaveTextContent(`${breadcrumb} 2`);
+ });
+
+ it('navigates when a navigable breadcrumb is clicked', () => {
+ render( );
+
+ const navigableBreadcrumbs = screen.getAllByRole('button');
+ navigableBreadcrumbs[0].click();
+
+ expect(navigableBreadcrumbs).toHaveLength(1);
+ expect(mockOnNavigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('always renders the current breadcrumb as un-navigable', () => {
+ render( );
+
+ const listItems = screen.getAllByRole('listitem');
+ const navigableBreadcrumbs = screen.queryAllByRole('button');
+
+ expect(listItems[0]).toHaveTextContent(`${breadcrumb} 1`);
+ expect(listItems[1]).toHaveTextContent(`${breadcrumb} 2`);
+ expect(navigableBreadcrumbs).toHaveLength(1);
+ });
+
+ it('supports un-navigable breadcrumbs', () => {
+ render(
+
+ );
+
+ const listItems = screen.getAllByRole('listitem');
+ const navigableBreadcrumbs = screen.queryAllByRole('button');
+
+ expect(listItems[0]).toHaveTextContent(`${breadcrumb} 1`);
+ expect(listItems[1]).toHaveTextContent(`${breadcrumb} 2`);
+ expect(navigableBreadcrumbs).toHaveLength(0);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/Checkbox.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/Checkbox.spec.tsx
new file mode 100644
index 00000000000..82ba26c8e53
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/Checkbox.spec.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Checkbox } from '../Checkbox';
+
+const myLabelText = 'My Checkbox';
+const handleSelect = jest.fn();
+
+describe('Checkbox', () => {
+ it('renders the Checkbox', () => {
+ render(
+
+ );
+ const input = screen.getByRole('checkbox');
+ const label = screen.getByText(myLabelText);
+ expect(input).toBeInTheDocument();
+ expect(input).not.toHaveAttribute('checked');
+ expect(label).toBeInTheDocument();
+ });
+
+ it('renders the Checkbox checked', () => {
+ render(
+
+ );
+ const input = screen.getByRole('checkbox');
+ expect(input).toHaveAttribute('checked');
+ });
+
+ it('accepts onSelect prop', () => {
+ render(
+
+ );
+ const input = screen.getByRole('checkbox');
+ fireEvent.click(input);
+ expect(handleSelect).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/DescriptionList.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/DescriptionList.spec.tsx
new file mode 100644
index 00000000000..155d281d160
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/DescriptionList.spec.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { DescriptionList } from '../DescriptionList';
+
+describe('DescriptionList', () => {
+ it('renders', () => {
+ const term = 'Description Term';
+ const details = 'Description Details';
+
+ render(
+
+ );
+
+ const list = screen.getByRole('list');
+ const terms = screen.getAllByRole('term');
+ const definitions = screen.getAllByRole('definition');
+
+ expect(list).toBeInTheDocument();
+ expect(terms[0]).toHaveTextContent(`${term} 1`);
+ expect(terms[1]).toHaveTextContent(`${term} 2`);
+ expect(definitions[0]).toHaveTextContent(`${details} 1`);
+ expect(definitions[1]).toHaveTextContent(`${details} 2`);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/DropdownMenu.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/DropdownMenu.spec.tsx
new file mode 100644
index 00000000000..934990ed044
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/DropdownMenu.spec.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import { DropdownMenu } from '../DropdownMenu';
+
+describe('DropdownMenu', () => {
+ const menuItem1 = { id: 'id-1', label: 'Menu item 1' };
+ const menuItem2 = { id: 'id-2', label: 'Menu item 2' };
+ const menuItems = [menuItem1, menuItem2];
+ const mockOnItemSelect = jest.fn();
+
+ afterEach(() => {
+ mockOnItemSelect.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const menu = screen.getByRole('menu');
+ const [item1, item2] = screen.getAllByRole('menuitem');
+
+ expect(menu).toBeInTheDocument();
+ expect(item1).toHaveTextContent(menuItem1.label);
+ expect(item2).toHaveTextContent(menuItem2.label);
+ });
+
+ it('can be opened', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+ const menu = screen.getByRole('menu');
+
+ expect(menu.getAttribute('class')).not.toContain('open');
+
+ act(() => {
+ button.click();
+ });
+
+ expect(menu.getAttribute('class')).toContain('open');
+ });
+
+ it('can be disabled', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeDisabled();
+ });
+
+ describe('MenuItems', () => {
+ it('can be disabled', () => {
+ render( );
+
+ const item = screen.getByRole('menuitem');
+
+ expect(item).toBeDisabled();
+ });
+
+ it('does not render if hidden', () => {
+ render(
+
+ );
+
+ const items = screen.getAllByRole('menuitem');
+
+ expect(items).toHaveLength(1);
+ });
+
+ it('calls onItemSelect', () => {
+ render(
+
+ );
+
+ const [item1, item2] = screen.getAllByRole('menuitem');
+
+ act(() => {
+ item1.click();
+ item2.click();
+ });
+
+ expect(mockOnItemSelect).toHaveBeenNthCalledWith(1, menuItem1.id);
+ expect(mockOnItemSelect).toHaveBeenNthCalledWith(2, menuItem2.id);
+ });
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/EmptyMessage.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/EmptyMessage.spec.tsx
new file mode 100644
index 00000000000..6a487f84a5f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/EmptyMessage.spec.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { EmptyMessage } from '../EmptyMessage';
+
+describe('EmptyMessage', () => {
+ it('renders', () => {
+ const message = 'I only wanted to be part of something';
+ render({message} );
+
+ const emptyMessage = screen.getByText(message);
+
+ expect(emptyMessage).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/PaginationButton.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/PaginationButton.spec.tsx
new file mode 100644
index 00000000000..b7b8be211c2
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/PaginationButton.spec.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { PaginationButton } from '../PaginationButton';
+
+describe('PaginationButton', () => {
+ it('renders "next" button', () => {
+ render(
+
+ );
+
+ const nextButton = screen.getByRole('button', { name: 'Go to next page' });
+ const nextIcon = nextButton.querySelector('svg');
+
+ expect(nextButton).toBeInTheDocument();
+ expect(nextIcon).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('renders "previous" button', () => {
+ render(
+
+ );
+
+ const previousButton = screen.getByRole('button', {
+ name: 'Go to previous page',
+ });
+ const previousIcon = previousButton.querySelector('svg');
+
+ expect(previousButton).toBeInTheDocument();
+ expect(previousIcon).toHaveAttribute('aria-hidden', 'true');
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/__tests__/Table.spec.tsx b/packages/react-storage/src/components/StorageBrowser/components/__tests__/Table.spec.tsx
new file mode 100644
index 00000000000..0a9d86b400b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/__tests__/Table.spec.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Table } from '../Table';
+
+describe('Table', () => {
+ const headers = [
+ { key: 'header-1', content: 'Header 1' },
+ { key: 'header-2', content: 'Header 2' },
+ ];
+ const rows = [
+ {
+ key: 'row-1',
+ content: [
+ { key: 'row-1-col-1', content: 'Row 1 Col 1' },
+ { key: 'row-1-col-2', content: 'Row 1 Col 2' },
+ ],
+ },
+ {
+ key: 'row-2',
+ content: [
+ { key: 'row-2-col-1', content: 'Row 2 Col 1' },
+ { key: 'row-2-col-2', content: 'Row 2 Col 2' },
+ ],
+ },
+ ];
+
+ it('renders', () => {
+ render();
+
+ const table = screen.getByRole('table');
+ const tableRowGroups = screen.getAllByRole('rowgroup');
+ const tableRows = screen.getAllByRole('row');
+ const tableHeaders = screen.getAllByRole('columnheader');
+ const tableDataCells = screen.getAllByRole('cell');
+
+ expect(table).toBeInTheDocument();
+ expect(tableRowGroups).toHaveLength(2);
+ expect(tableRows).toHaveLength(3);
+ expect(tableHeaders).toHaveLength(2);
+ expect(tableDataCells).toHaveLength(4);
+ });
+
+ it('renders only headers', () => {
+ render();
+
+ const tableRows = screen.getAllByRole('row');
+ const tableHeaders = screen.getAllByRole('columnheader');
+ const tableDataCells = screen.queryAllByRole('cell');
+
+ expect(tableRows).toHaveLength(1);
+ expect(tableHeaders).toHaveLength(2);
+ expect(tableDataCells).toHaveLength(0);
+ });
+
+ it('renders only data cells', () => {
+ render();
+
+ const tableRows = screen.getAllByRole('row');
+ const tableHeaders = screen.queryAllByRole('columnheader');
+ const tableDataCells = screen.getAllByRole('cell');
+
+ expect(tableRows).toHaveLength(2);
+ expect(tableHeaders).toHaveLength(0);
+ expect(tableDataCells).toHaveLength(4);
+ });
+
+ it('renders headers in table body', () => {
+ render(
+
+ );
+
+ const tableRows = screen.getAllByRole('row');
+ const tableHeaders = screen.queryAllByRole('columnheader');
+ const tableRowHeaders = screen.getAllByRole('rowheader');
+ const tableDataCells = screen.getAllByRole('cell');
+
+ expect(tableRows).toHaveLength(2);
+ expect(tableHeaders).toHaveLength(0);
+ expect(tableRowHeaders).toHaveLength(2);
+ expect(tableDataCells).toHaveLength(2);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/components/types.ts b/packages/react-storage/src/components/StorageBrowser/components/types.ts
new file mode 100644
index 00000000000..9cd69c80f08
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/components/types.ts
@@ -0,0 +1,8 @@
+import React from 'react';
+
+export interface DataListProps {
+ data?: T[];
+ renderItem?: (item: T, index: number) => React.JSX.Element;
+}
+
+export type WithKey = { key: K } & T;
diff --git a/packages/react-storage/src/components/StorageBrowser/componentsDefault.tsx b/packages/react-storage/src/components/StorageBrowser/componentsDefault.tsx
new file mode 100644
index 00000000000..0ca98490a97
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/componentsDefault.tsx
@@ -0,0 +1,306 @@
+import * as React from 'react';
+import {
+ Breadcrumbs,
+ TextField,
+ SearchField as AmplifySearchField,
+ Pagination as AmplifyPagination,
+ Loader,
+ CheckboxField,
+ MenuItem,
+ Menu,
+ Button,
+ View,
+ Heading,
+} from '@aws-amplify/ui-react';
+import { Components } from './ComponentsProvider';
+import { IconElement } from './context/elements';
+import { STORAGE_BROWSER_BLOCK } from './constants';
+
+const OverwriteToggle: Components['OverwriteToggle'] = ({
+ isDisabled,
+ isOverwritingEnabled,
+ label = '',
+ onToggle,
+}) => {
+ return (
+ {
+ onToggle?.();
+ }}
+ />
+ );
+};
+
+const SearchSubfoldersToggle: Components['SearchSubfoldersToggle'] = ({
+ isSearchingSubfolders,
+ label = '',
+ onToggle,
+}) => {
+ return (
+ {
+ onToggle?.();
+ }}
+ />
+ );
+};
+
+const Pagination: Components['Pagination'] = ({
+ page = 1,
+ onPaginate,
+ hasNextPage,
+ highestPageVisited,
+}) => {
+ return (
+ {
+ onPaginate?.(index ?? 0);
+ }}
+ onNext={() => {
+ onPaginate?.(page + 1);
+ }}
+ onPrevious={() => {
+ onPaginate?.(page - 1);
+ }}
+ />
+ );
+};
+
+const SearchField: Components['SearchField'] = ({
+ onQueryChange,
+ onSearch,
+ onClear,
+ placeholder,
+ label,
+ query,
+}) => {
+ return (
+ {
+ onQueryChange?.(e.target.value);
+ }}
+ onSubmit={() => {
+ onSearch?.();
+ }}
+ onClear={() => {
+ onClear?.();
+ }}
+ />
+ );
+};
+
+const Navigation: Components['Navigation'] = ({ items }) => {
+ return (
+
+ {items.map((item, i) => {
+ return (
+
+
+ {item.name}
+
+ {item.isCurrent ? null : }
+
+ );
+ })}
+
+ );
+};
+
+const LoadingIndicator: Components['LoadingIndicator'] = ({ isLoading }) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+};
+
+const FolderNameField: Components['FolderNameField'] = ({
+ onChange,
+ label,
+ placeholder,
+ validationMessage,
+ onValidate,
+}) => {
+ const handleValidate = ({
+ target: { value },
+ }:
+ | React.ChangeEvent
+ | React.FocusEvent) => {
+ onValidate?.(value);
+ };
+ return (
+ {
+ const { value } = event.target;
+ handleValidate?.(event);
+ onChange?.(value);
+ }}
+ />
+ );
+};
+
+const DataRefresh: Components['DataRefresh'] = ({ onRefresh }) => {
+ return (
+ {
+ onRefresh?.();
+ }}
+ aria-label="Refresh data"
+ >
+
+
+ );
+};
+
+const ActionsList: Components['ActionsList'] = ({
+ items,
+ onActionSelect,
+ isDisabled,
+}) => {
+ return (
+
+
+
+ }
+ >
+ {items
+ .filter(({ isHidden }) => !isHidden)
+ .map(({ actionType, icon, label, isDisabled }, i) => {
+ return (
+ {
+ onActionSelect?.(actionType);
+ }}
+ >
+ {icon && }
+ {label}
+
+ );
+ })}
+
+ );
+};
+
+const StatusDisplay: Components['StatusDisplay'] = ({ statuses, total }) => {
+ if (!statuses?.length) {
+ return null;
+ }
+
+ return (
+
+ {statuses.map(({ name, count }, i) => (
+
+
+ {name}
+
+ {`${count}/${total}`}
+
+ ))}
+
+ );
+};
+
+const ActionDestination: Components['ActionDestination'] = ({
+ isNavigable,
+ items,
+ label,
+}) => {
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+
+ {label}
+
+
+
+ {items.map((item, i) => {
+ return (
+
+ {isNavigable ? (
+
+ {item.name}
+
+ ) : (
+ item.name
+ )}
+
+ {item.isCurrent ? null : }
+
+ );
+ })}
+
+
+
+ );
+};
+
+const Title: Components['Title'] = ({ title }) => {
+ return (
+
+ {title}
+
+ );
+};
+
+export const componentsDefault: Components = {
+ ActionDestination,
+ ActionsList,
+ DataRefresh,
+ LoadingIndicator,
+ Pagination,
+ Navigation,
+ OverwriteToggle,
+ SearchField,
+ SearchSubfoldersToggle,
+ StatusDisplay,
+ FolderNameField,
+ Title,
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionCancel.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionCancel.tsx
new file mode 100644
index 00000000000..7621107f449
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionCancel.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { ButtonElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface ActionCancelProps {
+ onCancel?: () => void;
+ isDisabled?: boolean;
+ label?: string;
+}
+
+export const ActionCancel = ({
+ onCancel,
+ isDisabled,
+ label,
+}: ActionCancelProps): React.JSX.Element => (
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx
new file mode 100644
index 00000000000..19fc141a577
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionDestination.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+import {
+ DescriptionListElement,
+ DescriptionTermElement,
+ DescriptionDetailsElement,
+ SpanElement,
+ ViewElement,
+} from '../context/elements';
+import { Separator } from '../components/Separator';
+import { NavigationProps } from './Navigation';
+import { BreadcrumbNavigation } from '../components/BreadcrumbNavigation';
+
+export interface ActionDestinationProps {
+ isNavigable?: boolean;
+ items: NavigationProps['items'];
+ label?: string;
+}
+
+export const ActionDestination = ({
+ isNavigable,
+ items,
+ label,
+}: ActionDestinationProps): React.JSX.Element | null => {
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ {isNavigable ? (
+ <>
+ {`${label}:`}
+
+ >
+ ) : (
+
+
+ {`${label}:`}
+
+ {items.map(({ name }, index) => {
+ return (
+
+
+ {name}
+
+ {index === items.length - 1 ? null : }
+
+ );
+ })}
+
+ )}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx
new file mode 100644
index 00000000000..5cdf32ff1b5
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionExit.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+
+import { ButtonElement, IconElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface ActionExitProps {
+ onExit?: () => void;
+ isDisabled?: boolean;
+ label?: string;
+}
+
+export const ActionExit = ({
+ onExit,
+ isDisabled = false,
+ label,
+}: ActionExitProps): React.JSX.Element => (
+
+ {' '}
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionStart.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionStart.tsx
new file mode 100644
index 00000000000..9e7b751925c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionStart.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { ButtonElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface ActionStartProps {
+ onStart?: () => void;
+ isDisabled?: boolean;
+ label?: string;
+}
+
+export const ActionStart = ({
+ onStart,
+ isDisabled,
+ label,
+}: ActionStartProps): React.JSX.Element => (
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx b/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx
new file mode 100644
index 00000000000..8516fc1dc1c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/ActionsList.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { DropdownMenu } from '../components/DropdownMenu';
+import { StorageBrowserIconType } from '../context/elements';
+
+export interface ActionsListItem {
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ icon?: StorageBrowserIconType;
+ actionType: string;
+ label?: string;
+}
+
+export interface ActionsListProps {
+ isDisabled?: boolean;
+ items: ActionsListItem[];
+ onActionSelect?: (id: string) => void;
+}
+
+export const ActionsList = ({
+ isDisabled,
+ items,
+ onActionSelect,
+}: ActionsListProps): React.JSX.Element => {
+ return (
+ ({ ...item, id: item.actionType }))}
+ onItemSelect={onActionSelect}
+ />
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/AddFiles.tsx b/packages/react-storage/src/components/StorageBrowser/composables/AddFiles.tsx
new file mode 100644
index 00000000000..8926bb7bf9b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/AddFiles.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { ButtonElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface AddFilesProps {
+ onAddFiles?: () => void;
+ isDisabled?: boolean;
+ label?: string;
+}
+
+export const AddFiles = ({
+ onAddFiles,
+ isDisabled,
+ label,
+}: AddFilesProps): React.JSX.Element => (
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/AddFolder.tsx b/packages/react-storage/src/components/StorageBrowser/composables/AddFolder.tsx
new file mode 100644
index 00000000000..e81b8059fe8
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/AddFolder.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { ButtonElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface AddFolderProps {
+ onAddFolder?: () => void;
+ isDisabled?: boolean;
+ label?: string;
+}
+
+export const AddFolder = ({
+ onAddFolder,
+ isDisabled,
+ label,
+}: AddFolderProps): React.JSX.Element => (
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataRefresh.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataRefresh.tsx
new file mode 100644
index 00000000000..6a140e6cb09
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataRefresh.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { ButtonElement } from '../context/elements/definitions';
+import { IconElement } from '../context/elements/IconElement';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface DataRefreshProps {
+ onRefresh?: () => void;
+ isDisabled?: boolean;
+}
+
+export const DataRefresh = ({
+ onRefresh,
+ isDisabled = false,
+}: DataRefreshProps): React.JSX.Element => (
+
+
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/DataTable.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/DataTable.tsx
new file mode 100644
index 00000000000..2e1ad89ded1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/DataTable.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+
+import { Table } from '../../components/Table';
+import { SortHeader } from './headers/SortHeader';
+import { TextHeader } from './headers/TextHeader';
+import { ButtonDataCell } from './dataCells/ButtonDataCell';
+import { DateDataCell } from './dataCells/DateDataCell';
+import { NumberDataCell } from './dataCells/NumberDataCell';
+import { CheckboxDataCell } from './dataCells/CheckboxDataCell';
+import { TextDataCell } from './dataCells/TextDataCell';
+import { DataTableDataCell, DataTableHeader } from './types';
+import { WithKey } from '../../components/types';
+import { CheckboxHeader } from './headers/CheckboxHeader';
+
+export interface DataTableRow {
+ content: WithKey[];
+}
+
+export interface DataTableProps {
+ headers: WithKey[];
+ rows: WithKey[];
+}
+
+export const DataTable = ({
+ headers,
+ rows,
+}: DataTableProps): React.JSX.Element => {
+ const mappedHeaders = headers.map(({ key, content, type }) => {
+ switch (type) {
+ case 'checkbox': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'sort': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'text':
+ default: {
+ return {
+ key,
+ content: ,
+ };
+ }
+ }
+ });
+
+ const mappedRows = rows.map(({ key, content }) => ({
+ key,
+ content: content.map(({ key, content, type }) => {
+ switch (type) {
+ case 'button': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'checkbox': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'date': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'number': {
+ return {
+ key,
+ content: ,
+ };
+ }
+ case 'text':
+ default: {
+ return {
+ key,
+ content: ,
+ };
+ }
+ }
+ }),
+ }));
+
+ return ;
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/ButtonDataCell.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/ButtonDataCell.tsx
new file mode 100644
index 00000000000..7f4dc5f49dd
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/ButtonDataCell.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+
+import {
+ ButtonElement,
+ ButtonElementVariant,
+ IconElement,
+ StorageBrowserIconType,
+} from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../../../constants';
+
+export interface ButtonDataCellProps {
+ content: {
+ icon?: StorageBrowserIconType;
+ label?: string;
+ onClick?: () => void;
+ ariaLabel?: string;
+ isDisabled?: boolean;
+ };
+}
+
+export const ButtonDataCell = ({
+ content,
+}: ButtonDataCellProps): React.JSX.Element => {
+ const { ariaLabel, isDisabled, icon, label, onClick } = content;
+
+ // Special handling for icon-only cancel buttons
+ let buttonVariant: ButtonElementVariant = 'table-data';
+ const isIconOnlyButton = !!icon && !label;
+ if (isIconOnlyButton && icon === 'cancel') {
+ buttonVariant = 'cancel';
+ }
+
+ return (
+
+ {icon && (
+
+ )}
+ {label}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/CheckboxDataCell.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/CheckboxDataCell.tsx
new file mode 100644
index 00000000000..075aa73935b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/CheckboxDataCell.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Checkbox } from '@aws-amplify/ui-react';
+
+export interface CheckboxDataCellProps {
+ content: {
+ checked?: boolean;
+ label?: string;
+ onSelect?: () => void;
+ id?: string;
+ };
+}
+
+export const CheckboxDataCell = ({
+ content,
+}: CheckboxDataCellProps): React.JSX.Element => {
+ const { checked = false, label, onSelect, id } = content;
+ return (
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/DateDataCell.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/DateDataCell.tsx
new file mode 100644
index 00000000000..99ad83ba953
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/DateDataCell.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { ViewElement } from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../../../constants';
+
+export interface DateDataCellProps {
+ content: {
+ value?: Date;
+ displayValue?: string;
+ };
+}
+
+export const DateDataCell = ({
+ content,
+}: DateDataCellProps): React.JSX.Element => {
+ const { value, displayValue } = content;
+ return (
+
+ {displayValue ?? value?.toLocaleString()}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/NumberDataCell.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/NumberDataCell.tsx
new file mode 100644
index 00000000000..b2be29a0595
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/NumberDataCell.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import { ViewElement } from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../../../constants';
+
+export interface NumberDataCellProps {
+ content: {
+ value?: number;
+ displayValue?: string;
+ };
+}
+
+export const NumberDataCell = ({
+ content,
+}: NumberDataCellProps): React.JSX.Element => {
+ const { displayValue, value } = content;
+ return (
+
+ {displayValue ?? value}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/TextDataCell.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/TextDataCell.tsx
new file mode 100644
index 00000000000..42522265401
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/dataCells/TextDataCell.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+
+import {
+ IconElement,
+ StorageBrowserIconType,
+ SpanElement,
+ ViewElement,
+} from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../../../constants';
+
+export interface TextDataCellProps {
+ content: {
+ icon?: StorageBrowserIconType;
+ text?: string;
+ };
+}
+
+export const TextDataCell = ({
+ content,
+}: TextDataCellProps): React.JSX.Element => {
+ const { icon, text } = content;
+ return (
+
+ {icon && (
+
+ )}
+
+ {text}
+
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/CheckboxHeader.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/CheckboxHeader.tsx
new file mode 100644
index 00000000000..d9c63b70e26
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/CheckboxHeader.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { Checkbox } from '@aws-amplify/ui-react';
+
+export type CheckboxHeaderProps = {
+ content: {
+ checked?: boolean;
+ label?: string;
+ onSelect?: () => void;
+ id?: string;
+ };
+};
+
+export const CheckboxHeader = ({
+ content,
+}: CheckboxHeaderProps): React.JSX.Element => {
+ const { checked = false, label, onSelect, id } = content;
+ return (
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/SortHeader.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/SortHeader.tsx
new file mode 100644
index 00000000000..75e9c53e1b4
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/SortHeader.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { ButtonElement, IconElement } from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../../../constants';
+
+import { SortDirection } from '../types';
+
+export type SortHeaderProps = {
+ content: {
+ label?: string;
+ sortDirection?: SortDirection;
+ onSort?: () => void;
+ };
+};
+
+export const SortHeader = ({ content }: SortHeaderProps): React.JSX.Element => {
+ const { label, sortDirection, onSort } = content;
+ return (
+
+ {label}
+
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/TextHeader.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/TextHeader.tsx
new file mode 100644
index 00000000000..1d617246e4b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/headers/TextHeader.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { ViewElement } from '../../../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../../../constants';
+
+export type TextHeaderProps = {
+ content: {
+ text?: string;
+ };
+};
+
+export const TextHeader = ({ content }: TextHeaderProps): React.JSX.Element => {
+ const { text } = content;
+ return (
+
+ {text}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/index.ts b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/index.ts
new file mode 100644
index 00000000000..ee0c9a91c96
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/index.ts
@@ -0,0 +1,13 @@
+export { DataTable, DataTableProps } from './DataTable';
+export {
+ DataTableSortHeader,
+ DataTableTextHeader,
+ DataTableHeader,
+ DataTableButtonDataCell,
+ DataTableCheckboxDataCell,
+ DataTableDateDataCell,
+ DataTableNumberDataCell,
+ DataTableTextDataCell,
+ DataTableDataCell,
+ SortDirection,
+} from './types';
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DataTable/types.ts b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/types.ts
new file mode 100644
index 00000000000..50b6a173773
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DataTable/types.ts
@@ -0,0 +1,54 @@
+import { CheckboxHeaderProps } from './headers/CheckboxHeader';
+import { SortHeaderProps } from './headers/SortHeader';
+import { TextHeaderProps } from './headers/TextHeader';
+import { ButtonDataCellProps } from './dataCells/ButtonDataCell';
+import { CheckboxDataCellProps } from './dataCells/CheckboxDataCell';
+import { DateDataCellProps } from './dataCells/DateDataCell';
+import { NumberDataCellProps } from './dataCells/NumberDataCell';
+import { TextDataCellProps } from './dataCells/TextDataCell';
+
+export interface DataTableCheckboxHeader extends CheckboxHeaderProps {
+ type: 'checkbox';
+}
+
+export interface DataTableSortHeader extends SortHeaderProps {
+ type: 'sort';
+}
+
+export interface DataTableTextHeader extends TextHeaderProps {
+ type: 'text';
+}
+
+export type DataTableHeader =
+ | DataTableCheckboxHeader
+ | DataTableSortHeader
+ | DataTableTextHeader;
+
+export interface DataTableButtonDataCell extends ButtonDataCellProps {
+ type: 'button';
+}
+
+export interface DataTableCheckboxDataCell extends CheckboxDataCellProps {
+ type: 'checkbox';
+}
+
+export interface DataTableDateDataCell extends DateDataCellProps {
+ type: 'date';
+}
+
+export interface DataTableNumberDataCell extends NumberDataCellProps {
+ type: 'number';
+}
+
+export interface DataTableTextDataCell extends TextDataCellProps {
+ type: 'text';
+}
+
+export type DataTableDataCell =
+ | DataTableButtonDataCell
+ | DataTableDateDataCell
+ | DataTableNumberDataCell
+ | DataTableCheckboxDataCell
+ | DataTableTextDataCell;
+
+export type SortDirection = 'ascending' | 'descending';
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/DropZone.tsx b/packages/react-storage/src/components/StorageBrowser/composables/DropZone.tsx
new file mode 100644
index 00000000000..078a916a5ec
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/DropZone.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { useDropZone } from '@aws-amplify/ui-react-core';
+
+import { ViewElement } from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface DropZoneProps {
+ children?: React.ReactNode;
+ onDropFiles?: (files: File[]) => void;
+}
+
+export const DropZone = ({
+ children,
+ onDropFiles,
+}: DropZoneProps): React.JSX.Element => {
+ const { dragState, ...dropHandlers } = useDropZone({
+ onDropComplete: ({ acceptedFiles }) => {
+ onDropFiles?.(acceptedFiles);
+ },
+ });
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/FolderNameField.tsx b/packages/react-storage/src/components/StorageBrowser/composables/FolderNameField.tsx
new file mode 100644
index 00000000000..a9d9f92cdce
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/FolderNameField.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+
+import { Field } from '../components/Field';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface FolderNameFieldProps {
+ id?: string;
+ isDisabled?: boolean;
+ label?: string;
+ placeholder?: string;
+ onChange?: (value: string) => void;
+ onValidate?: (value: string) => void;
+ validationMessage?: React.ReactNode;
+}
+
+export function FolderNameField({
+ id,
+ isDisabled,
+ label,
+ onChange,
+ onValidate,
+ placeholder,
+ validationMessage,
+}: FolderNameFieldProps): React.JSX.Element {
+ const handleValidate = ({
+ target: { value },
+ }:
+ | React.ChangeEvent
+ | React.FocusEvent) => {
+ onValidate?.(value);
+ };
+
+ return (
+ {
+ if (validationMessage) handleValidate?.(event);
+
+ onChange?.(event.target.value);
+ }}
+ placeholder={placeholder}
+ type="text"
+ >
+ {validationMessage}
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/LoadingIndicator.tsx b/packages/react-storage/src/components/StorageBrowser/composables/LoadingIndicator.tsx
new file mode 100644
index 00000000000..c906fbf71e7
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/LoadingIndicator.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { IconElement, SpanElement, ViewElement } from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface LoadingIndicatorProps {
+ label?: string;
+ isLoading?: boolean;
+}
+export function LoadingIndicator({
+ label,
+ isLoading,
+}: LoadingIndicatorProps): React.JSX.Element | null {
+ return !isLoading ? null : (
+
+
+
+ {label}
+
+
+ );
+}
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/Message.tsx b/packages/react-storage/src/components/StorageBrowser/composables/Message.tsx
new file mode 100644
index 00000000000..72759f3c8cf
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/Message.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+
+import {
+ ButtonElement,
+ IconElement,
+ MessageVariant,
+ ViewElement,
+} from '../context/elements';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export type MessageType = MessageVariant;
+
+export interface MessageProps {
+ content?: React.ReactNode;
+ id?: string;
+ onDismiss?: (id?: string) => void;
+ type?: MessageType;
+}
+
+export const Message = ({
+ content,
+ id,
+ onDismiss,
+ type,
+}: MessageProps): React.JSX.Element | null => {
+ let ariaLabel;
+ switch (type) {
+ case 'error':
+ ariaLabel = 'Error';
+ break;
+ case 'info':
+ ariaLabel = 'Information';
+ break;
+ case 'warning':
+ ariaLabel = 'Warning';
+ break;
+ case 'success':
+ ariaLabel = 'Success';
+ break;
+ }
+
+ return !content ? null : (
+
+
+
+ {content}
+
+ {!onDismiss ? null : (
+ onDismiss(id)}
+ className={`${STORAGE_BROWSER_BLOCK}__message-dismiss`}
+ variant="message-dismiss"
+ aria-label="Dismiss message"
+ role="button"
+ >
+
+
+ )}
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/Navigation.tsx b/packages/react-storage/src/components/StorageBrowser/composables/Navigation.tsx
new file mode 100644
index 00000000000..b1b179187e1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/Navigation.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { BreadcrumbNavigation } from '../components/BreadcrumbNavigation';
+
+interface NavigationItem {
+ isCurrent?: boolean;
+ name?: string;
+ onNavigate?: () => void;
+}
+
+export interface NavigationProps {
+ items: NavigationItem[];
+}
+
+export const Navigation = ({
+ items,
+}: NavigationProps): React.JSX.Element | null => {
+ if (!items.length) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/OverwriteToggle.tsx b/packages/react-storage/src/components/StorageBrowser/composables/OverwriteToggle.tsx
new file mode 100644
index 00000000000..44a2d44b9ee
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/OverwriteToggle.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+import { InputElement, ViewElement, LabelElement } from '../context/elements/';
+
+const OVERWRITE_TOGGLE_ID = 'overwrite-toggle';
+
+export interface OverwriteToggleProps {
+ isDisabled?: boolean;
+ isOverwritingEnabled?: boolean;
+ label?: string;
+ onToggle?: () => void;
+}
+
+export const OverwriteToggle = ({
+ isOverwritingEnabled,
+ isDisabled,
+ label,
+ onToggle,
+}: OverwriteToggleProps): React.JSX.Element => (
+
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/Pagination.tsx b/packages/react-storage/src/components/StorageBrowser/composables/Pagination.tsx
new file mode 100644
index 00000000000..32ef3de3451
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/Pagination.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+import {
+ ListItemElement,
+ NavElement,
+ OrderedListElement,
+ SpanElement,
+} from '../context/elements';
+import { PaginationButton } from '../components/PaginationButton';
+
+export interface PaginationProps {
+ page?: number;
+ hasNextPage?: boolean;
+ onPaginate?: (page: number) => void;
+ highestPageVisited?: number;
+}
+
+export const Pagination = ({
+ page,
+ hasNextPage,
+ onPaginate,
+ highestPageVisited,
+}: PaginationProps): React.JSX.Element | null => {
+ if (!page) return null;
+
+ return (
+
+
+
+ {
+ if (onPaginate) onPaginate(page - 1);
+ }}
+ type="previous"
+ />
+
+
+
+ {page}
+
+
+
+ = highestPageVisited && !hasNextPage)
+ }
+ onClick={() => {
+ if (onPaginate) onPaginate(page + 1);
+ }}
+ type="next"
+ />
+
+
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/SearchField.tsx b/packages/react-storage/src/components/StorageBrowser/composables/SearchField.tsx
new file mode 100644
index 00000000000..8c0a9a1fc76
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/SearchField.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+
+import { Field } from '../components/Field';
+import { ButtonElement, IconElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK_TO_BE_UPDATED } from '../constants';
+
+export interface SearchFieldProps {
+ id?: string;
+ label?: string;
+ clearLabel?: string;
+ submitLabel?: string;
+ query?: string;
+ placeholder?: string;
+ onSearch?: () => void;
+ onClear?: () => void;
+ onQueryChange?: (query: string) => void;
+}
+
+export const SearchField = ({
+ id,
+ label,
+ clearLabel,
+ submitLabel,
+ onSearch,
+ onClear,
+ placeholder,
+ query = '',
+ onQueryChange,
+}: SearchFieldProps): React.JSX.Element => {
+ // FIXME: focus not returning to input field after clear
+
+ return (
+ <>
+
+ }
+ className={`${STORAGE_BROWSER_BLOCK_TO_BE_UPDATED}__search-field`}
+ variant="search"
+ onChange={(e) => {
+ onQueryChange?.(e.target.value);
+ }}
+ placeholder={placeholder}
+ onKeyUp={(event) => {
+ if (event.key === 'Enter') {
+ onSearch?.();
+ }
+ }}
+ value={query}
+ >
+ {query ? (
+
+
+
+ ) : null}
+
+
+ {submitLabel}
+
+ >
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/SearchSubfoldersToggle.tsx b/packages/react-storage/src/components/StorageBrowser/composables/SearchSubfoldersToggle.tsx
new file mode 100644
index 00000000000..1e7de54b336
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/SearchSubfoldersToggle.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+import { InputElement, ViewElement, LabelElement } from '../context/elements/';
+
+const SEARCH_SUBFOLDERS_TOGGLE_ID = 'search-subfolders-toggle';
+
+export interface SearchSubfoldersToggleProps {
+ isSearchingSubfolders?: boolean;
+ label?: string;
+ onToggle?: () => void;
+}
+
+export const SearchSubfoldersToggle = ({
+ isSearchingSubfolders,
+ label,
+ onToggle,
+}: SearchSubfoldersToggleProps): React.JSX.Element => (
+
+
+ {label}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/StatusDisplay.tsx b/packages/react-storage/src/components/StorageBrowser/composables/StatusDisplay.tsx
new file mode 100644
index 00000000000..c9fee7a95fb
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/StatusDisplay.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { DescriptionList } from '../components/DescriptionList';
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+interface Status {
+ name: string;
+ count: number;
+}
+
+export interface StatusDisplayProps {
+ statuses: Status[];
+ total: number;
+}
+
+export const StatusDisplay = ({
+ statuses,
+ total,
+}: StatusDisplayProps): React.JSX.Element | null => {
+ if (!statuses?.length) {
+ return null;
+ }
+
+ const descriptions = statuses.map(({ name, count }) => ({
+ term: name,
+ details: `${count}/${total}`,
+ }));
+
+ return (
+
+ );
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/Title.tsx b/packages/react-storage/src/components/StorageBrowser/composables/Title.tsx
new file mode 100644
index 00000000000..ca95fbba8c9
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/Title.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { HeadingElement } from '../context/elements';
+import { STORAGE_BROWSER_BLOCK } from '../constants';
+
+export interface TitleProps {
+ title?: string;
+}
+
+export const Title = ({ title }: TitleProps): React.JSX.Element => (
+
+ {title}
+
+);
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionCancel.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionCancel.spec.tsx
new file mode 100644
index 00000000000..2bcc54c6567
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionCancel.spec.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ActionCancel } from '../ActionCancel';
+
+describe('ActionCancel', () => {
+ it('renders a button element', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ });
+
+ it('renders a button with the expected label', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toHaveTextContent('Cancel');
+ });
+
+ it('renders a button with the expected disabled state', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toBeDisabled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionDestination.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionDestination.spec.tsx
new file mode 100644
index 00000000000..60968ad4597
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionDestination.spec.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ActionDestination } from '../ActionDestination';
+
+jest.mock('../../components/BreadcrumbNavigation', () => ({
+ BreadcrumbNavigation: () =>
,
+}));
+
+describe('ActionDestination', () => {
+ const item = 'Destination item';
+ const items = [
+ { name: `${item} 1`, onNavigate: jest.fn() },
+ { name: `${item} 2`, onNavigate: jest.fn(), isCurrent: true },
+ ];
+ const label = 'Destination label';
+
+ it('renders', () => {
+ render( );
+
+ const list = screen.getByRole('list');
+ const term = screen.getByRole('term');
+ const definitions = screen.getAllByRole('definition');
+
+ expect(list).toBeInTheDocument();
+ expect(term).toHaveTextContent(label);
+ expect(definitions[0]).toHaveTextContent(`${item} 1`);
+ expect(definitions[1]).toHaveTextContent(`${item} 2`);
+ });
+
+ it('renders a breadcrumbs navigation if destination should be navigable', () => {
+ render( );
+
+ const navigation = screen.getByTestId('breadcrumb-navigation');
+
+ expect(navigation).toBeInTheDocument();
+ expect(navigation.previousSibling).toHaveTextContent(label);
+ });
+
+ it('returns null if there are no navigation items', () => {
+ render( );
+
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionExit.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionExit.spec.tsx
new file mode 100644
index 00000000000..f891c78f868
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionExit.spec.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ActionExit } from '../ActionExit';
+
+const label = 'Leave?';
+
+describe('ActionExit', () => {
+ it('renders a button element', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeInTheDocument();
+ });
+
+ it('renders a button with the expected label', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toHaveTextContent(label);
+ });
+
+ it('renders an icon', () => {
+ render( );
+
+ const icon = screen.getByRole('img', { hidden: true });
+
+ expect(icon).toBeInTheDocument();
+ });
+
+ it('renders a button with the expected disabled state', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeDisabled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionStart.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionStart.spec.tsx
new file mode 100644
index 00000000000..228d04b9239
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionStart.spec.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ActionStart } from '../ActionStart';
+
+describe('ActionStart', () => {
+ it('renders a button element', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ });
+
+ it('renders a button with the expected text', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toHaveTextContent('Start');
+ });
+
+ it('renders a button with the expected disabled state', () => {
+ render( );
+ const button = screen.getByRole('button');
+ expect(button).toBeDisabled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionsList.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionsList.spec.tsx
new file mode 100644
index 00000000000..1be9042c7d0
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/ActionsList.spec.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ActionsList } from '../ActionsList';
+
+jest.mock('../../components/DropdownMenu', () => ({
+ DropdownMenu: () =>
,
+}));
+
+describe('ActionsList', () => {
+ it('renders', () => {
+ render( );
+
+ const menu = screen.getByTestId('dropdown-menu');
+
+ expect(menu).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFiles.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFiles.spec.tsx
new file mode 100644
index 00000000000..6144a86355b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFiles.spec.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { AddFiles } from '../AddFiles';
+
+describe('AddFiles', () => {
+ const label = 'add-files-label';
+ const mockOnAddFiles = jest.fn();
+
+ afterEach(() => {
+ mockOnAddFiles.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveTextContent(label);
+ });
+
+ it('can be disabled', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeDisabled();
+ });
+
+ it('calls onToggle', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+ button.click();
+
+ expect(mockOnAddFiles).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFolder.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFolder.spec.tsx
new file mode 100644
index 00000000000..87dd5437b77
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/AddFolder.spec.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { AddFolder } from '../AddFolder';
+
+describe('AddFolder', () => {
+ const label = 'add-folder-label';
+ const mockOnAddFolder = jest.fn();
+
+ afterEach(() => {
+ mockOnAddFolder.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveTextContent(label);
+ });
+
+ it('can be disabled', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+
+ expect(button).toBeDisabled();
+ });
+
+ it('calls onToggle', () => {
+ render( );
+
+ const button = screen.getByRole('button');
+ button.click();
+
+ expect(mockOnAddFolder).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataRefresh.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataRefresh.spec.tsx
new file mode 100644
index 00000000000..564db6dabc1
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataRefresh.spec.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { DataRefresh } from '../DataRefresh';
+
+describe('DataRefresh', () => {
+ it('renders', () => {
+ render( );
+
+ const button = screen.getByRole('button', {
+ name: 'Refresh data',
+ });
+
+ const icon = button.querySelector('svg');
+
+ expect(button).toBeInTheDocument();
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveAttribute('aria-hidden', 'true');
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/DataTable.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/DataTable.spec.tsx
new file mode 100644
index 00000000000..45dcd4733c0
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/DataTable.spec.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { DataTable } from '../../DataTable';
+
+describe('DataTable', () => {
+ it('renders', () => {
+ const headers = [
+ {
+ key: 'header-1',
+ type: 'checkbox' as const,
+ content: { onSelect: jest.fn() },
+ },
+ {
+ key: 'header-2',
+ type: 'sort' as const,
+ content: { label: 'header-2-sort' },
+ },
+ {
+ key: 'header-3',
+ type: 'text' as const,
+ content: { text: 'header-3-text' },
+ },
+ ];
+
+ const date = new Date(1726704000000);
+ const rows = [
+ {
+ key: 'row-1',
+ content: [
+ {
+ key: 'row-1 data-cell-1',
+ type: 'checkbox' as const,
+ content: { onSelect: jest.fn() },
+ },
+ {
+ key: 'row-1 data-cell-2',
+ type: 'button' as const,
+ content: { label: 'row-1-button' },
+ },
+ {
+ key: 'row-1 data-cell-3',
+ type: 'date' as const,
+ content: { value: date, displayValue: date.toLocaleString() },
+ },
+ ],
+ },
+ {
+ key: 'row-2',
+ content: [
+ {
+ key: 'row-2 data-cell-1',
+ type: 'checkbox' as const,
+ content: { onSelect: jest.fn() },
+ },
+ {
+ key: 'row-2 data-cell-2',
+ type: 'number' as const,
+ content: { displayValue: 'row-2-number' },
+ },
+ {
+ key: 'row-2 data-cell-3',
+ type: 'text' as const,
+ content: { text: 'row-2-text' },
+ },
+ ],
+ },
+ ];
+
+ render( );
+
+ const table = screen.getByRole('table');
+ const tableRowGroups = screen.getAllByRole('rowgroup');
+ const tableRows = screen.getAllByRole('row');
+ const tableHeaders = screen.getAllByRole('columnheader');
+ const tableDataCells = screen.getAllByRole('cell');
+ const [headerCheckbox, row1Checkbox, row2Checkbox] =
+ screen.getAllByRole('checkbox');
+
+ expect(table).toBeInTheDocument();
+ expect(tableRowGroups).toHaveLength(2);
+ expect(tableRows).toHaveLength(3);
+ expect(tableHeaders).toHaveLength(3);
+ expect(tableDataCells).toHaveLength(6);
+
+ const [header1, header2, header3] = tableHeaders;
+ expect(header1).toContainElement(headerCheckbox);
+ expect(header2).toHaveTextContent('header-2-sort');
+ expect(header3).toHaveTextContent('header-3-text');
+
+ const [
+ row1DataCell1,
+ row1DataCell2,
+ row1DataCell3,
+ row2DataCell1,
+ row2DataCell2,
+ row2DataCell3,
+ ] = tableDataCells;
+ expect(row1DataCell1).toContainElement(row1Checkbox);
+ expect(row1DataCell2).toHaveTextContent('row-1-button');
+ expect(row1DataCell3).toHaveTextContent(/.+/); // Expect any string rather than deal with mocking locale
+ expect(row2DataCell1).toContainElement(row2Checkbox);
+ expect(row2DataCell2).toHaveTextContent('row-2-number');
+ expect(row2DataCell3).toHaveTextContent('row-2-text');
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/ButtonDataCell.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/ButtonDataCell.spec.tsx
new file mode 100644
index 00000000000..5868a50304b
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/ButtonDataCell.spec.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { ButtonDataCell } from '../../../DataTable/dataCells/ButtonDataCell';
+
+describe('ButtonDataCell', () => {
+ it('renders', () => {
+ const { getByRole } = render(
+
+ );
+
+ const buttonDataCell = getByRole('button');
+
+ expect(buttonDataCell).toHaveTextContent('data-cell-button');
+ });
+
+ it('renders with an icon', () => {
+ const { container, getByRole } = render(
+
+ );
+
+ const buttonDataCell = getByRole('button');
+ const svg = container.querySelector('svg');
+
+ expect(buttonDataCell).toBeInTheDocument();
+ expect(svg).toBeInTheDocument();
+ });
+
+ it('renders with only an icon', () => {
+ const { container } = render( );
+
+ const svg = container.querySelector('svg');
+
+ expect(svg?.parentElement).toHaveTextContent('');
+ expect(svg).toBeInTheDocument();
+ });
+
+ it('renders button with aria-label', () => {
+ const { container, getByRole } = render(
+
+ );
+
+ const buttonDataCell = getByRole('button');
+ const svg = container.querySelector('svg');
+ expect(svg?.parentElement).toHaveTextContent('');
+ expect(svg).toBeInTheDocument();
+ expect(buttonDataCell).toHaveAttribute('aria-label', 'label');
+ });
+
+ it('renders disabled button', () => {
+ const { getByRole } = render(
+
+ );
+
+ const buttonDataCell = getByRole('button');
+ expect(buttonDataCell).toBeDisabled();
+ });
+
+ it('can be clicked', () => {
+ const mockOnClick = jest.fn();
+ const { getByRole } = render(
+
+ );
+
+ const buttonDataCell = getByRole('button');
+ buttonDataCell.click();
+
+ expect(mockOnClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/CheckboxDataCell.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/CheckboxDataCell.spec.tsx
new file mode 100644
index 00000000000..1da361edb80
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/CheckboxDataCell.spec.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { CheckboxDataCell } from '../../../DataTable/dataCells/CheckboxDataCell';
+
+describe('CheckboxDataCell', () => {
+ it('renders', () => {
+ render(
+
+ );
+
+ const checkboxDataCell = screen.getByRole('checkbox');
+
+ expect(checkboxDataCell).toBeInTheDocument();
+ expect(checkboxDataCell).not.toBeChecked();
+ });
+
+ it('renders as checked', () => {
+ render(
+
+ );
+
+ const checkboxDataCell = screen.getByRole('checkbox');
+
+ expect(checkboxDataCell).toBeChecked();
+ });
+
+ it('can be selected', () => {
+ const mockOnSelect = jest.fn();
+ render( );
+
+ const checkboxDataCell = screen.getByRole('checkbox');
+ checkboxDataCell.click();
+
+ expect(mockOnSelect).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/DateDataCell.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/DateDataCell.spec.tsx
new file mode 100644
index 00000000000..6269387c90f
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/DateDataCell.spec.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { DateDataCell } from '../../../DataTable/dataCells/DateDataCell';
+
+describe('DateDataCell', () => {
+ const date = new Date(1726704000000);
+ it('renders', () => {
+ const { container } = render(
+
+ );
+
+ const dateDataCell = container.querySelector('div');
+ expect(dateDataCell).toHaveTextContent(/.+/); // Expect any string rather than deal with mocking locale
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/NumberDataCell.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/NumberDataCell.spec.tsx
new file mode 100644
index 00000000000..419af625469
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/NumberDataCell.spec.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { NumberDataCell } from '../../../DataTable/dataCells/NumberDataCell';
+
+describe('NumberDataCell', () => {
+ it('renders', () => {
+ render(
+
+ );
+
+ const numberDataCell = screen.getByText('data-cell-number');
+
+ expect(numberDataCell).toBeInTheDocument();
+ });
+
+ it('falls back to raw value if no displayValue is provided', () => {
+ render( );
+
+ const numberDataCell = screen.getByText('123');
+
+ expect(numberDataCell).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/TextDataCell.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/TextDataCell.spec.tsx
new file mode 100644
index 00000000000..a8b81ac91fc
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/dataCells/TextDataCell.spec.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { TextDataCell } from '../../../DataTable/dataCells/TextDataCell';
+
+describe('TextDataCell', () => {
+ it('renders', () => {
+ render( );
+
+ const textDataCell = screen.getByText('data-cell-text');
+
+ expect(textDataCell).toBeInTheDocument();
+ });
+
+ it('renders with an icon', () => {
+ const { container } = render(
+
+ );
+
+ const textDataCell = screen.getByText('data-cell-text');
+ const svg = container.querySelector('svg');
+
+ expect(textDataCell).toBeInTheDocument();
+ expect(textDataCell).toHaveAttribute('title', 'data-cell-text');
+ expect(svg).toBeInTheDocument();
+ });
+
+ it('renders with only an icon', () => {
+ const { container } = render( );
+
+ const svg = container.querySelector('svg');
+
+ expect(svg?.parentElement).toHaveTextContent('');
+ expect(svg).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/CheckboxHeader.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/CheckboxHeader.spec.tsx
new file mode 100644
index 00000000000..3b0d91a237d
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/CheckboxHeader.spec.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { CheckboxHeader } from '../../../DataTable/headers/CheckboxHeader';
+
+describe('CheckboxHeader', () => {
+ it('renders', () => {
+ render(
+
+ );
+
+ const checkboxHeader = screen.getByRole('checkbox');
+
+ expect(checkboxHeader).toBeInTheDocument();
+ expect(checkboxHeader).not.toBeChecked();
+ });
+
+ it('renders as checked', () => {
+ render( );
+
+ const checkboxHeader = screen.getByRole('checkbox');
+
+ expect(checkboxHeader).toBeChecked();
+ });
+
+ it('can be selected', () => {
+ const mockOnSelect = jest.fn();
+ render( );
+
+ const checkboxDataCell = screen.getByRole('checkbox');
+ checkboxDataCell.click();
+
+ expect(mockOnSelect).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/SortHeader.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/SortHeader.spec.tsx
new file mode 100644
index 00000000000..5604fc3a850
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/SortHeader.spec.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { SortHeader } from '../../../DataTable/headers/SortHeader';
+
+describe('SortHeader', () => {
+ it('renders', () => {
+ const { container } = render(
+
+ );
+
+ const sortHeader = screen.getByRole('button');
+ const svg = container.querySelector('svg');
+
+ expect(sortHeader).toBeInTheDocument();
+ expect(svg).toBeInTheDocument();
+ expect(sortHeader).toHaveTextContent('Header-sort');
+ });
+
+ it('can be sorted', () => {
+ const mockOnSort = jest.fn();
+ render( );
+
+ const sortHeader = screen.getByRole('button');
+ sortHeader.click();
+
+ expect(mockOnSort).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/TextHeader.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/TextHeader.spec.tsx
new file mode 100644
index 00000000000..3c89c986204
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DataTable/headers/TextHeader.spec.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { TextHeader } from '../../../DataTable/headers/TextHeader';
+
+describe('TextHeader', () => {
+ it('renders', () => {
+ render( );
+
+ const textHeader = screen.getByText('header-text');
+
+ expect(textHeader).toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DropZone.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DropZone.spec.tsx
new file mode 100644
index 00000000000..86d313780ad
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/DropZone.spec.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { useDropZone } from '@aws-amplify/ui-react-core';
+import { DropZone } from '../DropZone';
+
+jest.mock('@aws-amplify/ui-react-core');
+
+describe('DropZone', () => {
+ // assert mocks
+ const mockUseDropZone = useDropZone as jest.Mock;
+
+ beforeEach(() => {
+ mockUseDropZone.mockReturnValue({ dragState: 'inactive' });
+ });
+
+ afterEach(() => {
+ mockUseDropZone.mockReset();
+ });
+
+ it('renders', () => {
+ render(
+
+
+
+ );
+
+ const child = screen.getByRole('table');
+
+ expect(child.parentElement).toHaveClass(
+ 'amplify-storage-browser__drop-zone'
+ );
+ });
+
+ it('calls onDropFiles', () => {
+ const files = [new File([], '')];
+ const mockOnDropFiles = jest.fn();
+ mockUseDropZone.mockImplementation(
+ ({
+ onDropComplete,
+ }: {
+ onDropComplete: ({ acceptedFiles }: { acceptedFiles: File[] }) => void;
+ }) => {
+ onDropComplete({ acceptedFiles: files });
+ return { dragState: 'inactive' };
+ }
+ );
+
+ render(
+
+
+
+ );
+
+ expect(mockOnDropFiles).toHaveBeenCalledWith(files);
+ });
+
+ it('appends an active modifier', () => {
+ mockUseDropZone.mockReturnValue({ dragState: 'accept' });
+
+ render(
+
+
+
+ );
+
+ const child = screen.getByRole('table');
+
+ expect(child.parentElement).toHaveClass(
+ 'amplify-storage-browser__drop-zone--active'
+ );
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/FolderNameField.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/FolderNameField.spec.tsx
new file mode 100644
index 00000000000..606d55c2542
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/FolderNameField.spec.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent, { UserEvent } from '@testing-library/user-event';
+
+import { FolderNameField } from '../FolderNameField';
+
+const placeholder = 'Placeholder';
+const id = 'test-id';
+const onChange = jest.fn();
+const onValidate = jest.fn();
+
+const message = 'Invalid!';
+const validationMessage = {message} ;
+
+describe('FolderNameField', () => {
+ let user: UserEvent;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ user = userEvent.setup();
+ });
+
+ it('has expected validation behavior', async () => {
+ const { rerender } = render(
+
+ );
+
+ const field = screen.getByPlaceholderText('Placeholder');
+ expect(field).toBeInTheDocument();
+
+ expect(field).toHaveAttribute('aria-describedby', id);
+
+ await user.type(field, 'hi!');
+
+ expect(onChange).toHaveBeenCalledTimes(3);
+ expect(onValidate).not.toHaveBeenCalled();
+
+ await user.tab();
+
+ expect(onValidate).toHaveBeenCalledTimes(1);
+
+ await user.clear(field);
+
+ expect(field).toHaveAttribute('aria-invalid', 'false');
+
+ rerender(
+
+ );
+
+ expect(field).toHaveAttribute('aria-invalid', 'true');
+
+ const span = screen.getByText(message);
+ expect(span).toBeInTheDocument();
+
+ await user.type(field, 'bye!');
+
+ expect(onValidate).toHaveBeenCalledTimes(5);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/LoadingIndicator.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/LoadingIndicator.spec.tsx
new file mode 100644
index 00000000000..c6bc1a5c5c4
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/LoadingIndicator.spec.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { LoadingIndicator } from '../LoadingIndicator';
+
+const label = 'Loading!';
+
+describe('LoadingIndicator', () => {
+ it('renders when isLoading is true', () => {
+ render( );
+
+ const loadingSpan = screen.getByText(label);
+ expect(loadingSpan).toBeInTheDocument();
+ expect(loadingSpan).toHaveAttribute('aria-live', 'polite');
+ });
+
+ it('does not render when isLoading is false', () => {
+ render( );
+
+ const loadingSpan = screen.queryByText(label);
+ expect(loadingSpan).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Message.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Message.spec.tsx
new file mode 100644
index 00000000000..affb8b5db87
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Message.spec.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { capitalize } from '@aws-amplify/ui';
+import userEvent, { UserEvent } from '@testing-library/user-event';
+
+import { Message, MessageType } from '../Message';
+
+const content = 'Something to say!';
+
+describe('Message', () => {
+ let user: UserEvent;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ });
+
+ it.each(['error', 'warning', 'success'] as MessageType[])(
+ 'renders with the expected values when provided content and type %s',
+ (type) => {
+ render( );
+
+ const message = screen.getByRole('alert');
+ expect(message).toBeInTheDocument();
+
+ const icon = screen.getByLabelText(capitalize(type));
+
+ expect(icon).toBeInTheDocument();
+ }
+ );
+
+ it('renders with the expected values when provided content and type info', () => {
+ render( );
+
+ const message = screen.getByText(content);
+ expect(message).toBeInTheDocument();
+
+ const icon = screen.getByLabelText('Information');
+ expect(icon).toBeInTheDocument();
+ });
+
+ it('does not render when content is undefined', () => {
+ render( );
+
+ const message = screen.queryByRole('alert');
+ expect(message).not.toBeInTheDocument();
+ });
+
+ it('renders dismiss button when provided onDismiss', async () => {
+ const onDismiss = jest.fn();
+
+ render( );
+
+ const message = screen.getByText(content);
+ expect(message).toBeInTheDocument();
+
+ const btn = screen.getByRole('button');
+ expect(btn).toBeInTheDocument();
+
+ await user.click(btn);
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Navigation.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Navigation.spec.tsx
new file mode 100644
index 00000000000..865d0df6971
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Navigation.spec.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Navigation } from '../Navigation';
+
+describe('Navigation', () => {
+ it('renders', () => {
+ const item = 'Navigation item';
+ const items = [
+ { name: `${item} 1`, onNavigate: jest.fn() },
+ { name: `${item} 2`, onNavigate: jest.fn(), isCurrent: true },
+ ];
+
+ render( );
+
+ const listItems = screen.getAllByRole('listitem');
+
+ expect(listItems[0]).toHaveTextContent(`${item} 1`);
+ expect(listItems[1]).toHaveTextContent(`${item} 2`);
+ });
+
+ it('returns null if there are no navigation items', () => {
+ render( );
+
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/OverwriteToggle.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/OverwriteToggle.spec.tsx
new file mode 100644
index 00000000000..2d773ce66de
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/OverwriteToggle.spec.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { OverwriteToggle } from '../OverwriteToggle';
+
+describe('OverwriteToggle', () => {
+ const label = 'overwrite-label';
+ const mockOnToggle = jest.fn();
+
+ afterEach(() => {
+ mockOnToggle.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).not.toBeChecked();
+ expect(checkbox.nextSibling).toHaveTextContent(label);
+ });
+
+ it('can be disabled', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+
+ expect(checkbox).toBeDisabled();
+ });
+
+ it('can be checked', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+
+ expect(checkbox).toBeChecked();
+ });
+
+ it('calls onToggle', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+ checkbox.click();
+
+ expect(mockOnToggle).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Pagination.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Pagination.spec.tsx
new file mode 100644
index 00000000000..3eab7e842d3
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Pagination.spec.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Pagination } from '../Pagination';
+
+describe('Pagination', () => {
+ it('renders the Pagination composable', async () => {
+ render(
+
+ );
+
+ const nav = screen.getByRole('navigation', {
+ name: 'Pagination',
+ });
+ const list = screen.getByRole('list');
+ const listItems = await screen.findAllByRole('listitem');
+ const nextButton = screen.getByRole('button', { name: 'Go to next page' });
+ const prevButton = screen.getByRole('button', {
+ name: 'Go to previous page',
+ });
+ const nextIcon = nextButton.querySelector('svg');
+ const prevIcon = nextButton.querySelector('svg');
+ const page = screen.getByText('1');
+
+ expect(nextButton).toBeInTheDocument();
+ expect(prevButton).toBeInTheDocument();
+ expect(nextIcon).toBeInTheDocument();
+ expect(prevIcon).toBeInTheDocument();
+ expect(page).toBeInTheDocument();
+ expect(nextIcon).toHaveAttribute('aria-hidden', 'true');
+ expect(prevIcon).toHaveAttribute('aria-hidden', 'true');
+ expect(nav).toBeInTheDocument();
+ expect(list).toBeInTheDocument();
+ expect(listItems).toHaveLength(3);
+ });
+
+ it('returns null if page is not provided', () => {
+ const { container } = render(
+
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('disables next button when on last page', () => {
+ render(
+
+ );
+
+ const nextButton = screen.getByRole('button', { name: 'Go to next page' });
+ expect(nextButton).toBeDisabled();
+ });
+
+ it('disables prev button when on first page', () => {
+ render(
+
+ );
+
+ const prevButton = screen.getByRole('button', {
+ name: 'Go to previous page',
+ });
+ expect(prevButton).toBeDisabled();
+ });
+
+ it('disables next button if highestPageVisited is not provided', () => {
+ render(
+
+ );
+
+ const nextButton = screen.getByRole('button', { name: 'Go to next page' });
+ expect(nextButton).toBeDisabled();
+ });
+
+ it('calls onPaginate when next button is clicked', () => {
+ const onPaginate = jest.fn();
+ render(
+
+ );
+
+ const nextButton = screen.getByRole('button', { name: 'Go to next page' });
+ nextButton.click();
+ expect(onPaginate).toHaveBeenCalledWith(2);
+ });
+
+ it('calls onPaginate when previous button is clicked', () => {
+ const onPaginate = jest.fn();
+ render(
+
+ );
+
+ const nextButton = screen.getByRole('button', {
+ name: 'Go to previous page',
+ });
+ nextButton.click();
+ expect(onPaginate).toHaveBeenCalledWith(1);
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchField.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchField.spec.tsx
new file mode 100644
index 00000000000..779c7fd20a9
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchField.spec.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { SearchField } from '../SearchField';
+
+describe('Search', () => {
+ it('renders the Search composable', () => {
+ render(
+
+ );
+
+ const field = screen.getByPlaceholderText('Placeholder');
+ const button = screen.getByRole('button', { name: 'Submit' });
+
+ expect(button).toBeInTheDocument();
+ expect(field).toBeInTheDocument();
+ });
+
+ it('calls onSearch when submit button is clicked', () => {
+ const onSearch = jest.fn();
+
+ render( );
+
+ const submitButton = screen.getByRole('button', { name: 'Submit' });
+ act(() => {
+ submitButton.click();
+ });
+ expect(onSearch).toHaveBeenCalled();
+ });
+
+ it('calls onSearch when Enter key is pressed', async () => {
+ const user = userEvent.setup();
+ const onSearch = jest.fn();
+
+ const { getByRole } = render( );
+
+ const input = getByRole('textbox');
+ input.focus();
+ await act(async () => {
+ await user.keyboard('boo');
+ await user.keyboard('{Enter}');
+ });
+
+ expect(onSearch).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchSubfoldersToggle.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchSubfoldersToggle.spec.tsx
new file mode 100644
index 00000000000..d241b593d64
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/SearchSubfoldersToggle.spec.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { SearchSubfoldersToggle } from '../SearchSubfoldersToggle';
+
+describe('SearchSubfoldersToggle', () => {
+ const label = 'overwrite-label';
+ const mockOnToggle = jest.fn();
+
+ afterEach(() => {
+ mockOnToggle.mockClear();
+ });
+
+ it('renders', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+
+ expect(checkbox).toBeInTheDocument();
+ expect(checkbox).not.toBeChecked();
+ expect(checkbox.nextSibling).toHaveTextContent(label);
+ });
+
+ it('can be checked', () => {
+ render(
+
+ );
+
+ const checkbox = screen.getByRole('checkbox');
+
+ expect(checkbox).toBeChecked();
+ });
+
+ it('calls onToggle', () => {
+ render( );
+
+ const checkbox = screen.getByRole('checkbox');
+ checkbox.click();
+
+ expect(mockOnToggle).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/StatusDisplay.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/StatusDisplay.spec.tsx
new file mode 100644
index 00000000000..2963b55e36c
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/StatusDisplay.spec.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { StatusDisplay } from '../StatusDisplay';
+
+describe('StatusDisplay', () => {
+ it('renders', () => {
+ const statuses = [
+ { name: 'completed', count: 4 },
+ { name: 'failed', count: 3 },
+ { name: 'canceled', count: 2 },
+ { name: 'queued', count: 1 },
+ ];
+
+ render( );
+
+ const [completed, failed, canceled, queued] =
+ screen.getAllByRole('definition');
+
+ expect(completed).toHaveTextContent('4/10');
+ expect(failed).toHaveTextContent('3/10');
+ expect(canceled).toHaveTextContent('2/10');
+ expect(queued).toHaveTextContent('1/10');
+ });
+
+ it('returns null if there are no statuses to display', () => {
+ render( );
+
+ const list = screen.queryByRole('list');
+
+ expect(list).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Title.spec.tsx b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Title.spec.tsx
new file mode 100644
index 00000000000..6dc778b2059
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/__tests__/Title.spec.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Title } from '../Title';
+
+describe('Title', () => {
+ it('renders', () => {
+ const expectedTitle = 'StorageBrowser';
+
+ render( );
+
+ const [renderedTitle] = screen.getAllByRole('heading');
+
+ expect(renderedTitle).toHaveTextContent('StorageBrowser');
+ });
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/context.ts b/packages/react-storage/src/components/StorageBrowser/composables/context.ts
new file mode 100644
index 00000000000..16c5050a9a8
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/context.ts
@@ -0,0 +1,9 @@
+import { createContextUtilities } from '@aws-amplify/ui-react-core';
+import { ComposablesContext } from './types';
+
+const defaultValue: ComposablesContext = {};
+
+export const { useComposables, ComposablesProvider } = createContextUtilities({
+ contextName: 'Composables',
+ defaultValue,
+});
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts b/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts
new file mode 100644
index 00000000000..1681570811a
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/defaults.ts
@@ -0,0 +1,45 @@
+import { ActionCancel } from './ActionCancel';
+import { ActionDestination } from './ActionDestination';
+import { ActionExit } from './ActionExit';
+import { ActionStart } from './ActionStart';
+import { ActionsList } from './ActionsList';
+import { AddFiles } from './AddFiles';
+import { AddFolder } from './AddFolder';
+import { DataRefresh } from './DataRefresh';
+import { DataTable } from './DataTable';
+import { DropZone } from './DropZone';
+import { FolderNameField } from './FolderNameField';
+import { LoadingIndicator } from './LoadingIndicator';
+import { Message } from './Message';
+import { Navigation } from './Navigation';
+import { OverwriteToggle } from './OverwriteToggle';
+import { Pagination } from './Pagination';
+import { SearchField } from './SearchField';
+import { SearchSubfoldersToggle } from './SearchSubfoldersToggle';
+import { StatusDisplay } from './StatusDisplay';
+import { Title } from './Title';
+
+import { Composables } from './types';
+
+export const DEFAULT_COMPOSABLES: Composables = {
+ ActionCancel,
+ ActionDestination,
+ ActionExit,
+ ActionStart,
+ ActionsList,
+ AddFiles,
+ AddFolder,
+ DataRefresh,
+ DataTable,
+ DropZone,
+ FolderNameField,
+ LoadingIndicator,
+ Message,
+ Navigation,
+ OverwriteToggle,
+ Pagination,
+ SearchSubfoldersToggle,
+ SearchField,
+ StatusDisplay,
+ Title,
+};
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/index.ts b/packages/react-storage/src/components/StorageBrowser/composables/index.ts
new file mode 100644
index 00000000000..cb969d1965d
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/index.ts
@@ -0,0 +1,3 @@
+export { ComposablesProvider } from './context';
+export { DEFAULT_COMPOSABLES } from './defaults';
+export { Composables } from './types';
diff --git a/packages/react-storage/src/components/StorageBrowser/composables/types.ts b/packages/react-storage/src/components/StorageBrowser/composables/types.ts
new file mode 100644
index 00000000000..d3a31a4be19
--- /dev/null
+++ b/packages/react-storage/src/components/StorageBrowser/composables/types.ts
@@ -0,0 +1,47 @@
+import { ActionCancelProps } from './ActionCancel';
+import { ActionDestinationProps } from './ActionDestination';
+import { ActionExitProps } from './ActionExit';
+import { ActionStartProps } from './ActionStart';
+import { ActionsListProps } from './ActionsList';
+import { AddFilesProps } from './AddFiles';
+import { AddFolderProps } from './AddFolder';
+import { DataRefreshProps } from './DataRefresh';
+import { DataTableProps } from './DataTable';
+import { DropZoneProps } from './DropZone';
+import { LoadingIndicatorProps } from './LoadingIndicator';
+import { MessageProps } from './Message';
+import { FolderNameFieldProps } from './FolderNameField';
+import { NavigationProps } from './Navigation';
+import { OverwriteToggleProps } from './OverwriteToggle';
+import { StatusDisplayProps } from './StatusDisplay';
+import { PaginationProps } from './Pagination';
+import { SearchFieldProps } from './SearchField';
+import { SearchSubfoldersToggleProps } from './SearchSubfoldersToggle';
+import { TitleProps } from './Title';
+
+export interface Composables {
+ ActionCancel: React.ComponentType;
+ ActionDestination: React.ComponentType;
+ ActionExit: React.ComponentType;
+ ActionStart: React.ComponentType;
+ ActionsList: React.ComponentType;
+ AddFiles: React.ComponentType;
+ AddFolder: React.ComponentType;
+ DataRefresh: React.ComponentType;
+ DataTable: React.ComponentType;
+ DropZone: React.ComponentType;
+ FolderNameField: React.ComponentType;
+ LoadingIndicator: React.ComponentType;
+ Message: React.ComponentType;
+ Navigation: React.ComponentType;
+ OverwriteToggle: React.ComponentType;
+ Pagination: React.ComponentType;
+ SearchField: React.ComponentType;
+ SearchSubfoldersToggle: React.ComponentType