diff --git a/ui/src/Components/Labels/StaticLabel/__snapshots__/index.test.js.snap b/ui/src/Components/Labels/StaticLabel/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..f08ea0003 --- /dev/null +++ b/ui/src/Components/Labels/StaticLabel/__snapshots__/index.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +" + + foo: bar + +" +`; diff --git a/ui/src/Components/Labels/StaticLabel/index.js b/ui/src/Components/Labels/StaticLabel/index.js new file mode 100644 index 000000000..7ea7b07a8 --- /dev/null +++ b/ui/src/Components/Labels/StaticLabel/index.js @@ -0,0 +1,29 @@ +import React from "react"; + +import { inject, observer } from "mobx-react"; + +import { BaseLabel } from "Components/Labels/BaseLabel"; + +// Renders a static label element, no click actions, no hover +const StaticLabel = inject("alertStore")( + observer( + class FilteringLabel extends BaseLabel { + render() { + const { name, value } = this.props; + return ( + + {name}: {value} + + ); + } + } + ) +); + +export { StaticLabel }; diff --git a/ui/src/Components/Labels/StaticLabel/index.test.js b/ui/src/Components/Labels/StaticLabel/index.test.js new file mode 100644 index 000000000..e35cd9796 --- /dev/null +++ b/ui/src/Components/Labels/StaticLabel/index.test.js @@ -0,0 +1,24 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { AlertStore } from "Stores/AlertStore"; + +import { StaticLabel } from "."; + +let alertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +describe("", () => { + it("matches snapshot", () => { + const tree = mount( + + ); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); +}); diff --git a/ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap b/ui/src/Components/SilenceModal/AlertManagerInput/__snapshots__/index.test.js.snap similarity index 100% rename from ui/src/Components/SilenceModal/__snapshots__/AlertManagerInput.test.js.snap rename to ui/src/Components/SilenceModal/AlertManagerInput/__snapshots__/index.test.js.snap diff --git a/ui/src/Components/SilenceModal/AlertManagerInput.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.js similarity index 100% rename from ui/src/Components/SilenceModal/AlertManagerInput.js rename to ui/src/Components/SilenceModal/AlertManagerInput/index.js diff --git a/ui/src/Components/SilenceModal/AlertManagerInput.test.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js similarity index 98% rename from ui/src/Components/SilenceModal/AlertManagerInput.test.js rename to ui/src/Components/SilenceModal/AlertManagerInput/index.test.js index 40ef5cdbb..c11c6f25d 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js @@ -6,7 +6,7 @@ import toDiffableHtml from "diffable-html"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; -import { AlertManagerInput } from "./AlertManagerInput"; +import { AlertManagerInput } from "."; let alertStore; let silenceFormStore; diff --git a/ui/src/Components/SilenceModal/Matchers.js b/ui/src/Components/SilenceModal/Matchers.js new file mode 100644 index 000000000..141bdb010 --- /dev/null +++ b/ui/src/Components/SilenceModal/Matchers.js @@ -0,0 +1,34 @@ +import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; + +const MatcherToFilter = matcher => { + const operator = matcher.isRegex + ? QueryOperators.Regex + : QueryOperators.Equal; + const value = + matcher.values.length > 1 + ? `(${matcher.values.map(v => v.value).join("|")})` + : matcher.values[0].value; + return FormatQuery( + matcher.name, + operator, + matcher.isRegex ? `^${value}$` : value + ); +}; + +const AlertManagersToFilter = alertmanagers => { + if (alertmanagers.length > 1) { + return FormatQuery( + StaticLabels.AlertManager, + QueryOperators.Regex, + `^(${alertmanagers.map(am => am.label).join("|")})$` + ); + } else if (alertmanagers.length === 1) { + return FormatQuery( + StaticLabels.AlertManager, + QueryOperators.Equal, + alertmanagers[0].label + ); + } +}; + +export { MatcherToFilter, AlertManagersToFilter }; diff --git a/ui/src/Components/SilenceModal/__snapshots__/SilencePreview.test.js.snap b/ui/src/Components/SilenceModal/PayloadPreview/__snapshots__/index.test.js.snap similarity index 89% rename from ui/src/Components/SilenceModal/__snapshots__/SilencePreview.test.js.snap rename to ui/src/Components/SilenceModal/PayloadPreview/__snapshots__/index.test.js.snap index 5a8cdc11d..c05809777 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/SilencePreview.test.js.snap +++ b/ui/src/Components/SilenceModal/PayloadPreview/__snapshots__/index.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` matches snapshot 1`] = ` +exports[` matches snapshot 1`] = ` "
   {
@@ -42,7 +42,7 @@ exports[` matches snapshot 1`] = `
   
   ":
   
-    "SilencePreview test"
+    "PayloadPreview test"
   
   }
 
