diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 046b76668f0..4fe53b56801 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -2,6 +2,7 @@ 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 { StoreState } from 'app/types'; import { @@ -16,7 +17,7 @@ import { VerticalGroup } from '@grafana/ui'; export interface OwnProps {} const mapStateToProps = (state: StoreState, props: OwnProps) => ({ - appNotifications: state.appNotifications.appNotifications, + appNotifications: selectAll(state.appNotifications), }); const mapDispatchToProps = { diff --git a/public/app/core/components/NavBar/NavBarMenu.test.tsx b/public/app/core/components/NavBar/NavBarMenu.test.tsx index 0c65010c21d..10e4569e239 100644 --- a/public/app/core/components/NavBar/NavBarMenu.test.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { NavModelItem } from '@grafana/data'; -import { render, screen } from 'test/redux-rtl'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { NavModelItem } from '@grafana/data'; +import { render } from 'test/redux-rtl'; import { NavBarMenu } from './NavBarMenu'; describe('NavBarMenu', () => { diff --git a/public/app/core/components/Signup/SignupPage.test.tsx b/public/app/core/components/Signup/SignupPage.test.tsx index 0a480655866..e08bc005f0a 100644 --- a/public/app/core/components/Signup/SignupPage.test.tsx +++ b/public/app/core/components/Signup/SignupPage.test.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { render } from 'test/redux-rtl'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { SignupPage } from './SignupPage'; const postMock = jest.fn(); jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ post: postMock, }), diff --git a/public/app/core/components/Signup/SignupPage.tsx b/public/app/core/components/Signup/SignupPage.tsx index 833b4533e56..4eab139d56b 100644 --- a/public/app/core/components/Signup/SignupPage.tsx +++ b/public/app/core/components/Signup/SignupPage.tsx @@ -1,10 +1,9 @@ import React, { FC } from 'react'; import { Form, Field, Input, Button, HorizontalGroup, LinkButton, FormAPI } from '@grafana/ui'; -import { getConfig } from 'app/core/config'; import { getBackendSrv } from '@grafana/runtime'; -import appEvents from 'app/core/app_events'; -import { AppEvents } from '@grafana/data'; +import { getConfig } from 'app/core/config'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { InnerBox, LoginLayout } from '../Login/LoginLayout'; import { PasswordField } from '../PasswordField/PasswordField'; @@ -26,6 +25,7 @@ interface QueryParams { interface Props extends GrafanaRouteComponentProps<{}, QueryParams> {} export const SignupPage: FC = (props) => { + const notifyApp = useAppNotification(); const onSubmit = async (formData: SignupDTO) => { if (formData.name === '') { delete formData.name; @@ -43,7 +43,7 @@ export const SignupPage: FC = (props) => { }) .catch((err) => { const msg = err.data?.message || err; - appEvents.emit(AppEvents.alertWarning, [msg]); + notifyApp.warning(msg); }); if (response.code === 'redirect-to-select-org') { diff --git a/public/app/core/components/Signup/VerifyEmail.tsx b/public/app/core/components/Signup/VerifyEmail.tsx index 788f4462ac8..ff49e1e8ff2 100644 --- a/public/app/core/components/Signup/VerifyEmail.tsx +++ b/public/app/core/components/Signup/VerifyEmail.tsx @@ -1,15 +1,15 @@ import React, { FC, useState } from 'react'; import { Form, Field, Input, Button, Legend, Container, HorizontalGroup, LinkButton } from '@grafana/ui'; -import { getConfig } from 'app/core/config'; import { getBackendSrv } from '@grafana/runtime'; -import appEvents from 'app/core/app_events'; -import { AppEvents } from '@grafana/data'; +import { getConfig } from 'app/core/config'; +import { useAppNotification } from 'app/core/copy/appNotification'; interface EmailDTO { email: string; } export const VerifyEmail: FC = () => { + const notifyApp = useAppNotification(); const [emailSent, setEmailSent] = useState(false); const onSubmit = (formModel: EmailDTO) => { @@ -20,7 +20,7 @@ export const VerifyEmail: FC = () => { }) .catch((err) => { const msg = err.data?.message || err; - appEvents.emit(AppEvents.alertWarning, [msg]); + notifyApp.warning(msg); }); }; diff --git a/public/app/core/components/Signup/VerifyEmailPage.test.tsx b/public/app/core/components/Signup/VerifyEmailPage.test.tsx index fab78b399b2..f03435676b7 100644 --- a/public/app/core/components/Signup/VerifyEmailPage.test.tsx +++ b/public/app/core/components/Signup/VerifyEmailPage.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { render } from 'test/redux-rtl'; import userEvent from '@testing-library/user-event'; import { VerifyEmailPage } from './VerifyEmailPage'; const postMock = jest.fn(); jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ post: postMock, }), diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index f33ea33471d..2c94b701653 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,6 +1,8 @@ -import { AppNotification, AppNotificationSeverity, AppNotificationTimeout } from 'app/types'; +import { useMemo } from 'react'; +import { AppNotification, AppNotificationSeverity, AppNotificationTimeout, useDispatch } from 'app/types'; import { getMessageFromError } from 'app/core/utils/errors'; import { v4 as uuidv4 } from 'uuid'; +import { notifyApp } from '../actions'; const defaultSuccessNotification = { title: '', @@ -53,3 +55,28 @@ export const createWarningNotification = (title: string, text = ''): AppNotifica text: text, id: uuidv4(), }); + +/** Hook for showing toast notifications with varying severity (success, warning error). + * @example + * const notifyApp = useAppNotification(); + * notifyApp.success('Success!', 'Some additional text'); + * notifyApp.warning('Warning!'); + * notifyApp.error('Error!'); + */ +export function useAppNotification() { + const dispatch = useDispatch(); + return useMemo( + () => ({ + success: (title: string, text = '') => { + dispatch(notifyApp(createSuccessNotification(title, text))); + }, + warning: (title: string, text = '') => { + dispatch(notifyApp(createWarningNotification(title, text))); + }, + error: (title: string, text = '') => { + dispatch(notifyApp(createErrorNotification(title, text))); + }, + }), + [dispatch] + ); +} diff --git a/public/app/core/reducers/appNotification.test.ts b/public/app/core/reducers/appNotification.test.ts index cc072562838..edbf431ba2e 100644 --- a/public/app/core/reducers/appNotification.test.ts +++ b/public/app/core/reducers/appNotification.test.ts @@ -1,14 +1,14 @@ import { appNotificationsReducer, clearAppNotification, notifyApp } from './appNotification'; -import { AppNotificationSeverity, AppNotificationTimeout } from 'app/types/'; +import { AppNotificationSeverity, AppNotificationsState, AppNotificationTimeout } from 'app/types/'; describe('clear alert', () => { it('should filter alert', () => { const id1 = '1767d3d9-4b99-40eb-ab46-de734a66f21d'; const id2 = '4767b3de-12dd-40e7-b58c-f778bd59d675'; - const initialState = { - appNotifications: [ - { + const initialState: AppNotificationsState = { + byId: { + [id1]: { id: id1, severity: AppNotificationSeverity.Success, icon: 'success', @@ -16,7 +16,7 @@ describe('clear alert', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - { + [id2]: { id: id2, severity: AppNotificationSeverity.Warning, icon: 'warning', @@ -24,14 +24,14 @@ describe('clear alert', () => { text: 'test alert fail 2', timeout: AppNotificationTimeout.Warning, }, - ], + }, }; const result = appNotificationsReducer(initialState, clearAppNotification(id2)); - const expectedResult = { - appNotifications: [ - { + const expectedResult: AppNotificationsState = { + byId: { + [id1]: { id: id1, severity: AppNotificationSeverity.Success, icon: 'success', @@ -39,7 +39,7 @@ describe('clear alert', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - ], + }, }; expect(result).toEqual(expectedResult); @@ -52,9 +52,9 @@ describe('notify', () => { const id2 = '4477fcd9-246c-45a5-8818-e22a16683dae'; const id3 = '55be87a8-bbab-45c7-b481-1f9d46f0d2ee'; - const initialState = { - appNotifications: [ - { + const initialState: AppNotificationsState = { + byId: { + [id1]: { id: id1, severity: AppNotificationSeverity.Success, icon: 'success', @@ -62,7 +62,7 @@ describe('notify', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - { + [id2]: { id: id2, severity: AppNotificationSeverity.Warning, icon: 'warning', @@ -70,7 +70,7 @@ describe('notify', () => { text: 'test alert fail 2', timeout: AppNotificationTimeout.Warning, }, - ], + }, }; const result = appNotificationsReducer( @@ -85,9 +85,9 @@ describe('notify', () => { }) ); - const expectedResult = { - appNotifications: [ - { + const expectedResult: AppNotificationsState = { + byId: { + [id1]: { id: id1, severity: AppNotificationSeverity.Success, icon: 'success', @@ -95,7 +95,7 @@ describe('notify', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - { + [id2]: { id: id2, severity: AppNotificationSeverity.Warning, icon: 'warning', @@ -103,7 +103,7 @@ describe('notify', () => { text: 'test alert fail 2', timeout: AppNotificationTimeout.Warning, }, - { + [id3]: { id: id3, severity: AppNotificationSeverity.Info, icon: 'info', @@ -111,16 +111,16 @@ describe('notify', () => { text: 'test alert info 3', timeout: AppNotificationTimeout.Success, }, - ], + }, }; expect(result).toEqual(expectedResult); }); it('Dedupe identical alerts', () => { - const initialState = { - appNotifications: [ - { + const initialState: AppNotificationsState = { + byId: { + id1: { id: 'id1', severity: AppNotificationSeverity.Success, icon: 'success', @@ -128,7 +128,7 @@ describe('notify', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - ], + }, }; const result = appNotificationsReducer( @@ -143,9 +143,9 @@ describe('notify', () => { }) ); - const expectedResult = { - appNotifications: [ - { + const expectedResult: AppNotificationsState = { + byId: { + id1: { id: 'id1', severity: AppNotificationSeverity.Success, icon: 'success', @@ -153,7 +153,7 @@ describe('notify', () => { text: 'test alert', timeout: AppNotificationTimeout.Success, }, - ], + }, }; expect(result).toEqual(expectedResult); diff --git a/public/app/core/reducers/appNotification.ts b/public/app/core/reducers/appNotification.ts index a61782ee730..5da53d31c81 100644 --- a/public/app/core/reducers/appNotification.ts +++ b/public/app/core/reducers/appNotification.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AppNotification, AppNotificationsState } from 'app/types/'; export const initialState: AppNotificationsState = { - appNotifications: [] as AppNotification[], + byId: {}, }; /** @@ -15,30 +15,31 @@ const appNotificationsSlice = createSlice({ name: 'appNotifications', initialState, reducers: { - notifyApp: (state, action: PayloadAction) => { - const newAlert = action.payload; - - for (const existingAlert of state.appNotifications) { - if ( - newAlert.icon === existingAlert.icon && - newAlert.severity === existingAlert.severity && - newAlert.text === existingAlert.text && - newAlert.title === existingAlert.title && - newAlert.component === existingAlert.component - ) { - return; - } + notifyApp: (state, { payload: newAlert }: PayloadAction) => { + if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert))) { + return; } - state.appNotifications.push(newAlert); + state.byId[newAlert.id] = newAlert; + }, + clearAppNotification: (state, { payload: alertId }: PayloadAction) => { + delete state.byId[alertId]; }, - clearAppNotification: (state, action: PayloadAction): AppNotificationsState => ({ - ...state, - appNotifications: state.appNotifications.filter((appNotification) => appNotification.id !== action.payload), - }), }, }); export const { notifyApp, clearAppNotification } = appNotificationsSlice.actions; export const appNotificationsReducer = appNotificationsSlice.reducer; + +export const selectAll = (state: AppNotificationsState) => Object.values(state.byId); + +function isSimilar(a: AppNotification, b: AppNotification): boolean { + return ( + a.icon === b.icon && + a.severity === b.severity && + a.text === b.text && + a.title === b.title && + a.component === b.component + ); +} diff --git a/public/app/core/reducers/navBarTree.ts b/public/app/core/reducers/navBarTree.ts index eca2b349f93..6223422bad1 100644 --- a/public/app/core/reducers/navBarTree.ts +++ b/public/app/core/reducers/navBarTree.ts @@ -5,10 +5,12 @@ import config from 'app/core/config'; const defaultPins = ['home', 'dashboards', 'explore', 'alerting'].join(','); const storedPins = (window.localStorage.getItem('pinnedNavItems') ?? defaultPins).split(','); -export const initialState: NavModelItem[] = (config.bootData.navTree as NavModelItem[]).map((n: NavModelItem) => ({ - ...n, - hideFromNavbar: n.id === undefined || !storedPins.includes(n.id), -})); +export const initialState: NavModelItem[] = ((config.bootData?.navTree ?? []) as NavModelItem[]).map( + (n: NavModelItem) => ({ + ...n, + hideFromNavbar: n.id === undefined || !storedPins.includes(n.id), + }) +); const navTreeSlice = createSlice({ name: 'navBarTree', diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts index 99b65bbd86c..56b7877fc80 100644 --- a/public/app/features/alerting/state/actions.ts +++ b/public/app/features/alerting/state/actions.ts @@ -1,8 +1,8 @@ -import { AppEvents } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; -import { appEvents } from 'app/core/core'; import { loadAlertRules, loadedAlertRules, notificationChannelLoaded, setNotificationChannels } from './reducers'; import { AlertRuleDTO, NotifierDTO, ThunkResult } from 'app/types'; +import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/actions'; export function getAlertRulesAsync(options: { state: string }): ThunkResult { return async (dispatch) => { @@ -20,14 +20,14 @@ export function togglePauseAlertRule(id: number, options: { paused: boolean }): }; } -export function createNotificationChannel(data: any): ThunkResult { +export function createNotificationChannel(data: any): ThunkResult> { return async (dispatch) => { try { await getBackendSrv().post(`/api/alert-notifications`, data); - appEvents.emit(AppEvents.alertSuccess, ['Notification created']); + dispatch(notifyApp(createSuccessNotification('Notification created'))); locationService.push('/alerting/notifications'); } catch (error) { - appEvents.emit(AppEvents.alertError, [error.data.error]); + dispatch(notifyApp(createErrorNotification(error.data.error))); } }; } @@ -36,9 +36,9 @@ export function updateNotificationChannel(data: any): ThunkResult { return async (dispatch) => { try { await getBackendSrv().put(`/api/alert-notifications/${data.id}`, data); - appEvents.emit(AppEvents.alertSuccess, ['Notification updated']); + dispatch(notifyApp(createSuccessNotification('Notification updated'))); } catch (error) { - appEvents.emit(AppEvents.alertError, [error.data.error]); + dispatch(notifyApp(createErrorNotification(error.data.error))); } }; } diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index 516986bb8ee..553a6e13c86 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { GrafanaTheme2, AppEvents } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; @@ -12,8 +12,8 @@ import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from ' import { makeAMLink } from '../../../utils/misc'; import { ChannelSubForm } from './ChannelSubForm'; import { DeletedSubForm } from './fields/DeletedSubform'; -import { appEvents } from 'app/core/core'; import { isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource'; +import { useAppNotification } from 'app/core/copy/appNotification'; interface Props { config: AlertManagerCortexConfig; @@ -38,6 +38,7 @@ export function ReceiverForm({ takenReceiverNames, commonSettingsComponent, }: Props): JSX.Element { + const notifyApp = useAppNotification(); const styles = useStyles2(getStyles); const readOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName); const defaultValues = initialValues || { @@ -84,7 +85,7 @@ export function ReceiverForm({ }; const onInvalid = () => { - appEvents.emit(AppEvents.alertError, ['There are errors in the form. Please correct them and try again!']); + notifyApp.error('There are errors in the form. Please correct them and try again!'); }; return ( diff --git a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx index a4741fa17dc..9d18b639290 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -1,5 +1,5 @@ import React, { FC, useMemo, useState } from 'react'; -import { GrafanaTheme2, AppEvents } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { PageToolbar, Button, useStyles2, CustomScrollbar, Spinner, ConfirmModal } from '@grafana/ui'; import { css } from '@emotion/css'; @@ -18,8 +18,8 @@ import { useCleanup } from 'app/core/hooks/useCleanup'; import { rulerRuleToFormValues, getDefaultFormValues, getDefaultQueries } from '../../utils/rule-form'; import { Link } from 'react-router-dom'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { useAppNotification } from 'app/core/copy/appNotification'; -import { appEvents } from 'app/core/core'; import { CloudConditionsStep } from './CloudConditionsStep'; import { GrafanaConditionsStep } from './GrafanaConditionsStep'; import * as ruleId from '../../utils/rule-id'; @@ -31,6 +31,7 @@ type Props = { export const AlertRuleForm: FC = ({ existing }) => { const styles = useStyles2(getStyles); const dispatch = useDispatch(); + const notifyApp = useAppNotification(); const [queryParams] = useQueryParams(); const returnTo: string = (queryParams['returnTo'] as string | undefined) ?? '/alerting/list'; @@ -98,7 +99,7 @@ export const AlertRuleForm: FC = ({ existing }) => { }; const onInvalid = () => { - appEvents.emit(AppEvents.alertError, ['There are errors in the form. Please correct them and try again!']); + notifyApp.error('There are errors in the form. Please correct them and try again!'); }; return ( diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 1d7aea69627..ab7b69e781e 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -2,11 +2,11 @@ import React, { FC, Fragment, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; import { css } from '@emotion/css'; -import { AppEvents, GrafanaTheme2, urlUtil } from '@grafana/data'; +import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Button, ConfirmModal, ClipboardButton, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; -import { appEvents } from 'app/core/core'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { Annotation } from '../../utils/constants'; import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; @@ -26,6 +26,7 @@ interface Props { export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const dispatch = useDispatch(); const location = useLocation(); + const notifyApp = useAppNotification(); const style = useStyles2(getStyles); const { namespace, group, rulerRule } = rule; const [ruleToDelete, setRuleToDelete] = useState(); @@ -189,10 +190,10 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { { - appEvents.emit(AppEvents.alertSuccess, ['URL copied!']); + notifyApp.success('URL copied!'); }} onClipboardError={(e) => { - appEvents.emit(AppEvents.alertError, ['Error while copying URL', e.text]); + notifyApp.error('Error while copying URL', e.text); }} className={style.button} size="sm" diff --git a/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx index 9d1526b6063..263c45f7c1e 100644 --- a/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx +++ b/public/app/features/dashboard/components/DeleteDashboard/useDashboardDelete.tsx @@ -1,19 +1,19 @@ import { useEffect } from 'react'; import { useAsyncFn } from 'react-use'; -import { AppEvents } from '@grafana/data'; -import appEvents from 'app/core/app_events'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { deleteDashboard } from 'app/features/manage-dashboards/state/actions'; import { locationService } from '@grafana/runtime'; export const useDashboardDelete = (uid: string) => { const [state, onDeleteDashboard] = useAsyncFn(() => deleteDashboard(uid, false), []); + const notifyApp = useAppNotification(); useEffect(() => { if (state.value) { locationService.replace('/'); - appEvents.emit(AppEvents.alertSuccess, ['Dashboard Deleted', state.value.title + ' has been deleted']); + notifyApp.success('Dashboard Deleted', `${state.value.title} has been deleted`); } - }, [state]); + }, [state, notifyApp]); return { state, onDeleteDashboard }; }; diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx index 0bf291f0de0..8d8f5104152 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveProvisionedDashboardForm.tsx @@ -3,11 +3,12 @@ import { css } from '@emotion/css'; import { saveAs } from 'file-saver'; import { Button, ClipboardButton, Modal, stylesFactory, TextArea, useTheme } from '@grafana/ui'; import { SaveDashboardFormProps } from '../types'; -import { AppEvents, GrafanaTheme } from '@grafana/data'; -import appEvents from '../../../../../core/app_events'; +import { GrafanaTheme } from '@grafana/data'; +import { useAppNotification } from 'app/core/copy/appNotification'; export const SaveProvisionedDashboardForm: React.FC = ({ dashboard, onCancel }) => { const theme = useTheme(); + const notifyApp = useAppNotification(); const [dashboardJSON, setDashboardJson] = useState(() => { const clone = dashboard.getSaveModelClone(); delete clone.id; @@ -22,8 +23,8 @@ export const SaveProvisionedDashboardForm: React.FC = ({ }, [dashboard.title, dashboardJSON]); const onCopyToClipboardSuccess = useCallback(() => { - appEvents.emit(AppEvents.alertSuccess, ['Dashboard JSON copied to clipboard']); - }, []); + notifyApp.success('Dashboard JSON copied to clipboard'); + }, [notifyApp]); const styles = getStyles(theme); return ( diff --git a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx index 7245e3029e6..59d0d10e9d1 100644 --- a/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/useDashboardSave.tsx @@ -1,8 +1,9 @@ import { useEffect } from 'react'; import useAsyncFn from 'react-use/lib/useAsyncFn'; -import { AppEvents, locationUtil } from '@grafana/data'; +import { locationUtil } from '@grafana/data'; import { SaveDashboardOptions } from './types'; import appEvents from 'app/core/app_events'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { DashboardModel } from 'app/features/dashboard/state'; import { saveDashboard as saveDashboardApiCall } from 'app/features/manage-dashboards/state/actions'; import { locationService, reportInteraction } from '@grafana/runtime'; @@ -24,6 +25,7 @@ export const useDashboardSave = (dashboard: DashboardModel) => { [] ); + const notifyApp = useAppNotification(); useEffect(() => { if (state.value) { dashboard.version = state.value.version; @@ -31,7 +33,7 @@ export const useDashboardSave = (dashboard: DashboardModel) => { // important that these happen before location redirect below appEvents.publish(new DashboardSavedEvent()); - appEvents.emit(AppEvents.alertSuccess, ['Dashboard saved']); + notifyApp.success('Dashboard saved'); reportInteraction(`Dashboard ${dashboard.id ? 'saved' : 'created'}`, { name: dashboard.title, url: state.value.url, @@ -44,7 +46,7 @@ export const useDashboardSave = (dashboard: DashboardModel) => { setTimeout(() => locationService.replace(newUrl)); } } - }, [dashboard, state]); + }, [dashboard, state, notifyApp]); return { state, onDashboardSave }; }; diff --git a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx index 25f4eb3aacb..f2422cc850d 100644 --- a/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx +++ b/public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx @@ -1,9 +1,9 @@ import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useAsyncFn } from 'react-use'; -import { AppEvents, locationUtil } from '@grafana/data'; -import appEvents from 'app/core/app_events'; +import { locationUtil } from '@grafana/data'; import { StoreState } from 'app/types'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { historySrv } from './HistorySrv'; import { DashboardModel } from '../../state'; import { locationService } from '@grafana/runtime'; @@ -15,6 +15,7 @@ const restoreDashboard = async (version: number, dashboard: DashboardModel) => { export const useDashboardRestore = (version: number) => { const dashboard = useSelector((state: StoreState) => state.dashboard.getModel()); const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []); + const notifyApp = useAppNotification(); useEffect(() => { if (state.value) { @@ -26,8 +27,8 @@ export const useDashboardRestore = (version: number) => { pathname: newUrl, state: { routeReloadCounter: prevState ? prevState + 1 : 1 }, }); - appEvents.emit(AppEvents.alertSuccess, ['Dashboard restored', 'Restored from version ' + version]); + notifyApp.success('Dashboard restored', `Restored from version ${version}`); } - }, [state, version]); + }, [state, version, notifyApp]); return { state, onRestoreDashboard }; }; diff --git a/public/app/features/folders/state/actions.ts b/public/app/features/folders/state/actions.ts index 4ef5b6bf2f0..ebda4e3b1ea 100644 --- a/public/app/features/folders/state/actions.ts +++ b/public/app/features/folders/state/actions.ts @@ -1,14 +1,13 @@ -import { AppEvents, locationUtil } from '@grafana/data'; +import { locationUtil } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; import { FolderState, ThunkResult } from 'app/types'; import { DashboardAcl, DashboardAclUpdateDTO, NewDashboardAclItem, PermissionLevel } from 'app/types/acl'; import { notifyApp, updateNavIndex } from 'app/core/actions'; +import { createSuccessNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { buildNavModel } from './navModel'; -import appEvents from 'app/core/app_events'; import { loadFolder, loadFolderPermissions, setCanViewFolderPermissions } from './reducers'; import { lastValueFrom } from 'rxjs'; -import { createWarningNotification } from 'app/core/copy/appNotification'; export function getFolderByUid(uid: string): ThunkResult { return async (dispatch) => { @@ -25,8 +24,7 @@ export function saveFolder(folder: FolderState): ThunkResult { version: folder.version, }); - // this should be redux action at some point - appEvents.emit(AppEvents.alertSuccess, ['Folder saved']); + dispatch(notifyApp(createSuccessNotification('Folder saved'))); locationService.push(`${res.url}/settings`); }; } @@ -143,9 +141,9 @@ export function addFolderPermission(newItem: NewDashboardAclItem): ThunkResult { - return async () => { + return async (dispatch) => { const newFolder = await getBackendSrv().post('/api/folders', { title: folderName }); - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); + dispatch(notifyApp(createSuccessNotification('Folder Created', 'OK'))); locationService.push(locationUtil.stripBaseFromUrl(newFolder.url)); }; } diff --git a/public/app/features/live/pages/AddNewRule.tsx b/public/app/features/live/pages/AddNewRule.tsx index 27a1c23bfa5..5fa392361d0 100644 --- a/public/app/features/live/pages/AddNewRule.tsx +++ b/public/app/features/live/pages/AddNewRule.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { Input, Field, Button, ValuePicker, HorizontalGroup } from '@grafana/ui'; import { DataSourcePicker, getBackendSrv } from '@grafana/runtime'; -import { AppEvents, DataSourceRef, LiveChannelScope, SelectableValue } from '@grafana/data'; -import appEvents from 'app/core/app_events'; +import { DataSourceRef, LiveChannelScope, SelectableValue } from '@grafana/data'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { Rule } from './types'; interface Props { @@ -29,14 +29,15 @@ export function AddNewRule({ onRuleAdded }: Props) { const [pattern, setPattern] = useState(); const [patternPrefix, setPatternPrefix] = useState(''); const [datasource, setDatasource] = useState(); + const notifyApp = useAppNotification(); const onSubmit = () => { if (!pattern) { - appEvents.emit(AppEvents.alertError, ['Enter path']); + notifyApp.error('Enter path'); return; } if (patternType === 'ds' && !patternPrefix.length) { - appEvents.emit(AppEvents.alertError, ['Select datasource']); + notifyApp.error('Select datasource'); return; } @@ -61,7 +62,7 @@ export function AddNewRule({ onRuleAdded }: Props) { onRuleAdded(v.rule); }) .catch((e) => { - appEvents.emit(AppEvents.alertError, ['Error adding rule', e]); + notifyApp.error('Error adding rule', e); e.isHandled = true; }); }; diff --git a/public/app/features/manage-dashboards/state/actions.ts b/public/app/features/manage-dashboards/state/actions.ts index fecd628bcd5..99499d4960c 100644 --- a/public/app/features/manage-dashboards/state/actions.ts +++ b/public/app/features/manage-dashboards/state/actions.ts @@ -1,4 +1,4 @@ -import { AppEvents, DataSourceInstanceSettings, locationUtil } from '@grafana/data'; +import { DataSourceInstanceSettings, locationUtil } from '@grafana/data'; import { clearDashboard, fetchDashboard, @@ -13,7 +13,8 @@ import { setLibraryPanelInputs, } from './reducers'; import { DashboardDataDTO, DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types'; -import { appEvents } from '../../../core/core'; +import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/actions'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { getDataSourceSrv, locationService, getBackendSrv } from '@grafana/runtime'; import { DashboardSearchHit } from '../../search/types'; @@ -31,7 +32,7 @@ export function fetchGcomDashboard(id: string): ThunkResult { dispatch(processElements(dashboard.json)); } catch (error) { dispatch(fetchFailed()); - appEvents.emit(AppEvents.alertError, [error.data.message || error]); + dispatch(notifyApp(createErrorNotification(error.data.message || error))); } }; } diff --git a/public/app/features/search/components/MoveToFolderModal.tsx b/public/app/features/search/components/MoveToFolderModal.tsx index 13d6b155d8e..dac46e5ae31 100644 --- a/public/app/features/search/components/MoveToFolderModal.tsx +++ b/public/app/features/search/components/MoveToFolderModal.tsx @@ -1,10 +1,10 @@ import React, { FC, useState } from 'react'; import { css } from '@emotion/css'; import { Button, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui'; -import { AppEvents, GrafanaTheme } from '@grafana/data'; +import { GrafanaTheme } from '@grafana/data'; import { FolderInfo } from 'app/types'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; -import appEvents from 'app/core/app_events'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { DashboardSection, OnMoveItems } from '../types'; import { getCheckedDashboards } from '../utils'; import { moveDashboards } from 'app/features/manage-dashboards/state/actions'; @@ -21,6 +21,7 @@ export const MoveToFolderModal: FC = ({ results, onMoveItems, isOpen, onD const theme = useTheme(); const styles = getStyles(theme); const selectedDashboards = getCheckedDashboards(results); + const notifyApp = useAppNotification(); const moveTo = () => { if (folder && selectedDashboards.length) { @@ -31,11 +32,11 @@ export const MoveToFolderModal: FC = ({ results, onMoveItems, isOpen, onD const ending = result.successCount === 1 ? '' : 's'; const header = `Dashboard${ending} Moved`; const msg = `${result.successCount} dashboard${ending} moved to ${folderTitle}`; - appEvents.emit(AppEvents.alertSuccess, [header, msg]); + notifyApp.success(header, msg); } if (result.totalCount === result.alreadyInFolderCount) { - appEvents.emit(AppEvents.alertError, ['Error', `Dashboard already belongs to folder ${folderTitle}`]); + notifyApp.error('Error', `Dashboard already belongs to folder ${folderTitle}`); } else { onMoveItems(selectedDashboards, folder); } diff --git a/public/app/types/appNotifications.ts b/public/app/types/appNotifications.ts index 96dcd33fe9f..7976b447986 100644 --- a/public/app/types/appNotifications.ts +++ b/public/app/types/appNotifications.ts @@ -22,5 +22,5 @@ export enum AppNotificationTimeout { } export interface AppNotificationsState { - appNotifications: AppNotification[]; + byId: Record; } diff --git a/public/app/types/store.ts b/public/app/types/store.ts index e94d1b9e1a8..7b03dc2b890 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -24,6 +24,7 @@ export type ThunkResult = ThunkAction; +// Typed useDispatch & useSelector hooks export type AppDispatch = ReturnType['dispatch']; export const useDispatch = () => useDispatchUntyped(); export const useSelector: TypedUseSelectorHook = useSelectorUntyped; diff --git a/public/test/redux-rtl.tsx b/public/test/redux-rtl.tsx index d37501e3569..0c8b33148ef 100644 --- a/public/test/redux-rtl.tsx +++ b/public/test/redux-rtl.tsx @@ -31,5 +31,4 @@ function render( return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); } -export * from '@testing-library/react'; export { render };