diff --git a/ui/src/AppBoot.test.js b/ui/src/AppBoot.test.js index 705301c97..0f390bf58 100644 --- a/ui/src/AppBoot.test.js +++ b/ui/src/AppBoot.test.js @@ -87,4 +87,14 @@ describe("ParseDefaultFilters()", () => { expect(filters).toContain("foo=bar"); expect(filters).toContain("bar=~baz"); }); + + it("returns [] on filters attr that decodes to an object instead of an array", () => { + const filters = FiltersSetting({ foo: "bar" }); + expect(filters).toHaveLength(0); + }); + + it("returns [] on filters attr that decodes to a string instead of an array", () => { + const filters = FiltersSetting("foo=bar"); + expect(filters).toHaveLength(0); + }); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js index 70eae7ec5..92819de8b 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.js @@ -29,8 +29,9 @@ const RenderNonLinkAnnotation = inject("alertStore")( { visible: true, show(e) { - // don't action link clicks inside Linkify - if (e.target.nodeName !== "A") this.visible = true; + // Linkify only handles value, no need to check for links of + // collapsed annotation + this.visible = true; }, hide(e) { // don't action link clicks inside Linkify diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js index ed1edbbad..31a3bfa67 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Annotation/index.test.js @@ -59,6 +59,18 @@ const MountedNonLinkAnnotation = visible => { ); }; +const MountedNonLinkAnnotationContainingLink = visible => { + return mount( + + ); +}; + describe("", () => { it("matches snapshot when visible=true", () => { const tree = ShallowNonLinkAnnotation(true); @@ -80,6 +92,12 @@ describe("", () => { expect(tree.html()).not.toMatch(/some long text/); }); + it("links inside annotation are rendered as a.href", () => { + const tree = MountedNonLinkAnnotationContainingLink(true); + const link = tree.find("a[href='http://example.com']"); + expect(link.text()).toBe("http://example.com"); + }); + it("clicking on + icon hides the value", () => { const tree = MountedNonLinkAnnotation(true); expect(tree.html()).toMatch(/fa-search-minus/); @@ -89,6 +107,13 @@ describe("", () => { expect(tree.html()).not.toMatch(/some long text/); }); + it("clicking on a link inside annotation doesn't hide the value", () => { + const tree = MountedNonLinkAnnotationContainingLink(true); + expect(tree.html()).toMatch(/fa-search-minus/); + tree.find("a").simulate("click"); + expect(tree.html()).toMatch(/fa-search-minus/); + }); + it("clicking on - icon shows the value", () => { const tree = MountedNonLinkAnnotation(false); expect(tree.html()).toMatch(/fa-search-plus/); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap index 2c7daac4d..fe7c57330 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/__snapshots__/index.test.js.snap @@ -112,6 +112,9 @@ exports[` matches snapshot with expaned details 1`] = ` alertname=MockAlert + + instance=~foo[0-9]+ + " diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js index 56ddd651b..4eb9b29de 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js @@ -30,6 +30,11 @@ const silence = { name: "alertname", value: "MockAlert", isRegex: false + }, + { + name: "instance", + value: "foo[0-9]+", + isRegex: true } ], startsAt: "2000-01-01T10:00:00Z", diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js index 815ddf9d5..a85aed849 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.js @@ -32,6 +32,15 @@ LoadButton.propTypes = { action: PropTypes.func.isRequired }; +const AllAlertsAreUsingSameAlertmanagers = alerts => { + const usedAMs = alerts.map(alert => + alert.alertmanager.map(am => am.name).sort() + ); + return usedAMs.every( + listOfAMs => JSON.stringify(listOfAMs) === JSON.stringify(usedAMs[0]) + ); +}; + const AlertGroup = observer( class AlertGroup extends Component { static propTypes = { @@ -137,9 +146,7 @@ const AlertGroup = observer( // alertmanagers (and there's > 1 alert to show, there's no footer for 1) showAlertmanagersInFooter = group.alerts.length > 1 && - Object.values(group.alertmanagerCount).every( - elem => elem === Object.values(group.alertmanagerCount)[0] - ); + AllAlertsAreUsingSameAlertmanagers(group.alerts); if (showAlertmanagersInFooter) { footerAlertmanagers = group.alerts[0].alertmanager.map(am => am.name); } diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js index 9c71d5cac..3739ac412 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/index.test.js @@ -65,6 +65,27 @@ describe("", () => { expect(tree.find("GroupFooter").html()).toMatch(/@alertmanager: default/); }); + it("doesn't render alertmanager labels in footer when they are unique", () => { + MockAlerts(5); + for (let i = 0; i < group.alerts.length; i++) { + group.alerts[i].alertmanager[0].name = `fakeAlertmanager${i}`; + } + group.alertmanagerCount = { + fakeAlertmanager0: 1, + fakeAlertmanager1: 1, + fakeAlertmanager2: 1, + fakeAlertmanager3: 1, + fakeAlertmanager4: 1 + }; + const tree = MountedAlertGroup(jest.fn(), true); + + const alerts = tree.find("ul.list-group"); + expect(alerts.html()).toMatch(/@alertmanager:/); + + const footer = tree.find("GroupFooter"); + expect(footer.html()).not.toMatch(/@alertmanager:/); + }); + it("only renders titlebar when collapsed", () => { MockAlerts(10); const tree = MountedAlertGroup(jest.fn(), false); diff --git a/ui/src/Components/Grid/AlertGrid/index.test.js b/ui/src/Components/Grid/AlertGrid/index.test.js index 4f3f7f3e6..8e7e5dd6e 100644 --- a/ui/src/Components/Grid/AlertGrid/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/index.test.js @@ -89,6 +89,15 @@ describe("", () => { expect(instance.masonryComponentReference.ref.forcePack).toHaveBeenCalled(); }); + it("masonryRepack() doesn't crash when masonryComponentReference.ref=false`", () => { + const tree = ShallowAlertGrid(); + const instance = tree.instance(); + const repackSpy = jest.spyOn(instance, "masonryRepack"); + instance.masonryComponentReference.ref = false; + instance.componentDidUpdate(); + expect(repackSpy).toHaveBeenCalled(); + }); + it("calling storeMasonryRef() saves the ref in local store", () => { const tree = ShallowAlertGrid(); const instance = tree.instance(); diff --git a/ui/src/Components/Labels/HistoryLabel/index.test.js b/ui/src/Components/Labels/HistoryLabel/index.test.js new file mode 100644 index 000000000..fb64c67ae --- /dev/null +++ b/ui/src/Components/Labels/HistoryLabel/index.test.js @@ -0,0 +1,34 @@ +import React from "react"; + +import { shallow } from "enzyme"; + +import { AlertStore } from "Stores/AlertStore"; + +import { HistoryLabel } from "."; + +let alertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +describe("", () => { + it("renders name, matcher and value if all are set", () => { + const tree = shallow( + + ); + expect(tree.text()).toBe("foo=bar"); + }); + + it("renders only value if name is falsey", () => { + const tree = shallow( + + ); + expect(tree.text()).toBe("bar"); + }); +}); diff --git a/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap b/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap index 318800f69..edee4f5ad 100644 --- a/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap +++ b/ui/src/Components/MultiSelect/__snapshots__/index.test.js.snap @@ -1,968 +1,294 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` matches snapshot when focused 1`] = ` - - + - - -
- -
- -
-
- -
- -
- - -
- - - - - - -
-
- - +
- - -
-
-
+ + + +
+ + +
+ + + + +
+
+ + +" `; exports[` matches snapshot with a value 1`] = ` - +" +
+
+
+
+ foo +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
+
+" `; exports[` matches snapshot with defaults 1`] = ` - +" +
+
+
+
+ Select... +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
+
+" `; exports[` matches snapshot with isMulti=true 1`] = ` - +" +
+
+
+
+ Select... +
+
+
+ +
+
+
+
+
+
+ + +
+ + + + +
+
+
+
+" `; exports[` matches snapshot with isMulti=true and a value 1`] = ` - +" +
+
+
+
+
+ foo +
+
+ + + + +
+
+
+
+ +
+
+
+
+
+
+
+ + + + +
+ + +
+ + + + +
+
+
+
+" `; diff --git a/ui/src/Components/MultiSelect/index.js b/ui/src/Components/MultiSelect/index.js index d8a528d52..3b4b82dc3 100644 --- a/ui/src/Components/MultiSelect/index.js +++ b/ui/src/Components/MultiSelect/index.js @@ -78,7 +78,7 @@ const ReactSelectStyles = { }), option: (base, state) => ({ ...base, - color: state.isSelected ? "#95a5a6" : "inherit", + color: "inherit", backgroundColor: "inherit", "&:hover": { color: "#fff", backgroundColor: "#455a64", cursor: "pointer" } }) diff --git a/ui/src/Components/MultiSelect/index.test.js b/ui/src/Components/MultiSelect/index.test.js index b575edef4..59b0d236d 100644 --- a/ui/src/Components/MultiSelect/index.test.js +++ b/ui/src/Components/MultiSelect/index.test.js @@ -2,6 +2,8 @@ import React from "react"; import { shallow, mount } from "enzyme"; +import toDiffableHtml from "diffable-html"; + import { MultiSelect } from "."; const Option = value => ({ label: value, value: value }); @@ -25,29 +27,29 @@ class CustomMultiSelect extends MultiSelect { describe("", () => { it("matches snapshot with defaults", () => { const tree = shallow(); - expect(tree).toMatchSnapshot(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); it("matches snapshot with isMulti=true", () => { const tree = shallow(); - expect(tree).toMatchSnapshot(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); it("matches snapshot when focused", () => { // this test is to cover styles state.isFocused conditions const tree = mount(); tree.find("input").simulate("focus"); - expect(tree).toMatchSnapshot(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); it("matches snapshot with a value", () => { const tree = shallow( ); - expect(tree).toMatchSnapshot(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); it("matches snapshot with isMulti=true and a value", () => { @@ -55,9 +57,9 @@ describe("", () => { ); - expect(tree).toMatchSnapshot(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); }); diff --git a/ui/src/Components/NavBar/FilterInput/index.test.js b/ui/src/Components/NavBar/FilterInput/index.test.js index 996e56193..296835244 100644 --- a/ui/src/Components/NavBar/FilterInput/index.test.js +++ b/ui/src/Components/NavBar/FilterInput/index.test.js @@ -63,7 +63,16 @@ describe("", () => { ); }); - it("Clicking on form-control div focuses input", () => { + it("submit should be no-op if input value is empty", () => { + const tree = MountedInput(); + const instance = tree.instance(); + instance.inputStore.value = ""; + expect(alertStore.filters.values).toHaveLength(0); + tree.find("form").simulate("submit"); + expect(alertStore.filters.values).toHaveLength(0); + }); + + it("clicking on form-control div focuses input", () => { const tree = MountedInput(); const instance = tree.instance(); const inputSpy = jest.spyOn(instance.inputStore.ref.input, "focus"); @@ -71,6 +80,22 @@ describe("", () => { formControl.simulate("click"); expect(inputSpy).toHaveBeenCalledTimes(1); }); + + it("clicking on a label doesn't trigger input focus", () => { + alertStore.filters.values = [NewUnappliedFilter("foo=bar")]; + const tree = MountedInput(); + const instance = tree.instance(); + const inputSpy = jest.spyOn(instance.inputStore.ref.input, "focus"); + tree.find("FilterInputLabel").simulate("click"); + expect(inputSpy).not.toHaveBeenCalled(); + }); + + it("componentDidMount executes even when inputStore.ref=null", () => { + const tree = MountedInput(); + const instance = tree.instance(); + instance.inputStore.ref = null; + instance.componentDidMount(); + }); }); describe("", () => { @@ -89,6 +114,15 @@ describe("", () => { expect(instance.inputStore.suggestions).toContain("foo=~bar"); }); + it("doesn't fetch any suggestion if the input value is empty", () => { + fetch.mockResponseOnce(JSON.stringify(["foo=bar", "foo=~bar"])); + + const tree = MountedInput(); + const instance = tree.instance(); + instance.onSuggestionsFetchRequested({ value: "" }); + expect(fetch.mock.calls).toHaveLength(0); + }); + it("clicking on a suggestion adds it to filters", async () => { fetch.mockResponse(JSON.stringify(["foo=bar", "foo=~bar"])); diff --git a/ui/src/Components/SilenceModal/AlertManagerInput.test.js b/ui/src/Components/SilenceModal/AlertManagerInput.test.js index 1e7257fb9..75c42bb1b 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput.test.js @@ -70,6 +70,15 @@ describe("", () => { } }); + it("doesn't override last selected Alertmanager instances on mount", () => { + silenceFormStore.data.alertmanagers = [AlertmanagerOption(1)]; + ShallowAlertManagerInput(); + expect(silenceFormStore.data.alertmanagers).toHaveLength(1); + expect(silenceFormStore.data.alertmanagers).toContainEqual( + AlertmanagerOption(1) + ); + }); + it("renders all 3 suggestions", () => { const tree = ValidateSuggestions(); const options = tree.find("[role='option']"); diff --git a/ui/src/Components/SilenceModal/DateTimeSelect/index.js b/ui/src/Components/SilenceModal/DateTimeSelect/index.js index 650f0192c..e8f902ec8 100644 --- a/ui/src/Components/SilenceModal/DateTimeSelect/index.js +++ b/ui/src/Components/SilenceModal/DateTimeSelect/index.js @@ -114,7 +114,7 @@ const CalculateChangeValueUp = (currentValue, step) => { return 1; } // otherwise use step or a value that moves current value to the next step - return step - (currentValue % step) || step; + return step - (currentValue % step); }; // calculate value for duration decrease button using a goal step diff --git a/ui/src/Components/SilenceModal/LabelNameInput.test.js b/ui/src/Components/SilenceModal/LabelNameInput.test.js index dbcc8934e..6af967fd4 100644 --- a/ui/src/Components/SilenceModal/LabelNameInput.test.js +++ b/ui/src/Components/SilenceModal/LabelNameInput.test.js @@ -77,7 +77,7 @@ describe("", () => { }, 100); }); - it("suggestions are empited on failed fetch", done => { + it("suggestions are emptied on failed fetch", done => { fetch.mockReject(new Error("fake error message")); ShallowLabelNameInput(); // use timeout since mount will call fetch @@ -86,4 +86,12 @@ describe("", () => { done(); }, 100); }); + + it("doesn't fetch suggestions if value is changed to empty string", () => { + const tree = MountedLabelNameInput(); + const instance = tree.instance(); + const fetchSpy = jest.spyOn(instance, "populateValueSuggestions"); + instance.onChange(""); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 21e03792d..130e5ec73 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -280,7 +280,7 @@ class AlertStore { } // settings exported via API - if (!equal(this.settings, result.settings)) { + if (!equal(this.settings.values, result.settings)) { this.settings.values = result.settings; } @@ -294,10 +294,8 @@ class AlertStore { this.info.totalAlerts = 0; // all unapplied filters should be marked applied to reset progress indicator - for (const [index, filter] of this.filters.values.entries()) { - if (!filter.applied) { - this.filters.values[index].applied = true; - } + for (let i = 0; i < this.filters.values.length; i++) { + this.filters.values[i].applied = true; } return { error: err }; @@ -310,5 +308,6 @@ export { FormatUnseeBackendURI, FormatAPIFilterQuery, DecodeLocationSearch, + UpdateLocationSearch, NewUnappliedFilter }; diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index b8958bd1e..56cd6216a 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -5,6 +5,7 @@ import { AlertStoreStatuses, FormatUnseeBackendURI, DecodeLocationSearch, + UpdateLocationSearch, NewUnappliedFilter } from "Stores/AlertStore"; @@ -63,6 +64,13 @@ describe("AlertStore.filters", () => { expect(store.filters.values[0]).toMatchObject(NewUnappliedFilter("foo")); }); + it("addFilter should not allow duplicates", () => { + const store = new AlertStore([]); + store.filters.addFilter("foo"); + store.filters.addFilter("foo"); + expect(store.filters.values).toHaveLength(1); + }); + it("removeFilter('foo') should remove passed filter if it's defined", () => { const store = new AlertStore([]); store.filters.addFilter("foo"); @@ -180,6 +188,23 @@ describe("DecodeLocationSearch", () => { }); }); +describe("UpdateLocationSearch", () => { + it("{q: foo} is pushed to location.search", () => { + UpdateLocationSearch({ q: "foo" }); + expect(window.location.search).toBe("?q=foo"); + }); + + it("{a: foo} is not pushed to location.search", () => { + UpdateLocationSearch({ a: "foo" }); + expect(window.location.search).toBe(""); + }); + + it("{a: foo, q: bar} is pushed to location.search", () => { + UpdateLocationSearch({ a: "foo", q: "bar" }); + expect(window.location.search).toBe("?q=bar"); + }); +}); + describe("AlertStore.fetch", () => { it("parseAPIResponse() rejects a response with mismatched filters", () => { const consoleSpy = jest.spyOn(console, "info"); @@ -247,7 +272,34 @@ describe("AlertStore.fetch", () => { it("unapplied filters are marked as applied on fetch error", async () => { fetch.mockReject("Fetch error"); const store = new AlertStore([NewUnappliedFilter("foo")]); + store.filters.values[0].applied = false; await expect(store.fetch()).resolves.toHaveProperty("error"); expect(store.filters.values[0].applied).toBe(true); }); + + it("stored settings are updated if needed after fetch", async () => { + const response = EmptyAPIResponse(); + fetch.mockResponse(JSON.stringify(response)); + + const store = new AlertStore(["label=value"]); + + // initial fetch, should update settings + store.settings.values = { foo: "bar" }; + await expect(store.fetch()).resolves.toBeUndefined(); + expect(store.settings.values).toMatchObject({ + staticColorLabels: ["job"], + annotationsDefaultHidden: false, + annotationsHidden: [], + annotationsVisible: [] + }); + + // second fetch, should keep same settings + await expect(store.fetch()).resolves.toBeUndefined(); + expect(store.settings.values).toMatchObject({ + staticColorLabels: ["job"], + annotationsDefaultHidden: false, + annotationsHidden: [], + annotationsVisible: [] + }); + }); }); diff --git a/ui/src/Stores/SilenceFormStore.test.js b/ui/src/Stores/SilenceFormStore.test.js index bac6d3287..8568e3389 100644 --- a/ui/src/Stores/SilenceFormStore.test.js +++ b/ui/src/Stores/SilenceFormStore.test.js @@ -70,6 +70,11 @@ describe("SilenceFormStore.data", () => { ); }); + it("deleteMatcher() is a no-op when matcher list is empty", () => { + store.data.deleteMatcher(1); + expect(store.data.matchers).toHaveLength(0); + }); + it("fillMatchersFromGroup() creates correct matcher object for a group", () => { const group = MockGroup(); store.data.fillMatchersFromGroup(group);