diff --git a/packages/grafana-ui/src/components/Modal/ModalsContext.tsx b/packages/grafana-ui/src/components/Modal/ModalsContext.tsx index d2841532942..aa69fc4ed52 100644 --- a/packages/grafana-ui/src/components/Modal/ModalsContext.tsx +++ b/packages/grafana-ui/src/components/Modal/ModalsContext.tsx @@ -22,6 +22,10 @@ interface ModalsProviderProps { props?: any; } +/** + * @deprecated. + * Not the real implementation used by core. + */ export class ModalsProvider extends Component<ModalsProviderProps, ModalsContextState> { constructor(props: ModalsProviderProps) { super(props); diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 9cf0b4d0aaf..6a527baa769 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -5,7 +5,7 @@ import { Router, Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { CompatRouter, CompatRoute } from 'react-router-dom-v5-compat'; import { config, locationService, navigationLogger, reportInteraction } from '@grafana/runtime'; -import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, ModalsProvider, PortalContainer } from '@grafana/ui'; +import { ErrorBoundaryAlert, GlobalStyles, ModalRoot, PortalContainer } from '@grafana/ui'; import { getAppRoutes } from 'app/routes/routes'; import { store } from 'app/store/store'; @@ -15,6 +15,7 @@ import { GrafanaApp } from './app'; import { AppChrome } from './core/components/AppChrome/AppChrome'; import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList'; import { GrafanaContext } from './core/context/GrafanaContext'; +import { ModalsContextProvider } from './core/context/ModalsContextProvider'; import { GrafanaRoute } from './core/navigation/GrafanaRoute'; import { RouteDescriptor } from './core/navigation/types'; import { contextSrv } from './core/services/context_srv'; @@ -102,11 +103,11 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState actions={[]} options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }} > - <ModalsProvider> - <GlobalStyles /> - <div className="grafana-app"> - <Router history={locationService.getHistory()}> - <CompatRouter> + <Router history={locationService.getHistory()}> + <CompatRouter> + <ModalsContextProvider> + <GlobalStyles /> + <div className="grafana-app"> <AppChrome> {pageBanners.map((Banner, index) => ( <Banner key={index.toString()} /> @@ -118,13 +119,13 @@ export class AppWrapper extends React.Component<AppWrapperProps, AppWrapperState <Hook key={index.toString()} /> ))} </AppChrome> - </CompatRouter> - </Router> - </div> - <LiveConnectionWarning /> - <ModalRoot /> - <PortalContainer /> - </ModalsProvider> + </div> + <LiveConnectionWarning /> + <ModalRoot /> + <PortalContainer /> + </ModalsContextProvider> + </CompatRouter> + </Router> </KBarProvider> </ThemeProvider> </GrafanaContext.Provider> diff --git a/public/app/app.ts b/public/app/app.ts index 613b40d560c..5796b18c21e 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -58,7 +58,6 @@ import { initIconCache } from './core/icons/iconBundle'; import { initializeI18n } from './core/internationalization'; import { setMonacoEnv } from './core/monacoEnv'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; -import { ModalManager } from './core/services/ModalManager'; import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; import { contextSrv } from './core/services/context_srv'; @@ -207,10 +206,6 @@ export class GrafanaApp { setDataSourceSrv(dataSourceSrv); initWindowRuntime(); - // init modal manager - const modalManager = new ModalManager(); - modalManager.init(); - let preloadResults: PluginPreloadResult[] = []; if (contextSrv.user.orgRole !== '') { diff --git a/public/app/core/components/modals/AngularModalProxy.tsx b/public/app/core/components/modals/AngularModalProxy.tsx deleted file mode 100644 index fe21787dc3b..00000000000 --- a/public/app/core/components/modals/AngularModalProxy.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -import { ModalRoot, ModalsProvider } from '@grafana/ui'; - -import { connectWithProvider } from '../../utils/connectWithReduxStore'; - -/** - * Component that enables rendering React modals from Angular - */ -export const AngularModalProxy = connectWithProvider((props: Record<string, unknown>) => { - return ( - <> - <ModalsProvider {...props}> - <ModalRoot /> - </ModalsProvider> - </> - ); -}); diff --git a/public/app/core/context/ModalsContextProvider.tsx b/public/app/core/context/ModalsContextProvider.tsx new file mode 100644 index 00000000000..897588a7139 --- /dev/null +++ b/public/app/core/context/ModalsContextProvider.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { textUtil } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { ConfirmModal, ConfirmModalProps, ModalsContext } from '@grafana/ui'; +import { ModalsContextState } from '@grafana/ui/src/components/Modal/ModalsContext'; +import { ShowConfirmModalEvent, ShowModalReactEvent } from 'app/types/events'; + +import appEvents from '../app_events'; + +export interface Props { + children: React.ReactNode; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface StateType<TProps = any> { + component: React.ComponentType<TProps> | null; + props: TProps; +} + +/** + * Implements the ModalsContext state logic (not used that much, only needed in edge cases) + * Also implements the handling of the events ShowModalReactEvent and ShowConfirmModalEvent. + */ +export function ModalsContextProvider(props: Props) { + const [state, setState] = useState<StateType>({ + component: null, + props: {}, + }); + + const contextValue: ModalsContextState = useMemo(() => { + function showModal<TProps = {}>(component: React.ComponentType<TProps>, props: TProps) { + setState({ component, props }); + } + + function hideModal() { + setState({ component: null, props: {} }); + } + + return { + component: state.component, + props: { + ...state.props, + isOpen: true, + onDismiss: hideModal, + }, + showModal, + hideModal, + }; + }, [state]); + + useEffect(() => { + appEvents.subscribe(ShowModalReactEvent, ({ payload }) => { + setState({ + component: payload.component, + props: payload.props, + }); + }); + + appEvents.subscribe(ShowConfirmModalEvent, (e) => { + showConfirmModal(e, setState); + }); + + // Dismiss the modal when the route changes (if there's a link in the modal) + let prevPath = ''; + locationService.getHistory().listen((location) => { + if (location.pathname !== prevPath) { + setState({ component: null, props: {} }); + } + prevPath = location.pathname; + }); + }, []); + + return <ModalsContext.Provider value={contextValue}>{props.children}</ModalsContext.Provider>; +} + +function showConfirmModal({ payload }: ShowConfirmModalEvent, setState: (state: StateType) => void) { + const { + confirmText, + onConfirm = () => undefined, + onDismiss, + text2, + altActionText, + onAltAction, + noText, + text, + text2htmlBind, + yesText = 'Yes', + icon, + title = 'Confirm', + yesButtonVariant, + } = payload; + + const hideModal = () => setState({ component: null, props: {} }); + + const props: ConfirmModalProps = { + confirmText: yesText, + confirmButtonVariant: yesButtonVariant, + confirmationText: confirmText, + icon, + title, + body: text, + description: text2 && text2htmlBind ? textUtil.sanitize(text2) : text2, + isOpen: true, + dismissText: noText, + onConfirm: () => { + onConfirm(); + hideModal(); + }, + onDismiss: () => { + onDismiss?.(); + hideModal(); + }, + onAlternative: onAltAction + ? () => { + onAltAction(); + hideModal(); + } + : undefined, + alternativeText: altActionText, + }; + + setState({ component: ConfirmModal, props }); +} diff --git a/public/app/core/services/ModalManager.ts b/public/app/core/services/ModalManager.ts deleted file mode 100644 index 1c04169bcda..00000000000 --- a/public/app/core/services/ModalManager.ts +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; - -import { textUtil } from '@grafana/data'; -import { config, CopyPanelEvent } from '@grafana/runtime'; -import { ConfirmModal, ConfirmModalProps } from '@grafana/ui'; -import appEvents from 'app/core/app_events'; -import { copyPanel } from 'app/features/dashboard/utils/panel'; - -import { - ShowConfirmModalEvent, - ShowConfirmModalPayload, - ShowModalReactEvent, - ShowModalReactPayload, -} from '../../types/events'; -import { AngularModalProxy } from '../components/modals/AngularModalProxy'; -import { provideTheme } from '../utils/ConfigProvider'; - -export class ModalManager { - reactModalRoot = document.body; - reactModalNode = document.createElement('div'); - root = createRoot(this.reactModalNode); - - init() { - appEvents.subscribe(ShowConfirmModalEvent, (e) => this.showConfirmModal(e.payload)); - appEvents.subscribe(ShowModalReactEvent, (e) => this.showModalReact(e.payload)); - appEvents.subscribe(CopyPanelEvent, (e) => copyPanel(e.payload)); - } - - showModalReact(options: ShowModalReactPayload) { - const { component, props } = options; - const modalProps = { - component, - props: { - ...props, - isOpen: true, - onDismiss: this.onReactModalDismiss, - }, - }; - - const elem = React.createElement(provideTheme(AngularModalProxy, config.theme2), modalProps); - this.reactModalRoot.appendChild(this.reactModalNode); - this.root.render(elem); - } - - onReactModalDismiss = () => { - this.root.render(null); - this.reactModalRoot.removeChild(this.reactModalNode); - }; - - showConfirmModal(payload: ShowConfirmModalPayload) { - const { - confirmText, - onConfirm = () => undefined, - onDismiss, - text2, - altActionText, - onAltAction, - noText, - text, - text2htmlBind, - yesText = 'Yes', - icon, - title = 'Confirm', - yesButtonVariant, - } = payload; - const props: ConfirmModalProps = { - confirmText: yesText, - confirmButtonVariant: yesButtonVariant, - confirmationText: confirmText, - icon, - title, - body: text, - description: text2 && text2htmlBind ? textUtil.sanitize(text2) : text2, - isOpen: true, - dismissText: noText, - onConfirm: () => { - onConfirm(); - this.onReactModalDismiss(); - }, - onDismiss: () => { - onDismiss?.(); - this.onReactModalDismiss(); - }, - onAlternative: onAltAction - ? () => { - onAltAction(); - this.onReactModalDismiss(); - } - : undefined, - alternativeText: altActionText, - }; - const modalProps = { - component: ConfirmModal, - props, - }; - - const elem = React.createElement(provideTheme(AngularModalProxy, config.theme2), modalProps); - this.reactModalRoot.appendChild(this.reactModalNode); - this.root.render(elem); - } -} diff --git a/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx b/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx index 4c6e6431ca2..d22ac005efd 100644 --- a/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx +++ b/public/app/features/alerting/unified/integration/AlertRulesDrawer.tsx @@ -11,12 +11,12 @@ const AlertRulesDrawerContent = React.lazy( interface Props { dashboardUid: string; - onClose: () => void; + onDismiss: () => void; } -export function AlertRulesDrawer({ dashboardUid, onClose }: Props) { +export function AlertRulesDrawer({ dashboardUid, onDismiss }: Props) { return ( - <Drawer title="Alert rules" subtitle={<DrawerSubtitle dashboardUid={dashboardUid} />} onClose={onClose} size="lg"> + <Drawer title="Alert rules" subtitle={<DrawerSubtitle dashboardUid={dashboardUid} />} onClose={onDismiss} size="lg"> <React.Suspense fallback={<LoadingPlaceholder text="Loading alert rules" />}> <AlertRulesDrawerContent dashboardUid={dashboardUid} /> </React.Suspense> diff --git a/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx b/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx index 963ceec182d..d7f4b18b857 100644 --- a/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx +++ b/public/app/features/alerting/unified/integration/AlertRulesToolbarButton.tsx @@ -1,9 +1,8 @@ -import React from 'react'; +import React, { useContext } from 'react'; -import { ToolbarButton } from '@grafana/ui'; +import { ModalsContext, ToolbarButton } from '@grafana/ui'; import { t } from '../../../../core/internationalization'; -import { useDashNavModalController } from '../../../dashboard/components/DashNav/DashNav'; import { alertRuleApi } from '../api/alertRuleApi'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; @@ -14,7 +13,7 @@ interface AlertRulesToolbarButtonProps { } export default function AlertRulesToolbarButton({ dashboardUid }: AlertRulesToolbarButtonProps) { - const { showModal, hideModal } = useDashNavModalController(); + const { showModal, hideModal } = useContext(ModalsContext); const { data: namespaces = [] } = alertRuleApi.endpoints.prometheusRuleNamespaces.useQuery({ ruleSourceName: GRAFANA_RULES_SOURCE_NAME, @@ -25,11 +24,18 @@ export default function AlertRulesToolbarButton({ dashboardUid }: AlertRulesTool return null; } + const onShowDrawer = () => { + showModal(AlertRulesDrawer, { + dashboardUid: dashboardUid, + onDismiss: hideModal, + }); + }; + return ( <ToolbarButton tooltip={t('dashboard.toolbar.alert-rules', 'Alert rules')} icon="bell" - onClick={() => showModal(<AlertRulesDrawer dashboardUid={dashboardUid} onClose={hideModal} />)} + onClick={onShowDrawer} key="button-alerting" /> ); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index 7950a1f1042..4be544ba1ec 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -9,7 +9,6 @@ import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import { contextSrv } from 'app/core/core'; import { Trans, t } from 'app/core/internationalization'; -import { DashNavModalContextProvider, DashNavModalRoot } from 'app/features/dashboard/components/DashNav/DashNav'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; @@ -510,12 +509,7 @@ export function ToolbarActions({ dashboard }: Props) { lastGroup = action.group; } - return ( - <DashNavModalContextProvider> - <ToolbarButtonRow alignment="right">{actionElements}</ToolbarButtonRow> - <DashNavModalRoot /> - </DashNavModalContextProvider> - ); + return <ToolbarButtonRow alignment="right">{actionElements}</ToolbarButtonRow>; } function addDynamicActions( diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index c9a88c6e97f..181677d5f2b 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -2,7 +2,6 @@ import { css } from '@emotion/css'; import React, { ReactNode } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import { createStateContext } from 'react-use'; import { textUtil } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src'; @@ -49,27 +48,6 @@ const mapDispatchToProps = { updateTimeZoneForSession, }; -export const [useDashNavModelContext, DashNavModalContextProvider] = createStateContext<{ component: React.ReactNode }>( - { - component: null, - } -); - -export function useDashNavModalController() { - const [_, setContextState] = useDashNavModelContext(); - - return { - showModal: (component: React.ReactNode) => setContextState({ component }), - hideModal: () => setContextState({ component: null }), - }; -} - -export function DashNavModalRoot() { - const [contextState] = useDashNavModelContext(); - - return <>{contextState.component}</>; -} - const connector = connect(null, mapDispatchToProps); const selectors = e2eSelectors.pages.Dashboard.DashNav; @@ -363,12 +341,11 @@ export const DashNav = React.memo<Props>((props) => { return ( <AppChromeUpdate actions={ - <DashNavModalContextProvider> + <> {renderLeftActions()} <NavToolbarSeparator leftActionsSeparator /> <ToolbarButtonRow alignment="right">{renderRightActions()}</ToolbarButtonRow> - <DashNavModalRoot /> - </DashNavModalContextProvider> + </> } /> ); diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx index d36844ac808..ae104700620 100644 --- a/public/app/features/org/OrgDetailsPage.test.tsx +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -5,7 +5,6 @@ import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { TestProvider } from 'test/helpers/TestProvider'; import { NavModel } from '@grafana/data'; -import { ModalManager } from 'app/core/services/ModalManager'; import { backendSrv } from '../../core/services/backend_srv'; import { Organization } from '../../types'; @@ -81,7 +80,6 @@ describe('Render', () => { }); it('should show a modal when submitting', async () => { - new ModalManager().init(); setup({ organization: { name: 'Cool org', diff --git a/public/test/helpers/TestProvider.tsx b/public/test/helpers/TestProvider.tsx index b7b232f041a..873c85c415b 100644 --- a/public/test/helpers/TestProvider.tsx +++ b/public/test/helpers/TestProvider.tsx @@ -5,7 +5,9 @@ import { Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { locationService } from '@grafana/runtime'; +import { ModalRoot } from '@grafana/ui'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; +import { ModalsContextProvider } from 'app/core/context/ModalsContextProvider'; import { configureStore } from 'app/store/configureStore'; import { StoreState } from 'app/types/store'; @@ -30,7 +32,10 @@ export function TestProvider(props: Props) { return ( <Provider store={store}> <Router history={locationService.getHistory()}> - <GrafanaContext.Provider value={context}>{children}</GrafanaContext.Provider> + <ModalsContextProvider> + <GrafanaContext.Provider value={context}>{children}</GrafanaContext.Provider> + <ModalRoot /> + </ModalsContextProvider> </Router> </Provider> );