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 <josh@trtr.co>
This commit is contained in:
Torkel Ödegaard 2024-03-28 13:26:57 +01:00 committed by GitHub
parent ce57ce4125
commit e90b87589f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 165 additions and 181 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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