diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index bb34c1a1eb..731eabaa5a 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -93,7 +93,7 @@ export class AddHelmRepoDialog extends React.Component {
this.setFilepath(fileType, v)} diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index f9c3bfdacb..591ee4f1cc 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -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} diff --git a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx index 63a13ab20d..f2dc829a08 100644 --- a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx @@ -20,7 +20,7 @@ export class ClusterAccessibleNamespaces extends React.Component { { this.namespaces.add(newNamespace); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); @@ -32,7 +32,7 @@ export class ClusterAccessibleNamespaces extends React.Component { }} /> - 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. ); diff --git a/src/renderer/components/editable-list/editable-list.tsx b/src/renderer/components/editable-list/editable-list.tsx index 5e062bedb0..3703842ddc 100644 --- a/src/renderer/components/editable-list/editable-list.tsx +++ b/src/renderer/components/editable-list/editable-list.tsx @@ -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 { 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 extends React.Component> { } render() { - const { items, remove, renderItem, placeholder, validator } = this.props; + const { items, remove, renderItem, placeholder, validators, asyncValidators } = this.props; return (
@@ -48,7 +49,8 @@ export class EditableList extends React.Component> { this.currentNewItem = val} diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 6b6169d03a..4f3b04804c 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -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; + } +} diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index a9382c1c0f..582c08b261 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -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 & TextareaHTMLAttributes & DOMAttributes; @@ -31,6 +31,7 @@ export type InputProps = Omit): void; onSubmit?(value: T): void; }; @@ -48,24 +49,31 @@ export class Input extends React.Component { public inputRef = React.createRef(); 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 { 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 { + 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([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 { 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 { 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 { 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 { disabled, invalid: !this.isValid, dirty: this.dirty, + waiting: this.asyncValidating, }); const inputProps: InputElementProps = { ...inputPropsRaw, @@ -227,7 +284,7 @@ export class Input extends React.Component { onChange: this.onChange, onKeyDown: this.onKeyDown, spellCheck: "false", - disabled, + disabled: disabled || this.isSubmitting, }; const showErrors = this.errors.length > 0; const errorsInfo = ( diff --git a/src/renderer/components/input/input_validators.ts b/src/renderer/components/input/input_validators.ts index 573fe8d885..f40607aee7 100644 --- a/src/renderer/components/input/input_validators.ts +++ b/src/renderer/components/input/input_validators.ts @@ -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; +} + 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 = {