1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix async validation errors not being displayed

- And fix validation errors sometimes being displayed multiple times

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-05-17 09:48:04 -04:00
parent 8ce4bceea7
commit 541a2e78d7
5 changed files with 54 additions and 66 deletions

View File

@ -137,6 +137,14 @@ export function hasDefiniteField<Field extends keyof T, T>(field: Field): (val:
return (val): val is T & { [f in Field]-?: NonNullable<T[Field]> } => val[field] != null; return (val): val is T & { [f in Field]-?: NonNullable<T[Field]> } => val[field] != null;
} }
export function isPromiseSettledRejected<T>(result: PromiseSettledResult<T>): result is PromiseRejectedResult {
return result.status === "rejected";
}
export function isPromiseSettledFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
return result.status === "fulfilled";
}
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return isObject(error) return isObject(error)
&& hasOptionalTypedProperty(error, "code", isString) && hasOptionalTypedProperty(error, "code", isString)

View File

@ -14,7 +14,6 @@ import { readFileNotify } from "../read-file-notify/read-file-notify";
import type { InstallRequest } from "../attempt-install/install-request"; import type { InstallRequest } from "../attempt-install/install-request";
import type { ExtensionInfo } from "../attempt-install-by-info.injectable"; import type { ExtensionInfo } from "../attempt-install-by-info.injectable";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import { AsyncInputValidationError } from "../../input/input_validators";
export type InstallFromInput = (input: string) => Promise<void>; export type InstallFromInput = (input: string) => Promise<void>;
@ -51,16 +50,12 @@ export const installFromInput = ({
return await attemptInstall({ fileName, dataP: readFileNotify(input) }); return await attemptInstall({ fileName, dataP: readFileNotify(input) });
} catch (error) { } catch (error) {
if (error instanceof AsyncInputValidationError) { const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
if (extNameCaptures) { if (extNameCaptures) {
const { name, version } = extNameCaptures; const { name, version } = extNameCaptures;
return await attemptInstallByInfo({ name, version }); return await attemptInstallByInfo({ name, version });
}
} else {
throw error;
} }
} }

View File

