feat(ui): render validation error as placeholders if form failed to validate

This commit is contained in:
Łukasz Mierzwa
2018-09-04 14:20:51 +01:00
parent 3cb3ed6990
commit ff25e30121
14 changed files with 155 additions and 28 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

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

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(),
@@ -70,6 +71,7 @@ class SilenceFormStore {
resetProgress() {
this.inProgress = false;
this.wasValidated = false;
},
// append a new empty matcher to the list

View File

@@ -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();