diff --git a/.betterer.results b/.betterer.results index bc8d05d499a..49df936c93c 100644 --- a/.betterer.results +++ b/.betterer.results @@ -562,8 +562,7 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/types/config.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "packages/grafana-data/src/types/dashboard.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -1049,11 +1048,10 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "packages/grafana-runtime/src/config.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "packages/grafana-runtime/src/services/AngularLoader.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -2923,11 +2921,7 @@ exports[`better eslint`] = { ], "public/app/core/reducers/root.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Unexpected any. Specify a different type.", "5"] + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "public/app/core/services/FetchQueue.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/packages/grafana-data/src/types/app.ts b/packages/grafana-data/src/types/app.ts index 04beaa52ed0..07800a059f8 100644 --- a/packages/grafana-data/src/types/app.ts +++ b/packages/grafana-data/src/types/app.ts @@ -27,6 +27,7 @@ export interface AppRootProps { /** * Pass the nav model to the container... is there a better way? + * @deprecated Use PluginPage component exported from @grafana/runtime instead */ onNavChanged: (nav: NavModel) => void; diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 266634540ba..e11886271ff 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -186,7 +186,6 @@ export interface GrafanaConfig { loginHint: string; passwordHint: string; loginError?: string; - navTree: any; viewersCanEdit: boolean; editorsCanAdmin: boolean; disableSanitizeHtml: boolean; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 928c15caa25..10c1eb5a23e 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -66,3 +66,8 @@ export interface NavModelBreadcrumb { } export type NavIndex = { [s: string]: NavModelItem }; + +export enum PageLayoutType { + Standard, + Canvas, +} diff --git a/packages/grafana-runtime/src/components/PluginPage.tsx b/packages/grafana-runtime/src/components/PluginPage.tsx new file mode 100644 index 00000000000..601a0fb56af --- /dev/null +++ b/packages/grafana-runtime/src/components/PluginPage.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { NavModelItem, PageLayoutType } from '@grafana/data'; + +export interface PluginPageProps { + pageNav?: NavModelItem; + children: React.ReactNode; + layout?: PageLayoutType; +} + +export type PluginPageType = React.ComponentType; + +export let PluginPage: PluginPageType = ({ children }) => { + return
{children}
; +}; + +/** + * Used to bootstrap the PluginPage during application start + * is exposed via runtime. + * + * @internal + */ +export function setPluginPage(component: PluginPageType) { + PluginPage = component; +} diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 6f6d1929489..28e65664601 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -66,7 +66,6 @@ export class GrafanaBootConfig implements GrafanaConfig { loginHint = ''; passwordHint = ''; loginError = undefined; - navTree: any; viewersCanEdit = false; editorsCanAdmin = false; disableSanitizeHtml = false; diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 81bdf5765c6..7709758b93b 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -28,6 +28,8 @@ export { PanelRenderer, type PanelRendererProps } from './components/PanelRender export { PanelDataErrorView, type PanelDataErrorViewProps } from './components/PanelDataErrorView'; export { toDataQueryError } from './utils/toDataQueryError'; export { setQueryRunnerFactory, createQueryRunner, type QueryRunnerFactory } from './services/QueryRunner'; +export { PluginPage } from './components/PluginPage'; +export type { PluginPageType, PluginPageProps } from './components/PluginPage'; export { DataSourcePicker, type DataSourcePickerProps, diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 4256dc3fa78..4ea5e30158b 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -282,6 +282,7 @@ func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { } pluginFilePath := filepath.Join(absPluginDir, rel) + // It's safe to ignore gosec warning G304 since we already clean the requested file path and subsequently // use this with a prefix of the plugin's directory, which is set during plugin loading // nolint:gosec diff --git a/public/app/app.ts b/public/app/app.ts index defcdd62ffc..2ae41005d67 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -34,6 +34,7 @@ import { } from '@grafana/runtime'; import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; +import { setPluginPage } from '@grafana/runtime/src/components/PluginPage'; import { getScrollbarWidth } from '@grafana/ui'; import config from 'app/core/config'; import { arrayMove } from 'app/core/utils/arrayMove'; @@ -44,6 +45,7 @@ import getDefaultMonacoLanguages from '../lib/monaco-languages'; import { AppWrapper } from './AppWrapper'; import { AppChromeService } from './core/components/AppChrome/AppChromeService'; import { getAllOptionEditors, getAllStandardFieldConfigs } from './core/components/OptionsUI/registry'; +import { PluginPage } from './core/components/PageNew/PluginPage'; import { GrafanaContextType } from './core/context/GrafanaContext'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { ModalManager } from './core/services/ModalManager'; @@ -103,6 +105,7 @@ export class GrafanaApp { setLocale(config.bootData.user.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); + setPluginPage(PluginPage); setPanelDataErrorView(PanelDataErrorView); setLocationSrv(locationService); setTimeZoneResolver(() => config.bootData.user.timezone); diff --git a/public/app/core/actions/cleanUp.ts b/public/app/core/actions/cleanUp.ts index a1c362a6307..4322612bda0 100644 --- a/public/app/core/actions/cleanUp.ts +++ b/public/app/core/actions/cleanUp.ts @@ -2,10 +2,10 @@ import { createAction } from '@reduxjs/toolkit'; import { StoreState } from '../../types'; -export type StateSelector = (state: StoreState) => T; +export type CleanUpAction = (state: StoreState) => void; -export interface CleanUp { - stateSelector: (state: StoreState) => T; +export interface CleanUpPayload { + cleanupAction: CleanUpAction; } -export const cleanUpAction = createAction>('core/cleanUpState'); +export const cleanUpAction = createAction('core/cleanUpState'); diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 8f300747ff3..d019bd5d85c 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { config } from '@grafana/runtime'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; @@ -12,7 +12,7 @@ import { Page as NewPage } from '../PageNew/Page'; import { OldNavOnly } from './OldNavOnly'; import { PageContents } from './PageContents'; -import { PageLayoutType, PageType } from './types'; +import { PageType } from './types'; import { usePageNav } from './usePageNav'; import { usePageTitle } from './usePageTitle'; @@ -25,7 +25,7 @@ export const OldPage: PageType = ({ toolbar, scrollRef, scrollTop, - layout = PageLayoutType.Default, + layout = PageLayoutType.Standard, }) => { const styles = useStyles2(getStyles); const navModel = usePageNav(navId, oldNavProp); @@ -36,7 +36,7 @@ export const OldPage: PageType = ({ return (
- {layout === PageLayoutType.Default && ( + {layout === PageLayoutType.Standard && (
{pageHeaderNav && } @@ -45,7 +45,7 @@ export const OldPage: PageType = ({
)} - {layout === PageLayoutType.Dashboard && ( + {layout === PageLayoutType.Canvas && ( <> {toolbar}
diff --git a/public/app/core/components/Page/types.ts b/public/app/core/components/Page/types.ts index 4f06651fe57..759a4380155 100644 --- a/public/app/core/components/Page/types.ts +++ b/public/app/core/components/Page/types.ts @@ -1,6 +1,6 @@ import React, { FC, HTMLAttributes, RefCallback } from 'react'; -import { NavModel, NavModelItem } from '@grafana/data'; +import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; import { PageHeader } from '../PageHeader/PageHeader'; @@ -22,11 +22,6 @@ export interface PageProps extends HTMLAttributes { scrollTop?: number; } -export enum PageLayoutType { - Default, - Dashboard, -} - export interface PageType extends FC { Header: typeof PageHeader; OldNavOnly: typeof OldNavOnly; diff --git a/public/app/core/components/PageNew/Page.tsx b/public/app/core/components/PageNew/Page.tsx index c6f3a467172..03c32de395d 100644 --- a/public/app/core/components/PageNew/Page.tsx +++ b/public/app/core/components/PageNew/Page.tsx @@ -2,12 +2,12 @@ import { css, cx } from '@emotion/css'; import React, { useEffect } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { Footer } from '../Footer/Footer'; -import { PageLayoutType, PageType } from '../Page/types'; +import { PageType } from '../Page/types'; import { usePageNav } from '../Page/usePageNav'; import { usePageTitle } from '../Page/usePageTitle'; @@ -23,7 +23,7 @@ export const Page: PageType = ({ subTitle, children, className, - layout = PageLayoutType.Default, + layout = PageLayoutType.Standard, toolbar, scrollTop, scrollRef, @@ -40,14 +40,14 @@ export const Page: PageType = ({ if (navModel) { chrome.update({ sectionNav: navModel.node, - ...(pageNav && { pageNav }), + pageNav: pageNav, }); } }, [navModel, pageNav, chrome]); return (
- {layout === PageLayoutType.Default && ( + {layout === PageLayoutType.Standard && (
{navModel && navModel.main.children && }
@@ -62,7 +62,7 @@ export const Page: PageType = ({
)} - {layout === PageLayoutType.Dashboard && ( + {layout === PageLayoutType.Canvas && (
{toolbar} diff --git a/public/app/core/components/PageNew/PluginPage.tsx b/public/app/core/components/PageNew/PluginPage.tsx new file mode 100644 index 00000000000..f620545e9ae --- /dev/null +++ b/public/app/core/components/PageNew/PluginPage.tsx @@ -0,0 +1,16 @@ +import React, { useContext } from 'react'; + +import { PluginPageProps } from '@grafana/runtime'; +import { PluginPageContext } from 'app/features/plugins/components/PluginPageContext'; + +import { Page } from '../Page/Page'; + +export function PluginPage({ children, pageNav, layout }: PluginPageProps) { + const context = useContext(PluginPageContext); + + return ( + + {children} + + ); +} diff --git a/public/app/core/components/connectWithCleanUp.tsx b/public/app/core/components/connectWithCleanUp.tsx index 6c1cc95ab6b..94506e0383a 100644 --- a/public/app/core/components/connectWithCleanUp.tsx +++ b/public/app/core/components/connectWithCleanUp.tsx @@ -2,20 +2,13 @@ import hoistNonReactStatics from 'hoist-non-react-statics'; import React, { ComponentType, FunctionComponent, useEffect } from 'react'; import { connect, MapDispatchToPropsParam, MapStateToPropsParam, useDispatch } from 'react-redux'; -import { cleanUpAction, StateSelector } from '../actions/cleanUp'; +import { cleanUpAction, CleanUpAction } from '../actions/cleanUp'; export const connectWithCleanUp = - < - TStateProps extends {} = {}, - TDispatchProps = {}, - TOwnProps = {}, - State = {}, - TSelector extends object = {}, - Statics = {} - >( + ( mapStateToProps: MapStateToPropsParam, mapDispatchToProps: MapDispatchToPropsParam, - stateSelector: StateSelector + cleanupAction: CleanUpAction ) => (Component: ComponentType) => { const ConnectedComponent = connect( @@ -28,7 +21,7 @@ export const connectWithCleanUp = const dispatch = useDispatch(); useEffect(() => { return function cleanUp() { - dispatch(cleanUpAction({ stateSelector })); + dispatch(cleanUpAction({ cleanupAction: cleanupAction })); }; }, [dispatch]); // @ts-ignore diff --git a/public/app/core/hooks/useCleanup.ts b/public/app/core/hooks/useCleanup.ts index 33b16150cbf..aa8f97a9a2d 100644 --- a/public/app/core/hooks/useCleanup.ts +++ b/public/app/core/hooks/useCleanup.ts @@ -1,16 +1,16 @@ import { useEffect, useRef } from 'react'; import { useDispatch } from 'react-redux'; -import { cleanUpAction, StateSelector } from '../actions/cleanUp'; +import { cleanUpAction, CleanUpAction } from '../actions/cleanUp'; -export function useCleanup(stateSelector: StateSelector) { +export function useCleanup(cleanupAction: CleanUpAction) { const dispatch = useDispatch(); //bit of a hack to unburden user from having to wrap stateSelcetor in a useCallback. Otherwise cleanup would happen on every render - const selectorRef = useRef(stateSelector); - selectorRef.current = stateSelector; + const selectorRef = useRef(cleanupAction); + selectorRef.current = cleanupAction; useEffect(() => { return () => { - dispatch(cleanUpAction({ stateSelector: selectorRef.current })); + dispatch(cleanUpAction({ cleanupAction: selectorRef.current })); }; }, [dispatch]); } diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts index d81abdb3d7a..4127ce94d3a 100644 --- a/public/app/core/reducers/navModel.ts +++ b/public/app/core/reducers/navModel.ts @@ -1,26 +1,24 @@ import { AnyAction, createAction } from '@reduxjs/toolkit'; +import { cloneDeep } from 'lodash'; import { NavIndex, NavModel, NavModelItem } from '@grafana/data'; import config from 'app/core/config'; export function buildInitialState(): NavIndex { const navIndex: NavIndex = {}; - const rootNodes = config.bootData.navTree as NavModelItem[]; + const rootNodes = cloneDeep(config.bootData.navTree as NavModelItem[]); buildNavIndex(navIndex, rootNodes); return navIndex; } function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem?: NavModelItem) { for (const node of children) { - const newNode = { - ...node, - parentItem: parentItem, - }; + node.parentItem = parentItem; - navIndex[node.id!] = newNode; + navIndex[node.id!] = node; if (node.children) { - buildNavIndex(navIndex, node.children, newNode); + buildNavIndex(navIndex, node.children, node); } } diff --git a/public/app/core/reducers/root.test.ts b/public/app/core/reducers/root.test.ts index a4a4d63dae1..86d694af84e 100644 --- a/public/app/core/reducers/root.test.ts +++ b/public/app/core/reducers/root.test.ts @@ -4,7 +4,7 @@ import { Team } from '../../types'; import { StoreState } from '../../types/store'; import { cleanUpAction } from '../actions/cleanUp'; -import { createRootReducer, recursiveCleanState } from './root'; +import { createRootReducer } from './root'; jest.mock('@grafana/runtime', () => ({ ...(jest.requireActual('@grafana/runtime') as unknown as object), @@ -16,40 +16,6 @@ jest.mock('@grafana/runtime', () => ({ }, })); -describe('recursiveCleanState', () => { - describe('when called with an existing state selector', () => { - it('then it should clear that state slice in state', () => { - const state = { - teams: { teams: [{ id: 1 }, { id: 2 }] }, - }; - // Choosing a deeper state selector here just to test recursive behaviour - // This should be same state slice that matches the state slice of a reducer like state.teams - const stateSelector = state.teams.teams[0]; - - recursiveCleanState(state, stateSelector); - - expect(state.teams.teams[0]).not.toBeDefined(); - expect(state.teams.teams[1]).toBeDefined(); - }); - }); - - describe('when called with a non existing state selector', () => { - it('then it should not clear that state slice in state', () => { - const state = { - teams: { teams: [{ id: 1 }, { id: 2 }] }, - }; - // Choosing a deeper state selector here just to test recursive behaviour - // This should be same state slice that matches the state slice of a reducer like state.teams - const stateSelector = state.teams.teams[2]; - - recursiveCleanState(state, stateSelector); - - expect(state.teams.teams[0]).toBeDefined(); - expect(state.teams.teams[1]).toBeDefined(); - }); - }); -}); - describe('rootReducer', () => { const rootReducer = createRootReducer(); @@ -89,7 +55,9 @@ describe('rootReducer', () => { reducerTester() .givenReducer(rootReducer, state, false, true) - .whenActionIsDispatched(cleanUpAction({ stateSelector: (storeState: StoreState) => storeState.teams })) + .whenActionIsDispatched( + cleanUpAction({ cleanupAction: (storeState) => (storeState.teams = initialTeamsState) }) + ) .thenStatePredicateShouldEqual((resultingState) => { expect(resultingState.teams).toEqual({ ...initialTeamsState }); return true; diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index c8154839d1d..c6ae27cc0c1 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -21,7 +21,7 @@ import usersReducers from 'app/features/users/state/reducers'; import templatingReducers from 'app/features/variables/state/keyedVariablesReducer'; import { alertingApi } from '../../features/alerting/unified/api/alertingApi'; -import { CleanUp, cleanUpAction } from '../actions/cleanUp'; +import { cleanUpAction } from '../actions/cleanUp'; const rootReducers = { ...sharedReducers, @@ -63,33 +63,9 @@ export const createRootReducer = () => { return appReducer(state, action); } - const { stateSelector } = action.payload as CleanUp; - const stateSlice = stateSelector(state); - recursiveCleanState(state, stateSlice); + const { cleanupAction } = action.payload; + cleanupAction(state); return appReducer(state, action); }; }; - -export const recursiveCleanState = (state: any, stateSlice: any): boolean => { - for (const stateKey in state) { - if (!state.hasOwnProperty(stateKey)) { - continue; - } - - const slice = state[stateKey]; - if (slice === stateSlice) { - state[stateKey] = undefined; - return true; - } - - if (typeof slice === 'object') { - const cleaned = recursiveCleanState(slice, stateSlice); - if (cleaned) { - return true; - } - } - } - - return false; -}; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 5ece429b47f..19fdb1a786e 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -80,9 +80,11 @@ export class KeybindingSrv { } toggleNav() { - window.location.href = locationUtil.getUrlForPartial(locationService.getLocation(), { - '__feature.topnav': (!config.featureToggles.topnav).toString(), - }); + window.location.href = + config.appSubUrl + + locationUtil.getUrlForPartial(locationService.getLocation(), { + '__feature.topnav': (!config.featureToggles.topnav).toString(), + }); } private openSearch() { diff --git a/public/app/features/alerting/EditNotificationChannelPage.tsx b/public/app/features/alerting/EditNotificationChannelPage.tsx index 7303f7ccfd3..9c7c1359c7d 100644 --- a/public/app/features/alerting/EditNotificationChannelPage.tsx +++ b/public/app/features/alerting/EditNotificationChannelPage.tsx @@ -12,7 +12,7 @@ import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app import { NotificationChannelForm } from './components/NotificationChannelForm'; import { loadNotificationChannel, testNotificationChannel, updateNotificationChannel } from './state/actions'; -import { resetSecureField } from './state/reducers'; +import { initialChannelState, resetSecureField } from './state/reducers'; import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels'; interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {} @@ -135,5 +135,5 @@ const mapDispatchToProps: MapDispatchToProps = { export default connectWithCleanUp( mapStateToProps, mapDispatchToProps, - (state) => state.notificationChannel + (state) => (state.notificationChannel = initialChannelState) )(EditNotificationChannelPage); diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index 9cefb26546e..b1cb82d68dd 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -66,7 +66,8 @@ const AmRoutes: FC = () => { setIsRootRouteEditMode(false); }; - useCleanup((state) => state.unifiedAlerting.saveAMConfig); + useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); + const handleSave = (data: Partial) => { if (!result) { return; diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index e550148abbe..892896be70f 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -15,6 +15,7 @@ import { useIsRuleEditable } from './hooks/useIsRuleEditable'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { fetchAllPromBuildInfoAction, fetchEditableRuleAction } from './state/actions'; import { useRulesAccess } from './utils/accessControlHooks'; +import { initialAsyncRequestState } from './utils/redux'; import * as ruleId from './utils/rule-id'; interface ExistingRuleEditorProps { @@ -22,7 +23,7 @@ interface ExistingRuleEditorProps { } const ExistingRuleEditor: FC = ({ identifier }) => { - useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule); + useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState)); const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule); const dispatch = useDispatch(); const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule); diff --git a/public/app/features/alerting/unified/components/receivers/GlobalConfigForm.tsx b/public/app/features/alerting/unified/components/receivers/GlobalConfigForm.tsx index 2a80517d38a..4a7fe07d95f 100644 --- a/public/app/features/alerting/unified/components/receivers/GlobalConfigForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/GlobalConfigForm.tsx @@ -14,6 +14,7 @@ import { globalConfigOptions } from '../../utils/cloud-alertmanager-notifier-typ import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; import { omitEmptyValues } from '../../utils/receiver-form'; +import { initialAsyncRequestState } from '../../utils/redux'; import { OptionField } from './form/fields/OptionField'; @@ -30,7 +31,9 @@ const defaultValues: FormValues = { export const GlobalConfigForm: FC = ({ config, alertManagerSourceName }) => { const dispatch = useDispatch(); - useCleanup((state) => state.unifiedAlerting.saveAMConfig); + + useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); + const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig); const readOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName); const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index 2eba93cb61e..e9b4dec7394 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -12,6 +12,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { updateAlertManagerConfigAction } from '../../state/actions'; import { makeAMLink } from '../../utils/misc'; +import { initialAsyncRequestState } from '../../utils/redux'; import { ensureDefine } from '../../utils/templates'; import { ProvisionedResource, ProvisioningAlert } from '../Provisioning'; @@ -38,7 +39,7 @@ export const TemplateForm: FC = ({ existing, alertManagerSourceName, conf const styles = useStyles2(getStyles); const dispatch = useDispatch(); - useCleanup((state) => state.unifiedAlerting.saveAMConfig); + useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig); 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 f2241fa6b77..782bb719038 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -13,6 +13,7 @@ import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray' import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector'; import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form'; import { makeAMLink } from '../../../utils/misc'; +import { initialAsyncRequestState } from '../../../utils/redux'; import { ChannelSubForm } from './ChannelSubForm'; import { DeletedSubForm } from './fields/DeletedSubform'; @@ -62,7 +63,7 @@ export function ReceiverForm({ defaultValues: JSON.parse(JSON.stringify(defaultValues)), }); - useCleanup((state) => state.unifiedAlerting.saveAMConfig); + useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState)); const { loading } = useUnifiedAlertingSelector((state) => state.saveAMConfig); 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 69c07fd898b..deb1483d35c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/AlertRuleForm.tsx @@ -65,7 +65,7 @@ export const AlertRuleForm: FC = ({ existing }) => { const showStep2 = Boolean(type && (type === RuleFormType.grafana || !!dataSourceName)); const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState; - useCleanup((state) => state.unifiedAlerting.ruleForm.saveRule); + useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState)); const submit = (values: RuleFormValues, exitOnSave: boolean) => { dispatch( diff --git a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx index 5ef3cf85c29..39c6f86eab3 100644 --- a/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx +++ b/public/app/features/alerting/unified/components/rules/EditRuleGroupModal.tsx @@ -50,7 +50,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement { } }, [dispatched, loading, onClose, error]); - useCleanup((state) => state.unifiedAlerting.updateLotexNamespaceAndGroup); + useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState)); const onSubmit = (values: FormValues) => { dispatch( diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 75aac08edf2..79bcbcc88c3 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -26,6 +26,7 @@ import { SilenceFormFields } from '../../types/silence-form'; import { matcherToMatcherField, matcherFieldToMatcher } from '../../utils/alertmanager'; import { parseQueryParamMatchers } from '../../utils/matchers'; import { makeAMLink } from '../../utils/misc'; +import { initialAsyncRequestState } from '../../utils/redux'; import { MatchedSilencedRules } from './MatchedSilencedRules'; import MatchersField from './MatchersField'; @@ -106,7 +107,7 @@ export const SilencesEditor: FC = ({ silence, alertManagerSourceName }) = const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence); - useCleanup((state) => state.unifiedAlerting.updateSilence); + useCleanup((state) => (state.unifiedAlerting.updateSilence = initialAsyncRequestState)); const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI; diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index d2b273d3968..a745b9e3e14 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -2,13 +2,12 @@ import { cx } from '@emotion/css'; import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { locationUtil, NavModel, NavModelItem, TimeRange } from '@grafana/data'; +import { locationUtil, NavModel, NavModelItem, TimeRange, PageLayoutType } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { Page } from 'app/core/components/Page/Page'; -import { PageLayoutType } from 'app/core/components/Page/types'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { getKioskMode } from 'app/core/navigation/kiosk'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -353,7 +352,7 @@ export class UnthemedDashboardPage extends PureComponent { { return function cleanUp() { dispatch( cleanUpAction({ - stateSelector: (state) => state.dataSourceSettings, + cleanupAction: (state) => state.dataSourceSettings, }) ); }; diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index cdcf8f78171..f27de866813 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -28,6 +28,7 @@ import { cleanUpAction } from '../../core/actions/cleanUp'; import { ImportDashboardOverview } from './components/ImportDashboardOverview'; import { fetchGcomDashboard, importDashboardJson } from './state/actions'; +import { initialImportDashboardState } from './state/reducers'; import { validateDashboardJson, validateGcomDashboard } from './utils/validation'; type DashboardImportPageRouteSearchParams = { @@ -63,7 +64,7 @@ class UnthemedDashboardImport extends PureComponent { } componentWillUnmount() { - this.props.cleanUpAction({ stateSelector: (state: StoreState) => state.importDashboard }); + this.props.cleanUpAction({ cleanupAction: (state) => (state.importDashboard = initialImportDashboardState) }); } onFileUpload = (event: FormEvent) => { diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index b8526de96c0..bb2bfe9408e 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -1,5 +1,6 @@ import { act, render, screen } from '@testing-library/react'; import React, { Component } from 'react'; +import { Provider } from 'react-redux'; import { Route, Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; @@ -8,6 +9,7 @@ import { locationService, setEchoSrv } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute'; import { Echo } from 'app/core/services/echo/Echo'; +import { configureStore } from 'app/store/configureStore'; import { getMockPlugin } from '../__mocks__/pluginMocks'; import { getPluginSettings } from '../pluginSettings'; @@ -63,14 +65,17 @@ class RootComponent extends Component { } function renderUnderRouter() { + const store = configureStore(); const route = { component: AppRootPage }; locationService.push('/a/my-awesome-plugin'); render( - - } /> - + + + } /> + + ); } diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 8e98fdb59c8..31c7180b04a 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -1,16 +1,25 @@ // Libraries -import React, { Component } from 'react'; -import { createHtmlPortalNode, InPortal, OutPortal, HtmlPortalNode } from 'react-reverse-portal'; +import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; +import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; +import { createSelector } from 'reselect'; import { AppEvents, AppPlugin, AppPluginMeta, KeyValue, NavModel, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv'; import { Page } from 'app/core/components/Page/Page'; +import { PageProps } from 'app/core/components/Page/types'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { appEvents } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { StoreState, useSelector } from 'app/types'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; +import { buildPluginSectionNav } from '../utils'; + +import { buildPluginPageContext, PluginPageContext } from './PluginPageContext'; + interface RouteParams { pluginId: string; } @@ -19,9 +28,135 @@ interface Props extends GrafanaRouteComponentProps {} interface State { loading: boolean; - portalNode: HtmlPortalNode; plugin?: AppPlugin | null; - nav?: NavModel; + pluginNav: NavModel | null; +} + +const initialState: State = { loading: true, pluginNav: null, plugin: null }; + +export function AppRootPage({ match, queryParams, location }: Props) { + const [state, dispatch] = useReducer(stateSlice.reducer, initialState); + const portalNode = useMemo(() => createHtmlPortalNode(), []); + const { plugin, loading, pluginNav } = state; + const sectionNav = useSelector( + createSelector(getNavIndex, (navIndex) => buildPluginSectionNav(location, pluginNav, navIndex)) + ); + const context = useMemo(() => buildPluginPageContext(sectionNav), [sectionNav]); + + useEffect(() => { + loadAppPlugin(match.params.pluginId, dispatch); + }, [match.params.pluginId]); + + const onNavChanged = useCallback( + (newPluginNav: NavModel) => dispatch(stateSlice.actions.changeNav(newPluginNav)), + [] + ); + + if (!plugin || match.params.pluginId !== plugin.meta.id) { + return {loading && }; + } + + if (!plugin.root) { + return ( + +
No root app page component found
; +
+ ); + } + + const pluginRoot = plugin.root && ( + + ); + + if (config.featureToggles.topnav && !pluginNav) { + return {pluginRoot}; + } + + return ( + <> + {pluginRoot} + {sectionNav ? ( + + + + + + ) : ( + + + + )} + + ); +} + +const stateSlice = createSlice({ + name: 'prom-builder-container', + initialState: initialState, + reducers: { + setState: (state, action: PayloadAction>) => { + Object.assign(state, action.payload); + }, + changeNav: (state, action: PayloadAction) => { + let pluginNav = action.payload; + // This is to hide the double breadcrumbs the old nav model can cause + if (pluginNav && pluginNav.node.children) { + pluginNav = { + ...pluginNav, + node: { + ...pluginNav.main, + hideFromBreadcrumbs: true, + }, + }; + } + state.pluginNav = pluginNav; + }, + }, +}); + +function getLoadingPageProps(): Partial { + if (config.featureToggles.topnav) { + return { navId: 'apps' }; + } + + const loading = { text: 'Loading plugin' }; + + return { + navModel: { main: loading, node: loading }, + }; +} + +async function loadAppPlugin(pluginId: string, dispatch: React.Dispatch) { + try { + const app = await getPluginSettings(pluginId).then((info) => { + const error = getAppPluginPageError(info); + if (error) { + appEvents.emit(AppEvents.alertError, [error]); + dispatch(stateSlice.actions.setState({ pluginNav: getWarningNav(error) })); + return null; + } + return importAppPlugin(info); + }); + dispatch(stateSlice.actions.setState({ plugin: app, loading: false, pluginNav: null })); + } catch (err) { + dispatch( + stateSlice.actions.setState({ + plugin: null, + loading: false, + pluginNav: process.env.NODE_ENV === 'development' ? getExceptionNav(err) : getNotFoundNav(), + }) + ); + } +} + +function getNavIndex(store: StoreState) { + return store.navIndex; } export function getAppPluginPageError(meta: AppPluginMeta) { @@ -37,100 +172,4 @@ export function getAppPluginPageError(meta: AppPluginMeta) { return null; } -class AppRootPage extends Component { - constructor(props: Props) { - super(props); - this.state = { - loading: true, - portalNode: createHtmlPortalNode(), - }; - } - - shouldComponentUpdate(nextProps: Props) { - return nextProps.location.pathname.startsWith('/a/'); - } - - async loadPluginSettings() { - const { params } = this.props.match; - try { - const app = await getPluginSettings(params.pluginId).then((info) => { - const error = getAppPluginPageError(info); - if (error) { - appEvents.emit(AppEvents.alertError, [error]); - this.setState({ nav: getWarningNav(error) }); - return null; - } - return importAppPlugin(info); - }); - this.setState({ plugin: app, loading: false, nav: undefined }); - } catch (err) { - this.setState({ - plugin: null, - loading: false, - nav: process.env.NODE_ENV === 'development' ? getExceptionNav(err) : getNotFoundNav(), - }); - } - } - - componentDidMount() { - this.loadPluginSettings(); - } - - componentDidUpdate(prevProps: Props) { - const { params } = this.props.match; - - if (prevProps.match.params.pluginId !== params.pluginId) { - this.setState({ loading: true, plugin: null }); - this.loadPluginSettings(); - } - } - - onNavChanged = (nav: NavModel) => { - this.setState({ nav }); - }; - - render() { - const { loading, plugin, nav, portalNode } = this.state; - - if (!plugin || this.props.match.params.pluginId !== plugin.meta.id) { - return ( - - - - ); - } - - if (!plugin.root) { - // TODO? redirect to plugin page? - return
No Root App
; - } - - return ( - <> - - - - {nav ? ( - - - - - - ) : ( - - - {loading && } - - )} - - ); - } -} - export default AppRootPage; diff --git a/public/app/features/plugins/components/PluginPageContext.tsx b/public/app/features/plugins/components/PluginPageContext.tsx new file mode 100644 index 00000000000..dbbca91cb1c --- /dev/null +++ b/public/app/features/plugins/components/PluginPageContext.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { NavModel } from '@grafana/data'; + +export interface PluginPageContextType { + sectionNav: NavModel; +} + +export const PluginPageContext = React.createContext(getInitialPluginPageContext()); + +PluginPageContext.displayName = 'PluginPageContext'; + +function getInitialPluginPageContext(): PluginPageContextType { + return { + sectionNav: { + main: { text: 'Plugin page' }, + node: { text: 'Plugin page' }, + }, + }; +} + +export function buildPluginPageContext(sectionNav: NavModel | null): PluginPageContextType { + return { + sectionNav: sectionNav ?? getInitialPluginPageContext().sectionNav, + }; +} diff --git a/public/app/features/plugins/utils.test.ts b/public/app/features/plugins/utils.test.ts new file mode 100644 index 00000000000..94eb01753c9 --- /dev/null +++ b/public/app/features/plugins/utils.test.ts @@ -0,0 +1,52 @@ +import { Location as HistoryLocation } from 'history'; + +import { config } from '@grafana/runtime'; + +import { buildPluginSectionNav } from './utils'; + +describe('buildPluginSectionNav', () => { + const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } }; + const appsSection = { + text: 'apps', + id: 'apps', + children: [ + { + text: 'App1', + children: [ + { + text: 'page1', + url: '/a/plugin1/page1', + }, + { + text: 'page2', + url: '/a/plugin1/page2', + }, + ], + }, + ], + }; + const navIndex = { apps: appsSection }; + + it('Should return pluginNav if topnav is disabled', () => { + config.featureToggles.topnav = false; + const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}); + expect(result).toBe(pluginNav); + }); + + it('Should return return section nav if topnav is enabled', () => { + config.featureToggles.topnav = true; + const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex); + expect(result?.main.text).toBe('apps'); + }); + + it('Should set active page', () => { + config.featureToggles.topnav = true; + const result = buildPluginSectionNav( + { pathname: '/a/plugin1/page2', search: '' } as HistoryLocation, + null, + navIndex + ); + expect(result?.main.children![0].children![1].active).toBe(true); + expect(result?.node.text).toBe('page2'); + }); +}); diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index f1662fa7b7a..ac45dd80bda 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -1,4 +1,8 @@ -import { GrafanaPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; +import { Location as HistoryLocation } from 'history'; + +import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { getNavModel } from 'app/core/selectors/navModel'; import { importPanelPluginFromMeta } from './importPanelPlugin'; import { getPluginSettings } from './pluginSettings'; @@ -28,3 +32,39 @@ export async function loadPlugin(pluginId: string): Promise { return result; } + +export function buildPluginSectionNav(location: HistoryLocation, pluginNav: NavModel | null, navIndex: NavIndex) { + // When topnav is disabled we only just show pluginNav like before + if (!config.featureToggles.topnav) { + return pluginNav; + } + + const originalSection = getNavModel(navIndex, 'apps').main; + const section = { ...originalSection }; + + // If we have plugin nav don't set active page in section as it will cause double breadcrumbs + const currentUrl = config.appSubUrl + location.pathname + location.search; + let activePage: NavModelItem | undefined; + + // Set active page + section.children = (section?.children ?? []).map((child) => { + if (child.children) { + return { + ...child, + children: child.children.map((pluginPage) => { + if (currentUrl.startsWith(pluginPage.url ?? '')) { + activePage = { + ...pluginPage, + active: true, + }; + return activePage; + } + return pluginPage; + }), + }; + } + return child; + }); + + return { main: section, node: activePage ?? section }; +} diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx index d81880e64fd..81188e35cf7 100644 --- a/public/app/features/scenes/components/Scene.tsx +++ b/public/app/features/scenes/components/Scene.tsx @@ -1,10 +1,10 @@ import React from 'react'; +import { PageLayoutType } from '@grafana/data'; import { config } from '@grafana/runtime'; import { PageToolbar, ToolbarButton } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; -import { PageLayoutType } from 'app/core/components/Page/types'; import { SceneObjectBase } from '../core/SceneObjectBase'; import { SceneComponentProps, SceneObjectStatePlain, SceneObject } from '../core/types'; @@ -54,7 +54,7 @@ function SceneRenderer({ model }: SceneComponentProps) { ); return ( - +
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />} diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index b337d59656d..b5528a19812 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -12,7 +12,7 @@ import { AccessControlAction, Role, StoreState, Team } from 'app/types'; import { connectWithCleanUp } from '../../core/components/connectWithCleanUp'; import { deleteTeam, loadTeams } from './state/actions'; -import { setSearchQuery, setTeamsSearchPage } from './state/reducers'; +import { initialTeamsState, setSearchQuery, setTeamsSearchPage } from './state/reducers'; import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissionTeamAdmin } from './state/selectors'; const pageLimit = 30; @@ -241,4 +241,8 @@ const mapDispatchToProps = { setTeamsSearchPage, }; -export default connectWithCleanUp(mapStateToProps, mapDispatchToProps, (state) => state.teams)(TeamList); +export default connectWithCleanUp( + mapStateToProps, + mapDispatchToProps, + (state) => (state.teams = initialTeamsState) +)(TeamList); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 947c8e19dde..f94273d3544 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -207,7 +207,7 @@ export function getAppRoutes(): RouteDescriptor[] { }, ...topnavRoutes, { - path: '/a/:pluginId/', + path: '/a/:pluginId', exact: false, // Someday * and will get a ReactRouter under that path! component: SafeDynamicImport(