diff --git a/locales/en/plugin__odf-console.json b/locales/en/plugin__odf-console.json index 01a5a03c2..c13e4d88d 100644 --- a/locales/en/plugin__odf-console.json +++ b/locales/en/plugin__odf-console.json @@ -144,10 +144,6 @@ "Clusters": "Clusters", "Connected applications": "Connected applications", "Cannot delete while connected to an application.": "Cannot delete while connected to an application.", - "Loading Empty Page": "Loading Empty Page", - "You are not authorized to complete this action. See your cluster administrator for role-based access control information.": "You are not authorized to complete this action. See your cluster administrator for role-based access control information.", - "Not Authorized": "Not Authorized", - "Empty Page": "Empty Page", "Clean up application resources on current primary cluster {{ failoverCluster }} to start the relocation.": "Clean up application resources on current primary cluster {{ failoverCluster }} to start the relocation.", "Cleanup Pending": "Cleanup Pending", "Relocating to cluster {{ preferredCluster }}": "Relocating to cluster {{ preferredCluster }}", @@ -1110,6 +1106,17 @@ "MCG": "MCG", "Object path: ": "Object path: ", "Copy to share": "Copy to share", + "Erase the contents of your bucket": "Erase the contents of your bucket", + "Storage endpoint": "Storage endpoint", + "Create on": "Create on", + "Owner": "Owner", + "Create and manage your buckets": "Create and manage your buckets", + "Navigate through your buckets effortlessly. View the contents of your S3-managed and Openshift-managed buckets, making it easy to locate and inspect objects.": "Navigate through your buckets effortlessly. View the contents of your S3-managed and Openshift-managed buckets, making it easy to locate and inspect objects.", + "No buckets found": "No buckets found", + "Search a bucket by name": "Search a bucket by name", + "Create bucket": "Create bucket", + "Browse, upload, and manage objects in buckets.<1>": "Browse, upload, and manage objects in buckets.<1>", + "Help materials": "Help materials", "Create Bucket": "Create Bucket", "An object bucket is a cloud storage container that organizes and manages files (objects), allowing users to store, retrieve and control access to data efficiently.": "An object bucket is a cloud storage container that organizes and manages files (objects), allowing users to store, retrieve and control access to data efficiently.", "Select bucket creation method": "Select bucket creation method", @@ -1347,7 +1354,6 @@ "{{count}} annotation_one": "{{count}} annotation", "{{count}} annotation_other": "{{count}} annotation", "Created at": "Created at", - "Owner": "Owner", "No labels": "No labels", "No owner": "No owner", "Select input": "Select input", @@ -1356,6 +1362,10 @@ "No resources available": "No resources available", "Select {{resourceLabel}}": "Select {{resourceLabel}}", "Error Loading": "Error Loading", + "Loading empty page": "Loading empty page", + "You are not authorized to complete this action. See your cluster administrator for role-based access control information.": "You are not authorized to complete this action. See your cluster administrator for role-based access control information.", + "Not Authorized": "Not Authorized", + "Empty Page": "Empty Page", "Reset": "Reset", "An error occurred. Please try again.": "An error occurred. Please try again.", "Error Loading {{label}}: {{message}}": "Error Loading {{label}}: {{message}}", diff --git a/packages/mco/components/drpolicy-list-page/drpolicy-list-page.spec.tsx b/packages/mco/components/drpolicy-list-page/drpolicy-list-page.spec.tsx index 6e9ec7166..763f0a64a 100644 --- a/packages/mco/components/drpolicy-list-page/drpolicy-list-page.spec.tsx +++ b/packages/mco/components/drpolicy-list-page/drpolicy-list-page.spec.tsx @@ -92,6 +92,6 @@ describe('Test drpolicy list page', () => { test('Empty page loading test', async () => { testCase = 3; render(); - expect(screen.getByLabelText('Loading Empty Page')).toBeInTheDocument(); + expect(screen.getByLabelText('Loading empty page')).toBeInTheDocument(); }); }); diff --git a/packages/mco/components/drpolicy-list-page/drpolicy-list-page.tsx b/packages/mco/components/drpolicy-list-page/drpolicy-list-page.tsx index 8cb9d1dae..bec655f00 100644 --- a/packages/mco/components/drpolicy-list-page/drpolicy-list-page.tsx +++ b/packages/mco/components/drpolicy-list-page/drpolicy-list-page.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { pluralize } from '@odf/core/components/utils'; +import EmptyPage from '@odf/shared/empty-state-page/empty-page'; import { useAccessReview } from '@odf/shared/hooks/rbac-hook'; import { Kebab } from '@odf/shared/kebab/kebab'; import { getName } from '@odf/shared/selectors'; @@ -28,7 +29,6 @@ import { import { DRPolicyModel } from '../../models'; import { DRPolicyKind } from '../../types'; import { getReplicationType, isDRPolicyValidated } from '../../utils'; -import EmptyPage from '../empty-state-page/empty-page'; import { Header, kebabActionItems, tableColumnInfo } from './helper'; import './drpolicy-list-page.scss'; diff --git a/packages/mco/components/modals/app-manage-policies/manage-policy-view.tsx b/packages/mco/components/modals/app-manage-policies/manage-policy-view.tsx index cbd663e2c..da4b1b4bc 100644 --- a/packages/mco/components/modals/app-manage-policies/manage-policy-view.tsx +++ b/packages/mco/components/modals/app-manage-policies/manage-policy-view.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { DRPolicyKind, DRPlacementControlKind } from '@odf/mco/types'; import { getDRPolicyStatus, parseSyncInterval } from '@odf/mco/utils'; import { formatTime, getLatestDate } from '@odf/shared/details-page/datetime'; +import EmptyPage from '@odf/shared/empty-state-page/empty-page'; import { StatusBox } from '@odf/shared/generic/status-box'; import { Labels } from '@odf/shared/labels'; import { ModalBody, ModalFooter } from '@odf/shared/modals/Modal'; @@ -31,7 +32,6 @@ import { SYNC_SCHEDULE_DISPLAY_TEXT, } from '../../../constants'; import { getDRPlacementControlResourceObj } from '../../../hooks'; -import EmptyPage from '../../empty-state-page/empty-page'; import { doNotDeletePVCAnnotationPromises, unAssignPromises, diff --git a/packages/mco/components/protected-applications/components.tsx b/packages/mco/components/protected-applications/components.tsx index 04e9e9009..dabfba96c 100644 --- a/packages/mco/components/protected-applications/components.tsx +++ b/packages/mco/components/protected-applications/components.tsx @@ -3,6 +3,7 @@ import { ActionDropdown, ToggleVariant, } from '@odf/shared/dropdown/action-dropdown'; +import EmptyPage from '@odf/shared/empty-state-page/empty-page'; import { DataUnavailableError } from '@odf/shared/generic/Error'; import { NamespaceModel } from '@odf/shared/models'; import { ResourceNameWIcon } from '@odf/shared/resource-link/resource-link'; @@ -36,7 +37,6 @@ import { } from '../../constants'; import { DRPlacementControlModel } from '../../models'; import { DRPlacementControlKind } from '../../types'; -import EmptyPage from '../empty-state-page/empty-page'; import { getCurrentActivity } from '../mco-dashboard/disaster-recovery/cluster-app-card/application'; import { getAlertMessages, diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx new file mode 100644 index 000000000..b042ee14f --- /dev/null +++ b/packages/odf/components/s3-browser/buckets-list-page/bucketListTable.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { + BUCKET_BOOKMARKS_USER_SETTINGS_KEY, + BUCKET_DETAILS_PAGE_PATH, +} from '@odf/core/constants'; +import { BucketCrFormat } from '@odf/core/types'; +import { EmptyPage } from '@odf/shared/empty-state-page'; +import { useUserSettingsLocalStorage } from '@odf/shared/hooks/useUserSettingsLocalStorage'; +import { + ComposableTable, + RowComponentType, +} from '@odf/shared/table/composable-table'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { sortRows } from '@odf/shared/utils'; +import { Timestamp } from '@openshift-console/dynamic-plugin-sdk'; +import { TFunction, Trans } from 'react-i18next'; +import { Link } from 'react-router-dom-v5-compat'; +import { Bullseye, Label } from '@patternfly/react-core'; +import { UserIcon } from '@patternfly/react-icons'; +import { ActionsColumn, IAction, Td, Tr } from '@patternfly/react-table'; + +const getRowActions = (t: TFunction): IAction[] => [ + { + title: ( + <> + {t('Empty bucket')} +

+ {t('Erase the contents of your bucket')} +

+ + ), + onClick: () => undefined, + }, + { + title: t('Delete bucket'), + onClick: () => undefined, + }, +]; + +const getColumnNames = (t: TFunction) => [ + '', // favoritable, + t('Name'), + t('Storage endpoint'), + t('Create on'), + t('Owner'), + '', // action kebab +]; + +const getHeaderColumns = (t: TFunction, favorites: string[]) => { + const columnNames = getColumnNames(t); + return [ + { + columnName: columnNames[0], + sortFunction: (a, b, c) => sortRows(a, b, c, 'metadata.name', favorites), + }, + { + columnName: columnNames[1], + sortFunction: (a, b, c) => sortRows(a, b, c, 'metadata.name'), + }, + { + columnName: columnNames[2], + thProps: { + className: 'pf-v5-u-w-16-on-lg', + }, + }, + { + columnName: columnNames[3], + sortFunction: (a, b, c) => + sortRows(a, b, c, 'metadata.creationTimestamp'), + thProps: { + className: 'pf-v5-u-w-16-on-lg', + }, + }, + { + columnName: columnNames[4], + thProps: { + className: 'pf-v5-u-w-16-on-lg', + }, + }, + { + columnName: columnNames[5], + }, + ]; +}; + +const NoBucketMessage: React.FC = () => { + const { t } = useCustomTranslation(); + return ( + <>} + title={t('Create and manage your buckets')} + isLoaded + canAccess + > + + Navigate through your buckets effortlessly. View the contents of your + S3-managed and Openshift-managed buckets, making it easy to locate and + inspect objects. + + + ); +}; + +const EmptyRowMessage: React.FC = () => { + const { t } = useCustomTranslation(); + return {t('No buckets found')}; +}; + +const BucketsTableRow: React.FC> = ({ + row: bucket, + rowIndex, + extraProps, +}) => { + const { t } = useCustomTranslation(); + const columnNames = getColumnNames(t); + const { + apiResponse: { owner }, + metadata: { name, creationTimestamp }, + } = bucket; + const { favorites, setFavorites }: RowExtraPropsType = extraProps; + + const onSetFavorite = (key, active) => { + setFavorites((oldFavorites) => [ + ...oldFavorites.filter((oldFavorite) => oldFavorite !== key), + ...(active ? [key] : []), + ]); + }; + + return ( + + + onSetFavorite(name, isFavoriting), + rowIndex, + }} + /> + + {name} + + + {/* ToDo: Currently we only support MCG, make is configurable once RGW is supported as well */} + + + + {} + + + {owner} + + + + + + ); +}; + +export const BucketsListTable: React.FC = ({ + allBuckets, + filteredBuckets, + loaded, + error, +}) => { + const { t } = useCustomTranslation(); + const [favorites, setFavorites] = useUserSettingsLocalStorage( + BUCKET_BOOKMARKS_USER_SETTINGS_KEY, + true, + [] + ); + return ( + + ); +}; + +type BucketsListTableProps = { + allBuckets: BucketCrFormat[]; + filteredBuckets: BucketCrFormat[]; + loaded: boolean; + error: any; +}; + +type RowExtraPropsType = { + favorites: string[]; + setFavorites: React.Dispatch>; +}; diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketPagination.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketPagination.tsx new file mode 100644 index 000000000..c0954ba43 --- /dev/null +++ b/packages/odf/components/s3-browser/buckets-list-page/bucketPagination.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { LIST_BUCKET, MAX_BUCKETS } from '@odf/core/constants'; +import { BucketCrFormat } from '@odf/core/types'; +import { convertBucketDataToCrFormat } from '@odf/core/utils'; +import useSWRMutation from 'swr/mutation'; +import { NoobaaS3Context } from '../noobaa-context'; +import { + ContinuationTokens, + fetchObjects, + Pagination, +} from '../pagination-helper'; + +export const BucketPagination: React.FC = ({ + setBucketInfo, +}) => { + const { noobaaS3 } = React.useContext(NoobaaS3Context); + const { data, error, isMutating, trigger } = useSWRMutation( + LIST_BUCKET, + (_url, { arg }: { arg: string }) => + noobaaS3.listBuckets({ + MaxBuckets: MAX_BUCKETS, + ...(!!arg && { ContinuationToken: arg }), + }) + ); + + const loadedWOError = !isMutating && !error; + const [continuationTokens, setContinuationTokens] = + React.useState({ + previous: [], + current: '', + next: '', + }); + + React.useEffect(() => { + setBucketInfo([ + convertBucketDataToCrFormat(data), + !isMutating && !error, + error, + ]); + }, [data, isMutating, error, setBucketInfo]); + + // initial fetch on first mount + React.useEffect(() => { + fetchObjects(setContinuationTokens, trigger, true, undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onNextClick = async () => { + if (!!continuationTokens.next && loadedWOError) + fetchObjects( + setContinuationTokens, + trigger, + true, + undefined, + continuationTokens.next + ); + }; + + const onPreviousClick = async () => { + if (!!continuationTokens.current && loadedWOError) { + const paginationToken = + continuationTokens.previous[continuationTokens.previous.length - 1]; + fetchObjects( + setContinuationTokens, + trigger, + false, + undefined, + paginationToken + ); + } + }; + + return ( + + ); +}; + +type BucketPaginationProps = { + setBucketInfo: React.Dispatch< + React.SetStateAction<[BucketCrFormat[], boolean, any]> + >; +}; diff --git a/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx new file mode 100644 index 000000000..535a4cb7a --- /dev/null +++ b/packages/odf/components/s3-browser/buckets-list-page/bucketsListPage.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { odfDocLinks } from '@odf/shared/constants'; +import { LoadingBox } from '@odf/shared/generic'; +import { DOC_VERSION as odfDocVersion, useRefresh } from '@odf/shared/hooks'; +import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; +import { getValidFilteredData, ViewDocumentation } from '@odf/shared/utils'; +import { + ListPageBody, + ListPageCreateLink, + ListPageFilter, + ListPageHeader, + useListPageFilter, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Trans } from 'react-i18next'; +import { Button, ButtonVariant, Grid, GridItem } from '@patternfly/react-core'; +import { SyncAltIcon } from '@patternfly/react-icons'; +import { BUCKET_CREATE_PAGE_PATH } from '../../../constants'; +import { BucketCrFormat } from '../../../types'; +import { NoobaaS3Provider } from '../noobaa-context'; +import { BucketsListTable } from './bucketListTable'; +import { BucketPagination } from './bucketPagination'; + +const BucketsListPageBody: React.FC = () => { + const { t } = useCustomTranslation(); + const [fresh, triggerRefresh] = useRefresh(); + const [bucketInfo, setBucketInfo] = React.useState< + [BucketCrFormat[], boolean, any] + >([[], false, undefined]); + const [buckets, loaded, loadError] = bucketInfo; + const [allBuckets, filteredBuckets, onFilterChange] = + useListPageFilter(buckets); + + return ( + + + +
+ +
+ +
+
+
+ +
+ {fresh && } +
+
+
+ {fresh ? ( + + ) : ( + + )} +
+ ); +}; + +const BucketsListPage: React.FC = () => { + const { t } = useCustomTranslation(); + + return ( + + + + {t('Create bucket')} + + +
+ + Browse, upload, and manage objects in buckets. + + +
+ +
+ ); +}; + +export default BucketsListPage; diff --git a/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx b/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx index 8d17fbe3d..737ced764 100644 --- a/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx +++ b/packages/odf/components/s3-browser/objects-list/ObjectsList.tsx @@ -1,17 +1,12 @@ import * as React from 'react'; -import { - ListObjectsV2CommandOutput, - _Object as Content, - CommonPrefix, -} from '@aws-sdk/client-s3'; +import { _Object as Content, CommonPrefix } from '@aws-sdk/client-s3'; import { SelectableTable } from '@odf/shared/table'; import { useCustomTranslation } from '@odf/shared/useCustomTranslationHook'; import { useModal } from '@openshift-console/dynamic-plugin-sdk'; import { LaunchModal } from '@openshift-console/dynamic-plugin-sdk/lib/app/modal-support/ModalProvider'; import { TFunction } from 'i18next'; -import * as _ from 'lodash-es'; import { useParams, useSearchParams } from 'react-router-dom-v5-compat'; -import useSWRMutation, { TriggerWithOptionsArgs } from 'swr/mutation'; +import useSWRMutation from 'swr/mutation'; import { Button, ButtonVariant, @@ -19,7 +14,6 @@ import { LevelItem, MenuToggle, } from '@patternfly/react-core'; -import { AngleLeftIcon, AngleRightIcon } from '@patternfly/react-icons'; import { ActionsColumn, IAction, @@ -29,6 +23,12 @@ import { LIST_OBJECTS, DELIMITER, MAX_KEYS, PREFIX } from '../../../constants'; import { ObjectCrFormat } from '../../../types'; import { getPath, convertObjectsDataToCrFormat } from '../../../utils'; import { NoobaaS3Context } from '../noobaa-context'; +import { + Pagination, + PaginationProps, + ContinuationTokens, + fetchObjects, +} from '../pagination-helper'; import { isRowSelectable, getColumns, @@ -36,79 +36,12 @@ import { EmptyPage, } from './table-components'; -type PaginationProps = { - onNext: () => void; - onPrevious: () => void; - disableNext: boolean; - disablePrevious: boolean; -}; - type TableActionsProps = { launcher: LaunchModal; selectedRows: unknown[]; loadedWOError: boolean; }; -type ContinuationTokens = { - previous: string[]; - current: string; - next: string; -}; - -type Trigger = TriggerWithOptionsArgs< - ListObjectsV2CommandOutput, - any, - string, - string ->; - -const continuationTokensSetter = ( - setContinuationTokens: React.Dispatch< - React.SetStateAction - >, - response: ListObjectsV2CommandOutput, - isNext: boolean, - setSelectedRows: React.Dispatch> -) => { - setContinuationTokens((oldTokens) => { - const newTokens = _.cloneDeep(oldTokens); - if (isNext) { - newTokens.previous.push(newTokens.current); - newTokens.current = newTokens.next; - } else { - newTokens.current = newTokens.previous.pop(); - } - newTokens.next = response.NextContinuationToken; - - return newTokens; - }); - setSelectedRows([]); -}; - -const fetchObjects = async ( - setContinuationTokens: React.Dispatch< - React.SetStateAction - >, - trigger: Trigger, - isNext: boolean, - setSelectedRows: React.Dispatch>, - paginationToken = '' -) => { - try { - const response: ListObjectsV2CommandOutput = await trigger(paginationToken); - continuationTokensSetter( - setContinuationTokens, - response, - isNext, - setSelectedRows - ); - } catch (err) { - // no need to handle any error here, use "error" object directly from the "useSWRMutation" hook - // eslint-disable-next-line no-console - console.error(err); - } -}; - const getBulkActionsItems = ( t: TFunction, _launcher: LaunchModal, @@ -139,34 +72,6 @@ export const CustomActionsToggle = (props: CustomActionsToggleProps) => { ); }; -const Pagination: React.FC = ({ - onNext, - onPrevious, - disableNext, - disablePrevious, -}) => { - return ( -
- - -
- ); -}; - const TableActions: React.FC = ({ onNext, onPrevious, diff --git a/packages/odf/components/s3-browser/pagination-helper.tsx b/packages/odf/components/s3-browser/pagination-helper.tsx new file mode 100644 index 000000000..fe1ec382a --- /dev/null +++ b/packages/odf/components/s3-browser/pagination-helper.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'; +import { ObjectCrFormat } from '@odf/core/types'; +import * as _ from 'lodash-es'; +import { TriggerWithOptionsArgs } from 'swr/dist/mutation'; +import { Button, ButtonVariant } from '@patternfly/react-core'; +import { AngleLeftIcon, AngleRightIcon } from '@patternfly/react-icons'; + +export type ContinuationTokens = { + previous: string[]; + current: string; + next: string; +}; + +export type PaginationProps = { + onNext: () => void; + onPrevious: () => void; + disableNext: boolean; + disablePrevious: boolean; +}; + +type Trigger = TriggerWithOptionsArgs< + ListObjectsV2CommandOutput, + any, + string, + string +>; + +export const continuationTokensSetter = ( + setContinuationTokens: React.Dispatch< + React.SetStateAction + >, + response: ListObjectsV2CommandOutput, + isNext: boolean, + setSelectedRows: React.Dispatch> +) => { + setContinuationTokens((oldTokens) => { + const newTokens = _.cloneDeep(oldTokens); + if (isNext) { + newTokens.previous.push(newTokens.current); + newTokens.current = newTokens.next; + } else { + newTokens.current = newTokens.previous.pop(); + } + newTokens.next = response.NextContinuationToken; + + return newTokens; + }); + !!setSelectedRows && setSelectedRows([]); +}; + +export const fetchObjects = async ( + setContinuationTokens: React.Dispatch< + React.SetStateAction + >, + trigger: Trigger, + isNext: boolean, + setSelectedRows: React.Dispatch>, + paginationToken = '' +) => { + try { + const response: ListObjectsV2CommandOutput = await trigger(paginationToken); + continuationTokensSetter( + setContinuationTokens, + response, + isNext, + setSelectedRows + ); + } catch (err) { + // no need to handle any error here, use "error" object directly from the "useSWRMutation" hook + // eslint-disable-next-line no-console + console.error(err); + } +}; + +export const Pagination: React.FC = ({ + onNext, + onPrevious, + disableNext, + disablePrevious, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/packages/odf/constants/s3-browser.ts b/packages/odf/constants/s3-browser.ts index bc5e354fe..865f3e5be 100644 --- a/packages/odf/constants/s3-browser.ts +++ b/packages/odf/constants/s3-browser.ts @@ -6,9 +6,15 @@ export const NOOBAA_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; export const DELIMITER = '/'; export const PREFIX = 'prefix'; export const MAX_KEYS = 300; +export const MAX_BUCKETS = 300; export const BUCKETS_BASE_ROUTE = '/odf/object-storage/buckets'; +export const BUCKET_CREATE_PAGE_PATH = '/odf/object-storage/create-bucket'; +export const BUCKET_DETAILS_PAGE_PATH = '/odf/object-storage/buckets'; // key to be used by SWR for caching particular API call export const LIST_BUCKET = 'LIST_BUCKET_CACHE_KEY'; export const LIST_OBJECTS = 'LIST_OBJECTS_CACHE_KEY'; + +// Bookmarking / favorites +export const BUCKET_BOOKMARKS_USER_SETTINGS_KEY = 'bucket-bookmarks'; diff --git a/packages/odf/types/s3-browser.ts b/packages/odf/types/s3-browser.ts index b0c54b83f..b1f65e437 100644 --- a/packages/odf/types/s3-browser.ts +++ b/packages/odf/types/s3-browser.ts @@ -8,3 +8,9 @@ export type ObjectCrFormat = K8sResourceCommon & { isFolder?: boolean; type?: string; }; + +export type BucketCrFormat = K8sResourceCommon & { + apiResponse?: { + owner?: string; + }; +}; diff --git a/packages/odf/utils/s3-browser.ts b/packages/odf/utils/s3-browser.ts index a30bbe731..d04b4ae82 100644 --- a/packages/odf/utils/s3-browser.ts +++ b/packages/odf/utils/s3-browser.ts @@ -1,9 +1,13 @@ -import { _Object as Content, CommonPrefix } from '@aws-sdk/client-s3'; +import { + _Object as Content, + CommonPrefix, + ListBucketsCommandOutput, +} from '@aws-sdk/client-s3'; import { DASH } from '@odf/shared/constants'; import { humanizeBinaryBytes } from '@odf/shared/utils'; import { TFunction } from 'i18next'; import { DELIMITER, BUCKETS_BASE_ROUTE, PREFIX } from '../constants'; -import { ObjectCrFormat } from '../types'; +import { BucketCrFormat, ObjectCrFormat } from '../types'; export const getBreadcrumbs = ( foldersPath: string, @@ -83,3 +87,17 @@ export const convertObjectsDataToCrFormat = ( return structuredObjects; }; + +export const convertBucketDataToCrFormat = ( + listBucketsCommandOutput: ListBucketsCommandOutput +): BucketCrFormat[] => + listBucketsCommandOutput?.Buckets.map((bucket) => ({ + metadata: { + name: bucket.Name, + uid: bucket.Name, + creationTimestamp: bucket.CreationDate.toString(), + }, + apiResponse: { + owner: listBucketsCommandOutput?.Owner?.DisplayName, + }, + })) || []; diff --git a/packages/shared/src/constants/doc.ts b/packages/shared/src/constants/doc.ts index 3167d85e7..7003f6271 100644 --- a/packages/shared/src/constants/doc.ts +++ b/packages/shared/src/constants/doc.ts @@ -14,3 +14,8 @@ export const odfDRDocApplyPolicy = (odfDocVersion) => `${odfDRDocHome( odfDocVersion )}#apply-drpolicy-to-sample-application_manage-dr`; + +// ToDo(Gowtham): Update doc link +export const odfDocLinks = (odfDocVersion) => ({ + S3_BUCKET: `${odfDRDocHome(odfDocVersion)}#s3-bucket`, +}); diff --git a/packages/mco/components/empty-state-page/empty-page.scss b/packages/shared/src/empty-state-page/empty-page.scss similarity index 100% rename from packages/mco/components/empty-state-page/empty-page.scss rename to packages/shared/src/empty-state-page/empty-page.scss diff --git a/packages/mco/components/empty-state-page/empty-page.tsx b/packages/shared/src/empty-state-page/empty-page.tsx similarity index 98% rename from packages/mco/components/empty-state-page/empty-page.tsx rename to packages/shared/src/empty-state-page/empty-page.tsx index 99d6402de..80229f5ec 100644 --- a/packages/mco/components/empty-state-page/empty-page.tsx +++ b/packages/shared/src/empty-state-page/empty-page.tsx @@ -30,7 +30,7 @@ const EmptyPage: React.FC = (props) => { return !isLoaded ? (
) : ( diff --git a/packages/shared/src/empty-state-page/index.ts b/packages/shared/src/empty-state-page/index.ts new file mode 100644 index 000000000..3b3dfb735 --- /dev/null +++ b/packages/shared/src/empty-state-page/index.ts @@ -0,0 +1 @@ +export { default as EmptyPage } from './empty-page'; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index 63d16a651..20838fc1a 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -10,3 +10,4 @@ export * from './useK8sList'; export * from './scheduler'; export * from './use-doc-version'; export * from './useRefresh'; +export * from './useUserSettingsLocalStorage'; diff --git a/packages/shared/src/hooks/useUserSettingsLocalStorage.ts b/packages/shared/src/hooks/useUserSettingsLocalStorage.ts new file mode 100644 index 000000000..595da37d0 --- /dev/null +++ b/packages/shared/src/hooks/useUserSettingsLocalStorage.ts @@ -0,0 +1,187 @@ +import * as React from 'react'; + +// Default ODF user settings storage path +const DEFAULT_ODF_CONSOLE_USER_SETTINGS_KEY = 'odf-console-user-settings'; + +export const deseralizeData = (data: string | null) => { + if (typeof data !== 'string') { + return data; + } + try { + return JSON.parse(data); + } catch { + return data; + } +}; + +export const seralizeData = (data: T) => { + if (typeof data === 'string') { + return data; + } + return JSON.stringify(data); +}; + +// Get user settings from local storage +const getDataFromLocalStorage = (storageKey: string) => + deseralizeData(localStorage.getItem(storageKey)) ?? {}; + +// Initialize state +const intializeDataFromLocalStorage = ( + storageKey: string, + keyRef: string, + defaultValue: T +) => { + const valueInStorage = getDataFromLocalStorage(storageKey); + return valueInStorage?.hasOwnProperty(keyRef) && + valueInStorage[keyRef] !== undefined + ? valueInStorage[keyRef] + : defaultValue; +}; + +// Update local state with user settings by event +const syncLatestDataFromLocalStorage = ( + isMounted: boolean, + storageKey: string, + storage: Storage, + key: string, + newValue: string, + data: T, + userSettingsKey: string, + setData: React.Dispatch +) => { + if (isMounted && storage === localStorage && key === storageKey) { + const configMapData = deseralizeData(newValue); + const newData = configMapData?.[userSettingsKey]; + + if (newData !== undefined && seralizeData(newData) !== seralizeData(data)) { + setData(newData); + } + } +}; + +const updateLocalStorage = ( + storageKey: string, + userSettingsKey: string, + configMapData: any, + newState: T +) => { + // Trigger update also when unmounted + const dataToUpdate = { + ...configMapData, + ...{ + [userSettingsKey]: newState, + }, + }; + + const newValue = seralizeData(dataToUpdate); + + try { + // Update the local storage + localStorage.setItem(storageKey, newValue); + + // Dispatch storage event to sync the settings changes for other tabs + generateStorageEventToUpdate(storageKey, newValue); + } catch (err) { + // eslint-disable-next-line no-console + console.error( + `Error while updating local storage for key ${storageKey}`, + err + ); + } +}; + +// Generate storage event to sync latest settings on other tabs +const generateStorageEventToUpdate = (storageKey, newValue: string) => { + // Create a storage event to dispatch locally since browser windows do not fire the + // storage event if the change originated from the current window + const event = new StorageEvent('storage', { + storageArea: localStorage, + key: storageKey, + newValue, + oldValue: localStorage.getItem(storageKey), + url: window.location.toString(), + }); + + window.dispatchEvent(event); +}; + +export const useUserSettingsLocalStorage = ( + // User settings sub storage path + userSettingsKey: string, + // Sync user settings changes in all tabs + sync = false, + // Default value for the initial settings + defaultValue?: T, + // ODF settings base storage path + storageKey: string = DEFAULT_ODF_CONSOLE_USER_SETTINGS_KEY +): [T, React.Dispatch>] => { + // Mount status for safty state updates + const mounted = React.useRef(true); + React.useEffect(() => () => (mounted.current = false), []); + const keyRef = React.useRef( + userSettingsKey?.replace(/[^-._a-zA-Z0-9]/g, '_') + ); + const defaultValueRef = React.useRef(defaultValue); + + const [data, setData] = React.useState(() => + intializeDataFromLocalStorage( + storageKey, + keyRef.current, + defaultValueRef.current + ) + ); + const dataRef = React.useRef(data); + dataRef.current = data; + + // Sync latest user settings across all tabs. + const syncLatestData = React.useCallback( + (event: StorageEvent) => + syncLatestDataFromLocalStorage( + mounted.current, + storageKey, + event.storageArea, + event.key, + event.newValue, + dataRef.current, + keyRef.current, + setData + ), + [storageKey] + ); + + React.useEffect(() => { + if (sync) { + window.addEventListener('storage', syncLatestData); + } + return () => { + if (sync) { + window.removeEventListener('storage', syncLatestData); + } + }; + }, [syncLatestData, sync]); + + const updateData = React.useCallback>>( + (action: React.SetStateAction) => { + const previousData = dataRef.current; + const newState = + typeof action === 'function' + ? (action as (prevState: T) => T)(previousData) + : action; + const configMapData = getDataFromLocalStorage(storageKey); + if ( + newState !== undefined && + seralizeData(newState) !== seralizeData(configMapData?.[keyRef.current]) + ) { + // Update settings changes for current tab + if (mounted.current) { + setData(newState); + } + // Update user settings changes in local stroage + updateLocalStorage(storageKey, keyRef.current, configMapData, newState); + } + }, + [storageKey] + ); + + return [data, updateData]; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d684ce081..063c2d411 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -33,3 +33,4 @@ export * from './yup-validation-resolver'; export * from './file-handling'; export * from './text-inputs'; export * from './label-expression-selector'; +export * from './empty-state-page'; diff --git a/packages/shared/src/table/composable-table.tsx b/packages/shared/src/table/composable-table.tsx index ca07463b9..2cea6585a 100644 --- a/packages/shared/src/table/composable-table.tsx +++ b/packages/shared/src/table/composable-table.tsx @@ -4,6 +4,7 @@ import { K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk'; import { SortByDirection, Table, + TableVariant, Tbody, Th, ThProps, @@ -36,6 +37,8 @@ export const ComposableTable: ComposableTableProps = < unfilteredData, noDataMsg, emptyRowMessage, + isFavorites, + isCompact, }) => { const { onSort, @@ -45,6 +48,7 @@ export const ComposableTable: ComposableTableProps = < } = useSortList(rows, columns, false); const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + ...(isFavorites ? { isFavorites: columnIndex === 0 } : {}), sortBy: { index: activeSortIndex, direction: activeSortDirection, @@ -67,6 +71,7 @@ export const ComposableTable: ComposableTableProps = < translate={null} aria-label="Composable table" className="pf-v5-u-mt-md" + {...(isCompact ? { variant: TableVariant.compact } : {})} > @@ -110,6 +115,8 @@ export type TableProps = { unfilteredData?: []; noDataMsg?: React.FC; emptyRowMessage?: React.FC; + isFavorites?: boolean; + isCompact?: boolean; }; type ComposableTableProps = ( diff --git a/packages/shared/src/utils/table.ts b/packages/shared/src/utils/table.ts index bd5900b3b..288eab2d1 100644 --- a/packages/shared/src/utils/table.ts +++ b/packages/shared/src/utils/table.ts @@ -1,15 +1,24 @@ import * as _ from 'lodash-es'; import { SortByDirection } from '@patternfly/react-table'; +const sort = (aValue: any, bValue: any, c: SortByDirection) => { + const negation = c !== SortByDirection.asc; + const sortVal = aValue.localeCompare(bValue); + return negation ? -sortVal : sortVal; +}; + export const sortRows = ( a: any, b: any, c: SortByDirection, - sortField: string + sortField: string, + favoriteNames?: string[] ) => { - const negation = c !== SortByDirection.asc; - const aValue = _.get(a, sortField, '').toString(); - const bValue = _.get(b, sortField, ''); - const sortVal = String(aValue).localeCompare(String(bValue)); - return negation ? -sortVal : sortVal; + let aValue = _.get(a, sortField, '').toString(); + let bValue = _.get(b, sortField, '').toString(); + if (!!favoriteNames) { + aValue = favoriteNames.includes(aValue).toString(); + bValue = favoriteNames.includes(bValue).toString(); + } + return sort(aValue, bValue, c); }; diff --git a/plugins/odf/console-extensions.json b/plugins/odf/console-extensions.json index 3b33b6a21..5b5431b6a 100644 --- a/plugins/odf/console-extensions.json +++ b/plugins/odf/console-extensions.json @@ -540,12 +540,28 @@ "required": ["ODF_MODEL"] } }, + { + "type": "odf.horizontalNav/tab", + "properties": { + "id": "buckets", + "name": "%plugin__odf-console~Buckets%", + "contextId": "odf-object-service", + "href": "buckets", + "component": { + "$codeRef": "s3BucketList.default" + } + }, + "flags": { + "required": ["MCG", "ODF_ADMIN"] + } + }, { "type": "odf.horizontalNav/tab", "properties": { "id": "backing-store", "name": "%plugin__odf-console~Backing Store%", "contextId": "odf-object-service", + "after": "buckets", "href": "noobaa.io~v1alpha1~BackingStore", "component": { "$codeRef": "resourcePages.BackingStoreListPage" @@ -587,22 +603,6 @@ "required": ["MCG", "ODF_ADMIN"] } }, - { - "type": "odf.horizontalNav/tab", - "properties": { - "id": "objectbuckets", - "name": "%plugin__odf-console~Object Buckets%", - "contextId": "odf-object-service", - "after": "namespace-store", - "href": "objectbucket.io~v1alpha1~ObjectBucket", - "component": { - "$codeRef": "ob.ObjectBucketListPage" - } - }, - "flags": { - "required": ["MCG"] - } - }, { "type": "odf.horizontalNav/tab", "properties": { @@ -636,7 +636,7 @@ "id": "objectbucketclaims", "name": "%plugin__odf-console~Object Bucket Claims%", "contextId": "odf-object-service", - "after": "objectbuckets", + "after": "namespace-store", "href": "objectbucket.io~v1alpha1~ObjectBucketClaim", "component": { "$codeRef": "obc.OBCListPage" diff --git a/plugins/odf/console-plugin.json b/plugins/odf/console-plugin.json index e3d350199..5b6222ca3 100644 --- a/plugins/odf/console-plugin.json +++ b/plugins/odf/console-plugin.json @@ -42,6 +42,7 @@ "odfReduxReducers": "@odf/core/redux", "monCountAlertModal": "@odf/core/modals/configure-mons/configure-mons", "storageConsumerListPage": "@odf/core/components/storage-consumers/client-list", - "s3BucketOverview": "@odf/core/components/s3-browser/bucket-overview/BucketOverview" + "s3BucketOverview": "@odf/core/components/s3-browser/bucket-overview/BucketOverview", + "s3BucketList": "@odf/core/components/s3-browser/buckets-list-page/bucketsListPage" } }