feat(ui): support multiple grids in the UI

This commit is contained in:
Łukasz Mierzwa
2020-03-29 20:01:11 +01:00
parent 1fc6aaf949
commit ec1705a0f0
4 changed files with 391 additions and 184 deletions

View File

@@ -0,0 +1,202 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import debounce from "lodash/debounce";
import MasonryInfiniteScroller from "react-masonry-infinite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faAngleDoubleDown } from "@fortawesome/free-solid-svg-icons/faAngleDoubleDown";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { APIGroup } from "Models/API";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { AlertGroup } from "./AlertGroup";
const Grid = observer(
class Grid extends Component {
static propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
gridSizesConfig: PropTypes.array.isRequired,
groupWidth: PropTypes.number.isRequired,
gridLabelName: PropTypes.string.isRequired,
gridLabelValue: PropTypes.string.isRequired,
gridAlertGroups: PropTypes.arrayOf(APIGroup).isRequired,
};
// store reference to generated masonry component so we can call it
// to repack the grid after any component was re-rendered, which could
// alter its size breaking grid layout
masonryComponentReference = observable(
{ ref: false },
{},
{ name: "Masonry reference" }
);
// store it for later
storeMasonryRef = action((ref) => {
this.masonryComponentReference.ref = ref;
});
// used to call forcePack() which will repack all grid elements
// (alert groups), this needs to be called if any group size changes
masonryRepack = debounce(
action(() => {
if (this.masonryComponentReference.ref) {
this.masonryComponentReference.ref.forcePack();
}
}),
10
);
initial = 50;
groupsToRender = observable(
{
value: this.initial,
setValue(value) {
this.value = value;
},
},
{
setValue: action.bound,
},
{ name: "Groups to render" }
);
// how many groups add to render count when user scrolls to the bottom
loadMoreStep = 30;
loadMore = action(() => {
const { gridAlertGroups } = this.props;
this.groupsToRender.value = Math.min(
this.groupsToRender.value + this.loadMoreStep,
gridAlertGroups.length
);
});
gridToggle = observable(
{
show: true,
toggle() {
this.show = !this.show;
},
},
{
toggle: action.bound,
}
);
componentDidUpdate() {
const { gridAlertGroups } = this.props;
this.masonryRepack();
if (this.groupsToRender.value > gridAlertGroups.length) {
this.groupsToRender.setValue(
Math.max(this.initial, gridAlertGroups.length)
);
}
}
render() {
const {
alertStore,
settingsStore,
silenceFormStore,
gridSizesConfig,
groupWidth,
gridLabelName,
gridLabelValue,
gridAlertGroups,
} = this.props;
return (
<React.Fragment>
{gridLabelName !== "" && gridLabelValue !== "" && (
<h5 className="d-flex flex-row justify-content-between px-2 mx-0 mt-2 mb-0 bg-secondary">
<span className="flex-shrink-0 flex-grow-0 text-white badge px-0 components-label ml-0 mr-2">
{gridAlertGroups.length}
</span>
<span
className="flex-shrink-1 flex-grow-1 text-center"
style={{ minWidth: "0px" }}
>
<FilteringLabel
key={gridLabelValue}
name={gridLabelName}
value={gridLabelValue}
alertStore={alertStore}
/>
</span>
<span
className="flex-shrink-0 flex-grow-0 text-white cursor-pointer badge px-0 components-label ml-2 mr-0"
onClick={this.gridToggle.toggle}
>
<TooltipWrapper title="Click to toggle this grid details">
<FontAwesomeIcon
icon={this.gridToggle.show ? faChevronDown : faChevronUp}
/>
</TooltipWrapper>
</span>
</h5>
)}
<MasonryInfiniteScroller
key={settingsStore.gridConfig.config.groupWidth}
ref={this.storeMasonryRef}
position={false}
pack={true}
sizes={gridSizesConfig}
loadMore={this.loadMore}
hasMore={false}
>
{this.gridToggle.show
? gridAlertGroups
.slice(0, this.groupsToRender.value)
.map((group) => (
<AlertGroup
key={group.id}
group={group}
showAlertmanagers={
Object.keys(alertStore.data.upstreams.clusters).length >
1
}
afterUpdate={this.masonryRepack}
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
style={{
width: groupWidth,
}}
/>
))
: []}
</MasonryInfiniteScroller>
{gridAlertGroups.length > this.groupsToRender.value && (
<div className="d-flex flex-row justify-content-between">
<span className="flex-shrink-1 flex-grow-1 text-center">
<button
type="button"
className="btn btn-secondary mb-3"
onClick={this.loadMore}
>
<FontAwesomeIcon className="mr-2" icon={faAngleDoubleDown} />
Load more
</button>
</span>
</div>
)}
</React.Fragment>
);
}
}
);
export { Grid };

