fix(ui): use custom masonry grid component

This commit is contained in:
Łukasz Mierzwa
2020-05-20 18:46:34 +01:00
committed by Łukasz Mierzwa
parent 90d969a09e
commit 53796bf54e
7 changed files with 326 additions and 365 deletions
-18
View File
@@ -19052,14 +19052,6 @@
"resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-4.2.12.tgz",
"integrity": "sha512-YD/2Oe4PU5uRv/TH6zTxykKMHpRHWHPEWCUohda81o/jzsrlgyUrklfy46fd8WjgYhlNkJKsiX/GXJAQQC1hcQ=="
},
"react-infinite-scroller": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/react-infinite-scroller/-/react-infinite-scroller-1.2.4.tgz",
"integrity": "sha512-/oOa0QhZjXPqaD6sictN2edFMsd3kkMiE19Vcz5JDgHpzEJVqYcmq+V3mkwO88087kvKGe1URNksHEOt839Ubw==",
"requires": {
"prop-types": "^15.5.8"
}
},
"react-input-autosize": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.2.tgz",
@@ -19119,16 +19111,6 @@
"tlds": "^1.57.0"
}
},
"react-masonry-infinite": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/react-masonry-infinite/-/react-masonry-infinite-1.2.2.tgz",
"integrity": "sha1-IME4b5zN2pdHUnyPQrwsAt0ueVE=",
"requires": {
"bricks.js": "^1.7.0",
"prop-types": "^15.5.10",
"react-infinite-scroller": "^1.0.12"
}
},
"react-media": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/react-media/-/react-media-1.10.0.tgz",
+1 -1
View File
@@ -18,6 +18,7 @@
"body-scroll-lock": "3.0.2",
"bootstrap": "4.5.0",
"bootswatch": "4.5.0",
"bricks.js": "1.8.0",
"copy-to-clipboard": "3.3.1",
"csshake": "1.5.3",
"fast-deep-equal": "3.1.1",
@@ -50,7 +51,6 @@
"react-js-pagination": "3.0.3",
"react-json-pretty": "2.2.0",
"react-linkify": "0.2.2",
"react-masonry-infinite": "1.2.2",
"react-media": "1.10.0",
"react-moment": "0.9.7",
"react-popper": "2.2.3",
+143 -276
View File
@@ -1,8 +1,7 @@
import React, { Component } from "react";
import React, { useEffect, useState, useCallback } from "react";
import PropTypes from "prop-types";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { useObserver } from "mobx-react";
import debounce from "lodash.debounce";
@@ -10,297 +9,165 @@ import { Fade } from "react-reveal";
import FontFaceObserver from "fontfaceobserver";
import MasonryInfiniteScroller from "react-masonry-infinite";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTh } from "@fortawesome/free-solid-svg-icons/faTh";
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 { APIGrid } from "Models/API";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { FilteringCounterBadge } from "Components/Labels/FilteringCounterBadge";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { useGrid } from "Hooks/useGrid";
import { ThemeContext } from "Components/Theme";
import { ToggleIcon } from "Components/ToggleIcon";
import { DefaultDetailsCollapseValue } from "./AlertGroup/DetailsToggle";
import { AlertGroup } from "./AlertGroup";
import { Swimlane } from "./Swimlane";
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,
grid: APIGrid.isRequired,
outerPadding: PropTypes.number.isRequired,
const Grid = ({
alertStore,
settingsStore,
silenceFormStore,
gridSizesConfig,
groupWidth,
grid,
outerPadding,
}) => {
const { ref, repack } = useGrid(gridSizesConfig);
const debouncedRepack = debounce(repack, 10);
const [groupsToRender, setGroupsToRender] = useState(50);
const [isExpanded, setIsExpanded] = useState(
!DefaultDetailsCollapseValue(settingsStore)
);
const toggleIsExpanded = useCallback(() => {
setIsExpanded(!isExpanded);
debouncedRepack();
}, [debouncedRepack, isExpanded]);
const onCollapseClick = (event) => {
// left click => toggle current grid
// left click + alt => toggle all grids
if (event.altKey === true) {
const toggleEvent = new CustomEvent("alertGridCollapse", {
detail: !isExpanded,
});
window.dispatchEvent(toggleEvent);
} else {
toggleIsExpanded();
}
};
const onAlertGridCollapseEvent = useCallback(
(event) => {
setIsExpanded(event.detail);
debouncedRepack();
},
[debouncedRepack]
);
useEffect(() => {
// 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
// (once the final font is loaded). This means that fallback font might
// render to a different size and the swap can result in component resize.
// For our grid this resize might leave gaps since everything uses fixed
// position, so we use font observer and trigger repack when fonts are loaded
for (const fontWeight of [300, 400, 600]) {
const font = new FontFaceObserver("Open Sans", {
weight: fontWeight,
});
// wait up to 30s, run no-op function on timeout
font.load(null, 30000).then(debouncedRepack, () => {});
}
window.addEventListener("alertGridCollapse", onAlertGridCollapseEvent);
return () => {
window.removeEventListener("alertGridCollapse", onAlertGridCollapseEvent);
};
}, [debouncedRepack, onAlertGridCollapseEvent]);
// 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 { grid } = this.props;
this.groupsToRender.value = Math.min(
this.groupsToRender.value + this.loadMoreStep,
grid.alertGroups.length
);
});
constructor(props) {
super(props);
const { settingsStore } = props;
this.gridToggle = observable(
{
show: !DefaultDetailsCollapseValue(settingsStore),
toggle() {
this.show = !this.show;
},
set(value) {
this.show = value;
},
},
{
toggle: action.bound,
set: action.bound,
}
);
useEffect(() => {
if (groupsToRender > grid.alertGroups.length) {
setGroupsToRender(Math.max(50, grid.alertGroups.length));
}
}, [grid.alertGroups.length, groupsToRender]);
onCollapseClick = (event) => {
// left click => toggle current grid
// left click + alt => toggle all grids
const context = React.useContext(ThemeContext);
this.gridToggle.toggle();
if (event.altKey === true) {
const toggleEvent = new CustomEvent("alertGridCollapse", {
detail: this.gridToggle.show,
});
window.dispatchEvent(toggleEvent);
}
};
onAlertGridCollapseEvent = (event) => {
this.gridToggle.set(event.detail);
};
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
// (once the final font is loaded). This means that fallback font might
// render to a different size and the swap can result in component resize.
// For our grid this resize might leave gaps since everything uses fixed
// position, so we use font observer and trigger repack when fonts are loaded
for (const fontWeight of [300, 400, 600]) {
const font = new FontFaceObserver("Open Sans", {
weight: fontWeight,
});
// wait up to 30s, run no-op function on timeout
font.load(null, 30000).then(this.masonryRepack, () => {});
}
window.addEventListener(
"alertGridCollapse",
this.onAlertGridCollapseEvent
);
}
componentDidUpdate() {
const { grid } = this.props;
this.masonryRepack();
if (this.groupsToRender.value > grid.alertGroups.length) {
this.groupsToRender.setValue(
Math.max(this.initial, grid.alertGroups.length)
);
}
}
componentWillUnmount() {
window.removeEventListener(
"alertGridCollapse",
this.onAlertGridCollapseEvent
);
}
render() {
const {
alertStore,
settingsStore,
silenceFormStore,
gridSizesConfig,
groupWidth,
grid,
outerPadding,
} = this.props;
return (
<React.Fragment>
{grid.labelName !== "" && (
return useObserver(() => (
<React.Fragment>
{grid.labelName !== "" && (
<Swimlane
alertStore={alertStore}
grid={grid}
isExpanded={isExpanded}
onToggle={onCollapseClick}
/>
)}
<div
className="components-grid"
ref={ref}
key={settingsStore.gridConfig.config.groupWidth}
style={{
paddingLeft: outerPadding + "px",
paddingRight: outerPadding + "px",
}}
>
{isExpanded || grid.labelName === ""
? grid.alertGroups.slice(0, groupsToRender).map((group) => (
<AlertGroup
key={group.id}
group={group}
showAlertmanagers={
Object.keys(alertStore.data.upstreams.clusters).length > 1
}
afterUpdate={debouncedRepack}
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
style={{
width: groupWidth,
}}
gridLabelValue={grid.labelValue}
/>
))
: []}
</div>
{isExpanded && grid.alertGroups.length > groupsToRender && (
<div className="d-flex flex-row justify-content-between">
<div className="flex-shrink-1 flex-grow-1 text-center">
<Fade
in={this.context.animations.in}
duration={this.context.animations.duration}
in={context.animations.in}
duration={context.animations.duration}
>
<h5 className="components-grid-swimlane d-flex flex-row justify-content-between rounded px-2 py-1 mt-2 mb-0 border border-dark">
<span
className="flex-shrink-1 flex-grow-1"
style={{ minWidth: "0px" }}
>
<span className="badge components-label px-0 ml-1 mr-3">
<FontAwesomeIcon icon={faTh} className="text-muted" />
</span>
{grid.labelName !== "" && grid.labelValue !== "" && (
<FilteringLabel
key={grid.labelValue}
name={grid.labelName}
value={grid.labelValue}
alertStore={alertStore}
/>
)}
</span>
<span className="flex-shrink-0 flex-grow-0 ml-2 mr-0">
<FilteringCounterBadge
name="@state"
value="unprocessed"
counter={grid.stateCount.unprocessed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="suppressed"
counter={grid.stateCount.suppressed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="active"
counter={grid.stateCount.active}
themed={true}
alertStore={alertStore}
/>
<span
className="text-muted cursor-pointer badge px-0 components-label ml-2 mr-1"
onClick={this.onCollapseClick}
>
<TooltipWrapper title="Click to toggle this grid details or Alt+Click to toggle all grids">
<ToggleIcon isOpen={this.gridToggle.show} />
</TooltipWrapper>
</span>
</span>
</h5>
<button
type="button"
className="btn btn-secondary mb-3"
onClick={() =>
setGroupsToRender(
Math.min(groupsToRender + 30, grid.alertGroups.length)
)
}
>
<FontAwesomeIcon className="mr-2" icon={faAngleDoubleDown} />
Load more
</button>
</Fade>
)}
<MasonryInfiniteScroller
key={settingsStore.gridConfig.config.groupWidth}
ref={this.storeMasonryRef}
position={false}
pack={true}
sizes={gridSizesConfig}
loadMore={this.loadMore}
hasMore={false}
style={{
paddingLeft: `${outerPadding}px`,
paddingRight: `${outerPadding}px`,
}}
>
{this.gridToggle.show || grid.labelName === ""
? grid.alertGroups
.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,
}}
gridLabelValue={grid.labelValue}
/>
))
: []}
</MasonryInfiniteScroller>
{this.gridToggle.show &&
grid.alertGroups.length > this.groupsToRender.value && (
<div className="d-flex flex-row justify-content-between">
<span className="flex-shrink-1 flex-grow-1 text-center">
<Fade
in={this.context.animations.in}
duration={this.context.animations.duration}
>
<button
type="button"
className="btn btn-secondary mb-3"
onClick={this.loadMore}
>
<FontAwesomeIcon
className="mr-2"
icon={faAngleDoubleDown}
/>
Load more
</button>
</Fade>
</span>
</div>
)}
</React.Fragment>
);
}
}
);
Grid.contextType = ThemeContext;
</div>
</div>
)}
</React.Fragment>
));
};
Grid.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
settingsStore: PropTypes.instanceOf(Settings).isRequired,
silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired,
gridSizesConfig: PropTypes.array.isRequired,
groupWidth: PropTypes.number.isRequired,
grid: APIGrid.isRequired,
outerPadding: PropTypes.number.isRequired,
};
export { Grid };
@@ -0,0 +1,78 @@
import React from "react";
import PropTypes from "prop-types";
import { Fade } from "react-reveal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTh } from "@fortawesome/free-solid-svg-icons/faTh";
import { AlertStore } from "Stores/AlertStore";
import { APIGrid } from "Models/API";
import { FilteringLabel } from "Components/Labels/FilteringLabel";
import { FilteringCounterBadge } from "Components/Labels/FilteringCounterBadge";
import { TooltipWrapper } from "Components/TooltipWrapper";
import { ToggleIcon } from "Components/ToggleIcon";
import { ThemeContext } from "Components/Theme";
const Swimlane = ({ alertStore, grid, isExpanded, onToggle }) => {
const context = React.useContext(ThemeContext);
return (
<Fade in={context.animations.in} duration={context.animations.duration}>
<h5 className="components-grid-swimlane d-flex flex-row justify-content-between rounded px-2 py-1 mt-2 mb-0 border border-dark">
<span className="flex-shrink-1 flex-grow-1" style={{ minWidth: "0px" }}>
<span className="badge components-label px-0 ml-1 mr-3">
<FontAwesomeIcon icon={faTh} className="text-muted" />
</span>
{grid.labelName !== "" && grid.labelValue !== "" && (
<FilteringLabel
key={grid.labelValue}
name={grid.labelName}
value={grid.labelValue}
alertStore={alertStore}
/>
)}
</span>
<span className="flex-shrink-0 flex-grow-0 ml-2 mr-0">
<FilteringCounterBadge
name="@state"
value="unprocessed"
counter={grid.stateCount.unprocessed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="suppressed"
counter={grid.stateCount.suppressed}
themed={true}
alertStore={alertStore}
/>
<FilteringCounterBadge
name="@state"
value="active"
counter={grid.stateCount.active}
themed={true}
alertStore={alertStore}
/>
<span
className="text-muted cursor-pointer badge px-0 components-label ml-2 mr-1"
onClick={onToggle}
>
<TooltipWrapper title="Click to toggle this grid details or Alt+Click to toggle all grids">
<ToggleIcon isOpen={isExpanded} />
</TooltipWrapper>
</span>
</span>
</h5>
</Fade>
);
};
Swimlane.propTypes = {
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
grid: APIGrid.isRequired,
isExpanded: PropTypes.bool.isRequired,
onToggle: PropTypes.func.isRequired,
};
export { Swimlane };
+8 -70
View File
@@ -30,6 +30,8 @@ beforeEach(() => {
silenceFormStore = new SilenceFormStore();
window.matchMedia = mockMatchMedia({});
jest.spyOn(React, "useContext").mockImplementation(() => MockThemeContext);
});
afterEach(() => {
@@ -166,66 +168,6 @@ describe("<Grid />", () => {
expect(alertGroups).toHaveLength(80);
});
it("resets groupsToRender.value back to 50 if current value is more than group alerts", () => {
MockGroupList(100, 5);
const tree = MountedGrid();
expect(tree.find("AlertGroup")).toHaveLength(50);
expect(tree.instance().groupsToRender.value).toBe(50);
tree.find("button").simulate("click");
expect(tree.find("AlertGroup")).toHaveLength(80);
expect(tree.instance().groupsToRender.value).toBe(80);
MockGroupList(10, 5);
tree.setProps({ grid: MockGrid() });
expect(tree.find("AlertGroup")).toHaveLength(10);
expect(tree.instance().groupsToRender.value).toBe(50);
MockGroupList(100, 5);
tree.setProps({ grid: MockGrid() });
expect(tree.find("AlertGroup")).toHaveLength(50);
expect(tree.instance().groupsToRender.value).toBe(50);
});
it("calling masonryRepack() calls forcePack() on Masonry instance`", () => {
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 = {
forcePack: jest.fn(),
};
instance.masonryRepack();
expect(instance.masonryComponentReference.ref.forcePack).toHaveBeenCalled();
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=false`", () => {
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = false;
instance.masonryRepack();
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=null`", () => {
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = null;
instance.masonryRepack();
});
it("masonryRepack() doesn't crash when masonryComponentReference.ref=undefined`", () => {
const tree = ShallowGrid();
const instance = tree.instance();
instance.masonryComponentReference.ref = undefined;
instance.masonryRepack();
});
it("calling storeMasonryRef() saves the ref in local store", () => {
const tree = ShallowGrid();
const instance = tree.instance();
instance.storeMasonryRef("foo");
expect(instance.masonryComponentReference.ref).toEqual("foo");
});
it("doesn't sort groups when sorting is set to 'disabled'", () => {
settingsStore.gridConfig.config.sortOrder =
settingsStore.gridConfig.options.disabled.value;
@@ -662,14 +604,10 @@ describe("<AlertGrid />", () => {
const tree = MountedAlertGrid();
tree.instance().viewport.updateWidths(1200, 1000);
tree.update();
expect(tree.find("Grid")).toHaveLength(2);
expect(tree.find("div.components-grid")).toHaveLength(2);
expect(tree.find("AlertGroup")).toHaveLength(20);
expect(tree.find("Grid").at(0).prop("outerPadding")).toBe(5);
expect(tree.find("Grid").at(1).prop("outerPadding")).toBe(5);
expect(
tree.find("Grid").at(0).find("div").at(3).prop("style")
).toMatchObject({
expect(tree.find("div.components-grid").at(0).prop("style")).toMatchObject({
paddingLeft: "5px",
paddingRight: "5px",
});
@@ -702,10 +640,10 @@ describe("<AlertGrid />", () => {
expect(tree.find("Grid")).toHaveLength(1);
expect(tree.find("AlertGroup")).toHaveLength(10);
expect(tree.find("Grid").at(0).prop("outerPadding")).toBe(0);
expect(
tree.find("Grid").at(0).find("div").at(1).prop("style")
).toMatchObject({ paddingLeft: "0px", paddingRight: "0px" });
expect(tree.find("div.components-grid").at(0).prop("style")).toMatchObject({
paddingLeft: "0px",
paddingRight: "0px",
});
tree.find("div.components-grid-alertgrid-alertgroup").forEach((node) => {
expect(node.prop("style")).toMatchObject({
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
import Bricks from "bricks.js";
const useGrid = (sizes) => {
const ref = useRef(null);
const grid = useRef(null);
const repack = () => {
if (grid.current) {
grid.current.pack();
}
};
useEffect(() => {
if (!grid.current && ref.current) {
grid.current = new Bricks({
container: ref.current,
sizes: sizes,
packed: "packed",
position: false,
});
window.addEventListener("resize", repack);
grid.current.pack();
}
return () => {
window.removeEventListener("resize", repack);
grid.current = null;
};
}, [sizes]);
return { ref, repack };
};
export { useGrid };
+60
View File
@@ -0,0 +1,60 @@
import React from "react";
import { renderHook, act } from "@testing-library/react-hooks";
import { mount } from "enzyme";
import { useGrid } from "./useGrid";
describe("useGrid", () => {
const sizes = [{ columns: 2, gutter: 0 }];
const Component = ({ count }) => {
const { ref, repack } = useGrid(sizes);
return (
<div ref={ref} id="root" onClick={repack}>
{Array.from(Array(count).keys()).map((i) => (
<div key={i} id={`item${i}`} style={{ width: 400 }}></div>
))}
</div>
);
};
it("does nothing if ref is null", () => {
const { result } = renderHook(() => useGrid());
expect(result.current.ref.current).toBe(null);
});
it("repack does nothing if ref is null", () => {
const { result } = renderHook(() => useGrid());
expect(result.current.ref.current).toBe(null);
result.current.repack();
});
it("packs grid if ref is set", () => {
const tree = mount(<Component count={4} />);
expect(tree.find("#item0").html()).toMatch(/data-packed/);
expect(tree.find("#item1").html()).toMatch(/data-packed/);
expect(tree.find("#item2").html()).toMatch(/data-packed/);
expect(tree.find("#item3").html()).toMatch(/data-packed/);
});
it("repack will repack the grid if ref is set", () => {
const tree = mount(<Component count={4} />);
expect(tree.find("#item0").html()).toMatch(/data-packed/);
expect(tree.find("#item1").html()).toMatch(/data-packed/);
expect(tree.find("#item2").html()).toMatch(/data-packed/);
expect(tree.find("#item3").html()).toMatch(/data-packed/);
tree.setProps({ count: 5 });
expect(tree.find("#item4").html()).not.toMatch(/data-packed/);
tree.find("#root").simulate("click");
expect(tree.find("#item4").html()).toMatch(/data-packed/);
});
it("unmounts cleanly", () => {
const tree = mount(<Component count={4} />);
tree.unmount();
});
});