feat(ui): add a modal with labels overview

Fixes #766
This commit is contained in:
Łukasz Mierzwa
2019-07-09 22:08:31 +01:00
parent e678928017
commit 70e69fda09
14 changed files with 667 additions and 11 deletions

View File

@@ -53,7 +53,7 @@ labels:
color: "#ff220c"
log:
config: false
level: warning
level: debug
sentry:
private: https://84a9ef37a6ed4fdb80e9ea2310d1ed26:8c6ee6f0ab02406482ff4b4e824e2c27@sentry.io/1279017
public: https://84a9ef37a6ed4fdb80e9ea2310d1ed26@sentry.io/1279017

View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MountedLabelWithPercent /> matches snapshot 1`] = `
"
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-1\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
25
</span>
<span class=\\"components-label-name\\">
foo:
</span>
<span class=\\"components-label-value\\">
bar
</span>
</span>
<div class=\\"progress silence-progress bg-white pr-1\\">
<div class=\\"progress-bar bg-success\\"
role=\\"progressbar\\"
style=\\"width: 50%;\\"
aria-valuenow=\\"50\\"
aria-valuemin=\\"0\\"
aria-valuemax=\\"100\\"
>
</div>
</div>
</div>
"
`;

View File

@@ -0,0 +1,69 @@
import React from "react";
import PropTypes from "prop-types";
import { inject, observer } from "mobx-react";
import { AlertStore } from "Stores/AlertStore";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { BaseLabel } from "Components/Labels/BaseLabel";
import "./index.scss";
const LabelWithPercent = inject("alertStore")(
observer(
class FilteringLabel extends BaseLabel {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hits: PropTypes.number.isRequired,
percent: PropTypes.number.isRequired
};
render() {
const { name, value, hits, percent } = this.props;
let cs = this.getClassAndStyle(
name,
value,
"components-label-with-hover mb-0 pl-0 text-left"
);
const progressBarBg =
percent > 66
? "bg-danger"
: percent > 66
? "bg-warning"
: "bg-success";
return (
<TooltipWrapper title="Click to only show alerts with this label or Alt+Click to hide them">
<span
className={cs.className}
style={cs.style}
onClick={e => this.handleClick(e)}
>
<span className="mr-1 px-1 bg-primary text-white components-labelWithPercent-percent">
{hits}
</span>
<span className="components-label-name">{name}:</span>{" "}
<span className="components-label-value">{value}</span>
</span>
<div className="progress silence-progress bg-white pr-1">
<div
className={`progress-bar ${progressBarBg}`}
role="progressbar"
style={{ width: percent + "%" }}
aria-valuenow={percent}
aria-valuemin="0"
aria-valuemax="100"
/>
</div>
</TooltipWrapper>
);
}
}
)
);
export { LabelWithPercent };

View File

@@ -0,0 +1,4 @@
.components-labelWithPercent-percent {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}

View File

@@ -0,0 +1,63 @@
import React from "react";
import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore";
import { LabelWithPercent } from ".";
let alertStore;
beforeEach(() => {
alertStore = new AlertStore([]);
});
const MountedLabelWithPercent = (name, value) => {
return mount(
<LabelWithPercent
alertStore={alertStore}
name={name}
value={value}
hits={25}
percent={50}
/>
).find(".components-label");
};
const RenderAndClick = (name, value, clickOptions) => {
const tree = MountedLabelWithPercent(name, value);
tree.find(".components-label").simulate("click", clickOptions || {});
};
describe("<MountedLabelWithPercent />", () => {
it("matches snapshot", () => {
const tree = mount(
<LabelWithPercent
alertStore={alertStore}
name="foo"
value="bar"
hits={25}
percent={50}
/>
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("calling onClick() adds a new filter 'foo=bar'", () => {
RenderAndClick("foo", "bar");
expect(alertStore.filters.values).toHaveLength(1);
expect(alertStore.filters.values).toContainEqual(
NewUnappliedFilter("foo=bar")
);
});
it("calling onClick() while holding Alt key adds a new filter 'foo!=bar'", () => {
RenderAndClick("foo", "bar", { altKey: true });
expect(alertStore.filters.values).toHaveLength(1);
expect(alertStore.filters.values).toContainEqual(
NewUnappliedFilter("foo!=bar")
);
});
});

View File

@@ -8,13 +8,12 @@ import ReactResizeDetector from "react-resize-detector";
import IdleTimer from "react-idle-timer";
import Flash from "react-reveal/Flash";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { IsMobile } from "Common/Device";
import { NavBarSlide } from "Components/Animations/NavBarSlide";
import { OverviewModal } from "Components/OverviewModal";
import { MainModal } from "Components/MainModal";
import { SilenceModal } from "Components/SilenceModal";
import { FetchIndicator } from "./FetchIndicator";
@@ -141,12 +140,8 @@ const NavBar = observer(
>
<nav className="navbar fixed-top navbar-expand navbar-dark p-1 bg-primary-transparent d-inline-block">
<ReactResizeDetector handleHeight onResize={this.onResize} />
<span className="navbar-brand my-0 mx-2 h1 d-none d-sm-block float-left">
<Flash spy={alertStore.info.totalAlerts}>
<div className="d-inline-block">
{alertStore.info.totalAlerts}
</div>
</Flash>
<span className="navbar-brand p-0 my-0 mx-2 h1 d-none d-sm-block float-left">
<OverviewModal alertStore={alertStore} />
<FetchIndicator alertStore={alertStore} />
</span>
<ul className={`navbar-nav float-right d-flex ${flexClass}`}>

View File

@@ -55,7 +55,7 @@ describe("<NavBar />", () => {
it("navbar-brand shows 15 alerts with totalAlerts=15", () => {
alertStore.info.totalAlerts = 15;
const tree = MountedNavbar();
const brand = tree.find(".navbar-brand");
const brand = tree.find("span.navbar-brand");
expect(brand.text()).toBe("15");
});

View File

@@ -0,0 +1,81 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observer } from "mobx-react";
import { AlertStore } from "Stores/AlertStore";
import { LabelWithPercent } from "Components/Labels/LabelWithPercent";
const LabelsTable = observer(({ alertStore }) => (
<table
className="table table-borderless top-labels"
style={{ tableLayout: "fixed" }}
>
<tbody className="mw-100">
{alertStore.data.counters.map(nameStats => (
<tr key={nameStats.name}>
<td width="25%" className="text-nowrap mw-100 p-1">
<span className="badge badge-light components-label mx-0 my-1 pl-0 text-left">
<span className="bg-primary text-white mr-1 px-1 components-labelWithPercent-percent">
{nameStats.hits}
</span>
{nameStats.name}
</span>
</td>
<td width="75%" className="mw-100 p-1">
{nameStats.values.slice(0, 9).map(valueStats => (
<LabelWithPercent
key={valueStats.value}
name={nameStats.name}
value={valueStats.value}
hits={valueStats.hits}
percent={valueStats.percent}
/>
))}
</td>
</tr>
))}
</tbody>
</table>
));
const NothingToShow = () => (
<div className="jumbotron bg-white">
<h1 className="display-5 text-secondary text-center">
No labels to display
</h1>
</div>
);
const OverviewModalContent = observer(
class OverviewModalContent extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
onHide: PropTypes.func.isRequired
};
render() {
const { alertStore, onHide } = this.props;
return (
<React.Fragment>
<div className="modal-header">
<h5 className="modal-title">Overview</h5>
<button type="button" className="close" onClick={onHide}>
<span className="align-middle">&times;</span>
</button>
</div>
<div className="modal-body">
{alertStore.data.counters.length === 0 ? (
<NothingToShow />
) : (
<LabelsTable alertStore={alertStore} />
)}
</div>
</React.Fragment>
);
}
}
);
export { OverviewModalContent };

View File

@@ -0,0 +1,66 @@
import React from "react";
import { Provider } from "mobx-react";
import { mount } from "enzyme";
import toDiffableHtml from "diffable-html";
import { AlertStore } from "Stores/AlertStore";
import { OverviewModalContent } from "./OverviewModalContent";
let alertStore;
const onHide = jest.fn();
beforeEach(() => {
alertStore = new AlertStore([]);
onHide.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("<OverviewModalContent />", () => {
it("matches snapshot with labels to show", () => {
alertStore.data.counters = [
{
name: "foo",
hits: 16,
values: [
{ value: "bar1", hits: 8, percent: 50 },
{ value: "bar2", hits: 4, percent: 25 },
{ value: "bar3", hits: 4, percent: 25 }
]
}
];
// we have multiple fragments and enzyme only renders the first one
// in html() and text(), debug() would work but it's noisy
// https://github.com/airbnb/enzyme/issues/1213
const tree = mount(
<span>
<Provider alertStore={alertStore}>
<OverviewModalContent alertStore={alertStore} onHide={onHide} />
</Provider>
</span>
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
it("matches snapshot with no labels to show", () => {
alertStore.data.counters = [];
// we have multiple fragments and enzyme only renders the first one
// in html() and text(), debug() would work but it's noisy
// https://github.com/airbnb/enzyme/issues/1213
const tree = mount(
<span>
<Provider alertStore={alertStore}>
<OverviewModalContent alertStore={alertStore} onHide={onHide} />
</Provider>
</span>
);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,154 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OverviewModalContent /> matches snapshot with labels to show 1`] = `
"
<span>
<div class=\\"modal-header\\">
<h5 class=\\"modal-title\\">
Overview
</h5>
<button type=\\"button\\"
class=\\"close\\"
>
<span class=\\"align-middle\\">
×
</span>
</button>
</div>
<div class=\\"modal-body\\">
<table class=\\"table table-borderless top-labels\\"
style=\\"table-layout: fixed;\\"
>
<tbody class=\\"mw-100\\">
<tr>
<td width=\\"25%\\"
class=\\"text-nowrap mw-100 p-1\\"
>
<span class=\\"badge badge-light components-label mx-0 my-1 pl-0 text-left\\">
<span class=\\"bg-primary text-white mr-1 px-1 components-labelWithPercent-percent\\">
16
</span>
foo
</span>
</td>
<td width=\\"75%\\"
class=\\"mw-100 p-1\\"
>
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-1\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
8
</span>
<span class=\\"components-label-name\\">
foo:
</span>
<span class=\\"components-label-value\\">
bar1
</span>
</span>
<div class=\\"progress silence-progress bg-white pr-1\\">
<div class=\\"progress-bar bg-success\\"
role=\\"progressbar\\"
style=\\"width: 50%;\\"
aria-valuenow=\\"50\\"
aria-valuemin=\\"0\\"
aria-valuemax=\\"100\\"
>
</div>
</div>
</div>
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-2\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
4
</span>
<span class=\\"components-label-name\\">
foo:
</span>
<span class=\\"components-label-value\\">
bar2
</span>
</span>
<div class=\\"progress silence-progress bg-white pr-1\\">
<div class=\\"progress-bar bg-success\\"
role=\\"progressbar\\"
style=\\"width: 25%;\\"
aria-valuenow=\\"25\\"
aria-valuemin=\\"0\\"
aria-valuemax=\\"100\\"
>
</div>
</div>
</div>
<div class
style=\\"display: inline-block; max-width: 100%;\\"
data-tooltipped
aria-describedby=\\"tippy-tooltip-3\\"
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
>
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
4
</span>
<span class=\\"components-label-name\\">
foo:
</span>
<span class=\\"components-label-value\\">
bar3
</span>
</span>
<div class=\\"progress silence-progress bg-white pr-1\\">
<div class=\\"progress-bar bg-success\\"
role=\\"progressbar\\"
style=\\"width: 25%;\\"
aria-valuenow=\\"25\\"
aria-valuemin=\\"0\\"
aria-valuemax=\\"100\\"
>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</span>
"
`;
exports[`<OverviewModalContent /> matches snapshot with no labels to show 1`] = `
"
<span>
<div class=\\"modal-header\\">
<h5 class=\\"modal-title\\">
Overview
</h5>
<button type=\\"button\\"
class=\\"close\\"
>
<span class=\\"align-middle\\">
×
</span>
</button>
</div>
<div class=\\"modal-body\\">
<div class=\\"jumbotron bg-white\\">
<h1 class=\\"display-5 text-secondary text-center\\">
No labels to display
</h1>
</div>
</div>
</span>
"
`;

