Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Upcoming Features
---

CloudPulse-Alerts: Add `DeleteChannelPayload` type and request for deletion of a notification channel ([#13256](https://github.com/linode/manager/pull/13256))
8 changes: 8 additions & 0 deletions packages/api-v4/src/cloudpulse/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,11 @@ export const updateNotificationChannel = (
setMethod('PUT'),
setData(data, editNotificationChannelPayloadSchema),
);

export const deleteNotificationChannel = (channelId: number) =>
Request<NotificationChannel>(
setURL(
`${API_ROOT}/monitor/alert-channels/${encodeURIComponent(channelId)}`,
),
setMethod('DELETE'),
);
7 changes: 7 additions & 0 deletions packages/api-v4/src/cloudpulse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,10 @@ export interface EditNotificationChannelPayloadWithId
*/
channelId: number;
}

export interface DeleteChannelPayload {
/**
* The ID of the channel to delete.
*/
channelId: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

CloudPulse-Alerts: Add support for delete action for user alert channels ([#13256](https://github.com/linode/manager/pull/13256))
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface EntityInfo {
| 'Managed Credential'
| 'Managed Service Monitor'
| 'NodeBalancer'
| 'Notification Channel'
| 'Placement Group'
| 'Subnet'
| 'Volume'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { getNotificationChannelActionsList } from '../Utils/utils';
import type { AlertNotificationType } from '@linode/api-v4';

export interface NotificationChannelActionHandlers {
/**
* Callback for delete action
*/
handleDelete: () => void;
/**
* Callback for show details action
*/
Expand All @@ -18,6 +22,10 @@ export interface NotificationChannelActionHandlers {
}

export interface NotificationChannelActionMenuProps {
/**
* Number of alerts associated with the notification channel
*/
alertsCount: number;
/**
* The label of the Notification Channel
*/
Expand All @@ -34,12 +42,14 @@ export interface NotificationChannelActionMenuProps {
export const NotificationChannelActionMenu = (
props: NotificationChannelActionMenuProps
) => {
const { channelLabel, handlers, notificationType } = props;
const { channelLabel, handlers, notificationType, alertsCount } = props;

return (
<ActionMenu
actionsList={
getNotificationChannelActionsList(handlers)[notificationType] || []
getNotificationChannelActionsList({ handlers, alertsCount })[
notificationType
] || []
}
ariaLabel={`Action menu for Notification Channel ${channelLabel}`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,34 @@ import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import { alertDefinitionFactory } from 'src/factories';
import { notificationChannelFactory } from 'src/factories/cloudpulse/channels';
import { formatDate } from 'src/utilities/formatDate';
import { renderWithTheme } from 'src/utilities/testHelpers';

import {
DELETE_CHANNEL_FAILED_MESSAGE,
DELETE_CHANNEL_SUCCESS_MESSAGE,
DELETE_CHANNEL_TOOLTIP_TEXT,
} from '../../constants';
import { NotificationChannelListTable } from './NotificationChannelListTable';

const mockScrollToElement = vi.fn();

const queryMocks = vi.hoisted(() => ({
mutateAsync: vi.fn(),
}));

vi.mock('src/queries/cloudpulse/alerts', async () => {
const actual = await vi.importActual('src/queries/cloudpulse/alerts');
return {
...actual,
useDeleteNotificationChannel: vi.fn(() => ({
mutateAsync: queryMocks.mutateAsync,
})),
};
});

const ALERT_TYPE = 'alerts-definitions';

describe('NotificationChannelListTable', () => {
Expand Down Expand Up @@ -158,4 +178,145 @@ describe('NotificationChannelListTable', () => {

screen.getByRole('button', { name: /next/i });
});

it('should not show delete action for system channels', async () => {
const channel = notificationChannelFactory.build({
type: 'system',
});

renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

const actionMenu = screen.getByRole('button', {
name: `Action menu for Notification Channel ${channel.label}`,
});

await userEvent.click(actionMenu);
expect(screen.queryByTestId('Delete')).not.toBeInTheDocument();
});

it('should disable delete if the user channel has alerts and show tooltip', async () => {
const channel = notificationChannelFactory.build({
alerts: alertDefinitionFactory.buildList(3),
});

renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

const actionMenu = screen.getByRole('button', {
name: `Action menu for Notification Channel ${channel.label}`,
});

await userEvent.click(actionMenu);
expect(screen.getByTestId('Delete')).toHaveAttribute(
'aria-disabled',
'true'
);

const tooltip = screen.getByLabelText(DELETE_CHANNEL_TOOLTIP_TEXT);
expect(tooltip).toBeInTheDocument();
});

it('should open delete confirmation dialog when delete is clicked', async () => {
const user = userEvent.setup();
const channel = notificationChannelFactory.build({
label: 'test_channel',
alerts: [],
});

renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

await user.click(
screen.getByRole('button', {
name: `Action menu for Notification Channel ${channel.label}`,
})
);
await user.click(screen.getByText('Delete'));

expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible();
});

it('should show success snackbar when deleting notification channel succeeds', async () => {
queryMocks.mutateAsync.mockResolvedValue({});
const user = userEvent.setup();
const channel = notificationChannelFactory.build({
label: 'Channel to be deleted',
alerts: [],
});

renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

await user.click(
screen.getByRole('button', {
name: `Action menu for Notification Channel ${channel.label}`,
})
);
await user.click(screen.getByText('Delete'));

expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible();

// Type the channel label to confirm
const input = screen.getByLabelText('Notification Channel Label');
await user.type(input, channel.label);
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.getByText(DELETE_CHANNEL_SUCCESS_MESSAGE)).toBeVisible();
});

it('should show error snackbar when deleting notification channel fails', async () => {
const user = userEvent.setup();
const channel = notificationChannelFactory.build({
label: 'Channel to be deleted',
alerts: [],
});

queryMocks.mutateAsync.mockRejectedValue([
{ reason: DELETE_CHANNEL_FAILED_MESSAGE },
]);

renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

await user.click(
screen.getByRole('button', {
name: `Action menu for Notification Channel ${channel.label}`,
})
);
await user.click(screen.getByText('Delete'));

expect(screen.getByText(`Delete ${channel.label}?`)).toBeVisible();

// Type the channel label to confirm
const input = screen.getByLabelText('Notification Channel Label');
await user.type(input, channel.label);
await user.click(screen.getByRole('button', { name: 'Delete' }));

expect(screen.getByText(DELETE_CHANNEL_FAILED_MESSAGE)).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TooltipIcon } from '@linode/ui';
import { Notice, TooltipIcon, Typography } from '@linode/ui';
import { GridLegacy, TableBody, TableHead } from '@mui/material';
import { useNavigate } from '@tanstack/react-router';
import { useSnackbar } from 'notistack';
import React from 'react';

import Paginate from 'src/components/Paginate';
Expand All @@ -10,16 +11,26 @@ 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 { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog';
import { useOrderV2 } from 'src/hooks/useOrderV2';
import { useDeleteNotificationChannel } from 'src/queries/cloudpulse/alerts';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';

import {
DELETE_CHANNEL_FAILED_MESSAGE,
DELETE_CHANNEL_SUCCESS_MESSAGE,
} from '../../constants';
import {
ChannelAlertsTooltipText,
ChannelListingTableLabelMap,
} from './constants';
import { NotificationChannelTableRow } from './NotificationChannelTableRow';

import type { APIError, NotificationChannel } from '@linode/api-v4';
import type {
APIError,
DeleteChannelPayload,
NotificationChannel,
} from '@linode/api-v4';
import type { Order } from '@linode/utilities';

export interface NotificationChannelListTableProps {
Expand All @@ -46,6 +57,15 @@ export const NotificationChannelListTable = React.memo(
(props: NotificationChannelListTableProps) => {
const { error, isLoading, notificationChannels, scrollToElement } = props;
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const { mutateAsync: deleteChannel } = useDeleteNotificationChannel();

const [selectedChannel, setSelectedChannel] =
React.useState<NotificationChannel | null>(null);
const [deleteState, setDeleteState] = React.useState({
isDialogOpen: false,
isDeleting: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Managing isDeleting in state is okay but may be unnecessary. The useDeleteNotificationChannel mutation hook will return an isPending value that could be used instead to track the loading state.

});

const handleDetails = ({ id }: NotificationChannel) => {
navigate({
Expand All @@ -60,6 +80,40 @@ export const NotificationChannelListTable = React.memo(
params: { channelId: id },
});
};

const handleDelete = React.useCallback((channel: NotificationChannel) => {
setSelectedChannel(channel);
setDeleteState((prev) => ({ ...prev, isDialogOpen: true }));
}, []);

const handleDeleteConfirm = React.useCallback(() => {
if (!selectedChannel) {
return;
}

setDeleteState((prev) => ({ ...prev, isDeleting: true }));
const payload: DeleteChannelPayload = {
channelId: selectedChannel.id,
};

deleteChannel(payload)
.then(() => {
enqueueSnackbar(DELETE_CHANNEL_SUCCESS_MESSAGE, {
variant: 'success',
});
})
.catch((deleteError: APIError[]) => {
const errorResponse = getAPIErrorOrDefault(
deleteError,
DELETE_CHANNEL_FAILED_MESSAGE
);
enqueueSnackbar(errorResponse[0].reason, { variant: 'error' });
})
.finally(() => {
setDeleteState({ isDialogOpen: false, isDeleting: false });
});
}, [deleteChannel, enqueueSnackbar, selectedChannel]);

const _error = error
? getAPIErrorOrDefault(
error,
Expand Down Expand Up @@ -180,6 +234,7 @@ export const NotificationChannelListTable = React.memo(
handlers={{
handleDetails: () => handleDetails(channel),
handleEdit: () => handleEdit(channel),
handleDelete: () => handleDelete(channel),
}}
key={channel.id}
notificationChannel={channel}
Expand All @@ -206,6 +261,31 @@ export const NotificationChannelListTable = React.memo(
pageSize={pageSize}
sx={{ border: 0 }}
/>
<TypeToConfirmDialog
entity={{
action: 'deletion',
name: selectedChannel?.label ?? '',
primaryBtnText: 'Delete',
type: 'Notification Channel',
}}
expand
label="Notification Channel Label"
loading={deleteState.isDeleting}
onClick={handleDeleteConfirm}
onClose={() => {
setDeleteState((prev) => ({ ...prev, isDialogOpen: false }));
setSelectedChannel(null);
}}
open={deleteState.isDialogOpen}
title={`Delete ${selectedChannel?.label ?? ''}?`}
>
<Notice variant="warning">
<Typography>
<strong>Warning:</strong> Deleting your Notification Channel
will result in permanent data loss.
</Typography>
</Notice>
</TypeToConfirmDialog>
</>
);
}}
Expand Down
Loading