feat(ui): add alert menu with actions specific to that alert

This commit is contained in:
Łukasz Mierzwa
2018-09-08 20:06:51 +01:00
parent bc799856f2
commit a4557cfc49
5 changed files with 241 additions and 8 deletions

View File

@@ -0,0 +1,134 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Manager, Reference, Popper } from "react-popper";
import onClickOutside from "react-onclickoutside";
import Moment from "react-moment";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown } from "@fortawesome/free-solid-svg-icons/faCaretDown";
import { faBellSlash } from "@fortawesome/free-solid-svg-icons/faBellSlash";
const onSilenceClick = (silenceFormStore, group, alert) => {
silenceFormStore.data.resetProgress();
silenceFormStore.data.fillMatchersFromGroup(group, [alert]);
silenceFormStore.toggle.show();
};
const MenuContent = onClickOutside(
({
popperPlacement,
popperRef,
popperStyle,
group,
alert,
afterClick,
silenceFormStore
}) => {
return (
<div
className="dropdown-menu d-block"
ref={popperRef}
style={popperStyle}
data-placement={popperPlacement}
>
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group, alert)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this alert
</div>
</div>
);
}
);
MenuContent.propTypes = {
popperPlacement: PropTypes.string,
popperRef: PropTypes.func,
popperStyle: PropTypes.object,
group: PropTypes.object.isRequired,
alert: PropTypes.object.isRequired,
afterClick: PropTypes.func.isRequired
};
const AlertMenu = observer(
class AlertMenu extends Component {
static propTypes = {
group: PropTypes.object.isRequired,
alert: PropTypes.object.isRequired,
silenceFormStore: PropTypes.object.isRequired
};
collapse = observable(
{
value: true,
toggle() {
this.value = !this.value;
},
hide() {
this.value = true;
}
},
{ toggle: action.bound, hide: action.bound },
{ name: "Alert menu toggle" }
);
handleClickOutside = action(event => {
this.collapse.hide();
});
render() {
const { group, alert, silenceFormStore } = this.props;
return (
<Manager>
<Reference>
{({ ref }) => (
<span
className="components-label-with-hover text-nowrap text-truncate px-1 mr-1 badge badge-secondary cursor-pointer"
ref={ref}
onClick={this.collapse.toggle}
data-toggle="dropdown"
>
<FontAwesomeIcon
className="pr-1"
style={{ width: "0.8rem" }}
icon={faCaretDown}
/>
<Moment fromNow>{alert.startsAt}</Moment>
</span>
)}
</Reference>
{this.collapse.value ? null : (
<Popper
placement="bottom-start"
modifiers={{
arrow: { enabled: false },
offset: { offset: "-5px, 0px" }
}}
>
{({ placement, ref, style }) => (
<MenuContent
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
group={group}
alert={alert}
silenceFormStore={silenceFormStore}
afterClick={this.collapse.hide}
handleClickOutside={this.collapse.hide}
/>
)}
</Popper>
)}
</Manager>
);
}
}
);
export { AlertMenu, MenuContent };

View File

@@ -0,0 +1,77 @@
import React from "react";
import { mount } from "enzyme";
import { MockAlertGroup, MockAlert } from "__mocks__/Alerts.js";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { AlertMenu, MenuContent } from "./AlertMenu";
let silenceFormStore;
let alert;
let group;
beforeEach(() => {
silenceFormStore = new SilenceFormStore();
alert = MockAlert([], { foo: "bar" }, "active");
group = MockAlertGroup({ alertname: "Fake Alert" }, [alert], [], {});
});
const MockAfterClick = jest.fn();
const MountedAlertMenu = group => {
return mount(
<AlertMenu
group={group}
alert={alert}
silenceFormStore={silenceFormStore}
/>
);
};
describe("<AlertMenu />", () => {
it("is collapsed by default", () => {
const tree = MountedAlertMenu(group);
expect(tree.instance().collapse.value).toBe(true);
});
it("clicking toggle sets collapse value to 'false'", () => {
const tree = MountedAlertMenu(group);
const toggle = tree.find(".cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
});
it("handleClickOutside() call sets collapse value to 'true'", () => {
const tree = MountedAlertMenu(group);
const toggle = tree.find(".cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
tree.instance().handleClickOutside();
expect(tree.instance().collapse.value).toBe(true);
});
});
const MountedMenuContent = group => {
return mount(
<MenuContent
popperPlacement="top"
popperRef={null}
popperStyle={{}}
group={group}
alert={alert}
afterClick={MockAfterClick}
silenceFormStore={silenceFormStore}
/>
);
};
describe("<MenuContent />", () => {
it("clicking on 'Silence' icon opens the silence form modal", () => {
const tree = MountedMenuContent(group);
const button = tree.find(".dropdown-item").at(0);
button.simulate("click");
expect(silenceFormStore.toggle.visible).toBe(true);
});
});

View File

@@ -42,7 +42,22 @@ exports[`<Alert /> matches snapshot with showAlertmanagers=false showReceiver=fa
hidden
</div>
</div>
<span class=\\"text-nowrap text-truncate px-1 mr-1 badge badge-secondary\\">
<span class=\\"components-label-with-hover text-nowrap text-truncate px-1 mr-1 badge badge-secondary cursor-pointer\\"
data-toggle=\\"dropdown\\"
>
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"caret-down\\"
class=\\"svg-inline--fa fa-caret-down fa-w-10 pr-1\\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 320 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z\\"
>
</path>
</svg>
<time datetime=\\"1534268200017\\">
a day ago
</time>

View File

@@ -3,31 +3,34 @@ import PropTypes from "prop-types";
import { observer } from "mobx-react";
import Moment from "react-moment";
import { GetLabelColorClass } from "Common/Colors";
import { StaticLabels } from "Common/Query";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { RenderNonLinkAnnotation, RenderLinkAnnotation } from "../Annotation";
import { Silence } from "../Silence";
import { AlertMenu } from "./AlertMenu";
import "./index.css";
const Alert = observer(
class Alert extends Component {
static propTypes = {
group: PropTypes.object.isRequired,
alert: PropTypes.object.isRequired,
showAlertmanagers: PropTypes.bool.isRequired,
showReceiver: PropTypes.bool.isRequired,
afterUpdate: PropTypes.func.isRequired
afterUpdate: PropTypes.func.isRequired,
silenceFormStore: PropTypes.object.isRequired
};
render() {
const {
group,
alert,
showAlertmanagers,
showReceiver,
afterUpdate
afterUpdate,
silenceFormStore
} = this.props;
let classNames = [
@@ -55,9 +58,11 @@ const Alert = observer(
/>
))}
</div>
<span className="text-nowrap text-truncate px-1 mr-1 badge badge-secondary">
<Moment fromNow>{alert.startsAt}</Moment>
</span>
<AlertMenu
group={group}
alert={alert}
silenceFormStore={silenceFormStore}
/>
{Object.entries(alert.labels).map(([name, value]) => (
<FilteringLabel key={name} name={name} value={value} />
))}

View File

@@ -181,12 +181,14 @@ const AlertGroup = observer(
.map(alert => (
<Alert
key={hash(alert.labels)}
group={group}
alert={alert}
showAlertmanagers={
showAlertmanagers && !showAlertmanagersInFooter
}
showReceiver={group.alerts.length === 1}
afterUpdate={afterUpdate}
silenceFormStore={silenceFormStore}
/>
))}
{group.alerts.length > this.defaultRenderCount ? (