diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 2d7b8be9e9d..9092afd51ea 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -55,4 +55,5 @@ export { createDataSourcePluginEventProperties, } from './analytics/plugins/eventProperties'; export { usePluginInteractionReporter } from './analytics/plugins/usePluginInteractionReporter'; +export { setReturnToPreviousHook, useReturnToPrevious } from './utils/returnToPrevious'; export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard } from './components/EmbeddedDashboard'; diff --git a/packages/grafana-runtime/src/utils/returnToPrevious.ts b/packages/grafana-runtime/src/utils/returnToPrevious.ts new file mode 100644 index 00000000000..f4c1654593a --- /dev/null +++ b/packages/grafana-runtime/src/utils/returnToPrevious.ts @@ -0,0 +1,23 @@ +interface ReturnToPreviousData { + title: string; + href: string; +} + +type ReturnToPreviousHook = () => (rtp: ReturnToPreviousData) => void; + +let rtpHook: ReturnToPreviousHook | undefined = undefined; + +export const setReturnToPreviousHook = (hook: ReturnToPreviousHook) => { + rtpHook = hook; +}; + +export const useReturnToPrevious: ReturnToPreviousHook = () => { + if (!rtpHook) { + if (process.env.NODE_ENV !== 'production') { + throw new Error('useReturnToPrevious hook not found in @grafana/runtime'); + } + return () => console.error('ReturnToPrevious hook not found'); + } + + return rtpHook(); +}; diff --git a/public/app/app.ts b/public/app/app.ts index 7d3bde2ea47..cb31f9c58f9 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -35,6 +35,7 @@ import { setPluginExtensionGetter, setEmbeddedDashboard, setAppEvents, + setReturnToPreviousHook, type GetPluginExtensions, } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; @@ -52,7 +53,7 @@ import appEvents from './core/app_events'; import { AppChromeService } from './core/components/AppChrome/AppChromeService'; import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry'; import { PluginPage } from './core/components/Page/PluginPage'; -import { GrafanaContextType } from './core/context/GrafanaContext'; +import { GrafanaContextType, useReturnToPreviousInternal } from './core/context/GrafanaContext'; import { initIconCache } from './core/icons/iconBundle'; import { initializeI18n } from './core/internationalization'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; @@ -247,6 +248,8 @@ export class GrafanaApp { config, }; + setReturnToPreviousHook(useReturnToPreviousInternal); + const root = createRoot(document.getElementById('reactRoot')!); root.render( React.createElement(AppWrapper, { diff --git a/public/app/core/components/AppChrome/AppChrome.test.tsx b/public/app/core/components/AppChrome/AppChrome.test.tsx index a846714075c..0893f09e7af 100644 --- a/public/app/core/components/AppChrome/AppChrome.test.tsx +++ b/public/app/core/components/AppChrome/AppChrome.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { KBarProvider } from 'kbar'; import React, { ReactNode } from 'react'; @@ -132,8 +132,10 @@ describe('AppChrome', () => { it('should not render a skip link if the page is chromeless', async () => { const { context } = setup(Children); - context.chrome.update({ - chromeless: true, + act(() => { + context.chrome.update({ + chromeless: true, + }); }); waitFor(() => { expect(screen.queryByRole('link', { name: 'Skip to main content' })).not.toBeInTheDocument(); diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index b231e7d7191..0bec848ad60 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -1,8 +1,9 @@ import { css, cx } from '@emotion/css'; import classNames from 'classnames'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import config from 'app/core/config'; import { useGrafana } from 'app/core/context/GrafanaContext'; @@ -16,6 +17,7 @@ import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './ import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu'; import { MegaMenu } from './MegaMenu/MegaMenu'; import { NavToolbar } from './NavToolbar/NavToolbar'; +import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; import { SectionNav } from './SectionNav/SectionNav'; import { TopSearchBar } from './TopBar/TopSearchBar'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; @@ -53,6 +55,18 @@ export function AppChrome({ children }: Props) { chrome.setMegaMenuOpen(!state.megaMenuOpen); }; + const path = locationService.getLocation().pathname; + const shouldShowReturnToPrevious = + config.featureToggles.returnToPrevious && state.returnToPrevious && path !== state.returnToPrevious.href; + + useEffect(() => { + if (state.returnToPrevious && path === state.returnToPrevious.href) { + chrome.clearReturnToPrevious(); + } + // We only want to pay attention when the location changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chrome, path]); + // Chromeless routes are without topNav, mega menu, search & command palette // We check chromeless twice here instead of having a separate path so {children} // doesn't get re-mounted when chromeless goes from true to false. @@ -105,6 +119,9 @@ export function AppChrome({ children }: Props) { )} {!state.chromeless && } + {shouldShowReturnToPrevious && state.returnToPrevious && ( + + )} ); } diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 3cf837cc893..3ce5628ab95 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -11,6 +11,8 @@ import { KioskMode } from 'app/types'; import { RouteDescriptor } from '../../navigation/types'; +import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; + export interface AppChromeState { chromeless?: boolean; sectionNav: NavModel; @@ -21,6 +23,10 @@ export interface AppChromeState { megaMenuDocked: boolean; kioskMode: KioskMode | null; layout: PageLayoutType; + returnToPrevious?: { + href: ReturnToPreviousProps['href']; + title: ReturnToPreviousProps['title']; + }; } export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; @@ -40,6 +46,9 @@ export class AppChromeService { ) ); + private sessionStorageData = window.sessionStorage.getItem('returnToPrevious'); + private returnToPreviousData = this.sessionStorageData ? JSON.parse(this.sessionStorageData) : undefined; + readonly state = new BehaviorSubject({ chromeless: true, // start out hidden to not flash it on pages without chrome sectionNav: { node: { text: t('nav.home.title', 'Home') }, main: { text: '' } }, @@ -48,6 +57,7 @@ export class AppChromeService { megaMenuDocked: this.megaMenuDocked, kioskMode: null, layout: PageLayoutType.Canvas, + returnToPrevious: this.returnToPreviousData, }); public setMatchedRoute(route: RouteDescriptor) { @@ -83,6 +93,16 @@ export class AppChromeService { } } + public setReturnToPrevious = (returnToPrevious: ReturnToPreviousProps) => { + this.update({ returnToPrevious }); + window.sessionStorage.setItem('returnToPrevious', JSON.stringify(returnToPrevious)); + }; + + public clearReturnToPrevious = () => { + this.update({ returnToPrevious: undefined }); + window.sessionStorage.removeItem('returnToPrevious'); + }; + private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; diff --git a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx index e7c99dee15c..15777eb8580 100644 --- a/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx +++ b/public/app/core/components/AppChrome/ReturnToPrevious/ReturnToPrevious.tsx @@ -2,7 +2,9 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; +import { useGrafana } from 'app/core/context/GrafanaContext'; import { t } from 'app/core/internationalization'; import { DismissableButton } from './DismissableButton'; @@ -14,11 +16,13 @@ export interface ReturnToPreviousProps { export const ReturnToPrevious = ({ href, title }: ReturnToPreviousProps) => { const styles = useStyles2(getStyles); + const { chrome } = useGrafana(); const handleOnClick = () => { - console.log('Going to...', href); + locationService.push(href); + chrome.clearReturnToPrevious(); }; const handleOnDismiss = () => { - console.log('Closing button'); + chrome.clearReturnToPrevious(); }; return ( diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index efd14746c88..a7eea0d2308 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -26,3 +26,10 @@ export function useGrafana(): GrafanaContextType { } return context; } + +// Implementation of useReturnToPrevious that's made available through +// @grafana/runtime +export function useReturnToPreviousInternal() { + const { chrome } = useGrafana(); + return chrome.setReturnToPrevious; +} diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 66281e40cc1..3137a401f88 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -42,6 +42,7 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + useReturnToPrevious: jest.fn(), })); jest.mock('./api/buildInfo'); jest.mock('./api/prometheus'); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx index 560d7afe82d..cd4c5c582a3 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx @@ -52,6 +52,7 @@ const mockRoute = (id?: string): GrafanaRouteComponentProps<{ id?: string; sourc jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + useReturnToPrevious: jest.fn(), })); jest.mock('../../hooks/useAbilities'); jest.mock('../../api/buildInfo'); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index 53caeeeccea..9ee4496bd17 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -24,6 +24,7 @@ import { RuleDetails } from './RuleDetails'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getPluginLinkExtensions: jest.fn(), + useReturnToPrevious: jest.fn(), })); jest.mock('../../hooks/useIsRuleEditable'); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 67196382007..bf6bd89917e 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -4,7 +4,7 @@ import React, { Fragment, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { GrafanaTheme2, textUtil, urlUtil } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { config, locationService, useReturnToPrevious } from '@grafana/runtime'; import { Button, ClipboardButton, @@ -55,6 +55,8 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop const location = useLocation(); const notifyApp = useAppNotification(); + const setReturnToPrevious = useReturnToPrevious(); + const [ruleToDelete, setRuleToDelete] = useState(); const [redirectToClone, setRedirectToClone] = useState< { identifier: RuleIdentifier; isProvisioned: boolean } | undefined @@ -135,16 +137,31 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop const dashboardUID = rule.annotations[Annotation.dashboardUID]; if (dashboardUID) { buttons.push( - - Go to dashboard - + config.featureToggles.returnToPrevious ? ( + { + setReturnToPrevious({ title: rule.name, href: locationService.getLocation().pathname }); + }} + > + Go to dashboard + + ) : ( + + Go to dashboard + + ) ); const panelId = rule.annotations[Annotation.panelID]; if (panelId) {