From 8ecd19701fbd6d06bc5165f6dea2eb35f6356b00 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 30 May 2022 10:49:28 -0400 Subject: [PATCH] Introduce unionizing functions for input validators Signed-off-by: Sebastian Malton --- .../components/+extensions/install.tsx | 26 +++---- .../input/__tests__/input_validators.test.ts | 77 ++++++++++++++++++- src/renderer/components/input/input.tsx | 16 ++-- .../components/input/input_validators.ts | 63 +++++++++++++++ 4 files changed, 159 insertions(+), 23 deletions(-) diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index 816cfadce9..7fa192e3a2 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -9,12 +9,13 @@ import { prevDefault } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; import { observer } from "mobx-react"; -import { asyncInputValidator, Input, InputValidators } from "../input"; +import { Input, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; import { TooltipPosition } from "../tooltip"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; +import { unionInputValidatorsAsync } from "../input/input_validators"; export interface InstallProps { installPath: string; @@ -28,23 +29,14 @@ interface Dependencies { extensionInstallationStateStore: ExtensionInstallationStateStore; } -const installInputValidator = asyncInputValidator({ - validate: async (value) => { - if ( - InputValidators.isUrl.validate(value) - || InputValidators.isExtensionNameInstall.validate(value) - ) { - return; - } - - try { - return await InputValidators.isPath.validate(value); - } catch { - throw new Error("Invalid URL, absolute path, or extension name"); - } +const installInputValidator = unionInputValidatorsAsync( + { + message: "Invalid URL, absolute path, or extension name", }, - debounce: InputValidators.isPath.debounce, -}); + InputValidators.isUrl, + InputValidators.isExtensionNameInstall, + InputValidators.isPath, +); const NonInjectedInstall: React.FC = ({ installPath, diff --git a/src/renderer/components/input/__tests__/input_validators.test.ts b/src/renderer/components/input/__tests__/input_validators.test.ts index 5d1356510b..cb6a0b3db9 100644 --- a/src/renderer/components/input/__tests__/input_validators.test.ts +++ b/src/renderer/components/input/__tests__/input_validators.test.ts @@ -3,11 +3,86 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { isEmail, isUrl, systemName } from "../input_validators"; +import { isEmail, isUrl, systemName, unionInputValidators, unionInputValidatorsAsync } from "../input_validators"; type TextValidationCase = [string, boolean]; describe("input validation tests", () => { + describe("unionInputValidators()", () => { + const emailOrUrl = unionInputValidators( + { + message: "Not an email or URL", + }, + isEmail, + isUrl, + ); + + it.each([ + "abc@news.com", + "abc@news.co.uk", + ])("Given '%s' is a valid email, emailOrUrl matches", (input) => { + expect(emailOrUrl.validate(input)).toBe(true); + }); + + it.each([ + "https://github-production-registry-package-file-4f11e5.s3.amazonaws.com/307985088/68bbbf00-309f-11eb-8457-a15e4efe9e77?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20201127%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20201127T123754Z&X-Amz-Expires=300&X-Amz-Signature=9b8167f00685a20d980224d397892195abc187cdb2934cefb79edcd7ec600f78&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=0&response-content-disposition=filename%3Dstarboard-lens-extension-0.0.1-alpha.1-npm.tgz&response-content-type=application%2Foctet-stream", + "http://www.google.com", + ])("Given '%s' is a valid url, emailOrUrl matches", (input) => { + expect(emailOrUrl.validate(input)).toBe(true); + }); + + it.each([ + "hello", + "57", + ])("Given '%s' is neither a valid email nor URL, emailOrUrl does not match", (input) => { + expect(emailOrUrl.validate(input)).toBe(false); + }); + }); + + describe("unionInputValidatorsAsync()", () => { + const emailOrUrl = unionInputValidatorsAsync( + { + message: "Not an email or URL", + }, + isEmail, + isUrl, + ); + + it.each([ + "abc@news.com", + "abc@news.co.uk", + ])("Given '%s' is a valid email, emailOrUrl matches", async (input) => { + try { + await emailOrUrl.validate(input); + } catch { + fail("Should not throw on valid input"); + } + }); + + it.each([ + "https://github-production-registry-package-file-4f11e5.s3.amazonaws.com/307985088/68bbbf00-309f-11eb-8457-a15e4efe9e77?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20201127%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20201127T123754Z&X-Amz-Expires=300&X-Amz-Signature=9b8167f00685a20d980224d397892195abc187cdb2934cefb79edcd7ec600f78&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=0&response-content-disposition=filename%3Dstarboard-lens-extension-0.0.1-alpha.1-npm.tgz&response-content-type=application%2Foctet-stream", + "http://www.google.com", + ])("Given '%s' is a valid url, emailOrUrl matches", async (input) => { + try { + await emailOrUrl.validate(input); + } catch { + fail("Should not throw on valid input"); + } + }); + + it.each([ + "hello", + "57", + ])("Given '%s' is neither a valid email nor URL, emailOrUrl does not match", async (input) => { + try { + await emailOrUrl.validate(input); + fail("Should throw on invalid input"); + } catch { + // We want this to happen + } + }); + }); + describe("isEmail tests", () => { const tests: TextValidationCase[] = [ ["abc@news.com", true], diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 91bfa27b38..f3e015806a 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -16,13 +16,23 @@ import type { InputValidator, InputValidation, InputValidationResult, SyncValida import uniqueId from "lodash/uniqueId"; import { debounce } from "lodash"; -const { conditionalValidators, asyncInputValidator, inputValidator, inputValidatorWithRequiredProps, ...InputValidators } = Validators; +const { + conditionalValidators, + asyncInputValidator, + inputValidator, + inputValidatorWithRequiredProps, + isAsyncValidator, + unionInputValidatorsAsync, + ...InputValidators +} = Validators; export { InputValidators, asyncInputValidator, inputValidator, inputValidatorWithRequiredProps, + isAsyncValidator, + unionInputValidatorsAsync, }; export type { InputValidator, @@ -87,10 +97,6 @@ const defaultProps: Partial = { blurOnEnter: true, }; -function isAsyncValidator(validator: InputValidator): validator is InputValidator { - return typeof validator.debounce === "number"; -} - export class Input extends React.Component { static defaultProps = defaultProps as object; diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index cba09101cc..be32f800a1 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -7,6 +7,7 @@ import type { InputProps } from "./input"; import type React from "react"; import fse from "fs-extra"; import { TypedRegEx } from "typed-regex"; +import type { SetRequired } from "type-fest"; export type InputValidationResult = IsAsync extends true @@ -48,6 +49,10 @@ export type InputValidator(validator: InputValidator): validator is InputValidator { + return typeof validator.debounce === "number"; +} + export function asyncInputValidator(validator: InputValidator): InputValidator { return validator; } @@ -60,6 +65,64 @@ export function inputValidatorWithRequiredProps(validator: InputValidator, "condition" | "message">, + ...validators: InputValidator[] +): InputValidator { + return inputValidator({ + ...baseValidator, + validate: (value, props) => validators.some(validator => validator.validate(value, props)), + }); +} + +/** + * Create a new input validator from a list of syncronous or async input validators. Will match as + * valid if one of the input validators matches the input + */ +export function unionInputValidatorsAsync( + baseValidator: SetRequired, "condition" | "message">, "message">, + ...validators: InputValidator[] +): InputValidator { + const longestDebounce = Math.max( + ...validators + .filter(isAsyncValidator) + .map(validator => validator.debounce), + 0, + ); + + return asyncInputValidator({ + debounce: longestDebounce, + validate: async (value, props) => { + for (const validator of validators) { + if (isAsyncValidator(validator)) { + try { + await validator.validate(value, props); + + return; + } catch { + // Do nothing + } + } else { + if (validator.validate(value, props)) { + return; + } + } + } + + /** + * If no validator returns `true` then mark as invalid by throwing. The message will be + * obtained from the `message` field. + */ + throw {}; + }, + ...baseValidator, + }); +} + export const isRequired = inputValidator({ condition: ({ required }) => required, message: () => `This field is required`,