diff --git a/ui/src/Components/SilenceModal/SilencePreview.js b/ui/src/Components/SilenceModal/PayloadPreview/index.js similarity index 85% rename from ui/src/Components/SilenceModal/SilencePreview.js rename to ui/src/Components/SilenceModal/PayloadPreview/index.js index ba004eff2..22ef4824f 100644 --- a/ui/src/Components/SilenceModal/SilencePreview.js +++ b/ui/src/Components/SilenceModal/PayloadPreview/index.js @@ -8,8 +8,8 @@ import "react-json-pretty/src/JSONPretty.monikai.css"; import { SilenceFormStore } from "Stores/SilenceFormStore"; -const SilencePreview = observer( - class SilencePreview extends Component { +const PayloadPreview = observer( + class PayloadPreview extends Component { static propTypes = { silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired }; @@ -26,4 +26,4 @@ const SilencePreview = observer( } ); -export { SilencePreview }; +export { PayloadPreview }; diff --git a/ui/src/Components/SilenceModal/SilencePreview.test.js b/ui/src/Components/SilenceModal/PayloadPreview/index.test.js similarity index 72% rename from ui/src/Components/SilenceModal/SilencePreview.test.js rename to ui/src/Components/SilenceModal/PayloadPreview/index.test.js index 44d0e0036..3dafd53d1 100644 --- a/ui/src/Components/SilenceModal/SilencePreview.test.js +++ b/ui/src/Components/SilenceModal/PayloadPreview/index.test.js @@ -7,17 +7,17 @@ import toDiffableHtml from "diffable-html"; import moment from "moment"; import { SilenceFormStore } from "Stores/SilenceFormStore"; -import { SilencePreview } from "./SilencePreview"; +import { PayloadPreview } from "."; -describe("", () => { +describe("", () => { it("matches snapshot", () => { const silenceFormStore = new SilenceFormStore(); silenceFormStore.data.startsAt = moment([2000, 1, 1, 0, 0, 0]); silenceFormStore.data.endsAt = moment([2000, 1, 1, 1, 0, 0]); silenceFormStore.data.createdBy = "me@example.com"; - silenceFormStore.data.comment = "SilencePreview test"; + silenceFormStore.data.comment = "PayloadPreview test"; - const tree = render(); + const tree = render(); expect(toDiffableHtml(tree.html())).toMatchSnapshot(); }); }); diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index b54c82d47..40f361444 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -9,17 +9,17 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons/faPlus"; import { faUser } from "@fortawesome/free-solid-svg-icons/faUser"; import { faCommentDots } from "@fortawesome/free-solid-svg-icons/faCommentDots"; import { faUndoAlt } from "@fortawesome/free-solid-svg-icons/faUndoAlt"; -import { faSave } from "@fortawesome/free-regular-svg-icons/faSave"; +import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore"; import { Settings } from "Stores/Settings"; import { AlertManagerInput } from "./AlertManagerInput"; import { SilenceMatch } from "./SilenceMatch"; import { DateTimeSelect } from "./DateTimeSelect"; -import { SilencePreview } from "./SilencePreview"; +import { PayloadPreview } from "./PayloadPreview"; const IconInput = ({ type, @@ -122,7 +122,7 @@ const SilenceForm = observer( settingsStore.silenceFormConfig.saveAuthor(silenceFormStore.data.author); if (silenceFormStore.data.isValid) - silenceFormStore.data.inProgress = true; + silenceFormStore.data.currentStage = SilenceFormStage.Preview; silenceFormStore.data.wasValidated = true; }); @@ -141,6 +141,7 @@ const SilenceForm = observer( {silenceFormStore.data.matchers.map(matcher => ( { silenceFormStore.data.deleteMatcher(matcher.id); @@ -194,13 +195,13 @@ const SilenceForm = observer( )} {this.previewCollapse.hidden ? null : ( - + )} ); diff --git a/ui/src/Components/SilenceModal/SilenceForm.test.js b/ui/src/Components/SilenceModal/SilenceForm.test.js index 77ef5c72b..1927b6a50 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.test.js +++ b/ui/src/Components/SilenceModal/SilenceForm.test.js @@ -4,7 +4,11 @@ import { mount, shallow } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; -import { SilenceFormStore, NewEmptyMatcher } from "Stores/SilenceFormStore"; +import { + SilenceFormStore, + SilenceFormStage, + NewEmptyMatcher +} from "Stores/SilenceFormStore"; import { SilenceForm } from "./SilenceForm"; let alertStore; @@ -94,28 +98,28 @@ describe(" matchers", () => { }); describe(" preview", () => { - it("doesn't render SilencePreview when previewCollapse.hidden is true", () => { + it("doesn't render PayloadPreview when previewCollapse.hidden is true", () => { const tree = ShallowSilenceForm(); const instance = tree.instance(); instance.previewCollapse.hidden = true; - expect(tree.find("SilencePreview")).toHaveLength(0); + expect(tree.find("PayloadPreview")).toHaveLength(0); }); - it("renders SilencePreview when previewCollapse.hidden is false", () => { + it("renders PayloadPreview when previewCollapse.hidden is false", () => { const tree = ShallowSilenceForm(); const instance = tree.instance(); instance.previewCollapse.hidden = false; - expect(tree.find("SilencePreview")).toHaveLength(1); + expect(tree.find("PayloadPreview")).toHaveLength(1); }); - it("clicking on the toggle icon toggles SilencePreview", () => { + it("clicking on the toggle icon toggles PayloadPreview", () => { const tree = ShallowSilenceForm(); const button = tree.find(".btn.cursor-pointer.text-muted"); - expect(tree.find("SilencePreview")).toHaveLength(0); + expect(tree.find("PayloadPreview")).toHaveLength(0); button.simulate("click"); - expect(tree.find("SilencePreview")).toHaveLength(1); + expect(tree.find("PayloadPreview")).toHaveLength(1); button.simulate("click"); - expect(tree.find("SilencePreview")).toHaveLength(0); + expect(tree.find("PayloadPreview")).toHaveLength(0); }); }); @@ -152,13 +156,13 @@ describe(" inputs", () => { }); describe("", () => { - it("calling submit doesn't mark form as in progress when form is invalid", () => { + it("calling submit doesn't move the form to Preview stage when form is invalid", () => { const tree = ShallowSilenceForm(); tree.simulate("submit", { preventDefault: jest.fn() }); - expect(silenceFormStore.data.inProgress).toBe(false); + expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput); }); - it("calling submit marks form as in progress when form is valid", () => { + it("calling submit move form to the 'Preview' stage when form is valid", () => { const matcher = NewEmptyMatcher(); matcher.name = "job"; matcher.values = [{ label: "node_exporter", value: "node_exporter" }]; @@ -170,7 +174,7 @@ describe("", () => { silenceFormStore.data.comment = "fake silence"; const tree = ShallowSilenceForm(); tree.simulate("submit", { preventDefault: jest.fn() }); - expect(silenceFormStore.data.inProgress).toBe(true); + expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.Preview); }); it("calling submit saves author value to the Settings store", () => { diff --git a/ui/src/Components/SilenceModal/LabelNameInput.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js similarity index 100% rename from ui/src/Components/SilenceModal/LabelNameInput.js rename to ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js diff --git a/ui/src/Components/SilenceModal/LabelNameInput.test.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.test.js similarity index 100% rename from ui/src/Components/SilenceModal/LabelNameInput.test.js rename to ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.test.js diff --git a/ui/src/Components/SilenceModal/LabelValueInput.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js similarity index 59% rename from ui/src/Components/SilenceModal/LabelValueInput.js rename to ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js index da6b0d90d..c1b0b7241 100644 --- a/ui/src/Components/SilenceModal/LabelValueInput.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.js @@ -4,13 +4,36 @@ import PropTypes from "prop-types"; import { action } from "mobx"; import { observer } from "mobx-react"; +import { components } from "react-select"; + +import { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceFormMatcher } from "Models/SilenceForm"; import { MultiSelect } from "Components/MultiSelect"; import { ValidationError } from "Components/MultiSelect/ValidationError"; +import { MatchCounter } from "./MatchCounter"; + +const Placeholder = props => { + return ( +
+ +
+ ); +}; + +const ValueContainer = ({ children, ...props }) => ( + + + {children} + +); const LabelValueInput = observer( class LabelValueInput extends MultiSelect { static propTypes = { + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, matcher: SilenceFormMatcher.isRequired, isValid: PropTypes.bool.isRequired }; @@ -29,7 +52,7 @@ const LabelValueInput = observer( }); renderProps = () => { - const { matcher, isValid } = this.props; + const { silenceFormStore, matcher, isValid } = this.props; return { instanceId: `silence-input-label-value-${matcher.id}`, @@ -37,7 +60,10 @@ const LabelValueInput = observer( options: matcher.suggestions.values, placeholder: isValid ? "Label value" : , isMulti: true, - onChange: this.onChange + onChange: this.onChange, + components: { ValueContainer, Placeholder }, + silenceFormStore: silenceFormStore, + matcher: matcher }; }; } diff --git a/ui/src/Components/SilenceModal/LabelValueInput.test.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.test.js similarity index 87% rename from ui/src/Components/SilenceModal/LabelValueInput.test.js rename to ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.test.js index bf6f76bc5..852a0618f 100644 --- a/ui/src/Components/SilenceModal/LabelValueInput.test.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/LabelValueInput.test.js @@ -4,12 +4,18 @@ import { shallow, mount } from "enzyme"; import toDiffableHtml from "diffable-html"; -import { NewEmptyMatcher, MatcherValueToObject } from "Stores/SilenceFormStore"; +import { + SilenceFormStore, + NewEmptyMatcher, + MatcherValueToObject +} from "Stores/SilenceFormStore"; import { LabelValueInput } from "./LabelValueInput"; +let silenceFormStore; let matcher; beforeEach(() => { + silenceFormStore = new SilenceFormStore(); matcher = NewEmptyMatcher(); matcher.name = "name"; matcher.suggestions.names = [ @@ -23,11 +29,23 @@ beforeEach(() => { }); const ShallowLabelValueInput = isValid => { - return shallow(); + return shallow( + + ); }; const MountedLabelValueInput = isValid => { - return mount(); + return mount( + + ); }; const ValidateSuggestions = () => { diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js new file mode 100644 index 000000000..5b3d66974 --- /dev/null +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js @@ -0,0 +1,122 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { observable, action } from "mobx"; +import { observer } from "mobx-react"; + +import { throttle } from "lodash"; + +import hash from "object-hash"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; + +import { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; +import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { SilenceFormMatcher } from "Models/SilenceForm"; +import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; + +const MatchCounter = observer( + class MatchCounter extends Component { + static propTypes = { + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, + matcher: SilenceFormMatcher.isRequired + }; + + matchedAlerts = observable( + { + total: 0, + error: null, + fetch: null, + setTotal(value) { + this.total = value; + }, + setError(value) { + this.error = value; + } + }, + { + setTotal: action, + setError: action + } + ); + + onFetch = throttle(() => { + const { silenceFormStore, matcher } = this.props; + + const filters = [MatcherToFilter(matcher)]; + if (silenceFormStore.data.alertmanagers.length) { + filters.push( + AlertManagersToFilter(silenceFormStore.data.alertmanagers) + ); + } + + const alertsURI = + FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); + + this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + .then(result => { + return result.json(); + }) + .then(result => { + this.matchedAlerts.setTotal(result.totalAlerts); + this.matchedAlerts.setError(null); + }) + .catch(err => { + console.trace(err); + return this.matchedAlerts.setError(err.message); + }); + }, 300); + + onUpdateCounter = () => { + const { matcher } = this.props; + + if (matcher.name === "" || matcher.values.length === 0) { + this.matchedAlerts.setTotal(0); + this.matchedAlerts.setError(null); + return; + } + + this.onFetch(); + }; + + componentDidMount() { + this.onUpdateCounter(); + } + + componentDidUpdate() { + this.onUpdateCounter(); + } + + render() { + const { silenceFormStore, matcher } = this.props; + + if (this.matchedAlerts.error !== null) { + return ( + + ); + } + + const matcherHash = hash({ + alertmanagers: silenceFormStore.data.alertmanagers, + matcher: { + name: matcher.name, + values: matcher.values, + isRegex: matcher.isRegex + } + }); + + return ( + + {this.matchedAlerts.total} + + ); + } + } +); + +export { MatchCounter }; diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js new file mode 100644 index 000000000..3179c90ed --- /dev/null +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.test.js @@ -0,0 +1,156 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { + SilenceFormStore, + NewEmptyMatcher, + MatcherValueToObject +} from "Stores/SilenceFormStore"; +import { MatchCounter } from "./MatchCounter"; + +let matcher; +let silenceFormStore; + +beforeEach(() => { + fetch.resetMocks(); + + silenceFormStore = new SilenceFormStore(); + matcher = NewEmptyMatcher(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const MountedMatchCounter = () => { + return mount( + + ); +}; + +describe("", () => { + it("matches snapshot with empty matcher", () => { + const tree = MountedMatchCounter(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("logs a trace on failed fetch", async () => { + const consoleSpy = jest + .spyOn(console, "trace") + .mockImplementation(() => {}); + fetch.mockReject("Fetch error"); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("renders error icon on failed fetch", async () => { + jest.spyOn(console, "trace").mockImplementation(() => {}); + fetch.mockReject("Fetch error"); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(toDiffableHtml(tree.html())).toMatch(/exclamation-circle/); + }); + + it("totalAlerts is 0 after mount", async () => { + const tree = MountedMatchCounter(); + expect(tree.text()).toBe("0"); + }); + + it("updates totalAlerts after successful fetch", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 123 })); + + // we need to set name & value to trigger fetch + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + const tree = MountedMatchCounter(); + expect(tree.text()).toBe("0"); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(tree.text()).toBe("123"); + }); + + it("sends correct query string for a 'foo=bar' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = false; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe("./alerts.json?q=foo%3Dbar"); + }); + + it("sends correct query string for a 'foo=~bar' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = true; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe("./alerts.json?q=foo%3D~%5Ebar%24"); + }); + + it("sends correct query string for a 'foo=~(bar|baz)' matcher", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar"), MatcherValueToObject("baz")]; + matcher.isRegex = true; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe( + "./alerts.json?q=foo%3D~%5E%28bar%7Cbaz%29%24" + ); + }); + + it("selecting one Alertmanager instance appends it to the filters", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + silenceFormStore.data.alertmanagers = [MatcherValueToObject("am1")]; + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = false; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe( + "./alerts.json?q=foo%3Dbar&q=%40alertmanager%3Dam1" + ); + }); + + it("selecting two Alertmanager instances appends it correctly to the filters", async () => { + fetch.mockResponse(JSON.stringify({ totalAlerts: 0 })); + + silenceFormStore.data.alertmanagers = [ + MatcherValueToObject("am1"), + MatcherValueToObject("am1") + ]; + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + matcher.isRegex = false; + + const tree = MountedMatchCounter(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls[0][0]).toBe( + "./alerts.json?q=foo%3Dbar&q=%40alertmanager%3D~%5E%28am1%7Cam1%29%24" + ); + }); +}); diff --git a/ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap b/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelNameInput.test.js.snap similarity index 100% rename from ui/src/Components/SilenceModal/__snapshots__/LabelNameInput.test.js.snap rename to ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelNameInput.test.js.snap diff --git a/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap b/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap similarity index 86% rename from ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap rename to ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap index e61f25c13..185dfd6c6 100644 --- a/ui/src/Components/SilenceModal/__snapshots__/LabelValueInput.test.js.snap +++ b/ui/src/Components/SilenceModal/SilenceMatch/__snapshots__/LabelValueInput.test.js.snap @@ -5,8 +5,16 @@ exports[` matches snapshot 1`] = `
-
- Label value + + 0 + +
+
+ Label value +
matches snapshot with empty matcher 1`] = ` +" + + 0 + +" +`; diff --git a/ui/src/Components/SilenceModal/SilenceMatch.js b/ui/src/Components/SilenceModal/SilenceMatch/index.js similarity index 85% rename from ui/src/Components/SilenceModal/SilenceMatch.js rename to ui/src/Components/SilenceModal/SilenceMatch/index.js index 9c4357b3e..cd3163627 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/index.js @@ -7,6 +7,7 @@ import { observer } from "mobx-react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; +import { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceFormMatcher } from "Models/SilenceForm"; import { LabelNameInput } from "./LabelNameInput"; import { LabelValueInput } from "./LabelValueInput"; @@ -14,6 +15,7 @@ import { LabelValueInput } from "./LabelValueInput"; const SilenceMatch = observer( class SilenceMatch extends Component { static propTypes = { + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired, matcher: SilenceFormMatcher.isRequired, showDelete: PropTypes.bool.isRequired, onDelete: PropTypes.func.isRequired, @@ -30,7 +32,13 @@ const SilenceMatch = observer( }); render() { - const { matcher, showDelete, onDelete, isValid } = this.props; + const { + silenceFormStore, + matcher, + showDelete, + onDelete, + isValid + } = this.props; return (
@@ -38,7 +46,11 @@ const SilenceMatch = observer(
- +
diff --git a/ui/src/Components/SilenceModal/SilenceMatch.test.js b/ui/src/Components/SilenceModal/SilenceMatch/index.test.js similarity index 96% rename from ui/src/Components/SilenceModal/SilenceMatch.test.js rename to ui/src/Components/SilenceModal/SilenceMatch/index.test.js index ffd7897df..74acb12e4 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch.test.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/index.test.js @@ -7,7 +7,7 @@ import { NewEmptyMatcher, MatcherValueToObject } from "Stores/SilenceFormStore"; -import { SilenceMatch } from "./SilenceMatch"; +import { SilenceMatch } from "."; let silenceFormStore; let matcher; diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js index be7109652..33243f048 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.js @@ -7,11 +7,12 @@ import { observer } from "mobx-react"; import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"; import { AlertStore } from "Stores/AlertStore"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore"; import { Settings } from "Stores/Settings"; import { MountModalBackdrop } from "Components/Animations/MountModal"; import { SilenceForm } from "./SilenceForm"; -import { SilenceSubmitController } from "./SilenceSubmitController"; +import { SilencePreview } from "./SilencePreview"; +import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController"; const SilenceModalContent = observer( class SilenceModalContent extends Component { @@ -46,7 +47,13 @@ const SilenceModalContent = observer(
{silenceFormStore.data.silenceID === null - ? "Add new silence" + ? silenceFormStore.data.currentStage === + SilenceFormStage.UserInput + ? "Add new silence" + : silenceFormStore.data.currentStage === + SilenceFormStage.Preview + ? "Preview silenced alerts" + : "Silence submitted" : `Editing silence ${silenceFormStore.data.silenceID}`}
- {silenceFormStore.data.inProgress ? ( - - ) : ( + {silenceFormStore.data.currentStage === + SilenceFormStage.UserInput ? ( + ) : silenceFormStore.data.currentStage === + SilenceFormStage.Preview ? ( + + ) : ( + )}
diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.test.js b/ui/src/Components/SilenceModal/SilenceModalContent.test.js index 0e9497637..edb033e25 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.test.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.test.js @@ -4,7 +4,7 @@ import { shallow } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore"; import { SilenceModalContent } from "./SilenceModalContent"; let alertStore; @@ -31,15 +31,22 @@ const ShallowSilenceModalContent = () => { }; describe("", () => { - it("renders SilenceForm when silenceFormStore.data.inProgress is false", () => { - silenceFormStore.data.inProgress = false; + it("renders SilenceForm when silenceFormStore.data.currentStage is 'UserInput'", () => { + silenceFormStore.data.currentStage = SilenceFormStage.UserInput; const tree = ShallowSilenceModalContent(); const form = tree.find("SilenceForm"); expect(form).toHaveLength(1); }); - it("renders SilenceSubmitController when silenceFormStore.data.inProgress is true", () => { - silenceFormStore.data.inProgress = true; + it("renders SilencePreview when silenceFormStore.data.currentStage is 'Preview'", () => { + silenceFormStore.data.currentStage = SilenceFormStage.Preview; + const tree = ShallowSilenceModalContent(); + const ctrl = tree.find("SilencePreview"); + expect(ctrl).toHaveLength(1); + }); + + it("renders SilenceSubmitController when silenceFormStore.data.currentStage is 'Submit'", () => { + silenceFormStore.data.currentStage = SilenceFormStage.Submit; const tree = ShallowSilenceModalContent(); const ctrl = tree.find("SilenceSubmitController"); expect(ctrl).toHaveLength(1); diff --git a/ui/src/Components/SilenceModal/SilencePreview/__snapshots__/index.test.js.snap b/ui/src/Components/SilenceModal/SilencePreview/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..a70beda82 --- /dev/null +++ b/ui/src/Components/SilenceModal/SilencePreview/__snapshots__/index.test.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +" +
+

+ This silence will match + + 3 + + alerts +

+
+
    +
  • + + alertname: foo + + + job: foo + + + instance: foo1 + +
  • +
  • + + alertname: bar + + + job: bar + + + instance: bar1 + +
  • +
  • + + alertname: bar + + + job: bar + + + instance: bar2 + +
  • +
+
+
+" +`; diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.js b/ui/src/Components/SilenceModal/SilencePreview/index.js new file mode 100644 index 000000000..ee83b4107 --- /dev/null +++ b/ui/src/Components/SilenceModal/SilencePreview/index.js @@ -0,0 +1,169 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; + +import { observable, action } from "mobx"; +import { observer } from "mobx-react"; + +import hash from "object-hash"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; +import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; + +import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; +import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { StaticLabel } from "Components/Labels/StaticLabel"; +import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; + +const FetchError = ({ message }) => ( +
+

+ +

+

{message}

+
+); +FetchError.propTypes = { + message: PropTypes.node.isRequired +}; + +const Preview = ({ alertStore, labelsList }) => ( +
    + {labelsList.map(labels => ( +
  • + {Object.entries(labels).map(([name, value]) => ( + + ))} +
  • + ))} +
+); +Preview.propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + labelsList: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +const SilencePreview = observer( + class SilencePreview extends Component { + static propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + silenceFormStore: PropTypes.instanceOf(SilenceFormStore).isRequired + }; + + matchedAlerts = observable( + { + alertLabels: [], + error: null, + fetch: null, + groupsToUniqueLabels(groups) { + const alerts = {}; + for (const group of groups) { + for (const alert of group.alerts) { + const alertLabels = Object.assign( + {}, + group.labels, + group.shared.labels, + alert.labels + ); + const alertHash = hash(alertLabels); + alerts[alertHash] = alertLabels; + } + } + this.alertLabels = Object.values(alerts); + }, + setError(value) { + this.error = value; + } + }, + { + groupsToUniqueLabels: action, + setError: action + } + ); + + onFetch = () => { + const { silenceFormStore } = this.props; + + const filters = [ + ...silenceFormStore.data.matchers.map(m => MatcherToFilter(m)), + AlertManagersToFilter(silenceFormStore.data.alertmanagers) + ]; + + const alertsURI = + FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); + + this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + .then(result => { + return result.json(); + }) + .then(result => { + this.matchedAlerts.groupsToUniqueLabels(Object.values(result.groups)); + this.matchedAlerts.setError(null); + }) + .catch(err => { + console.trace(err); + return this.matchedAlerts.setError( + `Request failed with: ${err.message}` + ); + }); + }; + + componentDidMount() { + this.onFetch(); + } + + render() { + const { alertStore, silenceFormStore } = this.props; + + return ( + +
+ {this.matchedAlerts.error !== null ? ( + + ) : ( + +

+ This silence will match{" "} + {this.matchedAlerts.alertLabels.length} alert + {this.matchedAlerts.alertLabels.length > 1 ? "s" : ""} +

+
+ +
+
+ )} +
+
+ + +
+
+ ); + } + } +); + +export { SilencePreview }; diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.test.js b/ui/src/Components/SilenceModal/SilencePreview/index.test.js new file mode 100644 index 000000000..91b10d70a --- /dev/null +++ b/ui/src/Components/SilenceModal/SilencePreview/index.test.js @@ -0,0 +1,120 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { EmptyAPIResponse } from "__mocks__/Fetch"; +import { MockAlertGroup, MockAlert } from "__mocks__/Alerts"; +import { AlertStore } from "Stores/AlertStore"; +import { + SilenceFormStore, + SilenceFormStage, + NewEmptyMatcher, + MatcherValueToObject +} from "Stores/SilenceFormStore"; +import { SilencePreview } from "."; + +let alertStore; +let silenceFormStore; + +beforeEach(() => { + fetch.resetMocks(); + + alertStore = new AlertStore([]); + + const matcher = NewEmptyMatcher(); + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + + silenceFormStore = new SilenceFormStore(); + silenceFormStore.data.matchers = [matcher]; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const MockAPIResponse = () => { + const response = EmptyAPIResponse(); + response.groups = { + "1": MockAlertGroup( + { alertname: "foo" }, + [MockAlert([], { instance: "foo1" }, "active")], + [], + { job: "foo" } + ), + "2": MockAlertGroup( + { alertname: "bar" }, + [ + MockAlert([], { instance: "bar1" }, "active"), + MockAlert([], { instance: "bar2" }, "active") + ], + [], + { job: "bar" } + ) + }; + return response; +}; + +const MountedSilencePreview = () => { + return mount( + + ); +}; + +describe("", () => { + it("fetches matching alerts on mount", async () => { + fetch.mockResponse(JSON.stringify(MockAPIResponse())); + + const tree = MountedSilencePreview(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalled(); + }); + + it("matches snapshot", async () => { + fetch.mockResponse(JSON.stringify(MockAPIResponse())); + + const tree = MountedSilencePreview(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("renders FetchError on failed fetch", async () => { + const consoleSpy = jest + .spyOn(console, "trace") + .mockImplementation(() => {}); + fetch.mockReject("Fetch error"); + + const tree = MountedSilencePreview(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + + tree.update(); + expect(tree.find("FetchError")).toHaveLength(1); + expect(consoleSpy).toHaveBeenCalled(); + expect(tree.find("Preview")).toHaveLength(0); + }); + + it("renders Preview on successful fetch", async () => { + fetch.mockResponse(JSON.stringify(MockAPIResponse())); + + const tree = MountedSilencePreview(); + await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined(); + + tree.update(); + expect(tree.find("FetchError")).toHaveLength(0); + expect(tree.find("Preview")).toHaveLength(1); + }); + + it("clicking on the submit button moves form to the 'Submit' stage", () => { + fetch.mockResponse(JSON.stringify(MockAPIResponse())); + + const tree = MountedSilencePreview(); + const button = tree.find(".btn-outline-primary"); + button.simulate("click"); + expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.Submit); + }); +}); diff --git a/ui/src/Components/SilenceModal/SilenceSubmitController.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js similarity index 100% rename from ui/src/Components/SilenceModal/SilenceSubmitController.js rename to ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.js diff --git a/ui/src/Components/SilenceModal/SilenceSubmitController.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js similarity index 87% rename from ui/src/Components/SilenceModal/SilenceSubmitController.test.js rename to ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js index facc04ffc..3d9f92dde 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmitController.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitController.test.js @@ -5,6 +5,7 @@ import { shallow } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore, + SilenceFormStage, MatcherValueToObject } from "Stores/SilenceFormStore"; import { SilenceSubmitController } from "./SilenceSubmitController"; @@ -36,10 +37,10 @@ describe("", () => { }); it("resets the form on 'Back' button click", () => { - silenceFormStore.data.inProgress = true; + silenceFormStore.data.currentStage = SilenceFormStage.Submit; const tree = ShallowSilenceSubmitController(); const button = tree.find("button"); button.simulate("click"); - expect(silenceFormStore.data.inProgress).toBe(false); + expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput); }); }); diff --git a/ui/src/Components/SilenceModal/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js similarity index 100% rename from ui/src/Components/SilenceModal/SilenceSubmitProgress.js rename to ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js diff --git a/ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js similarity index 100% rename from ui/src/Components/SilenceModal/SilenceSubmitProgress.test.js rename to ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js diff --git a/ui/src/Components/SilenceModal/__snapshots__/SilenceSubmitProgress.test.js.snap b/ui/src/Components/SilenceModal/SilenceSubmit/__snapshots__/SilenceSubmitProgress.test.js.snap similarity index 100% rename from ui/src/Components/SilenceModal/__snapshots__/SilenceSubmitProgress.test.js.snap rename to ui/src/Components/SilenceModal/SilenceSubmit/__snapshots__/SilenceSubmitProgress.test.js.snap diff --git a/ui/src/Components/SilenceModal/index.test.js b/ui/src/Components/SilenceModal/index.test.js index 5205a0a67..f31aaf94a 100644 --- a/ui/src/Components/SilenceModal/index.test.js +++ b/ui/src/Components/SilenceModal/index.test.js @@ -4,7 +4,12 @@ import { mount } from "enzyme"; import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; -import { SilenceFormStore } from "Stores/SilenceFormStore"; +import { + SilenceFormStore, + SilenceFormStage, + NewEmptyMatcher, + MatcherValueToObject +} from "Stores/SilenceFormStore"; import { SilenceModal } from "."; let alertStore; @@ -100,12 +105,17 @@ describe("", () => { expect(document.body.className.split(" ")).not.toContain("modal-open"); }); - it("inProgress is set to false after modal is hidden", () => { + it("currentStage is set to 'UserInput' after modal is hidden", () => { + const matcher = NewEmptyMatcher(); + matcher.name = "foo"; + matcher.values = [MatcherValueToObject("bar")]; + silenceFormStore.data.matchers = [matcher]; + silenceFormStore.toggle.visible = true; const tree = MountedSilenceModal(); - silenceFormStore.data.inProgress = true; + silenceFormStore.data.currentStage = SilenceFormStage.Preview; const toggle = tree.find("button.close"); toggle.simulate("click"); - expect(silenceFormStore.data.inProgress).toBe(false); + expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput); }); }); diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 3648e3afd..7bd470fd6 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -6,16 +6,22 @@ import equal from "fast-deep-equal"; import qs from "qs"; +const QueryStringEncodeOptions = { + encodeValuesOnly: true, // don't encode q[] + indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar +}; + +function FormatAlertsQ(filters) { + return qs.stringify({ q: filters }, QueryStringEncodeOptions); +} + // generate URL for the UI with a set of filters function FormatAPIFilterQuery(filters) { return qs.stringify( Object.assign(DecodeLocationSearch(window.location.search).params, { q: filters }), - { - encodeValuesOnly: true, // don't encode q[] - indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar - } + QueryStringEncodeOptions ); } @@ -336,6 +342,7 @@ export { AlertStoreStatuses, FormatBackendURI, FormatAPIFilterQuery, + FormatAlertsQ, DecodeLocationSearch, UpdateLocationSearch, NewUnappliedFilter diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index 90e3473a2..a9df5f260 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -4,6 +4,7 @@ import { AlertStore, AlertStoreStatuses, FormatBackendURI, + FormatAlertsQ, DecodeLocationSearch, UpdateLocationSearch, NewUnappliedFilter @@ -146,6 +147,12 @@ describe("FormatBackendURI", () => { }); }); +describe("FormatAlertsQ", () => { + it("encodes multiple values without indices", () => { + expect(FormatAlertsQ(["a", "b"])).toBe("q=a&q=b"); + }); +}); + describe("DecodeLocationSearch", () => { const defaultParams = { defaultsUsed: true, diff --git a/ui/src/Stores/SilenceFormStore.js b/ui/src/Stores/SilenceFormStore.js index 54f939c14..5f16b0903 100644 --- a/ui/src/Stores/SilenceFormStore.js +++ b/ui/src/Stores/SilenceFormStore.js @@ -19,6 +19,12 @@ const NewEmptyMatcher = () => { const MatcherValueToObject = value => ({ label: value, value: value }); +const SilenceFormStage = Object.freeze({ + UserInput: "form", + Preview: "preview", + Submit: "submit" +}); + class SilenceFormStore { // this is used to store modal visibility toggle toggle = observable( @@ -43,7 +49,7 @@ class SilenceFormStore { // this form from that alert so user can easily silence that alert data = observable( { - inProgress: false, + currentStage: SilenceFormStage.UserInput, wasValidated: false, silenceID: null, alertmanagers: [], @@ -76,7 +82,7 @@ class SilenceFormStore { }, resetProgress() { - this.inProgress = false; + this.currentStage = SilenceFormStage.UserInput; this.wasValidated = false; }, @@ -84,6 +90,10 @@ class SilenceFormStore { this.silenceID = null; }, + setStageSubmit() { + this.currentStage = SilenceFormStage.Submit; + }, + // append a new empty matcher to the list addEmptyMatcher() { let m = NewEmptyMatcher(); @@ -235,6 +245,7 @@ class SilenceFormStore { resetStartEnd: action.bound, resetProgress: action.bound, resetSilenceID: action.bound, + setStageSubmit: action.bound, addEmptyMatcher: action.bound, deleteMatcher: action.bound, fillMatchersFromGroup: action.bound, @@ -252,4 +263,9 @@ class SilenceFormStore { ); } -export { SilenceFormStore, NewEmptyMatcher, MatcherValueToObject }; +export { + SilenceFormStore, + SilenceFormStage, + NewEmptyMatcher, + MatcherValueToObject +}; diff --git a/ui/src/Stores/SilenceFormStore.test.js b/ui/src/Stores/SilenceFormStore.test.js index 6c85ccfca..b25a288ea 100644 --- a/ui/src/Stores/SilenceFormStore.test.js +++ b/ui/src/Stores/SilenceFormStore.test.js @@ -6,7 +6,11 @@ import { MockSilence, MockAlertmanager } from "__mocks__/Alerts.js"; -import { SilenceFormStore, NewEmptyMatcher } from "./SilenceFormStore"; +import { + SilenceFormStore, + SilenceFormStage, + NewEmptyMatcher +} from "./SilenceFormStore"; let store; beforeEach(() => { @@ -72,11 +76,10 @@ describe("SilenceFormStore.data", () => { expect(store.data.endsAt.isSame([2000, 1, 1], "day")).toBe(false); }); - it("resetProgress() sets 'inProgress' to false", () => { - store.data.inProgress = true; - expect(store.data.inProgress).toBe(true); + it("resetProgress() sets currentStage to UserInput", () => { + store.data.currentStage = SilenceFormStage.Submit; store.data.resetProgress(); - expect(store.data.inProgress).toBe(false); + expect(store.data.currentStage).toBe(SilenceFormStage.UserInput); }); it("resetProgress() sets 'wasValidated' to false", () => {