Skip to content

Commit

Permalink
add support for access list notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
rudream authored and github-actions committed Jun 5, 2024
1 parent 10212c3 commit 95909ae
Show file tree
Hide file tree
Showing 16 changed files with 596 additions and 435 deletions.
4 changes: 2 additions & 2 deletions web/packages/teleport/src/Navigation/NavigationItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { NavigationCategory } from 'teleport/Navigation/categories';
import { NavigationItem } from 'teleport/Navigation/NavigationItem';
import { NavigationItemSize } from 'teleport/Navigation/common';
import { makeUserContext } from 'teleport/services/user';
import { NotificationKind } from 'teleport/stores/storeNotifications';
import { LocalNotificationKind } from 'teleport/services/notifications';

class MockUserFeature implements TeleportFeature {
category = NavigationCategory.Resources;
Expand Down Expand Up @@ -142,7 +142,7 @@ describe('navigation items', () => {
ctx.storeNotifications.setNotifications([
{
item: {
kind: NotificationKind.AccessList,
kind: LocalNotificationKind.AccessList,
resourceName: 'banana',
route: '',
},
Expand Down
4 changes: 2 additions & 2 deletions web/packages/teleport/src/Navigation/NavigationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import {
} from 'teleport/Navigation/common';
import useStickyClusterId from 'teleport/useStickyClusterId';
import { storageService } from 'teleport/services/storageService';
import { LocalNotificationKind } from 'teleport/services/notifications';
import { useTeleport } from 'teleport';

import { NavTitle, RecommendationStatus } from 'teleport/types';
import { NotificationKind } from 'teleport/stores/storeNotifications';

import type {
TeleportFeature,
Expand Down Expand Up @@ -196,7 +196,7 @@ export function NavigationItem(props: NavigationItemProps) {
function renderHighlightFeature(featureName: NavTitle): JSX.Element {
if (featureName === NavTitle.AccessLists) {
const hasNotifications = ctx.storeNotifications.hasNotificationsByKind(
NotificationKind.AccessList
LocalNotificationKind.AccessList
);

if (hasNotifications) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const NotificationTypes = () => {
notification={notification}
key={notification.id}
closeNotificationsList={() => null}
markNotificationAsClicked={() => null}
removeNotification={() => null}
/>
);
})}
Expand Down
92 changes: 52 additions & 40 deletions web/packages/teleport/src/Notifications/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,23 @@ import useStickyClusterId from 'teleport/useStickyClusterId';
import { useTeleport } from '..';

import { NotificationContent } from './notificationContentFactory';

import { View } from './Notifications';

export function Notification({
notification,
view = 'All',
closeNotificationsList,
removeNotification,
markNotificationAsClicked,
}: {
notification: NotificationType;
view?: View;
closeNotificationsList: () => void;
removeNotification: (notificationId: string) => void;
markNotificationAsClicked: (notificationId: string) => void;
}) {
const ctx = useTeleport();
const { clusterId } = useStickyClusterId();
const [clicked, setClicked] = useState(notification.clicked);

const content = ctx.notificationContentFactory(notification);

Expand All @@ -73,29 +75,38 @@ export function Notification({
notificationState: NotificationState.CLICKED,
})
.then(res => {
setClicked(true);
markNotificationAsClicked(notification.id);
return res;
})
);

const [hideNotificationAttempt, hideNotification] = useAsync(() => {
return ctx.notificationService.upsertNotificationState(clusterId, {
notificationId: notification.id,
notificationState: NotificationState.DISMISSED,
});
return ctx.notificationService
.upsertNotificationState(clusterId, {
notificationId: notification.id,
notificationState: NotificationState.DISMISSED,
})
.then(() => {
removeNotification(notification.id);
});
});

function onMarkAsClicked() {
if (notification.localNotification) {
ctx.storeNotifications.markNotificationAsClicked(notification.id);
markNotificationAsClicked(notification.id);
return;
}
markAsClicked();
}

// Whether to show the text content dialog. This is only ever used for user-created notifications which only contain informational text
// and don't redirect to any page.
const [showTextContentDialog, setShowTextContentDialog] = useState(false);

// If the notification is unsupported or hidden, or if the view is "Unread" and the notification has been read,
// it should not be shown.
if (
!content ||
hideNotificationAttempt.status === 'success' ||
hideNotificationAttempt.status === 'processing' ||
(view === 'Unread' && clicked)
) {
if (!content || (view === 'Unread' && notification.clicked)) {
return null;
}

Expand All @@ -119,29 +130,26 @@ export function Notification({
const formattedDate = formatDate(notification.createdDate);

function onNotificationClick(e: React.MouseEvent<HTMLElement>) {
markAsClicked();
// Prevents this from being triggered when the user is just clicking away from
// an open "mark as read/hide this notification" menu popover.
if (e.currentTarget.contains(e.target as HTMLElement)) {
if (content.kind === 'text') {
setShowTextContentDialog(true);
return;
}
onMarkAsClicked();
closeNotificationsList();
history.push(content.redirectRoute);
}
}

const isClicked =
clicked ||
markAsClickedAttempt.status === 'processing' ||
(markAsClickedAttempt.status === 'success' &&
markAsClickedAttempt.data.notificationState ===
NotificationState.CLICKED);
notification.clicked || markAsClickedAttempt.status === 'processing';

return (
<>
<Container
data-testid="notification-item"
clicked={isClicked}
onClick={onNotificationClick}
className="notification"
Expand All @@ -158,7 +166,7 @@ export function Notification({
<ContentBody>
<Text>{content.title}</Text>
{content.kind === 'redirect' && content.QuickAction && (
<content.QuickAction markAsClicked={markAsClicked} />
<content.QuickAction markAsClicked={onMarkAsClicked} />
)}
{hideNotificationAttempt.status === 'error' && (
<Text typography="subtitle3" color="error.main">
Expand All @@ -174,30 +182,34 @@ export function Notification({
)}
</ContentBody>
<SideContent>
<Text typography="subtitle3">{formattedDate}</Text>
<MenuIcon
menuProps={{
anchorOrigin: { vertical: 'bottom', horizontal: 'right' },
transformOrigin: { vertical: 'top', horizontal: 'right' },
backdropProps: { className: IGNORE_CLICK_CLASSNAME },
}}
buttonIconProps={{ style: { borderRadius: '4px' } }}
>
{!isClicked && (
{!content?.hideDate && (
<Text typography="subtitle3">{formattedDate}</Text>
)}
{!notification.localNotification && (
<MenuIcon
menuProps={{
anchorOrigin: { vertical: 'bottom', horizontal: 'right' },
transformOrigin: { vertical: 'top', horizontal: 'right' },
backdropProps: { className: IGNORE_CLICK_CLASSNAME },
}}
buttonIconProps={{ style: { borderRadius: '4px' } }}
>
{!isClicked && (
<MenuItem
onClick={onMarkAsClicked}
className={IGNORE_CLICK_CLASSNAME}
>
Mark as read
</MenuItem>
)}
<MenuItem
onClick={markAsClicked}
onClick={hideNotification}
className={IGNORE_CLICK_CLASSNAME}
>
Mark as read
Hide this notification
</MenuItem>
)}
<MenuItem
onClick={hideNotification}
className={IGNORE_CLICK_CLASSNAME}
>
Hide this notification
</MenuItem>
</MenuIcon>
</MenuIcon>
)}
</SideContent>
</ContentContainer>
</Container>
Expand Down
122 changes: 122 additions & 0 deletions web/packages/teleport/src/Notifications/Notifications.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import { subMinutes, subSeconds } from 'date-fns';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router';
import { render, screen } from 'design/utils/testing';

import { createTeleportContext } from 'teleport/mocks/contexts';
import { LayoutContextProvider } from 'teleport/Main/LayoutContext';

import { FeaturesContextProvider } from 'teleport/FeaturesContext';
import { getOSSFeatures } from 'teleport/features';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
import TeleportContext from 'teleport/teleportContext';
import { NotificationSubKind } from 'teleport/services/notifications';

import { Notifications } from './Notifications';

test('notification bell with notifications', async () => {
const ctx = createTeleportContext();

jest.spyOn(ctx.notificationService, 'fetchNotifications').mockResolvedValue({
nextKey: '',
userLastSeenNotification: subMinutes(Date.now(), 12), // 12 minutes ago
notifications: [
{
id: '1',
title: 'Example notification 1',
subKind: NotificationSubKind.UserCreatedInformational,
createdDate: subSeconds(Date.now(), 15), // 15 seconds ago
clicked: false,
labels: [
{
name: 'text-content',
value: 'This is the text content of the notification.',
},
],
},
{
id: '2',
title: 'Example notification 2',
subKind: NotificationSubKind.UserCreatedInformational,
createdDate: subSeconds(Date.now(), 50), // 50 seconds ago
clicked: false,
labels: [
{
name: 'text-content',
value: 'This is the text content of the notification.',
},
],
},
],
});

jest
.spyOn(ctx.notificationService, 'upsertLastSeenNotificationTime')
.mockResolvedValue({
time: new Date(),
});

render(renderNotifications(ctx));

await screen.findByTestId('tb-notifications-badge');

expect(screen.getByTestId('tb-notifications')).toBeInTheDocument();

// Expect there to be 2 notifications.
expect(screen.getByTestId('tb-notifications-badge')).toHaveTextContent('2');
expect(screen.queryAllByTestId('notification-item')).toHaveLength(2);
});

test('notification bell with no notifications', async () => {
const ctx = createTeleportContext();
jest.spyOn(ctx.notificationService, 'fetchNotifications').mockResolvedValue({
nextKey: '',
userLastSeenNotification: subMinutes(Date.now(), 12), // 12 minutes ago
notifications: [],
});

jest
.spyOn(ctx.notificationService, 'upsertLastSeenNotificationTime')
.mockResolvedValue({
time: new Date(),
});

render(renderNotifications(ctx));

await screen.findByText(/you currently have no notifications/i);

expect(screen.queryByTestId('notification-item')).not.toBeInTheDocument();
});

const renderNotifications = (ctx: TeleportContext) => {
return (
<Router history={createMemoryHistory()}>
<LayoutContextProvider>
<TeleportContextProvider ctx={ctx}>
<FeaturesContextProvider value={getOSSFeatures()}>
<Notifications />
</FeaturesContextProvider>
</TeleportContextProvider>
</LayoutContextProvider>
</Router>
);
};
Loading

0 comments on commit 95909ae

Please sign in to comment.