diff --git a/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md b/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md new file mode 100644 index 00000000000..575de3bd42d --- /dev/null +++ b/packages/api-v4/.changeset/pr-13193-changed-1765542569473.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Renamed updated_at, created_at to updated,created in NotificationChannelBase interface ([#13193](https://github.com/linode/manager/pull/13193)) diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 07dca57b539..12b8066f928 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -286,13 +286,13 @@ interface NotificationChannelAlerts { interface NotificationChannelBase { alerts: NotificationChannelAlerts[]; channel_type: ChannelType; - created_at: string; + created: string; created_by: string; id: number; label: string; status: NotificationStatus; type: AlertNotificationType; - updated_at: string; + updated: string; updated_by: string; } diff --git a/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md b/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md new file mode 100644 index 00000000000..403ed960a55 --- /dev/null +++ b/packages/manager/.changeset/pr-13193-upcoming-features-1765540702676.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Introduce Listing for ACLP-Alerting Notification channels with ordering, pagination ([#13193](https://github.com/linode/manager/pull/13193)) diff --git a/packages/manager/src/factories/cloudpulse/channels.ts b/packages/manager/src/factories/cloudpulse/channels.ts index 9c96cd36e57..e36c53c1884 100644 --- a/packages/manager/src/factories/cloudpulse/channels.ts +++ b/packages/manager/src/factories/cloudpulse/channels.ts @@ -20,12 +20,12 @@ export const notificationChannelFactory = subject: 'Sample Alert', }, }, - created_at: new Date().toISOString(), + created: new Date().toISOString(), created_by: 'user1', id: Factory.each((i) => i), label: Factory.each((id) => `Channel-${id}`), status: 'Enabled', type: 'custom', - updated_at: new Date().toISOString(), + updated: new Date().toISOString(), updated_by: 'user1', }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx new file mode 100644 index 00000000000..57be8d91987 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.test.tsx @@ -0,0 +1,161 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationChannelListTable } from './NotificationChannelListTable'; + +const mockScrollToElement = vi.fn(); + +const ALERT_TYPE = 'alerts-definitions'; + +describe('NotificationChannelListTable', () => { + it('should render the notification channel table headers', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('Alerts')).toBeVisible(); + expect(screen.getByText('Channel Type')).toBeVisible(); + expect(screen.getByText('Created By')).toBeVisible(); + expect(screen.getByText('Last Modified')).toBeVisible(); + expect(screen.getByText('Last Modified By')).toBeVisible(); + }); + + it('should render the error message when error is provided', () => { + renderWithTheme( + + ); + + expect( + screen.getByText('Error in fetching the notification channels') + ).toBeVisible(); + }); + + it('should render notification channel rows', () => { + const updated = new Date().toISOString(); + const channel = notificationChannelFactory.build({ + channel_type: 'email', + created_by: 'user1', + label: 'Test Channel', + updated_by: 'user2', + updated, + }); + + renderWithTheme( + + ); + + expect(screen.getByText('Test Channel')).toBeVisible(); + expect(screen.getByText('Email')).toBeVisible(); + expect(screen.getByText('user1')).toBeVisible(); + expect(screen.getByText('user2')).toBeVisible(); + expect( + screen.getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); + + it('should render the loading state', () => { + renderWithTheme( + + ); + + screen.getByTestId('table-row-loading'); + }); + + it('should render tooltip for Alerts column', async () => { + renderWithTheme( + + ); + + const tooltipIcon = screen.getByTestId('tooltip-info-icon'); + await userEvent.hover(tooltipIcon); + + await waitFor(() => { + expect( + screen.getByText( + 'The number of alert definitions associated with the notification channel.' + ) + ).toBeVisible(); + }); + }); + + it('should render multiple notification channels', () => { + const channels = notificationChannelFactory.buildList(5); + + renderWithTheme( + + ); + + channels.forEach((channel) => { + expect(screen.getByText(channel.label)).toBeVisible(); + }); + }); + + it('should display correct alerts count', () => { + const channel = notificationChannelFactory.build({ + alerts: [ + { id: 1, label: 'Alert 1', type: ALERT_TYPE, url: 'url1' }, + { id: 2, label: 'Alert 2', type: ALERT_TYPE, url: 'url2' }, + { id: 3, label: 'Alert 3', type: ALERT_TYPE, url: 'url3' }, + ], + }); + + renderWithTheme( + + ); + + expect(screen.getByText('3')).toBeVisible(); + }); + + it('should render pagination footer', () => { + const channels = notificationChannelFactory.buildList(30); + + renderWithTheme( + + ); + + screen.getByRole('button', { name: /next/i }); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx new file mode 100644 index 00000000000..2566bda7132 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListTable.tsx @@ -0,0 +1,194 @@ +import { TooltipIcon } from '@linode/ui'; +import { GridLegacy, TableBody, TableHead } from '@mui/material'; +import React from 'react'; + +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +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 { useOrderV2 } from 'src/hooks/useOrderV2'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { + ChannelAlertsTooltipText, + ChannelListingTableLabelMap, +} from './constants'; +import { NotificationChannelTableRow } from './NotificationChannelTableRow'; + +import type { APIError, NotificationChannel } from '@linode/api-v4'; +import type { Order } from '@linode/utilities'; + +export interface NotificationChannelListTableProps { + /** + * The error returned from the API call to fetch notification channels + */ + error?: APIError[]; + /** + * Indicates if the data is loading + */ + isLoading: boolean; + /** + * The list of notification channels to display in the table + */ + notificationChannels: NotificationChannel[]; + /** + * Function to scroll to a specific element on the page + * @returns void + */ + scrollToElement: () => void; +} + +export const NotificationChannelListTable = React.memo( + (props: NotificationChannelListTableProps) => { + const { error, isLoading, notificationChannels, scrollToElement } = props; + + const _error = error + ? getAPIErrorOrDefault( + error, + 'Error in fetching the notification channels.' + ) + : undefined; + + const handleScrollAndPageChange = ( + page: number, + handlePageChange: (p: number) => void + ) => { + handlePageChange(page); + requestAnimationFrame(() => { + scrollToElement(); + }); + }; + + const handleScrollAndPageSizeChange = ( + pageSize: number, + handlePageChange: (p: number) => void, + handlePageSizeChange: (p: number) => void + ) => { + handlePageSizeChange(pageSize); + handlePageChange(1); + requestAnimationFrame(() => { + scrollToElement(); + }); + }; + + const handleSortClick = ( + orderBy: string, + handleOrderChange: (orderBy: string, order?: Order) => void, + handlePageChange: (page: number) => void, + order?: Order + ) => { + if (order) { + handleOrderChange(orderBy, order); + handlePageChange(1); + } + }; + + const { order, orderBy, handleOrderChange, sortedData } = useOrderV2({ + data: notificationChannels, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: 'label', + }, + from: '/alerts/notification-channels', + }, + preferenceKey: 'alerts-notification-channels', + }); + + return ( + + {({ + count, + data: paginatedAndOrderedNotificationChannels, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => { + const handleTableSort = (orderBy: string, order?: Order) => + handleSortClick( + orderBy, + handleOrderChange, + handlePageChange, + order + ); + + return ( + <> + + + + + {ChannelListingTableLabelMap.map((value) => ( + + {value.colName} + {value.colName === 'Alerts' && ( + + )} + + ))} + + + + + + + + {paginatedAndOrderedNotificationChannels.map( + (channel: NotificationChannel) => ( + + ) + )} + +
+
+ + handleScrollAndPageChange(page, handlePageChange) + } + handleSizeChange={(pageSize) => { + handleScrollAndPageSizeChange( + pageSize, + handlePageChange, + handlePageSizeChange + ); + }} + page={page} + pageSize={pageSize} + sx={{ border: 0 }} + /> + + ); + }} +
+ ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx new file mode 100644 index 00000000000..0e8d18f048c --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.test.tsx @@ -0,0 +1,128 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationChannelListing } from './NotificationChannelListing'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertNotificationChannelsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, + }; +}); + +const mockNotificationChannels = notificationChannelFactory.buildList(3); + +describe('NotificationChannelListing', () => { + beforeEach(() => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: mockNotificationChannels, + error: null, + isLoading: false, + }); + }); + + it('should render the notification channel listing with search field', () => { + renderWithTheme(); + + expect( + screen.getByPlaceholderText('Search for Notification Channels') + ).toBeVisible(); + }); + + it('should render the notification channels table', () => { + renderWithTheme(); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('Alerts')).toBeVisible(); + expect(screen.getByText('Channel Type')).toBeVisible(); + expect(screen.getByText('Created By')).toBeVisible(); + expect(screen.getByText('Last Modified')).toBeVisible(); + expect(screen.getByText('Last Modified By')).toBeVisible(); + }); + + it('should render notification channel rows', () => { + renderWithTheme(); + + mockNotificationChannels.forEach((channel) => { + expect(screen.getByText(channel.label)).toBeVisible(); + }); + }); + + it('should filter notification channels based on search text', async () => { + const channels = [ + notificationChannelFactory.build({ label: 'Email Channel' }), + notificationChannelFactory.build({ label: 'Slack Channel' }), + notificationChannelFactory.build({ label: 'PagerDuty Channel' }), + ]; + + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: channels, + error: null, + isLoading: false, + }); + + renderWithTheme(); + + const searchField = screen.getByPlaceholderText( + 'Search for Notification Channels' + ); + + await userEvent.type(searchField, 'Email'); + + // Wait for debounce + await vi.waitFor(() => { + expect(screen.getByText('Email Channel')).toBeVisible(); + expect(screen.queryByText('Slack Channel')).not.toBeInTheDocument(); + expect(screen.queryByText('PagerDuty Channel')).not.toBeInTheDocument(); + }); + }); + + it('should show loading state', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: null, + error: null, + isLoading: true, + }); + + renderWithTheme(); + + screen.getByTestId('table-row-loading'); + }); + + it('should show error state', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: null, + error: [{ reason: 'Error in fetching the notification channels' }], + isLoading: false, + }); + + renderWithTheme(); + + expect( + screen.getByText('Error in fetching the notification channels') + ).toBeVisible(); + }); + + it('should render empty table when no notification channels exist', () => { + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue({ + data: [], + error: null, + isLoading: false, + }); + + renderWithTheme(); + + expect(screen.getByText('Channel Name')).toBeVisible(); + expect(screen.getByText('No data to display.')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx index 55559285b9f..4423cadcde4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelListing.tsx @@ -1,6 +1,59 @@ -import { Paper } from '@linode/ui'; +import { Box, Stack } from '@linode/ui'; import React from 'react'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; + +import { scrollToElement } from '../../Utils/AlertResourceUtils'; +import { NotificationChannelListTable } from './NotificationChannelListTable'; + export const NotificationChannelListing = () => { - return Notification Channel Page; // Temporary placeholder + const { + data: notificationChannels, + error, + isLoading, + } = useAllAlertNotificationChannelsQuery(); + + const [searchText, setSearchText] = React.useState(''); + + const topRef = React.useRef(null); + + const getNotificationChannelsList = React.useMemo(() => { + if (!notificationChannels) { + return []; + } + if (searchText) { + return notificationChannels.filter(({ label }) => + label.toLowerCase().includes(searchText.toLowerCase()) + ); + } + return notificationChannels; + }, [notificationChannels, searchText]); + + return ( + + + + + scrollToElement(topRef.current)} + /> + + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx new file mode 100644 index 00000000000..dfce582e1cd --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.test.tsx @@ -0,0 +1,146 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; +import { formatDate } from 'src/utilities/formatDate'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { NotificationChannelTableRow } from './NotificationChannelTableRow'; + +describe('NotificationChannelTableRow', () => { + it('should render a notification channel row with all fields', () => { + const updated = new Date().toISOString(); + const channel = notificationChannelFactory.build({ + alerts: [ + { id: 1, label: 'Alert 1', type: 'alerts-definitions', url: 'url1' }, + { id: 2, label: 'Alert 2', type: 'alerts-definitions', url: 'url2' }, + ], + channel_type: 'email', + created_by: 'user1', + label: 'Test Channel', + updated_by: 'user2', + updated, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Test Channel')).toBeVisible(); + expect(screen.getByText('2')).toBeVisible(); // alerts count + expect(screen.getByText('Email')).toBeVisible(); + expect(screen.getByText('user1')).toBeVisible(); + expect(screen.getByText('user2')).toBeVisible(); + expect( + screen.getByText( + formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + }) + ) + ).toBeVisible(); + }); + + it('should render channel type as Email for email type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'email', + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Email')).toBeVisible(); + }); + + it('should render channel type as Slack for slack type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'slack', + content: { + slack: { + message: 'message', + slack_channel: 'channel', + slack_webhook_url: 'url', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Slack')).toBeVisible(); + }); + + it('should render channel type as PagerDuty for pagerduty type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'pagerduty', + content: { + pagerduty: { + attributes: [], + description: 'desc', + service_api_key: 'key', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('PagerDuty')).toBeVisible(); + }); + + it('should render channel type as Webhook for webhook type', () => { + const channel = notificationChannelFactory.build({ + channel_type: 'webhook', + content: { + webhook: { + http_headers: [], + webhook_url: 'url', + }, + }, + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('Webhook')).toBeVisible(); + }); + + it('should render zero alerts count when no alerts are associated', () => { + const channel = notificationChannelFactory.build({ + alerts: [], + }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText('0')).toBeVisible(); + }); + + it('should render row with correct data-qa attribute', () => { + const channel = notificationChannelFactory.build({ id: 123 }); + + renderWithTheme( + wrapWithTableBody( + + ) + ); + + expect(screen.getByText(channel.label)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx new file mode 100644 index 00000000000..086e58d0f7a --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/NotificationChannelTableRow.tsx @@ -0,0 +1,45 @@ +import { useProfile } from '@linode/queries'; +import React from 'react'; + +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { formatDate } from 'src/utilities/formatDate'; + +import { channelTypeMap } from '../../constants'; + +import type { NotificationChannel } from '@linode/api-v4'; + +interface NotificationChannelTableRowProps { + /** + * The notification channel details used by the component to fill the row details + */ + notificationChannel: NotificationChannel; +} + +export const NotificationChannelTableRow = ( + props: NotificationChannelTableRowProps +) => { + const { notificationChannel } = props; + const { data: profile } = useProfile(); + const { id, label, channel_type, created_by, updated, updated_by, alerts } = + notificationChannel; + return ( + + {label} + {alerts.length} + {channelTypeMap[channel_type]} + {created_by} + + {formatDate(updated, { + format: 'MMM dd, yyyy, h:mm a', + timezone: profile?.timezone, + })} + + {updated_by} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts new file mode 100644 index 00000000000..070d4165f23 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/NotificationChannels/NotificationsChannelsListing/constants.ts @@ -0,0 +1,36 @@ +import type { NotificationChannel } from '@linode/api-v4'; + +type ChannelListingTableLabel = { + colName: string; + label: keyof NotificationChannel; +}; + +export const ChannelListingTableLabelMap: ChannelListingTableLabel[] = [ + { + colName: 'Channel Name', + label: 'label', + }, + { + colName: 'Alerts', + label: 'alerts', + }, + { + colName: 'Channel Type', + label: 'channel_type', + }, + { + colName: 'Created By', + label: 'created_by', + }, + { + colName: 'Last Modified', + label: 'updated', + }, + { + colName: 'Last Modified By', + label: 'updated_by', + }, +]; + +export const ChannelAlertsTooltipText = + 'The number of alert definitions associated with the notification channel.'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 51becf87d3c..95670a13860 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -146,6 +146,13 @@ export const channelTypeOptions: Item[] = [ }, ]; +export const channelTypeMap: Record = { + email: 'Email', + pagerduty: 'PagerDuty', + slack: 'Slack', + webhook: 'Webhook', +}; + export const metricOperatorTypeMap: Record = { eq: '=', gt: '>', diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 53b7dd66f8d..705f935cd84 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -3563,9 +3563,17 @@ export const handlers = [ return HttpResponse.json({}); }), http.get('*/monitor/alert-channels', () => { - return HttpResponse.json( - makeResourcePage(notificationChannelFactory.buildList(7)) + const notificationChannels = notificationChannelFactory.buildList(3); + notificationChannels.push( + notificationChannelFactory.build({ + label: 'Email test channel', + updated: '2023-11-05T04:00:00', + updated_by: 'user3', + created_by: 'admin', + }) ); + notificationChannels.push(...notificationChannelFactory.buildList(75)); + return HttpResponse.json(makeResourcePage(notificationChannels)); }), http.get('*/monitor/services', () => { const response: ServiceTypesList = {