Merge pull request #90 from prymitive/silence-form-validation

Silence form validation
This commit is contained in:
Łukasz Mierzwa
2018-09-04 22:29:19 +01:00
committed by GitHub
15 changed files with 270 additions and 32 deletions

View File

@@ -0,0 +1,12 @@
import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
const ValidationError = () => (
<span className="text-danger">
<FontAwesomeIcon icon={faExclamationCircle} /> Required
</span>
);
export { ValidationError };

View File

@@ -0,0 +1,14 @@
import React from "react";
import { shallow } from "enzyme";
import toDiffableHtml from "diffable-html";
import { ValidationError } from "./ValidationError";
describe("<ValidationError />", () => {
it("matches snapshot", () => {
const tree = shallow(<ValidationError />);
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ValidationError /> matches snapshot 1`] = `
"
<span class=\\"text-danger\\">
<svg aria-hidden=\\"true\\"
data-prefix=\\"fas\\"
data-icon=\\"exclamation-circle\\"
class=\\"svg-inline--fa fa-exclamation-circle fa-w-16 \\"
role=\\"img\\"
xmlns=\\"http://www.w3.org/2000/svg\\"
viewbox=\\"0 0 512 512\\"
>
<path fill=\\"currentColor\\"
d=\\"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z\\"
>
</path>
</svg>
Required
</span>
"
`;

View File

@@ -7,6 +7,7 @@ import { observer } from "mobx-react";
import ReactSelect from "react-select";
import { MultiSelect, ReactSelectStyles } from "Components/MultiSelect";
import { ValidationError } from "Components/MultiSelect/ValidationError";
const AlertmanagerInstancesToOptions = instances =>
instances.map(i => ({
@@ -75,7 +76,13 @@ const AlertManagerInput = observer(
options={AlertmanagerInstancesToOptions(
alertStore.data.upstreams.instances
)}
placeholder="Alertmanager"
placeholder={
silenceFormStore.data.wasValidated ? (
<ValidationError />
) : (
"Alertmanager"
)
}
isMulti
onChange={this.onChange}
/>

View File

@@ -60,6 +60,21 @@ describe("<AlertManagerInput />", () => {
expect(tree).toMatchSnapshot();
});
it("doesn't render ValidationError after passed validation", () => {
const tree = ShallowAlertManagerInput();
silenceFormStore.data.wasValidated = true;
expect(tree.html()).not.toMatch(/fa-exclamation-circle/);
expect(tree.html()).not.toMatch(/Required/);
});
it("renders ValidationError after failed validation", () => {
const tree = ShallowAlertManagerInput();
silenceFormStore.data.alertmanagers = [];
silenceFormStore.data.wasValidated = true;
expect(tree.html()).toMatch(/fa-exclamation-circle/);
expect(tree.html()).toMatch(/Required/);
});
it("all available Alertmanager instances are selected by default", () => {
ShallowAlertManagerInput();
expect(silenceFormStore.data.alertmanagers).toHaveLength(3);

View File

@@ -1,15 +1,18 @@
import React from "react";
import PropTypes from "prop-types";
import { action } from "mobx";
import { observer } from "mobx-react";
import { MultiSelect } from "Components/MultiSelect";
import { ValidationError } from "Components/MultiSelect/ValidationError";
import { FormatUnseeBackendURI } from "Stores/AlertStore";
const LabelNameInput = observer(
class LabelNameInput extends MultiSelect {
static propTypes = {
matcher: PropTypes.object.isRequired
matcher: PropTypes.object.isRequired,
isValid: PropTypes.bool.isRequired
};
populateNameSuggestions = action(() => {
@@ -70,7 +73,7 @@ const LabelNameInput = observer(
}
renderProps = () => {
const { matcher } = this.props;
const { matcher, isValid } = this.props;
const value = matcher.name
? { label: matcher.name, value: matcher.name }
@@ -80,7 +83,7 @@ const LabelNameInput = observer(
instanceId: `silence-input-label-name-${matcher.id}`,
defaultValue: value,
options: matcher.suggestions.names,
placeholder: "Label name",
placeholder: isValid ? "Label name" : <ValidationError />,
onChange: this.onChange
};
};

View File

@@ -20,16 +20,16 @@ beforeEach(() => {
];
});
const ShallowLabelNameInput = () => {
return shallow(<LabelNameInput matcher={matcher} />);
const ShallowLabelNameInput = isValid => {
return shallow(<LabelNameInput matcher={matcher} isValid={isValid} />);
};
const MountedLabelNameInput = () => {
return mount(<LabelNameInput matcher={matcher} />);
const MountedLabelNameInput = isValid => {
return mount(<LabelNameInput matcher={matcher} isValid={isValid} />);
};
const ValidateSuggestions = () => {
const tree = MountedLabelNameInput();
const tree = MountedLabelNameInput(true);
// click on the react-select component doesn't seem to trigger options
// rendering in tests, so change the input instead
tree.find("input").simulate("change", { target: { value: "f" } });
@@ -38,10 +38,28 @@ const ValidateSuggestions = () => {
describe("<LabelNameInput />", () => {
it("matches snapshot", () => {
const tree = ShallowLabelNameInput();
const tree = ShallowLabelNameInput(true);
expect(tree).toMatchSnapshot();
});
it("doesn't renders ValidationError after passed validation", () => {
// clear the name so placeholder is rendered
matcher.name = "";
const tree = ShallowLabelNameInput(true);
expect(tree.html()).toMatch(/Label name/);
expect(tree.html()).not.toMatch(/fa-exclamation-circle/);
expect(tree.html()).not.toMatch(/Required/);
});
it("renders ValidationError after failed validation", () => {
// clear the name so placeholder is rendered
matcher.name = "";
const tree = ShallowLabelNameInput(false);
expect(tree.html()).not.toMatch(/Label name/);
expect(tree.html()).toMatch(/fa-exclamation-circle/);
expect(tree.html()).toMatch(/Required/);
});
it("renders suggestions", () => {
const tree = ValidateSuggestions();
const options = tree.find("[role='option']");
@@ -61,7 +79,7 @@ describe("<LabelNameInput />", () => {
fetch
.once(JSON.stringify(["name1", "name2", "name3"]))
.once(JSON.stringify(["value1", "value2", "value3"]));
ShallowLabelNameInput();
ShallowLabelNameInput(true);
// use timeout since mount will call fetch
setTimeout(() => {
expect(matcher.suggestions.names).toHaveLength(3);
@@ -79,7 +97,7 @@ describe("<LabelNameInput />", () => {
it("suggestions are emptied on failed fetch", done => {
fetch.mockReject(new Error("fake error message"));
ShallowLabelNameInput();
ShallowLabelNameInput(true);
// use timeout since mount will call fetch
setTimeout(() => {
expect(matcher.suggestions.names).toHaveLength(0);
@@ -88,7 +106,7 @@ describe("<LabelNameInput />", () => {
});
it("doesn't fetch suggestions if value is changed to empty string", () => {
const tree = MountedLabelNameInput();
const tree = MountedLabelNameInput(true);
const instance = tree.instance();
const fetchSpy = jest.spyOn(instance, "populateValueSuggestions");
instance.onChange("");

View File

@@ -1,14 +1,17 @@
import React from "react";
import PropTypes from "prop-types";
import { action } from "mobx";
import { observer } from "mobx-react";
import { MultiSelect } from "Components/MultiSelect";
import { ValidationError } from "Components/MultiSelect/ValidationError";
const LabelValueInput = observer(
class LabelValueInput extends MultiSelect {
static propTypes = {
matcher: PropTypes.object.isRequired
matcher: PropTypes.object.isRequired,
isValid: PropTypes.bool.isRequired
};
onChange = action((newValue, actionMeta) => {
@@ -25,13 +28,13 @@ const LabelValueInput = observer(
});
renderProps = () => {
const { matcher } = this.props;
const { matcher, isValid } = this.props;
return {
instanceId: `silence-input-label-value-${matcher.id}`,
defaultValue: matcher.values,
options: matcher.suggestions.values,
placeholder: "Label value",
placeholder: isValid ? "Label value" : <ValidationError />,
isMulti: true,
onChange: this.onChange
};

View File

@@ -20,16 +20,16 @@ beforeEach(() => {
];
});
const ShallowLabelValueInput = () => {
return shallow(<LabelValueInput matcher={matcher} />);
const ShallowLabelValueInput = isValid => {
return shallow(<LabelValueInput matcher={matcher} isValid={isValid} />);
};
const MountedLabelValueInput = () => {
return mount(<LabelValueInput matcher={matcher} />);
const MountedLabelValueInput = isValid => {
return mount(<LabelValueInput matcher={matcher} isValid={isValid} />);
};
const ValidateSuggestions = () => {
const tree = MountedLabelValueInput();
const tree = MountedLabelValueInput(true);
// click on the react-select component doesn't seem to trigger options
// rendering in tests, so change the input instead
tree.find("input").simulate("change", { target: { value: "f" } });
@@ -38,10 +38,22 @@ const ValidateSuggestions = () => {
describe("<LabelValueInput />", () => {
it("matches snapshot", () => {
const tree = ShallowLabelValueInput();
const tree = ShallowLabelValueInput(true);
expect(tree).toMatchSnapshot();
});
it("doesn't renders ValidationError after passed validation", () => {
const tree = ShallowLabelValueInput(true);
expect(tree.html()).not.toMatch(/fa-exclamation-circle/);
expect(tree.html()).not.toMatch(/Required/);
});
it("renders ValidationError after failed validation", () => {
const tree = ShallowLabelValueInput(false);
expect(tree.html()).toMatch(/fa-exclamation-circle/);
expect(tree.html()).toMatch(/Required/);
});
it("renders suggestions", () => {
const tree = ValidateSuggestions();
const options = tree.find("[role='option']");

View File

@@ -102,7 +102,10 @@ const SilenceForm = observer(
event.preventDefault();
silenceFormStore.data.inProgress = true;
if (silenceFormStore.data.isValid)
silenceFormStore.data.inProgress = true;
silenceFormStore.data.wasValidated = true;
});
render() {
@@ -124,6 +127,7 @@ const SilenceForm = observer(
silenceFormStore.data.deleteMatcher(matcher.id);
}}
showDelete={silenceFormStore.data.matchers.length > 1}
isValid={!silenceFormStore.data.wasValidated}
/>
))}
<button

View File

@@ -3,7 +3,7 @@ import React from "react";
import { mount, shallow } from "enzyme";
import { AlertStore } from "Stores/AlertStore";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { SilenceFormStore, NewEmptyMatcher } from "Stores/SilenceFormStore";
import { SilenceForm } from "./SilenceForm";
let alertStore;
@@ -121,7 +121,22 @@ describe("<SilenceForm /> inputs", () => {
});
describe("<SilenceForm />", () => {
it("calling submit marks form as in progress", () => {
it("calling submit doesn't mark form as in progress when form is invalid", () => {
const tree = ShallowSilenceForm();
tree.simulate("submit", { preventDefault: jest.fn() });
expect(silenceFormStore.data.inProgress).toBe(false);
});
it("calling submit marks form as in progress when form is valid", () => {
const matcher = NewEmptyMatcher();
matcher.name = "job";
matcher.values = ["node_exporter"];
silenceFormStore.data.matchers = [matcher];
silenceFormStore.data.alertmanagers = [
{ label: "am1", value: "http://example.com" }
];
silenceFormStore.data.author = "me@example.com";
silenceFormStore.data.comment = "fake silence";
const tree = ShallowSilenceForm();
tree.simulate("submit", { preventDefault: jest.fn() });
expect(silenceFormStore.data.inProgress).toBe(true);

View File

@@ -20,7 +20,8 @@ const SilenceMatch = observer(
isRegex: PropTypes.bool.isRequired
}),
showDelete: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired
onDelete: PropTypes.func.isRequired,
isValid: PropTypes.bool.isRequired
};
onIsRegexChange = action(event => {
@@ -33,15 +34,15 @@ const SilenceMatch = observer(
});
render() {
const { matcher, showDelete, onDelete } = this.props;
const { matcher, showDelete, onDelete, isValid } = this.props;
return (
<div className="d-flex flex-fill flex-lg-row flex-column mb-3">
<div className="flex-shrink-0 flex-grow-0 flex-basis-25 pr-lg-2 pb-2 pb-lg-0">
<LabelNameInput matcher={matcher} />
<LabelNameInput matcher={matcher} isValid={isValid} />
</div>
<div className="flex-shrink-0 flex-grow-0 flex-basis-50 pr-lg-2 pb-2 pb-lg-0">
<LabelValueInput matcher={matcher} />
<LabelValueInput matcher={matcher} isValid={isValid} />
</div>
<div className="flex-shrink-0 flex-grow-1 flex-basis-auto form-check form-check-inline d-flex justify-content-between m-0">
<span>

View File

@@ -2,17 +2,25 @@ import React from "react";
import { shallow } from "enzyme";
import { NewEmptyMatcher, MatcherValueToObject } from "Stores/SilenceFormStore";
import {
SilenceFormStore,
NewEmptyMatcher,
MatcherValueToObject
} from "Stores/SilenceFormStore";
import { SilenceMatch } from "./SilenceMatch";
let silenceFormStore;
let matcher;
beforeEach(() => {
silenceFormStore = new SilenceFormStore();
matcher = NewEmptyMatcher();
});
const ShallowLabelValueInput = () => {
return shallow(<SilenceMatch matcher={matcher} />);
return shallow(
<SilenceMatch matcher={matcher} silenceFormStore={silenceFormStore} />
);
};
describe("<SilenceMatch />", () => {

View File

@@ -44,6 +44,7 @@ class SilenceFormStore {
data = observable(
{
inProgress: false,
wasValidated: false,
alertmanagers: [],
matchers: [],
startsAt: moment(),
@@ -51,8 +52,26 @@ class SilenceFormStore {
comment: "",
author: "",
get isValid() {
if (this.alertmanagers.length === 0) return false;
if (this.matchers.length === 0) return false;
if (
this.matchers.filter(
m =>
m.name === "" ||
m.values.length === 0 ||
m.values.filter(v => v === "").length > 0
).length > 0
)
return false;
if (this.comment === "") return false;
if (this.author === "") return false;
return true;
},
resetProgress() {
this.inProgress = false;
this.wasValidated = false;
},
// append a new empty matcher to the list
@@ -181,6 +200,7 @@ class SilenceFormStore {
decStart: action.bound,
incEnd: action.bound,
decEnd: action.bound,
isValid: computed,
toAlertmanagerPayload: computed,
toDuration: computed
},

View File

@@ -1,7 +1,7 @@
import moment from "moment";
import { MockAlert, MockAlertGroup } from "__mocks__/Alerts.js";
import { SilenceFormStore } from "./SilenceFormStore";
import { SilenceFormStore, NewEmptyMatcher } from "./SilenceFormStore";
let store;
beforeEach(() => {
@@ -52,6 +52,13 @@ describe("SilenceFormStore.data", () => {
expect(store.data.inProgress).toBe(false);
});
it("resetProgress() sets 'wasValidated' to false", () => {
store.data.wasValidated = true;
expect(store.data.wasValidated).toBe(true);
store.data.resetProgress();
expect(store.data.wasValidated).toBe(false);
});
it("addEmptyMatcher() appends a matcher", () => {
expect(store.data.matchers).toHaveLength(0);
store.data.addEmptyMatcher();
@@ -129,6 +136,83 @@ describe("SilenceFormStore.data", () => {
});
});
const MockAlertmanager = () => ({
label: "default",
value: "http://localhost"
});
const MockMatcher = (name, values) => {
const matcher = NewEmptyMatcher();
matcher.name = name;
matcher.values = values;
return matcher;
};
describe("SilenceFormStore.data.isValid", () => {
it("isValid returns 'false' if alertmanagers list is empty", () => {
store.data.matchers = [MockMatcher("foo", ["bar"])];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if matchers list is empty", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if matchers list is pupulated when a matcher without any name", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("", ["bar"])];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if matchers list is pupulated when a matcher without any value ([])", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("foo", [])];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if matchers list is pupulated when a matcher with empty value ([''])", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("foo", [])];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if author is empty", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("foo", ["bar"])];
store.data.author = "";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'false' if comment is empty", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("foo", ["bar"])];
store.data.author = "me@example.com";
store.data.comment = "";
expect(store.data.isValid).toBe(false);
});
it("isValid returns 'true' if all fileds are set", () => {
store.data.alertmanagers = [MockAlertmanager];
store.data.matchers = [MockMatcher("foo", ["bar"])];
store.data.author = "me@example.com";
store.data.comment = "fake silence";
expect(store.data.isValid).toBe(true);
});
});
describe("SilenceFormStore.data startsAt & endsAt validation", () => {
it("toDuration returns correct duration for 5d 0h 1m", () => {
store.data.startsAt = moment([2000, 1, 1, 0, 0, 0]);