View File

@@ -10,15 +10,10 @@ import debounce from "lodash/debounce";
import ReactResizeDetector from "react-resize-detector";
import MasonryInfiniteScroller from "react-masonry-infinite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { AlertGroup } from "./AlertGroup";
import { Grid } from "./Grid";
import { GridSizesConfig, GetGridElementWidth } from "./GridSize";
const AlertGrid = observer(
@@ -69,54 +64,6 @@ const AlertGrid = observer(
this.viewport.updateWidths(document.body.clientWidth, window.innerWidth);
}, 100);
// store reference to generated masonry component so we can call it
// to repack the grid after any component was re-rendered, which could
// alter its size breaking grid layout
masonryComponentReference = observable(
{ ref: false },
{},
{ name: "Masonry reference" }
);
// store it for later
storeMasonryRef = action((ref) => {
this.masonryComponentReference.ref = ref;
});
// used to call forcePack() which will repack all grid elements
// (alert groups), this needs to be called if any group size changes
masonryRepack = debounce(
action(() => {
if (this.masonryComponentReference.ref) {
this.masonryComponentReference.ref.forcePack();
}
}),
10
);
initial = 50;
groupsToRender = observable(
{
value: this.initial,
setValue(value) {
this.value = value;
},
},
{
setValue: action.bound,
},
{ name: "Groups to render" }
);
// how many groups add to render count when user scrolls to the bottom
loadMoreStep = 30;
loadMore = action(() => {
const { alertStore } = this.props;
this.groupsToRender.value = Math.min(
this.groupsToRender.value + this.loadMoreStep,
alertStore.data.groups.length
);
});
componentDidMount() {
// We have font-display:swap set for font assets, this means that on initial
// render a fallback font might be used and later swapped for the final one
@@ -135,16 +82,6 @@ const AlertGrid = observer(
window.addEventListener("resize", this.handleResize);
}
componentDidUpdate() {
const { alertStore } = this.props;
if (this.groupsToRender.value > alertStore.data.groups.length) {
this.groupsToRender.setValue(
Math.max(this.initial, alertStore.data.groups.length)
);
}
}
componentWillUnmount() {
window.removeEventListener("resize", this.handleResize);
}
@@ -159,40 +96,19 @@ const AlertGrid = observer(
handleHeight
onResize={debounce(this.handleResize, 100)}
/>
<MasonryInfiniteScroller
key={settingsStore.gridConfig.config.groupWidth}
ref={this.storeMasonryRef}
position={false}
pack={true}
sizes={this.viewport.gridSizesConfig}
loadMore={this.loadMore}
hasMore={this.groupsToRender.value < alertStore.data.groups.length}
threshold={50}
loader={
<div key="loader" className="text-center text-muted py-3">
<FontAwesomeIcon icon={faCircleNotch} size="lg" spin />
</div>
}
>
{alertStore.data.groups
.slice(0, this.groupsToRender.value)
.map((group) => (
<AlertGroup
key={group.id}
group={group}
showAlertmanagers={
Object.keys(alertStore.data.upstreams.clusters).length > 1
}
afterUpdate={this.masonryRepack}
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
style={{
width: this.viewport.groupWidth,
}}
/>
))}
</MasonryInfiniteScroller>
{alertStore.data.grids.map((grid) => (
<Grid
key={`${grid.labelName}/${grid.labelValue}`}
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
gridSizesConfig={this.viewport.gridSizesConfig}
groupWidth={this.viewport.groupWidth}
gridLabelName={grid.labelName}
gridLabelValue={grid.labelValue}
gridAlertGroups={grid.alertGroups}
/>
))}
</React.Fragment>
);
}

View File

