diff --git a/ui/src/Components/NavBar/FilterInput/History.js b/ui/src/Components/NavBar/FilterInput/History.js index 6b49e286e..f27bd8fa9 100644 --- a/ui/src/Components/NavBar/FilterInput/History.js +++ b/ui/src/Components/NavBar/FilterInput/History.js @@ -1,8 +1,7 @@ -import React, { Component } from "react"; +import React, { useEffect, useRef } from "react"; import PropTypes from "prop-types"; -import { action, observable, toJS } from "mobx"; -import { observer } from "mobx-react"; +import { useObserver, useLocalStore } from "mobx-react"; import { localStored } from "mobx-stored"; import hash from "object-hash"; @@ -22,10 +21,6 @@ import { Settings } from "Stores/Settings"; import { DropdownSlide } from "Components/Animations/DropdownSlide"; import { HistoryLabel } from "Components/Labels/HistoryLabel"; -const defaultHistory = { - filters: [], -}; - // takes a filter object out of alertStore.history.values and creates a new // object with only those keys that will be stored in history function ReduceFilter(filter) { @@ -148,36 +143,34 @@ HistoryMenuContent.propTypes = { const HistoryMenu = onClickOutside(HistoryMenuContent); -const History = observer( - class History extends Component { - static propTypes = { - alertStore: PropTypes.instanceOf(AlertStore).isRequired, - settingsStore: PropTypes.instanceOf(Settings).isRequired, - }; +const History = ({ alertStore, settingsStore }) => { + // this will be dumped to local storage via mobx-stored + const history = localStored( + "history.filters", + { + filters: [], + }, + { + delay: 100, + } + ); - // how many filter sets do we store in local storage and render in the - // dropdown menu - maxSize = 8; - // this will be dumped to local storage via mobx-stored - history = localStored("history.filters", defaultHistory, { delay: 100 }); + const collapse = useLocalStore(() => ({ + isHidden: true, + toggle() { + this.isHidden = !this.isHidden; + }, + hide() { + this.isHidden = true; + }, + })); - collapse = observable( - { - value: true, - toggle() { - this.value = !this.value; - }, - hide() { - this.value = true; - }, - }, - { toggle: action.bound, hide: action.bound }, - { name: "History menu toggle" } - ); - - appendToHistory = action(() => { - const { alertStore } = this.props; + const mountRef = useRef(false); + // every time this component updates we will rewrite history + // (if there are changes) + useEffect(() => { + if (mountRef.current) { // we don't store unapplied (we only have raw text for those, we need // name & value for coloring) or invalid filters // also check for value, name might be missing for fuzzy filters, but @@ -191,85 +184,70 @@ const History = observer( // make a JSON dump for comparing later with what's already stored const filtersJSON = JSON.stringify(validAppliedFilters); - // dump observable array with stored filters to JS objects, without this - // we'll be passing around and comparing proxy objects that might mutate - // while we do so - const storedFilters = toJS(this.history.filters); - // rewrite history putting current filter set on top, this will move // it up if user selects a filter set that was already in history let newHistory = [ ...[validAppliedFilters], - ...storedFilters.filter((f) => JSON.stringify(f) !== filtersJSON), - ].slice(0, this.maxSize); - this.history.filters = newHistory; - }); - - clearHistory = action(() => { - this.history.filters = []; - }); - - componentDidUpdate() { - // every time this component updates we will rewrite history - // (if there are changes) - this.appendToHistory(); + ...history.filters.filter((f) => JSON.stringify(f) !== filtersJSON), + ].slice(0, 8); + history.filters = newHistory; + } else { + mountRef.current = true; } + }); - handleClickOutside = action((event) => { - this.collapse.hide(); - }); - - render() { - const { alertStore, settingsStore } = this.props; - - return ( - // data-filters is there to register filters for observation in mobx - // it needs to be using full filter object to notice changes to - // name & value but ignore hits - // using it this way will force re-render on every change, which is - // needed to keep track of all filter changes - ReduceFilter(f)) - .join(" ")} - > - - {({ ref }) => ( - - )} - - - - {({ placement, ref, style }) => ( - - )} - - - - ); - } - } -); + return useObserver(() => ( + // data-filters is there to register filters for observation in mobx + // it needs to be using full filter object to notice changes to + // name & value but ignore hits + // using it this way will force re-render on every change, which is + // needed to keep track of all filter changes + ReduceFilter(f)) + .join(" ")} + > + + {({ ref }) => ( + + )} + + + + {({ placement, ref, style }) => ( + { + history.filters = []; + }} + alertStore={alertStore} + settingsStore={settingsStore} + afterClick={collapse.hide} + handleClickOutside={collapse.hide} + outsideClickIgnoreClass="components-navbar-history" + /> + )} + + + + )); +}; +History.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + settingsStore: PropTypes.instanceOf(Settings).isRequired, +}; export { History, HistoryMenu, HistoryMenuContent, ReduceFilter }; diff --git a/ui/src/Components/NavBar/FilterInput/History.test.js b/ui/src/Components/NavBar/FilterInput/History.test.js index d8fa646d1..f3183ce7c 100644 --- a/ui/src/Components/NavBar/FilterInput/History.test.js +++ b/ui/src/Components/NavBar/FilterInput/History.test.js @@ -1,106 +1,100 @@ import React from "react"; import { act } from "react-dom/test-utils"; -import { mount, shallow } from "enzyme"; +import { mount } from "enzyme"; import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; -import { History, HistoryMenu, ReduceFilter } from "./History"; +import { History } from "./History"; let alertStore; let settingsStore; +beforeAll(() => { + jest.useFakeTimers(); +}); + beforeEach(() => { alertStore = new AlertStore([]); settingsStore = new Settings(); }); +afterEach(() => { + localStorage.setItem("history.filters", ""); +}); + const MountedHistory = () => { return mount( ); }; -const ShallowHistoryMenu = (historyTree) => { - const tree = shallow( - - ); - return tree; -}; - -const MountedHistoryMenu = (historyTree) => { - const tree = mount( - - ); - return tree; -}; - const AppliedFilter = (name, matcher, value) => { const filter = NewUnappliedFilter(`${name}${matcher}${value}`); filter.applied = true; + filter.isValid = true; filter.name = name; filter.matcher = matcher; filter.value = value; return filter; }; -const PopulateHistory = (instance, count) => { +const PopulateHistory = (tree, count) => { for (let i = 1; i <= count; i++) { alertStore.filters.values = [ AppliedFilter("foo", "=", `bar${i}`), AppliedFilter("baz", "=~", `bar${i}`), ]; - instance.appendToHistory(); + tree.update(); + jest.runOnlyPendingTimers(); } }; describe("", () => { - it("renders dropdown button when menu is hidden", () => { - const tree = MountedHistory(); - const dropdown = tree.find("button"); - expect(dropdown.props().className.split(" ")).toContain( - "components-navbar-history" - ); - }); - - // Due to https://github.com/FezVrasta/popper.js/issues/478 we can't test - // rendered dropdown content, only the fact that toggle value is updated - it("renders dropdown button when menu is visible", async () => { + it("menu content is hidden by default", async () => { const promise = Promise.resolve(); const tree = MountedHistory(); - const toggle = tree.find("button"); - - expect(tree.instance().collapse.value).toBe(true); - toggle.simulate("click"); - expect(tree.instance().collapse.value).toBe(false); + expect(tree.find("div.dropdown-menu")).toHaveLength(0); await act(() => promise); }); - it("hides when handleClickOutside() is called", async () => { + it("clicking toggle renders menu content", async () => { const promise = Promise.resolve(); const tree = MountedHistory(); - const instance = tree.instance(); - instance.collapse.value = false; - instance.handleClickOutside(); - expect(tree.instance().collapse.value).toBe(true); + const toggle = tree.find("button.cursor-pointer"); + toggle.simulate("click"); + expect(tree.find("div.dropdown-menu")).toHaveLength(1); + await act(() => promise); + }); + + it("clicking toggle twice hides menu content", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + const toggle = tree.find("button.cursor-pointer"); + + toggle.simulate("click"); + jest.runOnlyPendingTimers(); + expect(tree.find("div.dropdown-menu")).toHaveLength(1); + + toggle.simulate("click"); + jest.runOnlyPendingTimers(); + tree.update(); + expect(tree.find("div.dropdown-menu")).toHaveLength(0); + await act(() => promise); + }); + + it("clicking menu item hides menu content", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + const toggle = tree.find("button.cursor-pointer"); + + toggle.simulate("click"); + expect(tree.find("div.dropdown-menu")).toHaveLength(1); + + tree.find(".component-history-button").at(0).simulate("click"); + jest.runOnlyPendingTimers(); + tree.update(); + expect(tree.find("div.dropdown-menu")).toHaveLength(0); await act(() => promise); }); @@ -112,73 +106,71 @@ describe("", () => { AppliedFilter("baz", "!=", "bar"), ]; const tree = MountedHistory(); - tree.instance().appendToHistory(); - expect(tree.instance().history.filters).toHaveLength(1); - expect(JSON.stringify(tree.instance().history.filters[0])).toBe( - JSON.stringify([ - ReduceFilter(AppliedFilter("foo", "=", "bar")), - ReduceFilter(AppliedFilter("baz", "!=", "bar")), - ]) - ); + tree.find("button.cursor-pointer").simulate("click"); + expect(tree.find("button.dropdown-item")).toHaveLength(1); + const labels = tree.find("HistoryLabel"); + expect(labels).toHaveLength(2); + expect(labels.at(0).html()).toMatch(/>foo=barbaz!=bar promise); }); }); describe("", () => { - it("renders correctly when rendered with empty history", () => { - const historyTree = MountedHistory(); - const tree = ShallowHistoryMenu(historyTree); + it("renders correctly when rendered with empty history", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + tree.find("button.cursor-pointer").simulate("click"); expect(tree.text()).toBe( - "Last used filtersEmpty" + "Last used filtersEmptySave filtersReset filtersClear history" ); + await act(() => promise); }); - it("renders correctly when rendered with a filter in history", () => { - const historyTree = MountedHistory(); - PopulateHistory(historyTree.instance(), 1); + it("renders correctly when rendered with a filter in history", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + PopulateHistory(tree, 1); + tree.find("button.cursor-pointer").simulate("click"); - const tree = ShallowHistoryMenu(historyTree); expect(tree.text()).toBe( - "Last used filters" + "Last used filtersfoo=bar1baz=~bar1Save filtersReset filtersClear history" ); const labels = tree.find("HistoryLabel"); expect(labels).toHaveLength(2); expect(labels.at(0).html()).toMatch(/>foo=bar1baz=~bar1 promise); }); - it("clicking on a filter set in history populates alertStore", () => { - const historyTree = MountedHistory(); - PopulateHistory(historyTree.instance(), 1); + it("clicking on a filter set in history populates alertStore", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + PopulateHistory(tree, 1); + tree.find("button.cursor-pointer").simulate("click"); - const tree = MountedHistoryMenu(historyTree); - const button = tree.find("button").at(0); + const button = tree.find("button.dropdown-item").at(0); expect(button.text()).toBe("foo=bar1baz=~bar1"); alertStore.filters.values = [AppliedFilter("job", "=", "foo")]; expect(alertStore.filters.values).toHaveLength(1); button.simulate("click"); + jest.runOnlyPendingTimers(); expect(alertStore.filters.values).toHaveLength(2); expect(alertStore.filters.values[0]).toMatchObject({ raw: "foo=bar1" }); expect(alertStore.filters.values[1]).toMatchObject({ raw: "baz=~bar1" }); + await act(() => promise); }); - it("stores only up to maxSize last filter sets in history storage", () => { - const historyTree = MountedHistory(); - const instance = historyTree.instance(); - const maxSize = instance.maxSize; - PopulateHistory(instance, maxSize * 2); - expect(instance.history.filters).toHaveLength(maxSize); - }); + it("renders only up to maxSize last filter sets in history", async () => { + const promise = Promise.resolve(); + const tree = MountedHistory(); + PopulateHistory(tree, 16); + tree.find("button.cursor-pointer").simulate("click"); + expect(tree.find("button.dropdown-item")).toHaveLength(8); - it("renders only up to maxSize last filter sets in history", () => { - const historyTree = MountedHistory(); - const instance = historyTree.instance(); - PopulateHistory(instance, instance.maxSize * 2); - - const tree = ShallowHistoryMenu(historyTree); const labelSets = tree.find(".components-navbar-historymenu-labels"); expect(labelSets).toHaveLength(8); @@ -193,6 +185,7 @@ describe("", () => { expect(labelsFist).toHaveLength(2); expect(labelsFist.at(0).html()).toMatch(/>foo=bar16baz=~bar16 promise); }); it("clicking on 'Save filters' saves current filter set to Settings", () => { @@ -201,12 +194,13 @@ describe("", () => { AppliedFilter("bar", "=~", "baz"), ]; - const historyTree = MountedHistory(); - const tree = MountedHistoryMenu(historyTree); + const tree = MountedHistory(); + tree.find("button.cursor-pointer").simulate("click"); const button = tree.find(".component-history-button").at(0); expect(button.text()).toBe("Save filters"); button.simulate("click"); + jest.runOnlyPendingTimers(); expect(settingsStore.savedFilters.config.filters).toHaveLength(2); expect(settingsStore.savedFilters.config.filters).toContain("foo=bar"); expect(settingsStore.savedFilters.config.filters).toContain("bar=~baz"); @@ -214,27 +208,27 @@ describe("", () => { it("clicking on 'Reset filters' clears current filter set in Settings", () => { settingsStore.savedFilters.config.filters = ["foo=bar"]; + const tree = MountedHistory(); + tree.find("button.cursor-pointer").simulate("click"); - const historyTree = MountedHistory(); - const tree = MountedHistoryMenu(historyTree); const button = tree.find(".component-history-button").at(1); expect(button.text()).toBe("Reset filters"); - button.simulate("click"); + jest.runOnlyPendingTimers(); expect(settingsStore.savedFilters.config.filters).toHaveLength(0); }); it("clicking on 'Clear history' clears the history", () => { - const historyTree = MountedHistory(); - const instance = historyTree.instance(); - PopulateHistory(instance, 5); - expect(instance.history.filters).toHaveLength(5); + const tree = MountedHistory(); + PopulateHistory(tree, 5); + tree.find("button.cursor-pointer").simulate("click"); + expect(tree.find("button.dropdown-item")).toHaveLength(5); - const tree = MountedHistoryMenu(historyTree); const button = tree.find(".component-history-button").at(2); expect(button.text()).toBe("Clear history"); - button.simulate("click"); - expect(instance.history.filters).toHaveLength(0); + jest.runOnlyPendingTimers(); + tree.update(); + expect(tree.find("button.dropdown-item")).toHaveLength(0); }); });