feat(frontend): add TextInput component
This commit is contained in:
parent
b531495b95
commit
d78c90d654
2 changed files with 220 additions and 0 deletions
157
frontend/src/components/TextInput/TextInput.test.tsx
Normal file
157
frontend/src/components/TextInput/TextInput.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
63
frontend/src/components/TextInput/index.tsx
Normal file
63
frontend/src/components/TextInput/index.tsx
Normal 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
|
Reference in a new issue