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

make asyncValidators run only before onSubmit

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-05-07 13:27:21 -04:00
parent efedf2acd2
commit c7148bb980
7 changed files with 129 additions and 25 deletions

View File

@ -93,7 +93,7 @@ export class AddHelmRepoDialog extends React.Component<Props> {
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Input <Input
placeholder={placeholder} placeholder={placeholder}
validators = {isPath} asyncValidators={isPath}
className="box grow" className="box grow"
value={this.getFilePath(fileType)} value={this.getFilePath(fileType)}
onChange={v => this.setFilepath(fileType, v)} onChange={v => this.setFilepath(fileType, v)}

View File

@ -61,7 +61,7 @@ export const KubectlBinaries = observer(() => {
theme="round-black" theme="round-black"
value={userStore.downloadBinariesPath} value={userStore.downloadBinariesPath}
placeholder={getDefaultKubectlPath()} placeholder={getDefaultKubectlPath()}
validators={pathValidator} asyncValidators={pathValidator}
onChange={setDownloadPath} onChange={setDownloadPath}
onBlur={save} onBlur={save}
disabled={!userStore.downloadKubectlBinaries} disabled={!userStore.downloadKubectlBinaries}
@ -79,7 +79,7 @@ export const KubectlBinaries = observer(() => {
theme="round-black" theme="round-black"
placeholder={bundledKubectlPath()} placeholder={bundledKubectlPath()}
value={binariesPath} value={binariesPath}
validators={pathValidator} asyncValidators={pathValidator}
onChange={setBinariesPath} onChange={setBinariesPath}
onBlur={save} onBlur={save}
disabled={userStore.downloadKubectlBinaries} disabled={userStore.downloadKubectlBinaries}

View File

@ -20,7 +20,7 @@ export class ClusterAccessibleNamespaces extends React.Component<Props> {
<SubTitle title="Accessible Namespaces" id="accessible-namespaces" /> <SubTitle title="Accessible Namespaces" id="accessible-namespaces" />
<EditableList <EditableList
placeholder="Add new namespace..." placeholder="Add new namespace..."
validator={namespaceValue} validators={namespaceValue}
add={(newNamespace) => { add={(newNamespace) => {
this.namespaces.add(newNamespace); this.namespaces.add(newNamespace);
this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces);

View File

@ -2,7 +2,7 @@ import "./editable-list.scss";
import React from "react"; import React from "react";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Input, InputValidator } from "../input"; import { Input, InputValidator, AsyncInputValidator } from "../input";
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
@ -12,7 +12,8 @@ export interface Props<T> {
add: (newItem: string) => void, add: (newItem: string) => void,
remove: (info: { oldItem: T, index: number }) => void, remove: (info: { oldItem: T, index: number }) => void,
placeholder?: string, placeholder?: string,
validator?: InputValidator, validators?: InputValidator | InputValidator[],
asyncValidators?: AsyncInputValidator | AsyncInputValidator[];
// An optional prop used to convert T to a displayable string // An optional prop used to convert T to a displayable string
// defaults to `String` // defaults to `String`
@ -40,7 +41,7 @@ export class EditableList<T> extends React.Component<Props<T>> {
} }
render() { render() {
const { items, remove, renderItem, placeholder, validator } = this.props; const { items, remove, renderItem, placeholder, validators, asyncValidators } = this.props;
return ( return (
<div className="EditableList"> <div className="EditableList">
@ -48,7 +49,8 @@ export class EditableList<T> extends React.Component<Props<T>> {
<Input <Input
theme="round-black" theme="round-black"
value={this.currentNewItem} value={this.currentNewItem}
validators={validator} validators={validators}
asyncValidators={asyncValidators}
onSubmit={this.onSubmit} onSubmit={this.onSubmit}
placeholder={placeholder} placeholder={placeholder}
onChange={val => this.currentNewItem = val} onChange={val => this.currentNewItem = val}

View File

@ -23,6 +23,20 @@
} }
} }
&.waiting {
pointer-events: none;
&:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 0;
height: 100%;
background: transparentize(white, .85);
animation: waiting 1.5s infinite linear;
}
}
label { label {
--flex-gap: #{$padding / 1.5}; --flex-gap: #{$padding / 1.5};
@ -123,3 +137,18 @@
--border: none; --border: none;
--color: white; --color: white;
} }
@keyframes waiting {
0% {
left: 0;
width: 0;
}
50% {
left: 25%;
width: 75%;
}
75% {
left: 100%;
width: 0;
}
}

View File

@ -5,7 +5,7 @@ import { autobind, cssNames, getRandId } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Tooltip, TooltipProps } from "../tooltip"; import { Tooltip, TooltipProps } from "../tooltip";
import * as Validators from "./input_validators"; import * as Validators from "./input_validators";
import { InputValidator } from "./input_validators"; import { AsyncInputValidator, InputValidator, ValidatorMessage } from "./input_validators";
import isString from "lodash/isString"; import isString from "lodash/isString";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import { debounce } from "lodash"; import { debounce } from "lodash";
@ -13,7 +13,7 @@ import { observer } from "mobx-react";
const { conditionalValidators, ...InputValidators } = Validators; const { conditionalValidators, ...InputValidators } = Validators;
export { InputValidators, InputValidator }; export { InputValidators, InputValidator, AsyncInputValidator };
type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>; type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
@ -31,6 +31,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
iconRight?: string | React.ReactNode; iconRight?: string | React.ReactNode;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[]; validators?: InputValidator | InputValidator[];
asyncValidators?: AsyncInputValidator | AsyncInputValidator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void; onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: T): void; onSubmit?(value: T): void;
}; };
@ -48,24 +49,31 @@ export class Input extends React.Component<InputProps> {
public inputRef = React.createRef<InputElement>(); public inputRef = React.createRef<InputElement>();
public validators: InputValidator[] = []; public validators: InputValidator[] = [];
public asyncValidators: AsyncInputValidator[] = [];
@observable errors: React.ReactNode[] = []; @observable errors: React.ReactNode[] = [];
@observable dirty = Boolean(this.props.showErrorInitially); @observable dirty = Boolean(this.props.showErrorInitially);
@observable focused = false; @observable focused = false;
@observable asyncValidating = false;
@observable isSubmitting = false;
@computed get isValid() { @computed get isValid() {
return this.errors.length === 0; return this.errors.length === 0;
} }
componentDidMount() { componentDidMount() {
const { validators, asyncValidators, showErrorInitially } = this.props;
this.validators = conditionalValidators this.validators = conditionalValidators
// add conditional validators if matches input props // add conditional validators if matches input props
.filter(validator => validator.condition(this.props)) .filter(validator => validator.condition(this.props))
// add custom validators // add custom validators
.concat(this.props.validators); .concat(validators);
if (this.props.showErrorInitially) { this.asyncValidators = [asyncValidators].flat();
this.runValidatorsRaw();
if (showErrorInitially) {
this.runValidatorsRaw(this.getValue());
} }
this.autoFitHeight(); this.autoFitHeight();
@ -118,10 +126,44 @@ export class Input extends React.Component<InputProps> {
textArea.style.height = `${height}px`; textArea.style.height = `${height}px`;
} }
private resolveValidatorMessage(message: ValidatorMessage, value: string): React.ReactNode {
return typeof message === "function"
? message(value, this.props)
: message;
}
/**
* This function should only be run before submitting.
*/
async runAsyncValidators(value: string): Promise<React.ReactNode[]> {
if (this.asyncValidators.length === 0) {
return [];
}
try {
this.asyncValidating = true;
return (await Promise.all(
this.asyncValidators.map(validator => (
validator.validate(value, this.props)
.then(isValid => {
if (!isValid) {
return [this.resolveValidatorMessage(validator.message, value)];
}
return [];
})
.catch(error => Promise.resolve<React.ReactNode[]>([error, this.resolveValidatorMessage(validator.message, value)]))
)),
)).flat();
} finally {
this.asyncValidating = false;
}
}
@action @action
runValidatorsRaw() { runValidatorsRaw(value: string) {
this.errors = []; this.errors = [];
const value = this.getValue();
// run validators // run validators
for (const validator of this.validators) { for (const validator of this.validators) {
@ -139,7 +181,7 @@ export class Input extends React.Component<InputProps> {
this.inputRef.current.setCustomValidity(this.errors.length ? this.errors[0].toString() : ""); this.inputRef.current.setCustomValidity(this.errors.length ? this.errors[0].toString() : "");
} }
runValidators = debounce(() => this.runValidatorsRaw(), 500, { runValidators = debounce(() => this.runValidatorsRaw(this.getValue()), 500, {
trailing: true, trailing: true,
leading: false, leading: false,
}); });
@ -188,11 +230,25 @@ export class Input extends React.Component<InputProps> {
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (!modified && evt.key === "Enter") { if (!modified && evt.key === "Enter") {
this.runValidatorsRaw(); const value = this.getValue();
this.isSubmitting = true;
this.runValidatorsRaw(value);
if (!this.isValid) {
return this.isSubmitting = false;
}
this.runAsyncValidators(value)
.then(errors => {
this.errors.push(...errors);
if (this.isValid) { if (this.isValid) {
this.props.onSubmit?.(this.getValue()); this.props.onSubmit?.(value);
} }
this.isSubmitting = false;
});
} }
} }
@ -210,7 +266,7 @@ export class Input extends React.Component<InputProps> {
const { const {
multiLine, validators, theme, maxRows, children, showErrorsAsTooltip, multiLine, validators, theme, maxRows, children, showErrorsAsTooltip,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
onChange, onSubmit, showErrorInitially, ...inputPropsRaw onChange, onSubmit, asyncValidators, showErrorInitially, ...inputPropsRaw
} = this.props; } = this.props;
const className = cssNames("Input", this.props.className, { const className = cssNames("Input", this.props.className, {
[`theme ${theme}`]: theme, [`theme ${theme}`]: theme,
@ -218,6 +274,7 @@ export class Input extends React.Component<InputProps> {
disabled, disabled,
invalid: !this.isValid, invalid: !this.isValid,
dirty: this.dirty, dirty: this.dirty,
waiting: this.asyncValidating,
}); });
const inputProps: InputElementProps = { const inputProps: InputElementProps = {
...inputPropsRaw, ...inputPropsRaw,
@ -227,7 +284,7 @@ export class Input extends React.Component<InputProps> {
onChange: this.onChange, onChange: this.onChange,
onKeyDown: this.onKeyDown, onKeyDown: this.onKeyDown,
spellCheck: "false", spellCheck: "false",
disabled, disabled: disabled || this.isSubmitting,
}; };
const showErrors = this.errors.length > 0; const showErrors = this.errors.length > 0;
const errorsInfo = ( const errorsInfo = (

View File

@ -2,12 +2,20 @@ import type { InputProps } from "./input";
import { ReactNode } from "react"; import { ReactNode } from "react";
import fse from "fs-extra"; import fse from "fs-extra";
export type ValidatorMessage = ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
export interface InputValidator { export interface InputValidator {
condition?(props: InputProps): boolean; // auto-bind condition depending on input props condition?(props: InputProps): boolean; // auto-bind condition depending on input props
message: ReactNode | ((value: string, props?: InputProps) => ReactNode | string); message: ValidatorMessage;
validate(value: string, props?: InputProps): boolean; validate(value: string, props?: InputProps): boolean;
} }
export interface AsyncInputValidator {
condition?(props: InputProps): boolean; // auto-bind condition depending on input props
message: ValidatorMessage;
validate(value: string, props?: InputProps): Promise<boolean>;
}
export const isRequired: InputValidator = { export const isRequired: InputValidator = {
condition: ({ required }) => required, condition: ({ required }) => required,
message: "This field is required", message: "This field is required",
@ -54,10 +62,18 @@ export const isExtensionNameInstall: InputValidator = {
validate: value => value.match(isExtensionNameInstallRegex) !== null, validate: value => value.match(isExtensionNameInstallRegex) !== null,
}; };
export const isPath: InputValidator = { export const isPath: AsyncInputValidator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: "This field must be a path to an existing file.", message: "This field must be a path to an existing file.",
validate: value => value && fse.pathExistsSync(value), validate: async value => {
try {
await fse.stat(value);
return true;
} catch (err) {
return false;
}
},
}; };
export const minLength: InputValidator = { export const minLength: InputValidator = {