🤖 Switch InputField + InputFieldWithIcon JSX components to TS, add more test coverage, fix Storybook build (#43307)

Zed + Opus 4.6; prompt: Convert the InputField JSX component to
TypeScript and remove the ts-ignore directives that we no longer need
after doing so.

- [x] Changes file added
- [x] Automated tests updated
This commit is contained in:
Ian Littman
2026-04-09 08:41:48 -05:00
committed by GitHub
parent f829170923
commit 2891904f31
74 changed files with 842 additions and 651 deletions
@@ -1,291 +0,0 @@
import React, { Component } from "react";
import PropTypes from "prop-types";
import classnames from "classnames";
import { noop, pick } from "lodash";
import { stringToClipboard } from "utilities/copy_text";
import FormField from "components/forms/FormField";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
const baseClass = "input-field";
class InputField extends Component {
static propTypes = {
autofocus: PropTypes.bool,
/** readOnly displays a non-editable field */
readOnly: PropTypes.bool,
/** disabled displays a greyed out non-editable field */
disabled: PropTypes.bool,
error: PropTypes.string,
inputClassName: PropTypes.string, // eslint-disable-line react/forbid-prop-types
inputWrapperClass: PropTypes.string,
inputOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types
name: PropTypes.string,
onChange: PropTypes.func,
onBlur: PropTypes.func,
onFocus: PropTypes.func,
placeholder: PropTypes.string,
type: PropTypes.string,
blockAutoComplete: PropTypes.bool,
value: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string,
PropTypes.number,
]).isRequired,
/** Returns both name and value */
parseTarget: PropTypes.bool,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
labelTooltipPosition: PropTypes.string,
helpText: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
PropTypes.object,
]),
/** Use in conjunction with type "password" and enableCopy to see eye icon to view */
enableShowSecret: PropTypes.bool,
enableCopy: PropTypes.bool,
ignore1password: PropTypes.bool,
// Accepts string or number for HTML compatibility, (e.g., step="0.1", step={0.1})
/** Only effective on input type number */
step: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Only effective on input type number */
min: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Only effective on input type number */
max: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
static defaultProps = {
autofocus: false,
inputWrapperClass: "",
inputOptions: {},
label: null,
labelClassName: "",
onFocus: noop,
onBlur: noop,
type: "text",
blockAutoComplete: false,
value: "",
parseTarget: false,
tooltip: "",
labelTooltipPosition: undefined,
helpText: "",
enableCopy: false,
enableShowSecret: false,
ignore1password: false,
step: undefined,
min: undefined,
max: undefined,
};
constructor() {
super();
this.state = {
copied: false,
};
}
componentDidMount() {
const { autofocus } = this.props;
const { input } = this;
if (autofocus) {
input.focus();
}
return false;
}
onInputChange = (evt) => {
evt.preventDefault();
const { value, name } = evt.target;
const { onChange, parseTarget } = this.props;
if (parseTarget) {
// Returns both name and value
return onChange({ value, name });
}
return onChange(value);
};
onToggleSecret = (evt) => {
evt.preventDefault();
this.setState({ showSecret: !this.state.showSecret });
return false;
};
onClickCopy = (e) => {
e.preventDefault();
stringToClipboard(this.props.value).then(() => {
this.setState({ copied: true });
setTimeout(() => {
this.setState({ copied: false });
}, 2000);
});
};
renderShowSecretButton = () => {
const { onToggleSecret } = this;
return (
<Button
variant="icon"
className={`${baseClass}__show-secret-icon`}
onClick={onToggleSecret}
size="small"
>
<Icon name="eye" />
</Button>
);
};
renderCopyButton = () => {
const { onClickCopy } = this;
const copyButtonValue = <Icon name="copy" />;
const wrapperClasses = classnames(`${baseClass}__copy-wrapper`, {
[`${baseClass}__copy-wrapper__text-area`]: this.props.type === "textarea",
});
const copiedConfirmationClasses = classnames(
`${baseClass}__copied-confirmation`
);
return (
<div className={wrapperClasses}>
{this.state.copied && (
<span className={copiedConfirmationClasses}>Copied!</span>
)}
<Button variant="icon" onClick={onClickCopy} size="small" iconStroke>
{copyButtonValue}
</Button>
{this.props.enableShowSecret && this.renderShowSecretButton()}
</div>
);
};
render() {
const {
readOnly,
disabled,
error,
inputClassName,
inputOptions,
inputWrapperClass,
name,
onFocus,
onBlur,
placeholder,
type,
blockAutoComplete,
value,
ignore1password,
enableCopy,
enableShowSecret,
step,
min,
max,
} = this.props;
const { onInputChange } = this;
const shouldShowPasswordClass =
type === "password" && !this.state.showSecret;
const inputClasses = classnames(baseClass, inputClassName, {
[`${baseClass}--password`]: shouldShowPasswordClass,
[`${baseClass}--read-only`]: readOnly || disabled,
[`${baseClass}--disabled`]: disabled,
[`${baseClass}--error`]: error,
[`${baseClass}__textarea`]: type === "textarea",
});
const inputWrapperClasses = classnames(inputWrapperClass, {
[`input-field--read-only`]: readOnly || disabled,
[`input-field--disabled`]: disabled,
});
const formFieldProps = pick(this.props, [
"helpText",
"label",
"error",
"name",
"tooltip",
"labelTooltipPosition",
]);
const inputContainerClasses = classnames(`${baseClass}__input-container`, {
"copy-enabled": enableCopy,
});
if (type === "textarea") {
return (
<FormField
{...formFieldProps}
type="textarea"
className={inputWrapperClasses}
>
<div className={inputContainerClasses}>
<textarea
name={name}
id={name}
onChange={onInputChange}
onBlur={onBlur}
onFocus={onFocus}
className={inputClasses}
disabled={readOnly || disabled}
placeholder={placeholder}
ref={(r) => {
this.input = r;
}}
type={type}
{...inputOptions}
value={value}
/>
{enableCopy && this.renderCopyButton()}
</div>
</FormField>
);
}
const inputType = this.state.showSecret ? "text" : type;
return (
<FormField
{...formFieldProps}
type="input"
className={inputWrapperClasses}
>
<div className={inputContainerClasses}>
<input
disabled={readOnly || disabled}
name={name}
id={name}
onChange={onInputChange}
onFocus={onFocus}
onBlur={onBlur}
className={inputClasses}
placeholder={placeholder}
ref={(r) => {
this.input = r;
}}
type={inputType}
{...inputOptions}
value={value}
autoComplete={blockAutoComplete ? "new-password" : ""}
data-1p-ignore={ignore1password}
step={step}
min={min}
max={max}
/>
{enableCopy && this.renderCopyButton()}
</div>
</FormField>
);
}
}
export default InputField;
@@ -1,124 +0,0 @@
import React from "react";
import InputField from ".";
export default {
component: InputField,
title: "Components/FormFields/InputField",
argTypes: {
type: {
control: "select",
options: ["text", "password", "email", "number", "textarea"],
},
value: {
control: "text",
},
placeholder: {
control: "text",
},
label: {
control: "text",
},
error: {
control: "text",
},
helpText: {
control: "text",
},
disabled: {
control: "boolean",
},
readOnly: {
control: "boolean",
},
autofocus: {
control: "boolean",
},
enableCopy: {
control: "boolean",
},
enableShowSecret: {
control: "boolean",
},
},
};
const Template = (args) => <InputField {...args} />;
export const Basic = Template.bind({});
Basic.args = {
name: "basic-input",
label: "Basic Input",
value: "",
placeholder: "Enter text here",
};
export const WithValue = Template.bind({});
WithValue.args = {
...Basic.args,
value: "Sample text",
};
export const WithError = Template.bind({});
WithError.args = {
...Basic.args,
error: "This field is required",
};
export const Disabled = Template.bind({});
Disabled.args = {
...Basic.args,
disabled: true,
};
export const ReadOnly = Template.bind({});
ReadOnly.args = {
...Basic.args,
readOnly: true,
value: "Read-only content",
};
export const WithHelpText = Template.bind({});
WithHelpText.args = {
...Basic.args,
helpText: "This is some helpful information about the input field.",
};
export const Password = Template.bind({});
Password.args = {
...Basic.args,
type: "password",
label: "Password",
placeholder: "Enter your password",
};
export const Textarea = Template.bind({});
Textarea.args = {
...Basic.args,
type: "textarea",
label: "Text area",
placeholder: "Enter multiple lines of text",
};
export const WithCopyEnabled = Template.bind({});
WithCopyEnabled.args = {
...Basic.args,
enableCopy: true,
value: "This text can be copied",
};
export const WithCopyEnabledInput = Template.bind({});
WithCopyEnabledInput.args = {
...WithCopyEnabled.args,
};
export const WithTooltip = Template.bind({});
WithTooltip.args = {
...Basic.args,
tooltip: "This is a tooltip for the input field",
};
export const AutoFocus = Template.bind({});
AutoFocus.args = {
...Basic.args,
autofocus: true,
};
@@ -1,7 +1,6 @@
import { Meta, StoryObj } from "@storybook/react";
import { action } from "@storybook/addon-actions";
// @ts-ignore
import InputField from ".";
import "../../../../index.scss";
@@ -20,10 +19,6 @@ const meta: Meta<typeof InputField> = {
blockAutoComplete: { control: "boolean" },
enableCopy: { control: "boolean" },
enableShowSecret: { control: "boolean" },
copyButtonPosition: {
control: "radio",
options: ["inside", "outside"],
},
labelTooltipPosition: {
control: "select",
options: ["top", "right", "bottom", "left"],
@@ -47,6 +42,15 @@ export const Default: Story = {
},
};
export const WithValue: Story = {
args: {
...Default.args,
name: "with-value-input",
label: "Input with Value",
value: "Sample text",
},
};
export const WithError: Story = {
args: {
...Default.args,
@@ -116,6 +120,15 @@ export const WithCopyButton: Story = {
},
};
export const AutoFocus: Story = {
args: {
...Default.args,
name: "autofocus-input",
label: "Autofocus Input",
autofocus: true,
},
};
export const Textarea: Story = {
args: {
...Default.args,
@@ -1,8 +1,8 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithSetup } from "test/test-utils";
// @ts-ignore
import InputField from "./InputField";
describe("InputField Component", () => {
@@ -161,4 +161,208 @@ describe("InputField Component", () => {
expect(screen.getByPlaceholderText(/enter text/i)).toBeDisabled();
});
test("calls onChange with { name, value } when parseTarget is true", async () => {
render(
<InputField
value=""
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="my-field"
parseTarget
/>
);
await userEvent.type(screen.getByPlaceholderText(/enter text/i), "A");
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith({
name: "my-field",
value: "A",
});
});
test("renders as read-only when readOnly prop is true", () => {
render(
<InputField
value="read only value"
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
readOnly
/>
);
expect(screen.getByPlaceholderText(/enter text/i)).toBeDisabled();
});
test("auto-focuses the input when autofocus is true", () => {
render(
<InputField
value=""
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
autofocus
/>
);
expect(screen.getByPlaceholderText(/enter text/i)).toHaveFocus();
});
test("sets autocomplete to 'new-password' when blockAutoComplete is true", () => {
render(
<InputField
value=""
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
blockAutoComplete
/>
);
expect(screen.getByPlaceholderText(/enter text/i)).toHaveAttribute(
"autocomplete",
"new-password"
);
});
test("sets data-1p-ignore when ignore1password is true", () => {
render(
<InputField
value=""
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
ignore1password
/>
);
expect(screen.getByPlaceholderText(/enter text/i)).toHaveAttribute(
"data-1p-ignore",
"true"
);
});
test("renders tooltip on label hover", async () => {
const { user } = renderWithSetup(
<InputField
value=""
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
tooltip="Helpful tooltip text"
/>
);
await user.hover(screen.getByText(/test input/i));
await waitFor(() => {
expect(screen.getByText("Helpful tooltip text")).toBeInTheDocument();
});
});
test("sets step, min, and max attributes on number input", () => {
render(
<InputField
value={5}
onChange={mockOnChange}
label="Number Input"
placeholder="Enter number"
name="test-number"
type="number"
step={0.5}
min={0}
max={100}
/>
);
const input = screen.getByPlaceholderText(/enter number/i);
expect(input).toHaveAttribute("step", "0.5");
expect(input).toHaveAttribute("min", "0");
expect(input).toHaveAttribute("max", "100");
});
test("copies value to clipboard when copy button is clicked", async () => {
const writeTextMock = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
writable: true,
configurable: true,
});
render(
<InputField
value="Copy me"
onChange={mockOnChange}
label="Test Input"
placeholder="Enter text"
name="test-input"
enableCopy
/>
);
const copyButton = screen.getByTestId("copy-icon").closest("button")!;
await userEvent.click(copyButton);
expect(writeTextMock).toHaveBeenCalledWith("Copy me");
await waitFor(() => {
expect(screen.getByText("Copied!")).toBeInTheDocument();
});
});
test("renders show-secret eye toggle with enableCopy and enableShowSecret on password field", async () => {
render(
<InputField
value="s3cret"
onChange={mockOnChange}
label="Password"
placeholder="Enter password"
name="test-password"
type="password"
enableCopy
enableShowSecret
/>
);
// The eye icon should be present
const eyeIcon = screen.getByTestId("eye-icon");
expect(eyeIcon).toBeInTheDocument();
// Initially the input type should be password
const input = screen.getByPlaceholderText(/enter password/i);
expect(input).toHaveAttribute("type", "password");
// Click the eye toggle to reveal the secret
const eyeButton = eyeIcon.closest("button")!;
await userEvent.click(eyeButton);
// After toggling, the input type should be text
expect(input).toHaveAttribute("type", "text");
// Click again to hide
await userEvent.click(eyeButton);
expect(input).toHaveAttribute("type", "password");
});
test("renders copy button in textarea mode when enableCopy is true", () => {
render(
<InputField
value="Textarea content"
onChange={mockOnChange}
label="Test Textarea"
placeholder="Enter text"
name="test-textarea"
type="textarea"
enableCopy
/>
);
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
});
});
@@ -0,0 +1,257 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import classnames from "classnames";
import { PlacesType } from "react-tooltip-5";
import { stringToClipboard } from "utilities/copy_text";
import FormField from "components/forms/FormField";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
const baseClass = "input-field";
export interface IInputFieldProps {
autofocus?: boolean;
/** readOnly displays a non-editable field */
readOnly?: boolean;
/** disabled displays a greyed out non-editable field */
disabled?: boolean;
error?: string | null;
inputClassName?: string;
inputWrapperClass?: string;
inputOptions?: React.InputHTMLAttributes<HTMLInputElement>;
name?: string;
/**
* Receives the field value (string) by default, or { name, value } when
* parseTarget is true. See IInputFieldParseTarget and InputFieldOnChange
* in interfaces/form_field.ts for caller-side typing helpers.
*/
onChange?: (value: any) => void;
onBlur?: (
evt: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => void;
onFocus?: (
evt: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => void;
placeholder?: string;
type?: string;
blockAutoComplete?: boolean;
value?: boolean | string | number | null;
/** Returns both name and value */
parseTarget?: boolean;
tooltip?: React.ReactNode;
labelTooltipPosition?: PlacesType;
label?: React.ReactNode;
labelClassName?: string;
helpText?: React.ReactNode;
/** Use in conjunction with type "password" and enableCopy to see eye icon to view */
enableShowSecret?: boolean;
enableCopy?: boolean;
ignore1password?: boolean;
/** Only effective on input type number */
step?: string | number;
/** Only effective on input type number */
min?: string | number;
/** Only effective on input type number */
max?: string | number;
}
const InputField = ({
autofocus = false,
readOnly,
disabled,
error,
inputClassName,
inputWrapperClass = "",
inputOptions = {},
name,
onChange,
onBlur,
onFocus,
placeholder,
type = "text",
blockAutoComplete = false,
value = "",
parseTarget = false,
tooltip = "",
labelTooltipPosition,
label = null,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
labelClassName: _labelClassName = "",
helpText = "",
enableShowSecret = false,
enableCopy = false,
ignore1password = false,
step,
min,
max,
}: IInputFieldProps): JSX.Element => {
const [copied, setCopied] = useState(false);
const [showSecret, setShowSecret] = useState(false);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
useEffect(() => {
if (autofocus && inputRef.current) {
(inputRef.current as HTMLElement).focus();
}
}, [autofocus]);
const onInputChange = useCallback(
(evt: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
evt.preventDefault();
const target = evt.target as HTMLInputElement;
const { value: inputValue, name: inputName } = target;
if (parseTarget) {
// Returns both name and value
return onChange?.({ value: inputValue, name: inputName });
}
return onChange?.(inputValue);
},
[onChange, parseTarget]
);
const onToggleSecret = useCallback((evt: React.MouseEvent) => {
evt.preventDefault();
setShowSecret((prev) => !prev);
}, []);
const onClickCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
stringToClipboard(value).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
});
},
[value]
);
const renderShowSecretButton = () => {
return (
<Button
variant="icon"
className={`${baseClass}__show-secret-icon`}
onClick={onToggleSecret}
size="small"
>
<Icon name="eye" />
</Button>
);
};
const renderCopyButton = () => {
const copyButtonValue = <Icon name="copy" />;
const wrapperClasses = classnames(`${baseClass}__copy-wrapper`, {
[`${baseClass}__copy-wrapper__text-area`]: type === "textarea",
});
const copiedConfirmationClasses = classnames(
`${baseClass}__copied-confirmation`
);
return (
<div className={wrapperClasses}>
{copied && <span className={copiedConfirmationClasses}>Copied!</span>}
<Button variant="icon" onClick={onClickCopy} size="small" iconStroke>
{copyButtonValue}
</Button>
{enableShowSecret && renderShowSecretButton()}
</div>
);
};
const shouldShowPasswordClass = type === "password" && !showSecret;
const inputClasses = classnames(baseClass, inputClassName, {
[`${baseClass}--password`]: shouldShowPasswordClass,
[`${baseClass}--read-only`]: readOnly || disabled,
[`${baseClass}--disabled`]: disabled,
[`${baseClass}--error`]: !!error,
[`${baseClass}__textarea`]: type === "textarea",
});
const inputWrapperClasses = classnames(inputWrapperClass, {
[`input-field--read-only`]: readOnly || disabled,
[`input-field--disabled`]: disabled,
});
const formFieldProps = {
helpText,
label,
error,
name: name ?? "",
tooltip,
labelTooltipPosition,
};
const inputContainerClasses = classnames(`${baseClass}__input-container`, {
"copy-enabled": enableCopy,
});
if (type === "textarea") {
return (
<FormField
{...formFieldProps}
type="textarea"
className={inputWrapperClasses}
>
<div className={inputContainerClasses}>
<textarea
name={name}
id={name}
onChange={onInputChange}
onBlur={onBlur}
onFocus={onFocus}
className={inputClasses}
disabled={readOnly || disabled}
placeholder={placeholder}
ref={(r) => {
inputRef.current = r;
}}
{...(inputOptions as React.TextareaHTMLAttributes<HTMLTextAreaElement>)}
value={value as string | number}
/>
{enableCopy && renderCopyButton()}
</div>
</FormField>
);
}
const inputType = showSecret ? "text" : type;
return (
<FormField {...formFieldProps} type="input" className={inputWrapperClasses}>
<div className={inputContainerClasses}>
<input
disabled={readOnly || disabled}
name={name}
id={name}
onChange={onInputChange}
onFocus={onFocus}
onBlur={onBlur}
className={inputClasses}
placeholder={placeholder}
ref={(r) => {
inputRef.current = r;
}}
type={inputType}
{...inputOptions}
value={value as string | number}
autoComplete={blockAutoComplete ? "new-password" : ""}
data-1p-ignore={ignore1password}
step={step}
min={min}
max={max}
/>
{enableCopy && renderCopyButton()}
</div>
</FormField>
);
};
export default InputField;