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

Refactor <Input> to be clearer. Add namespace name validator

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-05-07 12:39:29 -04:00
parent 9e75016247
commit efedf2acd2
5 changed files with 119 additions and 188 deletions

View File

@ -4,6 +4,7 @@ import { Cluster } from "../../../../main/cluster";
import { SubTitle } from "../../layout/sub-title"; import { SubTitle } from "../../layout/sub-title";
import { EditableList } from "../../editable-list"; import { EditableList } from "../../editable-list";
import { observable } from "mobx"; import { observable } from "mobx";
import { namespaceValue } from "../../input/input_validators";
interface Props { interface Props {
cluster: Cluster; cluster: Cluster;
@ -19,6 +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}
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 } from "../input"; import { Input, InputValidator } 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,6 +12,7 @@ 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,
// 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`
@ -39,7 +40,7 @@ export class EditableList<T> extends React.Component<Props<T>> {
} }
render() { render() {
const { items, remove, renderItem, placeholder } = this.props; const { items, remove, renderItem, placeholder, validator } = this.props;
return ( return (
<div className="EditableList"> <div className="EditableList">
@ -47,6 +48,7 @@ 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}
onSubmit={this.onSubmit} onSubmit={this.onSubmit}
placeholder={placeholder} placeholder={placeholder}
onChange={val => this.currentNewItem = val} onChange={val => this.currentNewItem = val}

View File

@ -39,8 +39,8 @@ export class HotbarAddCommand extends React.Component {
data-test-id="command-palette-hotbar-add-name" data-test-id="command-palette-hotbar-add-name"
validators={[uniqueHotbarName]} validators={[uniqueHotbarName]}
onSubmit={(v) => this.onSubmit(v)} onSubmit={(v) => this.onSubmit(v)}
dirty={true} showErrorInitially={true}
showValidationLine={true} /> />
<small className="hint"> <small className="hint">
Please provide a new hotbar name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel) Please provide a new hotbar name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small> </small>

View File

@ -1,15 +1,15 @@
import "./input.scss"; import "./input.scss";
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; 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 { 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 { InputValidator } from "./input_validators";
import isString from "lodash/isString"; import isString from "lodash/isString";
import isFunction from "lodash/isFunction"; import { action, computed, observable } from "mobx";
import isBoolean from "lodash/isBoolean"; import { debounce } from "lodash";
import uniqueId from "lodash/uniqueId"; import { observer } from "mobx-react";
const { conditionalValidators, ...InputValidators } = Validators; const { conditionalValidators, ...InputValidators } = Validators;
@ -25,8 +25,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
autoSelectOnFocus?: boolean autoSelectOnFocus?: boolean
multiLine?: boolean; // use text-area as input field multiLine?: boolean; // use text-area as input field
maxRows?: number; // when multiLine={true} define max rows size maxRows?: number; // when multiLine={true} define max rows size
dirty?: boolean; // show validation errors even if the field wasn't touched yet showErrorInitially?: boolean; // show validation errors even if the field wasn't touched yet
showValidationLine?: boolean; // show animated validation line for async validators
showErrorsAsTooltip?: boolean | Omit<TooltipProps, "targetId">; // show validation errors as a tooltip :hover (instead of block below) showErrorsAsTooltip?: boolean | Omit<TooltipProps, "targetId">; // show validation errors as a tooltip :hover (instead of block below)
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
iconRight?: string | React.ReactNode; iconRight?: string | React.ReactNode;
@ -36,46 +35,50 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
onSubmit?(value: T): void; onSubmit?(value: T): void;
}; };
interface State {
focused?: boolean;
dirty?: boolean;
dirtyOnBlur?: boolean;
valid?: boolean;
validating?: boolean;
errors?: React.ReactNode[];
}
const defaultProps: Partial<InputProps> = { const defaultProps: Partial<InputProps> = {
rows: 1, rows: 1,
maxRows: 10000, maxRows: 10000,
showValidationLine: true,
validators: [], validators: [],
showErrorInitially: false,
}; };
export class Input extends React.Component<InputProps, State> { @observer
export class Input extends React.Component<InputProps> {
static defaultProps = defaultProps as object; static defaultProps = defaultProps as object;
public input: InputElement; public inputRef = React.createRef<InputElement>();
public validators: InputValidator[] = []; public validators: InputValidator[] = [];
public state: State = { @observable errors: React.ReactNode[] = [];
dirty: !!this.props.dirty, @observable dirty = Boolean(this.props.showErrorInitially);
valid: true, @observable focused = false;
errors: [],
};
isValid() { @computed get isValid() {
return this.state.valid; 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) { setValue(value: string) {
if (value !== this.getValue()) { 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 }); 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<InputProps, State> {
const { value, defaultValue = "" } = this.props; const { value, defaultValue = "" } = this.props;
if (value !== undefined) return value; // controlled input 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; return defaultValue as string;
} }
focus() { focus() {
this.input.focus(); this.inputRef.current.focus();
} }
blur() { blur() {
this.input.blur(); this.inputRef.current.blur();
} }
select() { select() {
this.input.select(); this.inputRef.current.select();
} }
private autoFitHeight() { private autoFitHeight() {
@ -106,7 +109,8 @@ export class Input extends React.Component<InputProps, State> {
if (!multiLine) { if (!multiLine) {
return; return;
} }
const textArea = this.input;
const textArea = this.inputRef.current;
const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight); const lineHeight = parseFloat(window.getComputedStyle(textArea).lineHeight);
const rowsCount = (this.getValue().match(/\n/g) || []).length + 1; const rowsCount = (this.getValue().match(/\n/g) || []).length + 1;
const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows); const height = lineHeight * Math.min(Math.max(rowsCount, rows), maxRows);
@ -114,117 +118,62 @@ export class Input extends React.Component<InputProps, State> {
textArea.style.height = `${height}px`; textArea.style.height = `${height}px`;
} }
private validationId: string; @action
runValidatorsRaw() {
async validate(value = this.getValue()) { this.errors = [];
let validationId = (this.validationId = ""); // reset every time for async validators const value = this.getValue();
const asyncValidators: Promise<any>[] = [];
const errors: React.ReactNode[] = [];
// run validators // run validators
for (const validator of this.validators) { for (const validator of this.validators) {
if (errors.length) { const isValid = validator.validate(value, this.props);
// stop validation check if there is an error already
break;
}
const result = validator.validate(value, this.props);
if (isBoolean(result) && !result) { if (!isValid) {
errors.push(this.getValidatorError(value, validator)); if (typeof validator.message === "function") {
} else if (result instanceof Promise) { this.errors.push(validator.message(value, this.props));
if (!validationId) { } else {
this.validationId = validationId = uniqueId("validation_id_"); 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.inputRef.current.setCustomValidity(this.errors.length ? this.errors[0].toString() : "");
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() : "");
} }
setValidation(errors: React.ReactNode[]) { runValidators = debounce(() => this.runValidatorsRaw(), 500, {
this.setState({ trailing: true,
validating: false, leading: false,
valid: !errors.length, });
errors,
});
}
private getValidatorError(value: string, { message }: InputValidator) { validate() {
if (isFunction(message)) return message(value, this.props); this.errors = [];
this.runValidators();
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 });
} }
@autobind() @autobind()
onFocus(evt: React.FocusEvent<InputElement>) { onFocus(evt: React.FocusEvent<InputElement>) {
const { onFocus, autoSelectOnFocus } = this.props; const { onFocus, autoSelectOnFocus } = this.props;
if (onFocus) onFocus(evt); onFocus?.(evt);
if (autoSelectOnFocus) this.select();
this.setState({ focused: true }); if (autoSelectOnFocus) {
this.select();
}
this.focused = true;
} }
@autobind() @autobind()
onBlur(evt: React.FocusEvent<InputElement>) { onBlur(evt: React.FocusEvent<InputElement>) {
const { onBlur } = this.props; this.props.onBlur?.(evt);
this.focused = false;
if (onBlur) onBlur(evt);
if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false });
this.setState({ focused: false });
} }
@autobind() @autobind()
onChange(evt: React.ChangeEvent<any>) { onChange(evt: React.ChangeEvent<InputElement>) {
if (this.props.onChange) { this.props.onChange?.(evt.currentTarget.value, evt);
this.props.onChange(evt.currentTarget.value, evt);
}
this.validate(); this.validate();
this.autoFitHeight(); 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 // re-render component when used as uncontrolled input
// when used @defaultValue instead of @value changing real input.value doesn't call render() // when used @defaultValue instead of @value changing real input.value doesn't call render()
if (this.isUncontrolled && this.showMaxLenIndicator) { if (this.isUncontrolled && this.showMaxLenIndicator) {
@ -233,19 +182,17 @@ export class Input extends React.Component<InputProps, State> {
} }
@autobind() @autobind()
onKeyDown(evt: React.KeyboardEvent<any>) { onKeyDown(evt: React.KeyboardEvent<InputElement>) {
this.props.onKeyDown?.(evt);
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
if (this.props.onKeyDown) { if (!modified && evt.key === "Enter") {
this.props.onKeyDown(evt); this.runValidatorsRaw();
}
switch (evt.key) { if (this.isValid) {
case "Enter": this.props.onSubmit?.(this.getValue());
if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { }
this.props.onSubmit(this.getValue());
}
break;
} }
} }
@ -259,68 +206,33 @@ export class Input extends React.Component<InputProps, State> {
return this.props.value === undefined; 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() { render() {
const { const {
multiLine, showValidationLine, 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,
dirty: _dirty, // excluded from passing to input-element onChange, onSubmit, showErrorInitially, ...inputPropsRaw
...inputProps
} = this.props; } = this.props;
const { focused, dirty, valid, validating, errors } = this.state;
const className = cssNames("Input", this.props.className, { const className = cssNames("Input", this.props.className, {
[`theme ${theme}`]: theme, [`theme ${theme}`]: theme,
focused, focused: this.focused,
disabled, disabled,
invalid: !valid, invalid: !this.isValid,
dirty, dirty: this.dirty,
validating,
validatingLine: validating && showValidationLine,
}); });
const inputProps: InputElementProps = {
// prepare input props ...inputPropsRaw,
Object.assign(inputProps, {
className: "input box grow", className: "input box grow",
onFocus: this.onFocus, onFocus: this.onFocus,
onBlur: this.onBlur, onBlur: this.onBlur,
onChange: this.onChange, onChange: this.onChange,
onKeyDown: this.onKeyDown, onKeyDown: this.onKeyDown,
rows: multiLine ? (rows || 1) : null,
ref: this.bindRef,
spellCheck: "false", spellCheck: "false",
disabled, disabled,
}); };
const showErrors = errors.length > 0 && !valid && dirty; const showErrors = this.errors.length > 0;
const errorsInfo = ( const errorsInfo = (
<div className="errors box grow"> <div className="errors box grow">
{errors.map((error, i) => <p key={i}>{error}</p>)} {this.errors.map((error, i) => <p key={i}>{error}</p>)}
</div> </div>
); );
const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
@ -345,7 +257,18 @@ export class Input extends React.Component<InputProps, State> {
{tooltipError} {tooltipError}
<label className="input-area flex gaps align-center" id=""> <label className="input-area flex gaps align-center" id="">
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft} {isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />} {
multiLine
? <textarea
ref={this.inputRef as React.RefObject<HTMLTextAreaElement>}
rows={rows || 1}
{...inputProps}
/>
: <input
ref={this.inputRef as React.RefObject<HTMLInputElement>}
{...inputProps}
/>
}
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight} {isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
{contentRight} {contentRight}
</label> </label>

View File

@ -3,27 +3,26 @@ import { ReactNode } from "react";
import fse from "fs-extra"; import fse from "fs-extra";
export interface InputValidator { export interface InputValidator {
debounce?: number; // debounce for async validators in ms
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: ReactNode | ((value: string, props?: InputProps) => ReactNode | string);
validate(value: string, props?: InputProps): boolean | Promise<any>; // promise can throw error message validate(value: string, props?: InputProps): 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",
validate: value => !!value.trim(), validate: value => !!value.trim(),
}; };
export const isEmail: InputValidator = { export const isEmail: InputValidator = {
condition: ({ type }) => type === "email", condition: ({ type }) => type === "email",
message: () => `Wrong email format`, message: "Must be an email",
validate: value => !!value.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/), validate: value => !!value.match(/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/),
}; };
export const isNumber: InputValidator = { export const isNumber: InputValidator = {
condition: ({ type }) => type === "number", condition: ({ type }) => type === "number",
message: () => `Invalid number`, message: "Must be a number",
validate: (value, { min, max }) => { validate: (value, { min, max }) => {
const numVal = +value; const numVal = +value;
@ -37,7 +36,7 @@ export const isNumber: InputValidator = {
export const isUrl: InputValidator = { export const isUrl: InputValidator = {
condition: ({ type }) => type === "url", condition: ({ type }) => type === "url",
message: () => `Wrong url format`, message: "Must be a valid URL",
validate: value => { validate: value => {
try { try {
return Boolean(new URL(value)); return Boolean(new URL(value));
@ -51,13 +50,13 @@ export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<ver
export const isExtensionNameInstall: InputValidator = { export const isExtensionNameInstall: InputValidator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: () => "Not an extension name with optional version", message: "Not an extension name with optional version",
validate: value => value.match(isExtensionNameInstallRegex) !== null, validate: value => value.match(isExtensionNameInstallRegex) !== null,
}; };
export const isPath: InputValidator = { export const isPath: InputValidator = {
condition: ({ type }) => type === "text", condition: ({ type }) => type === "text",
message: () => `This field must be a valid path`, message: "This field must be a path to an existing file.",
validate: value => value && fse.pathExistsSync(value), validate: value => value && fse.pathExistsSync(value),
}; };
@ -76,12 +75,17 @@ export const maxLength: InputValidator = {
const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
export const systemName: InputValidator = { export const systemName: InputValidator = {
message: () => `A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`, message: "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.",
validate: value => !!value.match(systemNameMatcher),
};
export const namespaceValue: InputValidator = {
message: "A Namespace must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.",
validate: value => !!value.match(systemNameMatcher), validate: value => !!value.match(systemNameMatcher),
}; };
export const accountId: InputValidator = { export const accountId: InputValidator = {
message: () => `Invalid account ID`, message: "Invalid account ID",
validate: value => (isEmail.validate(value) || systemName.validate(value)) validate: value => (isEmail.validate(value) || systemName.validate(value))
}; };