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">
<Input
placeholder={placeholder}
validators = {isPath}
asyncValidators={isPath}
className="box grow"
value={this.getFilePath(fileType)}
onChange={v => this.setFilepath(fileType, v)}

View File

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

View File

@ -20,7 +20,7 @@ export class ClusterAccessibleNamespaces extends React.Component<Props> {
<SubTitle title="Accessible Namespaces" id="accessible-namespaces" />
<EditableList
placeholder="Add new namespace..."
validator={namespaceValue}
validators={namespaceValue}
add={(newNamespace) => {
this.namespaces.add(newNamespace);
this.props.cluster.accessibleNamespaces = Array.from(this.namespaces);
@ -32,7 +32,7 @@ export class ClusterAccessibleNamespaces extends React.Component<Props> {
}}
/>
<small className="hint">
This setting is useful for manually specifying which namespaces you have access to. This is useful when you do not have permissions to list namespaces.
This setting is useful for manually specifying which namespaces you have access to. This is useful when you do not have permissions to list namespaces.
</small>
</>
);

View File

@ -2,7 +2,7 @@ import "./editable-list.scss";
import React from "react";
import { Icon } from "../icon";
import { Input, InputValidator } from "../input";
import { Input, InputValidator, AsyncInputValidator } from "../input";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { autobind } from "../../utils";
@ -12,7 +12,8 @@ export interface Props<T> {
add: (newItem: string) => void,
remove: (info: { oldItem: T, index: number }) => void,
placeholder?: string,
validator?: InputValidator,
validators?: InputValidator | InputValidator[],
asyncValidators?: AsyncInputValidator | AsyncInputValidator[];
// An optional prop used to convert T to a displayable string
// defaults to `String`
@ -40,7 +41,7 @@ export class EditableList<T> extends React.Component<Props<T>> {
}
render() {
const { items, remove, renderItem, placeholder, validator } = this.props;
const { items, remove, renderItem, placeholder, validators, asyncValidators } = this.props;
return (
<div className="EditableList">
@ -48,7 +49,8 @@ export class EditableList<T> extends React.Component<Props<T>> {
<Input
theme="round-black"
value={this.currentNewItem}
validators={validator}
validators={validators}
asyncValidators={asyncValidators}
onSubmit={this.onSubmit}
placeholder={placeholder}
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 {
--flex-gap: #{$padding / 1.5};
@ -123,3 +137,18 @@
--border: none;
--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 { Tooltip, TooltipProps } from "../tooltip";
import * as Validators from "./input_validators";
import { InputValidator } from "./input_validators";
import { AsyncInputValidator, InputValidator, ValidatorMessage } from "./input_validators";
import isString from "lodash/isString";
import { action, computed, observable } from "mobx";
import { debounce } from "lodash";
@ -13,7 +13,7 @@ import { observer } from "mobx-react";
const { conditionalValidators, ...InputValidators } = Validators;
export { InputValidators, InputValidator };
export { InputValidators, InputValidator, AsyncInputValidator };
type InputElement = HTMLInputElement | HTMLTextAreaElement;
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;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[];
asyncValidators?: AsyncInputValidator | AsyncInputValidator[];
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: T): void;
};
@ -48,24 +49,31 @@ export class Input extends React.Component<InputProps> {
public inputRef = React.createRef<InputElement>();
public validators: InputValidator[] = [];
public asyncValidators: AsyncInputValidator[] = [];
@observable errors: React.ReactNode[] = [];
@observable dirty = Boolean(this.props.showErrorInitially);
@observable focused = false;
@observable asyncValidating = false;
@observable isSubmitting = false;
@computed get isValid() {
return this.errors.length === 0;
}
componentDidMount() {
const { validators, asyncValidators, showErrorInitially } = this.props;
this.validators = conditionalValidators
// add conditional validators if matches input props
.filter(validator => validator.condition(this.props))
// add custom validators
.concat(this.props.validators);
.concat(validators);
if (this.props.showErrorInitially) {
this.runValidatorsRaw();
this.asyncValidators = [asyncValidators].flat();
if (showErrorInitially) {
this.runValidatorsRaw(this.getValue());
}
this.autoFitHeight();
@ -118,10 +126,44 @@ export class Input extends React.Component<InputProps> {
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
runValidatorsRaw() {
runValidatorsRaw(value: string) {
this.errors = [];
const value = this.getValue();
// run 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() : "");
}
runValidators = debounce(() => this.runValidatorsRaw(), 500, {
runValidators = debounce(() => this.runValidatorsRaw(this.getValue()), 500, {
trailing: true,
leading: false,
});
@ -188,11 +230,25 @@ export class Input extends React.Component<InputProps> {
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (!modified && evt.key === "Enter") {
this.runValidatorsRaw();
const value = this.getValue();
if (this.isValid) {
this.props.onSubmit?.(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) {
this.props.onSubmit?.(value);
}
this.isSubmitting = false;
});
}
}
@ -210,7 +266,7 @@ export class Input extends React.Component<InputProps> {
const {
multiLine, validators, theme, maxRows, children, showErrorsAsTooltip,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
onChange, onSubmit, showErrorInitially, ...inputPropsRaw
onChange, onSubmit, asyncValidators, showErrorInitially, ...inputPropsRaw
} = this.props;
const className = cssNames("Input", this.props.className, {
[`theme ${theme}`]: theme,
@ -218,6 +274,7 @@ export class Input extends React.Component<InputProps> {
disabled,
invalid: !this.isValid,
dirty: this.dirty,
waiting: this.asyncValidating,
});
const inputProps: InputElementProps = {
...inputPropsRaw,
@ -227,7 +284,7 @@ export class Input extends React.Component<InputProps> {
onChange: this.onChange,
onKeyDown: this.onKeyDown,
spellCheck: "false",
disabled,
disabled: disabled || this.isSubmitting,
};
const showErrors = this.errors.length > 0;
const errorsInfo = (

View File

@ -2,12 +2,20 @@ import type { InputProps } from "./input";
import { ReactNode } from "react";
import fse from "fs-extra";
export type ValidatorMessage = ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
export interface InputValidator {
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;
}
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 = {
condition: ({ required }) => required,
message: "This field is required",
@ -54,10 +62,18 @@ export const isExtensionNameInstall: InputValidator = {
validate: value => value.match(isExtensionNameInstallRegex) !== null,
};
export const isPath: InputValidator = {
export const isPath: AsyncInputValidator = {
condition: ({ type }) => type === "text",
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 = {