Merge pull request #70 from mcataford/feat/registration-flow

feat: basic registration flow
This commit is contained in:
Marc 2023-12-27 12:55:17 -05:00 committed by GitHub
commit 339fb115ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 498 additions and 0 deletions

View file

@ -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>

View 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(),
)
},
)
})

View 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

View 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()
})
})

View 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 }

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