feat(notifications): track toast notifications [EE-4132] (#7711)

* feat(notifications): track toast notifications [EE-4132]

* suggested refactoring

* fix failing test

* remove duplicate styles

* applying spacing to context icon
pull/7727/head
itsconquest 2022-09-23 17:17:44 +12:00 committed by GitHub
parent 4e20d70a99
commit 648c1db437
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 608 additions and 59 deletions

View File

@ -1,6 +1,14 @@
import _ from 'lodash';
import toastr from 'toastr';
import sanitize from 'sanitize-html';
import jwtDecode from 'jwt-decode';
import { v4 as uuid } from 'uuid';
import { get as localStorageGet } from '@/portainer/hooks/useLocalStorage';
import { notificationsStore } from '@/react/portainer/notifications/notifications-store';
import { ToastNotification } from '@/react/portainer/notifications/types';
const { addNotification } = notificationsStore.getState();
toastr.options = {
timeOut: 3000,
@ -25,15 +33,18 @@ toastr.options = {
};
export function notifySuccess(title: string, text: string) {
saveNotification(title, text, 'success');
toastr.success(sanitize(_.escape(text)), sanitize(_.escape(title)));
}
export function notifyWarning(title: string, text: string) {
saveNotification(title, text, 'warning');
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
}
export function notifyError(title: string, e?: Error, fallbackText = '') {
const msg = pickErrorMsg(e) || fallbackText;
saveNotification(title, msg, 'error');
// eslint-disable-next-line no-console
console.error(e);
@ -86,3 +97,20 @@ function pickErrorMsg(e?: Error) {
return msg;
}
function saveNotification(title: string, text: string, type: string) {
const notif: ToastNotification = {
id: uuid(),
title,
details: text,
type,
timeStamp: new Date(),
};
const jwt = localStorageGet('JWT', '');
if (jwt !== '') {
const { id } = jwtDecode(jwt) as { id: number };
if (id) {
addNotification(id, notif);
}
}
}

View File

@ -1,9 +1,10 @@
import angular from 'angular';
import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
import authLogsViewModule from './auth-logs-view';
import activityLogsViewModule from './activity-logs-view';
export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).config(config).name;
export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).component('notifications', NotificationsViewAngular).config(config).name;
/* @ngInject */
function config($stateRegistryProvider) {
@ -26,4 +27,14 @@ function config($stateRegistryProvider) {
},
},
});
$stateRegistryProvider.register({
name: 'portainer.notifications',
url: '/notifications',
views: {
'content@': {
component: 'notifications',
},
},
});
}

View File