View File

@@ -0,0 +1,84 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observer, Provider } from "mobx-react";
import { observable, action } from "mobx";
import Flash from "react-reveal/Flash";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
import { AlertStore } from "Stores/AlertStore";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { Modal } from "Components/Modal";
import "./index.scss";
// https://github.com/facebook/react/issues/14603
const OverviewModalContent = React.lazy(() =>
import("./OverviewModalContent").then(module => ({
default: module.OverviewModalContent
}))
);
const OverviewModal = observer(
class OverviewModal extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired
};
toggle = observable(
{
show: false,
toggle() {
this.show = !this.show;
},
hide() {
this.show = false;
}
},
{ toggle: action.bound, hide: action.bound }
);
render() {
const { alertStore } = this.props;
return (
<React.Fragment>
<TooltipWrapper title="Show alert overview">
<Flash spy={alertStore.info.totalAlerts}>
<div
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 ${
this.toggle.show ? "border-bottom border-info" : ""
}`}
onClick={this.toggle.toggle}
>
{alertStore.info.totalAlerts}
</div>
</Flash>
</TooltipWrapper>
<Modal isOpen={this.toggle.show}>
<React.Suspense
fallback={
<h1 className="display-1 text-secondary p-5 m-auto">
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
</h1>
}
>
<Provider alertStore={alertStore}>
<OverviewModalContent
alertStore={alertStore}
onHide={this.toggle.hide}
isVisible={this.toggle.show}
/>
</Provider>
</React.Suspense>
</Modal>
</React.Fragment>
);
}
}
);
export { OverviewModal };

View File

@@ -0,0 +1,10 @@
@import "~bootswatch/dist/flatly/variables";
@import "~bootstrap/scss/bootstrap";
@import "~bootswatch/dist/flatly/bootswatch";
.navbar-brand {
&:hover,
&:focus {
color: $green !important;
}
}

View File

@@ -0,0 +1,96 @@
import React from "react";
import { mount } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { OverviewModal } from ".";
let alertStore;
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
alertStore = new AlertStore([]);
});
const MountedOverviewModal = () => {
return mount(<OverviewModal alertStore={alertStore} />);
};
describe("<OverviewModal />", () => {
it("only renders the counter when modal is not shown", () => {
const tree = MountedOverviewModal();
expect(tree.text()).toBe("0");
expect(tree.find("OverviewModalContent")).toHaveLength(0);
});
it("renders a spinner placeholder while modal content is loading", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
expect(tree.find("OverviewModalContent")).toHaveLength(0);
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(1);
});
it("renders modal content if fallback is not used", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
expect(tree.find("OverviewModalContent")).toHaveLength(1);
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(0);
});
it("hides the modal when toggle() is called twice", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(tree.find("OverviewModalContent")).toHaveLength(1);
toggle.simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(tree.find("OverviewModalContent")).toHaveLength(0);
});
it("hides the modal when hide() is called", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
expect(tree.find("OverviewModalContent")).toHaveLength(1);
const instance = tree.instance();
instance.toggle.hide();
jest.runOnlyPendingTimers();
tree.update();
expect(tree.find("OverviewModalContent")).toHaveLength(0);
});
it("'modal-open' class is appended to body node when modal is visible", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
expect(document.body.className.split(" ")).toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is hidden", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
toggle.simulate("click");
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
it("'modal-open' class is removed from body node after modal is unmounted", () => {
const tree = MountedOverviewModal();
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
tree.unmount();
expect(document.body.className.split(" ")).not.toContain("modal-open");
});
});

View File

@@ -137,7 +137,7 @@ class AlertStore {
data = observable(
{
colors: {},
counters: {},
counters: [],
groups: {},
silences: {},
upstreams: { instances: [], clusters: {} },