Skip to content

feat(storage-browser): Multi File Download #6620

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-queens-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aws-amplify/ui-react-storage": minor
---

feat(storage-browser): Multi File Download
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StorageBrowser, useView } from './StorageBrowser';
import { ComposedCopyView } from './ComposedCopyView';
import { ComposedCreateFolderView } from './ComposedCreateFolderView';
import { ComposedDeleteView } from './ComposedDeleteView';
import { ComposedDownloadView } from './ComposedDownloadView';
import { ComposedUploadView } from './ComposedUploadView';

function LocationsView() {
Expand Down Expand Up @@ -52,6 +53,8 @@ function MyLocationActionView() {
return <ComposedCreateFolderView onExit={onExit} />;
case 'delete':
return <ComposedDeleteView onExit={onExit} />;
case 'download':
return <ComposedDownloadView onExit={onExit} />;
case 'upload':
return <ComposedUploadView onExit={onExit} />;
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react';
import { StorageBrowser, useView } from './StorageBrowser';

export function ComposedDownloadView({ onExit }: { onExit: () => void }) {
const state = useView('Download');

return (
<StorageBrowser.DownloadView.Provider {...state} onActionExit={onExit}>
<StorageBrowser.DownloadView.Exit />
<StorageBrowser.DownloadView.TasksTable />
<StorageBrowser.DownloadView.Start />
<StorageBrowser.DownloadView.Message />
</StorageBrowser.DownloadView.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as React from 'react';
import { StorageBrowser, useView } from './StorageBrowser';
import { CustomDeleteView } from './CustomDeleteView';
import { CustomCopyView } from './CustomCopyView';
import { CustomCreateFolderView } from './CustomCreateFolderView';
import { CustomUploadView } from './CustomUploadView';
import { CustomDeleteView } from './CustomDeleteView';
import { CustomDownloadView } from './CustomDownloadView';
import { CustomLocationsView } from './CustomLocationsView';
import { CustomUploadView } from './CustomUploadView';

function MyLocationActionView() {
const state = useView('LocationDetail');
Expand All @@ -19,6 +20,8 @@ function MyLocationActionView() {
return <CustomCreateFolderView onExit={onExit} />;
case 'delete':
return <CustomDeleteView onExit={onExit} />;
case 'download':
return <CustomDownloadView onExit={onExit} />;
case 'upload':
return <CustomUploadView onExit={onExit} />;
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Button, Flex, Text } from '@aws-amplify/ui-react';
import { StorageBrowser, useView } from './StorageBrowser';
import { useView } from './StorageBrowser';

export function CustomDeleteView({ onExit }: { onExit: () => void }) {
const state = useView('Delete');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import { Button, Flex, Text, View } from '@aws-amplify/ui-react';
import { useView } from './StorageBrowser';

export function CustomDownloadView({ onExit }: { onExit: () => void }) {
const state = useView('Download');

return (
<Flex direction="column">
<Button variation="link" alignSelf="flex-start" onClick={onExit}>
Exit
</Button>
{state.tasks.map((task) => (
<Flex key={task.data.key} direction="row">
<Text>{task.data.key}</Text>
</Flex>
))}
<Button onClick={state.onActionStart}>
Download {state.tasks.length} files
</Button>
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CreateStorageBrowserInput,
DeleteHandlerOutput,
DownloadHandlerOutput,
UploadHandlerOutput,
} from '@aws-amplify/ui-react-storage/browser';
import uniqueId from 'lodash/uniqueId';
Expand Down Expand Up @@ -59,13 +60,27 @@ export const defaultActions: CreateStorageBrowserInput['actions']['default'] = {
},
viewName: 'DeleteView',
},
download: () => {
return {
result: Promise.resolve({
status: 'COMPLETE' as const,
value: { url: new URL('') },
}),
};
download: {
actionListItem: {
icon: 'download',
label: 'Download',
},
handler: () => {
const result: DownloadHandlerOutput['result'] = new Promise((resolve) => {
setTimeout(() => {
resolve({
status: 'COMPLETE' as const,
// creating URL with empty string will throw TypeError,
// preventing promise from resolving
value: { url: null },
});
}, 500);
});
return {
result,
};
},
viewName: 'DownloadView',
},
upload: {
actionListItem: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ Below you wll find examples of how to compose each view. See [View reference](#v
<Tabs.Item value="Locations">Locations</Tabs.Item>
<Tabs.Item value="LocationDetail">Location Detail</Tabs.Item>
<Tabs.Item value="Upload">Upload</Tabs.Item>
<Tabs.Item value="Download">Download</Tabs.Item>
<Tabs.Item value="Copy">Copy</Tabs.Item>
<Tabs.Item value="CreateFolder">CreateFolder</Tabs.Item>
<Tabs.Item value="Delete">Delete</Tabs.Item>
Expand Down Expand Up @@ -396,6 +397,12 @@ Below you wll find examples of how to compose each view. See [View reference](#v
```
</ExampleCode>
</Tabs.Panel>
<Tabs.Panel value="Download">
<ExampleCode>
```jsx file=./examples/ComposedDownloadView.tsx
```
</ExampleCode>
</Tabs.Panel>
<Tabs.Panel value="Copy">
<ExampleCode>
```jsx file=./examples/ComposedCopyView.tsx
Expand All @@ -415,7 +422,7 @@ Below you wll find examples of how to compose each view. See [View reference](#v

The `createStorageBrowser` function returns a `useView()` hook that lets you use the Storage Browser's internal state and event handlers to build your own UI on top the Storage Browser functionality.

The `useView()` hook takes a single argument which is the name of the view you want to get the UI state and event handlers for. The available views are `Locations`, `LocationDetails`, `Copy`, `Upload`, `Delete`, and `CreateFolder`. The return value of the hook will have all the necessary internal state and event handlers required to build that view. In fact, the Storage Browser component itself uses the `useView()` to manage its state, so you can build the UI exactly how we do!
The `useView()` hook takes a single argument which is the name of the view you want to get the UI state and event handlers for. The available views are `Locations`, `LocationDetails`, `Copy`, `Upload`, `Download`, `Delete`, and `CreateFolder`. The return value of the hook will have all the necessary internal state and event handlers required to build that view. In fact, the Storage Browser component itself uses the `useView()` to manage its state, so you can build the UI exactly how we do!

<Example>
<Custom />
Expand All @@ -424,6 +431,7 @@ The `useView()` hook takes a single argument which is the name of the view you w
<Tabs.Item value="StorageBrowser">StorageBrowser</Tabs.Item>
<Tabs.Item value="Locations">Locations</Tabs.Item>
<Tabs.Item value="Upload">Upload</Tabs.Item>
<Tabs.Item value="Download">Download</Tabs.Item>
<Tabs.Item value="Copy">Copy</Tabs.Item>
<Tabs.Item value="CreateFolder">CreateFolder</Tabs.Item>
<Tabs.Item value="Delete">Delete</Tabs.Item>
Expand Down Expand Up @@ -457,6 +465,12 @@ The `useView()` hook takes a single argument which is the name of the view you w
```jsx file=./examples/CustomUploadView.tsx
```
</ExampleCode>
</Tabs.Panel>
<Tabs.Panel value="Download">
<ExampleCode>
```jsx file=./examples/CustomDownloadView.tsx
```
</ExampleCode>
</Tabs.Panel>
<Tabs.Panel value="Delete">
<ExampleCode>
Expand All @@ -479,7 +493,7 @@ The following `default` actions are invoked by the default UI views:
* `copy`: Copy data to another location within a `bucket` or `prefix` in [Copy action view](#copy-view).
* `createFolder`: Create a folder within a `bucket` or `prefix` in [CreateFolder action view](#createfolder-view).
* `delete`: Delete a given object in [Delete action view](#delete-view).
* `download`: Download a given object in [LocationDetails view](#locationdetails-view) using an [anchor link](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download).
* `download`: Download a given object in [LocationDetails view](#locationdetails-view) using an [anchor link](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download) or in [Download action view](#download-view).
* `upload`: Upload a file in [Upload action view](#upload-view).

`defaultActionConfigs` allows access to the default implementation of these actions. Below is an example to instrument the default upload action to count the files being uploaded.
Expand Down Expand Up @@ -824,6 +838,36 @@ interface UploadViewState {
* `<StorageBrowser.UploadView.Title />`


### Download view

#### Download view state

```ts
interface DownloadViewState {
isProcessing: boolean;
isProcessingComplete: boolean;
location: LocationState;
onActionCancel: () => void;
onActionExit: () => void;
onActionStart: () => void;
onTaskRemove?: (task: Task<DeleteHandlerData>) => void;
statusCounts: StatusCounts;
tasks: Task<DeleteHandlerData>[];
}
```

#### Download view components

* `<StorageBrowser.DownloadView.Provider />`
* `<StorageBrowser.DownloadView.Cancel />`
* `<StorageBrowser.DownloadView.Exit />`
* `<StorageBrowser.DownloadView.Message />`
* `<StorageBrowser.DownloadView.Start />`
* `<StorageBrowser.DownloadView.Statuses />`
* `<StorageBrowser.DownloadView.TasksTable />`
* `<StorageBrowser.DownloadView.Title />`


### Copy view

#### Copy view state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ Feature: Create folder with Storage Browser
When I click the "Exit" button
# list uploaded file
Then I see "1" files with random names
# download file
Then I click checkbox for with "1" files with random names
When I click the "Menu Toggle" button
Then I click the "Download" menuitem
Then I click the "Download" button
Then I see "All files downloaded"
When I click the "Exit" button
# copy file
Then I click checkbox for with "1" files with random names
When I click the "Menu Toggle" button
Expand Down Expand Up @@ -174,3 +181,12 @@ Feature: Create folder with Storage Browser
When A network failure occurs
Then I click the "Delete" button
Then I see "All files failed to delete"
@react
Scenario: Download file shows a Network error if offline
When I click the first button containing "public"
Then I click checkbox for file "001_dont_delete_file.txt"
When I click the "Menu Toggle" button
Then I click the "Download" menuitem
When A network failure occurs
Then I click the "Download" button
Then I see "All files failed to download"
4 changes: 2 additions & 2 deletions packages/react-storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"name": "createStorageBrowser",
"path": "dist/esm/browser.mjs",
"import": "{ createStorageBrowser }",
"limit": "64.5 kB",
"limit": "64.6 kB",
"ignore": [
"@aws-amplify/storage"
]
Expand All @@ -75,7 +75,7 @@
"name": "StorageBrowser",
"path": "dist/esm/index.mjs",
"import": "{ StorageBrowser }",
"limit": "87 kB"
"limit": "88 kB"
},
{
"name": "FileUploader",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ exports[`defaultActionConfigs matches expected shape 1`] = `
"handler": [Function],
"viewName": "DeleteView",
},
"download": [Function],
"download": {
"actionListItem": {
"disable": [Function],
"hide": [Function],
"icon": "download",
"label": "Download",
},
"handler": [Function],
"viewName": "DownloadView",
},
"listLocationItems": [Function],
"upload": {
"actionListItem": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ describe('defaultActionConfigs', () => {
});
});

describe('download', () => {
const { disable, hide } = defaultActionConfigs.download.actionListItem;
it('hides the action list item as expected', () => {
for (const permissionsWithoutDownload of generateCombinations(
LOCATION_PERMISSION_VALUES.filter((value) => value !== 'get')
)) {
const permissionsWithDownload = [
...permissionsWithoutDownload,
'get' as const,
];
expect(hide?.(permissionsWithoutDownload)).toBe(true);
expect(hide?.(permissionsWithDownload)).toBe(false);
}
});

it('is disabled when no files are selected', () => {
expect(disable?.(undefined)).toBe(true);
expect(disable?.([])).toBe(true);
expect(disable?.([file])).toBe(false);
});
});

describe('copy', () => {
const { disable, hide } = defaultActionConfigs.copy.actionListItem;
it('hides the action list item as expected', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CopyActionConfig,
CreateFolderActionConfig,
DeleteActionConfig,
DownloadActionConfig,
UploadActionConfig,
} from './types';

Expand Down Expand Up @@ -49,12 +50,22 @@ export const uploadActionConfig: UploadActionConfig = {
handler: defaultHandlers.upload,
};

export const downloadActionConfig: DownloadActionConfig = {
viewName: 'DownloadView',
actionListItem: {
disable: (selected) => !selected || selected.length === 0,
hide: (permissions) => !permissions.includes('get'),
icon: 'download',
label: 'Download',
},
handler: defaultHandlers.download,
};

// Action view configs only, does not include `listLocationItems`
export const defaultActionViewConfigs = {
copy: copyActionConfig,
createFolder: createFolderActionConfig,
// provide `download` handler only; `download` does not have a dedicated view/config
download: defaultHandlers.download,
download: downloadActionConfig,
delete: deleteActionConfig,
upload: uploadActionConfig,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ export interface CopyActionConfig
export interface CreateFolderActionConfig
extends ActionViewConfig<CreateFolderHandler, 'CreateFolderView'> {}

export interface DownloadActionConfig
extends ActionViewConfig<DownloadHandler, 'DownloadView'> {}

export interface ListActionConfig<T> {
/**
* action handler
Expand All @@ -140,7 +143,7 @@ export interface DefaultActionConfigs {
listLocationItems?: ListLocationItemsHandler;
upload?: UploadActionConfig;
delete?: DeleteActionConfig;
download?: DownloadHandler;
download?: DownloadHandler | DownloadActionConfig;
copy?: CopyActionConfig;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getUrl } from '../../storage-internal';
import type {
OptionalFileData,
TaskData,
TaskHandler,
TaskHandlerInput,
Expand All @@ -9,7 +10,7 @@ import type {

import { constructBucket } from './utils';

export interface DownloadHandlerData extends TaskData {
export interface DownloadHandlerData extends OptionalFileData, TaskData {
fileKey: string;
}

Expand Down
Loading
Loading