feat(frontend): add TextInput component

This commit is contained in:
Marc 2023-12-26 19:46:24 -05:00
parent b531495b95
commit d78c90d654
Signed by: marc
GPG key ID: 048E042F22B5DC79
2 changed files with 220 additions and 0 deletions

View file

@ -0,0 +1,157 @@
import React from "react"
import { screen, render, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import TextInput, { type TextInputProps } from "."
const defaultProps: TextInputProps = {
ariaLabel: "input element",
errorText: "",
label: "",
onChange: () => {},
validate: () => true,
value: undefined,
}
/*
* Since TextInput is controlled, this allows rendering it
* with a stateful wrapper to simulate how the component would
* behave in the wild.
*/
function renderComponent(props?: Partial<TextInputProps>) {
const propsWithDefaults = {
...defaultProps,
...(props ?? {}),
}
const TextInputWithWrapper = () => {
const [value, setValue] = React.useState<string>()
const onChange = (newValue: string) => {
propsWithDefaults.onChange(newValue)
setValue(newValue)
}
return (
<TextInput {...propsWithDefaults} onChange={onChange} value={value} />
)
}
return {
...render(<TextInputWithWrapper />),
user: userEvent.setup(),
}
}
describe("TextInput", () => {
it("runs the provided onChange on input", async () => {
const mockOnChange = jest.fn()
const mockInput = "testinput"
const { user } = renderComponent({ onChange: mockOnChange })
const inputElement = screen.getByLabelText("input element")
await user.type(inputElement, mockInput)
await waitFor(() => expect(mockOnChange).toHaveBeenCalledWith(mockInput))
})
it("attaches the given ariaLabel to the input element", () => {
const { user } = renderComponent({
ariaLabel: "testlabel",
})
expect(screen.queryByLabelText("testlabel")).toBeInTheDocument()
})
it("displays an error message if the field validation fails", async () => {
const mockInput = "thisisamockinput"
const mockErrorText = "thisinputiserroneous"
const mockValidation = (value: string) => false
const { user } = renderComponent({
validate: mockValidation,
errorText: mockErrorText,
})
const inputElement = screen.getByLabelText("input element")
user.type(inputElement, mockInput)
await waitFor(() =>
expect(inputElement.getAttribute("value")).toEqual(mockInput),
)
expect(screen.getByText(mockErrorText)).toBeInTheDocument()
})
it("removes the error text when validation errors are corrected", async () => {
const mockErrorText = "thisinputiserroneous"
// Valid: more than two characters.
const mockValidation = (value: string) => value.length > 2
const { user } = renderComponent({
validate: mockValidation,
errorText: mockErrorText,
})
const inputElement = screen.getByLabelText("input element")
user.type(inputElement, "no")
await waitFor(() =>
expect(inputElement.getAttribute("value")).toEqual("no"),
)
expect(screen.getByText(mockErrorText)).toBeInTheDocument()
// Value now has three character and is valid again.
user.type(inputElement, "n")
await waitFor(() =>
expect(inputElement.getAttribute("value")).toEqual("non"),
)
expect(screen.queryByText(mockErrorText)).not.toBeInTheDocument()
})
it("does not display the error state when initially empty", async () => {
const mockErrorText = "thisinputiserroneous"
// Always invalid.
const mockValidation = (value: string) => false
const { user } = renderComponent({
validate: mockValidation,
errorText: mockErrorText,
})
const inputElement = screen.getByLabelText("input element")
expect(screen.queryByText(mockErrorText)).not.toBeInTheDocument()
})
it("displays the error state when returning to empty state", async () => {
const mockErrorText = "thisinputiserroneous"
const mockValidation = (value: string) => value.length >= 1
const { user } = renderComponent({
validate: mockValidation,
errorText: mockErrorText,
})
const inputElement = screen.getByLabelText("input element")
user.type(inputElement, "t")
await waitFor(() => expect(inputElement.getAttribute("value")).toEqual("t"))
user.type(inputElement, "{backspace}")
await waitFor(() => expect(inputElement.getAttribute("value")).toEqual(""))
expect(screen.queryByText(mockErrorText)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,63 @@
/*
* The TextInput component is a multipurpose text-input component
* with validation.
*
* TextInput is a controlled component and expects to be provided with
* a current value and state update function (onChange).
*/
import React from "react"
import FormControl from "@mui/material/FormControl"
import TextField from "@mui/material/TextField"
import InputLabel from "@mui/material/InputLabel"
import FormHelperText from "@mui/material/FormHelperText"
interface Props {
// Aria label applied to the input element.
ariaLabel: string
// Text to display if validation fails. Only used if a validation function is provided.
errorText?: string
// Text label visible to the user with the input.
label: string
// Function to run on each field change.
onChange: (value: string) => void
// Optional validation function that decides if the current input state triggers an error.
validate?: (value: string) => boolean
// Input field value
value: string | undefined
}
function TextInput({
ariaLabel,
errorText,
label,
onChange,
validate = () => true,
value,
}: Props) {
const isError = value !== undefined && !validate(value)
const helpText = isError ? <FormHelperText>{errorText}</FormHelperText> : null
return (
<FormControl>
<TextField
label={label}
value={value ?? ""}
onChange={(e) => {
const updatedValue = e.target.value
onChange(updatedValue)
}}
error={isError}
inputProps={{
"aria-label": ariaLabel,
}}
/>
{helpText}
</FormControl>
)
}
export { Props as TextInputProps }
export default TextInput