diff --git a/ui/package-lock.json b/ui/package-lock.json index 0d72c1815..db4910312 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 713f07ede..2b8870d4e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/Components/Grid/AlertGrid/Grid.js b/ui/src/Components/Grid/AlertGrid/Grid.js index fb7c15b86..44bbdc0ea 100644 --- a/ui/src/Components/Grid/AlertGrid/Grid.js +++ b/ui/src/Components/Grid/AlertGrid/Grid.js @@ -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 ( - - {grid.labelName !== "" && ( + return useObserver(() => ( + + {grid.labelName !== "" && ( + + )} +
+ {isExpanded || grid.labelName === "" + ? grid.alertGroups.slice(0, groupsToRender).map((group) => ( + 1 + } + afterUpdate={debouncedRepack} + alertStore={alertStore} + settingsStore={settingsStore} + silenceFormStore={silenceFormStore} + style={{ + width: groupWidth, + }} + gridLabelValue={grid.labelValue} + /> + )) + : []} +
+ {isExpanded && grid.alertGroups.length > groupsToRender && ( +
+
-
- - - - - {grid.labelName !== "" && grid.labelValue !== "" && ( - - )} - - - - - - - - - - - -
+
- )} - - {this.gridToggle.show || grid.labelName === "" - ? grid.alertGroups - .slice(0, this.groupsToRender.value) - .map((group) => ( - - 1 - } - afterUpdate={this.masonryRepack} - alertStore={alertStore} - settingsStore={settingsStore} - silenceFormStore={silenceFormStore} - style={{ - width: groupWidth, - }} - gridLabelValue={grid.labelValue} - /> - )) - : []} - - {this.gridToggle.show && - grid.alertGroups.length > this.groupsToRender.value && ( -
- - - - - -
- )} - - ); - } - } -); -Grid.contextType = ThemeContext; +
+
+ )} +
+ )); +}; +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 }; diff --git a/ui/src/Components/Grid/AlertGrid/Swimlane.js b/ui/src/Components/Grid/AlertGrid/Swimlane.js new file mode 100644 index 000000000..4aac7025a --- /dev/null +++ b/ui/src/Components/Grid/AlertGrid/Swimlane.js @@ -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 ( + +
+ + + + + {grid.labelName !== "" && grid.labelValue !== "" && ( + + )} + + + + + + + + + + + +
+
+ ); +}; +Swimlane.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + grid: APIGrid.isRequired, + isExpanded: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +}; + +export { Swimlane }; diff --git a/ui/src/Components/Grid/AlertGrid/index.test.js b/ui/src/Components/Grid/AlertGrid/index.test.js index e6e532042..585bfa02c 100644 --- a/ui/src/Components/Grid/AlertGrid/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/index.test.js @@ -30,6 +30,8 @@ beforeEach(() => { silenceFormStore = new SilenceFormStore(); window.matchMedia = mockMatchMedia({}); + + jest.spyOn(React, "useContext").mockImplementation(() => MockThemeContext); }); afterEach(() => { @@ -166,66 +168,6 @@ describe("", () => { 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("", () => { 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("", () => { 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({ diff --git a/ui/src/Hooks/useGrid.js b/ui/src/Hooks/useGrid.js new file mode 100644 index 000000000..53fa38d3d --- /dev/null +++ b/ui/src/Hooks/useGrid.js @@ -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 }; diff --git a/ui/src/Hooks/useGrid.test.js b/ui/src/Hooks/useGrid.test.js new file mode 100644 index 000000000..ca5d07d1c --- /dev/null +++ b/ui/src/Hooks/useGrid.test.js @@ -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 ( +
+ {Array.from(Array(count).keys()).map((i) => ( +
+ ))} +
+ ); + }; + + 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(); + 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(); + 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(); + tree.unmount(); + }); +});