1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00
lens/src/renderer/components/input/input.tsx
Iku-turso a277cfcf02
Technical requirements for behavioural unit tests (#5084)
* Implement a lot of technical requirements for behavioural unit tests

Note: the crux of this was to make routing env-agnostic, and not based on URLs as magic strings, but instead something type-enforced.

Note: extension-based routes comply to same exact interface by "late-registering" their routes when installed. Routes are just injectables.

Note: another chunk of global shared state is no more.

Note: a lot of explicit side effects have been cornered to injectables.

Note: a lot of stuff has become reactive as part if this.

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make a directory commonly available

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Require id for <Select /> to prevent non-deterministic renders

This was caused by global state in a 3rd party lib: "react-select".

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Specify id for all <Select /> to satisfy previous commit

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent explicit side effect in component by using existing dependency instead

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Extract instantiation of "conf" as injectables for causing side effects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Introduce a legacy-helper to make gradual refactoring of inheritors of Singleton easier

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Make legacy unit tests for hotbar green and more simple by using the new legacy helper

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Temporarily kludge all unit tests green with a disclaimer about allowing side-effects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove kludge in previous commit by explicitly permitting specific side effects where old unit tests require it

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent old unit test with side effects from accessing file system

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Migrate to actual typing for di.permitSideEffects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent unit tests from failing because of non-standard method of HTML-element not present in js-dom

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Adapt integration tests to recent changes

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix code style

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix artifact from bad rebase

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Add a deprecation from a review comment

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove change that is not required

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant comment

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix code style

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant file

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix bad merge

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Improve variable name

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Tweak logger interface to be more descriptive

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make injecting legacy singleton always provide new instance

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove conditional typing when not needed

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Improve naming of variable

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove unnecessary code style changes

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove flag for causing side effects from too broad scope

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Override side-effects in unit test using injectable instead of monkey patching

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Flag some side-effects and add general overrides

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix unit tests in CI by removing explicit side-effect

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove explicit side-effect from getting default shell

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce abstraction for getting absolute paths

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using abstraction for getting absolute path to control explicit side effect

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce abstraction for joining paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using abstraction for joining paths to control explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix fake implementation for join paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix test after removing explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove explicit side effects from kubeconfig-syncs

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix arguments after removing explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make registrators not async for not being needed anymore

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make generalCatalogEntities non-observable, as there is no requirement

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove redundant code

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Simplify logic for registering general catalog entity sources

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add TODO

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Replace function for getting application menu items with reactive solution

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix typo in interface name

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove global shared state usages of hot bar store

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant enum

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>
2022-03-31 16:57:05 +03:00

429 lines
12 KiB
TypeScript

/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./input.scss";
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { boundMethod, cssNames, debouncePromise, getRandId } from "../../utils";
import { Icon } from "../icon";
import { Tooltip, TooltipProps } from "../tooltip";
import * as Validators from "./input_validators";
import type { InputValidator } from "./input_validators";
import isFunction from "lodash/isFunction";
import isBoolean from "lodash/isBoolean";
import uniqueId from "lodash/uniqueId";
import { debounce } from "lodash";
const { conditionalValidators, ...InputValidators } = Validators;
export { InputValidators };
export type { InputValidator };
type InputElement = HTMLInputElement | HTMLTextAreaElement;
type InputElementProps = InputHTMLAttributes<HTMLInputElement> & TextareaHTMLAttributes<HTMLTextAreaElement> & DOMAttributes<InputElement>;
export interface IconDataFnArg {
isDirty: boolean;
}
/**
* One of the folloing:
* - A material icon name
* - A react node
* - Or a function that produces a react node
*/
export type IconData = string | React.ReactNode | ((opt: IconDataFnArg) => React.ReactNode);
export type InputProps = Omit<InputElementProps, "onChange" | "onSubmit"> & {
theme?: "round-black" | "round";
className?: string;
value?: string;
trim?: boolean;
autoSelectOnFocus?: boolean;
defaultValue?: string;
multiLine?: boolean; // use text-area as input field
maxRows?: number; // when multiLine={true} define max rows size
dirty?: 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)
iconLeft?: IconData;
iconRight?: IconData;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator | InputValidator[];
blurOnEnter?: boolean;
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;
};
interface State {
focused: boolean;
dirty: boolean;
valid: boolean;
validating: boolean;
errors: React.ReactNode[];
submitted: boolean;
}
const defaultProps: Partial<InputProps> = {
rows: 1,
maxRows: 10000,
showValidationLine: true,
validators: [],
blurOnEnter: true,
};
export class Input extends React.Component<InputProps, State> {
static defaultProps = defaultProps as object;
public input: InputElement | null = null;
public validators: InputValidator[] = [];
public state: State = {
focused: false,
valid: true,
validating: false,
dirty: !!this.props.dirty,
errors: [],
submitted: false,
};
componentWillUnmount(): void {
this.setDirtyOnChange.cancel();
}
setValue(value = "") {
if (value !== this.getValue()) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;
nativeInputValueSetter.call(this.input, value);
const evt = new Event("input", { bubbles: true });
this.input.dispatchEvent(evt);
}
}
getValue(): string {
const { trim, value, defaultValue } = this.props;
const rawValue = value ?? this.input?.value ?? defaultValue ?? "";
return trim ? rawValue.trim() : rawValue;
}
focus() {
this.input.focus();
}
blur() {
this.input.blur();
}
select() {
this.input.select();
}
private autoFitHeight() {
const { multiLine, rows, maxRows } = this.props;
if (!multiLine) {
return;
}
const textArea = this.input;
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 validationId: string;
async validate() {
const value = this.getValue();
let validationId = (this.validationId = ""); // reset every time for async validators
const asyncValidators: Promise<any>[] = [];
const errors: React.ReactNode[] = [];
// 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);
if (isBoolean(result) && !result) {
errors.push(this.getValidatorError(value, validator));
} else if (result instanceof Promise) {
if (!validationId) {
this.validationId = validationId = uniqueId("validation_id_");
}
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() : "");
}
setValidation(errors: React.ReactNode[]) {
this.setState({
validating: false,
valid: !errors.length,
errors,
});
}
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) {
this.setState({ dirty });
}
@boundMethod
onFocus(evt: React.FocusEvent<InputElement>) {
const { onFocus, autoSelectOnFocus } = this.props;
onFocus?.(evt);
if (autoSelectOnFocus) this.select();
this.setState({ focused: true });
}
@boundMethod
onBlur(evt: React.FocusEvent<InputElement>) {
this.props.onBlur?.(evt);
this.setState({ focused: false });
}
setDirtyOnChange = debounce(() => this.setDirty(), 500);
@boundMethod
onChange(evt: React.ChangeEvent<any>) {
this.props.onChange?.(evt.currentTarget.value, evt);
this.validate();
this.autoFitHeight();
this.setDirtyOnChange();
// 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();
}
}
@boundMethod
onKeyDown(evt: React.KeyboardEvent<InputElement>) {
this.props.onKeyDown?.(evt);
if (evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey || evt.repeat) {
return;
}
if (evt.key === "Enter") {
if (this.state.valid) {
this.props.onSubmit?.(this.getValue(), evt);
this.setDirtyOnChange.cancel();
this.setState({ submitted: true });
if (this.input && typeof this.props.value !== "string") {
this.input.value = "";
}
} else {
this.setDirty();
}
if(this.props.blurOnEnter){
//pressing enter indicates that the edit is complete, we can unfocus now
this.blur();
}
}
}
get showMaxLenIndicator() {
const { maxLength, multiLine } = this.props;
return maxLength && multiLine;
}
get isUncontrolled() {
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) {
if (!this.state.submitted) {
this.validate();
} else {
this.setState({ submitted: false });
}
this.autoFitHeight();
}
if (prevProps.dirty !== dirty) {
this.setDirty(dirty);
}
if (prevProps.validators !== validators) {
this.setupValidators();
}
}
get themeSelection(): Record<string, boolean> {
const { theme } = this.props;
if (!theme) {
return {};
}
return {
theme: true,
round: true,
black: theme === "round-black",
};
}
@boundMethod
bindRef(elem: InputElement) {
this.input = elem;
}
private renderIcon(iconData: IconData) {
if (typeof iconData === "string") {
return <Icon material={iconData} />;
}
if (typeof iconData === "function") {
return iconData({
isDirty: Boolean(this.getValue()),
});
}
return iconData;
}
render() {
const {
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id,
dirty: _dirty, // excluded from passing to input-element
defaultValue,
trim,
blurOnEnter,
...inputProps
} = this.props;
const { focused, dirty, valid, validating, errors } = this.state;
const className = cssNames("Input", this.props.className, {
...this.themeSelection,
focused,
disabled,
invalid: !valid,
dirty,
validating,
validatingLine: validating && showValidationLine,
});
// prepare input props
Object.assign(inputProps, {
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 errorsInfo = (
<div className="errors box grow">
{errors.map((error, i) => <p key={i}>{error}</p>)}
</div>
);
// TODO: Remove side-effect to allow deterministic unit testing
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="">
{this.renderIcon(iconLeft)}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{this.renderIcon(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>
);
}
}