@ -3,6 +3,7 @@ import clsx from 'clsx';
import { getDocURL } from '@@/PageHeader/ContextHelp/docURLs';
import headerStyles from '../HeaderTitle.module.css';
import './ContextHelp.css';
export function ContextHelp() {
@ -12,16 +13,19 @@ export function ContextHelp() {
}
return (
<div
className={clsx(
'menu-icon',
'icon-badge text-lg !p-2 mr-1',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
title="Help"
>
<HelpCircle className="feather" onClick={onHelpClick} />
<div className={clsx(headerStyles.menuButton)}>
<div
className={clsx(
headerStyles.menuIcon,
'menu-icon',
'icon-badge text-lg !p-2 mr-1',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
title="Help"
>
<HelpCircle className="feather" onClick={onHelpClick} />
</div>
</div>
);
}

View File

@ -2,20 +2,21 @@
border: 0px;
font-size: 17px;
background: none;
margin-right: 15px;
margin-right: 8px;
display: flex;
align-items: center;
}
.menu-icon {
background: var(--user-menu-icon-color);
position: relative;
}
.menu-list {
background: var(--bg-dropdown-menu-color);
border-radius: 8px;
border: 1px solid var(--ui-gray-5) !important;
width: 180px;
min-width: 180px;
padding: 5px !important;
box-shadow: 0 6px 12px rgb(0 0 0 / 18%);
@apply th-dark:!border-none;

View File

@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react';
import { ContextHelp } from '@@/PageHeader/ContextHelp';
import { useHeaderContext } from './HeaderContainer';
import { NotificationsMenu } from './NotificationsMenu';
import { UserMenu } from './UserMenu';
interface Props {
@ -20,7 +21,8 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
</div>
{children && <span>{children}</span>}
</div>
<div className="flex items-center gap-4">
<div className="flex items-end">
<NotificationsMenu />
<ContextHelp />
{!window.ddExtension && <UserMenu />}
</div>

View File

@ -0,0 +1,53 @@
.badge {
position: absolute;
top: 8px;
right: 10px;
width: 6px;
height: 6px;
background: red;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
}
.notification-container {
display: flex;
border-bottom: 1px solid var(--ui-gray-4);
padding: 5px 10px;
margin-bottom: 15px;
}
.item-last {
margin-left: auto;
}
.container {
display: flex;
}
.notificationIcon {
flex-basis: 5rem;
width: 5rem;
}
.notificationBody {
flex-basis: 30rem;
}
.deleteButton {
flex-basis: 5rem;
}
.container > div {
padding: 0px 10px;
margin: auto;
}
.notification-title {
font-weight: 700;
}
.notification-link {
border-top: 1px solid var(--ui-gray-4);
padding: 10px;
text-align: center;
}

View File

@ -0,0 +1,206 @@
import clsx from 'clsx';
import {
Menu,
MenuButton,
MenuList,
MenuLink as ReachMenuLink,
} from '@reach/menu-button';
import { UISrefProps, useSref } from '@uirouter/react';
import Moment from 'moment';
import { useEffect, useState } from 'react';
import { useStore } from 'zustand';
import { AutomationTestingProps } from '@/types';
import { useUser } from '@/portainer/hooks/useUser';
import { ToastNotification } from '@/react/portainer/notifications/types';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
import { notificationsStore } from '../../portainer/notifications/notifications-store';
import headerStyles from './HeaderTitle.module.css';
import notificationStyles from './NotificationsMenu.module.css';
export function NotificationsMenu() {
const notificationsStoreState = useStore(notificationsStore);
const { removeNotification } = notificationsStoreState;
const { clearUserNotifications } = notificationsStoreState;
const { user } = useUser();
const userNotifications: ToastNotification[] = useStore(
notificationsStore,
(state) => state.userNotifications[user.Id]
);
const [badge, setBadge] = useState(false);
useEffect(() => {
if (userNotifications?.length > 0) {
setBadge(true);
} else {
setBadge(false);
}
}, [userNotifications]);
return (
<Menu>
<MenuButton
className={clsx(
'ml-auto flex items-center gap-1 self-start',
headerStyles.menuButton
)}
data-cy="notificationsMenu-button"
aria-label="Notifications menu toggle"
>
<div
className={clsx(
headerStyles.menuIcon,
'icon-badge text-lg !p-2 mr-1',
'text-gray-8',
'th-dark:text-gray-warm-7'
)}
>
<Icon icon="bell" feather />
<span className={badge ? clsx(notificationStyles.badge) : ''} />
</div>
</MenuButton>
<MenuList
className={headerStyles.menuList}
aria-label="Notifications Menu"
data-cy="notificationsMenu"
>
<div>
<div className={clsx(notificationStyles.notificationContainer)}>
<div>
<h4>Notifications</h4>
</div>
<div className={clsx(notificationStyles.itemLast)}>
{userNotifications?.length > 0 && (
<Button
color="none"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onClear();
}}
data-cy="notification-deleteButton"
>
Clear all
</Button>
)}
</div>
</div>
</div>
{userNotifications?.length > 0 ? (
<>
{userNotifications.map((notification) => (
<MenuLink
to="portainer.notifications"
params={{ notificationFrom: notification.timeStamp }}
notification={notification}
key={notification.id}
onDelete={() => onDelete(notification.id)}
/>
))}
<div className={clsx(notificationStyles.notificationLink)}>
<Link to="portainer.notifications">View all notifications</Link>
</div>
</>
) : (
<div>
<Icon icon="bell" feather size="xl" />
<div>
<p>You have no notifications yet.</p>
</div>
</div>
)}
</MenuList>
</Menu>
);
function onDelete(notificationId: string) {
removeNotification(user.Id, notificationId);
}
function onClear() {
clearUserNotifications(user.Id);
}
}
interface MenuLinkProps extends AutomationTestingProps, UISrefProps {
notification: ToastNotification;
onDelete: () => void;
}
function MenuLink({
to,
params,
options,
notification,
onDelete,
}: MenuLinkProps) {
const anchorProps = useSref(to, params, options);
return (
<ReachMenuLink href={anchorProps.href} className={headerStyles.menuLink}>
<div className={clsx(notificationStyles.container)}>
<div className={clsx(notificationStyles.notificationIcon)}>
{notification.type === 'success' ? (
<Icon icon="check-circle" feather size="lg" mode="success" />
) : (
<Icon icon="alert-circle" feather size="lg" mode="danger" />
)}
</div>
<div className={clsx(notificationStyles.notificationBody)}>
<p className={clsx(notificationStyles.notificationTitle)}>
{notification.title}
</p>
<p>{notification.details}</p>
<p className="small text-muted">
{formatTime(notification.timeStamp)}
</p>
</div>
<div className={clsx(notificationStyles.deleteButton)}>
<Button
color="none"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDelete();
}}
data-cy="notification-deleteButton"
size="large"
>
<Icon icon="trash-2" feather />
</Button>
</div>
</div>
</ReachMenuLink>
);
}
function formatTime(timeCreated: Date) {
const timeStamp = new Date(timeCreated).valueOf().toString();
const diff = Math.floor((Date.now() - parseInt(timeStamp, 10)) / 1000);
if (diff <= 86400) {
let interval = Math.floor(diff / 3600);
if (interval >= 1) {
return `${interval} hours ago`;
}
interval = Math.floor(diff / 60);
if (interval >= 1) {
return `${interval} min ago`;
}
}
if (diff > 86400) {
const formatDate = Moment(timeCreated).format('YYYY-MM-DD h:mm:ss');
return formatDate;
}
return 'Just now';
}

View File

@ -0,0 +1,79 @@
import { Bell, Trash2 } from 'react-feather';
import { useStore } from 'zustand';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { react2angular } from '@/react-tools/react2angular';
import { useUser } from '@/portainer/hooks/useUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { PageHeader } from '@@/PageHeader';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { notificationsStore } from './notifications-store';
import { ToastNotification } from './types';
import { columns } from './columns';
import { createStore } from './datatable-store';
const storageKey = 'notifications-list';
const useSettingsStore = createStore(storageKey);
export function NotificationsView() {
const settingsStore = useSettingsStore();
const { user } = useUser();
const userNotifications: ToastNotification[] = useStore(
notificationsStore,
(state) => state.userNotifications[user.Id]
);
const breadcrumbs = 'Notifications';
return (
<>
<PageHeader title="Notifications" breadcrumbs={breadcrumbs} reload />
<Datatable
columns={columns}
titleOptions={{
title: 'Notifications',
icon: Bell,
}}
dataset={userNotifications}
settingsStore={settingsStore}
storageKey="notifications"
emptyContentLabel="No notifications found"
totalCount={userNotifications.length}
renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} />
)}
/>
</>
);
}
function TableActions({ selectedRows }: { selectedRows: ToastNotification[] }) {
const { user } = useUser();
const notificationsStoreState = useStore(notificationsStore);
return (
<Button
icon={Trash2}
color="dangerlight"
onClick={() => handleRemove()}
disabled={selectedRows.length === 0}
>
Remove
</Button>
);
function handleRemove() {
const { removeNotifications } = notificationsStoreState;
const ids = selectedRows.map((row) => row.id);
removeNotifications(user.Id, ids);
}
}
export const NotificationsViewAngular = react2angular(
withUIRouter(withReactQuery(withCurrentUser(NotificationsView))),
[]
);

