diff --git a/.betterer.results b/.betterer.results index a20d93acd02..3a1fbf12f96 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 0f4627e338c..7e16a531a0c 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -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 - + - + diff --git a/public/app/app.ts b/public/app/app.ts index 078d2c1ed87..defcdd62ffc 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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, diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index a9c94b907d1..2f987dc1b27 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -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
{children}
; @@ -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} />
{children}
- {state.megaMenuOpen && ( - - )} + {state.megaMenuOpen && } ); } diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index bf6c0c031b6..defb56e3de7 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -63,5 +63,3 @@ export class AppChromeService { return useObservable(this.state, this.state.getValue()); } } - -export const appChromeService = new AppChromeService(); diff --git a/public/app/core/components/AppChrome/AppChromeUpdate.tsx b/public/app/core/components/AppChrome/AppChromeUpdate.tsx index 38545bfce8f..fa3364b3f50 100644 --- a/public/app/core/components/AppChrome/AppChromeUpdate.tsx +++ b/public/app/core/components/AppChrome/AppChromeUpdate.tsx @@ -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(({ pageNav, actions }: AppChromeUpdateProps) => { + const { chrome } = useGrafana(); + useEffect(() => { - appChromeService.update({ pageNav, actions }); + chrome.update({ pageNav, actions }); }); return null; }); diff --git a/public/app/core/components/PageNew/Page.test.tsx b/public/app/core/components/PageNew/Page.test.tsx index a28ecec2b7d..20cf2406c54 100644 --- a/public/app/core/components/PageNew/Page.test.tsx +++ b/public/app/core/components/PageNew/Page.test.tsx @@ -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) => { }, ]; + const context = getGrafanaContextMock(); const store = configureStore(); - return render( + const renderResult = render( - -
Children
-
+ + +
Children
+
+
); + + 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 }); diff --git a/public/app/core/components/PageNew/Page.tsx b/public/app/core/components/PageNew/Page.tsx index 2634adc9ec1..81a7818206a 100644 --- a/public/app/core/components/PageNew/Page.tsx +++ b/public/app/core/components/PageNew/Page.tsx @@ -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 (
diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts new file mode 100644 index 00000000000..6540814c2d2 --- /dev/null +++ b/public/app/core/context/GrafanaContext.ts @@ -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(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; +} diff --git a/public/app/core/navigation/GrafanaRoute.test.tsx b/public/app/core/navigation/GrafanaRoute.test.tsx index 514e38dbf45..258f4d568c4 100644 --- a/public/app/core/navigation/GrafanaRoute.test.tsx +++ b/public/app/core/navigation/GrafanaRoute.test.tsx @@ -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( - + + + ); expect(capturedProps.queryParams.query).toBe('hello'); diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index 17a2e93d3b9..aea63ce904e 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -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 {} -export class GrafanaRoute extends React.Component { - 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 ; +} - 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'); +function cleanupDOM() { + document.body.classList.remove('sidemenu-open--xs'); - // cleanup tooltips - const tooltipById = document.getElementById('tooltip'); - tooltipById?.parentElement?.removeChild(tooltipById); + // 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(); - } + const tooltipsByClass = document.querySelectorAll('.tooltip'); + for (let i = 0; i < tooltipsByClass.length; i++) { + const tooltip = tooltipsByClass[i]; + tooltip.parentElement?.removeChild(tooltip); } - - render() { - const { props } = this; - navigationLogger('GrafanaRoute', false, 'Rendered', props.route); - const RouteComponent = props.route.component; - return ; + // cleanup tether-drop + for (const drop of Drop.drops) { + drop.destroy(); } } diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx index eab12a8c089..953927fc9c5 100644 --- a/public/app/core/utils/ConfigProvider.tsx +++ b/public/app/core/utils/ConfigProvider.tsx @@ -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(config); -export const ConfigConsumer = ConfigContext.Consumer; - -export const provideConfig = (component: React.ComponentType) => { - const ConfigProvider = (props: any) => ( - {React.createElement(component, { ...props })} - ); - 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, theme: GrafanaTheme2) => { - return provideConfig((props: any) => ( - {React.createElement(component, { ...props })} - )); + return function ThemeProviderWrapper(props: any) { + return {React.createElement(component, { ...props })}; + }; }; diff --git a/public/app/features/explore/spec/helper/setup.tsx b/public/app/features/explore/spec/helper/setup.tsx index 1e20d654f7e..89abe13e394 100644 --- a/public/app/features/explore/spec/helper/setup.tsx +++ b/public/app/features/explore/spec/helper/setup.tsx @@ -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( - - } /> - + + + } /> + + ); diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index cf9ba1f02ed..b0b38ff30a4 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -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( - } /> + + } /> + ); } diff --git a/public/test/mocks/getGrafanaContextMock.ts b/public/test/mocks/getGrafanaContextMock.ts new file mode 100644 index 00000000000..f671eee493d --- /dev/null +++ b/public/test/mocks/getGrafanaContextMock.ts @@ -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 { + 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, + }; +}