fix(ui): rewrite alert group menu components with hooks

This commit is contained in:
Łukasz Mierzwa
2020-04-30 15:34:04 +01:00
committed by Łukasz Mierzwa
parent d15a0f1ff1
commit 84a32a07cf
4 changed files with 236 additions and 202 deletions

View File

@@ -1,8 +1,7 @@
import React, { Component } from "react";
import React from "react";
import PropTypes from "prop-types";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { useObserver, useLocalStore } from "mobx-react";
import hash from "object-hash";
@@ -96,94 +95,79 @@ MenuContent.propTypes = {
afterClick: PropTypes.func.isRequired,
};
const AlertMenu = observer(
class AlertMenu extends Component {
static propTypes = {
group: APIGroup.isRequired,
alert: APIAlert.isRequired,
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
setIsMenuOpen: PropTypes.func.isRequired,
};
const AlertMenu = ({
group,
alert,
alertStore,
silenceFormStore,
setIsMenuOpen,
}) => {
const collapse = useLocalStore(() => ({
value: true,
toggle() {
this.value = !this.value;
setIsMenuOpen(!this.value);
},
hide() {
this.value = true;
setIsMenuOpen(!this.value);
},
}));
constructor(props) {
super(props);
const uniqueClass = `components-grid-alert-${group.id}-${hash(alert.labels)}`;
this.collapse = observable(
{
value: true,
toggle() {
this.value = !this.value;
props.setIsMenuOpen(!this.value);
},
hide() {
this.value = true;
props.setIsMenuOpen(!this.value);
},
},
{ toggle: action.bound, hide: action.bound },
{ name: "Alert menu toggle" }
);
}
handleClickOutside = action((event) => {
this.collapse.hide();
});
render() {
const { group, alert, alertStore, silenceFormStore } = this.props;
const uniqueClass = `components-grid-alert-${group.id}-${hash(
alert.labels
)}`;
return (
<Manager>
<Reference>
{({ ref }) => (
<span
className={`components-label components-label-with-hover px-1 mr-1 badge badge-secondary cursor-pointer ${uniqueClass}`}
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>
<DropdownSlide in={!this.collapse.value} unmountOnExit>
<Popper
placement="bottom-start"
modifiers={[
{ name: "arrow", enabled: false },
{ name: "offset", options: { offset: "-5px, 0px" } },
]}
>
{({ placement, ref, style }) => (
<MenuContent
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
group={group}
alert={alert}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={this.collapse.hide}
handleClickOutside={this.collapse.hide}
outsideClickIgnoreClass={uniqueClass}
/>
)}
</Popper>
</DropdownSlide>
</Manager>
);
}
}
);
return useObserver(() => (
<Manager>
<Reference>
{({ ref }) => (
<span
className={`components-label components-label-with-hover px-1 mr-1 badge badge-secondary cursor-pointer ${uniqueClass}`}
ref={ref}
onClick={collapse.toggle}
data-toggle="dropdown"
>
<FontAwesomeIcon
className="pr-1"
style={{ width: "0.8rem" }}
icon={faCaretDown}
/>
<Moment fromNow>{alert.startsAt}</Moment>
</span>
)}
</Reference>
<DropdownSlide in={!collapse.value} unmountOnExit>
<Popper
placement="bottom-start"
modifiers={[
{ name: "arrow", enabled: false },
{ name: "offset", options: { offset: "-5px, 0px" } },
]}
>
{({ placement, ref, style }) => (
<MenuContent
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
group={group}
alert={alert}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={collapse.hide}
handleClickOutside={collapse.hide}
outsideClickIgnoreClass={uniqueClass}
/>
)}
</Popper>
</DropdownSlide>
</Manager>
));
};
AlertMenu.propTypes = {
group: APIGroup.isRequired,
alert: APIAlert.isRequired,
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
setIsMenuOpen: PropTypes.func.isRequired,
};
export { AlertMenu, MenuContent };

View File

@@ -13,9 +13,20 @@ let silenceFormStore;
let alert;
let group;
let MockAfterClick;
let MockSetIsMenuOpen;
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
alertStore = new AlertStore([]);
silenceFormStore = new SilenceFormStore();
MockAfterClick = jest.fn();
MockSetIsMenuOpen = jest.fn();
alert = MockAlert([], { foo: "bar" }, "active");
group = MockAlertGroup({ alertname: "Fake Alert" }, [alert], [], {}, {});
@@ -38,9 +49,6 @@ beforeEach(() => {
};
});
const MockAfterClick = jest.fn();
const MockSetIsMenuOpen = jest.fn();
const MountedAlertMenu = (group) => {
return mount(
<AlertMenu
@@ -54,30 +62,54 @@ const MountedAlertMenu = (group) => {
};
describe("<AlertMenu />", () => {
it("is collapsed by default", () => {
it("menu content is hidden by default", () => {
const tree = MountedAlertMenu(group);
expect(tree.instance().collapse.value).toBe(true);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
expect(MockSetIsMenuOpen).not.toHaveBeenCalled();
});
it("clicking toggle sets collapse value to 'false'", async () => {
it("clicking toggle renders menu content", async () => {
const promise = Promise.resolve();
const tree = MountedAlertMenu(group);
const toggle = tree.find(".cursor-pointer");
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
await act(() => promise);
});
it("handleClickOutside() call sets collapse value to 'true'", async () => {
it("clicking toggle twice hides menu content", async () => {
const promise = Promise.resolve();
const tree = MountedAlertMenu(group);
const toggle = tree.find(".cursor-pointer");
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
jest.runOnlyPendingTimers();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
tree.instance().handleClickOutside();
toggle.simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(2);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
await act(() => promise);
});
expect(tree.instance().collapse.value).toBe(true);
it("clicking menu item hides menu content", async () => {
const promise = Promise.resolve();
const tree = MountedAlertMenu(group);
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
tree.find("a.dropdown-item").at(0).simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(2);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
await act(() => promise);
});
});

View File

@@ -1,8 +1,7 @@
import React, { Component } from "react";
import React from "react";
import PropTypes from "prop-types";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { useObserver, useLocalStore } from "mobx-react";
import copy from "copy-to-clipboard";
@@ -99,88 +98,75 @@ MenuContent.propTypes = {
afterClick: PropTypes.func.isRequired,
};
const GroupMenu = observer(
class GroupMenu extends Component {
static propTypes = {
group: APIGroup.isRequired,
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
themed: PropTypes.bool.isRequired,
setIsMenuOpen: PropTypes.func.isRequired,
};
const GroupMenu = ({
group,
alertStore,
silenceFormStore,
themed,
setIsMenuOpen,
}) => {
const collapse = useLocalStore(() => ({
isHidden: true,
toggle() {
this.isHidden = !this.isHidden;
setIsMenuOpen(!this.isHidden);
},
hide() {
this.isHidden = true;
setIsMenuOpen(!this.isHidden);
},
}));
constructor(props) {
super(props);
this.collapse = observable(
{
value: true,
toggle() {
this.value = !this.value;
props.setIsMenuOpen(!this.value);
},
hide() {
this.value = true;
props.setIsMenuOpen(!this.value);
},
},
{ toggle: action.bound, hide: action.bound },
{ name: "Alert group menu toggle" }
);
}
handleClickOutside = action((event) => {
this.collapse.hide();
});
render() {
const { group, alertStore, silenceFormStore, themed } = this.props;
return (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
onClick={this.collapse.toggle}
className={`${
themed ? "text-white" : "text-muted"
} cursor-pointer badge pl-0 pr-3 pr-sm-2 components-label mr-0 components-grid-alertgroup-${
group.id
}`}
data-toggle="dropdown"
>
<FontAwesomeIcon icon={faEllipsisV} />
</span>
)}
</Reference>
<DropdownSlide in={!this.collapse.value} unmountOnExit>
<Popper
placement="bottom-start"
modifiers={[
{ name: "arrow", enabled: false },
{ name: "offset", options: { offset: "-5px, 0px" } },
]}
>
{({ placement, ref, style }) => (
<MenuContent
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
group={group}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={this.collapse.hide}
handleClickOutside={this.collapse.hide}
outsideClickIgnoreClass={`components-grid-alertgroup-${group.id}`}
/>
)}
</Popper>
</DropdownSlide>
</Manager>
);
}
}
);
return useObserver(() => (
<Manager>
<Reference>
{({ ref }) => (
<span
ref={ref}
onClick={collapse.toggle}
className={`${
themed ? "text-white" : "text-muted"
} cursor-pointer badge pl-0 pr-3 pr-sm-2 components-label mr-0 components-grid-alertgroup-${
group.id
}`}
data-toggle="dropdown"
>
<FontAwesomeIcon icon={faEllipsisV} />
</span>
)}
</Reference>
<DropdownSlide in={!collapse.isHidden} unmountOnExit>
<Popper
placement="bottom-start"
modifiers={[
{ name: "arrow", enabled: false },
{ name: "offset", options: { offset: "-5px, 0px" } },
]}
>
{({ placement, ref, style }) => (
<MenuContent
popperPlacement={placement}
popperRef={ref}
popperStyle={style}
group={group}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
afterClick={collapse.hide}
handleClickOutside={collapse.hide}
outsideClickIgnoreClass={`components-grid-alertgroup-${group.id}`}
/>
)}
</Popper>
</DropdownSlide>
</Manager>
));
};
GroupMenu.propTypes = {
group: APIGroup.isRequired,
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
themed: PropTypes.bool.isRequired,
setIsMenuOpen: PropTypes.func.isRequired,
};
export { GroupMenu, MenuContent };

View File

@@ -13,10 +13,21 @@ import { GroupMenu, MenuContent } from "./GroupMenu";
let alertStore;
let silenceFormStore;
let MockAfterClick;
let MockSetIsMenuOpen;
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
alertStore = new AlertStore([]);
silenceFormStore = new SilenceFormStore();
jest.clearAllMocks();
MockAfterClick = jest.fn();
MockSetIsMenuOpen = jest.fn();
alertStore.data.upstreams = {
clusters: { default: ["am1"] },
instances: [
@@ -36,9 +47,6 @@ beforeEach(() => {
};
});
const MockAfterClick = jest.fn();
const MockSetIsMenuOpen = jest.fn();
const MountedGroupMenu = (group, themed) => {
return mount(
<GroupMenu
@@ -48,38 +56,62 @@ const MountedGroupMenu = (group, themed) => {
themed={themed}
setIsMenuOpen={MockSetIsMenuOpen}
/>
).find("GroupMenu");
);
};
describe("<GroupMenu />", () => {
it("is collapsed by default", () => {
it("menu content is hidden by default", () => {
const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {}, {});
const tree = MountedGroupMenu(group, true);
expect(tree.instance().collapse.value).toBe(true);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
expect(MockSetIsMenuOpen).not.toHaveBeenCalled();
});
it("clicking toggle sets collapse value to 'false'", async () => {
it("clicking toggle renders menu content", async () => {
const promise = Promise.resolve();
const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {}, {});
const tree = MountedGroupMenu(group, true);
const toggle = tree.find(".cursor-pointer");
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
await act(() => promise);
});
it("handleClickOutside() call sets collapse value to 'true'", async () => {
it("clicking toggle twice hides menu content", async () => {
const promise = Promise.resolve();
const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {}, {});
const tree = MountedGroupMenu(group, true);
const toggle = tree.find(".cursor-pointer");
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(tree.instance().collapse.value).toBe(false);
jest.runOnlyPendingTimers();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
tree.instance().handleClickOutside();
toggle.simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(2);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
await act(() => promise);
});
expect(tree.instance().collapse.value).toBe(true);
it("clicking menu item hides menu content", async () => {
const promise = Promise.resolve();
const group = MockAlertGroup({ alertname: "Fake Alert" }, [], [], {}, {});
const tree = MountedGroupMenu(group, true);
const toggle = tree.find("span.cursor-pointer");
toggle.simulate("click");
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(1);
expect(tree.find("div.dropdown-menu")).toHaveLength(1);
tree.find("div.dropdown-item").at(0).simulate("click");
jest.runOnlyPendingTimers();
tree.update();
expect(MockSetIsMenuOpen).toHaveBeenCalledTimes(2);
expect(tree.find("div.dropdown-menu")).toHaveLength(0);
await act(() => promise);
});
});