GrafanaContext: Exploring a way to get rid of global scope singletons (#52128)

* Context start

* More progress on more generic react context for services

* Update

* Update Page test

* Fixing tests

* Moving to core app
This commit is contained in:
Torkel Ödegaard 2022-07-23 17:09:03 +02:00 committed by GitHub
parent 4eb0a8a98e
commit b782d9aa12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 165 additions and 101 deletions

View File

@ -3356,9 +3356,7 @@ exports[`better eslint`] = {
],
"public/app/core/utils/ConfigProvider.tsx: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.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/core/utils/acl.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]

View File

@ -15,11 +15,12 @@ import { GrafanaApp } from './app';
import { AppChrome } from './core/components/AppChrome/AppChrome';
import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList';
import { NavBar } from './core/components/NavBar/NavBar';
import { GrafanaContext } from './core/context/GrafanaContext';
import { I18nProvider } from './core/internationalization';
import { GrafanaRoute } from './core/navigation/GrafanaRoute';
import { RouteDescriptor } from './core/navigation/types';
import { contextSrv } from './core/services/context_srv';
import { ConfigContext, ThemeProvider } from './core/utils/ConfigProvider';
import { ThemeProvider } from './core/utils/ConfigProvider';
import { CommandPalette } from './features/commandPalette/CommandPalette';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
@ -99,6 +100,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
}
render() {
const { app } = this.props;
const { ready } = this.state;
navigationLogger('AppWrapper', false, 'rendering');
@ -114,7 +116,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
<Provider store={store}>
<I18nProvider>
<ErrorBoundaryAlert style="page">
<ConfigContext.Provider value={config}>
<GrafanaContext.Provider value={app.context}>
<ThemeProvider value={config.theme2}>
<KBarProvider
actions={[]}
@ -147,7 +149,7 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState
</ModalsProvider>
</KBarProvider>
</ThemeProvider>
</ConfigContext.Provider>
</GrafanaContext.Provider>
</ErrorBoundaryAlert>
</I18nProvider>
</Provider>

View File

@ -42,7 +42,9 @@ import { getStandardTransformers } from 'app/features/transformers/standardTrans
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 { GrafanaContextType } from './core/context/GrafanaContext';
import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks';
import { ModalManager } from './core/services/ModalManager';
import { backendSrv } from './core/services/backend_srv';
@ -91,6 +93,8 @@ if (process.env.NODE_ENV === 'development') {
}
export class GrafanaApp {
context!: GrafanaContextType;
async init() {
try {
setBackendSrv(backendSrv);
@ -147,6 +151,13 @@ export class GrafanaApp {
// Preload selected app plugins
await preloadPlugins(config.pluginsToPreload);
this.context = {
backend: backendSrv,
location: locationService,
chrome: new AppChromeService(),
config,
};
ReactDOM.render(
React.createElement(AppWrapper, {
app: this,

View File

@ -4,10 +4,10 @@ import React, { PropsWithChildren } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { MegaMenu } from '../MegaMenu/MegaMenu';
import { appChromeService } from './AppChromeService';
import { NavToolbar } from './NavToolbar';
import { TopSearchBar } from './TopSearchBar';
import { TOP_BAR_LEVEL_HEIGHT } from './types';
@ -16,7 +16,8 @@ export interface Props extends PropsWithChildren<{}> {}
export function AppChrome({ children }: Props) {
const styles = useStyles2(getStyles);
const state = appChromeService.useState();
const { chrome } = useGrafana();
const state = chrome.useState();
if (state.chromeless || !config.featureToggles.topnav) {
return <main className="main-view">{children} </main>;
@ -31,14 +32,12 @@ export function AppChrome({ children }: Props) {
sectionNav={state.sectionNav}
pageNav={state.pageNav}
actions={state.actions}
onToggleSearchBar={appChromeService.toggleSearchBar}
onToggleMegaMenu={appChromeService.toggleMegaMenu}
onToggleSearchBar={chrome.toggleSearchBar}
onToggleMegaMenu={chrome.toggleMegaMenu}
/>
</div>
<div className={cx(styles.content, state.searchBarHidden && styles.contentNoSearchBar)}>{children}</div>
{state.megaMenuOpen && (
<MegaMenu searchBarHidden={state.searchBarHidden} onClose={appChromeService.toggleMegaMenu} />
)}
{state.megaMenuOpen && <MegaMenu searchBarHidden={state.searchBarHidden} onClose={chrome.toggleMegaMenu} />}
</main>
);
}

View File

@ -63,5 +63,3 @@ export class AppChromeService {
return useObservable(this.state, this.state.getValue());
}
}
export const appChromeService = new AppChromeService();

View File

@ -1,8 +1,7 @@
import React, { useEffect } from 'react';
import { NavModelItem } from '@grafana/data';
import { appChromeService } from './AppChromeService';
import { useGrafana } from 'app/core/context/GrafanaContext';
export interface AppChromeUpdateProps {
pageNav?: NavModelItem;
@ -13,8 +12,10 @@ export interface AppChromeUpdateProps {
* This is the way core pages and plugins update the breadcrumbs and page toolbar actions
*/
export const AppChromeUpdate = React.memo<AppChromeUpdateProps>(({ pageNav, actions }: AppChromeUpdateProps) => {
const { chrome } = useGrafana();
useEffect(() => {
appChromeService.update({ pageNav, actions });
chrome.update({ pageNav, actions });
});
return null;
});

View File

@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { configureStore } from 'app/store/configureStore';
import { PageProps } from '../Page/types';
@ -31,15 +33,20 @@ const setup = (props: Partial<PageProps>) => {
},
];
const context = getGrafanaContextMock();
const store = configureStore();
return render(
const renderResult = render(
<Provider store={store}>
<Page {...props}>
<div data-testid="page-children">Children</div>
</Page>
<GrafanaContext.Provider value={context}>
<Page {...props}>
<div data-testid="page-children">Children</div>
</Page>
</GrafanaContext.Provider>
</Provider>
);
return { renderResult, context };
};
describe('Render', () => {
@ -68,6 +75,12 @@ describe('Render', () => {
expect(screen.getAllByRole('tab').length).toBe(2);
});
it('should update chrome with section and pageNav', async () => {
const { context } = setup({ navId: 'child1', pageNav });
expect(context.chrome.state.getValue().sectionNav.id).toBe('child1');
expect(context.chrome.state.getValue().pageNav).toBe(pageNav);
});
it('should render section nav model based on navId and item page nav', async () => {
setup({ navId: 'child1', pageNav });

View File

@ -4,9 +4,8 @@ import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CustomScrollbar, useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
// Components
import { appChromeService } from '../AppChrome/AppChromeService';
import { Footer } from '../Footer/Footer';
import { PageLayoutType, PageType } from '../Page/types';
import { usePageNav } from '../Page/usePageNav';
@ -31,6 +30,7 @@ export const Page: PageType = ({
}) => {
const styles = useStyles2(getStyles);
const navModel = usePageNav(navId, oldNavProp);
const { chrome } = useGrafana();
usePageTitle(navModel, pageNav);
@ -38,12 +38,12 @@ export const Page: PageType = ({
useEffect(() => {
if (navModel) {
appChromeService.update({
chrome.update({
sectionNav: navModel.node,
...(pageNav && { pageNav }),
});
}
}, [navModel, pageNav]);
}, [navModel, pageNav, chrome]);
return (
<div className={cx(styles.wrapper, className)}>

View File

@ -0,0 +1,25 @@
import React, { useContext } from 'react';
import { GrafanaConfig } from '@grafana/data';
import { LocationService } from '@grafana/runtime/src/services/LocationService';
import { BackendSrv } from '@grafana/runtime/src/services/backendSrv';
import { AppChromeService } from '../components/AppChrome/AppChromeService';
export interface GrafanaContextType {
backend: BackendSrv;
location: LocationService;
config: GrafanaConfig;
chrome: AppChromeService;
}
export const GrafanaContext = React.createContext<GrafanaContextType | undefined>(undefined);
export function useGrafana(): GrafanaContextType {
const context = useContext(GrafanaContext);
if (!context) {
throw new Error('No GrafanaContext found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return context as GrafanaContextType;
}

View File

@ -1,8 +1,10 @@
import { render } from '@testing-library/react';
import React from 'react';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { setEchoSrv } from '@grafana/runtime';
import { GrafanaContext } from '../context/GrafanaContext';
import { Echo } from '../services/echo/Echo';
import { GrafanaRoute } from './GrafanaRoute';
@ -24,7 +26,9 @@ describe('GrafanaRoute', () => {
const match = {} as any;
render(
<GrafanaRoute location={location} history={history} match={match} route={{ component: PageComponent } as any} />
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<GrafanaRoute location={location} history={history} match={match} route={{ component: PageComponent } as any} />
</GrafanaContext.Provider>
);
expect(capturedProps.queryParams.query).toBe('hello');

View File

@ -1,78 +1,76 @@
import React from 'react';
import React, { useEffect } from 'react';
// @ts-ignore
import Drop from 'tether-drop';
import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
import { appChromeService } from '../components/AppChrome/AppChromeService';
import { useGrafana } from '../context/GrafanaContext';
import { keybindingSrv } from '../services/keybindingSrv';
import { GrafanaRouteComponentProps } from './types';
import { GrafanaRouteComponentProps, RouteDescriptor } from './types';
export interface Props extends Omit<GrafanaRouteComponentProps, 'queryParams'> {}
export class GrafanaRoute extends React.Component<Props> {
componentDidMount() {
appChromeService.routeMounted(this.props.route);
export function GrafanaRoute(props: Props) {
const { chrome } = useGrafana();
this.updateBodyClassNames();
this.cleanupDOM();
useEffect(() => {
chrome.routeMounted(props.route);
updateBodyClassNames(props.route);
cleanupDOM();
// unbinds all and re-bind global keybindins
keybindingSrv.reset();
keybindingSrv.initGlobals();
reportPageview();
navigationLogger('GrafanaRoute', false, 'Mounted', this.props.match);
}
navigationLogger('GrafanaRoute', false, 'Mounted', props.match);
componentDidUpdate(prevProps: Props) {
this.cleanupDOM();
return () => {
navigationLogger('GrafanaRoute', false, 'Unmounted', props.route);
updateBodyClassNames(props.route, true);
};
}, [chrome, props.route, props.match]);
useEffect(() => {
cleanupDOM();
reportPageview();
navigationLogger('GrafanaRoute', false, 'Updated', this.props, prevProps);
}
navigationLogger('GrafanaRoute', false, 'Updated', props);
});
componentWillUnmount() {
this.updateBodyClassNames(true);
navigationLogger('GrafanaRoute', false, 'Unmounted', this.props.route);
}
navigationLogger('GrafanaRoute', false, 'Rendered', props.route);
getPageClasses() {
return this.props.route.pageClass ? this.props.route.pageClass.split(' ') : [];
}
return <props.route.component {...props} queryParams={locationSearchToObject(props.location.search)} />;
}
updateBodyClassNames(clear = false) {
for (const cls of this.getPageClasses()) {
if (clear) {
document.body.classList.remove(cls);
} else {
document.body.classList.add(cls);
}
function getPageClasses(route: RouteDescriptor) {
return route.pageClass ? route.pageClass.split(' ') : [];
}
function updateBodyClassNames(route: RouteDescriptor, clear = false) {
for (const cls of getPageClasses(route)) {
if (clear) {
document.body.classList.remove(cls);
} else {
document.body.classList.add(cls);
}
}
cleanupDOM() {
document.body.classList.remove('sidemenu-open--xs');
// cleanup tooltips
const tooltipById = document.getElementById('tooltip');
tooltipById?.parentElement?.removeChild(tooltipById);
const tooltipsByClass = document.querySelectorAll('.tooltip');
for (let i = 0; i < tooltipsByClass.length; i++) {
const tooltip = tooltipsByClass[i];
tooltip.parentElement?.removeChild(tooltip);
}
// cleanup tether-drop
for (const drop of Drop.drops) {
drop.destroy();
}
}
render() {
const { props } = this;
navigationLogger('GrafanaRoute', false, 'Rendered', props.route);
const RouteComponent = props.route.component;
return <RouteComponent {...props} queryParams={locationSearchToObject(props.location.search)} />;
}
}
function cleanupDOM() {
document.body.classList.remove('sidemenu-open--xs');
// cleanup tooltips
const tooltipById = document.getElementById('tooltip');
tooltipById?.parentElement?.removeChild(tooltipById);
const tooltipsByClass = document.querySelectorAll('.tooltip');
for (let i = 0; i < tooltipsByClass.length; i++) {
const tooltip = tooltipsByClass[i];
tooltip.parentElement?.removeChild(tooltip);
}
// cleanup tether-drop
for (const drop of Drop.drops) {
drop.destroy();
}
}

View File

@ -1,27 +1,16 @@
import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config, GrafanaBootConfig, ThemeChangedEvent } from '@grafana/runtime';
import { ThemeChangedEvent } from '@grafana/runtime';
import { ThemeContext } from '@grafana/ui';
import { appEvents } from '../core';
export const ConfigContext = React.createContext<GrafanaBootConfig>(config);
export const ConfigConsumer = ConfigContext.Consumer;
export const provideConfig = (component: React.ComponentType<any>) => {
const ConfigProvider = (props: any) => (
<ConfigContext.Provider value={config}>{React.createElement(component, { ...props })}</ConfigContext.Provider>
);
return ConfigProvider;
};
export const ThemeProvider = ({ children, value }: { children: React.ReactNode; value: GrafanaTheme2 }) => {
const [theme, setTheme] = useState(value);
useEffect(() => {
const sub = appEvents.subscribe(ThemeChangedEvent, (event) => {
//config.theme = event.payload;
setTheme(event.payload);
});
@ -32,7 +21,7 @@ export const ThemeProvider = ({ children, value }: { children: React.ReactNode;
};
export const provideTheme = (component: React.ComponentType<any>, theme: GrafanaTheme2) => {
return provideConfig((props: any) => (
<ThemeProvider value={theme}>{React.createElement(component, { ...props })}</ThemeProvider>
));
return function ThemeProviderWrapper(props: any) {
return <ThemeProvider value={theme}>{React.createElement(component, { ...props })}</ThemeProvider>;
};
};

View File

@ -4,9 +4,11 @@ import { fromPairs } from 'lodash';
import React from 'react';
import { Provider } from 'react-redux';
import { Route, Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { DataSourceApi, DataSourceInstanceSettings, DataSourceRef, QueryEditorProps, ScopedVars } from '@grafana/data';
import { locationService, setDataSourceSrv, 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';
@ -92,9 +94,11 @@ export function setupExplore(options?: SetupOptions): {
const { unmount, container } = render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</Router>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Router history={locationService.getHistory()}>
<Route path="/explore" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</Router>
</GrafanaContext.Provider>
</Provider>
);

View File

@ -1,9 +1,11 @@
import { act, render, screen } from '@testing-library/react';
import React, { Component } from 'react';
import { Route, Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { AppPlugin, PluginType, AppRootProps, NavModelItem } from '@grafana/data';
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';
@ -66,7 +68,9 @@ function renderUnderRouter() {
render(
<Router history={locationService.getHistory()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</GrafanaContext.Provider>
</Router>
);
}

View File

@ -0,0 +1,18 @@
import { GrafanaConfig } from '@grafana/data';
import { BackendSrv, LocationService } from '@grafana/runtime';
import { AppChromeService } from 'app/core/components/AppChrome/AppChromeService';
import { GrafanaContextType } from 'app/core/context/GrafanaContext';
/** Not sure what this should evolve into, just a starting point */
export function getGrafanaContextMock(overrides: Partial<GrafanaContextType> = {}): GrafanaContextType {
return {
chrome: new AppChromeService(),
// eslint-disable-next-line
backend: {} as BackendSrv,
// eslint-disable-next-line
location: {} as LocationService,
// eslint-disable-next-line
config: {} as GrafanaConfig,
...overrides,
};
}