From 61ce28a855da178c1b01b4a16eab576463b276f9 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Wed, 11 Mar 2026 11:36:07 +0100 Subject: [PATCH] new: STORIF-310 - Bucket tab filters created. --- .../objectStorage/object-storage.e2e.spec.ts | 184 ++++++++++++++++-- .../support/intercepts/object-storage.ts | 2 +- .../cypress/support/intercepts/regions.ts | 9 + .../BucketLanding/OMC_BucketLanding.tsx | 103 +++++++++- .../Partials/EndpointMultiselect.test.tsx | 23 ++- .../Partials/EndpointMultiselect.tsx | 24 ++- .../EndpointSummaryRow.test.tsx | 0 .../EndpointSummaryRow.tsx | 0 .../EndpointSummaryTable.tsx | 0 .../SummaryLanding/SummaryLanding.tsx | 6 +- .../src/features/ObjectStorage/utilities.ts | 13 ++ 11 files changed, 336 insertions(+), 28 deletions(-) rename packages/manager/src/features/ObjectStorage/{SummaryLanding => }/Partials/EndpointMultiselect.test.tsx (77%) rename packages/manager/src/features/ObjectStorage/{SummaryLanding => }/Partials/EndpointMultiselect.tsx (67%) rename packages/manager/src/features/ObjectStorage/SummaryLanding/{Partials => EndpointSummaryTable}/EndpointSummaryRow.test.tsx (100%) rename packages/manager/src/features/ObjectStorage/SummaryLanding/{Partials => EndpointSummaryTable}/EndpointSummaryRow.tsx (100%) rename packages/manager/src/features/ObjectStorage/SummaryLanding/{Partials => EndpointSummaryTable}/EndpointSummaryTable.tsx (100%) diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 16c7ab3a57e..9fed5f852b8 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -3,6 +3,7 @@ */ import { createBucket } from '@linode/api-v4/lib/object-storage'; +import { getNewRegionLabel } from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; import { interceptGetNetworkUtilization, @@ -16,6 +17,7 @@ import { interceptGetBuckets, interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; +import { interceptGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { chooseCluster } from 'support/util/clusters'; @@ -27,6 +29,8 @@ import { createObjectStorageBucketFactoryLegacy, } from 'src/factories'; +import type { Region } from '@linode/api-v4/lib/object-storage'; + /** * Create a bucket with the given label and cluster. * @@ -41,23 +45,29 @@ import { */ const setUpBucket = ( label: string, - cluster: string, + region: string, cors_enabled: boolean = true ) => { return createBucket( createObjectStorageBucketFactoryLegacy.build({ - cluster, + region, cors_enabled, label, // API accepts either `cluster` or `region`, but not both. Our factory - // populates both fields, so we have to manually set `region` to `undefined` + // populates both fields, so we have to manually set `cluster` to `undefined` // to avoid 400 responses from the API. - region: undefined, + cluster: undefined, }) ); }; +const setupBuckets = (bucketsDetails: { label: string; region: string }[]) => { + return Promise.all( + bucketsDetails.map(({ label, region }) => setUpBucket(label, region)) + ); +}; + authenticate(); beforeEach(() => { cy.tag('method:e2e'); @@ -78,12 +88,11 @@ describe('object storage end-to-end tests', () => { cy.tag('purpose:syntheticTesting'); const bucketLabel = randomLabel(); const bucketClusterObj = chooseCluster(); - const bucketCluster = bucketClusterObj.id; const bucketRegion = getRegionById(bucketClusterObj.region).label; const bucketHostname = `${bucketLabel}.${bucketClusterObj.domain}`; interceptGetBuckets().as('getBuckets'); interceptCreateBucket().as('createBucket'); - interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); + interceptDeleteBucket().as('deleteBucket'); interceptGetNetworkUtilization().as('getNetworkUtilization'); mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); @@ -157,17 +166,15 @@ describe('object storage end-to-end tests', () => { it('can update bucket access', () => { const bucketLabel = randomLabel(); const bucketClusterObj = chooseCluster(); - const bucketCluster = bucketClusterObj.id; - const bucketAccessPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/access`; + const bucketRegion = bucketClusterObj.region; + const bucketAccessPage = `/object-storage/buckets/${bucketRegion}/${bucketLabel}/access`; cy.defer( - () => setUpBucket(bucketLabel, bucketCluster), + () => setUpBucket(bucketLabel, bucketRegion), 'creating Object Storage bucket' ).then(() => { - interceptGetBucketAccess(bucketLabel, bucketCluster).as( - 'getBucketAccess' - ); - interceptUpdateBucketAccess(bucketLabel, bucketCluster).as( + interceptGetBucketAccess(bucketLabel, bucketRegion).as('getBucketAccess'); + interceptUpdateBucketAccess(bucketLabel, bucketRegion).as( 'updateBucketAccess' ); @@ -197,4 +204,155 @@ describe('object storage end-to-end tests', () => { cy.findByText('Bucket access updated successfully.'); }); }); + + /* + * - Confirms that user can filter bucket list by region. + */ + it('can filter the list of buckets by region', () => { + interceptGetBuckets().as('getBuckets'); + interceptGetRegions().as('getRegions'); + + const bucketsDetails = new Array(2).fill({}).map((_, index) => ({ + label: randomLabel(), + region: index === 0 ? 'us-ord' : 'us-lax', + })); + + cy.defer( + () => setupBuckets(bucketsDetails), + 'creating Object Storage bucket' + ).then(() => { + cy.visitWithLogin('/object-storage/buckets'); + cy.wait(['@getBuckets', '@getRegions']).then(([_, { response }]) => { + const regions: Region[] = response?.body.data; + + const selectedBucket = bucketsDetails[0]; + const selectedRegion = regions.find( + (region) => region.id === selectedBucket.region + ); + + expect( + selectedRegion, + `expected region matching ${selectedBucket.region}` + ).to.exist; + + const selectedRegionLabel = selectedRegion + ? getNewRegionLabel(selectedRegion) + : ''; + + const regionSelect = ui.autocomplete + .findByLabel('Region') + .should('be.visible') + .type(selectedRegionLabel); + + ui.autocompletePopper + .findByTitle(selectedRegionLabel, { exact: false }) + .should('be.visible') + .click(); + + regionSelect.click(); + + cy.get('tbody').within(() => { + cy.get('tr') + .should('have.length', 1) + .within(() => { + cy.findByText(selectedBucket.label).should('be.visible'); + }); + }); + }); + }); + }); + + /* + * - Confirms that user can filter bucket list by endpoint. + */ + it('can filter the list of buckets by endpoint', () => { + interceptGetBuckets().as('getBuckets'); + interceptGetRegions().as('getRegions'); + + const bucketsDetails = new Array(2).fill({}).map((_, index) => ({ + label: randomLabel(), + region: index === 0 ? 'us-ord' : 'us-lax', + })); + + cy.defer( + () => setupBuckets(bucketsDetails), + 'creating Object Storage bucket' + ).then(() => { + cy.visitWithLogin('/object-storage/buckets'); + cy.wait(['@getBuckets', '@getRegions']); + + const selectedBucket = bucketsDetails[0]; + const selectedBucketRegion = selectedBucket.region; + + const endpointSelect = ui.autocomplete.findByLabel('Endpoint'); + endpointSelect.should('be.visible').type(selectedBucketRegion); + ui.autocompletePopper + .findByTitle(selectedBucketRegion, { exact: false }) + .should('be.visible') + .click(); + endpointSelect.click(); + + cy.get('tbody').within(() => { + cy.get('tr') + .should('have.length', 1) + .within(() => { + cy.findByText(selectedBucket.label).should('be.visible'); + }); + }); + }); + }); + + /* + * - Confirms that when region is selected, endpoint multiselect. + * shows only endpoints related to the selected region. + */ + it('should filter list of endpoints when region is selected', () => { + interceptGetBuckets().as('getBuckets'); + interceptGetRegions().as('getRegions'); + + const bucketsDetails = new Array(2).fill({}).map((_, index) => ({ + label: randomLabel(), + region: index === 0 ? 'us-ord' : 'us-lax', + })); + + cy.defer( + () => setupBuckets(bucketsDetails), + 'creating Object Storage bucket' + ).then(() => { + cy.visitWithLogin('/object-storage/buckets'); + cy.wait(['@getBuckets', '@getRegions']).then(([_, { response }]) => { + const regions: Region[] = response?.body.data; + + const selectedBucket = bucketsDetails[0]; + const selectedRegion = regions.find( + (region) => region.id === selectedBucket.region + ); + + expect( + selectedRegion, + `expected region matching ${selectedBucket.region}` + ).to.exist; + + const selectedRegionLabel = selectedRegion + ? getNewRegionLabel(selectedRegion) + : ''; + + const regionSelect = ui.autocomplete + .findByLabel('Region') + .should('be.visible') + .type(selectedRegionLabel); + ui.autocompletePopper + .findByTitle(selectedRegionLabel, { exact: false }) + .should('be.visible') + .click(); + regionSelect.click(); + + ui.autocomplete.findByLabel('Endpoint').should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(new RegExp('^.*-.*-.*\..*.')) + .should('have.length', 1); + }); + }); + }); }); diff --git a/packages/manager/cypress/support/intercepts/object-storage.ts b/packages/manager/cypress/support/intercepts/object-storage.ts index 3064e25b892..a845105e84b 100644 --- a/packages/manager/cypress/support/intercepts/object-storage.ts +++ b/packages/manager/cypress/support/intercepts/object-storage.ts @@ -197,7 +197,7 @@ export const interceptDeleteBucket = ( apiMatcher(`object-storage/buckets/${cluster}/*`) ); } - return cy.intercept('DELETE', apiMatcher('object-storage/buckets/*')); + return cy.intercept('DELETE', apiMatcher('object-storage/buckets/**/*')); }; /** diff --git a/packages/manager/cypress/support/intercepts/regions.ts b/packages/manager/cypress/support/intercepts/regions.ts index e372f41655b..e3f677940a3 100644 --- a/packages/manager/cypress/support/intercepts/regions.ts +++ b/packages/manager/cypress/support/intercepts/regions.ts @@ -14,6 +14,15 @@ import { makeResponse } from 'support/util/response'; import type { Region, RegionAvailability } from '@linode/api-v4'; import type { ExtendedRegion } from 'support/util/regions'; +/** + * Intercepts GET regions request. + * + * @returns Cypress chainable. + */ +export const interceptGetRegions = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('regions*')); +}; + /** * Intercepts GET request to fetch Linode regions and mocks response. * diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index cc0d8c1dd48..4ec6f989269 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -1,5 +1,11 @@ import { useProfile } from '@linode/queries'; -import { CircleProgress, ErrorState, Notice, Typography } from '@linode/ui'; +import { + Box, + CircleProgress, + ErrorState, + Notice, + Typography, +} from '@linode/ui'; import { readableBytes, useOpenClose } from '@linode/utilities'; import Grid from '@mui/material/Grid'; import * as React from 'react'; @@ -7,6 +13,7 @@ import { makeStyles } from 'tss-react/mui'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; @@ -21,10 +28,14 @@ import { } from 'src/utilities/analytics/customEventAnalytics'; import { CancelNotice } from '../CancelNotice'; +import { useIsObjectStorageGen2Enabled } from '../hooks/useIsObjectStorageGen2Enabled'; +import { EndpointMultiselect } from '../Partials/EndpointMultiselect'; +import { uniqueByKey } from '../utilities'; import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; +import type { EndpointMultiselectValue } from '../Partials/EndpointMultiselect'; import type { APIError, ObjectStorageBucket } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -42,6 +53,7 @@ export const OMC_BucketLanding = (props: Props) => { const { isCreateBucketDrawerOpen } = props; const { data: profile } = useProfile(); const { availableStorageRegions } = useObjectStorageRegions(); + const { isObjectStorageGen2Enabled } = useIsObjectStorageGen2Enabled(); const isRestrictedUser = profile?.restricted; @@ -62,6 +74,14 @@ export const OMC_BucketLanding = (props: Props) => { const [bucketDetailDrawerOpen, setBucketDetailDrawerOpen] = React.useState(false); + const [selectedRegions, setSelectedRegions] = React.useState< + { label: string; value: string }[] + >([]); + + const [selectedEndpoints, setSelectedEndpoints] = React.useState< + EndpointMultiselectValue[] + >([]); + const [selectedBucket, setSelectedBucket] = React.useState< ObjectStorageBucket | undefined >(undefined); @@ -144,6 +164,27 @@ export const OMC_BucketLanding = (props: Props) => { const totalUsage = sumBucketUsage(buckets); const bucketLabel = selectedBucket ? selectedBucket.label : ''; + const endpointOptions = React.useMemo( + () => + uniqueByKey( + buckets + .filter((bucket) => { + if (selectedRegions.length) { + return selectedRegions.some( + (region) => region.value === bucket.region + ); + } + + return true; + }) + .map((bucket) => ({ + label: bucket.s3_endpoint, + })), + 'label' + ) as EndpointMultiselectValue[], + [buckets, selectedRegions] + ); + const { handleOrderChange, order, @@ -161,6 +202,20 @@ export const OMC_BucketLanding = (props: Props) => { preferenceKey: 'object-storage-buckets', }); + const filteredData = orderedData?.filter((bucket) => { + if (selectedEndpoints.length) { + return selectedEndpoints.some( + (endpoint) => bucket.s3_endpoint === endpoint.label + ); + } + + if (selectedRegions.length) { + return selectedRegions.some((region) => bucket.region === region.value); + } + + return true; + }); + if (isRestrictedUser) { return ; } @@ -190,16 +245,54 @@ export const OMC_BucketLanding = (props: Props) => { } return ( - + <> + {unavailableRegionLabels && unavailableRegionLabels.length > 0 && ( )} + + + Filter by + + + ({ + display: 'flex', + gap: theme.spacingFunction(16), + marginBottom: theme.spacingFunction(16), + })} + > + + setSelectedRegions(values.map((value) => ({ label: value, value }))) + } + regions={availableStorageRegions.filter((r) => + buckets.some((b) => b.region === r.id) + )} + selectedIds={selectedRegions.map((r) => r.value)} + /> + + {isObjectStorageGen2Enabled && ( + + )} + + { ) : null} 1 ? 8 : 18} /> + { Account Settings. */} {buckets.length === 1 && } + - + ); }; diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.test.tsx b/packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.test.tsx similarity index 77% rename from packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.test.tsx rename to packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.test.tsx index 569a39ae95f..377ff125fb0 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.test.tsx +++ b/packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.test.tsx @@ -39,7 +39,7 @@ const endpointsMock = [ const onChangeMock = vi.fn(); describe('EndpointMultiselect', () => { - it('should show loading text while fetching endpoints', async () => { + it('should show loading text while fetching endpoints', () => { queryMocks.useObjectStorageEndpoints.mockReturnValue({ data: [], isFetching: true, @@ -54,7 +54,7 @@ describe('EndpointMultiselect', () => { expect(getByPlaceholderText('Loading S3 endpoints...')).toBeVisible(); }); - it('should show proper placeholder after fetching endpoints', async () => { + it('should show proper placeholder after fetching endpoints', () => { queryMocks.useObjectStorageEndpoints.mockReturnValue({ data: endpointsMock, isFetching: false, @@ -70,4 +70,23 @@ describe('EndpointMultiselect', () => { getByPlaceholderText('Select an Object Storage S3 endpoint') ).toBeVisible(); }); + + it('should show label if showLabel property set to true', () => { + queryMocks.useObjectStorageEndpoints.mockReturnValue({ + data: endpointsMock, + isFetching: false, + }); + + const selectedEndpoints: EndpointMultiselectValue[] = []; + + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Endpoint')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.tsx b/packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.tsx similarity index 67% rename from packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.tsx rename to packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.tsx index 85bb5dfb15a..5d45640d764 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointMultiselect.tsx +++ b/packages/manager/src/features/ObjectStorage/Partials/EndpointMultiselect.tsx @@ -3,17 +3,30 @@ import * as React from 'react'; import { useObjectStorageEndpoints } from 'src/queries/object-storage/queries'; +import type { SxProps, Theme } from '@linode/ui'; + export interface EndpointMultiselectValue { label: string; } interface Props { + disabled?: boolean; onChange: (value: EndpointMultiselectValue[]) => void; + options?: EndpointMultiselectValue[]; + showLabel?: boolean; + sx?: SxProps; values: EndpointMultiselectValue[]; } -export const EndpointMultiselect = ({ values, onChange }: Props) => { - const { data: endpoints, isFetching } = useObjectStorageEndpoints(); +export const EndpointMultiselect = ({ + values, + onChange, + options, + showLabel = false, + sx, + disabled = false, +}: Props) => { + const { data: endpoints, isFetching } = useObjectStorageEndpoints(!options); const multiselectOptions = React.useMemo( () => (endpoints ?? []) @@ -26,18 +39,19 @@ export const EndpointMultiselect = ({ values, onChange }: Props) => { return ( onChange(newValues)} - options={multiselectOptions} + options={options ? options : multiselectOptions} placeholder={ isFetching ? `Loading S3 endpoints...` : 'Select an Object Storage S3 endpoint' } + sx={sx} value={values} /> ); diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryRow.test.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.test.tsx rename to packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryRow.test.tsx diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryRow.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryRow.tsx rename to packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryRow.tsx diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryTable.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/SummaryLanding/Partials/EndpointSummaryTable.tsx rename to packages/manager/src/features/ObjectStorage/SummaryLanding/EndpointSummaryTable/EndpointSummaryTable.tsx diff --git a/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx b/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx index 646cfeab731..d9d37388e6a 100644 --- a/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/SummaryLanding/SummaryLanding.tsx @@ -3,10 +3,10 @@ import React from 'react'; import { Link } from 'src/components/Link'; -import { EndpointMultiselect } from './Partials/EndpointMultiselect'; -import { EndpointSummaryTable } from './Partials/EndpointSummaryTable'; +import { EndpointMultiselect } from '../Partials/EndpointMultiselect'; +import { EndpointSummaryTable } from './EndpointSummaryTable/EndpointSummaryTable'; -import type { EndpointMultiselectValue } from './Partials/EndpointMultiselect'; +import type { EndpointMultiselectValue } from '../Partials/EndpointMultiselect'; export const SummaryLanding = () => { const [selectedEndpoints, setSelectedEndpoints] = React.useState< diff --git a/packages/manager/src/features/ObjectStorage/utilities.ts b/packages/manager/src/features/ObjectStorage/utilities.ts index 2b3bf462d6b..d2563829d5b 100644 --- a/packages/manager/src/features/ObjectStorage/utilities.ts +++ b/packages/manager/src/features/ObjectStorage/utilities.ts @@ -199,3 +199,16 @@ export const filterRegionsByEndpoints = ( return regions.filter((region) => endpointRegions.has(region.id)); }; + +export const uniqueByKey = >( + arr: Array, + key: string +): Array => { + const seen = new Set(); + return arr.filter((item) => { + const value = item[key]; + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +};