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, + }); + }, + }); +};