View File

@ -0,0 +1,11 @@
import { Column } from 'react-table';
import { ToastNotification } from '../types';
export const details: Column<ToastNotification> = {
Header: 'Details',
accessor: 'details',
id: 'details',
disableFilters: true,
canHide: true,
};

View File

@ -0,0 +1,6 @@
import { type } from './type';
import { title } from './title';
import { details } from './details';
import { time } from './time';
export const columns = [type, title, details, time];

View File

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { isoDate } from '@/portainer/filters/filters';
import { ToastNotification } from '../types';
export const time: Column<ToastNotification> = {
Header: 'Time',
accessor: (row) => (row.timeStamp ? isoDate(row.timeStamp) : '-'),
id: 'time',
disableFilters: true,
canHide: true,
};

View File

@ -0,0 +1,11 @@
import { Column } from 'react-table';
import { ToastNotification } from '../types';
export const title: Column<ToastNotification> = {
Header: 'Title',
accessor: 'title',
id: 'title',
disableFilters: true,
canHide: true,
};

View File

@ -0,0 +1,11 @@
import { Column } from 'react-table';
import { ToastNotification } from '../types';
export const type: Column<ToastNotification> = {
Header: 'Type',
accessor: (row) => row.type.charAt(0).toUpperCase() + row.type.slice(1),
id: 'type',
disableFilters: true,
canHide: true,
};

