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