mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
- Added logging by default for non-CI environments running integration tests - Increasing the timeout on integration tests so that spectron is more likely to error out instead (should help with debugging tests) Signed-off-by: Sebastian Malton <sebastian@malton.name>
343 lines
9.9 KiB
TypeScript
343 lines
9.9 KiB
TypeScript
import "./input.scss";
|
|
|
|
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
import { autobind, cssNames, getRandId } from "../../utils";
|
|
import { Icon } from "../icon";
|
|
import { Tooltip, TooltipProps } from "../tooltip";
|
|
import * as Validators 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";
|
|
import { observer } from "mobx-react";
|
|
|
|
const { conditionalValidators, ...InputValidators } = Validators;
|
|
|
|
export { InputValidators, InputValidator, AsyncInputValidator };
|
|
|
|
type InputElement = HTMLInputElement | HTMLTextAreaElement;
|
|
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
|
|
|
|
export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSubmit"> & {
|
|
theme?: "round-black";
|
|
className?: string;
|
|
value?: T;
|
|
autoSelectOnFocus?: boolean
|
|
multiLine?: boolean; // use text-area as input field
|
|
maxRows?: number; // when multiLine={true} define max rows size
|
|
showErrorInitially?: boolean; // show validation errors even if the field wasn't touched yet
|
|
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
|
|
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;
|
|
};
|
|
|
|
const defaultProps: Partial<InputProps> = {
|
|
rows: 1,
|
|
maxRows: 10000,
|
|
validators: [],
|
|
showErrorInitially: false,
|
|
};
|
|
|
|
@observer
|
|
export class Input extends React.Component<InputProps> {
|
|
static defaultProps = defaultProps as object;
|
|
|
|
inputRef = React.createRef<InputElement>();
|
|
validators = [
|
|
...conditionalValidators.filter(({ condition }) => condition(this.props)),
|
|
...[this.props.validators],
|
|
]
|
|
.flat()
|
|
.filter(Boolean);
|
|
asyncValidators = [
|
|
this.props.asyncValidators
|
|
]
|
|
.flat()
|
|
.filter(Boolean);
|
|
|
|
@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() {
|
|
if (this.props.showErrorInitially) {
|
|
this.runValidatorsRaw(this.getValue());
|
|
}
|
|
|
|
this.autoFitHeight();
|
|
}
|
|
|
|
setValue(value: string) {
|
|
if (value !== this.getValue()) {
|
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.inputRef.constructor.prototype, "value").set;
|
|
|
|
nativeInputValueSetter.call(this.inputRef, value);
|
|
const evt = new Event("input", { bubbles: true });
|
|
|
|
this.inputRef.current.dispatchEvent(evt);
|
|
}
|
|
}
|
|
|
|
getValue(): string {
|
|
const { value, defaultValue = "" } = this.props;
|
|
|
|
if (value !== undefined) return value; // controlled input
|
|
if (this.inputRef) return this.inputRef.current.value; // uncontrolled input
|
|
|
|
return defaultValue as string;
|
|
}
|
|
|
|
focus() {
|
|
this.inputRef.current.focus();
|
|
}
|
|
|
|
blur() {
|
|
this.inputRef.current.blur();
|
|
}
|
|
|
|
select() {
|
|
this.inputRef.current.select();
|
|
}
|
|
|
|
private autoFitHeight() {
|
|
const { multiLine, rows, maxRows } = this.props;
|
|
|
|
if (!multiLine) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
|
|
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(value: string) {
|
|
this.errors = [];
|
|
|
|
// run validators
|
|
for (const validator of this.validators) {
|
|
const isValid = validator.validate(value, this.props);
|
|
|
|
if (!isValid) {
|
|
if (typeof validator.message === "function") {
|
|
this.errors.push(validator.message(value, this.props));
|
|
} else {
|
|
this.errors.push(validator.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.inputRef.current.setCustomValidity(this.errors.length ? this.errors[0].toString() : "");
|
|
}
|
|
|
|
runValidators = debounce(() => this.runValidatorsRaw(this.getValue()), 500, {
|
|
trailing: true,
|
|
leading: false,
|
|
});
|
|
|
|
validate() {
|
|
this.errors = [];
|
|
this.runValidators();
|
|
}
|
|
|
|
@autobind()
|
|
onFocus(evt: React.FocusEvent<InputElement>) {
|
|
const { onFocus, autoSelectOnFocus } = this.props;
|
|
|
|
onFocus?.(evt);
|
|
|
|
if (autoSelectOnFocus) {
|
|
this.select();
|
|
}
|
|
|
|
this.focused = true;
|
|
}
|
|
|
|
@autobind()
|
|
onBlur(evt: React.FocusEvent<InputElement>) {
|
|
this.props.onBlur?.(evt);
|
|
this.focused = false;
|
|
}
|
|
|
|
@autobind()
|
|
onChange(evt: React.ChangeEvent<InputElement>) {
|
|
this.props.onChange?.(evt.currentTarget.value, evt);
|
|
this.validate();
|
|
this.autoFitHeight();
|
|
|
|
// 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) {
|
|
this.forceUpdate();
|
|
}
|
|
}
|
|
|
|
@autobind()
|
|
onKeyDown(evt: React.KeyboardEvent<InputElement>) {
|
|
this.props.onKeyDown?.(evt);
|
|
|
|
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
|
|
|
|
if (!modified && evt.key === "Enter") {
|
|
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) {
|
|
this.props.onSubmit?.(value);
|
|
}
|
|
|
|
this.isSubmitting = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
get showMaxLenIndicator() {
|
|
const { maxLength, multiLine } = this.props;
|
|
|
|
return maxLength && multiLine;
|
|
}
|
|
|
|
get isUncontrolled() {
|
|
return this.props.value === undefined;
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
multiLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
|
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
|
|
onChange, onSubmit, asyncValidators, showErrorInitially, ...inputPropsRaw
|
|
} = this.props;
|
|
const className = cssNames("Input", this.props.className, {
|
|
[`theme ${theme}`]: theme,
|
|
focused: this.focused,
|
|
disabled,
|
|
invalid: !this.isValid,
|
|
dirty: this.dirty,
|
|
waiting: this.asyncValidating,
|
|
});
|
|
const inputProps: InputElementProps = {
|
|
...inputPropsRaw,
|
|
className: "input box grow",
|
|
onFocus: this.onFocus,
|
|
onBlur: this.onBlur,
|
|
onChange: this.onChange,
|
|
onKeyDown: this.onKeyDown,
|
|
spellCheck: "false",
|
|
disabled: disabled || this.isSubmitting,
|
|
};
|
|
const showErrors = this.errors.length > 0;
|
|
const errorsInfo = (
|
|
<div className="errors box grow">
|
|
{this.errors.map((error, i) => <p key={i}>{error}</p>)}
|
|
</div>
|
|
);
|
|
const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
|
let tooltipError: React.ReactNode;
|
|
|
|
if (showErrorsAsTooltip && showErrors) {
|
|
const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {};
|
|
|
|
tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className);
|
|
tooltipError = (
|
|
<Tooltip targetId={componentId} {...tooltipProps}>
|
|
<div className="flex gaps align-center">
|
|
<Icon material="error_outline"/>
|
|
{errorsInfo}
|
|
</div>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div id={componentId} className={className}>
|
|
{tooltipError}
|
|
<label className="input-area flex gaps align-center" id="">
|
|
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
|
{
|
|
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}
|
|
{contentRight}
|
|
</label>
|
|
<div className="input-info flex gaps">
|
|
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
|
{this.showMaxLenIndicator && (
|
|
<div className="maxLengthIndicator box right">
|
|
{this.getValue().length} / {maxLength}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|