From f5277c5fb4687d8d778469ff1b818dc687e58db2 Mon Sep 17 00:00:00 2001
From: Divyansh Kamboj
Date: Fri, 11 Oct 2024 11:18:18 +0530
Subject: [PATCH] Add Delete and Empty Bucket modals to S3 Browser
- Add Delete Bucket modal for bucket removal
- Add Empty Bucket modal for clearing bucket contents
- Implement success/failure alerts for operations
- Handle S3 API responses
Signed-off-by: Divyansh Kamboj
---
locales/en/plugin__odf-console.json | 17 +
.../bucket-overview/BucketOverview.tsx | 206 ++++++++++--
.../buckets-list-page/bucketListTable.tsx | 79 ++++-
.../buckets-list-page/bucketsListPage.tsx | 17 +
packages/odf/constants/s3-browser.ts | 1 +
.../DeleteBucketModal.tsx | 212 ++++++++++++
.../EmptyBucketModal.tsx | 309 ++++++++++++++++++
.../lazy-delete-and-empty-bucket.ts | 8 +
packages/shared/src/s3/commands.ts | 10 +
packages/shared/src/s3/types.ts | 12 +
10 files changed, 829 insertions(+), 42 deletions(-)
create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx
create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx
create mode 100644 packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts
diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json
index 7a119742d..b282c5061 100644
--- a/locales/en/plugin__odf-console.json
+++ b/locales/en/plugin__odf-console.json
@@ -1394,6 +1394,23 @@
"Organize objects within a bucket by creating virtual folders for easier management and navigation of objects.": "Organize objects within a bucket by creating virtual folders for easier management and navigation of objects.",
"Folders structure and group objects logically by using prefixes in object keys, without enforcing any physical hierarchy.": "Folders structure and group objects logically by using prefixes in object keys, without enforcing any physical hierarchy.",
"Folder name": "Folder name",
+ "Delete bucket permanently?": "Delete bucket permanently?",
+ "The bucket is being deleted. This may take a while.": "The bucket is being deleted. This may take a while.",
+ "Cannot delete this bucket: it is not empty": "Cannot delete this bucket: it is not empty",
+ "Bucket must be empty before it can be deleted. Use the <2>Empty bucket2> configuration to erase all the contents i.e. <5>objects5> of the bucket and try again.": "Bucket must be empty before it can be deleted. Use the <2>Empty bucket2> configuration to erase all the contents i.e. <5>objects5> of the bucket and try again.",
+ "Deleting a bucket cannot be undone.": "Deleting a bucket cannot be undone.",
+ "Bucket names are unique. If you delete a bucket, another S3 user can use the name.": "Bucket names are unique. If you delete a bucket, another S3 user can use the name.",
+ "Bucket name input": "Bucket name input",
+ "<0>To confirm deletion, type <1>{{bucketName}}1>:0>": "<0>To confirm deletion, type <1>{{bucketName}}1>:0>",
+ "Empty bucket permanently?": "Empty bucket permanently?",
+ "Emptying the bucket will permanentaly delete all objects. This action cannot be undone.": "Emptying the bucket will permanentaly delete all objects. This action cannot be undone.",
+ "Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).": "Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).",
+ "The bucket is being emptied. This may take a while to complete.": "The bucket is being emptied. This may take a while to complete.",
+ "Cannot empty bucket": "Cannot empty bucket",
+ "Check potential reasons": "Check potential reasons",
+ "Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.": "Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.",
+ "Successfully emptied bucket ": "Successfully emptied bucket ",
+ "Your bucket is now empty. If you want to delete this bucket, click Delete bucket": "Your bucket is now empty. If you want to delete this bucket, click Delete bucket",
"<0>To confirm deletion, type <1>{{delete}}1> in the text input field.0>": "<0>To confirm deletion, type <1>{{delete}}1> in the text input field.0>",
"Object name": "Object name",
"Delete object?": "Delete object?",
diff --git a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx
index 29eeb5984..206fb21b7 100644
--- a/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx
+++ b/packages/odf/components/s3-browser/bucket-overview/BucketOverview.tsx
@@ -1,8 +1,17 @@
import * as React from 'react';
import { BucketDetails } from '@odf/core/components/s3-browser/bucket-details/BucketDetails';
+import {
+ EmptyBucketAlerts,
+ EmptyBucketResponse,
+} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal';
+import {
+ LazyEmptyBucketModal,
+ LazyDeleteBucketModal,
+} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket';
import PageHeading from '@odf/shared/heading/page-heading';
import { useRefresh } from '@odf/shared/hooks';
import { ModalKeys, defaultModalMap } from '@odf/shared/modals/types';
+import { S3Commands } from '@odf/shared/s3';
import { BlueSyncIcon } from '@odf/shared/status';
import { K8sResourceKind } from '@odf/shared/types';
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
@@ -26,7 +35,7 @@ import { ActionsColumn, IAction } from '@patternfly/react-table';
import { PREFIX, BUCKETS_BASE_ROUTE } from '../../../constants';
import { NooBaaObjectBucketModel } from '../../../models';
import { getBreadcrumbs } from '../../../utils';
-import { NoobaaS3Provider } from '../noobaa-context';
+import { NoobaaS3Context, NoobaaS3Provider } from '../noobaa-context';
import { CustomActionsToggle } from '../objects-list';
import { ObjectListWithSidebar } from '../objects-list/ObjectListWithSidebar';
import { PageTitle } from './PageTitle';
@@ -47,16 +56,39 @@ const getBucketActionsItems = (
navigate: NavigateFunction,
bucketName: string,
isCreatedByOBC: boolean,
- noobaaObjectBucket: K8sResourceKind
+ noobaaS3: S3Commands,
+ noobaaObjectBucket: K8sResourceKind,
+ refreshTokens: () => void,
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >
): IAction[] => [
- // ToDo: add empty/delete bucket actions
{
title: t('Empty bucket'),
- onClick: () => undefined,
+ onClick: () =>
+ launcher(LazyEmptyBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+ }),
},
{
title: t('Delete bucket'),
- onClick: () => undefined,
+ onClick: () =>
+ launcher(LazyDeleteBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ launcher,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+ }),
},
...(isCreatedByOBC
? [
@@ -90,6 +122,53 @@ const getBucketActionsItems = (
: []),
];
+const createBucketActions = (
+ t: TFunction,
+ fresh: boolean,
+ triggerRefresh: () => void,
+ foldersPath: string | null,
+ launcher: ReturnType,
+ navigate: NavigateFunction,
+ bucketName: string,
+ isCreatedByOBC: boolean,
+ noobaaS3: S3Commands,
+ noobaaObjectBucket: K8sResourceKind,
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >
+) => {
+ return (
+ <>
+ }
+ onClick={triggerRefresh}
+ isDisabled={!fresh}
+ isInline
+ >
+ {t('Refresh')}
+
+ {!foldersPath && (
+
+ )}
+ >
+ );
+};
+
const BucketOverview: React.FC<{}> = () => {
const { t } = useCustomTranslation();
const [fresh, triggerRefresh] = useRefresh();
@@ -100,6 +179,12 @@ const BucketOverview: React.FC<{}> = () => {
const { bucketName } = useParams();
const [searchParams] = useSearchParams();
+ const [emptyBucketResponse, setEmptyBucketResponse] =
+ React.useState({
+ response: null,
+ bucketName: '',
+ });
+
// if non-empty means we are inside particular folder(s) of a bucket, else just inside a bucket (top-level)
const foldersPath = searchParams.get(PREFIX);
@@ -151,38 +236,84 @@ const BucketOverview: React.FC<{}> = () => {
: []),
];
- const actions = () => {
- return (
- <>
- }
- onClick={triggerRefresh}
- isDisabled={!fresh}
- isInline
- >
- {t('Refresh')}
-
- {!foldersPath && (
-
- )}
- >
+ const renderActions = (noobaaS3: S3Commands) => () =>
+ createBucketActions(
+ t,
+ fresh,
+ triggerRefresh,
+ foldersPath,
+ launcher,
+ navigate,
+ bucketName,
+ isCreatedByOBC,
+ noobaaS3,
+ noobaaObjectBucket,
+ setEmptyBucketResponse
);
- };
return (
+
+
+ );
+};
+
+type NavPage = {
+ href: string;
+ name: string;
+ component: React.ComponentType;
+};
+
+type BucketOverviewContentProps = {
+ breadcrumbs: { name: string; path: string }[];
+ foldersPath: string | null;
+ currentFolder: string;
+ isCreatedByOBC: boolean;
+ fresh: boolean;
+ triggerRefresh: () => void;
+ noobaaObjectBucket: K8sResourceKind;
+ navPages: NavPage[];
+ bucketName: string;
+ actions: (noobaaS3: S3Commands) => () => JSX.Element;
+ launcher: LaunchModal;
+ emptyBucketResponse: EmptyBucketResponse;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+};
+
+const BucketOverviewContent: React.FC = ({
+ breadcrumbs,
+ foldersPath,
+ currentFolder,
+ isCreatedByOBC,
+ fresh,
+ triggerRefresh,
+ noobaaObjectBucket,
+ navPages,
+ bucketName,
+ actions,
+ emptyBucketResponse,
+ setEmptyBucketResponse,
+}) => {
+ const { noobaaS3 } = React.useContext(NoobaaS3Context);
+
+ return (
+ <>
= () => {
noobaaObjectBucket={noobaaObjectBucket}
/>
}
- actions={actions}
+ actions={actions(noobaaS3)}
className="pf-v5-u-mt-md"
/>
+
= () => {
} as any
}
/>
-
+ >
);
};
diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx
index ebdb9f6b4..46f0d786c 100644
--- a/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx
+++ b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx
@@ -3,16 +3,24 @@ import {
BUCKET_BOOKMARKS_USER_SETTINGS_KEY,
BUCKETS_BASE_ROUTE,
} from '@odf/core/constants';
+import { EmptyBucketResponse } from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal';
+import {
+ LazyDeleteBucketModal,
+ LazyEmptyBucketModal,
+} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket';
import { BucketCrFormat } from '@odf/core/types';
import { Timestamp } from '@odf/shared/details-page/timestamp';
import { EmptyPage } from '@odf/shared/empty-state-page';
import { useUserSettingsLocalStorage } from '@odf/shared/hooks/useUserSettingsLocalStorage';
+import { S3Commands } from '@odf/shared/s3';
import {
ComposableTable,
RowComponentType,
} from '@odf/shared/table/composable-table';
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
import { sortRows } from '@odf/shared/utils';
+import { useModal } from '@openshift-console/dynamic-plugin-sdk';
+import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider';
import { TFunction } from 'react-i18next';
import { Link } from 'react-router-dom-v5-compat';
import { Bullseye, Label } from '@patternfly/react-core';
@@ -24,9 +32,18 @@ import {
Td,
Tr,
} from '@patternfly/react-table';
+import { NoobaaS3Context } from '../noobaa-context';
-const getRowActions = (t: TFunction): IAction[] => [
- // ToDo: add empty/delete bucket action
+const getRowActions = (
+ t: TFunction,
+ launcher: LaunchModal,
+ bucketName: string,
+ noobaaS3: S3Commands,
+ refreshTokens: () => void,
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >
+): IAction[] => [
{
title: (
<>
@@ -36,11 +53,30 @@ const getRowActions = (t: TFunction): IAction[] => [
>
),
- onClick: () => undefined,
+ onClick: () =>
+ launcher(LazyEmptyBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+ }),
},
{
title: t('Delete bucket'),
- onClick: () => undefined,
+ onClick: () =>
+ launcher(LazyDeleteBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ launcher,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+ }),
},
];
@@ -122,7 +158,15 @@ const BucketsTableRow: React.FC> = ({
apiResponse: { owner },
metadata: { name, creationTimestamp },
} = bucket;
- const { favorites, setFavorites }: RowExtraPropsType = extraProps;
+ const {
+ favorites,
+ setFavorites,
+ triggerRefresh,
+ setEmptyBucketResponse,
+ launcher,
+ }: RowExtraPropsType = extraProps;
+
+ const { noobaaS3 } = React.useContext(NoobaaS3Context);
const onSetFavorite = (key, active) => {
setFavorites((oldFavorites) => [
@@ -156,7 +200,16 @@ const BucketsTableRow: React.FC> = ({
{owner}
-
+
);
@@ -167,6 +220,7 @@ export const BucketsListTable: React.FC = ({
filteredBuckets,
loaded,
error,
+ setEmptyBucketResponse,
}) => {
const { t } = useCustomTranslation();
const [favorites, setFavorites] = useUserSettingsLocalStorage(
@@ -174,6 +228,8 @@ export const BucketsListTable: React.FC = ({
true,
[]
);
+ const launcher = useModal();
+
return (
= ({
loadError={error}
isFavorites={true}
variant={TableVariant.compact}
- extraProps={{ favorites, setFavorites }}
+ extraProps={{ favorites, setFavorites, setEmptyBucketResponse, launcher }}
/>
);
};
@@ -196,9 +252,18 @@ type BucketsListTableProps = {
filteredBuckets: BucketCrFormat[];
loaded: boolean;
error: any;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+ triggerRefresh: () => void;
};
type RowExtraPropsType = {
favorites: string[];
setFavorites: React.Dispatch>;
+ triggerRefresh: () => void;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+ launcher: LaunchModal;
};
diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx
index 46753b9ea..f410475c4 100644
--- a/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx
+++ b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx
@@ -1,4 +1,8 @@
import * as React from 'react';
+import {
+ EmptyBucketAlerts,
+ EmptyBucketResponse,
+} from '@odf/core/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal';
import { useRefresh } from '@odf/shared/hooks';
import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
import { getValidFilteredData } from '@odf/shared/utils';
@@ -31,12 +35,23 @@ const BucketsListPageBody: React.FC = ({
}) => {
const { t } = useCustomTranslation();
const [fresh, triggerRefresh] = useRefresh();
+ const [emptyBucketResponse, setEmptyBucketResponse] =
+ React.useState({
+ response: null,
+ bucketName: '',
+ });
+
const [buckets, loaded, loadError] = bucketInfo;
const [allBuckets, filteredBuckets, onFilterChange] =
useListPageFilter(buckets);
return (
+
@@ -71,6 +86,8 @@ const BucketsListPageBody: React.FC = ({
filteredBuckets={filteredBuckets}
loaded={loaded}
error={loadError}
+ setEmptyBucketResponse={setEmptyBucketResponse}
+ triggerRefresh={triggerRefresh}
/>
)}
diff --git a/packages/odf/constants/s3-browser.ts b/packages/odf/constants/s3-browser.ts
index f147ca80a..e210961b3 100644
--- a/packages/odf/constants/s3-browser.ts
+++ b/packages/odf/constants/s3-browser.ts
@@ -23,6 +23,7 @@ export const BUCKET_TAGGING_CACHE_KEY_SUFFIX = 'BUCKET_TAGGING_CACHE_KEY';
export const BUCKET_VERSIONING_CACHE_KEY_SUFFIX = 'BUCKET_VERSIONING_CACHE_KEY';
export const LIST_BUCKET = 'LIST_BUCKET_CACHE_KEY';
export const LIST_OBJECTS = 'LIST_OBJECTS_CACHE_KEY';
+export const LIST_VERSIONED_OBJECTS = 'LIST_VERSIONED_OBJECTS';
export const OBJECT_CACHE_KEY_SUFFIX = 'OBJECT_CACHE_KEY';
export const OBJECT_TAGGING_CACHE_KEY_SUFFIX = 'OBJECT_TAGGING_CACHE_KEY';
diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx b/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx
new file mode 100644
index 000000000..1bc434ce8
--- /dev/null
+++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/DeleteBucketModal.tsx
@@ -0,0 +1,212 @@
+import * as React from 'react';
+import {
+ BUCKET_BOOKMARKS_USER_SETTINGS_KEY,
+ LIST_VERSIONED_OBJECTS,
+} from '@odf/core/constants';
+import { useUserSettingsLocalStorage } from '@odf/shared';
+import { CommonModalProps } from '@odf/shared/modals';
+import { S3Commands } from '@odf/shared/s3';
+import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
+import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider';
+import { Trans } from 'react-i18next';
+import useSWR from 'swr';
+import {
+ Modal,
+ Button,
+ Text,
+ TextInput,
+ ModalVariant,
+ ValidatedOptions,
+ TextInputTypes,
+ TextVariants,
+ TextContent,
+ FormGroup,
+ Alert,
+ Spinner,
+ AlertVariant,
+ AlertActionLink,
+ ButtonVariant,
+} from '@patternfly/react-core';
+import { EmptyBucketResponse, getTextInputLabel } from './EmptyBucketModal';
+import { LazyEmptyBucketModal } from './lazy-delete-and-empty-bucket';
+
+type DeleteBucketModalProps = {
+ bucketName: string;
+ noobaaS3: S3Commands;
+ launcher: LaunchModal;
+ refreshTokens?: () => void;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+};
+
+const DeleteBucketModal: React.FC> = ({
+ closeModal,
+ isOpen,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ launcher,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+}) => {
+ const { t } = useCustomTranslation();
+ const [inputValue, setInputValue] = React.useState('');
+ const [inProgress, setInProgress] = React.useState(false);
+ const [deleteError, setDeleteError] = React.useState(null);
+
+ const {
+ data,
+ error,
+ isLoading: isChecking,
+ } = useSWR(`${bucketName}-${LIST_VERSIONED_OBJECTS}`, () =>
+ noobaaS3.listObjectVersions({ Bucket: bucketName, MaxKeys: 1 })
+ );
+ const hasObjects =
+ data?.Versions?.length > 0 || data?.DeleteMarkers?.length > 0;
+
+ const [_favorites, setFavorites] = useUserSettingsLocalStorage(
+ BUCKET_BOOKMARKS_USER_SETTINGS_KEY,
+ true,
+ []
+ );
+
+ const onDelete = async (event) => {
+ event.preventDefault();
+ setInProgress(true);
+
+ try {
+ await noobaaS3.deleteBucket({
+ Bucket: bucketName,
+ });
+
+ setInProgress(false);
+ closeModal();
+ setFavorites((oldFavorites) =>
+ oldFavorites.filter((bucket) => bucket !== bucketName)
+ );
+ refreshTokens?.();
+ } catch (err) {
+ setDeleteError(err);
+ setInProgress(false);
+ }
+ };
+
+ return (
+
+ {inProgress && (
+
+
+ {t('The bucket is being deleted. This may take a while.')}
+
+
+ )}
+
+ {t('Delete bucket')}
+
+
+
+ {t('Cancel')}
+
+ ,
+ ]}
+ >
+ {isChecking ? (
+
+ ) : (
+ hasObjects && (
+
+ launcher(LazyEmptyBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName,
+ noobaaS3,
+ refreshTokens,
+ setEmptyBucketResponse,
+ },
+ })
+ }
+ >
+ {t('Empty bucket')}
+
+ }
+ >
+
+
+ Bucket must be empty before it can be deleted. Use the{' '}
+ Empty bucket configuration to erase all the contents i.e.{' '}
+ objects of the bucket and try again.
+
+
+
+ )
+ )}
+
+
+ {t('Deleting a bucket cannot be undone.')}
+
+
+ {t(
+ 'Bucket names are unique. If you delete a bucket, another S3 user can use the name.'
+ )}
+
+
+
+
+ setInputValue(value)}
+ aria-label={t('Bucket name input')}
+ validated={
+ inputValue === bucketName
+ ? ValidatedOptions.success
+ : ValidatedOptions.default
+ }
+ placeholder={bucketName}
+ />
+
+ {(error || deleteError) && (
+
+ {error?.message || deleteError?.message}
+
+ )}
+
+ );
+};
+
+export default DeleteBucketModal;
diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx b/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx
new file mode 100644
index 000000000..562f17892
--- /dev/null
+++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/EmptyBucketModal.tsx
@@ -0,0 +1,309 @@
+import * as React from 'react';
+import { DeleteObjectsCommandOutput } from '@aws-sdk/client-s3';
+import { NoobaaS3Context } from '@odf/core/components/s3-browser/noobaa-context';
+import { CommonModalProps } from '@odf/shared/modals';
+import { S3Commands } from '@odf/shared/s3';
+import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook';
+import { useModal } from '@openshift-console/dynamic-plugin-sdk';
+import { TFunction } from 'i18next';
+import { Trans, useTranslation } from 'react-i18next';
+import {
+ Modal,
+ Button,
+ Text,
+ TextInput,
+ ModalVariant,
+ ValidatedOptions,
+ TextInputTypes,
+ TextVariants,
+ TextContent,
+ Alert,
+ AlertVariant,
+ AlertActionCloseButton,
+ AlertActionLink,
+ FormGroup,
+} from '@patternfly/react-core';
+import { LazyDeleteObjectsSummary } from '../delete-objects/LazyDeleteModals';
+import { LazyDeleteBucketModal } from './lazy-delete-and-empty-bucket';
+
+type EmptyBucketModalProps = {
+ bucketName: string;
+ noobaaS3: S3Commands;
+ refreshTokens?: () => void;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+};
+
+export const getTextInputLabel = (t: TFunction, bucketName: string) => (
+
+
+ To confirm deletion, type {{ bucketName }} :
+
+
+);
+
+const EmptyBucketModal: React.FC> = ({
+ closeModal,
+ isOpen,
+ extraProps: { bucketName, noobaaS3, refreshTokens, setEmptyBucketResponse },
+}) => {
+ const { t } = useCustomTranslation();
+ const [inputValue, setInputValue] = React.useState('');
+ const [inProgress, setInProgress] = React.useState(false);
+
+ const onEmpty = async (event) => {
+ event.preventDefault();
+ setInProgress(true);
+ let deleteResponse: DeleteObjectsCommandOutput;
+
+ try {
+ let isTruncated = true;
+ let keyMarker: string;
+ let versionIdMarker: string;
+
+ while (isTruncated) {
+ const deleteObjectKeys = [];
+ const searchParams = {
+ Bucket: bucketName,
+ KeyMarker: keyMarker,
+ VersionIdMarker: versionIdMarker,
+ };
+
+ // eslint-disable-next-line no-await-in-loop
+ const objects = await noobaaS3.listObjectVersions(searchParams);
+
+ if (objects?.Versions) {
+ deleteObjectKeys.push(
+ ...objects.Versions.map((object) => ({
+ Key: object.Key,
+ VersionId: object.VersionId,
+ }))
+ );
+ }
+ if (objects?.DeleteMarkers) {
+ deleteObjectKeys.push(
+ ...objects.DeleteMarkers.map((marker) => ({
+ Key: marker.Key,
+ VersionId: marker.VersionId,
+ }))
+ );
+ }
+
+ // eslint-disable-next-line no-await-in-loop
+ deleteResponse = await noobaaS3.deleteObjects({
+ Bucket: bucketName,
+ Delete: { Objects: deleteObjectKeys },
+ });
+
+ if (deleteResponse.Errors?.length > 0) {
+ throw new Error(`${deleteResponse.Errors.length} objects failed`);
+ }
+
+ isTruncated = objects.IsTruncated;
+ keyMarker = objects.NextKeyMarker;
+ versionIdMarker = objects.NextVersionIdMarker;
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Error while emptying bucket ', err);
+ } finally {
+ setInProgress(false);
+ closeModal();
+ setEmptyBucketResponse({
+ response: deleteResponse,
+ bucketName: bucketName,
+ });
+ refreshTokens?.();
+ }
+ };
+
+ return (
+
+
+ {t(
+ 'Emptying the bucket will permanentaly delete all objects. This action cannot be undone.'
+ )}
+
+
+ {t(
+ 'Any objects added during this process may also be deleted. To prevent adding new objects during the emptying process, consider updating the bucket policy (through CLI).'
+ )}
+
+
+ }
+ actions={[
+
+ {inProgress && (
+
+
+ {t(
+ 'The bucket is being emptied. This may take a while to complete.'
+ )}
+
+
+ )}
+
+ {t('Empty bucket')}
+
+
+
+ {t('Cancel')}
+
+
,
+ ]}
+ >
+
+ setInputValue(value)}
+ aria-label={t('Bucket name input')}
+ validated={
+ inputValue === bucketName
+ ? ValidatedOptions.success
+ : ValidatedOptions.default
+ }
+ placeholder={bucketName}
+ />
+
+
+ );
+};
+
+export type EmptyBucketResponse = {
+ response: DeleteObjectsCommandOutput;
+ bucketName: string;
+};
+
+type EmptyBucketAlertProps = {
+ emptyBucketResponse: EmptyBucketResponse;
+ setEmptyBucketResponse: React.Dispatch<
+ React.SetStateAction
+ >;
+ triggerRefresh: () => void;
+};
+
+export const EmptyBucketAlerts: React.FC = ({
+ emptyBucketResponse,
+ setEmptyBucketResponse,
+ triggerRefresh,
+}) => {
+ const { noobaaS3 } = React.useContext(NoobaaS3Context);
+ const { t } = useTranslation();
+ const launcher = useModal();
+
+ if (emptyBucketResponse.response === null) return null;
+
+ const hasErrors =
+ emptyBucketResponse.response?.Errors &&
+ emptyBucketResponse.response.Errors.length > 0;
+
+ if (hasErrors) {
+ return (
+ {
+ setEmptyBucketResponse({ response: null, bucketName: '' });
+ }}
+ />
+ }
+ actionLinks={
+
+ launcher(LazyDeleteObjectsSummary, {
+ isOpen: true,
+ extraProps: {
+ foldersPath: '',
+ errorResponse: emptyBucketResponse.response.Errors,
+ selectedObjects: [],
+ },
+ })
+ }
+ >
+ {t('Check potential reasons')}
+
+ }
+ >
+
+ {t(
+ 'Bucket emptying was not completed. Check for conflicts or permissions issues that are blocking this operation.'
+ )}
+
+
+ );
+ }
+
+ return (
+ {
+ setEmptyBucketResponse({ response: null, bucketName: '' });
+ }}
+ />
+ }
+ actionLinks={
+ <>
+
+ launcher(LazyDeleteBucketModal, {
+ isOpen: true,
+ extraProps: {
+ bucketName: emptyBucketResponse.bucketName,
+ noobaaS3,
+ launcher,
+ triggerRefresh,
+ setEmptyBucketResponse,
+ },
+ })
+ }
+ >
+ {t('Delete bucket')}
+
+
+ setEmptyBucketResponse({ response: null, bucketName: '' })
+ }
+ >
+ {t('Dismiss')}
+
+ >
+ }
+ >
+
+ {t(
+ 'Your bucket is now empty. If you want to delete this bucket, click Delete bucket'
+ )}
+
+
+ );
+};
+
+export default EmptyBucketModal;
diff --git a/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts b/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts
new file mode 100644
index 000000000..2a5ba5469
--- /dev/null
+++ b/packages/odf/modals/s3-browser/delete-and-empty-bucket/lazy-delete-and-empty-bucket.ts
@@ -0,0 +1,8 @@
+import * as React from 'react';
+
+export const LazyEmptyBucketModal = React.lazy(
+ () => import('./EmptyBucketModal')
+);
+export const LazyDeleteBucketModal = React.lazy(
+ () => import('./DeleteBucketModal')
+);
diff --git a/packages/shared/src/s3/commands.ts b/packages/shared/src/s3/commands.ts
index f02b637ae..49fc4cc61 100644
--- a/packages/shared/src/s3/commands.ts
+++ b/packages/shared/src/s3/commands.ts
@@ -12,6 +12,8 @@ import {
GetBucketTaggingCommand,
GetBucketAclCommand,
GetBucketPolicyCommand,
+ ListObjectVersionsCommand,
+ DeleteBucketCommand,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@@ -19,11 +21,13 @@ import {
CreateBucket,
ListBuckets,
ListObjectsV2,
+ ListObjectVersions,
PutBucketTags,
GetSignedUrl,
GetObject,
GetObjectTagging,
DeleteObjects,
+ DeleteBucket,
GetBucketEncryption,
GetBucketVersioning,
GetBucketTagging,
@@ -49,6 +53,9 @@ export class S3Commands extends S3Client {
createBucket: CreateBucket = (input) =>
this.send(new CreateBucketCommand(input));
+ deleteBucket: DeleteBucket = (input) =>
+ this.send(new DeleteBucketCommand(input));
+
getBucketAcl: GetBucketAcl = (input) =>
this.send(new GetBucketAclCommand(input));
@@ -74,6 +81,9 @@ export class S3Commands extends S3Client {
listObjects: ListObjectsV2 = (input) =>
this.send(new ListObjectsV2Command(input));
+ listObjectVersions: ListObjectVersions = (input) =>
+ this.send(new ListObjectVersionsCommand(input));
+
getSignedUrl: GetSignedUrl = (input, expiresIn) =>
getSignedUrl(this, new GetObjectCommand(input), { expiresIn });
diff --git a/packages/shared/src/s3/types.ts b/packages/shared/src/s3/types.ts
index 2e4b1fe52..e1d6285c8 100644
--- a/packages/shared/src/s3/types.ts
+++ b/packages/shared/src/s3/types.ts
@@ -23,6 +23,10 @@ import {
GetObjectCommandOutput,
GetObjectTaggingCommandInput,
GetObjectTaggingCommandOutput,
+ ListObjectVersionsCommandInput,
+ ListObjectVersionsCommandOutput,
+ DeleteBucketCommandInput,
+ DeleteBucketCommandOutput,
} from '@aws-sdk/client-s3';
// Bucket command types
@@ -30,6 +34,10 @@ export type CreateBucket = (
input?: CreateBucketCommandInput
) => Promise;
+export type DeleteBucket = (
+ input: DeleteBucketCommandInput
+) => Promise;
+
export type GetBucketAcl = (
input?: GetBucketAclCommandInput
) => Promise;
@@ -84,6 +92,10 @@ export type ListObjectsV2 = (
input: ListObjectsV2CommandInput
) => Promise;
+export type ListObjectVersions = (
+ input: ListObjectVersionsCommandInput
+) => Promise;
+
// Bucket Policy
type BucketPolicyCondition = Record;