Merge pull request #70 from mcataford/feat/registration-flow
feat: basic registration flow
This commit is contained in:
commit
339fb115ed
7 changed files with 498 additions and 0 deletions
|
@ -13,6 +13,7 @@ import { useOwnFileList } from "./hooks/files"
|
|||
import { Router, Route } from "./router"
|
||||
|
||||
import FileListView from "./components/FileListView"
|
||||
import RegisterView from "./components/RegisterView"
|
||||
|
||||
const routeLabels = {
|
||||
ITEM_DETAILS: "item-details",
|
||||
|
@ -39,6 +40,9 @@ const App = () => {
|
|||
<Route path="/item/:itemId">
|
||||
<FileListView />
|
||||
</Route>
|
||||
<Route path="/register">
|
||||
<RegisterView />
|
||||
</Route>
|
||||
</Router>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
110
frontend/src/components/RegisterView/RegisterView.test.tsx
Normal file
110
frontend/src/components/RegisterView/RegisterView.test.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { screen, render, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
|
||||
import AxiosMockAdapter from "axios-mock-adapter"
|
||||
|
||||
import axios from "../../axios"
|
||||
import RegisterView from "."
|
||||
|
||||
function renderComponent() {
|
||||
return {
|
||||
...render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RegisterView />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
user: userEvent.setup(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("RegisterView", () => {
|
||||
it("renders form fields", () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByLabelText("Email")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText("New account email address"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByLabelText("Password")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText("New account password input"),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a submission button", () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("submit account registration"),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText("Create account")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("sends a request to the account creation API on submission", async () => {
|
||||
const axiosMockAdapter = new AxiosMockAdapter(axios)
|
||||
|
||||
axiosMockAdapter
|
||||
.onPost("/auth/user/")
|
||||
.reply(201, { username: "test", id: 1 })
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const testInput = {
|
||||
username: "test@domain.com",
|
||||
password: "password",
|
||||
}
|
||||
|
||||
const emailInput = screen.getByLabelText("New account email address")
|
||||
await user.type(emailInput, testInput.username)
|
||||
|
||||
const passwordInput = screen.getByLabelText("New account password input")
|
||||
await user.type(passwordInput, testInput.password)
|
||||
|
||||
const submitButton = screen.getByText("Create account")
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(axiosMockAdapter.history.post.length).toEqual(1)
|
||||
|
||||
const requestBody = JSON.parse(axiosMockAdapter.history.post[0].data)
|
||||
|
||||
expect(requestBody).toEqual(testInput)
|
||||
})
|
||||
|
||||
it.each`
|
||||
scenario | emailAddress | password
|
||||
${"no email value"} | ${undefined} | ${"password"}
|
||||
${"no password value"} | ${"email@email.com"} | ${undefined}
|
||||
${"empty form"} | ${undefined} | ${undefined}
|
||||
`(
|
||||
"the submission button is disabled if the form isn't validated ($scenario)",
|
||||
async ({ emailAddress, password }) => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
if (emailAddress !== undefined) {
|
||||
const emailInput = screen.getByLabelText("New account email address")
|
||||
await user.type(emailInput, emailAddress)
|
||||
await waitFor(() =>
|
||||
expect(emailInput.getAttribute("value")).toEqual(emailAddress),
|
||||
)
|
||||
}
|
||||
if (password !== undefined) {
|
||||
const passwordInput = screen.getByLabelText(
|
||||
"New account password input",
|
||||
)
|
||||
|
||||
await user.type(passwordInput, password)
|
||||
await waitFor(() =>
|
||||
expect(passwordInput.getAttribute("value")).toEqual(password),
|
||||
)
|
||||
}
|
||||
|
||||
const submitButton = screen.getByText("Create account")
|
||||
|
||||
await waitFor(() =>
|
||||
expect(submitButton.getAttribute("disabled")).not.toBeUndefined(),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
97
frontend/src/components/RegisterView/index.tsx
Normal file
97
frontend/src/components/RegisterView/index.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import React from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
|
||||
import Typography from "@mui/material/Typography"
|
||||
import Box from "@mui/material/Box"
|
||||
import FormGroup from "@mui/material/FormGroup"
|
||||
import FormControl from "@mui/material/FormControl"
|
||||
import TextField from "@mui/material/TextField"
|
||||
import InputLabel from "@mui/material/InputLabel"
|
||||
import FormHelperText from "@mui/material/FormHelperText"
|
||||
import Button from "@mui/material/Button"
|
||||
|
||||
import axiosWithDefaults from "../../axios"
|
||||
import TextInput from "../TextInput"
|
||||
import { validateEmail, validatePassword } from "./validation"
|
||||
|
||||
function RegisterView() {
|
||||
const [emailAddress, setEmailAddress] = React.useState<string | undefined>()
|
||||
const [password, setPassword] = React.useState<string | undefined>()
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async ({
|
||||
email,
|
||||
password,
|
||||
}: { email: string; password: string }) => {
|
||||
const response = await axiosWithDefaults.post("/auth/user/", {
|
||||
username: email,
|
||||
password,
|
||||
})
|
||||
|
||||
return response
|
||||
},
|
||||
})
|
||||
|
||||
const emailField = React.useMemo(
|
||||
() => (
|
||||
<TextInput
|
||||
errorText={"Enter valid email of the format 'abc@xyz.org'"}
|
||||
label="Email"
|
||||
ariaLabel="New account email address"
|
||||
onChange={setEmailAddress}
|
||||
validate={validateEmail}
|
||||
value={emailAddress}
|
||||
/>
|
||||
),
|
||||
[emailAddress, setEmailAddress, validateEmail],
|
||||
)
|
||||
|
||||
const passwordField = React.useMemo(
|
||||
() => (
|
||||
<TextInput
|
||||
errorText={"A valid password should have between 8-64 characters."}
|
||||
label="Password"
|
||||
ariaLabel="New account password input"
|
||||
onChange={setPassword}
|
||||
validate={validatePassword}
|
||||
value={password}
|
||||
/>
|
||||
),
|
||||
[setPassword, password, validatePassword],
|
||||
)
|
||||
|
||||
const isFormValid = React.useMemo(() => {
|
||||
return validateEmail(emailAddress) && validatePassword(password)
|
||||
}, [emailAddress, password, validatePassword, validateEmail])
|
||||
|
||||
const onCreateClick = React.useCallback(() => {
|
||||
if (!isFormValid) return
|
||||
|
||||
mutate({ email: String(emailAddress), password: String(password) })
|
||||
}, [mutate, emailAddress, password, isFormValid])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", width: "100%" }}>
|
||||
<FormGroup sx={{ flexGrow: 0.1, display: "flex", gap: "10px" }}>
|
||||
<Typography variant="h1" sx={{ fontSize: "2rem" }}>
|
||||
Create an account
|
||||
</Typography>
|
||||
<Typography>
|
||||
{"Fill the form below to create an account and get started!"}
|
||||
</Typography>
|
||||
{emailField}
|
||||
{passwordField}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onCreateClick}
|
||||
aria-label="submit account registration"
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</FormGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterView
|
37
frontend/src/components/RegisterView/validation.test.ts
Normal file
37
frontend/src/components/RegisterView/validation.test.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { validateEmail, validatePassword } from "./validation"
|
||||
|
||||
describe("Email address format validation", () => {
|
||||
it("empty values are not valid", () => {
|
||||
expect(validateEmail("")).toBeFalsy()
|
||||
})
|
||||
|
||||
it.each`
|
||||
scenario | value
|
||||
${"without user"} | ${"@test.com"}
|
||||
${"without @"} | ${"test.com"}
|
||||
${"without domain"} | ${"me@.com"}
|
||||
${"without extension"} | ${"me@domain"}
|
||||
`("missing parts make emails invalid ($scenario)", ({ value }) => {
|
||||
expect(validateEmail(value)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("correctly formatted addresses are valid", () => {
|
||||
expect(validateEmail("me@domain.com")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Password format validation", () => {
|
||||
it("passwords must have at least 8 characters", () => {
|
||||
for (let i = 0; i < 8; i++)
|
||||
expect(validatePassword("a".repeat(i))).toBeFalsy()
|
||||
})
|
||||
|
||||
it("passwords with 8-64 characters are valid", () => {
|
||||
for (let i = 8; i <= 64; i++)
|
||||
expect(validatePassword("a".repeat(i))).toBeTruthy()
|
||||
})
|
||||
|
||||
it("passwords of more than 64 charactrs are invalid", () => {
|
||||
expect(validatePassword("a".repeat(65))).toBeFalsy()
|
||||
})
|
||||
})
|
30
frontend/src/components/RegisterView/validation.ts
Normal file
30
frontend/src/components/RegisterView/validation.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Validates email address formats.
|
||||
*
|
||||
* Addresses are expected of the format:
|
||||
* user@domain.ext
|
||||
*/
|
||||
function validateEmail(value: string | undefined): boolean {
|
||||
return Boolean(
|
||||
value &&
|
||||
(
|
||||
value.match(
|
||||
/^[A-Za-z0-9.-_]+@[A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)*\.[a-zA-Z]+$/g,
|
||||
) ?? []
|
||||
).length === 1,
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* Validates password formats.
|
||||
*
|
||||
* Passwords are enforced to have:
|
||||
* - Between 8 and 64 characters
|
||||
*/
|
||||
function validatePassword(value: string | undefined): boolean {
|
||||
if (!value || value.length < 8 || value.length > 64) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export { validateEmail, validatePassword }
|
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