From 9a284d456098fb6808b6a8aaf26fa4658d4aeca9 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:51:38 +0100 Subject: [PATCH] [v11.0.x] ModalsContext: Unify modals context and manager (#85342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModalsContext: Unify modals context and manager (#84916) * ModalsContext: Unify modals context and manager * Clear on location change * fixes * Update * use generics to avoid anys --------- Co-authored-by: joshhunt (cherry picked from commit e90b87589f7856fa56a60f2cb552a913de870fe0) Co-authored-by: Torkel Ödegaard --- .../src/components/Modal/ModalsContext.tsx | 4 + public/app/AppWrapper.tsx | 27 ++-- public/app/app.ts | 5 - .../components/modals/AngularModalProxy.tsx | 18 --- .../core/context/ModalsContextProvider.tsx | 124 ++++++++++++++++++ public/app/core/services/ModalManager.ts | 102 -------------- .../unified/integration/AlertRulesDrawer.tsx | 6 +- .../integration/AlertRulesToolbarButton.tsx | 16 ++- .../scene/NavToolbarActions.tsx | 8 +- .../dashboard/components/DashNav/DashNav.tsx | 27 +--- .../app/features/org/OrgDetailsPage.test.tsx | 2 - public/test/helpers/TestProvider.tsx | 7 +- 12 files changed, 165 insertions(+), 181 deletions(-) delete mode 100644 public/app/core/components/modals/AngularModalProxy.tsx create mode 100644 public/app/core/context/ModalsContextProvider.tsx delete mode 100644 public/app/core/services/ModalManager.ts 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 { 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 - - -
- - + + + + +
{pageBanners.map((Banner, index) => ( @@ -118,13 +119,13 @@ export class AppWrapper extends React.Component ))} - - -
- - - - +
+ + + + + + 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) => { - return ( - <> - - - - - ); -}); 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 { + component: React.ComponentType | 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({ + component: null, + props: {}, + }); + + const contextValue: ModalsContextState = useMemo(() => { + function showModal(component: React.ComponentType, 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 {props.children}; +} + +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 ( - } onClose={onClose} size="lg"> + } onClose={onDismiss} size="lg"> }> 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 ( showModal()} + 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 ( - - {actionElements} - - - ); + return {actionElements}; } 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) => { return ( + <> {renderLeftActions()} {renderRightActions()} - - + } /> ); 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 ( - {children} + + {children} + + );