diff --git a/packages/manager/.changeset/pr-11382-upcoming-features-1733485035913.md b/packages/manager/.changeset/pr-11382-upcoming-features-1733485035913.md new file mode 100644 index 00000000000..4f3f3c27984 --- /dev/null +++ b/packages/manager/.changeset/pr-11382-upcoming-features-1733485035913.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Show ACLP supported regions per service type in region select ([#11382](https://github.com/linode/manager/pull/11382)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index f2fbcdc66a7..7a142a0a6bd 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -35,6 +35,7 @@ import { } from 'support/intercepts/databases'; import { Database } from '@linode/api-v4'; import { mockGetAccount } from 'support/intercepts/account'; +import { Flags } from 'src/featureFlags'; /** * Verifies the presence and values of specific properties within the aclpPreference object @@ -44,6 +45,24 @@ import { mockGetAccount } from 'support/intercepts/account'; * @param requestPayload - The payload received from the request, containing the aclpPreference object. * @param expectedValues - An object containing the expected values for properties to validate against the requestPayload. */ + +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: 'us-ord', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: 'us-ord', + }, + ], +}; const { metrics, id, @@ -78,10 +97,9 @@ const metricDefinitions = { }; const mockRegion = regionFactory.build({ - capabilities: ['Linodes'], + capabilities: ['Managed Databases'], id: 'us-ord', label: 'Chicago, IL', - country: 'us', }); const databaseMock: Database = databaseFactory.build({ @@ -97,9 +115,7 @@ const mockAccount = accountFactory.build(); describe('Tests for API error handling', () => { beforeEach(() => { - mockAppendFeatureFlags({ - aclp: { beta: true, enabled: true }, - }); + mockAppendFeatureFlags(flags); mockGetAccount(mockAccount); mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); mockGetCloudPulseDashboards(serviceType, [dashboard]).as('fetchDashboard'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 1c63c435993..6f5f367e895 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -27,7 +27,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; -import { extendRegion } from 'support/util/regions'; import { CloudPulseMetricsResponse, Database } from '@linode/api-v4'; import { Interception } from 'cypress/types/net-stubbing'; import { generateRandomMetricsData } from 'support/util/cloudpulse'; @@ -49,14 +48,29 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: '', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: 'us-ord', + }, + ], +}; const { metrics, id, serviceType, dashboardName, - region, engine, clusterName, nodeType, @@ -91,14 +105,18 @@ const mockLinode = linodeFactory.build({ }); const mockAccount = accountFactory.build(); -const mockRegion = extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-ord', - label: 'Chicago, IL', - country: 'us', - }) -); + +const mockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-ord', + label: 'Chicago, IL', +}); + +const extendedMockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'Newark,NL', +}); const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ data: generateRandomMetricsData(timeDurationToSelect, '5 min'), }); @@ -151,9 +169,9 @@ const getWidgetLegendRowValuesFromResponse = ( }; const databaseMock: Database = databaseFactory.build({ - label: widgetDetails.dbaas.clusterName, - type: widgetDetails.dbaas.engine, - region: widgetDetails.dbaas.region, + label: clusterName, + type: engine, + region: mockRegion.label, version: '1', status: 'provisioning', cluster_size: 1, @@ -177,7 +195,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { mockCreateCloudPulseMetrics(serviceType, metricsAPIResponsePayload).as( 'getMetrics' ); - mockGetRegions([mockRegion]); + mockGetRegions([mockRegion, extendedMockRegion]); mockGetUserPreferences({}); mockGetDatabases([databaseMock]).as('getDatabases'); @@ -191,35 +209,60 @@ describe('Integration Tests for DBaaS Dashboard ', () => { ui.autocomplete .findByLabel('Dashboard') .should('be.visible') - .type(`${dashboardName}{enter}`) - .should('be.visible'); + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); // Select a time duration from the autocomplete input. ui.autocomplete .findByLabel('Time Range') .should('be.visible') - .type(`${timeDurationToSelect}{enter}`) - .should('be.visible'); + .type(timeDurationToSelect); + + ui.autocompletePopper + .findByTitle(timeDurationToSelect) + .should('be.visible') + .click(); - //Select a Engine from the autocomplete input. + //Select a Database Engine from the autocomplete input. ui.autocomplete .findByLabel('Database Engine') .should('be.visible') - .type(`${engine}{enter}`) - .should('be.visible'); + .type(engine); + + ui.autocompletePopper.findByTitle(engine).should('be.visible').click(); + + // Select a region from the dropdown. + ui.regionSelect.find().click(); - // Select a region from the dropdown. - ui.regionSelect.find().click().type(`${region}{enter}`); + ui.regionSelect.find().type(extendedMockRegion.label); + + // Since DBaaS does not support this region, we expect it to not be in the dropdown. + + ui.autocompletePopper.find().within(() => { + cy.findByText( + `${extendedMockRegion.label} (${extendedMockRegion.id})` + ).should('not.exist'); + }); - // Select a resource from the autocomplete input. + ui.regionSelect.find().click().clear(); + ui.regionSelect + .findItemByRegionId(mockRegion.id, [mockRegion]) + .should('be.visible') + .click(); + + // Select a resource (Database Clusters) from the autocomplete input. ui.autocomplete .findByLabel('Database Clusters') .should('be.visible') - .type(`${clusterName}{enter}`) - .click(); - cy.findByText(clusterName).should('be.visible'); + .type(clusterName); + + ui.autocompletePopper.findByTitle(clusterName).should('be.visible').click(); - //Select a Node from the autocomplete input. + // Select a Node from the autocomplete input. ui.autocomplete .findByLabel('Node Type') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 303748e957a..7e49463a19d 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -26,7 +26,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; -import { extendRegion } from 'support/util/regions'; import { CloudPulseMetricsResponse } from '@linode/api-v4'; import { generateRandomMetricsData } from 'support/util/cloudpulse'; import { Interception } from 'cypress/types/net-stubbing'; @@ -46,15 +45,25 @@ import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; */ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; -const { - metrics, - id, - serviceType, - dashboardName, - region, - resource, -} = widgetDetails.linode; +const flags: Partial = { + aclp: { enabled: true, beta: true }, + aclpResourceTypeMap: [ + { + dimensionKey: 'LINODE_ID', + maxResourceSelections: 10, + serviceType: 'linode', + supportedRegionIds: 'us-ord', + }, + { + dimensionKey: 'cluster_id', + maxResourceSelections: 10, + serviceType: 'dbaas', + supportedRegionIds: '', + }, + ], +}; +const { metrics, id, serviceType, dashboardName, region, resource } = + widgetDetails.linode; const dashboard = dashboardFactory.build({ label: dashboardName, @@ -85,14 +94,18 @@ const mockLinode = linodeFactory.build({ }); const mockAccount = accountFactory.build(); -const mockRegion = extendRegion( - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-ord', - label: 'Chicago, IL', - country: 'us', - }) -); + +const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-ord', + label: 'Chicago, IL', +}); + +const extendedMockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'Newark,NL', +}); const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ data: generateRandomMetricsData(timeDurationToSelect, '5 min'), }); @@ -170,18 +183,41 @@ describe('Integration Tests for Linode Dashboard ', () => { ui.autocomplete .findByLabel('Dashboard') .should('be.visible') - .type(`${dashboardName}{enter}`) - .should('be.visible'); + .type(dashboardName); + + ui.autocompletePopper + .findByTitle(dashboardName) + .should('be.visible') + .click(); // Select a time duration from the autocomplete input. ui.autocomplete .findByLabel('Time Range') .should('be.visible') - .type(`${timeDurationToSelect}{enter}`) - .should('be.visible'); + .type(timeDurationToSelect); + + ui.autocompletePopper + .findByTitle(timeDurationToSelect) + .should('be.visible') + .click(); + + ui.regionSelect.find().click(); + + // Select a region from the dropdown. + ui.regionSelect.find().click(); + + ui.regionSelect.find().type(extendedMockRegion.label); + + // Since Linode does not support this region, we expect it to not be in the dropdown. + + ui.autocompletePopper.find().within(() => { + cy.findByText( + `${extendedMockRegion.label} (${extendedMockRegion.id})` + ).should('not.exist'); + }); // Select a region from the dropdown. - ui.regionSelect.find().click().type(`${region}{enter}`); + ui.regionSelect.find().click().clear().type(`${region}{enter}`); // Select a resource from the autocomplete input. ui.autocomplete @@ -191,6 +227,7 @@ describe('Integration Tests for Linode Dashboard ', () => { .click(); cy.findByText(resource).should('be.visible'); + // Wait for all metrics query requests to resolve. cy.wait(['@getMetrics', '@getMetrics', '@getMetrics', '@getMetrics']); }); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 5c1fc1f2d83..493dbc39767 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -73,6 +73,7 @@ export interface CloudPulseResourceTypeMapFlag { dimensionKey: string; maxResourceSelections?: number; serviceType: string; + supportedRegionIds?: string; } interface gpuV2 { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index 55c96678749..e5bc12d7e3b 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -3,8 +3,11 @@ import { CloudPulseSelectTypes } from './models'; import type { CloudPulseServiceTypeFilterMap } from './models'; const TIME_DURATION = 'Time Range'; +export const DBAAS_CAPABILITY = 'Managed Databases'; +export const LINODE_CAPABILITY = 'Linodes'; export const LINODE_CONFIG: Readonly = { + capability: LINODE_CAPABILITY, filters: [ { configuration: { @@ -52,6 +55,7 @@ export const LINODE_CONFIG: Readonly = { }; export const DBAAS_CONFIG: Readonly = { + capability: DBAAS_CAPABILITY, filters: [ { configuration: { diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index f51e945074e..05d4c7fc926 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -1,10 +1,18 @@ -import type { DatabaseEngine, DatabaseType } from '@linode/api-v4'; +import type { + Capabilities, + DatabaseEngine, + DatabaseType, +} from '@linode/api-v4'; import type { QueryFunction, QueryKey } from '@tanstack/react-query'; /** * The CloudPulseServiceTypeMap has list of filters to be built for different service types like dbaas, linode etc.,The properties here are readonly as it is only for reading and can't be modified in code */ export interface CloudPulseServiceTypeFilterMap { + /** + * Current capability corresponding to a service type + */ + readonly capability: Capabilities; /** * The list of filters for a service type */ diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index 9372ec32ead..8e3a69b9696 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -1,12 +1,16 @@ +import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import * as regions from 'src/queries/regions/regions'; +import { dashboardFactory, regionFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { DBAAS_CAPABILITY, LINODE_CAPABILITY } from '../Utils/FilterConfig'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; import type { Region } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag, Flags } from 'src/featureFlags'; +import type * as regions from 'src/queries/regions/regions'; const props: CloudPulseRegionSelectProps = { handleRegionChange: vi.fn(), @@ -14,11 +18,32 @@ const props: CloudPulseRegionSelectProps = { selectedDashboard: undefined, }; -describe('CloudPulseRegionSelect', () => { - vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ - data: Array(), - } as ReturnType); +const queryMocks = vi.hoisted(() => ({ + useRegionsQuery: vi.fn().mockReturnValue({}), +})); + +const flags: Partial = { + aclpResourceTypeMap: [ + { + serviceType: 'dbaas', + supportedRegionIds: 'us-west, us-east', + }, + { + serviceType: 'linode', + supportedRegionIds: 'us-lax, us-mia', + }, + ] as CloudPulseResourceTypeMapFlag[], +}; +vi.mock('src/queries/regions/regions', async () => { + const actual = await vi.importActual('src/queries/regions/regions'); + return { + ...actual, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +describe('CloudPulseRegionSelect', () => { it('should render a Region Select component', () => { const { getByLabelText, getByTestId } = renderWithTheme( @@ -29,7 +54,7 @@ describe('CloudPulseRegionSelect', () => { }); it('should render a Region Select component with proper error message on api call failure', () => { - vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + queryMocks.useRegionsQuery.mockReturnValue({ data: undefined, isError: true, isLoading: false, @@ -40,4 +65,79 @@ describe('CloudPulseRegionSelect', () => { expect(getByText('Failed to fetch Region.')); }); + + it('should render a Region Select component with capability specific and launchDarkly based supported regions', async () => { + const user = userEvent.setup(); + + const allRegions: Region[] = [ + regionFactory.build({ + capabilities: [LINODE_CAPABILITY], + id: 'us-lax', + label: 'US, Los Angeles, CA', + }), + regionFactory.build({ + capabilities: [LINODE_CAPABILITY], + id: 'us-mia', + label: 'US, Miami, FL', + }), + regionFactory.build({ + capabilities: [DBAAS_CAPABILITY], + id: 'us-west', + label: 'US, Fremont, CA', + }), + regionFactory.build({ + capabilities: [DBAAS_CAPABILITY], + id: 'us-east', + label: 'US, Newark, NJ', + }), + regionFactory.build({ + capabilities: [DBAAS_CAPABILITY], + id: 'us-central', + label: 'US, Dallas, TX', + }), + ]; + + queryMocks.useRegionsQuery.mockReturnValue({ + data: allRegions, + isError: false, + isLoading: false, + }); + + const { getByRole, queryByRole } = renderWithTheme( + , + { flags } + ); + + await user.click(getByRole('button', { name: 'Open' })); + // example: region id => 'us-west' belongs to service type - 'dbaas', capability -'Managed Databases', and is supported via launchDarkly + expect( + getByRole('option', { + name: 'US, Fremont, CA (us-west)', + }) + ).toBeInTheDocument(); + expect( + getByRole('option', { + name: 'US, Newark, NJ (us-east)', + }) + ).toBeInTheDocument(); + expect( + queryByRole('option', { + name: 'US, Dallas, TX (us-central)', + }) + ).toBeNull(); + expect( + queryByRole('option', { + name: 'US, Los Angeles, CA (us-lax)', + }) + ).toBeNull(); + expect( + queryByRole('option', { + name: 'US, Miami, FL (us-mia)', + }) + ).toBeNull(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 3d38f51c287..f69ab97e964 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import type { Dashboard, FilterValue } from '@linode/api-v4'; +import { FILTER_CONFIG } from '../Utils/FilterConfig'; + +import type { Dashboard, FilterValue, Region } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; export interface CloudPulseRegionSelectProps { defaultValue?: FilterValue; @@ -22,6 +26,8 @@ export const CloudPulseRegionSelect = React.memo( (props: CloudPulseRegionSelectProps) => { const { data: regions, isError, isLoading } = useRegionsQuery(); + const flags = useFlags(); + const { defaultValue, handleRegionChange, @@ -31,6 +37,11 @@ export const CloudPulseRegionSelect = React.memo( selectedDashboard, } = props; + const serviceType: string | undefined = selectedDashboard?.service_type; + const capability = serviceType + ? FILTER_CONFIG.get(serviceType)?.capability + : undefined; + const [selectedRegion, setSelectedRegion] = React.useState(); // Once the data is loaded, set the state variable with value stored in preferences React.useEffect(() => { @@ -44,6 +55,29 @@ export const CloudPulseRegionSelect = React.memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [regions]); + // validate launchDarkly region_ids with the ids from the fetched 'all-regions' + const supportedRegions = React.useMemo(() => { + const resourceTypeFlag = flags.aclpResourceTypeMap?.find( + (item: CloudPulseResourceTypeMapFlag) => + item.serviceType === serviceType + ); + + if ( + resourceTypeFlag?.supportedRegionIds === null || + resourceTypeFlag?.supportedRegionIds === undefined + ) { + return regions; + } + + const supportedRegionsIdList = resourceTypeFlag.supportedRegionIds + .split(',') + .map((regionId: string) => regionId.trim()); + + return regions?.filter((region) => + supportedRegionsIdList.includes(region.id) + ); + }, [flags.aclpResourceTypeMap, regions, serviceType]); + return ( { @@ -54,7 +88,7 @@ export const CloudPulseRegionSelect = React.memo( savePreferences ); }} - currentCapability={undefined} + currentCapability={capability} data-testid="region-select" disableClearable={false} disabled={!selectedDashboard || !regions} @@ -64,7 +98,7 @@ export const CloudPulseRegionSelect = React.memo( loading={isLoading} noMarginTop placeholder={placeholder ?? 'Select a Region'} - regions={regions ? regions : []} + regions={supportedRegions ?? []} value={selectedRegion} /> );