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) {