Chore: modal refactor

This commit is contained in:
Alexander Zobnin
2020-05-19 17:31:03 +03:00
parent 0c2197e4ad
commit f6e127e55c
7 changed files with 280 additions and 154 deletions

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { config, GrafanaBootConfig } from '@grafana/runtime';
import { ThemeContext, getTheme } from '@grafana/ui';
import { GrafanaThemeType } from '@grafana/data';
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 getCurrentThemeName = () =>
config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark;
export const getCurrentTheme = () => getTheme(getCurrentThemeName());
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
return (
<ConfigConsumer>
{config => {
return <ThemeContext.Provider value={getCurrentTheme()}>{children}</ThemeContext.Provider>;
}}
</ConfigConsumer>
);
};
export const provideTheme = (component: React.ComponentType<any>) => {
return provideConfig((props: any) => <ThemeProvider>{React.createElement(component, { ...props })}</ThemeProvider>);
};

View File

@@ -0,0 +1,70 @@
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import { provideTheme } from '../ConfigProvider/ConfigProvider';
interface ModalWrapperProps {
showModal: <T>(component: React.ComponentType<T>, props: T) => void;
hideModal: () => void;
}
type ModalWrapper<T> = FC<ModalWrapperProps>;
interface Props {
children: ModalWrapper<any>;
}
interface State {
component: React.ComponentType<any> | null;
props: any;
}
export class ModalController extends React.Component<Props, State> {
modalRoot = document.body;
modalNode = document.createElement('div');
constructor(props: Props) {
super(props);
this.state = {
component: null,
props: {},
};
}
showModal = (component: React.ComponentType<any>, props: any) => {
this.setState({
component,
props
});
};
hideModal = () => {
this.modalRoot.removeChild(this.modalNode);
this.setState({
component: null,
props: {},
});
};
renderModal() {
const { component, props } = this.state;
if (!component) {
return null;
}
this.modalRoot.appendChild(this.modalNode);
const modal = React.createElement(provideTheme(component), props);
return ReactDOM.createPortal(modal, this.modalNode);
}
render() {
const { children } = this.props;
const ChildrenComponent = children;
return (
<>
<ChildrenComponent showModal={this.showModal} hideModal={this.hideModal} />
{this.renderModal()}
</>
);
}
}

View File

@@ -1,3 +1,4 @@
export { GFHeartIcon } from './GFHeartIcon/GFHeartIcon'; export { GFHeartIcon } from './GFHeartIcon/GFHeartIcon';
export { FAIcon } from './FAIcon/FAIcon'; export { FAIcon } from './FAIcon/FAIcon';
export { Tooltip } from './Tooltip/Tooltip'; export { Tooltip } from './Tooltip/Tooltip';
export { ModalController } from './Modal/ModalController';

View File

