Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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": Changed
---

Renamed updated_at, created_at to updated,created in NotificationChannelBase interface ([#13193](https://github.com/linode/manager/pull/13193))
4 changes: 2 additions & 2 deletions packages/api-v4/src/cloudpulse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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))
4 changes: 2 additions & 2 deletions packages/manager/src/factories/cloudpulse/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Original file line number Diff line number Diff line change
@@ -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(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[]}
scrollToElement={mockScrollToElement}
/>
);

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(
<NotificationChannelListTable
error={[{ reason: 'Error in fetching the notification channels' }]}
isLoading={false}
notificationChannels={[]}
scrollToElement={mockScrollToElement}
/>
);

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(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

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(
<NotificationChannelListTable
isLoading={true}
notificationChannels={[]}
scrollToElement={mockScrollToElement}
/>
);

screen.getByTestId('table-row-loading');
});

it('should render tooltip for Alerts column', async () => {
renderWithTheme(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[]}
scrollToElement={mockScrollToElement}
/>
);

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(
<NotificationChannelListTable
isLoading={false}
notificationChannels={channels}
scrollToElement={mockScrollToElement}
/>
);

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(
<NotificationChannelListTable
isLoading={false}
notificationChannels={[channel]}
scrollToElement={mockScrollToElement}
/>
);

expect(screen.getByText('3')).toBeVisible();
});

it('should render pagination footer', () => {
const channels = notificationChannelFactory.buildList(30);

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

screen.getByRole('button', { name: /next/i });
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<Paginate data={sortedData ?? []}>
{({
count,
data: paginatedAndOrderedNotificationChannels,
handlePageChange,
handlePageSizeChange,
page,
pageSize,
}) => {
const handleTableSort = (orderBy: string, order?: Order) =>
handleSortClick(
orderBy,
handleOrderChange,
handlePageChange,
order
);

return (
<>
<GridLegacy sx={{ marginTop: 2 }}>
<Table
colCount={7}
data-qa="notification-channels-table"
size="small"
>
<TableHead>
<TableRow>
{ChannelListingTableLabelMap.map((value) => (
<TableSortCell
active={orderBy === value.label}
direction={order}
handleClick={handleTableSort}
key={value.label}
label={value.label}
noWrap
>
{value.colName}
{value.colName === 'Alerts' && (
<TooltipIcon
status="info"
sxTooltipIcon={{ margin: 0, padding: 0 }}
text={ChannelAlertsTooltipText}
/>
)}
</TableSortCell>
))}
<TableCell />
</TableRow>
</TableHead>
<TableBody>
<TableContentWrapper
error={_error}
length={paginatedAndOrderedNotificationChannels.length}
loading={isLoading}
loadingProps={{ columns: 7 }}
/>
</TableBody>
<TableBody>
{paginatedAndOrderedNotificationChannels.map(
(channel: NotificationChannel) => (
<NotificationChannelTableRow
key={channel.id}
notificationChannel={channel}
/>
)
)}
</TableBody>
</Table>
</GridLegacy>
<PaginationFooter
count={count}
eventCategory="Notification Channels Table"
handlePageChange={(page) =>
handleScrollAndPageChange(page, handlePageChange)
}
handleSizeChange={(pageSize) => {
handleScrollAndPageSizeChange(
pageSize,
handlePageChange,
handlePageSizeChange
);
}}
page={page}
pageSize={pageSize}
sx={{ border: 0 }}
/>
</>
);
}}
</Paginate>
);
}
);
Loading