diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx index cf89aad0c2c..7cfa437fe4b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx @@ -63,6 +63,7 @@ export const AlertConfirmationDialog = React.memo( disabled: isLoading, label: 'Cancel', onClick: handleCancel, + buttonType: 'outlined', }} /> ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx index 4de285d50fd..e1191701576 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx @@ -69,7 +69,9 @@ describe('Alert Listing Reusable Table for contextual view', () => { }); it('Should show confirm dialog on save button click when changes are made', async () => { - renderWithTheme(); + renderWithTheme( + + ); // First toggle an alert to make changes const alert = alerts[0]; @@ -86,6 +88,24 @@ describe('Alert Listing Reusable Table for contextual view', () => { expect(screen.getByTestId('confirmation-dialog')).toBeVisible(); }); + it('Should hide confirm dialog on save button click when changes are made', async () => { + renderWithTheme(); + + // First toggle an alert to make changes + const alert = alerts[0]; + const row = await screen.findByTestId(alert.id); + const toggle = await within(row).findByRole('checkbox'); + await userEvent.click(toggle); + + // Now the save button should be enabled + const saveButton = screen.getByTestId('save-alerts'); + expect(saveButton).not.toBeDisabled(); + + // Click save and verify dialog appears + await userEvent.click(saveButton); + expect(screen.queryByTestId('confirmation-dialog')).not.toBeInTheDocument(); + }); + it('Should have save button in disabled form when no changes are made', () => { renderWithTheme(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx index 48f58e8d6de..8c596005ebc 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -68,6 +68,11 @@ export interface AlertInformationActionTableProps { * Service type of the selected entity */ serviceType: string; + + /** + * Flag to determine if confirmation dialog should be displayed + */ + showConfirmationDialog?: boolean; } export interface TableColumnHeader { @@ -108,11 +113,11 @@ export const AlertInformationActionTable = ( alerts, columns, entityId, - entityName, error, orderByColumn, serviceType, onToggleAlert, + showConfirmationDialog, } = props; const alertsTableRef = React.useRef(null); @@ -125,7 +130,7 @@ export const AlertInformationActionTable = ( const [isLoading, setIsLoading] = React.useState(false); const isEditMode = !!entityId; - const isCreateMode = !!onToggleAlert; + const isCreateMode = !isEditMode; const { enabledAlerts, setEnabledAlerts, hasUnsavedChanges } = useContextualAlertsState(alerts, entityId); @@ -135,6 +140,13 @@ export const AlertInformationActionTable = ( entityId ?? '' ); + // To send initial state of alerts through toggle handler function + React.useEffect(() => { + if (onToggleAlert) { + onToggleAlert(enabledAlerts); + } + }, []); + const handleCancel = () => { setIsDialogOpen(false); }; @@ -274,11 +286,6 @@ export const AlertInformationActionTable = ( return null; } - // TODO: Remove this once we have a way to toggle ACCOUNT and REGION level alerts - if (!isEditMode && alert.scope !== 'entity') { - return null; - } - const status = enabledAlerts[alert.type]?.includes( alert.id ); @@ -312,13 +319,14 @@ export const AlertInformationActionTable = ( buttonType="primary" data-qa-buttons="true" data-testid="save-alerts" - disabled={!hasUnsavedChanges} + disabled={!hasUnsavedChanges || isLoading} + loading={isLoading} onClick={() => { - window.scrollTo({ - behavior: 'instant', - top: 0, - }); - setIsDialogOpen(true); + if (showConfirmationDialog) { + setIsDialogOpen(true); + } else { + handleConfirm(enabledAlerts); + } }} > Save @@ -337,13 +345,12 @@ export const AlertInformationActionTable = ( isOpen={isDialogOpen} message={ <> - Are you sure you want to save these settings for {entityName}? All - legacy alert settings will be disabled and replaced by the new{' '} - Alerts(Beta) settings. + Are you sure you want to save (Beta) Alerts? Legacy settings + will be disabled and replaced by (Beta) Alerts settings. } - primaryButtonLabel="Save" - title="Save Alerts?" + primaryButtonLabel="Confirm" + title="Are you sure you want to save (Beta) Alerts? " /> ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx index 6799845c82d..2f8e251a68a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx @@ -1,4 +1,4 @@ -import { waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -24,8 +24,18 @@ vi.mock('src/queries/cloudpulse/alerts', async () => { const serviceType = 'linode'; const entityId = '123'; const entityName = 'test-instance'; +const region = 'us-ord'; +const onToggleAlert = vi.fn(); const alerts = [ - ...alertFactory.buildList(3, { service_type: serviceType }), + ...alertFactory.buildList(3, { + service_type: serviceType, + regions: ['us-ord'], + }), + alertFactory.build({ + label: 'test-alert', + service_type: serviceType, + regions: ['us-ord'], + }), ...alertFactory.buildList(7, { entity_ids: [entityId], service_type: serviceType, @@ -33,6 +43,7 @@ const alerts = [ ...alertFactory.buildList(1, { entity_ids: [entityId], service_type: serviceType, + regions: ['us-ord'], status: 'enabled', type: 'system', }), @@ -48,6 +59,8 @@ const component = ( ); @@ -98,4 +111,24 @@ describe('Alert Resuable Component for contextual view', () => { const alert = alerts[alerts.length - 1]; expect(getByText(alert.label)).toBeInTheDocument(); }); + + it('Should hide manage alerts button for undefined entityId', () => { + renderWithTheme(); + + const manageAlerts = screen.queryByTestId('manage-alerts'); + expect(manageAlerts).not.toBeInTheDocument(); + expect(screen.queryByText('Alerts')).not.toBeInTheDocument(); + }); + + it('Should filter alerts based on region', async () => { + renderWithTheme(component); + await userEvent.click(screen.getByRole('button', { name: 'Open' })); + expect(screen.getByText('test-alert')).toBeVisible(); + }); + + it('Should show header for edit mode', async () => { + renderWithTheme(component); + await userEvent.click(screen.getByText('Manage Alerts')); + expect(screen.getByText('Alerts')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx index 9840478ee92..483628c260d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -13,13 +13,11 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useFlags } from 'src/hooks/useFlags'; import { useAlertDefinitionByServiceTypeQuery } from 'src/queries/cloudpulse/alerts'; import { AlertContextualViewTableHeaderMap } from '../AlertsListing/constants'; -import { - convertAlertsToTypeSet, - filterAlertsByStatusAndType, -} from '../Utils/utils'; +import { convertAlertsToTypeSet, filterAlerts } from '../Utils/utils'; import { AlertInformationActionTable } from './AlertInformationActionTable'; import type { @@ -38,13 +36,22 @@ interface AlertReusableComponentProps { */ entityName?: string; + /** + * Whether the legacy alert is available for the entity + */ + isLegacyAlertAvailable?: boolean; + /** * Called when an alert is toggled on or off. - * Only use in create flow. * @param payload enabled alerts ids */ onToggleAlert?: (payload: CloudPulseAlertsPayload) => void; + /** + * Region ID for the selected entity + */ + regionId?: string; + /** * Service type of selected entity */ @@ -52,7 +59,14 @@ interface AlertReusableComponentProps { } export const AlertReusableComponent = (props: AlertReusableComponentProps) => { - const { entityId, entityName, onToggleAlert, serviceType } = props; + const { + entityId, + entityName, + onToggleAlert, + serviceType, + regionId, + isLegacyAlertAvailable, + } = props; const { data: alerts, error, @@ -64,12 +78,14 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { AlertDefinitionType | undefined >(); - // Filter alerts based on status, search text & selected type + // Filter alerts based on status, search text, selected type, and region const filteredAlerts = React.useMemo( - () => filterAlertsByStatusAndType(alerts, searchText, selectedType), - [alerts, searchText, selectedType] + () => filterAlerts({ alerts, searchText, selectedType, regionId }), + [alerts, regionId, searchText, selectedType] ); + const { aclpBetaServices } = useFlags(); + const history = useHistory(); // Filter unique alert types from alerts list @@ -80,21 +96,24 @@ export const AlertReusableComponent = (props: AlertReusableComponentProps) => { } return ( - + - - - Alerts - + {entityId && ( + + + Alerts + {aclpBetaServices?.[serviceType]?.alerts && } + + - - + )} { onToggleAlert={onToggleAlert} orderByColumn="Alert Name" serviceType={serviceType} + showConfirmationDialog={isLegacyAlertAvailable} /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 8418fd4e21e..a80eff55fdd 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -9,7 +9,7 @@ import { convertAlertsToTypeSet, convertSecondsToMinutes, convertSecondsToOptions, - filterAlertsByStatusAndType, + filterAlerts, getSchemaWithEntityIdValidation, getServiceTypeLabel, handleMultipleError, @@ -50,13 +50,32 @@ it('test convertSecondsToOptions method', () => { expect(convertSecondsToOptions(900)).toEqual('15 min'); }); -it('test filterAlertsByStatusAndType method', () => { - const alerts = alertFactory.buildList(12, { created_by: 'system' }); - expect(filterAlertsByStatusAndType(alerts, '', 'system')).toHaveLength(12); - expect(filterAlertsByStatusAndType(alerts, '', 'user')).toHaveLength(0); - expect(filterAlertsByStatusAndType(alerts, 'Alert-1', 'system')).toHaveLength( - 4 - ); +it('test filterAlerts method', () => { + const alerts = [ + ...alertFactory.buildList(12, { created_by: 'system' }), + alertFactory.build({ + label: 'Alert-14', + scope: 'region', + regions: ['us-east'], + }), + ]; + expect( + filterAlerts({ alerts, searchText: '', selectedType: 'system' }) + ).toHaveLength(12); + expect( + filterAlerts({ alerts, searchText: '', selectedType: 'user' }) + ).toHaveLength(0); + expect( + filterAlerts({ alerts, searchText: 'Alert-1', selectedType: 'system' }) + ).toHaveLength(4); + expect( + filterAlerts({ + alerts, + searchText: '', + selectedType: 'system', + regionId: 'us-east', + }) + ).toHaveLength(13); }); it('test convertAlertsToTypeSet method', () => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index b3a18689fa6..b86d15beb5d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -127,6 +127,25 @@ interface SupportedRegionsProps { serviceType: AlertServiceType | null; } +interface FilterAlertsProps { + /** + * The list of alerts to be filtered + */ + alerts: Alert[] | undefined; + /** + * The region ID to filter the alerts + */ + regionId?: string; + /** + * The search text to filter the alerts + */ + searchText: string; + /** + * The selected alert type to filter the alerts + */ + selectedType: AlertDefinitionType | undefined; +} + /** * @param serviceType Service type for which the label needs to be displayed * @param serviceTypeList List of available service types in Cloud Pulse @@ -229,19 +248,19 @@ export const getChipLabels = ( * @param alerts list of alerts to be filtered * @param searchText text to be searched in alert name * @param selectedType selecte alert type - * @returns list of filtered alerts based on searchText & selectedType + * @param region region of the entity + * @returns list of filtered alerts based on searchText, selectedType, and region */ -export const filterAlertsByStatusAndType = ( - alerts: Alert[] | undefined, - searchText: string, - selectedType: string | undefined -): Alert[] => { +export const filterAlerts = (props: FilterAlertsProps): Alert[] => { + const { alerts, regionId, searchText, selectedType } = props; return ( - alerts?.filter(({ label, status, type }) => { + alerts?.filter(({ label, status, type, scope, regions }) => { return ( (status === 'enabled' || status === 'in progress') && (!selectedType || type === selectedType) && - (!searchText || label.toLowerCase().includes(searchText.toLowerCase())) + (!searchText || + label.toLowerCase().includes(searchText.toLowerCase())) && + (scope !== 'region' || (regionId && regions?.includes(regionId))) ); }) ?? [] ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 17630884a99..34aef1e600a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -190,12 +190,12 @@ export const CREATE_ALERT_SUCCESS_MESSAGE = export const UPDATE_ALERT_SUCCESS_MESSAGE = 'Alert successfully updated. It may take a few minutes for your changes to take effect.'; -export const ALERT_SCOPE_TOOLTIP_CONTEXTUAL = - 'Indicates whether the alert applies to all Linodes in the account, Linodes in specific regions, or just this Linode (entity).'; - export const ALERT_SCOPE_TOOLTIP_TEXT = 'The set of entities to which the alert applies: account-wide, specific regions, or individual entities.'; +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 type AlertFormMode = 'create' | 'edit' | 'view'; export const DELETE_ALERT_SUCCESS_MESSAGE = 'Alert successfully deleted.'; diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 2a46110e0ff..0979fa7a269 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -89,19 +89,20 @@ export const useContextualAlertsState = ( user: [], }; - if (entityId) { - alerts.forEach((alert) => { - const isAccountOrRegion = - alert.scope === 'region' || alert.scope === 'account'; - const shouldInclude = entityId - ? isAccountOrRegion || alert.entity_ids.includes(entityId) - : false; - - if (shouldInclude) { - initialStates[alert.type]?.push(alert.id); - } - }); - } + alerts.forEach((alert) => { + const isAccountOrRegion = + alert.scope === 'region' || alert.scope === 'account'; + + // include alerts which has either account or region level scope or entityId is present in the alert's entity_ids + const shouldInclude = entityId + ? isAccountOrRegion || alert.entity_ids.includes(entityId) + : isAccountOrRegion; + + if (shouldInclude) { + initialStates[alert.type]?.push(alert.id); + } + }); + return initialStates; }, [] diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx index 7edbfaa1a6a..fa7370800ec 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeAlerts/LinodeAlerts.tsx @@ -1,4 +1,5 @@ import { useLinodeQuery } from '@linode/queries'; +import { useIsLinodeAclpSubscribed } from '@linode/shared'; import { Box } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; @@ -27,6 +28,7 @@ const LinodeAlerts = () => { regionId: linode?.region, type: 'alerts', }); + const isLinodeAclpSubscribed = useIsLinodeAclpSubscribed(id, 'beta'); return ( @@ -45,6 +47,7 @@ const LinodeAlerts = () => { ) : ( diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4ed70cfb712..52a3ff38fe8 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -2730,6 +2730,7 @@ export const handlers = [ service_type: serviceType === 'dbaas' ? 'dbaas' : 'linode', type: 'user', scope: 'region', + regions: ['us-east'], }), ], });