@@ -1,25 +1,24 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import ReactDOM from 'react-dom'; import { cx, css } from 'emotion';
import classNames from 'classnames';
import { ZBX_ACK_ACTION_ADD_MESSAGE, ZBX_ACK_ACTION_ACK, ZBX_ACK_ACTION_CHANGE_SEVERITY, ZBX_ACK_ACTION_CLOSE } from '../../datasource-zabbix/constants'; import { ZBX_ACK_ACTION_ADD_MESSAGE, ZBX_ACK_ACTION_ACK, ZBX_ACK_ACTION_CHANGE_SEVERITY, ZBX_ACK_ACTION_CLOSE } from '../../datasource-zabbix/constants';
import { Button, Input, VerticalGroup, Spinner } from '@grafana/ui'; import { Button, VerticalGroup, Spinner, Modal, Input, Forms, stylesFactory, withTheme, Themeable } from '@grafana/ui';
import { FAIcon } from '../../components'; import { FAIcon } from '../../components';
import * as grafanaUi from '@grafana/ui'; import * as grafanaUi from '@grafana/ui';
const Checkbox: any = grafanaUi.Forms?.Checkbox || (grafanaUi as any).Checkbox; import { GrafanaTheme } from '@grafana/data';
const RadioButtonGroup: any = grafanaUi.Forms?.RadioButtonGroup || (grafanaUi as any).RadioButtonGroup; const Checkbox: any = Forms?.Checkbox || (grafanaUi as any).Checkbox;
const RadioButtonGroup: any = Forms?.RadioButtonGroup || (grafanaUi as any).RadioButtonGroup;
const KEYBOARD_ENTER_KEY = 13; const KEYBOARD_ENTER_KEY = 13;
const KEYBOARD_ESCAPE_KEY = 27; const KEYBOARD_ESCAPE_KEY = 27;
interface Props { interface Props extends Themeable {
canAck?: boolean; canAck?: boolean;
canClose?: boolean; canClose?: boolean;
isOpen?: boolean;
severity?: number; severity?: number;
withBackdrop?: boolean; withBackdrop?: boolean;
onSubmit: (data?: AckProblemData) => Promise<any> | any; onSubmit: (data?: AckProblemData) => Promise<any> | any;
onClose?: () => void; onDismiss?: () => void;
} }
interface State { interface State {
@@ -50,9 +49,7 @@ const severityOptions = [
{value: 5, label: 'Disaster'} {value: 5, label: 'Disaster'}
]; ];
export class AckModal extends PureComponent<Props, State> { export class AckModalUnthemed extends PureComponent<Props, State> {
modalContainer: HTMLElement;
static defaultProps: Partial<Props> = { static defaultProps: Partial<Props> = {
withBackdrop: true, withBackdrop: true,
}; };
@@ -70,8 +67,6 @@ export class AckModal extends PureComponent<Props, State> {
selectedSeverity: props.severity || 0, selectedSeverity: props.severity || 0,
loading: false, loading: false,
}; };
this.modalContainer = document.body;
} }
handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -108,7 +103,7 @@ export class AckModal extends PureComponent<Props, State> {
dismiss = () => { dismiss = () => {
this.setState({ value: '', error: false, errorMessage: '', ackError: '', loading: false }); this.setState({ value: '', error: false, errorMessage: '', ackError: '', loading: false });
this.props.onClose(); this.props.onDismiss();
} }
submit = () => { submit = () => {
@@ -141,7 +136,7 @@ export class AckModal extends PureComponent<Props, State> {
} }
ackData.action = action; ackData.action = action;
this.props.onSubmit(ackData).then((response) => { this.props.onSubmit(ackData).then(() => {
this.dismiss(); this.dismiss();
}).catch(err => { }).catch(err => {
this.setState({ this.setState({
@@ -151,87 +146,131 @@ export class AckModal extends PureComponent<Props, State> {
}); });
} }
render() { renderActions() {
const { canClose, canAck } = this.props; const { canClose } = this.props;
if (!this.props.isOpen || !this.modalContainer) {
return null;
}
const inputClass = classNames({ 'zbx-ack-error': this.state.error }); const actions = [
<Checkbox key="ack" label="Acknowledge" value={this.state.acknowledge} onChange={this.onAcknowledgeToggle} />,
const modalNode = ( <Checkbox
<div className="modal modal--narrow zbx-ack-modal" key="modal"> key="change-severity"
<div className="modal-body"> label="Change severity"
<div className="modal-header"> description=""
<h2 className="modal-header-title" style={{ display: 'flex' }}> value={this.state.changeSeverity}
{this.state.loading ? <Spinner size={18} /> : <FAIcon icon="reply-all" />} onChange={this.onChangeSeverityToggle}
<span className="p-l-1">Acknowledge Problem</span> />,
</h2> this.state.changeSeverity &&
<RadioButtonGroup
<a className="modal-header-close" onClick={this.dismiss}> key="severity"
<FAIcon icon="remove" /> size="sm"
</a> options={severityOptions}
</div> value={this.state.selectedSeverity}
<div className="modal-content"> onChange={this.onChangeSelectedSeverity}
<div className="gf-form"> />,
<label className="gf-form-hint"> canClose &&
<Input className={inputClass} <Checkbox key="close" label="Close problem" disabled={!canClose} value={this.state.closeProblem} onChange={this.onCloseProblemToggle} />,
type="text"
name="message"
placeholder="Message"
maxLength={64}
autoComplete="off"
autoFocus={true}
value={this.state.value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}>
</Input>
<small className="gf-form-hint-text muted">Press Enter to submit</small>
{this.state.error &&
<small className="gf-form-hint-text muted ack-error-message">{this.state.errorMessage}</small>
}
</label>
</div>
<div className="gf-form">
<VerticalGroup>
<Checkbox label="Acknowledge" value={this.state.acknowledge} onChange={this.onAcknowledgeToggle} />
<Checkbox label="Change severity" description="" value={this.state.changeSeverity} onChange={this.onChangeSeverityToggle} />
{this.state.changeSeverity &&
<RadioButtonGroup
size="sm"
options={severityOptions}
value={this.state.selectedSeverity}
onChange={this.onChangeSelectedSeverity}
/>
}
{canClose &&
<Checkbox label="Close problem" disabled={!canClose} value={this.state.closeProblem} onChange={this.onCloseProblemToggle} />
}
</VerticalGroup>
</div>
{this.state.ackError &&
<div className="gf-form ack-request-error">
<span className="ack-error-message">{this.state.ackError}</span>
</div>
}
<div className="gf-form-button-row text-center">
<Button variant="primary" onClick={this.submit}>Update</Button>
<Button variant="secondary" onClick={this.dismiss}>Cancel</Button>
</div>
</div>
</div>
</div>
);
const modalNodeWithBackdrop = [
modalNode,
<div className="modal-backdrop in" key="modal-backdrop" onClick={this.handleBackdropClick}></div>
]; ];
const modal = this.props.withBackdrop ? modalNodeWithBackdrop : modalNode; // <VerticalGroup /> doesn't handle empty elements properly, so don't return it
return ReactDOM.createPortal(modal, this.modalContainer); return actions.filter(e => e);
}
render() {
const { theme } = this.props;
const styles = getStyles(theme);
const modalClass = cx(styles.modal);
const modalTitleClass = cx(styles.modalHeaderTitle);
const inputGroupClass = cx('gf-form', styles.inputGroup);
const inputClass = cx(this.state.error && styles.input);
const inputHintClass = cx('gf-form-hint-text', styles.inputHint);
const inputErrorClass = cx('gf-form-hint-text', styles.inputError);
return (
<Modal
isOpen={true}
onDismiss={this.dismiss}
className={modalClass}
title={
<div className={modalTitleClass}>
{this.state.loading ? <Spinner size={18} /> : <FAIcon icon="reply-all" />}
<span className="p-l-1">Acknowledge Problem</span>
</div>
}
>
<div className={inputGroupClass}>
<label className="gf-form-hint">
<Input className={inputClass}
type="text"
name="message"
placeholder="Message"
maxLength={64}
autoComplete="off"
autoFocus={true}
value={this.state.value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}>
</Input>
<small className={inputHintClass}>Press Enter to submit</small>
{this.state.error &&
<small className={inputErrorClass}>{this.state.errorMessage}</small>
}
</label>
</div>
<div className="gf-form">
<VerticalGroup>
{this.renderActions()}
</VerticalGroup>
</div>
{this.state.ackError &&
<div className="gf-form ack-request-error">
<span className={styles.ackError}>{this.state.ackError}</span>
</div>
}
<div className="gf-form-button-row text-center">
<Button variant="primary" onClick={this.submit}>Update</Button>
<Button variant="secondary" onClick={this.dismiss}>Cancel</Button>
</div>
</Modal>
);
} }
} }
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
modal: css`
width: 500px;
`,
modalHeaderTitle: css`
font-size: ${theme.typography.heading.h3};
padding-top: ${theme.spacing.sm};
margin: 0 ${theme.spacing.md};
display: flex;
`,
inputGroup: css`
margin-bottom: 16px;
`,
input: css`
border-color: ${theme.colors.red};
border-radius: 2px;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${theme.colors.pageBg}, 0 0 0px 4px ${theme.colors.red};
`,
inputHint: css`
display: inherit;
float: right;
color: ${theme.colors.textWeak};
`,
inputError: css`
float: left;
color: ${theme.colors.red};
`,
ackError: css`
color: ${theme.colors.red};
`,
};
});
export const AckModal = withTheme(AckModalUnthemed);