View File

@ -0,0 +1,36 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

@ -0,0 +1,64 @@
import create from 'zustand/vanilla';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import { ToastNotification } from './types';
interface NotificationsState {
userNotifications: Record<string, ToastNotification[]>;
addNotification: (userId: number, notification: ToastNotification) => void;
removeNotification: (userId: number, notificationId: string) => void;
removeNotifications: (userId: number, notifications: string[]) => void;
clearUserNotifications: (userId: number) => void;
}
export const notificationsStore = create<NotificationsState>()(
persist(
(set) => ({
userNotifications: {},
addNotification: (userId: number, notification: ToastNotification) => {
set((state) => ({
userNotifications: {
...state.userNotifications,
[userId]: [
...(state.userNotifications[userId] || []),
notification,
],
},
}));
},
removeNotification: (userId: number, notificationId: string) => {
set((state) => ({
userNotifications: {
...state.userNotifications,
[userId]: state.userNotifications[userId].filter(
(notif) => notif.id !== notificationId
),
},
}));
},
removeNotifications: (userId: number, notificationIds: string[]) => {
set((state) => ({
userNotifications: {
...state.userNotifications,
[userId]: state.userNotifications[userId].filter(
(notification) => !notificationIds.includes(notification.id)
),
},
}));
},
clearUserNotifications: (userId: number) => {
set((state) => ({
userNotifications: {
...state.userNotifications,
[userId]: [],
},
}));
},
}),
{
name: keyBuilder('notifications'),
}
)
);

View File

@ -0,0 +1,7 @@
export type ToastNotification = {
id: string;
title: string;
details: string;
type: string;
timeStamp: Date;
};

View File

@ -5,6 +5,7 @@ import {
HardDrive,
Radio,
FileText,
Bell,
} from 'react-feather';
import { usePublicSettings } from '@/react/portainer/settings/queries';
@ -113,50 +114,57 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
data-cy="portainerSidebar-activityLogs"
/>
</SidebarItem>
<SidebarItem
to="portainer.settings"
label="Settings"
icon={Settings}
data-cy="portainerSidebar-settings"
>
{!window.ddExtension && (
<SidebarItem
to="portainer.settings.authentication"
label="Authentication"
data-cy="portainerSidebar-authentication"
/>
)}
{process.env.PORTAINER_EDITION !== 'CE' && (
<SidebarItem
to="portainer.settings.cloud"
label="Cloud"
data-cy="portainerSidebar-cloud"
/>
)}
<SidebarItem
to="portainer.settings.edgeCompute"
label="Edge Compute"
data-cy="portainerSidebar-edgeCompute"
/>
<SidebarItem.Wrapper label="Help / About">
<a
href={
process.env.PORTAINER_EDITION === 'CE'
? 'https://www.portainer.io/community_help'
: 'https://documentation.portainer.io/r/business-support'
}
target="_blank"
rel="noreferrer"
className="px-3 rounded flex h-8 items-center"
>
Help / About
</a>
</SidebarItem.Wrapper>
</SidebarItem>
</>
)}
<SidebarItem
to="portainer.notifications"
icon={Bell}
label="Notifications"
data-cy="portainerSidebar-notifications"
/>
{isAdmin && (
<SidebarItem
to="portainer.settings"
label="Settings"
icon={Settings}
data-cy="portainerSidebar-settings"
>
{!window.ddExtension && (
<SidebarItem
to="portainer.settings.authentication"
label="Authentication"
data-cy="portainerSidebar-authentication"
/>
)}
{process.env.PORTAINER_EDITION !== 'CE' && (
<SidebarItem
to="portainer.settings.cloud"
label="Cloud"
data-cy="portainerSidebar-cloud"
/>
)}
<SidebarItem
to="portainer.settings.edgeCompute"
label="Edge Compute"
data-cy="portainerSidebar-edgeCompute"
/>
<SidebarItem.Wrapper label="Help / About">
<a
href={
process.env.PORTAINER_EDITION === 'CE'
? 'https://www.portainer.io/community_help'
: 'https://documentation.portainer.io/r/business-support'
}
target="_blank"
rel="noreferrer"
className="px-3 rounded flex h-8 items-center"
>
Help / About
</a>
</SidebarItem.Wrapper>
</SidebarItem>
)}
</SidebarSection>
);
}

View File

@ -46,9 +46,7 @@ export function Sidebar() {
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
{(isAdmin || isTeamLeader) && (
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
)}
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
</ul>
</div>