mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
TopNav: Plugin page layouts / information architecture (#53174)
* Change nav structure when topnav is enable to do initial tests with new information architecture * Support for nested sections * Updated * sentance case * Progress on plugin challange * Rewrite to functional component * Progress * Updates * Progress * Progress on things * missing file * Fixing issue with runtime, need to use setter way to set component exposed via runtime * Move PageLayoutType to grafana/data * Fixing breadcrumb issue, adding more tests * reverted backend change * fix recursive issue with cleanup
This commit is contained in:
parent
a423c7f22e
commit
11de1dfe40
@ -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"],
|
||||
|
@ -27,6 +27,7 @@ export interface AppRootProps<T = KeyValue> {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
|
@ -186,7 +186,6 @@ export interface GrafanaConfig {
|
||||
loginHint: string;
|
||||
passwordHint: string;
|
||||
loginError?: string;
|
||||
navTree: any;
|
||||
viewersCanEdit: boolean;
|
||||
editorsCanAdmin: boolean;
|
||||
disableSanitizeHtml: boolean;
|
||||
|
@ -66,3 +66,8 @@ export interface NavModelBreadcrumb {
|
||||
}
|
||||
|
||||
export type NavIndex = { [s: string]: NavModelItem };
|
||||
|
||||
export enum PageLayoutType {
|
||||
Standard,
|
||||
Canvas,
|
||||
}
|
||||
|
25
packages/grafana-runtime/src/components/PluginPage.tsx
Normal file
25
packages/grafana-runtime/src/components/PluginPage.tsx
Normal file
@ -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<PluginPageProps>;
|
||||
|
||||
export let PluginPage: PluginPageType = ({ children }) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to bootstrap the PluginPage during application start
|
||||
* is exposed via runtime.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function setPluginPage(component: PluginPageType) {
|
||||
PluginPage = component;
|
||||
}
|
@ -66,7 +66,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
loginHint = '';
|
||||
passwordHint = '';
|
||||
loginError = undefined;
|
||||
navTree: any;
|
||||
viewersCanEdit = false;
|
||||
editorsCanAdmin = false;
|
||||
disableSanitizeHtml = false;
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -2,10 +2,10 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { StoreState } from '../../types';
|
||||
|
||||
export type StateSelector<T> = (state: StoreState) => T;
|
||||
export type CleanUpAction = (state: StoreState) => void;
|
||||
|
||||
export interface CleanUp<T> {
|
||||
stateSelector: (state: StoreState) => T;
|
||||
export interface CleanUpPayload {
|
||||
cleanupAction: CleanUpAction;
|
||||
}
|
||||
|
||||
export const cleanUpAction = createAction<CleanUp<{}>>('core/cleanUpState');
|
||||
export const cleanUpAction = createAction<CleanUpPayload>('core/cleanUpState');
|
||||
|
@ -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 (
|
||||
<div className={cx(styles.wrapper, className)}>
|
||||
{layout === PageLayoutType.Default && (
|
||||
{layout === PageLayoutType.Standard && (
|
||||
<CustomScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}>
|
||||
<div className="page-scrollbar-content">
|
||||
{pageHeaderNav && <PageHeader navItem={pageHeaderNav} />}
|
||||
@ -45,7 +45,7 @@ export const OldPage: PageType = ({
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
)}
|
||||
{layout === PageLayoutType.Dashboard && (
|
||||
{layout === PageLayoutType.Canvas && (
|
||||
<>
|
||||
{toolbar}
|
||||
<div className={styles.scrollWrapper}>
|
||||
|
@ -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<HTMLDivElement> {
|
||||
scrollTop?: number;
|
||||
}
|
||||
|
||||
export enum PageLayoutType {
|
||||
Default,
|
||||
Dashboard,
|
||||
}
|
||||
|
||||
export interface PageType extends FC<PageProps> {
|
||||
Header: typeof PageHeader;
|
||||
OldNavOnly: typeof OldNavOnly;
|
||||
|
@ -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 (
|
||||
<div className={cx(styles.wrapper, className)}>
|
||||
{layout === PageLayoutType.Default && (
|
||||
{layout === PageLayoutType.Standard && (
|
||||
<div className={styles.panes}>
|
||||
{navModel && navModel.main.children && <SectionNav model={navModel} />}
|
||||
<div className={styles.pageContent}>
|
||||
@ -62,7 +62,7 @@ export const Page: PageType = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{layout === PageLayoutType.Dashboard && (
|
||||
{layout === PageLayoutType.Canvas && (
|
||||
<CustomScrollbar autoHeightMin={'100%'} scrollTop={scrollTop} scrollRefCallback={scrollRef}>
|
||||
<div className={styles.dashboardContent}>
|
||||
{toolbar}
|
||||
|
16
public/app/core/components/PageNew/PluginPage.tsx
Normal file
16
public/app/core/components/PageNew/PluginPage.tsx
Normal file
@ -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 (
|
||||
<Page navModel={context.sectionNav} pageNav={pageNav} layout={layout}>
|
||||
<Page.Contents>{children}</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
@ -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 = {}
|
||||
>(
|
||||
<TStateProps extends {} = {}, TDispatchProps = {}, TOwnProps = {}, State = {}, Statics = {}>(
|
||||
mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State>,
|
||||
mapDispatchToProps: MapDispatchToPropsParam<TDispatchProps, TOwnProps>,
|
||||
stateSelector: StateSelector<TSelector>
|
||||
cleanupAction: CleanUpAction
|
||||
) =>
|
||||
(Component: ComponentType<any>) => {
|
||||
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
|
||||
|
@ -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<T>(stateSelector: StateSelector<T>) {
|
||||
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]);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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<StoreState>()
|
||||
.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;
|
||||
|
@ -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<any>;
|
||||
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;
|
||||
};
|
||||
|
@ -80,7 +80,9 @@ export class KeybindingSrv {
|
||||
}
|
||||
|
||||
toggleNav() {
|
||||
window.location.href = locationUtil.getUrlForPartial(locationService.getLocation(), {
|
||||
window.location.href =
|
||||
config.appSubUrl +
|
||||
locationUtil.getUrlForPartial(locationService.getLocation(), {
|
||||
'__feature.topnav': (!config.featureToggles.topnav).toString(),
|
||||
});
|
||||
}
|
||||
|
@ -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<DispatchProps, OwnProps> = {
|
||||
export default connectWithCleanUp(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
(state) => state.notificationChannel
|
||||
(state) => (state.notificationChannel = initialChannelState)
|
||||
)(EditNotificationChannelPage);
|
||||
|
@ -66,7 +66,8 @@ const AmRoutes: FC = () => {
|
||||
setIsRootRouteEditMode(false);
|
||||
};
|
||||
|
||||
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
||||
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
||||
|
||||
const handleSave = (data: Partial<FormAmRoute>) => {
|
||||
if (!result) {
|
||||
return;
|
||||
|
@ -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<ExistingRuleEditorProps> = ({ 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);
|
||||
|
@ -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<Props> = ({ 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);
|
||||
|
@ -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<Props> = ({ 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);
|
||||
|
||||
|
@ -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<R extends ChannelValues>({
|
||||
defaultValues: JSON.parse(JSON.stringify(defaultValues)),
|
||||
});
|
||||
|
||||
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
||||
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
|
||||
|
||||
const { loading } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||
|
||||
|
@ -65,7 +65,7 @@ export const AlertRuleForm: FC<Props> = ({ 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(
|
||||
|
@ -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(
|
||||
|
@ -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<Props> = ({ 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;
|
||||
|
||||
|
@ -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<Props, State> {
|
||||
<Page
|
||||
navModel={sectionNav}
|
||||
pageNav={pageNav}
|
||||
layout={PageLayoutType.Dashboard}
|
||||
layout={PageLayoutType.Canvas}
|
||||
toolbar={toolbar}
|
||||
className={cx(viewPanel && 'panel-in-fullscreen', queryParams.editview && 'dashboard-content--hidden')}
|
||||
scrollRef={this.setScrollRef}
|
||||
|
@ -34,7 +34,7 @@ export const useInitDataSourceSettings = (uid: string) => {
|
||||
return function cleanUp() {
|
||||
dispatch(
|
||||
cleanUpAction({
|
||||
stateSelector: (state) => state.dataSourceSettings,
|
||||
cleanupAction: (state) => state.dataSourceSettings,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
@ -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<Props> {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.cleanUpAction({ stateSelector: (state: StoreState) => state.importDashboard });
|
||||
this.props.cleanUpAction({ cleanupAction: (state) => (state.importDashboard = initialImportDashboardState) });
|
||||
}
|
||||
|
||||
onFileUpload = (event: FormEvent<HTMLInputElement>) => {
|
||||
|
@ -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<AppRootProps> {
|
||||
}
|
||||
|
||||
function renderUnderRouter() {
|
||||
const store = configureStore();
|
||||
const route = { component: AppRootPage };
|
||||
locationService.push('/a/my-awesome-plugin');
|
||||
|
||||
render(
|
||||
<Router history={locationService.getHistory()}>
|
||||
<Provider store={store}>
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
|
||||
</GrafanaContext.Provider>
|
||||
</Provider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
@ -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<RouteParams> {}
|
||||
|
||||
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 <Page {...getLoadingPageProps()}>{loading && <PageLoader />}</Page>;
|
||||
}
|
||||
|
||||
if (!plugin.root) {
|
||||
return (
|
||||
<Page navModel={getWarningNav('Plugin load error')}>
|
||||
<div>No root app page component found</div>;
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const pluginRoot = plugin.root && (
|
||||
<plugin.root
|
||||
meta={plugin.meta}
|
||||
basename={match.url}
|
||||
onNavChanged={onNavChanged}
|
||||
query={queryParams as KeyValue}
|
||||
path={location.pathname}
|
||||
/>
|
||||
);
|
||||
|
||||
if (config.featureToggles.topnav && !pluginNav) {
|
||||
return <PluginPageContext.Provider value={context}>{pluginRoot}</PluginPageContext.Provider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InPortal node={portalNode}>{pluginRoot}</InPortal>
|
||||
{sectionNav ? (
|
||||
<Page navModel={sectionNav} pageNav={pluginNav?.node}>
|
||||
<Page.Contents isLoading={loading}>
|
||||
<OutPortal node={portalNode} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
) : (
|
||||
<Page>
|
||||
<OutPortal node={portalNode} />
|
||||
</Page>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const stateSlice = createSlice({
|
||||
name: 'prom-builder-container',
|
||||
initialState: initialState,
|
||||
reducers: {
|
||||
setState: (state, action: PayloadAction<Partial<State>>) => {
|
||||
Object.assign(state, action.payload);
|
||||
},
|
||||
changeNav: (state, action: PayloadAction<NavModel>) => {
|
||||
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<PageProps> {
|
||||
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<AnyAction>) {
|
||||
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<Props, State> {
|
||||
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 (
|
||||
<Page>
|
||||
<PageLoader />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin.root) {
|
||||
// TODO? redirect to plugin page?
|
||||
return <div>No Root App</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InPortal node={portalNode}>
|
||||
<plugin.root
|
||||
meta={plugin.meta}
|
||||
basename={this.props.match.url}
|
||||
onNavChanged={this.onNavChanged}
|
||||
query={this.props.queryParams as KeyValue}
|
||||
path={this.props.location.pathname}
|
||||
/>
|
||||
</InPortal>
|
||||
{nav ? (
|
||||
<Page navModel={nav}>
|
||||
<Page.Contents isLoading={loading}>
|
||||
<OutPortal node={portalNode} />
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
) : (
|
||||
<Page>
|
||||
<OutPortal node={portalNode} />
|
||||
{loading && <PageLoader />}
|
||||
</Page>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppRootPage;
|
||||
|
26
public/app/features/plugins/components/PluginPageContext.tsx
Normal file
26
public/app/features/plugins/components/PluginPageContext.tsx
Normal file
@ -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,
|
||||
};
|
||||
}
|
52
public/app/features/plugins/utils.test.ts
Normal file
52
public/app/features/plugins/utils.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
@ -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<GrafanaPlugin> {
|
||||
|
||||
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 };
|
||||
}
|
||||
|
@ -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<Scene>) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Page navId="scenes" layout={PageLayoutType.Dashboard} toolbar={pageToolbar}>
|
||||
<Page navId="scenes" layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
|
||||
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
|
||||
<layout.Component model={layout} isEditing={isEditing} />
|
||||
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
|
||||
|
@ -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);
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user