View File

@@ -6,10 +6,10 @@ import { isNewProblem, formatLastChange } from '../../utils';
import { ProblemsPanelOptions, TriggerSeverity } from '../../types'; import { ProblemsPanelOptions, TriggerSeverity } from '../../types';
import { AckProblemData, AckModal } from '../AckModal'; import { AckProblemData, AckModal } from '../AckModal';
import EventTag from '../EventTag'; import EventTag from '../EventTag';
import Tooltip from '../../../components/Tooltip/Tooltip';
import AlertAcknowledges from './AlertAcknowledges'; import AlertAcknowledges from './AlertAcknowledges';
import AlertIcon from './AlertIcon'; import AlertIcon from './AlertIcon';
import { ProblemDTO, ZBXTag } from '../../../datasource-zabbix/types'; import { ProblemDTO, ZBXTag } from '../../../datasource-zabbix/types';
import { ModalController, Tooltip } from '../../../components';
interface AlertCardProps { interface AlertCardProps {
problem: ProblemDTO; problem: ProblemDTO;
@@ -18,15 +18,7 @@ interface AlertCardProps {
onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => Promise<any> | any; onProblemAck?: (problem: ProblemDTO, data: AckProblemData) => Promise<any> | any;
} }
interface AlertCardState { export default class AlertCard extends PureComponent<AlertCardProps> {
showAckDialog: boolean;
}
export default class AlertCard extends PureComponent<AlertCardProps, AlertCardState> {
constructor(props) {
super(props);
this.state = { showAckDialog: false };
}
handleTagClick = (tag: ZBXTag, ctrlKey?: boolean, shiftKey?: boolean) => { handleTagClick = (tag: ZBXTag, ctrlKey?: boolean, shiftKey?: boolean) => {
if (this.props.onTagClick) { if (this.props.onTagClick) {
@@ -36,23 +28,7 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
ackProblem = (data: AckProblemData) => { ackProblem = (data: AckProblemData) => {
const problem = this.props.problem; const problem = this.props.problem;
return this.props.onProblemAck(problem, data).then(result => { return this.props.onProblemAck(problem, data);
this.closeAckDialog();
}).catch(err => {
console.log(err);
this.closeAckDialog();
});
}
showAckDialog = () => {
const problem = this.props.problem;
if (problem.showAckButton) {
this.setState({ showAckDialog: true });
}
}
closeAckDialog = () => {
this.setState({ showAckDialog: false });
} }
render() { render() {
@@ -61,10 +37,11 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
const cardClass = classNames('alert-rule-item', 'zbx-trigger-card', { 'zbx-trigger-highlighted': panelOptions.highlightBackground }); const cardClass = classNames('alert-rule-item', 'zbx-trigger-card', { 'zbx-trigger-highlighted': panelOptions.highlightBackground });
const descriptionClass = classNames('alert-rule-item__text', { 'zbx-description--newline': panelOptions.descriptionAtNewLine }); const descriptionClass = classNames('alert-rule-item__text', { 'zbx-description--newline': panelOptions.descriptionAtNewLine });
const problemSeverity = Number(problem.severity);
let severityDesc: TriggerSeverity; let severityDesc: TriggerSeverity;
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === Number(problem.severity)); severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
if (problem.severity) { if (problem.severity) {
severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === Number(problem.severity)); severityDesc = _.find(panelOptions.triggerSeverity, s => s.priority === problemSeverity);
} }
const lastchange = formatLastChange(problem.timestamp, panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat); const lastchange = formatLastChange(problem.timestamp, panelOptions.customLastChangeFormat && panelOptions.lastChangeFormat);
@@ -161,14 +138,24 @@ export default class AlertCard extends PureComponent<AlertCardProps, AlertCardSt
</Tooltip> </Tooltip>
)} )}
{problem.eventid && ( {problem.eventid && (
<AlertAcknowledgesButton problem={problem} onClick={this.showAckDialog} /> <ModalController>
{({ showModal, hideModal }) => (
<AlertAcknowledgesButton
problem={problem}
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
)} )}
</div> </div>
</div> </div>
<AckModal
isOpen={this.state.showAckDialog}
onSubmit={this.ackProblem}
onClose={this.closeAckDialog} />
</li> </li>
); );
} }

View File

@@ -9,7 +9,7 @@ import EventTag from '../EventTag';
import ProblemStatusBar from './ProblemStatusBar'; import ProblemStatusBar from './ProblemStatusBar';
import AcknowledgesList from './AcknowledgesList'; import AcknowledgesList from './AcknowledgesList';
import ProblemTimeline from './ProblemTimeline'; import ProblemTimeline from './ProblemTimeline';
import { FAIcon, Tooltip } from '../../../components'; import { FAIcon, Tooltip, ModalController } from '../../../components';
import { renderUrl } from '../../utils'; import { renderUrl } from '../../utils';
import { getLocationSrv } from '@grafana/runtime'; import { getLocationSrv } from '@grafana/runtime';
@@ -28,17 +28,15 @@ interface ProblemDetailsState {
events: ZBXEvent[]; events: ZBXEvent[];
alerts: ZBXAlert[]; alerts: ZBXAlert[];
show: boolean; show: boolean;
showAckDialog: boolean;
} }
export default class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDetailsState> { export class ProblemDetails extends PureComponent<ProblemDetailsProps, ProblemDetailsState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
events: [], events: [],
alerts: [], alerts: [],
show: false, show: false,
showAckDialog: false,
}; };
} }
@@ -76,17 +74,7 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
ackProblem = (data: AckProblemData) => { ackProblem = (data: AckProblemData) => {
const problem = this.props.original as ProblemDTO; const problem = this.props.original as ProblemDTO;
return this.props.onProblemAck(problem, data).then(result => { return this.props.onProblemAck(problem, data);
this.closeAckDialog();
});
}
showAckDialog = () => {
this.setState({ showAckDialog: true });
}
closeAckDialog = () => {
this.setState({ showAckDialog: false });
} }
openInExplore = () => { openInExplore = () => {
@@ -149,10 +137,23 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
<ProblemStatusBar problem={problem} alerts={alerts} className={compactStatusBar && 'compact'} /> <ProblemStatusBar problem={problem} alerts={alerts} className={compactStatusBar && 'compact'} />
{problem.showAckButton && {problem.showAckButton &&
<div className="problem-actions"> <div className="problem-actions">
<ProblemActionButton className="navbar-button navbar-button--settings" <ModalController>
icon="reply-all" {({ showModal, hideModal }) => (
tooltip="Acknowledge problem" <ProblemActionButton
onClick={this.showAckDialog} /> className="navbar-button navbar-button--settings"
icon="reply-all"
tooltip="Acknowledge problem"
onClick={() => {
showModal(AckModal, {
canClose: problem.manual_close === '1',
severity: problemSeverity,
onSubmit: this.ackProblem,
onDismiss: hideModal,
});
}}
/>
)}
</ModalController>
</div> </div>
} }
</div> </div>
@@ -208,12 +209,6 @@ export default class ProblemDetails extends PureComponent<ProblemDetailsProps, P
{problem.groups && <ProblemGroups groups={problem.groups} className="problem-details-right-item" />} {problem.groups && <ProblemGroups groups={problem.groups} className="problem-details-right-item" />}
{problem.hosts && <ProblemHosts hosts={problem.hosts} className="problem-details-right-item" />} {problem.hosts && <ProblemHosts hosts={problem.hosts} className="problem-details-right-item" />}
</div> </div>
<AckModal
canClose={problem.manual_close === '1'}
severity={problemSeverity}
isOpen={this.state.showAckDialog}
onSubmit={this.ackProblem}
onClose={this.closeAckDialog} />
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { isNewProblem } from '../../utils'; import { isNewProblem } from '../../utils';
import EventTag from '../EventTag'; import EventTag from '../EventTag';
import ProblemDetails from './ProblemDetails'; import { ProblemDetails } from './ProblemDetails';
import { AckProblemData } from '../AckModal'; import { AckProblemData } from '../AckModal';
import { GFHeartIcon, FAIcon } from '../../../components'; import { GFHeartIcon, FAIcon } from '../../../components';
import { ProblemsPanelOptions, GFTimeRange, RTCell, TriggerSeverity, RTResized } from '../../types'; import { ProblemsPanelOptions, GFTimeRange, RTCell, TriggerSeverity, RTResized } from '../../types';