From efedf2acd2b66ba3b8b28aa4796ef5942b1e7c2e Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 7 May 2021 12:39:29 -0400 Subject: [PATCH] Refactor to be clearer. Add namespace name validator Signed-off-by: Sebastian Malton --- .../cluster-accessible-namespaces.tsx | 2 + .../editable-list/editable-list.tsx | 6 +- .../components/hotbar/hotbar-add-command.tsx | 4 +- src/renderer/components/input/input.tsx | 269 +++++++----------- .../components/input/input_validators.ts | 26 +- 5 files changed, 119 insertions(+), 188 deletions(-) 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 0fb3e7e08f..63a13ab20d 100644 --- a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx @@ -4,6 +4,7 @@ import { Cluster } from "../../../../main/cluster"; import { SubTitle } from "../../layout/sub-title"; import { EditableList } from "../../editable-list"; import { observable } from "mobx"; +import { namespaceValue } from "../../input/input_validators"; interface Props { cluster: Cluster; @@ -19,6 +20,7 @@ export class ClusterAccessibleNamespaces extends React.Component { { this.namespaces.add(newNamespace); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); diff --git a/src/renderer/components/editable-list/editable-list.tsx b/src/renderer/components/editable-list/editable-list.tsx index 3c6309b344..5e062bedb0 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 } from "../input"; +import { Input, InputValidator } from "../input"; import { observable } from "mobx"; import { observer } from "mobx-react"; import { autobind } from "../../utils"; @@ -12,6 +12,7 @@ export interface Props { add: (newItem: string) => void, remove: (info: { oldItem: T, index: number }) => void, placeholder?: string, + validator?: InputValidator, // An optional prop used to convert T to a displayable string // defaults to `String` @@ -39,7 +40,7 @@ export class EditableList extends React.Component> { } render() { - const { items, remove, renderItem, placeholder } = this.props; + const { items, remove, renderItem, placeholder, validator } = this.props; return (
@@ -47,6 +48,7 @@ export class EditableList extends React.Component> { this.currentNewItem = val} diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 5ec5734219..f4a1fd162e 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -39,8 +39,8 @@ export class HotbarAddCommand extends React.Component { data-test-id="command-palette-hotbar-add-name" validators={[uniqueHotbarName]} onSubmit={(v) => this.onSubmit(v)} - dirty={true} - showValidationLine={true} /> + showErrorInitially={true} + /> Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index ad3b77c8e8..a9382c1c0f 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -1,15 +1,15 @@ import "./input.scss"; import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; -import { autobind, cssNames, debouncePromise, getRandId } from "../../utils"; +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 isString from "lodash/isString"; -import isFunction from "lodash/isFunction"; -import isBoolean from "lodash/isBoolean"; -import uniqueId from "lodash/uniqueId"; +import { action, computed, observable } from "mobx"; +import { debounce } from "lodash"; +import { observer } from "mobx-react"; const { conditionalValidators, ...InputValidators } = Validators; @@ -25,8 +25,7 @@ export type InputProps = Omit; // show validation errors as a tooltip :hover (instead of block below) iconLeft?: string | React.ReactNode; // material-icon name in case of string-type iconRight?: string | React.ReactNode; @@ -36,46 +35,50 @@ export type InputProps = Omit = { rows: 1, maxRows: 10000, - showValidationLine: true, validators: [], + showErrorInitially: false, }; -export class Input extends React.Component { +@observer +export class Input extends React.Component { static defaultProps = defaultProps as object; - public input: InputElement; + public inputRef = React.createRef(); public validators: InputValidator[] = []; - public state: State = { - dirty: !!this.props.dirty, - valid: true, - errors: [], - }; + @observable errors: React.ReactNode[] = []; + @observable dirty = Boolean(this.props.showErrorInitially); + @observable focused = false; - isValid() { - return this.state.valid; + @computed get isValid() { + return this.errors.length === 0; + } + + componentDidMount() { + this.validators = conditionalValidators + // add conditional validators if matches input props + .filter(validator => validator.condition(this.props)) + // add custom validators + .concat(this.props.validators); + + if (this.props.showErrorInitially) { + this.runValidatorsRaw(); + } + + this.autoFitHeight(); } setValue(value: string) { if (value !== this.getValue()) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.inputRef.constructor.prototype, "value").set; - nativeInputValueSetter.call(this.input, value); + nativeInputValueSetter.call(this.inputRef, value); const evt = new Event("input", { bubbles: true }); - this.input.dispatchEvent(evt); + this.inputRef.current.dispatchEvent(evt); } } @@ -83,21 +86,21 @@ export class Input extends React.Component { const { value, defaultValue = "" } = this.props; if (value !== undefined) return value; // controlled input - if (this.input) return this.input.value; // uncontrolled input + if (this.inputRef) return this.inputRef.current.value; // uncontrolled input return defaultValue as string; } focus() { - this.input.focus(); + this.inputRef.current.focus(); } blur() { - this.input.blur(); + this.inputRef.current.blur(); } select() { - this.input.select(); + this.inputRef.current.select(); } private autoFitHeight() { @@ -106,7 +109,8 @@ export class Input extends React.Component { if (!multiLine) { return; } - const textArea = this.input; + + const textArea = this.inputRef.current; const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight); const rowsCount = (this.getValue().match(/\n/g) || []).length + 1; const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows); @@ -114,117 +118,62 @@ export class Input extends React.Component { textArea.style.height = `${height}px`; } - private validationId: string; - - async validate(value = this.getValue()) { - let validationId = (this.validationId = ""); // reset every time for async validators - const asyncValidators: Promise[] = []; - const errors: React.ReactNode[] = []; + @action + runValidatorsRaw() { + this.errors = []; + const value = this.getValue(); // run validators for (const validator of this.validators) { - if (errors.length) { - // stop validation check if there is an error already - break; - } - const result = validator.validate(value, this.props); + const isValid = validator.validate(value, this.props); - if (isBoolean(result) && !result) { - errors.push(this.getValidatorError(value, validator)); - } else if (result instanceof Promise) { - if (!validationId) { - this.validationId = validationId = uniqueId("validation_id_"); + if (!isValid) { + if (typeof validator.message === "function") { + this.errors.push(validator.message(value, this.props)); + } else { + this.errors.push(validator.message); } - asyncValidators.push( - result.then( - () => null, // don't consider any valid result from promise since we interested in errors only - error => this.getValidatorError(value, validator) || error - ) - ); } } - // save sync validators result first - this.setValidation(errors); - - // handle async validators result - if (asyncValidators.length > 0) { - this.setState({ validating: true, valid: false }); - const asyncErrors = await Promise.all(asyncValidators); - - if (this.validationId === validationId) { - this.setValidation(errors.concat(...asyncErrors.filter(err => err))); - } - } - - this.input.setCustomValidity(errors.length ? errors[0].toString() : ""); + this.inputRef.current.setCustomValidity(this.errors.length ? this.errors[0].toString() : ""); } - setValidation(errors: React.ReactNode[]) { - this.setState({ - validating: false, - valid: !errors.length, - errors, - }); - } + runValidators = debounce(() => this.runValidatorsRaw(), 500, { + trailing: true, + leading: false, + }); - private getValidatorError(value: string, { message }: InputValidator) { - if (isFunction(message)) return message(value, this.props); - - return message || ""; - } - - private setupValidators() { - this.validators = conditionalValidators - // add conditional validators if matches input props - .filter(validator => validator.condition(this.props)) - // add custom validators - .concat(this.props.validators) - // debounce async validators - .map(({ debounce, ...validator }) => { - if (debounce) validator.validate = debouncePromise(validator.validate, debounce); - - return validator; - }); - // run validation - this.validate(); - } - - setDirty(dirty = true) { - if (this.state.dirty === dirty) return; - this.setState({ dirty }); + validate() { + this.errors = []; + this.runValidators(); } @autobind() onFocus(evt: React.FocusEvent) { const { onFocus, autoSelectOnFocus } = this.props; - if (onFocus) onFocus(evt); - if (autoSelectOnFocus) this.select(); - this.setState({ focused: true }); + onFocus?.(evt); + + if (autoSelectOnFocus) { + this.select(); + } + + this.focused = true; } @autobind() onBlur(evt: React.FocusEvent) { - const { onBlur } = this.props; - - if (onBlur) onBlur(evt); - if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false }); - this.setState({ focused: false }); + this.props.onBlur?.(evt); + this.focused = false; } @autobind() - onChange(evt: React.ChangeEvent) { - if (this.props.onChange) { - this.props.onChange(evt.currentTarget.value, evt); - } - + onChange(evt: React.ChangeEvent) { + this.props.onChange?.(evt.currentTarget.value, evt); this.validate(); this.autoFitHeight(); - // mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing - if (!this.state.dirty) this.setState({ dirtyOnBlur: true }); - // re-render component when used as uncontrolled input // when used @defaultValue instead of @value changing real input.value doesn't call render() if (this.isUncontrolled && this.showMaxLenIndicator) { @@ -233,19 +182,17 @@ export class Input extends React.Component { } @autobind() - onKeyDown(evt: React.KeyboardEvent) { + onKeyDown(evt: React.KeyboardEvent) { + this.props.onKeyDown?.(evt); + const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; - if (this.props.onKeyDown) { - this.props.onKeyDown(evt); - } + if (!modified && evt.key === "Enter") { + this.runValidatorsRaw(); - switch (evt.key) { - case "Enter": - if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { - this.props.onSubmit(this.getValue()); - } - break; + if (this.isValid) { + this.props.onSubmit?.(this.getValue()); + } } } @@ -259,68 +206,33 @@ export class Input extends React.Component { return this.props.value === undefined; } - componentDidMount() { - this.setupValidators(); - this.autoFitHeight(); - } - - componentDidUpdate(prevProps: InputProps) { - const { defaultValue, value, dirty, validators } = this.props; - - if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) { - this.validate(); - this.autoFitHeight(); - } - - if (prevProps.dirty !== dirty) { - this.setDirty(dirty); - } - - if (prevProps.validators !== validators) { - this.setupValidators(); - } - } - - @autobind() - bindRef(elem: InputElement) { - this.input = elem; - } - render() { const { - multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, + multiLine, validators, theme, maxRows, children, showErrorsAsTooltip, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, - dirty: _dirty, // excluded from passing to input-element - ...inputProps + onChange, onSubmit, showErrorInitially, ...inputPropsRaw } = this.props; - const { focused, dirty, valid, validating, errors } = this.state; - const className = cssNames("Input", this.props.className, { [`theme ${theme}`]: theme, - focused, + focused: this.focused, disabled, - invalid: !valid, - dirty, - validating, - validatingLine: validating && showValidationLine, + invalid: !this.isValid, + dirty: this.dirty, }); - - // prepare input props - Object.assign(inputProps, { + const inputProps: InputElementProps = { + ...inputPropsRaw, className: "input box grow", onFocus: this.onFocus, onBlur: this.onBlur, onChange: this.onChange, onKeyDown: this.onKeyDown, - rows: multiLine ? (rows || 1) : null, - ref: this.bindRef, spellCheck: "false", disabled, - }); - const showErrors = errors.length > 0 && !valid && dirty; + }; + const showErrors = this.errors.length > 0; const errorsInfo = (
- {errors.map((error, i) =>

{error}

)} + {this.errors.map((error, i) =>

{error}

)}
); const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; @@ -345,7 +257,18 @@ export class Input extends React.Component { {tooltipError}