@ -9,14 +9,12 @@ import { prevDefault } from "../../utils";
import { Button } from "../button"; import { Button } from "../button";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Input, InputValidators } from "../input"; import { asyncInputValidator, Input, InputValidators } from "../input";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
import extensionInstallationStateStoreInjectable import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import { inputValidator } from "../input/input_validators";
export interface InstallProps { export interface InstallProps {
installPath: string; installPath: string;
@ -30,17 +28,22 @@ interface Dependencies {
extensionInstallationStateStore: ExtensionInstallationStateStore; extensionInstallationStateStore: ExtensionInstallationStateStore;
} }
const installInputValidators = [ const installInputValidator = asyncInputValidator({
InputValidators.isUrl, validate: async (value) => {
InputValidators.isPath, if (
InputValidators.isExtensionNameInstall, InputValidators.isUrl.validate(value)
]; || InputValidators.isExtensionNameInstall.validate(value)
) {
return;
}
const installInputValidator = inputValidator({ try {
message: "Invalid URL, absolute path, or extension name", return await InputValidators.isPath.validate(value);
validate: (value: string, props) => ( } catch {
installInputValidators.some(({ validate }) => validate(value, props)) throw new Error("Invalid URL, absolute path, or extension name");
), }
},
debounce: InputValidators.isPath.debounce,
}); });
const NonInjectedInstall: React.FC<Dependencies & InstallProps> = ({ const NonInjectedInstall: React.FC<Dependencies & InstallProps> = ({

View File

@ -7,21 +7,19 @@ import "./input.scss";
import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import React from "react"; import React from "react";
import { autoBind, cssNames, debouncePromise, getRandId } from "../../utils"; import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import type { TooltipProps } from "../tooltip"; import type { TooltipProps } from "../tooltip";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import * as Validators from "./input_validators"; import * as Validators from "./input_validators";
import type { InputValidator, InputValidation, InputValidationResult, SyncValidationMessageBuilder } from "./input_validators"; import type { InputValidator, InputValidation, InputValidationResult, SyncValidationMessageBuilder } from "./input_validators";
import isFunction from "lodash/isFunction";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import { debounce } from "lodash"; import { debounce } from "lodash";
const { conditionalValidators, AsyncInputValidationError, asyncInputValidator, inputValidator, ...InputValidators } = Validators; const { conditionalValidators, asyncInputValidator, inputValidator, ...InputValidators } = Validators;
export { export {
InputValidators, InputValidators,
AsyncInputValidationError,
asyncInputValidator, asyncInputValidator,
inputValidator, inputValidator,
}; };
@ -165,7 +163,7 @@ export class Input extends React.Component<InputProps, State> {
async validate() { async validate() {
const value = this.getValue(); const value = this.getValue();
let validationId = (this.validationId = ""); // reset every time for async validators let validationId = (this.validationId = ""); // reset every time for async validators
const asyncValidators: Promise<any>[] = []; const asyncValidators: Promise<React.ReactNode>[] = [];
const errors: React.ReactNode[] = []; const errors: React.ReactNode[] = [];
// run validators // run validators
@ -179,28 +177,13 @@ export class Input extends React.Component<InputProps, State> {
if (!validationId) { if (!validationId) {
this.validationId = validationId = uniqueId("validation_id_"); this.validationId = validationId = uniqueId("validation_id_");
} }
asyncValidators.push( asyncValidators.push((async () => {
validator.validate(value, this.props).then( try {
() => null, // don't consider any valid result from promise since we interested in errors only await validator.validate(value, this.props);
error => this.getValidatorError(value, validator) || error, } catch (error) {
), return this.getValidatorError(value, validator) || (error instanceof Error ? error.message : String(error));
); }
} })());
const isValid = validator.validate(value, this.props);
if (isValid === false) {
errors.push(this.getValidatorError(value, validator));
} else if (isValid instanceof Promise) {
if (!validationId) {
this.validationId = validationId = uniqueId("validation_id_");
}
asyncValidators.push(
isValid.then(
() => null, // don't consider any valid result from promise since we interested in errors only
error => this.getValidatorError(value, validator) || error,
),
);
} }
} }
@ -210,10 +193,15 @@ export class Input extends React.Component<InputProps, State> {
// handle async validators result // handle async validators result
if (asyncValidators.length > 0) { if (asyncValidators.length > 0) {
this.setState({ validating: true, valid: false }); this.setState({ validating: true, valid: false });
const asyncErrors = await Promise.all(asyncValidators); const asyncErrors = await Promise.allSettled(asyncValidators);
if (this.validationId === validationId) { if (this.validationId === validationId) {
this.setValidation(errors.concat(...asyncErrors.filter(err => err))); errors.push(...asyncErrors
.filter(isPromiseSettledFulfilled)
.map(res => res.value)
.filter(Boolean));
this.setValidation(errors);
} }
} }
@ -229,9 +217,9 @@ export class Input extends React.Component<InputProps, State> {
} }
private getValidatorError(value: string, { message }: InputValidator) { private getValidatorError(value: string, { message }: InputValidator) {
if (isFunction(message)) return message(value, this.props); return typeof message === "function"
? message(value, this.props)
return message || ""; : message;
} }
private setupValidators() { private setupValidators() {

View File

@ -8,9 +8,6 @@ import type { ReactNode } from "react";
import fse from "fs-extra"; import fse from "fs-extra";
import { TypedRegEx } from "typed-regex"; import { TypedRegEx } from "typed-regex";
export class AsyncInputValidationError extends Error {
}
export type InputValidationResult<IsAsync extends boolean> = export type InputValidationResult<IsAsync extends boolean> =
IsAsync extends true IsAsync extends true
? Promise<void> ? Promise<void>
@ -42,12 +39,11 @@ export type InputValidator<IsAsync extends boolean = boolean, RequireProps exten
} }
: { : {
/** /**
* If asyncronous then the rejection message is the error message * The validation message maybe either specified from the `message` field (higher priority)
* * or if that is not provided then the message will retrived from the rejected with value
* This function MUST reject with an instance of {@link AsyncInputValidationError}
*/ */
validate: InputValidation<true, RequireProps>; validate: InputValidation<true, RequireProps>;
message?: undefined; message?: ReactNode | SyncValidationMessageBuilder<RequireProps>;
debounce: number; debounce: number;
} }
); );
@ -131,10 +127,8 @@ export const isPath = asyncInputValidator({
debounce: 100, debounce: 100,
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
validate: async value => { validate: async value => {
try { if (!await fse.pathExists(value)) {
await fse.pathExists(value); throw new Error(`"${value}" is not a valid file path`);
} catch {
throw new AsyncInputValidationError(`${value} is not a valid file path`);
} }
}, },
}); });