@@ -9,7 +9,8 @@ import { mockMatchMedia } from "__mocks__/matchMedia";
import { AlertStore } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { GetGridElementWidth } from "./GridSize";
import { GetGridElementWidth, GridSizesConfig } from "./GridSize";
import { Grid } from "./Grid";
import { AlertGrid } from ".";
let alertStore;
@@ -45,12 +46,36 @@ const ShallowAlertGrid = () => {
);
};
const MountedAlertGroup = () => {
return mount(
<AlertGrid
const ShallowGrid = () => {
return shallow(
<Grid
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
gridSizesConfig={GridSizesConfig(1024, 420)}
groupWidth={420}
gridLabelName=""
gridLabelValue=""
gridAlertGroups={
alertStore.data.grids.length ? alertStore.data.grids[0].alertGroups : []
}
/>
);
};
const MountedGrid = () => {
return mount(
<Grid
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
gridSizesConfig={GridSizesConfig(1024, 420)}
groupWidth={420}
gridLabelName=""
gridLabelValue=""
gridAlertGroups={
alertStore.data.grids.length ? alertStore.data.grids[0].alertGroups : []
}
/>
);
};
@@ -83,58 +108,54 @@ const MockGroupList = (count, alertPerGroup) => {
instances: [{ name: "am", uri: "http://am", error: "" }],
clusters: { am: ["am"] },
};
alertStore.data.groups = groups;
alertStore.data.grids = [
{
labelName: "",
labelValue: "",
alertGroups: groups,
},
];
};
const VerifyColumnCount = (innerWidth, outerWidth, columns) => {
MockGroupList(60, 5);
const tree = ShallowAlertGrid();
tree.instance().viewport.updateWidths(innerWidth, outerWidth);
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(
Math.floor(innerWidth / columns)
);
};
describe("<AlertGrid />", () => {
describe("<Grid />", () => {
it("renders only first 50 alert groups", () => {
MockGroupList(60, 5);
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const alertGroups = tree.find("AlertGroup");
expect(alertGroups).toHaveLength(50);
});
it("appends 30 groups after loadMore() call", () => {
it("appends 30 groups after clicking 'Load More' button", () => {
MockGroupList(100, 5);
const tree = ShallowAlertGrid();
// call it directly, it should happen on scroll to the bottom of the page
tree.instance().loadMore();
const tree = ShallowGrid();
tree.find("button").simulate("click");
const alertGroups = tree.find("AlertGroup");
expect(alertGroups).toHaveLength(80);
});
it("resets groupsToRender.value back to 50 if current value is > alertStore.data.groups.length", () => {
it("resets groupsToRender.value back to 50 if current value is more than group alerts", () => {
MockGroupList(100, 5);
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
expect(tree.find("AlertGroup")).toHaveLength(50);
expect(tree.instance().groupsToRender.value).toBe(50);
tree.instance().loadMore();
tree.find("button").simulate("click");
expect(tree.find("AlertGroup")).toHaveLength(80);
expect(tree.instance().groupsToRender.value).toBe(80);
MockGroupList(10, 5);
tree.instance().componentDidUpdate();
tree.setProps({ gridAlertGroups: alertStore.data.grids[0].alertGroups });
expect(tree.find("AlertGroup")).toHaveLength(10);
expect(tree.instance().groupsToRender.value).toBe(50);
MockGroupList(100, 5);
tree.instance().componentDidUpdate();
tree.setProps({ gridAlertGroups: alertStore.data.grids[0].alertGroups });
expect(tree.find("AlertGroup")).toHaveLength(50);
expect(tree.instance().groupsToRender.value).toBe(50);
});
it("calling masonryRepack() calls forcePack() on Masonry instance`", () => {
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const instance = tree.instance();
// it's a shallow render so we don't really have masonry mounted, fake it
instance.masonryComponentReference.ref = {
@@ -145,28 +166,28 @@ describe("<AlertGrid />", () => {
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=false`", () => {
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = false;
instance.masonryRepack();
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=null`", () => {
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = null;
instance.masonryRepack();
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=undefined`", () => {
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = undefined;
instance.masonryRepack();
});
it("calling storeMasonryRef() saves the ref in local store", () => {
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const instance = tree.instance();
instance.storeMasonryRef("foo");
expect(instance.masonryComponentReference.ref).toEqual("foo");
@@ -177,7 +198,7 @@ describe("<AlertGrid />", () => {
settingsStore.gridConfig.options.disabled.value;
settingsStore.gridConfig.config.reverseSort = false;
MockGroupList(3, 1);
const tree = ShallowAlertGrid();
const tree = ShallowGrid();
const alertGroups = tree.find("AlertGroup");
expect(alertGroups.map((g) => g.props().group.id)).toEqual([
"id1",
@@ -186,6 +207,85 @@ describe("<AlertGrid />", () => {
]);
});
it("click on the grid toggle toggles all groups", () => {
MockGroupList(10, 3);
const tree = MountedGrid();
tree.setProps({
gridLabelName: "foo",
gridLabelValue: "bar",
});
expect(tree.find("AlertGroup")).toHaveLength(10);
tree.find("span.cursor-pointer").at(0).simulate("click");
expect(tree.find("AlertGroup")).toHaveLength(0);
tree.find("span.cursor-pointer").at(0).simulate("click");
expect(tree.find("AlertGroup")).toHaveLength(10);
});
it("left click on a group collapse toggle only toggles clicked group", () => {
MockGroupList(10, 3);
const tree = MountedGrid();
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(3);
}
tree
.find("AlertGroup")
.at(2)
.find("GroupHeader")
.find("span.cursor-pointer")
.at(1)
.simulate("click");
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(
i === 2 ? 0 : 3
);
}
});
it("left click + alt on a group collapse toggle toggles all groups", () => {
MockGroupList(10, 3);
const tree = MountedGrid();
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(3);
}
tree
.find("AlertGroup")
.at(2)
.find("GroupHeader")
.find("span.cursor-pointer")
.at(1)
.simulate("click", { altKey: true });
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(0);
}
});
});
describe("<AlertGrid />", () => {
const VerifyColumnCount = (innerWidth, outerWidth, columns) => {
MockGroupList(60, 5);
const wrapper = ShallowAlertGrid();
wrapper.instance().viewport.updateWidths(innerWidth, outerWidth);
const tree = ShallowGrid();
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(
Math.floor(innerWidth / columns)
);
};
it("doesn't throw errors after FontFaceObserver timeout", () => {
MockGroupList(60, 5);
ShallowAlertGrid();
@@ -261,13 +361,24 @@ describe("<AlertGrid />", () => {
it("viewport resize also resizes alert groups", () => {
MockGroupList(60, 5);
const tree = ShallowAlertGrid();
const wrapper = ShallowAlertGrid();
const tree = ShallowGrid();
// set initial width
tree.instance().viewport.updateWidths(1980, 1980);
wrapper.instance().viewport.updateWidths(1980, 1980);
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(1980 / 4);
// then resize and verify if column count was changed
tree.instance().viewport.updateWidths(1000, 1000);
wrapper.instance().viewport.updateWidths(1000, 1000);
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(1000 / 2);
});
@@ -275,13 +386,23 @@ describe("<AlertGrid />", () => {
settingsStore.gridConfig.config.groupWidth = 400;
MockGroupList(60, 5);
const tree = ShallowAlertGrid();
const wrapper = ShallowAlertGrid();
const tree = ShallowGrid();
// set initial width
tree.instance().viewport.updateWidths(1600, 1600);
wrapper.instance().viewport.updateWidths(1600, 1600);
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(400);
// then resize and verify if column count was changed
tree.instance().viewport.updateWidths(1584, 1600);
wrapper.instance().viewport.updateWidths(1584, 1600);
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
expect(tree.find("AlertGroup").at(0).props().style.width).toBe(396);
});
@@ -297,14 +418,20 @@ describe("<AlertGrid />", () => {
it("viewport resize doesn't allow loops", () => {
settingsStore.gridConfig.config.groupWidth = 400;
const tree = ShallowAlertGrid();
MockGroupList(60, 5);
const wrapper = ShallowAlertGrid();
const tree = ShallowGrid();
let results = [];
for (var index = 0; index < 14; index++) {
MockGroupList(60, 5);
tree
wrapper
.instance()
.viewport.updateWidths(index % 2 === 0 ? 1600 : 1584, 1600);
tree.setProps({
gridSizesConfig: wrapper.instance().viewport.gridSizesConfig,
groupWidth: wrapper.instance().viewport.groupWidth,
});
results.push(tree.find("AlertGroup").at(0).props().style.width);
}
@@ -326,50 +453,6 @@ describe("<AlertGrid />", () => {
]);
});
it("left click on a group collapse toggle only toggles clicked group", () => {
MockGroupList(10, 3);
const tree = MountedAlertGroup();
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(3);
}
tree
.find("AlertGroup")
.at(2)
.find("GroupHeader")
.find("span.cursor-pointer")
.at(1)
.simulate("click");
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(
i === 2 ? 0 : 3
);
}
});
it("left click + alt on a group collapse toggle toggles all groups", () => {
MockGroupList(10, 3);
const tree = MountedAlertGroup();
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(3);
}
tree
.find("AlertGroup")
.at(2)
.find("GroupHeader")
.find("span.cursor-pointer")
.at(1)
.simulate("click", { altKey: true });
for (let i = 0; i <= 9; i++) {
expect(tree.find("AlertGroup").at(i).find("Alert")).toHaveLength(0);
}
});
it("doesn't crash on unmount", () => {
MockGroupList(60, 5);
const tree = ShallowAlertGrid();

View File

@@ -243,7 +243,13 @@ const MockGrid = (alertStore) => {
],
clusters: { am: ["am1", "am2"], failed: ["failed"] },
};
alertStore.data.groups = groups;
alertStore.data.grids = [
{
labelName: "",
labelValue: "",
alertGroups: groups,
},
];
};
export { MockGrid };