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:
Torkel Ödegaard 2022-09-05 14:56:08 +02:00 committed by GitHub
parent a423c7f22e
commit 11de1dfe40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 399 additions and 248 deletions

View File

@ -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"],

View File

@ -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;

View File

@ -186,7 +186,6 @@ export interface GrafanaConfig {
loginHint: string;
passwordHint: string;
loginError?: string;
navTree: any;
viewersCanEdit: boolean;
editorsCanAdmin: boolean;
disableSanitizeHtml: boolean;

View File

@ -66,3 +66,8 @@ export interface NavModelBreadcrumb {
}
export type NavIndex = { [s: string]: NavModelItem };
export enum PageLayoutType {
Standard,
Canvas,
}

View 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;
}

View File

@ -66,7 +66,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
loginHint = '';
passwordHint = '';
loginError = undefined;
navTree: any;
viewersCanEdit = false;
editorsCanAdmin = false;
disableSanitizeHtml = false;

View File

@ -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,

View File

@ -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

View File

@ -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);

View File

@ -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');

View File

@ -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}>

View File

@ -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;

View File

@ -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}

View 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>
);
}

View File

@ -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

View File

@ -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]);
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;
};

View File

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

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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(

View File

@ -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(

View File

@ -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;

View File

@ -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}

View File

@ -34,7 +34,7 @@ export const useInitDataSourceSettings = (uid: string) => {
return function cleanUp() {
dispatch(
cleanUpAction({
stateSelector: (state) => state.dataSourceSettings,
cleanupAction: (state) => state.dataSourceSettings,
})
);
};

View File

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

View File

@ -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()}>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</GrafanaContext.Provider>
<Provider store={store}>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</GrafanaContext.Provider>
</Provider>
</Router>
);
}

View File

@ -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;

View 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,
};
}

View 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');
});
});

View File

@ -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 };
}

View File

@ -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} />}

View File

@ -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);

View File

@ -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(