diff --git a/packages/api-v4/.changeset/pr-11583-upcoming-features-1738238193677.md b/packages/api-v4/.changeset/pr-11583-upcoming-features-1738238193677.md new file mode 100644 index 00000000000..cd58f843b1f --- /dev/null +++ b/packages/api-v4/.changeset/pr-11583-upcoming-features-1738238193677.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add new `editAlertDefinition` endpoint to edit the resources associated with CloudPulse alerts ([#11583](https://github.com/linode/manager/pull/11583)) diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index 7a5cd18bd3d..d3efb663433 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -10,6 +10,7 @@ import { Alert, AlertServiceType, CreateAlertDefinitionPayload, + EditAlertDefinitionPayload, NotificationChannel, } from './types'; import { BETA_API_ROOT as API_ROOT } from '../constants'; @@ -50,6 +51,20 @@ export const getAlertDefinitionByServiceTypeAndId = ( setMethod('GET') ); +export const editAlertDefinition = ( + data: EditAlertDefinitionPayload, + serviceType: string, + alertId: number +) => + Request( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType + )}/alert-definitions/${encodeURIComponent(alertId)}` + ), + setMethod('PUT'), + setData(data) + ); export const getNotificationChannels = (params?: Params, filters?: Filter) => Request>( setURL(`${API_ROOT}/monitor/alert-channels`), diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 1fa7b63d280..7a43d6c192d 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -294,3 +294,7 @@ export type NotificationChannel = | NotificationChannelSlack | NotificationChannelWebHook | NotificationChannelPagerDuty; + +export interface EditAlertDefinitionPayload { + entity_ids: string[]; +} diff --git a/packages/manager/.changeset/pr-11583-upcoming-features-1738238243389.md b/packages/manager/.changeset/pr-11583-upcoming-features-1738238243389.md new file mode 100644 index 00000000000..1c9a8bad26e --- /dev/null +++ b/packages/manager/.changeset/pr-11583-upcoming-features-1738238243389.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add scaffolding for new edit resource component for system alerts in CloudPulse alerts section ([#11583](https://github.com/linode/manager/pull/11583)) diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index 9cb01794533..9e7c723b3e4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -15,7 +15,7 @@ import { AlertDetailCriteria } from './AlertDetailCriteria'; import { AlertDetailNotification } from './AlertDetailNotification'; import { AlertDetailOverview } from './AlertDetailOverview'; -interface RouteParams { +export interface AlertRouteParams { /** * The id of the alert for which the data needs to be shown */ @@ -27,7 +27,7 @@ interface RouteParams { } export const AlertDetail = () => { - const { alertId, serviceType } = useParams(); + const { alertId, serviceType } = useParams(); const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( Number(alertId), diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index e9743f733bb..17422740458 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -4,6 +4,7 @@ import { Route, Switch } from 'react-router-dom'; import { AlertDetail } from '../AlertsDetail/AlertDetail'; import { AlertListing } from '../AlertsListing/AlertListing'; import { CreateAlertDefinition } from '../CreateAlert/CreateAlertDefinition'; +import { EditAlertResources } from '../EditAlert/EditAlertResources'; export const AlertDefinitionLanding = () => { return ( @@ -19,6 +20,12 @@ export const AlertDefinitionLanding = () => { > + + + void; + + /** + * Callback for edit alerts action + */ + handleEdit: () => void; } export interface AlertActionMenuProps { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index 6a20db83091..312238fd5ca 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -48,6 +48,10 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { history.push(`${location.pathname}/detail/${serviceType}/${_id}`); }; + const handleEdit = ({ id, service_type: serviceType }: Alert) => { + history.push(`${location.pathname}/edit/${serviceType}/${id}`); + }; + return ( {({ data: orderedData, handleOrderChange, order, orderBy }) => ( @@ -93,6 +97,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { handleDetails(alert), + handleEdit: () => handleEdit(alert), }} alert={alert} key={alert.id} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index bccedceb40d..31d81b05984 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -29,6 +29,7 @@ describe('Alert Row', () => { { { { ({ ...vi.importActual('src/queries/cloudpulse/resources'), @@ -33,6 +34,14 @@ const linodes = linodeFactory.buildList(3).map((value, index) => { const searchPlaceholder = 'Search for a Region or Resource'; const regionPlaceholder = 'Select Regions'; +const checkedAttribute = 'data-qa-checked'; +const cloudPulseResources: CloudPulseResources[] = linodes.map((linode) => { + return { + id: String(linode.id), + label: linode.label, + region: linode.region, + }; +}); beforeAll(() => { window.scrollTo = vi.fn(); // mock for scrollTo and scroll @@ -41,7 +50,7 @@ beforeAll(() => { beforeEach(() => { queryMocks.useResourcesQuery.mockReturnValue({ - data: linodes, + data: cloudPulseResources, isError: false, isFetching: false, }); @@ -173,4 +182,51 @@ describe('AlertResources component tests', () => { .every((text, index) => text?.includes(linodes[index].region)) // validation ).toBe(true); }); + + it('should handle selection correctly and publish', async () => { + const handleResourcesSelection = vi.fn(); + + const { getByTestId } = renderWithTheme( + + ); + // validate, by default selections are there + expect(getByTestId('select_item_1')).toHaveAttribute( + checkedAttribute, + 'true' + ); + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'false' + ); + + // validate it selects 3 + await userEvent.click(getByTestId('select_item_3')); + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'true' + ); + expect(handleResourcesSelection).toHaveBeenCalledWith(['1', '2', '3']); + + // unselect 3 and test + await userEvent.click(getByTestId('select_item_3')); + // validate it gets unselected + expect(getByTestId('select_item_3')).toHaveAttribute( + checkedAttribute, + 'false' + ); + expect(handleResourcesSelection).toHaveBeenLastCalledWith(['1', '2']); + + // click select all + await userEvent.click(getByTestId('select_all_in_page_1')); + expect(handleResourcesSelection).toHaveBeenLastCalledWith(['1', '2', '3']); + + // click select all again to unselect all + await userEvent.click(getByTestId('select_all_in_page_1')); + expect(handleResourcesSelection).toHaveBeenLastCalledWith([]); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index ff2f4f6a7ed..011f251797a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -30,6 +30,16 @@ export interface AlertResourcesProp { */ alertResourceIds: string[]; + /** + * Callback for publishing the selected resources + */ + handleResourcesSelection?: (resources: string[]) => void; + + /** + * This controls whether we need to show the checkbox in case of editing the resources + */ + isSelectionsNeeded?: boolean; + /** * The service type associated with the alerts like DBaaS, Linode etc., */ @@ -37,9 +47,18 @@ export interface AlertResourcesProp { } export const AlertResources = React.memo((props: AlertResourcesProp) => { - const { alertLabel, alertResourceIds, serviceType } = props; + const { + alertLabel, + alertResourceIds, + handleResourcesSelection, + isSelectionsNeeded, + serviceType, + } = props; const [searchText, setSearchText] = React.useState(); const [filteredRegions, setFilteredRegions] = React.useState(); + const [selectedResources, setSelectedResources] = React.useState( + alertResourceIds + ); const { data: regions, @@ -58,6 +77,19 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { serviceType === 'dbaas' ? { platform: 'rdbms-default' } : {} ); + const computedSelectedResources = React.useMemo(() => { + if (!isSelectionsNeeded || !resources) { + return alertResourceIds; + } + return resources + .filter(({ id }) => alertResourceIds.includes(id)) + .map(({ id }) => id); + }, [resources, isSelectionsNeeded, alertResourceIds]); + + React.useEffect(() => { + setSelectedResources(computedSelectedResources); + }, [computedSelectedResources]); + // A map linking region IDs to their corresponding region objects, used for quick lookup when displaying data in the table. const regionsIdToRegionMap: Map = React.useMemo(() => { return getRegionsIdRegionMap(regions); @@ -67,10 +99,11 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const regionOptions: Region[] = React.useMemo(() => { return getRegionOptions({ data: resources, + isAdditionOrDeletionNeeded: isSelectionsNeeded, regionsIdToRegionMap, resourceIds: alertResourceIds, }); - }, [resources, alertResourceIds, regionsIdToRegionMap]); + }, [resources, alertResourceIds, regionsIdToRegionMap, isSelectionsNeeded]); const isDataLoadingError = isRegionsError || isResourcesError; @@ -96,25 +129,45 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { return getFilteredResources({ data: resources, filteredRegions, + isAdditionOrDeletionNeeded: isSelectionsNeeded, regionsIdToRegionMap, resourceIds: alertResourceIds, searchText, + selectedResources, }); }, [ resources, - alertResourceIds, - searchText, filteredRegions, + isSelectionsNeeded, regionsIdToRegionMap, + alertResourceIds, + searchText, + selectedResources, ]); + const handleSelection = React.useCallback( + (ids: string[], isSelectionAction: boolean) => { + setSelectedResources((prevSelected) => { + const updatedSelection = isSelectionAction + ? [...prevSelected, ...ids.filter((id) => !prevSelected.includes(id))] + : prevSelected.filter((resource) => !ids.includes(resource)); + + handleResourcesSelection?.(updatedSelection); + return updatedSelection; + }); + }, + [handleResourcesSelection] + ); + const titleRef = React.useRef(null); // Reference to the component title, used for scrolling to the title when the table's page size or page number changes. + const isNoResources = + !isDataLoadingError && !isSelectionsNeeded && alertResourceIds.length === 0; if (isResourcesFetching || isRegionsFetching) { return ; } - if (!isDataLoadingError && alertResourceIds.length === 0) { + if (isNoResources) { return ( @@ -136,38 +189,38 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { {alertLabel || 'Resources'} {/* It can be either the passed alert label or just Resources */} - {(isDataLoadingError || alertResourceIds.length) && ( // if there is data loading error display error message with empty table setup - - - - - - - - + + + + - - scrollToElement(titleRef.current)} + + - )} + + scrollToElement(titleRef.current)} + /> + + ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx index 3dbfcb504ad..039da3a8b50 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from '@linode/ui'; import React from 'react'; import { sortData } from 'src/components/OrderBy'; @@ -11,9 +12,15 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableSortCell } from 'src/components/TableSortCell'; +import { isAllPageSelected, isSomeSelected } from '../Utils/AlertResourceUtils'; + import type { Order } from 'src/hooks/useOrder'; export interface AlertInstance { + /** + * Indicates if the instance is selected or not + */ + checked?: boolean; /** * The id of the instance */ @@ -34,12 +41,22 @@ export interface DisplayAlertResourceProp { */ filteredResources: AlertInstance[] | undefined; + /** + * Callback for clicking on check box + */ + handleSelection?: (id: string[], isSelectAction: boolean) => void; + /** * A flag indicating if there was an error loading the data. If true, the error message * (specified by `errorText`) will be displayed in the table. */ isDataLoadingError?: boolean; + /** + * This controls whether to show the selection check box or not + */ + isSelectionsNeeded?: boolean; + /** * Callback to scroll till the element required on page change change or sorting change */ @@ -48,7 +65,13 @@ export interface DisplayAlertResourceProp { export const DisplayAlertResources = React.memo( (props: DisplayAlertResourceProp) => { - const { filteredResources, isDataLoadingError, scrollToElement } = props; + const { + filteredResources, + handleSelection, + isDataLoadingError, + isSelectionsNeeded, + scrollToElement, + } = props; const pageSize = 25; const [sorting, setSorting] = React.useState<{ @@ -99,6 +122,15 @@ export const DisplayAlertResources = React.memo( }, [scrollToGivenElement] ); + + const handleSelectionChange = React.useCallback( + (id: string[], isSelectionAction: boolean) => { + if (handleSelection) { + handleSelection(id, isSelectionAction); + } + }, + [handleSelection] + ); return ( {({ @@ -113,6 +145,27 @@ export const DisplayAlertResources = React.memo( + {isSelectionsNeeded && ( + + + handleSelectionChange( + paginatedData.map(({ id }) => id), + !isAllPageSelected(paginatedData) + ) + } + sx={{ + p: 0, + }} + checked={isAllPageSelected(paginatedData)} + data-testid={`select_all_in_page_${page}`} + /> + + )} { handleSort(orderBy, order, handlePageChange); @@ -144,8 +197,22 @@ export const DisplayAlertResources = React.memo( data-testid="alert_resources_content" > {!isDataLoadingError && - paginatedData.map(({ id, label, region }, index) => ( + paginatedData.map(({ checked, id, label, region }, index) => ( + {isSelectionsNeeded && ( + + { + handleSelectionChange([id], !checked); + }} + sx={{ + p: 0, + }} + checked={checked} + data-testid={`select_item_${id}`} + /> + + )} {label} diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx new file mode 100644 index 00000000000..c6bf740b3c6 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import { alertFactory, linodeFactory, regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditAlertResources } from './EditAlertResources'; + +import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; + +const linodes = linodeFactory.buildList(4); +// Mock Data +const alertDetails = alertFactory.build({ + entity_ids: ['1', '2', '3'], + service_type: 'linode', +}); +const regions = regionFactory.buildList(4).map((region, index) => ({ + ...region, + id: linodes[index].region, +})); +const cloudPulseResources: CloudPulseResources[] = linodes.map((linode) => { + return { + id: String(linode.id), + label: linode.label, + region: linode.region, + }; +}); + +// Mock Queries +const queryMocks = vi.hoisted(() => ({ + useAlertDefinitionQuery: vi.fn(), + useRegionsQuery: vi.fn(), + useResourcesQuery: vi.fn(), +})); +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useAlertDefinitionQuery: queryMocks.useAlertDefinitionQuery, +})); +vi.mock('src/queries/cloudpulse/resources', () => ({ + ...vi.importActual('src/queries/cloudpulse/resources'), + useResourcesQuery: queryMocks.useResourcesQuery, +})); +vi.mock('src/queries/regions/regions', () => ({ + ...vi.importActual('src/queries/regions/regions'), + useRegionsQuery: queryMocks.useRegionsQuery, +})); + +beforeAll(() => { + // Mock window.scrollTo to prevent the "Not implemented" error + window.scrollTo = vi.fn(); +}); + +// Shared Setup +beforeEach(() => { + vi.clearAllMocks(); + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: alertDetails, + isError: false, + isFetching: false, + }); + queryMocks.useResourcesQuery.mockReturnValue({ + data: cloudPulseResources, + isError: false, + isFetching: false, + }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isError: false, + isFetching: false, + }); +}); + +describe('EditAlertResources component tests', () => { + it('Edit alert resources happy path', async () => { + const { getByPlaceholderText, getByText } = renderWithTheme( + + ); + // validate resources sections is rendered + expect( + getByPlaceholderText('Search for a Region or Resource') + ).toBeInTheDocument(); + expect(getByPlaceholderText('Select Regions')).toBeInTheDocument(); + expect(getByText(alertDetails.label)).toBeInTheDocument(); + }); + + it('Edit alert resources alert details error and loading path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: true, // simulate error + isFetching: false, + }); + const { getByText } = renderWithTheme(); + expect( + getByText( + 'An error occurred while loading the alerts definitions and resources. Please try again later.' + ) + ).toBeInTheDocument(); + + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, // simulate loading + }); + const { getByTestId } = renderWithTheme(); + expect(getByTestId('circle-progress')).toBeInTheDocument(); + }); + + it('Edit alert resources alert details empty path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, // simulate empty + isError: false, + isFetching: false, + }); + const { getByText } = renderWithTheme(); + expect(getByText('No Data to display.')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx new file mode 100644 index 00000000000..74f117b332f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -0,0 +1,125 @@ +import { Box, CircleProgress } from '@linode/ui'; +import { useTheme } from '@mui/material'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; + +import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; +import { AlertResources } from '../AlertsResources/AlertsResources'; +import { getAlertBoxStyles } from '../Utils/utils'; + +import type { AlertRouteParams } from '../AlertsDetail/AlertDetail'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +export const EditAlertResources = () => { + const { alertId, serviceType } = useParams(); + + const theme = useTheme(); + + const definitionLanding = '/monitor/alerts/definitions'; + + const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( + Number(alertId), + serviceType + ); + const [, setSelectedResources] = React.useState([]); + + React.useEffect(() => { + setSelectedResources( + alertDetails ? alertDetails.entity_ids.map((id) => id) : [] + ); + }, [alertDetails]); + + const { newPathname, overrides } = React.useMemo(() => { + const overrides = [ + { + label: 'Definitions', + linkTo: definitionLanding, + position: 1, + }, + { + label: 'Edit', + linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, + position: 2, + }, + ]; + + return { newPathname: '/Definitions/Edit', overrides }; + }, [serviceType, alertId]); + + if (isFetching) { + return getEditAlertMessage(, newPathname, overrides); + } + + if (isError) { + return getEditAlertMessage( + , + newPathname, + overrides + ); + } + + if (!alertDetails) { + return getEditAlertMessage( + , + newPathname, + overrides + ); + } + + const handleResourcesSelection = (resourceIds: string[]) => { + setSelectedResources(resourceIds); // keep track of the selected resources and update it on save + }; + + const { entity_ids, label, service_type } = alertDetails; + + return ( + <> + + + + + + ); +}; + +/** + * Returns a common UI structure for loading, error, or empty states. + * @param messageComponent - A React component to display (e.g., CircleProgress, ErrorState, or Placeholder). + * @param pathName - The current pathname to be provided in breadcrumb + * @param crumbOverrides - The overrides to be provided in breadcrumb + */ +const getEditAlertMessage = ( + messageComponent: React.ReactNode, + pathName: string, + crumbOverrides: CrumbOverridesProps[] +) => { + return ( + <> + + + {messageComponent} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts index 1cbfca5f9aa..169f9daa166 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -68,6 +68,16 @@ describe('getRegionOptions', () => { }); expect(result.length).toBe(2); // Should still return unique regions }); + it('should return all region objects if resourceIds is empty and isAdditionOrDeletionNeeded is true', () => { + const result = getRegionOptions({ + data, + isAdditionOrDeletionNeeded: true, + regionsIdToRegionMap: regionsIdToLabelMap, + resourceIds: [], + }); + // Valid case + expect(result.length).toBe(3); + }); }); describe('getFilteredResources', () => { @@ -141,4 +151,30 @@ describe('getFilteredResources', () => { }); expect(result.length).toBe(0); }); + it('should return checked true for already selected instances', () => { + const // Case with searchText + result = getFilteredResources({ + data, + filteredRegions: [], + regionsIdToRegionMap, + resourceIds: ['1', '2'], + searchText: '', + selectedResources: ['1'], + }); + expect(result.length).toBe(2); + expect(result[0].checked).toBe(true); + }); + it('should return all resources in case of edit flow', () => { + const // Case with searchText + result = getFilteredResources({ + data, + filteredRegions: [], + isAdditionOrDeletionNeeded: true, + regionsIdToRegionMap, + resourceIds: [], + searchText: undefined, + selectedResources: ['1'], + }); + expect(result.length).toBe(data.length); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts index bc775d7e966..638dc349000 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -11,10 +11,15 @@ interface FilterResourceProps { * The selected regions on which the data needs to be filtered and it is in format US, Newark, NJ (us-east) */ filteredRegions?: string[]; + /** + * Property to integrate and edit the resources associated with alerts + */ + isAdditionOrDeletionNeeded?: boolean; /** * The map that holds the id of the region to Region object, helps in building the alert resources */ regionsIdToRegionMap: Map; + /** * The resources associated with the alerts */ @@ -24,6 +29,11 @@ interface FilterResourceProps { * The search text with which the resources needed to be filtered */ searchText?: string; + + /** + * This property helps to track the list of selected resources + */ + selectedResources?: string[]; } /** @@ -46,13 +56,22 @@ export const getRegionsIdRegionMap = ( export const getRegionOptions = ( filterProps: FilterResourceProps ): Region[] => { - const { data, regionsIdToRegionMap, resourceIds } = filterProps; - if (!data || !resourceIds.length || !regionsIdToRegionMap.size) { + const { + data, + isAdditionOrDeletionNeeded, + regionsIdToRegionMap, + resourceIds, + } = filterProps; + const isEmpty = + !data || + (!isAdditionOrDeletionNeeded && !resourceIds.length) || + !regionsIdToRegionMap.size; + if (isEmpty) { return []; } const uniqueRegions = new Set(); data.forEach(({ id, region }) => { - if (resourceIds.includes(String(id))) { + if (isAdditionOrDeletionNeeded || resourceIds.includes(String(id))) { const regionObject = region ? regionsIdToRegionMap.get(region) : undefined; @@ -74,21 +93,28 @@ export const getFilteredResources = ( const { data, filteredRegions, + isAdditionOrDeletionNeeded, regionsIdToRegionMap, resourceIds, searchText, + selectedResources, } = filterProps; - if (!data || resourceIds.length === 0) { + if (!data || (!isAdditionOrDeletionNeeded && resourceIds.length === 0)) { return []; } return data // here we always use the base data from API for filtering as source of truth - .filter(({ id }) => resourceIds.includes(String(id))) + .filter( + ({ id }) => isAdditionOrDeletionNeeded || resourceIds.includes(String(id)) + ) .map((resource) => { const regionObj = resource.region ? regionsIdToRegionMap.get(resource.region) : undefined; return { ...resource, + checked: selectedResources + ? selectedResources.includes(resource.id) + : false, region: resource.region // here replace region id, formatted to Chicago, US(us-west) compatible to display in table ? regionObj ? `${regionObj.label} (${regionObj.id})` @@ -122,3 +148,19 @@ export const scrollToElement = (scrollToElement: HTMLDivElement | null) => { }); } }; + +/** + * @param data The list of alert instances displayed in the table. + * @returns True if, all instances are selected else false. + */ +export const isAllPageSelected = (data: AlertInstance[]): boolean => { + return Boolean(data?.length) && data.every(({ checked }) => checked); +}; + +/** + * @param data The list of alert instances displayed in the table. + * @returns True if, any one of instances is selected else false. + */ +export const isSomeSelected = (data: AlertInstance[]): boolean => { + return Boolean(data?.length) && data.some(({ checked }) => checked); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index b9a3c1b8859..e02fea17efa 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -8,6 +8,7 @@ import type { Action } from 'src/components/ActionMenu/ActionMenu'; */ export const getAlertTypeToActionsList = ({ handleDetails, + handleEdit, }: ActionHandlers): Record => ({ // for now there is system and user alert types, in future more alert types can be added and action items will differ according to alert types system: [ @@ -15,6 +16,10 @@ export const getAlertTypeToActionsList = ({ onClick: handleDetails, title: 'Show Details', }, + { + onClick: handleEdit, + title: 'Edit', + }, ], user: [ { diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts index c154a21337a..a19ab7c9d0e 100644 --- a/packages/manager/src/queries/cloudpulse/alerts.ts +++ b/packages/manager/src/queries/cloudpulse/alerts.ts @@ -1,4 +1,7 @@ -import { createAlertDefinition } from '@linode/api-v4/lib/cloudpulse'; +import { + createAlertDefinition, + editAlertDefinition, +} from '@linode/api-v4/lib/cloudpulse'; import { keepPreviousData, useMutation, @@ -13,6 +16,7 @@ import type { Alert, AlertServiceType, CreateAlertDefinitionPayload, + EditAlertDefinitionPayload, NotificationChannel, } from '@linode/api-v4/lib/cloudpulse'; import type { APIError, Filter, Params } from '@linode/api-v4/lib/types'; @@ -57,3 +61,16 @@ export const useAllAlertNotificationChannelsQuery = ( ...queryFactory.notificationChannels._ctx.all(params, filter), }); }; + +export const useEditAlertDefinition = ( + serviceType: string, + alertId: number +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => editAlertDefinition(data, serviceType, alertId), + onSuccess() { + queryClient.invalidateQueries(queryFactory.alerts); + }, + }); +}; diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts index ee3b80b9aea..7ac6cb65edf 100644 --- a/packages/manager/src/queries/cloudpulse/resources.ts +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -17,7 +17,7 @@ export const useResourcesQuery = ( select: (resources) => { return resources.map((resource) => { return { - id: resource.id, + id: String(resource.id), label: resource.label, region: resource.region, regions: resource.regions ? resource.regions : [],