Skip to content

Commit c2e728f

Browse files
authored
[Closes #3] Update forms to use Mantine and React Query hooks (#9)
* Switch Login form to useForm * First pass, register/invite form refactor to Mantine useForm * Admin invite form refactor * Refactor forgot password form * Refactor reset password form * Starting rewrite of user form * Support uncontrolled mode on PhotoInput * More form handling refactor, more Mantine and React Query * More useQuery * Tweak * Fix test
1 parent e8c1b57 commit c2e728f

File tree

17 files changed

+1365
-433
lines changed

17 files changed

+1365
-433
lines changed

client/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ yarn-error.log*
77
pnpm-debug.log*
88
lerna-debug.log*
99

10+
.vite
1011
node_modules
1112
dist
1213
dist-ssr

client/src/Admin/Invites/AdminInviteForm.jsx

Lines changed: 25 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useState } from 'react';
21
import { useNavigate } from 'react-router';
32
import { Alert, Button, Container, Fieldset, Group, Stack, Textarea, TextInput, Title } from '@mantine/core';
3+
import { isEmail, isNotEmpty, useForm } from '@mantine/form';
44
import { useMutation } from '@tanstack/react-query';
55
import { Head } from '@unhead/react';
66

@@ -9,75 +9,57 @@ import Api from '../../Api';
99
function AdminInviteForm () {
1010
const navigate = useNavigate();
1111

12-
const [invite, setInvite] = useState({
13-
firstName: '',
14-
lastName: '',
15-
email: '',
16-
message: '',
12+
const form = useForm({
13+
initialValues: {
14+
firstName: '',
15+
lastName: '',
16+
email: '',
17+
message: '',
18+
},
19+
validate: {
20+
firstName: isNotEmpty('First name is required.'),
21+
email: isEmail('Please enter a valid email address.'),
22+
},
1723
});
1824

1925
const onSubmitMutation = useMutation({
20-
mutationFn: () => Api.invites.create(invite),
26+
mutationFn: (values) => Api.invites.create(values),
2127
onSuccess: () => navigate('/admin/invites', { flash: 'Invite sent!' }),
22-
onError: () => window.scrollTo(0, 0),
28+
onError: (errors) => form.setErrors(errors),
29+
onSettled: () => window.scrollTo({ top: 0, behavior: 'smooth' }),
2330
});
2431

25-
function onChange (event) {
26-
const newInvite = { ...invite };
27-
newInvite[event.target.name] = event.target.value;
28-
setInvite(newInvite);
29-
}
30-
31-
function onSubmit (event) {
32-
event.preventDefault();
33-
onSubmitMutation.mutate();
34-
}
35-
3632
return (
3733
<>
3834
<Head>
3935
<title>Invite a new User</title>
4036
</Head>
4137
<Container>
4238
<Title mb='md'>Invite a new User</Title>
43-
<form onSubmit={onSubmit}>
39+
<form onSubmit={form.onSubmit(onSubmitMutation.mutateAsync)}>
4440
<Fieldset variant='unstyled' disabled={onSubmitMutation.isPending}>
4541
<Stack w={{ base: '100%', xs: 320 }}>
46-
{onSubmitMutation.error && onSubmitMutation.error.message && <Alert color='red'>{onSubmitMutation.error.message}</Alert>}
42+
{form.errors._form && <Alert color='red'>{form.errors._form}</Alert>}
4743
<TextInput
44+
{...form.getInputProps('firstName')}
45+
key='firstName'
4846
label='First name'
49-
type='text'
50-
id='firstName'
51-
name='firstName'
52-
onChange={onChange}
53-
value={invite.firstName ?? ''}
54-
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('firstName')}
5547
/>
5648
<TextInput
49+
{...form.getInputProps('lastName')}
50+
key='lastName'
5751
label='Last name'
58-
type='text'
59-
id='lastName'
60-
name='lastName'
61-
onChange={onChange}
62-
value={invite.lastName ?? ''}
63-
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('lastName')}
6452
/>
6553
<TextInput
54+
{...form.getInputProps('email')}
55+
key='email'
6656
label='Email'
6757
type='email'
68-
id='email'
69-
name='email'
70-
onChange={onChange}
71-
value={invite.email ?? ''}
72-
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('email')}
7358
/>
7459
<Textarea
60+
{...form.getInputProps('message')}
61+
key='message'
7562
label='Message'
76-
id='message'
77-
name='message'
78-
onChange={onChange}
79-
value={invite.message ?? ''}
80-
error={onSubmitMutation.error?.errorMessagesHTMLFor?.('message')}
8163
/>
8264
<Group>
8365
<Button type='submit'>

client/src/Api.js

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
/* eslint-disable no-throw-literal */
2+
13
import axios from 'axios';
24

35
import { StatusCodes } from 'http-status-codes';
4-
import UnexpectedError from './UnexpectedError';
5-
import ValidationError from './ValidationError';
6+
import { capitalize } from 'inflection';
67

78
const instance = axios.create({
89
headers: {
@@ -47,12 +48,20 @@ function calculateLastPage (response, page) {
4748
return newLastPage;
4849
}
4950

50-
function handleValidationError (error) {
51+
function handleError (error) {
52+
const errors = {};
5153
if (error.response?.status === StatusCodes.UNPROCESSABLE_ENTITY) {
52-
throw new ValidationError(error.response.data);
54+
for (const err of error.response.data.errors) {
55+
errors[err.path] ||= new Set();
56+
errors[err.path].add(err.message);
57+
}
58+
for (const key of Object.keys(errors)) {
59+
errors[key] = capitalize([...errors[key]].join(', '));
60+
}
5361
} else {
54-
throw new UnexpectedError();
62+
errors._form = error.message;
5563
}
64+
throw errors;
5665
}
5766

5867
const Api = {
@@ -68,21 +77,32 @@ const Api = {
6877
},
6978
auth: {
7079
login (email, password) {
71-
return instance.post('/api/auth/login', { email, password });
80+
return instance.post('/api/auth/login', { email, password })
81+
.catch((error) => {
82+
switch (error.response?.status) {
83+
case StatusCodes.NOT_FOUND:
84+
case StatusCodes.UNPROCESSABLE_ENTITY:
85+
throw { _form: 'Invalid email and/or password' };
86+
case StatusCodes.FORBIDDEN:
87+
throw { _form: 'Your account has been deactivated.' };
88+
default:
89+
throw { _form: error.message };
90+
}
91+
});
7292
},
7393
logout () {
7494
return instance.delete('/api/auth/logout');
7595
},
7696
register (data) {
77-
return instance.post('/api/auth/register', data).catch(handleValidationError);
97+
return instance.post('/api/auth/register', data).catch(handleError);
7898
},
7999
},
80100
invites: {
81101
index (page = 1) {
82102
return instance.get('/api/invites', { params: { page } });
83103
},
84104
create (data) {
85-
return instance.post('/api/invites', data).catch(handleValidationError);
105+
return instance.post('/api/invites', data).catch(handleError);
86106
},
87107
get (id) {
88108
return instance.get(`/api/invites/${id}`);
@@ -99,13 +119,20 @@ const Api = {
99119
},
100120
passwords: {
101121
reset (email) {
102-
return instance.post('/api/passwords', { email });
122+
return instance.post('/api/passwords', { email }).catch((error) => {
123+
switch (error.response?.status) {
124+
case StatusCodes.NOT_FOUND:
125+
throw { email: 'Email not found.' };
126+
default:
127+
throw { _form: error.message };
128+
}
129+
});
103130
},
104131
get (token) {
105132
return instance.get(`/api/passwords/${token}`);
106133
},
107134
update (token, password) {
108-
return instance.patch(`/api/passwords/${token}`, { password });
135+
return instance.patch(`/api/passwords/${token}`, { password }).catch(handleError);
109136
},
110137
},
111138
users: {
@@ -119,7 +146,7 @@ const Api = {
119146
return instance.get(`/api/users/${id}`);
120147
},
121148
update (id, data) {
122-
return instance.patch(`/api/users/${id}`, data);
149+
return instance.patch(`/api/users/${id}`, data).catch(handleError);
123150
},
124151
},
125152
};

client/src/Components/PhotoInput.jsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1+
import { useEffect } from 'react';
12
import { Box, CloseButton, Image, Input, Loader, Text } from '@mantine/core';
3+
import { useUncontrolled } from '@mantine/hooks';
24
import classNames from 'classnames';
35

46
import DropzoneUploader from './DropzoneUploader';
57
import classes from './PhotoInput.module.css';
68

7-
function PhotoInput ({ children, description, error, id, label, name, onChange, value, valueUrl }) {
8-
function onRemoved () {
9-
if (onChange) {
10-
onChange({ target: { name, value: '' } });
9+
function PhotoInput ({ children, description, error, id, label, name, onChange, defaultValue, value, valueUrl }) {
10+
const [_value, handleChange] = useUncontrolled({
11+
value,
12+
defaultValue,
13+
finalValue: '',
14+
onChange,
15+
});
16+
17+
useEffect(() => {
18+
if (!_value) {
19+
handleChange(defaultValue);
1120
}
21+
}, [defaultValue]);
22+
23+
function onRemoved () {
24+
handleChange('');
1225
}
1326

1427
function onUploaded (status) {
15-
if (onChange) {
16-
onChange({ target: { name, value: status.filename } });
17-
}
28+
handleChange(status.filename);
1829
}
1930

2031
return (
@@ -24,7 +35,7 @@ function PhotoInput ({ children, description, error, id, label, name, onChange,
2435
<DropzoneUploader
2536
id={id}
2637
multiple={false}
27-
disabled={!!value && value !== ''}
38+
disabled={!!_value && _value !== ''}
2839
onRemoved={onRemoved}
2940
onUploaded={onUploaded}
3041
>
@@ -42,14 +53,14 @@ function PhotoInput ({ children, description, error, id, label, name, onChange,
4253
<Loader className={classes.spinner} />
4354
</Box>
4455
));
45-
} else if (statuses.length === 0 && value) {
56+
} else if (statuses.length === 0 && _value) {
4657
return (
4758
<Box className={classes.preview}>
4859
<Image src={valueUrl} alt='' />
4960
<CloseButton className={classes.remove} onClick={onRemoved} />
5061
</Box>
5162
);
52-
} else if (statuses.length === 0 && !value) {
63+
} else if (statuses.length === 0 && !_value) {
5364
return children || <Text className='clickable' inherit={false} fz='sm' my='sm'>Drag-and-drop a photo file here, or click here to browse and select a file.</Text>;
5465
}
5566
}}

client/src/Invites/Invite.jsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useState } from 'react';
21
import { useNavigate, useParams } from 'react-router';
32
import { Box, Container, Stack, Title } from '@mantine/core';
43
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -22,28 +21,15 @@ function Invite () {
2221
},
2322
});
2423

25-
const [user, setUser] = useState({
26-
firstName: '',
27-
lastName: '',
28-
email: '',
29-
password: '',
30-
});
31-
3224
const onSubmitMutation = useMutation({
33-
mutationFn: () => Api.auth.register({ ...user, inviteId }),
25+
mutationFn: (values) => Api.auth.register({ ...values, inviteId }),
3426
onSuccess: (response) => {
3527
setAuthUser(response.data);
3628
navigate('/account', { state: { flash: 'Your account has been created!' } });
3729
},
3830
onError: () => window.scrollTo(0, 0),
3931
});
4032

41-
function onChange (event) {
42-
const newUser = { ...user };
43-
newUser[event.target.name] = event.target.value;
44-
setUser(newUser);
45-
}
46-
4733
return (
4834
<>
4935
<Head>
@@ -55,7 +41,7 @@ function Invite () {
5541
{invite?.acceptedAt && <Box>This invite has already been accepted.</Box>}
5642
{invite?.revokedAt && <Box>This invite is no longer available.</Box>}
5743
{invite && invite.acceptedAt === null && invite.revokedAt === null && (
58-
<RegistrationForm onSubmitMutation={onSubmitMutation} onChange={onChange} user={user} />
44+
<RegistrationForm onSubmitMutation={onSubmitMutation} />
5945
)}
6046
</Stack>
6147
</Container>

0 commit comments

Comments
 (0)