diff --git a/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md b/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md new file mode 100644 index 00000000000..e81fc7c072b --- /dev/null +++ b/packages/manager/.changeset/pr-12582-upcoming-features-1753455419647.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +ACLP: add checkbox functionality in `AlertRegions`. ([#12582](https://github.com/linode/manager/pull/12582)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx index f54a12cb28b..a5d746fc5dd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.test.tsx @@ -1,10 +1,12 @@ import { regionFactory } from '@linode/utilities'; -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { databaseFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; +import { REGION_GROUP_INFO_MESSAGE } from '../constants'; import { AlertRegions } from './AlertRegions'; import type { AlertServiceType } from '@linode/api-v4'; @@ -38,7 +40,7 @@ vi.mock('src/hooks/useFlags', async (importOriginal) => ({ queryMocks.useRegionsQuery.mockReturnValue({ data: regions, isLoading: false, - isError: false, + isErrro: false, }); queryMocks.useResourcesQuery.mockReturnValue({ @@ -57,11 +59,41 @@ const component = ( describe('Alert Regions', () => { it('Should render the filters and notices ', () => { renderWithTheme(component); + const text = screen.getByText(REGION_GROUP_INFO_MESSAGE); const regionSearch = screen.getByTestId('region-search'); const showSelectedOnly = screen.getByTestId('show-selected-only'); + expect(text).toBeInTheDocument(); expect(regionSearch).toBeInTheDocument(); expect(showSelectedOnly).toBeInTheDocument(); }); + + it('should select all regions when the select all checkbox is checked', async () => { + renderWithTheme(component); + + const selectAllCheckbox = within( + screen.getByTestId('select-all-checkbox') + ).getByRole('checkbox'); + await userEvent.click(selectAllCheckbox); + + expect(selectAllCheckbox).toBeChecked(); + + const notice = screen.getByTestId('selection_notice'); + + expect(notice.textContent).toBe('1 of 1 regions are selected.'); + }); + + it('should show only header on click of show selected only', async () => { + renderWithTheme(component); + + const checkbox = within(screen.getByTestId('show-selected-only')).getByRole( + 'checkbox' + ); + + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + expect(screen.getAllByRole('row').length).toBe(1); // Only header row should be visible + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx index c360cb33a0b..17d39dd93fa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/AlertRegions.tsx @@ -6,12 +6,18 @@ import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { type AlertFormMode } from '../constants'; +import { + type AlertFormMode, + REGION_GROUP_INFO_MESSAGE, + type SelectDeselectAll, +} from '../constants'; import { AlertListNoticeMessages } from '../Utils/AlertListNoticeMessages'; -import { getSupportedRegions } from '../Utils/utils'; +import { AlertSelectedInfoNotice } from '../Utils/AlertSelectedInfoNotice'; +import { getFilteredRegions } from '../Utils/utils'; import { DisplayAlertRegions } from './DisplayAlertRegions'; -import type { AlertServiceType, Filter, Region } from '@linode/api-v4'; +import type { AlertRegion } from './DisplayAlertRegions'; +import type { AlertServiceType, Filter } from '@linode/api-v4'; interface AlertRegionsProps { /** @@ -40,9 +46,7 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { const { serviceType, handleChange, value = [], errorText, mode } = props; const [searchText, setSearchText] = React.useState(''); const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); - - // Todo: State variable will be added when checkbox functionality implemented - const [, setSelectedRegions] = React.useState(value); + const [selectedRegions, setSelectedRegions] = React.useState(value); const [showSelected, setShowSelected] = React.useState(false); const resourceFilterMap: Record = { @@ -84,27 +88,50 @@ export const AlertRegions = React.memo((props: AlertRegionsProps) => { [handleChange] ); - const filteredRegionsWithStatus: Region[] = React.useMemo( + const filteredRegionsWithStatus: AlertRegion[] = React.useMemo( () => - getSupportedRegions({ + getFilteredRegions({ serviceType, + selectedRegions, resources, regions, }), - [regions, resources, serviceType] + [regions, resources, selectedRegions, serviceType] + ); + + const handleSelectAll = React.useCallback( + (action: SelectDeselectAll) => { + let regionIds: string[] = []; + if (action === 'Select All') { + regionIds = filteredRegionsWithStatus?.map((region) => region.id) ?? []; + } + + setSelectedRegions(regionIds); + if (handleChange) { + handleChange(regionIds); + } + }, + [filteredRegionsWithStatus, handleChange] ); if (isRegionsLoading || isResourcesLoading) { return ; } const filteredRegionsBySearchText = filteredRegionsWithStatus.filter( - ({ label }) => label.toLowerCase().includes(searchText.toLowerCase()) + ({ label, checked }) => + label.toLowerCase().includes(searchText.toLowerCase()) && + ((mode && checked) || !mode) ); return ( {mode === 'view' && Regions} + + { )} + {mode !== 'view' && ( + + )} 0 && + selectedRegions.length === filteredRegionsWithStatus.length + } + isSomeSelected={ + selectedRegions.length > 0 && + selectedRegions.length !== filteredRegionsWithStatus.length + } mode={mode} regions={filteredRegionsBySearchText} showSelected={showSelected} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx index 8fbfe8c6b0c..1d78fed424f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.test.tsx @@ -7,14 +7,21 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DisplayAlertRegions } from './DisplayAlertRegions'; -const regions = regionFactory.buildList(10); +const regions = regionFactory.buildList(10).map(({ id, label }) => ({ + id, + label, + checked: false, + count: Math.random(), +})); const handleChange = vi.fn(); +const handleSelectAll = vi.fn(); describe('DisplayAlertRegions', () => { it('should render the regions table', () => { renderWithTheme( @@ -30,6 +37,7 @@ describe('DisplayAlertRegions', () => { it('should display checkbox and label', () => { renderWithTheme( @@ -48,6 +56,7 @@ describe('DisplayAlertRegions', () => { it('should select checkbox when clicked', async () => { renderWithTheme( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx index 9df2c56bb9c..c5a41bdd1e7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertRegions/DisplayAlertRegions.tsx @@ -10,15 +10,45 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; -import type { AlertFormMode } from '../constants'; -import type { Region } from '@linode/api-v4'; +import type { AlertFormMode, SelectDeselectAll } from '../constants'; + +export interface AlertRegion { + /** + * Indicates if the region is selected. + * This is used to determine if the region should be checked in the UI. + */ + checked: boolean; + /** + * The number of associated entities in the region. + */ + count: number; + /** + * Id of the region + */ + id: string; + /** + * Label of the region. + */ + label: string; +} interface DisplayAlertRegionProps { + /** + * Function to handle the selection of all regions. + */ + handleSelectAll: (action: SelectDeselectAll) => void; /** * Function to handle the change in selection of a region. */ handleSelectionChange: (regionId: string, isChecked: boolean) => void; - + /** + * Indicates if all regions are selected. + */ + isAllSelected?: boolean; + /** + * Indicates if some regions are selected. + */ + isSomeSelected?: boolean; /** * Flag to indicate the mode of the form */ @@ -26,7 +56,7 @@ interface DisplayAlertRegionProps { /** * List of regions to be displayed. */ - regions?: Region[]; + regions?: AlertRegion[]; /** * To indicate whether to show only selected regions or not. */ @@ -35,7 +65,15 @@ interface DisplayAlertRegionProps { export const DisplayAlertRegions = React.memo( (props: DisplayAlertRegionProps) => { - const { regions, handleSelectionChange, mode } = props; + const { + regions, + handleSelectionChange, + isSomeSelected, + isAllSelected, + showSelected, + handleSelectAll, + mode, + } = props; return ( @@ -60,8 +98,14 @@ export const DisplayAlertRegions = React.memo( {}} + indeterminate={isSomeSelected && !isAllSelected} + onChange={(_, checked) => + handleSelectAll( + checked ? 'Select All' : 'Deselect All' + ) + } /> @@ -76,6 +120,16 @@ export const DisplayAlertRegions = React.memo( > Region + {}} + label="Associated Entities" + > + Associated Entities + @@ -83,25 +137,29 @@ export const DisplayAlertRegions = React.memo( length={regions?.length ?? 0} loading={false} > - {paginatedData.map(({ label, id }) => { - return ( - - {mode !== 'view' && ( + {paginatedData + ?.filter(({ checked }) => (showSelected ? checked : true)) + .map(({ label, id, checked, count }) => { + return ( + + {mode !== 'view' && ( + + + handleSelectionChange(id, status) + } + /> + + )} + - - handleSelectionChange(id, status) - } - /> + {label} ({id}) - )} - - - {label} ({id}) - - - ); - })} + {count} + + ); + })} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 50778292c78..07f586e6cd1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -18,8 +18,8 @@ import { getSupportedRegionIds, scrollToElement, } from '../Utils/AlertResourceUtils'; +import { AlertSelectedInfoNotice } from '../Utils/AlertSelectedInfoNotice'; import { AlertResourcesFilterRenderer } from './AlertsResourcesFilterRenderer'; -import { AlertsResourcesNotice } from './AlertsResourcesNotice'; import { databaseTypeClassMap, getSearchPlaceholderText, @@ -454,11 +454,12 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { resources && resources.length > 0 && ( - )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx new file mode 100644 index 00000000000..3d9b4f78dbd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertMaxSelectionText } from './AlertMaxSelectionText'; + +describe('AlertMaxSelectionText', () => { + it('displays correct max selection text based on passed maxSelectionCount', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('warning-tip')).toBeInTheDocument(); + expect(getByTestId('warning-tip')).toHaveTextContent( + 'You can select up to 2 entities.' + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx new file mode 100644 index 00000000000..3016971ab43 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertMaxSelectionText.tsx @@ -0,0 +1,18 @@ +import { Typography } from '@linode/ui'; +import React from 'react'; + +interface AlertMaxSelectionTextProps { + /** + * The maximum selection count that needs to displayed in the text + */ + maxSelectionCount: number; +} + +export const AlertMaxSelectionText = (props: AlertMaxSelectionTextProps) => { + const { maxSelectionCount } = props; + return ( + + You can select up to {maxSelectionCount} entities. + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx similarity index 74% rename from packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx rename to packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx index c5b9660832c..72e85b49be6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertSelectedInfoNotice.tsx @@ -4,46 +4,52 @@ import React from 'react'; import { AlertMaxSelectionText } from './AlertMaxSelectionText'; -import type { SelectDeselectAll } from './AlertsResources'; +import type { SelectDeselectAll } from '../constants'; -interface AlertResourceNoticeProps { +interface AlertSelectedInfoNoticeProps { /** * Callback to handle selection changes (select all or deselect all). */ handleSelectionChange: (action: SelectDeselectAll) => void; /** - * The maximum number of resources that can be selected based on service type. + * The maximum number of elements that can be selected. */ maxSelectionCount?: number; /** - * The number of currently selected resources. + * The property that is selected. Ex: regions, entities etc */ - selectedResources: number; + property: string; /** - * The total number of available resources. + * The number of currently selected elements. */ - totalResources: number; + selectedCount: number; + + /** + * The total number of available elements. + */ + totalCount: number; } -export const AlertsResourcesNotice = React.memo( - (props: AlertResourceNoticeProps) => { +export const AlertSelectedInfoNotice = React.memo( + (props: AlertSelectedInfoNoticeProps) => { const { handleSelectionChange, maxSelectionCount, - selectedResources, - totalResources, + selectedCount, + totalCount, + property, } = props; const isSelectAll = maxSelectionCount !== undefined - ? selectedResources === 0 - : selectedResources < totalResources; + ? selectedCount === 0 + : selectedCount < totalCount; const buttonText = isSelectAll ? 'Select All' : 'Deselect All'; const isButtonDisabled = isSelectAll && maxSelectionCount !== undefined - ? totalResources > maxSelectionCount + ? totalCount > maxSelectionCount : false; return ( @@ -54,7 +60,7 @@ export const AlertsResourcesNotice = React.memo( fontFamily: theme.tokens.alias.Typography.Body.Bold, })} > - {selectedResources} of {totalResources} entities are selected. + {selectedCount} of {totalCount} {property} are selected. { singleLineErrorSeparator: string; } +interface FilterRegionProps { + /** + * The list of regions + */ + regions?: Region[]; + /** + * The list of resources + */ + resources?: CloudPulseResources[]; + /** + * The selected region ids + */ + selectedRegions: string[]; + /** + * The service type for which the regions are being filtered + */ + serviceType: AlertServiceType | null; +} + interface SupportedRegionsProps { /** * The list of regions @@ -453,6 +473,51 @@ export const handleMultipleError = ( } }; +/** + * + * @param props The props required to filter the regions + * @returns The filtered regions based on the selected regions and resources + */ +export const getFilteredRegions = (props: FilterRegionProps): AlertRegion[] => { + const { regions, resources, selectedRegions, serviceType } = props; + + const supportedRegionsFromResources = getSupportedRegions({ + regions, + resources, + serviceType, + }); + + // map region to its resources count + const regionToResourceCount = + resources?.reduce( + (previous, { region }) => { + if (!region) return previous; + return { + ...previous, + [region]: (previous[region] ?? 0) + 1, + }; + }, + {} as { [region: string]: number } + ) ?? {}; + + return supportedRegionsFromResources.map(({ label, id }) => { + const data = { label, id }; + + if (selectedRegions.includes(id)) { + return { + ...data, + checked: true, + count: regionToResourceCount[id] ?? 0, + }; + } + return { + ...data, + checked: false, + count: regionToResourceCount[id] ?? 0, + }; + }); +}; + /** * * @param props The props required to get the supported regions diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index b1fded774cb..098d522c3aa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -201,8 +201,13 @@ export const ALERT_SCOPE_TOOLTIP_TEXT = export const ALERT_SCOPE_TOOLTIP_CONTEXTUAL = 'Indicates whether the alert applies to all entities in the account, entities in specific regions, or just this entity.'; +export const REGION_GROUP_INFO_MESSAGE = + 'This alert applies to all entities associated with selected regions, and will be applied to any new entities that are added. The alert is triggered per entity rather than being based on the aggregated data for all entities.'; + export type AlertFormMode = 'create' | 'edit' | 'view'; +export type SelectDeselectAll = 'Deselect All' | 'Select All'; + export const DELETE_ALERT_SUCCESS_MESSAGE = 'Alert successfully deleted.'; export const PORTS_TRAILING_COMMA_ERROR_MESSAGE =