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`] = `
-
-
+
-
-
+
+ 0 results available. Select is focused ,type to refine list, press Down to open the menu,
+
+
+
+"
`;
exports[` matches snapshot with a value 1`] = `
-
+"
+
+"
`;
exports[` matches snapshot with defaults 1`] = `
-
+"
+
+"
`;
exports[` matches snapshot with isMulti=true 1`] = `
-
+"
+
+"
`;
exports[` matches snapshot with isMulti=true and a value 1`] = `
-
+"
+
+"
`;
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);