diff --git a/packages/api-v4/.changeset/pr-11785-upcoming-features-1741159921041.md b/packages/api-v4/.changeset/pr-11785-upcoming-features-1741159921041.md
new file mode 100644
index 00000000000..2b1745177ba
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-11785-upcoming-features-1741159921041.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Upcoming Features
+---
+
+add `EntityAlertUpdatePayload` cloudpulse types.ts ([#11785](https://github.com/linode/manager/pull/11785))
diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts
index f79104daf03..3a88b09c1b8 100644
--- a/packages/api-v4/src/cloudpulse/alerts.ts
+++ b/packages/api-v4/src/cloudpulse/alerts.ts
@@ -82,3 +82,34 @@ export const getAlertDefinitionByServiceType = (serviceType: string) =>
),
setMethod('GET')
);
+
+ export const addEntityToAlert = (
+ serviceType: string,
+ entityId: string,
+ data: { 'alert-definition-id': number }
+ ) =>
+ Request<{}>(
+ setURL(
+ `${API_ROOT}/monitor/service/${encodeURIComponent(
+ serviceType
+ )}/entity/${encodeURIComponent(entityId)}/alert-definition`
+ ),
+ setMethod('POST'),
+ setData(data)
+ );
+
+ export const deleteEntityFromAlert = (
+ serviceType: string,
+ entityId: string,
+ alertId: number
+ ) =>
+ Request<{}>(
+ setURL(
+ `${API_ROOT}/monitor/service/${encodeURIComponent(
+ serviceType
+ )}/entity/${encodeURIComponent(
+ entityId
+ )}/alert-definition/${encodeURIComponent(alertId)}`
+ ),
+ setMethod('DELETE')
+ );
diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts
index a903db83ed6..1f375e7db7c 100644
--- a/packages/api-v4/src/cloudpulse/types.ts
+++ b/packages/api-v4/src/cloudpulse/types.ts
@@ -328,3 +328,8 @@ export interface EditAlertPayloadWithService
}
export type AlertStatusUpdateType = 'Enable' | 'Disable';
+
+export interface EntityAlertUpdatePayload {
+ entityId: string;
+ alert: Alert;
+}
diff --git a/packages/manager/.changeset/pr-11785-upcoming-features-1741159867178.md b/packages/manager/.changeset/pr-11785-upcoming-features-1741159867178.md
new file mode 100644
index 00000000000..93cbc714090
--- /dev/null
+++ b/packages/manager/.changeset/pr-11785-upcoming-features-1741159867178.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Add `AlertsConfirmationDialog.tsx`, update `AlertInformationActionTable` to show `confirmation dialog` on toggle ([#11785](https://github.com/linode/manager/pull/11785))
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.test.tsx
new file mode 100644
index 00000000000..a6c52bbd471
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.test.tsx
@@ -0,0 +1,68 @@
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { alertFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { AlertConfirmationDialog } from './AlertConfirmationDialog';
+
+const entityName = 'entity-1';
+const alert = alertFactory.build({ service_type: 'dbaas' });
+const confirmFunction = vi.fn();
+
+describe('Alert confirmation dialog', () => {
+ it('should show confirmation dialog', () => {
+ const { getByTestId, getByText } = renderWithTheme(
+
+ );
+
+ expect(getByTestId('confirmation-dialog')).toBeInTheDocument();
+ expect(getByText(`Disable ${alert.label} Alert?`)).toBeVisible();
+ expect(
+ getByText(`Are you sure you want to disable the alert for ${entityName}?`)
+ ).toBeInTheDocument();
+ });
+ it('should click confirm button', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ const button = getByText('Disable');
+
+ await userEvent.click(button);
+
+ expect(confirmFunction).toBeCalledWith(alert, true);
+ });
+ it('should show enable text', async () => {
+ const { getByTestId, getByText } = renderWithTheme(
+
+ );
+
+ expect(getByTestId('confirmation-dialog')).toBeInTheDocument();
+ expect(getByText(`Enable ${alert.label} Alert?`)).toBeVisible();
+ expect(
+ getByText(`Are you sure you want to enable the alert for ${entityName}?`)
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx
new file mode 100644
index 00000000000..9afecdfce86
--- /dev/null
+++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx
@@ -0,0 +1,89 @@
+import { ActionsPanel, Typography } from '@linode/ui';
+import React from 'react';
+
+import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
+
+import type { Alert } from '@linode/api-v4';
+
+interface AlertConfirmationDialogProps {
+ /**
+ * alert object of the selected row
+ */
+ alert: Alert;
+
+ /**
+ * Name of the selected entity
+ */
+ entityName: string;
+
+ /**
+ * Handler function for cancel button
+ */
+ handleCancel: () => void;
+
+ /**
+ * Handler function for enable/disable button
+ * @param alert selected alert from the row
+ * @param currentStatus current state of the toggle button
+ */
+ handleConfirm: (alert: Alert, currentStatus: boolean) => void;
+
+ /**
+ * Current state of the toggle button whether active or not
+ */
+ isActive: boolean;
+
+ /**
+ * Loading state of the confirmation dialog
+ */
+ isLoading?: boolean;
+
+ /**
+ * Current state of the confirmation dialoge whether open or not
+ */
+ isOpen: boolean;
+}
+
+export const AlertConfirmationDialog = React.memo(
+ (props: AlertConfirmationDialogProps) => {
+ const {
+ alert,
+ entityName,
+ handleCancel,
+ handleConfirm,
+ isActive,
+ isLoading = false,
+ isOpen,
+ } = props;
+
+ const actionsPanel = (
+ handleConfirm(alert, isActive),
+ }}
+ secondaryButtonProps={{
+ disabled: isLoading,
+ label: 'Cancel',
+ onClick: handleCancel,
+ }}
+ />
+ );
+
+ return (
+
+
+ Are you sure you want to {isActive ? 'disable' : 'enable'} the alert
+ for {entityName}?
+
+
+ );
+ }
+);
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 d9e1a629d32..f00aadc8159 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx
@@ -1,4 +1,5 @@
import { within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import React from 'react';
import { alertFactory } from 'src/factories/cloudpulse/alerts';
@@ -64,4 +65,19 @@ describe('Alert Listing Reusable Table for contextual view', () => {
expect(checkbox).toHaveProperty('checked');
});
+
+ it('Should show confirm dialog on checkbox click', async () => {
+ const { findByTestId, findByText } = renderWithTheme(
+
+ );
+ const alert = alerts[0];
+ const row = await findByTestId(alert.id);
+
+ const checkbox = await within(row).findByRole('checkbox');
+
+ await userEvent.click(checkbox);
+
+ const text = await findByText(`Disable ${alert.label} Alert?`);
+ expect(text).toBeInTheDocument();
+ });
});
diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
index e186879c479..fdc98763b18 100644
--- a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
+++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx
@@ -1,5 +1,6 @@
import { Box } from '@linode/ui';
import { Grid, TableBody, TableHead } from '@mui/material';
+import { useSnackbar } from 'notistack';
import React from 'react';
import OrderBy from 'src/components/OrderBy';
@@ -10,11 +11,16 @@ import { TableCell } from 'src/components/TableCell';
import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper';
import { TableRow } from 'src/components/TableRow';
import { TableSortCell } from 'src/components/TableSortCell';
+import {
+ useAddEntityToAlert,
+ useRemoveEntityFromAlert,
+} from 'src/queries/cloudpulse/alerts';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog';
import { AlertInformationActionRow } from './AlertInformationActionRow';
-import type { APIError, Alert } from '@linode/api-v4';
+import type { APIError, Alert, EntityAlertUpdatePayload } from '@linode/api-v4';
export interface AlertInformationActionTableProps {
/**
@@ -60,115 +66,139 @@ export interface TableColumnHeader {
label: string;
}
-export interface AlertStatusMap {
- /**
- * Alert id to boolean mapping to hold whether that alert id is enabled or not
- */
- [alertId: number]: boolean;
-}
-
-// generate the mapping of id to whether entity present in entity_ids list or not for quick access in table row toggles
-const generateStatusMap = (
- alerts: Alert[],
- entityId: string
-): AlertStatusMap => {
- return alerts.reduce(
- (previousValue, alert) => ({
- ...previousValue,
- [alert.id]: alert.entity_ids.includes(entityId),
- }),
- {}
- );
-};
-
export const AlertInformationActionTable = (
props: AlertInformationActionTableProps
) => {
- const { alerts, columns, entityId, error, orderByColumn } = props;
- const [alertStatusMap, setAlertStatusMap] = React.useState(
- generateStatusMap(alerts, entityId)
- );
+ const { alerts, columns, entityId, entityName, error, orderByColumn } = props;
const _error = error
? getAPIErrorOrDefault(error, 'Error while fetching the alerts')
: undefined;
+ const { enqueueSnackbar } = useSnackbar();
+ const [selectedAlert, setSelectedAlert] = React.useState({} as Alert);
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const { mutateAsync: addEntity } = useAddEntityToAlert();
+
+ const { mutateAsync: removeEntity } = useRemoveEntityFromAlert();
- const handleToggle = ({ id }: Alert) => {
- setAlertStatusMap((previousValue) => {
- return {
- ...previousValue,
- [id]: !previousValue[id],
+ const handleCancel = () => {
+ setIsDialogOpen(false);
+ };
+ const handleConfirm = React.useCallback(
+ (alert: Alert, currentStatus: boolean) => {
+ const payload: EntityAlertUpdatePayload = {
+ alert,
+ entityId,
};
- });
+
+ setIsLoading(true);
+ (currentStatus ? removeEntity(payload) : addEntity(payload))
+ .then(() => {
+ enqueueSnackbar(
+ `The alert settings for ${entityName} saved successfully.`,
+ { variant: 'success' }
+ );
+ })
+ .catch(() => {
+ enqueueSnackbar(
+ `${currentStatus ? 'Disabling' : 'Enabling'} alert failed.`,
+ {
+ variant: 'error',
+ }
+ );
+ })
+ .finally(() => {
+ setIsLoading(false);
+ setIsDialogOpen(false);
+ });
+ },
+ [addEntity, enqueueSnackbar, entityId, entityName, removeEntity]
+ );
+ const handleToggle = (alert: Alert) => {
+ setIsDialogOpen(true);
+ setSelectedAlert(alert);
};
return (
-
- {({ data: orderedData, handleOrderChange, order, orderBy }) => (
-
- {({
- count,
- data: paginatedAndOrderedAlerts,
- handlePageChange,
- handlePageSizeChange,
- page,
- pageSize,
- }) => (
-
-
-
-
-
-
- {columns.map(({ columnName, label }) => {
- return (
-
- {columnName}
-
- );
- })}
-
-
-
-
- {paginatedAndOrderedAlerts?.map((alert) => (
-
+
+ {({ data: orderedData, handleOrderChange, order, orderBy }) => (
+
+ {({
+ count,
+ data: paginatedAndOrderedAlerts,
+ handlePageChange,
+ handlePageSizeChange,
+ page,
+ pageSize,
+ }) => (
+
+
+
+
+
+
+ {columns.map(({ columnName, label }) => {
+ return (
+
+ {columnName}
+
+ );
+ })}
+
+
+
+
- ))}
-
-
-
-
-
- )}
-
- )}
-
+ {paginatedAndOrderedAlerts?.map((alert) => (
+
+ ))}
+
+
+
+
+
+ )}
+
+ )}
+
+
+ >
);
};
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index 6afe643e449..afda6199c9f 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -2474,6 +2474,7 @@ export const handlers = [
'*/monitor/services/:serviceType/alert-definitions',
async ({ params }) => {
const serviceType = params.serviceType;
+ alertFactory.resetSequenceNumber();
return HttpResponse.json({
data: [
...alertFactory.buildList(20, {
diff --git a/packages/manager/src/queries/cloudpulse/alerts.ts b/packages/manager/src/queries/cloudpulse/alerts.ts
index 443d659656f..4a1d5dd38fc 100644
--- a/packages/manager/src/queries/cloudpulse/alerts.ts
+++ b/packages/manager/src/queries/cloudpulse/alerts.ts
@@ -1,5 +1,7 @@
import {
+ addEntityToAlert,
createAlertDefinition,
+ deleteEntityFromAlert,
editAlertDefinition,
} from '@linode/api-v4/lib/cloudpulse';
import {
@@ -17,6 +19,7 @@ import type {
AlertServiceType,
CreateAlertDefinitionPayload,
EditAlertPayloadWithService,
+ EntityAlertUpdatePayload,
NotificationChannel,
} from '@linode/api-v4/lib/cloudpulse';
import type { APIError, Filter, Params } from '@linode/api-v4/lib/types';
@@ -80,3 +83,76 @@ export const useEditAlertDefinition = () => {
},
});
};
+
+export const useAddEntityToAlert = () => {
+ const queryClient = useQueryClient();
+
+ // Todo: Will update the type of api response once it is finalized
+ return useMutation<{}, APIError[], EntityAlertUpdatePayload>({
+ mutationFn: (payload: EntityAlertUpdatePayload) => {
+ const { alert, entityId } = payload;
+ const { id: alertId, service_type: serviceType } = alert;
+ return addEntityToAlert(serviceType, entityId, {
+ 'alert-definition-id': alertId,
+ });
+ },
+
+ onSuccess(_data, variable) {
+ const { alert, entityId } = variable;
+ const { id: alertId, service_type: serviceType } = alert;
+
+ alert.entity_ids.push(entityId);
+ queryClient.setQueryData(
+ queryFactory.alerts._ctx.alertByServiceTypeAndId(
+ serviceType,
+ String(alertId)
+ ).queryKey,
+ alert
+ );
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.alertsByServiceType(serviceType)
+ .queryKey,
+ });
+
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.all().queryKey,
+ });
+ },
+ });
+};
+export const useRemoveEntityFromAlert = () => {
+ const queryClient = useQueryClient();
+
+ // Todo: Will update the type of api response once it is finalized
+ return useMutation<{}, APIError[], EntityAlertUpdatePayload>({
+ mutationFn: (payload: EntityAlertUpdatePayload) => {
+ const { alert, entityId } = payload;
+ const { id: alertId, service_type: serviceType } = alert;
+ return deleteEntityFromAlert(serviceType, entityId, alertId);
+ },
+ onSuccess(_data, variable) {
+ const { alert, entityId } = variable;
+ const { id: alertId, service_type: serviceType } = alert;
+
+ const index = alert.entity_ids.indexOf(entityId);
+ if (index > -1) {
+ alert.entity_ids.splice(index, 1);
+ }
+ queryClient.setQueryData(
+ queryFactory.alerts._ctx.alertByServiceTypeAndId(
+ serviceType,
+ String(alertId)
+ ).queryKey,
+ alert
+ );
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.alertsByServiceType(serviceType)
+ .queryKey,
+ });
+
+ queryClient.invalidateQueries({
+ queryKey: queryFactory.alerts._ctx.all().queryKey,
+ });
+ },
+ });
+};