mirror of https://github.com/portainer/portainer
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 iconpull/7727/head
parent
4e20d70a99
commit
648c1db437
|
@ -1,6 +1,14 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import toastr from 'toastr';
|
import toastr from 'toastr';
|
||||||
import sanitize from 'sanitize-html';
|
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 = {
|
toastr.options = {
|
||||||
timeOut: 3000,
|
timeOut: 3000,
|
||||||
|
@ -25,15 +33,18 @@ toastr.options = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function notifySuccess(title: string, text: string) {
|
export function notifySuccess(title: string, text: string) {
|
||||||
|
saveNotification(title, text, 'success');
|
||||||
toastr.success(sanitize(_.escape(text)), sanitize(_.escape(title)));
|
toastr.success(sanitize(_.escape(text)), sanitize(_.escape(title)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyWarning(title: string, text: string) {
|
export function notifyWarning(title: string, text: string) {
|
||||||
|
saveNotification(title, text, 'warning');
|
||||||
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
||||||
const msg = pickErrorMsg(e) || fallbackText;
|
const msg = pickErrorMsg(e) || fallbackText;
|
||||||
|
saveNotification(title, msg, 'error');
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -86,3 +97,20 @@ function pickErrorMsg(e?: Error) {
|
||||||
|
|
||||||
return msg;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
|
||||||
import authLogsViewModule from './auth-logs-view';
|
import authLogsViewModule from './auth-logs-view';
|
||||||
import activityLogsViewModule from './activity-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 */
|
/* @ngInject */
|
||||||
function config($stateRegistryProvider) {
|
function config($stateRegistryProvider) {
|
||||||
|
@ -26,4 +27,14 @@ function config($stateRegistryProvider) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$stateRegistryProvider.register({
|
||||||
|
name: 'portainer.notifications',
|
||||||
|
url: '/notifications',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'notifications',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import clsx from 'clsx';
|
||||||
|
|
||||||
import { getDocURL } from '@@/PageHeader/ContextHelp/docURLs';
|
import { getDocURL } from '@@/PageHeader/ContextHelp/docURLs';
|
||||||
|
|
||||||
|
import headerStyles from '../HeaderTitle.module.css';
|
||||||
import './ContextHelp.css';
|
import './ContextHelp.css';
|
||||||
|
|
||||||
export function ContextHelp() {
|
export function ContextHelp() {
|
||||||
|
@ -12,8 +13,10 @@ export function ContextHelp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={clsx(headerStyles.menuButton)}>
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
headerStyles.menuIcon,
|
||||||
'menu-icon',
|
'menu-icon',
|
||||||
'icon-badge text-lg !p-2 mr-1',
|
'icon-badge text-lg !p-2 mr-1',
|
||||||
'text-gray-8',
|
'text-gray-8',
|
||||||
|
@ -23,5 +26,6 @@ export function ContextHelp() {
|
||||||
>
|
>
|
||||||
<HelpCircle className="feather" onClick={onHelpClick} />
|
<HelpCircle className="feather" onClick={onHelpClick} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,20 +2,21 @@
|
||||||
border: 0px;
|
border: 0px;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
background: none;
|
background: none;
|
||||||
margin-right: 15px;
|
margin-right: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
background: var(--user-menu-icon-color);
|
background: var(--user-menu-icon-color);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list {
|
.menu-list {
|
||||||
background: var(--bg-dropdown-menu-color);
|
background: var(--bg-dropdown-menu-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--ui-gray-5) !important;
|
border: 1px solid var(--ui-gray-5) !important;
|
||||||
width: 180px;
|
min-width: 180px;
|
||||||
padding: 5px !important;
|
padding: 5px !important;
|
||||||
box-shadow: 0 6px 12px rgb(0 0 0 / 18%);
|
box-shadow: 0 6px 12px rgb(0 0 0 / 18%);
|
||||||
@apply th-dark:!border-none;
|
@apply th-dark:!border-none;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react';
|
||||||
import { ContextHelp } from '@@/PageHeader/ContextHelp';
|
import { ContextHelp } from '@@/PageHeader/ContextHelp';
|
||||||
|
|
||||||
import { useHeaderContext } from './HeaderContainer';
|
import { useHeaderContext } from './HeaderContainer';
|
||||||
|
import { NotificationsMenu } from './NotificationsMenu';
|
||||||
import { UserMenu } from './UserMenu';
|
import { UserMenu } from './UserMenu';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -20,7 +21,8 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
|
||||||
</div>
|
</div>
|
||||||
{children && <span>{children}</span>}
|
{children && <span>{children}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-end">
|
||||||
|
<NotificationsMenu />
|
||||||
<ContextHelp />
|
<ContextHelp />
|
||||||
{!window.ddExtension && <UserMenu />}
|
{!window.ddExtension && <UserMenu />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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';
|
||||||
|
}
|
|
@ -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))),
|
||||||
|
[]
|
||||||
|
);
|
|
@ -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,
|
||||||
|
};
|
|
@ -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];
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
|
@ -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),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -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'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type ToastNotification = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
details: string;
|
||||||
|
type: string;
|
||||||
|
timeStamp: Date;
|
||||||
|
};
|
|
@ -5,6 +5,7 @@ import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Radio,
|
Radio,
|
||||||
FileText,
|
FileText,
|
||||||
|
Bell,
|
||||||
} from 'react-feather';
|
} from 'react-feather';
|
||||||
|
|
||||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
@ -113,7 +114,15 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
data-cy="portainerSidebar-activityLogs"
|
data-cy="portainerSidebar-activityLogs"
|
||||||
/>
|
/>
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<SidebarItem
|
||||||
|
to="portainer.notifications"
|
||||||
|
icon={Bell}
|
||||||
|
label="Notifications"
|
||||||
|
data-cy="portainerSidebar-notifications"
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="portainer.settings"
|
to="portainer.settings"
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
@ -155,7 +164,6 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
</a>
|
</a>
|
||||||
</SidebarItem.Wrapper>
|
</SidebarItem.Wrapper>
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
);
|
);
|
||||||
|
|
|
@ -46,9 +46,7 @@ export function Sidebar() {
|
||||||
|
|
||||||
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
|
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
|
||||||
|
|
||||||
{(isAdmin || isTeamLeader) && (
|
|
||||||
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
|
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue