Frontend: Add notification persistence behind feature flag (#47871)

This commit is contained in:
kay delaney 2022-04-20 10:42:32 +01:00 committed by GitHub
parent be3f52abb1
commit c48d8d1d48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 529 additions and 80 deletions

View File

@ -54,4 +54,5 @@ export interface FeatureToggles {
storageLocalUpload?: boolean;
azureMonitorResourcePickerForMetrics?: boolean;
explore2Dashboard?: boolean;
persistNotifications?: boolean;
}

View File

@ -5,8 +5,8 @@ import { eventFactory } from '../events/eventFactory';
import { BusEventBase, BusEventWithPayload } from '../events/types';
import { DataHoverPayload } from '../events';
export type AlertPayload = [string, string?];
export type AlertErrorPayload = [string, (string | Error)?];
export type AlertPayload = [string, string?, string?];
export type AlertErrorPayload = [string, (string | Error)?, string?];
export const AppEvents = {
alertSuccess: eventFactory<AlertPayload>('alert-success'),

View File

@ -22,7 +22,7 @@ export interface Props extends HTMLAttributes<HTMLDivElement> {
topSpacing?: number;
}
function getIconFromSeverity(severity: AlertVariant): string {
export function getIconFromSeverity(severity: AlertVariant): string {
switch (severity) {
case 'error':
case 'warning':
@ -150,7 +150,7 @@ const getStyles = (
color: ${theme.colors.text.secondary};
padding-top: ${theme.spacing(1)};
max-height: 50vh;
overflow-y: scroll;
overflow-y: auto;
`,
buttonWrapper: css`
padding: ${theme.spacing(1)};

View File

@ -38,6 +38,12 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink {
},
}
if hs.Features.IsEnabled(featuremgmt.FlagPersistNotifications) {
children = append(children, &dtos.NavLink{
Text: "Notifications", Id: "notifications", Url: hs.Cfg.AppSubURL + "/notifications", Icon: "bell",
})
}
if setting.AddChangePasswordLink() {
children = append(children, &dtos.NavLink{
Text: "Change password", Id: "change-password", Url: hs.Cfg.AppSubURL + "/profile/password",

View File

@ -213,5 +213,11 @@ var (
State: FeatureStateBeta,
FrontendOnly: true,
},
{
Name: "persistNotifications",
Description: "PoC Notifications page",
State: FeatureStateAlpha,
FrontendOnly: true,
},
}
)

View File

@ -158,4 +158,8 @@ const (
// FlagExplore2Dashboard
// Experimental Explore to Dashboard workflow
FlagExplore2Dashboard = "explore2Dashboard"
// FlagPersistNotifications
// PoC Notifications page
FlagPersistNotifications = "persistNotifications"
)

View File

@ -1,3 +1,3 @@
import { clearAppNotification, notifyApp } from '../reducers/appNotification';
import { hideAppNotification, notifyApp } from '../reducers/appNotification';
import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel';
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, clearAppNotification };
export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification };

View File

@ -1,36 +1,47 @@
import React, { Component } from 'react';
import { AppNotification } from 'app/types';
import { Alert } from '@grafana/ui';
import React from 'react';
import { useEffectOnce } from 'react-use';
import { css } from '@emotion/css';
import { Alert, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { AppNotification, timeoutMap } from 'app/types';
interface Props {
appNotification: AppNotification;
onClearNotification: (id: string) => void;
}
export default class AppNotificationItem extends Component<Props> {
shouldComponentUpdate(nextProps: Props) {
return this.props.appNotification.id !== nextProps.appNotification.id;
}
export default function AppNotificationItem({ appNotification, onClearNotification }: Props) {
const styles = useStyles2(getStyles);
componentDidMount() {
const { appNotification, onClearNotification } = this.props;
useEffectOnce(() => {
setTimeout(() => {
onClearNotification(appNotification.id);
}, appNotification.timeout);
}
}, timeoutMap[appNotification.severity]);
});
render() {
const { appNotification, onClearNotification } = this.props;
return (
<Alert
severity={appNotification.severity}
title={appNotification.title}
onRemove={() => onClearNotification(appNotification.id)}
elevated
>
{appNotification.component || appNotification.text}
</Alert>
);
}
return (
<Alert
severity={appNotification.severity}
title={appNotification.title}
onRemove={() => onClearNotification(appNotification.id)}
elevated
>
<div className={styles.wrapper}>
<span>{appNotification.component || appNotification.text}</span>
{appNotification.traceId && <span className={styles.trace}>Trace ID: {appNotification.traceId}</span>}
</div>
</Alert>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
}),
trace: css({
fontSize: theme.typography.pxToRem(10),
}),
};
}

View File

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import appEvents from 'app/core/app_events';
import AppNotificationItem from './AppNotificationItem';
import { notifyApp, clearAppNotification } from 'app/core/actions';
import { selectAll } from 'app/core/reducers/appNotification';
import { notifyApp, hideAppNotification } from 'app/core/actions';
import { selectVisible } from 'app/core/reducers/appNotification';
import { StoreState } from 'app/types';
import {
@ -17,12 +17,12 @@ import { VerticalGroup } from '@grafana/ui';
export interface OwnProps {}
const mapStateToProps = (state: StoreState, props: OwnProps) => ({
appNotifications: selectAll(state.appNotifications),
appNotifications: selectVisible(state.appNotifications),
});
const mapDispatchToProps = {
notifyApp,
clearAppNotification,
hideAppNotification,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -39,7 +39,7 @@ export class AppNotificationListUnConnected extends PureComponent<Props> {
}
onClearAppNotification = (id: string) => {
this.props.clearAppNotification(id);
this.props.hideAppNotification(id);
};
render() {

View File

@ -0,0 +1,111 @@
import React, { ReactNode } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { getIconFromSeverity } from '@grafana/ui/src/components/Alert/Alert';
import { Icon, IconButton, IconName, useTheme2 } from '@grafana/ui';
export type AlertVariant = 'success' | 'warning' | 'error' | 'info';
export interface Props {
title: string;
severity?: AlertVariant;
timestamp?: number;
traceId?: string;
children?: ReactNode;
onRemove?: (event: React.MouseEvent) => void;
}
export const StoredNotificationItem = ({
title,
severity = 'error',
traceId,
timestamp,
children,
onRemove,
}: Props) => {
const theme = useTheme2();
const styles = getStyles(theme, severity);
return (
<div className={styles.wrapper}>
<div className={styles.icon}>
<Icon size="xl" name={getIconFromSeverity(severity) as IconName} />
</div>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{children}</div>
<span className={styles.trace}>{traceId && `Trace ID: ${traceId}`}</span>
<div className={styles.close}>
<IconButton aria-label="Close alert" name="times" onClick={onRemove} size="lg" type="button" />
</div>
{timestamp && <span className={styles.timestamp}>{formatDistanceToNow(timestamp, { addSuffix: true })}</span>}
</div>
);
};
const getStyles = (theme: GrafanaTheme2, severity: AlertVariant) => {
const color = theme.colors[severity];
const borderRadius = theme.shape.borderRadius();
return {
wrapper: css({
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: 'auto 1fr auto',
gridTemplateAreas: `
'icon title close'
'icon body body'
'icon trace timestamp'`,
gap: `0 ${theme.spacing(2)}`,
background: theme.colors.background.secondary,
borderRadius: borderRadius,
}),
icon: css({
gridArea: 'icon',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(2, 3),
background: color.main,
color: color.contrastText,
borderRadius: `${borderRadius} 0 0 ${borderRadius}`,
}),
title: css({
gridArea: 'title',
alignSelf: 'center',
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
body: css({
gridArea: 'body',
maxHeight: '50vh',
marginRight: theme.spacing(1),
overflowY: 'auto',
overflowWrap: 'break-word',
wordBreak: 'break-word',
color: theme.colors.text.secondary,
}),
trace: css({
gridArea: 'trace',
justifySelf: 'start',
alignSelf: 'end',
paddingBottom: theme.spacing(1),
fontSize: theme.typography.pxToRem(10),
color: theme.colors.text.secondary,
}),
close: css({
gridArea: 'close',
display: 'flex',
justifySelf: 'end',
padding: theme.spacing(1, 0.5),
background: 'none',
}),
timestamp: css({
gridArea: 'timestamp',
alignSelf: 'end',
padding: theme.spacing(1),
fontSize: theme.typography.pxToRem(10),
color: theme.colors.text.secondary,
}),
};
};

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { AppNotification, AppNotificationSeverity, AppNotificationTimeout, useDispatch } from 'app/types';
import { AppNotification, AppNotificationSeverity, useDispatch } from 'app/types';
import { getMessageFromError } from 'app/core/utils/errors';
import { v4 as uuidv4 } from 'uuid';
import { notifyApp } from '../actions';
@ -9,7 +9,6 @@ const defaultSuccessNotification = {
text: '',
severity: AppNotificationSeverity.Success,
icon: 'check',
timeout: AppNotificationTimeout.Success,
};
const defaultWarningNotification = {
@ -17,7 +16,6 @@ const defaultWarningNotification = {
text: '',
severity: AppNotificationSeverity.Warning,
icon: 'exclamation-triangle',
timeout: AppNotificationTimeout.Warning,
};
const defaultErrorNotification = {
@ -25,19 +23,21 @@ const defaultErrorNotification = {
text: '',
severity: AppNotificationSeverity.Error,
icon: 'exclamation-triangle',
timeout: AppNotificationTimeout.Error,
};
export const createSuccessNotification = (title: string, text = ''): AppNotification => ({
export const createSuccessNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultSuccessNotification,
title: title,
text: text,
title,
text,
id: uuidv4(),
timestamp: Date.now(),
showing: true,
});
export const createErrorNotification = (
title: string,
text: string | Error = '',
traceId?: string,
component?: React.ReactElement
): AppNotification => {
return {
@ -45,15 +45,21 @@ export const createErrorNotification = (
text: getMessageFromError(text),
title,
id: uuidv4(),
traceId,
component,
timestamp: Date.now(),
showing: true,
};
};
export const createWarningNotification = (title: string, text = ''): AppNotification => ({
export const createWarningNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultWarningNotification,
title: title,
text: text,
title,
text,
traceId,
id: uuidv4(),
timestamp: Date.now(),
showing: true,
});
/** Hook for showing toast notifications with varying severity (success, warning error).
@ -70,11 +76,11 @@ export function useAppNotification() {
success: (title: string, text = '') => {
dispatch(notifyApp(createSuccessNotification(title, text)));
},
warning: (title: string, text = '') => {
dispatch(notifyApp(createWarningNotification(title, text)));
warning: (title: string, text = '', traceId?: string) => {
dispatch(notifyApp(createWarningNotification(title, text, traceId)));
},
error: (title: string, text = '') => {
dispatch(notifyApp(createErrorNotification(title, text)));
error: (title: string, text = '', traceId?: string) => {
dispatch(notifyApp(createErrorNotification(title, text, traceId)));
},
}),
[dispatch]

View File

@ -1,6 +1,7 @@
import { appNotificationsReducer, clearAppNotification, notifyApp } from './appNotification';
import { AppNotificationSeverity, AppNotificationsState, AppNotificationTimeout } from 'app/types/';
import { appNotificationsReducer, clearNotification, notifyApp } from './appNotification';
import { AppNotificationSeverity, AppNotificationsState } from 'app/types/';
const timestamp = 1649849468889;
describe('clear alert', () => {
it('should filter alert', () => {
const id1 = '1767d3d9-4b99-40eb-ab46-de734a66f21d';
@ -14,7 +15,8 @@ describe('clear alert', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
},
[id2]: {
id: id2,
@ -22,12 +24,14 @@ describe('clear alert', () => {
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
showing: true,
timestamp,
},
},
lastRead: timestamp - 10,
};
const result = appNotificationsReducer(initialState, clearAppNotification(id2));
const result = appNotificationsReducer(initialState, clearNotification(id2));
const expectedResult: AppNotificationsState = {
byId: {
@ -37,9 +41,11 @@ describe('clear alert', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
},
},
lastRead: timestamp - 10,
};
expect(result).toEqual(expectedResult);
@ -60,7 +66,8 @@ describe('notify', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
},
[id2]: {
id: id2,
@ -68,9 +75,11 @@ describe('notify', () => {
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
showing: true,
timestamp,
},
},
lastRead: timestamp - 10,
};
const result = appNotificationsReducer(
@ -81,7 +90,8 @@ describe('notify', () => {
icon: 'info',
title: 'test3',
text: 'test alert info 3',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp: 1649802870373,
})
);
@ -93,7 +103,8 @@ describe('notify', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
timestamp,
showing: true,
},
[id2]: {
id: id2,
@ -101,7 +112,8 @@ describe('notify', () => {
icon: 'warning',
title: 'test2',
text: 'test alert fail 2',
timeout: AppNotificationTimeout.Warning,
timestamp,
showing: true,
},
[id3]: {
id: id3,
@ -109,9 +121,11 @@ describe('notify', () => {
icon: 'info',
title: 'test3',
text: 'test alert info 3',
timeout: AppNotificationTimeout.Success,
timestamp: 1649802870373,
showing: true,
},
},
lastRead: timestamp - 10,
};
expect(result).toEqual(expectedResult);
@ -126,9 +140,11 @@ describe('notify', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
},
},
lastRead: timestamp - 10,
};
const result = appNotificationsReducer(
@ -139,7 +155,8 @@ describe('notify', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
})
);
@ -151,9 +168,11 @@ describe('notify', () => {
icon: 'success',
title: 'test',
text: 'test alert',
timeout: AppNotificationTimeout.Success,
showing: true,
timestamp,
},
},
lastRead: timestamp - 10,
};
expect(result).toEqual(expectedResult);

View File

@ -1,8 +1,14 @@
import { config } from '@grafana/runtime';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppNotification, AppNotificationsState } from 'app/types/';
import { AppNotification, AppNotificationSeverity, AppNotificationsState } from 'app/types/';
export const STORAGE_KEY = 'notifications';
export const NEW_NOTIFS_KEY = `${STORAGE_KEY}/lastRead`;
type StoredNotification = Omit<AppNotification, 'component'>;
export const initialState: AppNotificationsState = {
byId: {},
byId: deserializeNotifications(),
lastRead: Number.parseInt(window.localStorage.getItem(NEW_NOTIFS_KEY) ?? `${Date.now()}`, 10),
};
/**
@ -16,30 +22,107 @@ const appNotificationsSlice = createSlice({
initialState,
reducers: {
notifyApp: (state, { payload: newAlert }: PayloadAction<AppNotification>) => {
if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert))) {
if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert) && alert.showing)) {
return;
}
state.byId[newAlert.id] = newAlert;
serializeNotifications(state.byId);
},
clearAppNotification: (state, { payload: alertId }: PayloadAction<string>) => {
hideAppNotification: (state, { payload: alertId }: PayloadAction<string>) => {
if (!(alertId in state.byId)) {
return;
}
state.byId[alertId].showing = false;
serializeNotifications(state.byId);
},
clearNotification: (state, { payload: alertId }: PayloadAction<string>) => {
delete state.byId[alertId];
serializeNotifications(state.byId);
},
clearAllNotifications: (state) => {
state.byId = {};
serializeNotifications(state.byId);
},
readAllNotifications: (state, { payload: timestamp }: PayloadAction<number>) => {
state.lastRead = timestamp;
},
},
});
export const { notifyApp, clearAppNotification } = appNotificationsSlice.actions;
export const { notifyApp, hideAppNotification, clearNotification, clearAllNotifications, readAllNotifications } =
appNotificationsSlice.actions;
export const appNotificationsReducer = appNotificationsSlice.reducer;
export const selectAll = (state: AppNotificationsState) => Object.values(state.byId);
// Selectors
export const selectLastReadTimestamp = (state: AppNotificationsState) => state.lastRead;
export const selectAll = (state: AppNotificationsState) =>
Object.values(state.byId).sort((a, b) => b.timestamp - a.timestamp);
export const selectWarningsAndErrors = (state: AppNotificationsState) => selectAll(state).filter(isAtLeastWarning);
export const selectVisible = (state: AppNotificationsState) => Object.values(state.byId).filter((n) => n.showing);
// Helper functions
function isSimilar(a: AppNotification, b: AppNotification): boolean {
return a.icon === b.icon && a.severity === b.severity && a.text === b.text && a.title === b.title;
}
function isAtLeastWarning(notif: AppNotification) {
return notif.severity === AppNotificationSeverity.Warning || notif.severity === AppNotificationSeverity.Error;
}
function isStoredNotification(obj: any): obj is StoredNotification {
return (
a.icon === b.icon &&
a.severity === b.severity &&
a.text === b.text &&
a.title === b.title &&
a.component === b.component
typeof obj.id === 'string' &&
typeof obj.icon === 'string' &&
typeof obj.title === 'string' &&
typeof obj.text === 'string'
);
}
// (De)serialization
export function deserializeNotifications(): Record<string, StoredNotification> {
if (!config.featureToggles?.persistNotifications) {
return {};
}
const storedNotifsRaw = window.localStorage.getItem(STORAGE_KEY);
if (!storedNotifsRaw) {
return {};
}
const parsed = JSON.parse(storedNotifsRaw);
if (!Object.values(parsed).every((v) => isStoredNotification(v))) {
return {};
}
return parsed;
}
function serializeNotifications(notifs: Record<string, StoredNotification>) {
if (!config.featureToggles?.persistNotifications) {
return;
}
const reducedNotifs = Object.values(notifs)
.filter(isAtLeastWarning)
.reduce<Record<string, StoredNotification>>((prev, cur) => {
prev[cur.id] = {
id: cur.id,
severity: cur.severity,
icon: cur.icon,
title: cur.title,
text: cur.text,
traceId: cur.traceId,
timestamp: cur.timestamp,
showing: cur.showing,
};
return prev;
}, {});
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(reducedNotifs));
}

View File

@ -310,6 +310,7 @@ export class BackendSrv implements BackendService {
this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [
message,
description,
err.data.traceID,
]);
}

View File

@ -264,10 +264,15 @@ describe('backendSrv', () => {
data: {
message: 'Something failed',
error: 'Error',
traceID: 'bogus-trace-id',
},
} as FetchError
);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, ['Something failed', '']);
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvents.alertError, [
'Something failed',
'',
'bogus-trace-id',
]);
});
});
});

View File

@ -0,0 +1,36 @@
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { config } from '@grafana/runtime';
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
import { StoreState } from '../../types';
import { getNavModel } from '../../core/selectors/navModel';
import Page from '../../core/components/Page/Page';
import { StoredNotifications } from './StoredNotifications';
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'notifications'),
});
const connector = connect(mapStateToProps, undefined);
interface OwnProps extends GrafanaRouteComponentProps {}
type Props = OwnProps & ConnectedProps<typeof connector>;
export const NotificationsPage = ({ navModel }: Props) => {
if (!config.featureToggles.persistNotifications) {
return null;
}
return (
<Page navModel={navModel}>
<Page.Contents>
<StoredNotifications />
</Page.Contents>
</Page>
);
};
export default connect(mapStateToProps)(NotificationsPage);

View File

@ -0,0 +1,123 @@
import React, { useRef } from 'react';
import { useEffectOnce } from 'react-use';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Icon, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types';
import {
clearAllNotifications,
clearNotification,
readAllNotifications,
selectWarningsAndErrors,
selectLastReadTimestamp,
} from 'app/core/reducers/appNotification';
import { StoredNotificationItem } from 'app/core/components/AppNotifications/StoredNotificationItem';
export function StoredNotifications() {
const dispatch = useDispatch();
const notifications = useSelector((state) => selectWarningsAndErrors(state.appNotifications));
const lastReadTimestamp = useRef(useSelector((state) => selectLastReadTimestamp(state.appNotifications)));
const styles = useStyles2(getStyles);
useEffectOnce(() => {
dispatch(readAllNotifications(Date.now()));
});
const onClearNotification = (id: string) => {
dispatch(clearNotification(id));
};
const clearAllNotifs = () => {
dispatch(clearAllNotifications());
};
if (notifications.length === 0) {
return (
<div className={styles.noNotifsWrapper}>
<Icon name="bell" size="xxl" />
<span>Notifications you have received will appear here.</span>
</div>
);
}
return (
<div className={styles.wrapper}>
<Button variant="destructive" onClick={clearAllNotifs} className={styles.clearAll}>
Clear all notifications
</Button>
<ul className={styles.list}>
{notifications.map((notif) => (
<li
key={notif.id}
className={cx(styles.listItem, { [styles.newItem]: notif.timestamp > lastReadTimestamp.current })}
>
<StoredNotificationItem
severity={notif.severity}
title={notif.title}
onRemove={() => onClearNotification(notif.id)}
timestamp={notif.timestamp}
traceId={notif.traceId}
>
<span>{notif.text}</span>
</StoredNotificationItem>
</li>
))}
</ul>
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
smallText: css({
fontSize: theme.typography.pxToRem(10),
color: theme.colors.text.secondary,
}),
side: css({
display: 'flex',
flexDirection: 'column',
padding: '3px 6px',
paddingTop: theme.spacing(1),
alignItems: 'flex-end',
justifyContent: 'space-between',
flexShrink: 0,
}),
list: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
listItem: css({
listStyle: 'none',
gap: theme.spacing(1),
alignItems: 'center',
position: 'relative',
}),
newItem: css({
'&::before': {
content: '""',
height: '100%',
position: 'absolute',
left: '-7px',
top: 0,
background: theme.colors.gradients.brandVertical,
width: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius(1),
},
}),
noNotifsWrapper: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: theme.spacing(1),
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
clearAll: css({
alignSelf: 'flex-end',
}),
};
}

View File

@ -6,7 +6,7 @@ import {
NavModelItem,
PanelData,
} from '@grafana/data';
import { Table } from '@grafana/ui';
import { Button, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
@ -16,6 +16,7 @@ import { QueryGroupOptions } from 'app/types';
import Page from '../../core/components/Page/Page';
import AutoSizer from 'react-virtualized-auto-sizer';
import { PanelRenderer } from '../panel/components/PanelRenderer';
import { useAppNotification } from 'app/core/copy/appNotification';
interface State {
queryRunner: PanelQueryRunner;
@ -58,6 +59,8 @@ export const TestStuffPage: FC = () => {
url: 'sandbox/test',
};
const notifyApp = useAppNotification();
return (
<Page navModel={{ node: node, main: node }}>
<Page.Contents>
@ -90,6 +93,23 @@ export const TestStuffPage: FC = () => {
onOptionsChange={onOptionsChange}
/>
</div>
<div style={{ display: 'flex', gap: '1em' }}>
<Button onClick={() => notifyApp.success('Success toast', 'some more text goes here')} variant="primary">
Success
</Button>
<Button
onClick={() => notifyApp.warning('Warning toast', 'some more text goes here', 'bogus-trace-99999')}
variant="secondary"
>
Warning
</Button>
<Button
onClick={() => notifyApp.error('Error toast', 'some more text goes here', 'bogus-trace-fdsfdfsfds')}
variant="destructive"
>
Error
</Button>
</div>
</Page.Contents>
</Page>
);

View File

@ -73,6 +73,7 @@ const displayAlert = (datasourceName: string, region: string) =>
createErrorNotification(
`CloudWatch request limit reached in ${region} for data source ${datasourceName}`,
'',
undefined,
React.createElement(ThrottlingErrorMessage, { region }, null)
)
)

View File

@ -418,6 +418,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
),
},
{
path: '/notifications',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
),
},
...getPluginCatalogRoutes(),
...getLiveRoutes(),
...getAlertingRoutes(),

View File

@ -4,8 +4,10 @@ export interface AppNotification {
icon: string;
title: string;
text: string;
traceId?: string;
component?: React.ReactElement;
timeout: AppNotificationTimeout;
showing: boolean;
timestamp: number;
}
export enum AppNotificationSeverity {
@ -16,11 +18,19 @@ export enum AppNotificationSeverity {
}
export enum AppNotificationTimeout {
Warning = 5000,
Success = 3000,
Warning = 5000,
Error = 7000,
}
export const timeoutMap = {
[AppNotificationSeverity.Success]: AppNotificationTimeout.Success,
[AppNotificationSeverity.Warning]: AppNotificationTimeout.Warning,
[AppNotificationSeverity.Error]: AppNotificationTimeout.Error,
[AppNotificationSeverity.Info]: AppNotificationTimeout.Success,
};
export interface AppNotificationsState {
byId: Record<string, AppNotification>;
lastRead: number;
}