diff --git a/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md b/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md new file mode 100644 index 00000000000..444f9327d46 --- /dev/null +++ b/packages/manager/.changeset/pr-13029-upcoming-features-1761669638634.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +CloudPulse-Metrics: Add optional-filter component at `CloudPulseFirewallNodebalancersSelect.tsx` and integrate it with existing firewall-nodebalancer filters ([#13029](https://github.com/linode/manager/pull/13029)) diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index d17871c1663..e8148e9fcc6 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -11,7 +11,10 @@ import { import { RESOURCE_FILTER_MAP } from '../Utils/constants'; import { useAclpPreference } from '../Utils/UserPreference'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { + getAssociatedEntityType, + getResourcesFilterConfig, +} from '../Utils/utils'; import { renderPlaceHolder, RenderWidgets, @@ -114,9 +117,9 @@ export const CloudPulseDashboard = (props: DashboardProperties) => { // Get the resources filter configuration for the dashboard const resourcesFilterConfig = getResourcesFilterConfig(dashboardId); - const associatedEntityType = - resourcesFilterConfig?.associatedEntityType ?? 'both'; const filterFn = resourcesFilterConfig?.filterFn; + // Get the associated entity type for the dashboard + const associatedEntityType = getAssociatedEntityType(dashboardId); const { data: resourceList, diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts index 8f326982c80..798a0e85fec 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.test.ts @@ -1,4 +1,5 @@ import { databaseQueries } from '@linode/queries'; +import { nodeBalancerFactory } from '@linode/utilities'; import { DateTime } from 'luxon'; import { @@ -12,9 +13,11 @@ import { deepEqual, filterBasedOnConfig, filterEndpointsUsingRegion, + filterFirewallNodebalancers, filterUsingDependentFilters, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getTextFilterProperties, } from './FilterBuilder'; import { @@ -43,7 +46,9 @@ const dbaasConfig = FILTER_CONFIG.get(1); const nodeBalancerConfig = FILTER_CONFIG.get(3); -const firewallConfig = FILTER_CONFIG.get(4); +const linodeFirewallConfig = FILTER_CONFIG.get(4); + +const nodebalancerFirewallConfig = FILTER_CONFIG.get(8); const dbaasDashboard = dashboardFactory.build({ service_type: 'dbaas', id: 1 }); @@ -135,7 +140,7 @@ it('test getResourceSelectionProperties method', () => { }); it('test getResourceSelectionProperties method for linode-firewall', () => { - const resourceSelectionConfig = firewallConfig?.filters.find( + const resourceSelectionConfig = linodeFirewallConfig?.filters.find( (filterObj) => filterObj.name === 'Firewalls' ); @@ -426,7 +431,7 @@ it('test getTextFilterProperties method for port', () => { }); it('test getTextFilterProperties method for interface_id', () => { - const interfaceIdFilterConfig = firewallConfig?.filters.find( + const interfaceIdFilterConfig = linodeFirewallConfig?.filters.find( (filterObj) => filterObj.name === 'Interface IDs' ); @@ -488,6 +493,49 @@ it('test getEndpointsProperties method', () => { expect(xFilter).toEqual({ region: 'us-east' }); } }); +it('test getFirewallNodebalancersProperties', () => { + const nodebalancersConfig = nodebalancerFirewallConfig?.filters.find( + (filterObj) => filterObj.name === 'NodeBalancers' + ); + + expect(nodebalancersConfig).toBeDefined(); + + if (nodebalancersConfig) { + const nodebalancersProperties = getFirewallNodebalancersProperties( + { + config: nodebalancersConfig, + dashboard: dashboardFactory.build({ service_type: 'firewall', id: 8 }), + dependentFilters: { + resource_id: '1', + associated_entity_region: 'us-east', + }, + isServiceAnalyticsIntegration: false, + }, + vi.fn() + ); + const { + label, + disabled, + selectedDashboard, + savePreferences, + handleNodebalancersSelection, + defaultValue, + xFilter, + } = nodebalancersProperties; + + expect(nodebalancersProperties).toBeDefined(); + expect(label).toEqual(nodebalancersConfig.configuration.name); + expect(selectedDashboard.service_type).toEqual('firewall'); + expect(savePreferences).toEqual(true); + expect(disabled).toEqual(false); + expect(handleNodebalancersSelection).toBeDefined(); + expect(defaultValue).toEqual(undefined); + expect(xFilter).toEqual({ + resource_id: '1', + associated_entity_region: 'us-east', + }); + } +}); it('test getFiltersForMetricsCallFromCustomSelect method', () => { const result = getMetricsCallCustomFilters( @@ -669,6 +717,76 @@ describe('filterEndpointsUsingRegion', () => { }); }); +describe('filterFirewallNodebalancers', () => { + const mockData = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + ]; + const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1' }, + }, + ]; + + it('should return undefined if data is undefined', () => { + expect( + filterFirewallNodebalancers( + undefined, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ) + ).toEqual(undefined); + }); + + it('should return undefined if xFilter/firewalls is empty or undefined', () => { + const result = filterFirewallNodebalancers( + mockData, + undefined, + mockFirewalls + ); + const result2 = filterFirewallNodebalancers(mockData, {}, mockFirewalls); + const result3 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + [] + ); + const result4 = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + undefined + ); + expect(result).toEqual(undefined); + expect(result2).toEqual(undefined); + expect(result3).toEqual(undefined); + expect(result4).toEqual(undefined); + }); + + it('should filter nodebalancers based on xFilter', () => { + const result = filterFirewallNodebalancers( + mockData, + { associated_entity_region: 'us-east', resource_id: '1' }, + mockFirewalls + ); + expect(result).toEqual([ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + ]); + }); +}); + describe('filterBasedOnConfig', () => { const config: CloudPulseServiceTypeFilters = { configuration: { diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts index 17d7895b956..6ec4a6480b5 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterBuilder.ts @@ -8,6 +8,7 @@ import { } from './constants'; import { FILTER_CONFIG } from './FilterConfig'; import { CloudPulseAvailableViews, CloudPulseSelectTypes } from './models'; +import { getAssociatedEntityType } from './utils'; import type { CloudPulseMetricsFilter, @@ -16,6 +17,10 @@ import type { import type { CloudPulseCustomSelectProps } from '../shared/CloudPulseCustomSelect'; import type { CloudPulseEndpointsSelectProps } from '../shared/CloudPulseEndpointsSelect'; import type { CloudPulseEndpoints } from '../shared/CloudPulseEndpointsSelect'; +import type { + CloudPulseFirewallNodebalancersSelectProps, + CloudPulseNodebalancers, +} from '../shared/CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from '../shared/CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from '../shared/CloudPulseRegionSelect'; import type { @@ -36,6 +41,7 @@ import type { Dashboard, DateTimeWithPreset, Filters, + NodeBalancer, TimeDuration, } from '@linode/api-v4'; @@ -183,7 +189,7 @@ export const getResourcesProperties = ( resourceType: dashboard.service_type, savePreferences: !isServiceAnalyticsIntegration, xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), - associatedEntityType: config.configuration.associatedEntityType ?? 'both', + associatedEntityType: getAssociatedEntityType(dashboard.id), filterFn: config.configuration.filterFn, }; }; @@ -408,6 +414,47 @@ export const getEndpointsProperties = ( }; }; +/** + * + * @param props The cloudpulse filter properties selected so far + * @param handleFirewallNodebalancersChange The callback function when selection of nodebalancers changes + * @returns CloudPulseFirewallNodebalancersSelectProps + */ +export const getFirewallNodebalancersProperties = ( + props: CloudPulseFilterProperties, + handleFirewallNodebalancersChange: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void +): CloudPulseFirewallNodebalancersSelectProps => { + const { filterKey, name: label, placeholder } = props.config.configuration; + const { + config, + dashboard, + dependentFilters, + isServiceAnalyticsIntegration, + preferences, + shouldDisable, + } = props; + return { + defaultValue: preferences?.[config.configuration.filterKey], + selectedDashboard: dashboard, + disabled: + shouldDisable || + shouldDisableFilterByFilterKey( + filterKey, + dependentFilters ?? {}, + dashboard, + preferences + ), + handleNodebalancersSelection: handleFirewallNodebalancersChange, + label, + placeholder, + savePreferences: !isServiceAnalyticsIntegration, + xFilter: filterBasedOnConfig(config, dependentFilters ?? {}), + isOptional: config.configuration.isOptional, + }; +}; /** * This function helps in builder the xFilter needed to passed in a apiV4 call * @@ -769,3 +816,45 @@ export const filterEndpointsUsingRegion = ( return data.filter(({ region }) => region === regionFromFilter); }; + +/** + * + * @param data The nodebalancers for which the filter needs to be applied + * @param xFilter The selected filters that will be used to filter the nodebalancers + * @param firewalls The firewalls for which the filter needs to be applied + * @returns The filtered nodebalancers + */ + +export const filterFirewallNodebalancers = ( + data?: NodeBalancer[], + xFilter?: CloudPulseMetricsFilter, + firewalls?: CloudPulseResources[] +): CloudPulseNodebalancers[] | undefined => { + // If data is undefined or xFilter/firewalls is undefined or empty, return undefined + if (!data || !xFilter || !Object.keys(xFilter).length || !firewalls?.length) { + return undefined; + } + + // Map the nodebalancers to the CloudPulseNodebalancers interface + const nodebalancers: CloudPulseNodebalancers[] = data.map((nodebalancer) => ({ + id: String(nodebalancer.id), + label: nodebalancer.label, + associated_entity_region: nodebalancer.region, + })); + + const firewallObj = firewalls.find( + (firewall) => firewall.id === String(xFilter[RESOURCE_ID]) + ); + + return nodebalancers.filter((nodebalancer) => { + return Object.entries(xFilter).every(([key, filterValue]) => { + // If the filter key is the resource id, check if the nodebalancer is associated with the selected firewall + if (key === RESOURCE_ID) { + return firewallObj?.entities?.[nodebalancer.id]; + } + const nodebalancerValue = + nodebalancer[key as keyof CloudPulseNodebalancers]; + return nodebalancerValue === filterValue; + }); + }); +}; diff --git a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts index f8aa2bf58aa..a68c9968303 100644 --- a/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts +++ b/packages/manager/src/features/CloudPulse/Utils/FilterConfig.ts @@ -5,6 +5,7 @@ import { queryFactory } from 'src/queries/cloudpulse/queries'; import { ENDPOINT, INTERFACE_IDS_PLACEHOLDER_TEXT, + NODEBALANCER_ID, PARENT_ENTITY_REGION, REGION, RESOURCE_ID, @@ -322,6 +323,7 @@ export const FIREWALL_CONFIG: Readonly = { }, ], serviceType: 'firewall', + associatedEntityType: 'linode', }; export const FIREWALL_NODEBALANCER_CONFIG: Readonly = @@ -362,8 +364,28 @@ export const FIREWALL_NODEBALANCER_CONFIG: Readonly = diff --git a/packages/manager/src/features/CloudPulse/Utils/constants.ts b/packages/manager/src/features/CloudPulse/Utils/constants.ts index b7abcf56256..2095c2852be 100644 --- a/packages/manager/src/features/CloudPulse/Utils/constants.ts +++ b/packages/manager/src/features/CloudPulse/Utils/constants.ts @@ -16,6 +16,8 @@ export const PARENT_ENTITY_REGION = 'associated_entity_region'; export const RESOURCES = 'resources'; +export const NODEBALANCER_ID = 'nodebalancer_id'; + export const INTERVAL = 'interval'; export const TIME_DURATION = 'dateTimeDuration'; diff --git a/packages/manager/src/features/CloudPulse/Utils/models.ts b/packages/manager/src/features/CloudPulse/Utils/models.ts index 4daa996d85f..0386c8bd7a2 100644 --- a/packages/manager/src/features/CloudPulse/Utils/models.ts +++ b/packages/manager/src/features/CloudPulse/Utils/models.ts @@ -17,6 +17,10 @@ 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 { + /** + * The associated entity type for the service type + */ + readonly associatedEntityType?: AssociatedEntityType; /** * Current capability corresponding to a service type */ @@ -24,9 +28,7 @@ export interface CloudPulseServiceTypeFilterMap { /** * The list of filters for a service type */ - readonly filters: CloudPulseServiceTypeFilters[]; - /** * The service types like dbaas, linode etc., */ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts index fb4a91e16ae..657235635bb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.test.ts @@ -25,6 +25,7 @@ import { arePortsValid, areValidInterfaceIds, filterFirewallResources, + getAssociatedEntityType, getEnabledServiceTypes, getFilteredDimensions, getResourcesFilterConfig, @@ -370,6 +371,20 @@ describe('getEnabledServiceTypes', () => { }); }); + describe('getAssociatedEntityType', () => { + it('should return undefined if the dashboard id is not provided', () => { + expect(getAssociatedEntityType(undefined)).toBeUndefined(); + }); + + it('should return the associated entity type for the linode-firewall dashboard', () => { + expect(getAssociatedEntityType(4)).toBe('linode'); + }); + + it('should return the associated entity type for the nodebalancer-firewall dashboard', () => { + expect(getAssociatedEntityType(8)).toBe('nodebalancer'); + }); + }); + describe('filterFirewallResources', () => { it('should return the filtered firewall resources for linode', () => { const resources = [ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 4d6dd48dd4c..c49c50500ae 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -550,6 +550,19 @@ export const getResourcesFilterConfig = ( )?.configuration; }; +/** + * @param dashboardId The id of the dashboard + * @returns The associated entity type for the dashboard + */ +export const getAssociatedEntityType = ( + dashboardId: number | undefined +): AssociatedEntityType | undefined => { + if (!dashboardId) { + return undefined; + } + return FILTER_CONFIG.get(dashboardId)?.associatedEntityType; +}; + /** * * @param resources Firewall resources diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx index 75ab29be939..0a90f3801d4 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseComponentRenderer.tsx @@ -6,6 +6,7 @@ import NullComponent from 'src/components/NullComponent'; import { CloudPulseCustomSelect } from './CloudPulseCustomSelect'; import { CloudPulseDateTimeRangePicker } from './CloudPulseDateTimeRangePicker'; import { CloudPulseEndpointsSelect } from './CloudPulseEndpointsSelect'; +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; import { CloudPulseNodeTypeFilter } from './CloudPulseNodeTypeFilter'; import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; @@ -15,6 +16,7 @@ import { CloudPulseTextFilter } from './CloudPulseTextFilter'; import type { CloudPulseCustomSelectProps } from './CloudPulseCustomSelect'; import type { CloudPulseDateTimeRangePickerProps } from './CloudPulseDateTimeRangePicker'; import type { CloudPulseEndpointsSelectProps } from './CloudPulseEndpointsSelect'; +import type { CloudPulseFirewallNodebalancersSelectProps } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseNodeTypeFilterProps } from './CloudPulseNodeTypeFilter'; import type { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; import type { CloudPulseResourcesSelectProps } from './CloudPulseResourcesSelect'; @@ -27,6 +29,7 @@ export interface CloudPulseComponentRendererProps { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -41,6 +44,7 @@ const Components: { | CloudPulseCustomSelectProps | CloudPulseDateTimeRangePickerProps | CloudPulseEndpointsSelectProps + | CloudPulseFirewallNodebalancersSelectProps | CloudPulseNodeTypeFilterProps | CloudPulseRegionSelectProps | CloudPulseResourcesSelectProps @@ -59,6 +63,7 @@ const Components: { tags: CloudPulseTagsSelect, associated_entity_region: CloudPulseRegionSelect, endpoint: CloudPulseEndpointsSelect, + nodebalancer_id: CloudPulseFirewallNodebalancersSelect, }; const buildComponent = (props: CloudPulseComponentRendererProps) => { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx index 62b50cf23d1..1c7a7fedf16 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.test.tsx @@ -127,11 +127,12 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { emitFilterChange={vi.fn()} handleToggleAppliedFilter={vi.fn()} isServiceAnalyticsIntegration={false} - resource_ids={[1, 2]} + resource_ids={[1]} /> ); expect(getByPlaceholderText('Select a Firewall')).toBeVisible(); expect(getByPlaceholderText('Select a NodeBalancer Region')).toBeVisible(); + expect(getByPlaceholderText('Select NodeBalancers')).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx index 3455340bba4..4b033145e94 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDashboardFilterBuilder.tsx @@ -14,6 +14,7 @@ import { FIREWALL, INTERFACE_ID, NODE_TYPE, + NODEBALANCER_ID, PARENT_ENTITY_REGION, PORT, REGION, @@ -25,6 +26,7 @@ import { getCustomSelectProperties, getEndpointsProperties, getFilters, + getFirewallNodebalancersProperties, getNodeTypeProperties, getRegionProperties, getResourcesProperties, @@ -38,6 +40,7 @@ import type { CloudPulseMetricsFilter, FilterValueType, } from '../Dashboard/CloudPulseDashboardLanding'; +import type { CloudPulseNodebalancers } from './CloudPulseFirewallNodebalancersSelect'; import type { CloudPulseResources } from './CloudPulseResourcesSelect'; import type { CloudPulseTags } from './CloudPulseTagsFilter'; import type { AclpConfig, Dashboard } from '@linode/api-v4'; @@ -244,6 +247,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( } : { [filterKey]: region, + [NODEBALANCER_ID]: undefined, }; emitFilterChangeByFilterKey( filterKey, @@ -266,6 +270,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( [emitFilterChangeByFilterKey] ); + const handleFirewallNodebalancersChange = React.useCallback( + (nodebalancers: CloudPulseNodebalancers[], savePref: boolean = false) => { + emitFilterChangeByFilterKey( + NODEBALANCER_ID, + nodebalancers.map((nodebalancer) => nodebalancer.id), + nodebalancers.map((nodebalancer) => nodebalancer.label), + savePref, + { + [NODEBALANCER_ID]: nodebalancers.map( + (nodebalancer) => nodebalancer.id + ), + } + ); + }, + [emitFilterChangeByFilterKey] + ); + const handleCustomSelectChange = React.useCallback( ( filterKey: string, @@ -386,6 +407,23 @@ export const CloudPulseDashboardFilterBuilder = React.memo( }, handleEndpointsChange ); + } else if (config.configuration.filterKey === NODEBALANCER_ID) { + return getFirewallNodebalancersProperties( + { + config, + dashboard, + dependentFilters: resource_ids?.length + ? { + ...dependentFilterReference.current, + [RESOURCE_ID]: resource_ids.map(String), + } + : dependentFilterReference.current, + isServiceAnalyticsIntegration, + preferences, + shouldDisable: isError || isLoading, + }, + handleFirewallNodebalancersChange + ); } else { return getCustomSelectProperties( { @@ -409,6 +447,7 @@ export const CloudPulseDashboardFilterBuilder = React.memo( handleResourceChange, handleEndpointsChange, handleCustomSelectChange, + handleFirewallNodebalancersChange, isServiceAnalyticsIntegration, preferences, isError, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx new file mode 100644 index 00000000000..f76f36cc241 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.test.tsx @@ -0,0 +1,348 @@ +import { nodeBalancerFactory } from '@linode/utilities'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { dashboardFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseFirewallNodebalancersSelect } from './CloudPulseFirewallNodebalancersSelect'; + +import type { CloudPulseResources } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useAllNodeBalancersQuery: vi.fn().mockReturnValue({}), + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllNodeBalancersQuery: queryMocks.useAllNodeBalancersQuery, + }; +}); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockNodebalancerHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected'; + +const mockNodebalancers = [ + nodeBalancerFactory.build({ + id: 1, + label: 'nodebalancer-1', + region: 'us-east', + }), + nodeBalancerFactory.build({ + id: 2, + label: 'nodebalancer-2', + region: 'us-west', + }), + nodeBalancerFactory.build({ + id: 3, + label: 'nodebalancer-3', + region: 'us-east', + }), +]; + +const mockFirewalls: CloudPulseResources[] = [ + { + id: '1', + label: 'firewall-1', + entities: { '1': 'nodebalancer-1', '3': 'nodebalancer-3' }, + }, + { + id: '2', + label: 'firewall-2', + entities: { '2': 'nodebalancer-2' }, + }, +]; + +const mockDashboard = dashboardFactory.build({ + service_type: 'firewall', + id: 8, +}); + +describe('CloudPulseFirewallNodebalancersSelect component tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with the correct label and placeholder', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByLabelText('NodeBalancers (optional)')).toBeVisible(); + expect(screen.getByPlaceholderText('Select NodeBalancers')).toBeVisible(); + }); + + it('should render disabled component when disabled prop is true', () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + expect(screen.getByTestId('textfield-input')).toBeDisabled(); + }); + + it('should render nodebalancers when data is available', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show nodebalancers that are associated with the selected firewall + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 as it's not associated with firewall-1 + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should be able to select and deselect the nodebalancers', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: SELECT_ALL }) + ); + + // Check that nodebalancers are selected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + + // Close the autocomplete to trigger the handler call + await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + + // Should call the handler with the selected nodebalancers + expect(mockNodebalancerHandler).toHaveBeenCalledWith( + [ + { + id: '1', + label: 'nodebalancer-1', + associated_entity_region: 'us-east', + }, + { + id: '3', + label: 'nodebalancer-3', + associated_entity_region: 'us-east', + }, + ], + true + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + await userEvent.click( + await screen.findByRole('option', { name: 'Deselect All' }) + ); + + // Check that nodebalancers are deselected + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should show appropriate error message on nodebalancers call failure', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: null, + isError: true, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + // The error message should be visible immediately when isError is true + expect(screen.getByText('Failed to fetch NodeBalancers.')).toBeVisible(); + }); + + it('should filter nodebalancers based on xFilter and firewalls', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should only show nodebalancers associated with firewall-1 in us-east + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toBeVisible(); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toBeVisible(); + + // Should not show nodebalancer-2 (associated with firewall-2) + expect( + screen.queryByRole('option', { + name: 'nodebalancer-2', + }) + ).not.toBeInTheDocument(); + }); + + it('should handle default values correctly', async () => { + queryMocks.useAllNodeBalancersQuery.mockReturnValue({ + data: mockNodebalancers, + isError: false, + isLoading: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: mockFirewalls, + isError: false, + isLoading: false, + }); + + const defaultValue = ['1', '3']; // IDs of nodebalancers to select by default + + renderWithTheme( + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'Open' })); + + // Should show that nodebalancer-1 and nodebalancer-3 are selected by default + expect( + await screen.findByRole('option', { + name: 'nodebalancer-1', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + await screen.findByRole('option', { + name: 'nodebalancer-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx new file mode 100644 index 00000000000..0833c8d4d92 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseFirewallNodebalancersSelect.tsx @@ -0,0 +1,248 @@ +import { useAllNodeBalancersQuery } from '@linode/queries'; +import { Autocomplete, SelectedIcon, StyledListItem } from '@linode/ui'; +import { Box } from '@mui/material'; +import React from 'react'; + +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +import { PARENT_ENTITY_REGION, RESOURCE_FILTER_MAP } from '../Utils/constants'; +import { deepEqual, filterFirewallNodebalancers } from '../Utils/FilterBuilder'; +import { getAssociatedEntityType } from '../Utils/utils'; +import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; + +import type { CloudPulseMetricsFilter } from '../Dashboard/CloudPulseDashboardLanding'; +import type { Dashboard, FilterValue } from '@linode/api-v4'; + +export interface CloudPulseNodebalancers { + /** + * The region of the nodebalancer + */ + associated_entity_region: string; + /** + * The id of the nodebalancer + */ + id: string; + /** + * The label of the nodebalancer + */ + label: string; +} + +export interface CloudPulseFirewallNodebalancersSelectProps { + /** + * The default value of the nodebalancers filter + */ + defaultValue?: Partial; + /** + * Whether the nodebalancers filter is disabled + */ + disabled?: boolean; + /** + * The function to handle the nodebalancers selection + */ + handleNodebalancersSelection: ( + nodebalancers: CloudPulseNodebalancers[], + savePref?: boolean + ) => void; + /** + * Whether the nodebalancers filter is optional + */ + isOptional?: boolean; + /** + * The label of the nodebalancers filter + */ + label: string; + /** + * The placeholder of the nodebalancers filter + */ + placeholder?: string; + /** + * Whether to save the preferences + */ + savePreferences?: boolean; + /** + * The selected dashboard + */ + selectedDashboard: Dashboard; + /** + * The dependent filters of the nodebalancers + */ + xFilter?: CloudPulseMetricsFilter; +} + +export const CloudPulseFirewallNodebalancersSelect = React.memo( + (props: CloudPulseFirewallNodebalancersSelectProps) => { + const { + defaultValue, + disabled, + handleNodebalancersSelection, + label, + placeholder, + savePreferences, + xFilter, + isOptional, + selectedDashboard, + } = props; + + const serviceType = selectedDashboard.service_type; + const region = xFilter?.[PARENT_ENTITY_REGION]; + + // Get the associated entity type for the selected dashboard + const associatedEntityType = getAssociatedEntityType(selectedDashboard.id); + + const { data: firewalls } = useResourcesQuery( + disabled !== undefined ? !disabled : Boolean(region), + serviceType, + {}, + + RESOURCE_FILTER_MAP[serviceType] ?? {}, + associatedEntityType + ); + + const [selectedNodebalancers, setSelectedNodebalancers] = + React.useState(); + + /** + * This is used to track the open state of the autocomplete and useRef optimizes the re-renders that this component goes through and it is used for below + * When the autocomplete is already closed, we should publish the resources on clear action and deselect action as well since onclose will not be triggered at that time + * When the autocomplete is open, we should not publish any resources on clear action until the autocomplete is close + */ + const isAutocompleteOpen = React.useRef(false); // Ref to track the open state of Autocomplete + + const { + data: nodebalancers, + isError, + isLoading, + } = useAllNodeBalancersQuery( + true, + {}, + { + '+order': 'asc', + '+order_by': 'label', + } + ); + + // Get the list of nodebalancers that are associated with the selected firewall + const getNodebalancersList = React.useMemo< + CloudPulseNodebalancers[] + >(() => { + return ( + filterFirewallNodebalancers(nodebalancers, xFilter, firewalls) ?? [] + ); + }, [firewalls, nodebalancers, xFilter]); + + // Once the data is loaded, set the state variable with value stored in preferences + React.useEffect(() => { + if (disabled && !selectedNodebalancers) { + return; + } + // To save default values, go through side effects + if (!getNodebalancersList || !savePreferences || selectedNodebalancers) { + if (selectedNodebalancers) { + setSelectedNodebalancers([]); + handleNodebalancersSelection([]); + } + } else { + // Get the default nodebalancers from the nodebalancer ids stored in preferences + const defaultNodebalancers = + defaultValue && Array.isArray(defaultValue) + ? defaultValue.map((nodebalancer) => String(nodebalancer)) + : []; + const nodebalancers = getNodebalancersList.filter((nodebalancerObj) => + defaultNodebalancers.includes(nodebalancerObj.id) + ); + + handleNodebalancersSelection(nodebalancers); + setSelectedNodebalancers(nodebalancers); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getNodebalancersList]); + + return ( + option.label === value.label} + label={label || 'NodeBalancers'} + limitTags={1} + loading={isLoading} + multiple + noMarginTop + onChange={(_e, nodebalancerSelections) => { + setSelectedNodebalancers(nodebalancerSelections); + + if (!isAutocompleteOpen.current) { + handleNodebalancersSelection( + nodebalancerSelections, + savePreferences + ); + } + }} + onClose={() => { + isAutocompleteOpen.current = false; + handleNodebalancersSelection( + selectedNodebalancers ?? [], + savePreferences + ); + }} + onOpen={() => { + isAutocompleteOpen.current = true; + }} + options={getNodebalancersList} + placeholder={ + selectedNodebalancers?.length + ? '' + : placeholder || 'Select NodeBalancers' + } + renderOption={(props, option) => { + const { key, ...rest } = props; + const isNodebalancerSelected = selectedNodebalancers?.some( + (item) => item.id === option.id + ); + + const isSelectAllORDeslectAllOption = + option.label === 'Select All ' || option.label === 'Deselect All '; + + const ListItem = isSelectAllORDeslectAllOption + ? StyledListItem + : 'li'; + + return ( + + <> + {option.label} + + + + ); + }} + textFieldProps={{ + ...CLOUD_PULSE_TEXT_FIELD_PROPS, + optional: isOptional, + }} + value={selectedNodebalancers ?? []} + /> + ); + }, + compareProps +); + +function compareProps( + prevProps: CloudPulseFirewallNodebalancersSelectProps, + nextProps: CloudPulseFirewallNodebalancersSelectProps +): boolean { + if (prevProps.selectedDashboard.id !== nextProps.selectedDashboard.id) { + return false; + } + if (!deepEqual(prevProps.xFilter, nextProps.xFilter)) { + return false; + } + + // Ignore function props in comparison + return true; +} diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index a889102a251..49f7f6ebbd5 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -15,7 +15,7 @@ import { } from '../Utils/constants'; import { deepEqual, filterUsingDependentFilters } from '../Utils/FilterBuilder'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; -import { getResourcesFilterConfig } from '../Utils/utils'; +import { getAssociatedEntityType } from '../Utils/utils'; import { CLOUD_PULSE_TEXT_FIELD_PROPS } from './styles'; import type { Item } from '../Alerts/constants'; @@ -84,8 +84,7 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setSelectedRegion] = React.useState(); // Get the associated entity type for the dashboard - const associatedEntityType = - getResourcesFilterConfig(dashboardId)?.associatedEntityType; + const associatedEntityType = getAssociatedEntityType(dashboardId); const { values: linodeRegions, isLoading: isLinodeRegionIdLoading,