((resolve) => window.setTimeout(() => resolve(), ms));
+
+export default () => {
+ const [model, setModel] = useState({ foo: "Foo" });
+
+ const asyncValidator = async (value: string) => {
+ await timeout(300);
+
+ if (!value.toLowerCase().includes("o")) {
+ return "Must include the letter 'o'";
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/web/html/src/components/input/validation/helpers.stories.tsx b/web/html/src/components/input/validation/helpers.stories.tsx
new file mode 100644
index 000000000000..3415a19c0419
--- /dev/null
+++ b/web/html/src/components/input/validation/helpers.stories.tsx
@@ -0,0 +1,16 @@
+import { useState } from "react";
+
+import { Form, Text, Validation } from "components/input";
+
+export default () => {
+ const [model, setModel] = useState({ foo: "Foo", bar: "3", tea: "Hi" });
+
+ return (
+
+ );
+};
diff --git a/web/html/src/components/input/validation/multiple-messages.stories.tsx b/web/html/src/components/input/validation/multiple-messages.stories.tsx
new file mode 100644
index 000000000000..856c922d1a60
--- /dev/null
+++ b/web/html/src/components/input/validation/multiple-messages.stories.tsx
@@ -0,0 +1,20 @@
+import { useState } from "react";
+
+import { Form, Text } from "components/input";
+
+export default () => {
+ const [model, setModel] = useState({ foo: "Hi" });
+
+ const validator = (value: string) => {
+ if (!value.toLowerCase().includes("i")) {
+ return ["Must include the letter 'i'", "It's really gotta"];
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/web/html/src/components/input/validation/validation.test.ts b/web/html/src/components/input/validation/validation.test.ts
new file mode 100644
index 000000000000..67221d86a886
--- /dev/null
+++ b/web/html/src/components/input/validation/validation.test.ts
@@ -0,0 +1,115 @@
+import { Validation } from "./validation";
+
+const errorMessage = "error message";
+
+describe("validation", () => {
+ test("matches", () => {
+ const validator = Validation.matches(/foo/, errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("-")).toEqual(errorMessage);
+ expect(validator("-foo")).toEqual(undefined);
+ expect(validator("-foo-")).toEqual(undefined);
+ expect(validator("-fo-")).toEqual(errorMessage);
+ });
+
+ test("minLength string", () => {
+ const validator = Validation.minLength(3, errorMessage);
+
+ // Here and elsewhere, if you want the value to be required, set the `required` flag instead
+ expect(validator("")).toEqual(undefined);
+ expect(validator("foo")).toEqual(undefined);
+ expect(validator("fo")).toEqual(errorMessage);
+ });
+
+ test("minLength object", () => {
+ const validator = Validation.minLength(3, errorMessage);
+
+ expect(
+ validator({
+ foo: "foo",
+ bar: "bar",
+ tea: "tea",
+ })
+ ).toEqual(undefined);
+
+ expect(
+ validator({
+ foo: "foo",
+ bar: "ba",
+ tea: "tea",
+ })
+ ).toEqual(errorMessage);
+ });
+
+ test("maxLength string", () => {
+ const validator = Validation.maxLength(3, errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("foo")).toEqual(undefined);
+ expect(validator("fooo")).toEqual(errorMessage);
+ });
+
+ test("maxLength object", () => {
+ const validator = Validation.maxLength(3, errorMessage);
+
+ expect(
+ validator({
+ foo: "foo",
+ bar: "bar",
+ tea: "tea",
+ })
+ ).toEqual(undefined);
+
+ expect(
+ validator({
+ foo: "foo",
+ bar: "barr",
+ tea: "tea",
+ })
+ ).toEqual(errorMessage);
+ });
+
+ test("isInt", () => {
+ const validator = Validation.isInt(errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("0")).toEqual(undefined);
+ expect(validator("42")).toEqual(undefined);
+ expect(validator("42.")).toEqual(errorMessage);
+ expect(validator("4.2")).toEqual(errorMessage);
+ expect(validator("0x1")).toEqual(errorMessage);
+ expect(validator("foo")).toEqual(errorMessage);
+ });
+
+ test("min", () => {
+ const validator = Validation.min(7, errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("6")).toEqual(errorMessage);
+ expect(validator("7")).toEqual(undefined);
+ expect(validator("8")).toEqual(undefined);
+ });
+
+ test("max", () => {
+ const validator = Validation.max(7, errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("6")).toEqual(undefined);
+ expect(validator("7")).toEqual(undefined);
+ expect(validator("8")).toEqual(errorMessage);
+ });
+
+ test("intRange", () => {
+ const validator = Validation.range(3, 5, errorMessage);
+
+ expect(validator("")).toEqual(undefined);
+ expect(validator("1.5")).toEqual(errorMessage);
+ expect(validator("2")).toEqual(errorMessage);
+ expect(validator("3")).toEqual(undefined);
+ expect(validator("4")).toEqual(undefined);
+ expect(validator("4.5")).toEqual(errorMessage);
+ expect(validator("5")).toEqual(undefined);
+ expect(validator("6")).toEqual(errorMessage);
+ });
+});
diff --git a/web/html/src/components/input/validation/validation.ts b/web/html/src/components/input/validation/validation.ts
new file mode 100644
index 000000000000..649796c7d5b4
--- /dev/null
+++ b/web/html/src/components/input/validation/validation.ts
@@ -0,0 +1,144 @@
+type OneOrMany = T | T[];
+type SyncOrAsync = T | Promise;
+
+export type ValidationResult = OneOrMany | undefined>;
+export type Validator = (...args: any[]) => SyncOrAsync;
+
+/** String must match `regex` */
+const matches =
+ (regex: RegExp, message = t("Doesn't match expected format")): Validator =>
+ (value: string) => {
+ // Here and elsewhere, if you want the value to be required, set the `required` flag instead
+ if (value === "") {
+ return;
+ }
+
+ if (!regex.test(value)) {
+ return message;
+ }
+ };
+
+// TODO: Some places that use this are off by one from what they want to be
+/** String must be at least `length` chars long */
+const minLength =
+ (length: number, message = t(`Must be at least ${length} characters long`)): Validator =>
+ (value: Record | string) => {
+ if (typeof value === "object") {
+ const isInvalid = Object.values(value).some((item) => item.length !== 0 && item.length < length);
+ if (isInvalid) {
+ return message;
+ }
+ } else if (value.length !== 0 && value.length < length) {
+ return message;
+ }
+ };
+
+/** String must be no more than `length` chars long */
+const maxLength =
+ (length: number, message = t(`Must be no more than ${length} characters long`)): Validator =>
+ (value: Record | string) => {
+ if (typeof value === "object") {
+ const isInvalid = Object.values(value).some((item) => item.length !== 0 && item.length > length);
+ if (isInvalid) {
+ return message;
+ }
+ } else if (value.length !== 0 && value.length > length) {
+ return message;
+ }
+ };
+
+/** String is integer */
+const isInt =
+ (message = t(`Must be an integer`)): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value) {
+ return message;
+ }
+ };
+
+/** Value is an integer no smaller than `minValue` */
+const min =
+ (minValue: number, message = t(`Must be an integer no smaller than ${minValue}`)): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value || parsed < minValue) {
+ return message;
+ }
+ };
+
+/** Value is an integer no larger than `maxValue` */
+const max =
+ (maxValue: number, message = t(`Must be an integer no larger than ${maxValue}`)): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value || parsed > maxValue) {
+ return message;
+ }
+ };
+
+/** Value is an integer greater than `gtValue` */
+const gt =
+ (gtValue: number, message = t(`Must be an integer greater than ${gtValue}`)): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value || parsed <= gtValue) {
+ return message;
+ }
+ };
+
+/** Value is an integer smaller than `ltValue` */
+const lt =
+ (ltValue: number, message = t(`Must be an integer greater than ${ltValue}`)): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value || parsed >= ltValue) {
+ return message;
+ }
+ };
+
+/** Value is an integer that is no smaller than `minValue` and no larger than `maxValue` */
+const range =
+ (minValue: number, maxValue: number, message?: string): Validator =>
+ (value: string) => {
+ if (value === "") {
+ return;
+ }
+
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed) || parsed.toString() !== value || parsed < minValue || parsed > maxValue) {
+ return message;
+ }
+ };
+
+export const Validation = {
+ matches,
+ minLength,
+ maxLength,
+ min,
+ max,
+ gt,
+ lt,
+ isInt,
+ range,
+};
diff --git a/web/html/src/components/validation.ts b/web/html/src/components/validation.ts
deleted file mode 100644
index 46412fecbe23..000000000000
--- a/web/html/src/components/validation.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// https://github.com/chriso/validator.js
-import validator from "validator";
-
-const f =
- (fn) =>
- (...args) =>
- (str) =>
- fn(str, ...args);
-const validations: Record = {};
-
-Object.keys(validator).forEach((v) => {
- if (typeof validator[v] === "function") {
- validations[v] = f(validator[v]);
- }
-});
-
-export default validations;
diff --git a/web/html/src/manager/images/image-import.tsx b/web/html/src/manager/images/image-import.tsx
index d037552d1951..90b462396fac 100644
--- a/web/html/src/manager/images/image-import.tsx
+++ b/web/html/src/manager/images/image-import.tsx
@@ -254,36 +254,21 @@ class ImageImport extends React.Component {