Skip to content

Commit 1152989

Browse files
feat(users): add create user dialog (#276)
* feat(styles/global): add field and error styles * feat(styles/theme): add border to dialog in dark mode * feat(users): add user creation dialog * fix(users/dialog): close after user is created * fix(users/header): remove unused `Intercom` import * test(users): remove "Import data" button test
1 parent d93d4cc commit 1152989

File tree

6 files changed

+409
-16
lines changed

6 files changed

+409
-16
lines changed

components/users/dialog.tsx

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import { FormEvent, useCallback, useEffect, useState } from 'react';
2+
import { Dialog } from '@rmwc/dialog';
3+
import { IconButton } from '@rmwc/icon-button';
4+
import { TextField } from '@rmwc/textfield';
5+
import axios from 'axios';
6+
import useTranslation from 'next-translate/useTranslation';
7+
8+
import AvailabilitySelect from 'components/availability-select';
9+
import Button from 'components/button';
10+
import CloseIcon from 'components/icons/close';
11+
import LangSelect from 'components/lang-select';
12+
import Loader from 'components/loader';
13+
import PhotoInput from 'components/photo-input';
14+
import SubjectSelect from 'components/subject-select';
15+
import VenueInput from 'components/venue-input';
16+
17+
import { User, UserJSON } from 'lib/model/user';
18+
import { Availability } from 'lib/model/availability';
19+
import { Callback } from 'lib/model/callback';
20+
import { Subject } from 'lib/model/subject';
21+
import { ValidationsContext } from 'lib/context/validations';
22+
import useAnalytics from 'lib/hooks/analytics';
23+
import { useOrg } from 'lib/context/org';
24+
import usePrevious from 'lib/hooks/previous';
25+
import useSingle from 'lib/hooks/single';
26+
import useSocialProps from 'lib/hooks/social-props';
27+
import useTrack from 'lib/hooks/track';
28+
29+
const emptyUser = new User();
30+
31+
export interface UserDialogProps {
32+
setDialogOpen: Callback<boolean>;
33+
}
34+
35+
export default function UserDialog({ setDialogOpen }: UserDialogProps): JSX.Element {
36+
const track = useTrack();
37+
38+
const updateRemote = useCallback(
39+
async (user: User) => {
40+
const { data } = await axios.post<UserJSON>('/api/users', user.toJSON());
41+
const created = User.fromJSON(data);
42+
track('User Signed Up', created.toSegment());
43+
return created;
44+
},
45+
[track]
46+
);
47+
48+
const { org } = useOrg();
49+
const { t, lang: locale } = useTranslation();
50+
const {
51+
data: user,
52+
setData: setUser,
53+
validations,
54+
setValidations,
55+
onSubmit,
56+
loading,
57+
checked,
58+
error,
59+
} = useSingle(emptyUser, updateRemote);
60+
61+
useAnalytics(
62+
'User Signup Errored',
63+
() => error && { ...user.toSegment(), error }
64+
);
65+
66+
const getSocialProps = useSocialProps(
67+
user,
68+
setUser,
69+
'field',
70+
'user',
71+
User
72+
);
73+
74+
useEffect(() => {
75+
if (!org) return;
76+
setUser((prev) => {
77+
const orgs = new Set(prev.orgs);
78+
orgs.add(org.id);
79+
return new User({ ...prev, orgs: [...orgs] });
80+
});
81+
}, [setUser, org]);
82+
83+
const onNameChange = useCallback(
84+
(evt: FormEvent<HTMLInputElement>) => {
85+
const name = evt.currentTarget.value;
86+
track('User Name Updated', { name });
87+
setUser((prev) => new User({ ...prev, name }));
88+
},
89+
[track, setUser]
90+
);
91+
const onEmailChange = useCallback(
92+
(evt: FormEvent<HTMLInputElement>) => {
93+
const email = evt.currentTarget.value;
94+
track('User Email Updated', { email });
95+
setUser((prev) => new User({ ...prev, email }));
96+
},
97+
[track, setUser]
98+
);
99+
const onPhoneChange = useCallback(
100+
(evt: FormEvent<HTMLInputElement>) => {
101+
const phone = evt.currentTarget.value;
102+
track('User Phone Updated', { phone });
103+
setUser((prev) => new User({ ...prev, phone }));
104+
},
105+
[track, setUser]
106+
);
107+
const onPhotoChange = useCallback(
108+
(photo: string) => {
109+
track('User Photo Updated', { photo });
110+
setUser((prev) => new User({ ...prev, photo }));
111+
},
112+
[track, setUser]
113+
);
114+
const onBackgroundChange = useCallback(
115+
(background: string) => {
116+
track('User Background Updated', { background });
117+
setUser((prev) => new User({ ...prev, background }));
118+
},
119+
[track, setUser]
120+
);
121+
const onVenueChange = useCallback(
122+
(venue: string) => {
123+
setUser((prev) => new User({ ...prev, venue }));
124+
},
125+
[setUser]
126+
);
127+
const onBioChange = useCallback(
128+
(evt: FormEvent<HTMLInputElement>) => {
129+
const bio = evt.currentTarget.value;
130+
track('User Bio Updated', { bio });
131+
setUser((prev) => new User({ ...prev, bio }));
132+
},
133+
[track, setUser]
134+
);
135+
const onSubjectsChange = useCallback(
136+
(subjects: Subject[]) => {
137+
track('User Subjects Updated', { subjects }, 2500);
138+
setUser((prev) => new User({ ...prev, subjects }));
139+
},
140+
[track, setUser]
141+
);
142+
const onAvailabilityChange = useCallback(
143+
(availability: Availability) => {
144+
// TODO: Fix the `useContinuous` hook that the `AvailabilitySelect` uses
145+
// to skip this callback when the component is initially mounted.
146+
track('User Availability Updated', {
147+
availability: availability.toSegment(),
148+
});
149+
setUser((prev) => new User({ ...prev, availability }));
150+
},
151+
[track, setUser]
152+
);
153+
const onLangsChange = useCallback(
154+
(langs: string[]) => {
155+
track('User Langs Updated', { langs }, 2500);
156+
setUser((prev) => new User({ ...prev, langs }));
157+
},
158+
[track, setUser]
159+
);
160+
const onReferenceChange = useCallback(
161+
(evt: FormEvent<HTMLInputElement>) => {
162+
const reference = evt.currentTarget.value;
163+
track('User Reference Updated', { reference }, 2500);
164+
setUser((prev) => new User({ ...prev, reference }));
165+
},
166+
[track, setUser]
167+
);
168+
169+
const [open, setOpen] = useState<boolean>(true);
170+
const prevLoading = usePrevious(loading);
171+
useEffect(() => {
172+
if (prevLoading && !loading && checked) setOpen(false);
173+
}, [prevLoading, loading, checked]);
174+
175+
return (
176+
<Dialog open={open} onClosed={() => setDialogOpen(false)}>
177+
<ValidationsContext.Provider value={{ validations, setValidations }}>
178+
<div className='wrapper'>
179+
<Loader active={!!loading} checked={!!checked} />
180+
<div className='nav'>
181+
<IconButton icon={<CloseIcon />} onClick={() => setOpen(false)} />
182+
</div>
183+
<form className='form' onSubmit={onSubmit}>
184+
<div className='inputs'>
185+
<TextField
186+
label={t('user:name')}
187+
value={user.name}
188+
onChange={onNameChange}
189+
className='field'
190+
outlined
191+
required
192+
/>
193+
<TextField
194+
label={t('user:email')}
195+
value={user.email}
196+
onChange={onEmailChange}
197+
className='field'
198+
type='email'
199+
outlined
200+
required
201+
/>
202+
<TextField
203+
label={t('user:phone')}
204+
value={user.phone ? user.phone : undefined}
205+
onChange={onPhoneChange}
206+
className='field'
207+
type='tel'
208+
outlined
209+
/>
210+
</div>
211+
<div className='divider' />
212+
<div className='inputs'>
213+
<PhotoInput
214+
label={t('user:photo')}
215+
value={user.photo}
216+
onChange={onPhotoChange}
217+
className='field'
218+
outlined
219+
/>
220+
<PhotoInput
221+
label={t('user:background')}
222+
value={user.background}
223+
onChange={onBackgroundChange}
224+
className='field'
225+
outlined
226+
/>
227+
</div>
228+
<div className='divider' />
229+
<div className='inputs'>
230+
<VenueInput
231+
label={t('user:venue')}
232+
value={user.venue}
233+
onChange={onVenueChange}
234+
className='field'
235+
outlined
236+
/>
237+
</div>
238+
<div className='divider' />
239+
<div className='inputs'>
240+
<SubjectSelect
241+
label={t('user:subjects')}
242+
placeholder={t('common:subjects-placeholder')}
243+
value={user.subjects}
244+
onChange={onSubjectsChange}
245+
className='field'
246+
renderToPortal
247+
outlined
248+
/>
249+
<LangSelect
250+
className='field'
251+
label={t('user:langs')}
252+
placeholder={t('common:langs-placeholder')}
253+
onChange={onLangsChange}
254+
value={user.langs}
255+
renderToPortal
256+
outlined
257+
/>
258+
<AvailabilitySelect
259+
className='field'
260+
label={t('user:availability')}
261+
onChange={onAvailabilityChange}
262+
value={user.availability}
263+
renderToPortal
264+
outlined
265+
/>
266+
<TextField
267+
label={t('user:bio')}
268+
placeholder={
269+
(org?.signup[locale] || {}).bio || t('common:bio-placeholder')
270+
}
271+
helpText={{
272+
persistent: true,
273+
children: t('common:bio-help', { name: 'your' }),
274+
}}
275+
value={user.bio}
276+
onChange={onBioChange}
277+
className='field'
278+
outlined
279+
rows={8}
280+
textarea
281+
/>
282+
<TextField
283+
label={t('user:reference', {
284+
org: org?.name || 'Tutorbook',
285+
})}
286+
placeholder={t('common:reference-placeholder', {
287+
org: org?.name || 'Tutorbook',
288+
})}
289+
value={user.reference}
290+
onChange={onReferenceChange}
291+
className='field'
292+
outlined
293+
rows={3}
294+
textarea
295+
/>
296+
</div>
297+
<div className='divider' />
298+
<div className='inputs'>
299+
<TextField {...getSocialProps('website')} />
300+
<TextField {...getSocialProps('facebook')} />
301+
<TextField {...getSocialProps('instagram')} />
302+
<TextField {...getSocialProps('twitter')} />
303+
<TextField {...getSocialProps('linkedin')} />
304+
<TextField {...getSocialProps('github')} />
305+
<TextField {...getSocialProps('indiehackers')} />
306+
<Button
307+
disabled={loading}
308+
label='Create user'
309+
type='submit'
310+
raised
311+
arrow
312+
/>
313+
{!!error && (
314+
<div data-cy='error' className='error'>
315+
Hmm, it looks like we hit a snag. To get help, contact [email protected] with the following error message: {error}
316+
</div>
317+
)}
318+
</div>
319+
</form>
320+
<style jsx>{`
321+
.wrapper {
322+
max-height: calc(100vh - 32px);
323+
position: relative;
324+
overflow: hidden;
325+
display: flex;
326+
flex-direction: column;
327+
height: 100%;
328+
}
329+
330+
.nav {
331+
display: flex;
332+
justify-content: space-between;
333+
border-bottom: 1px solid var(--accents-2);
334+
padding: 12px 14px;
335+
}
336+
337+
.nav :global(button) {
338+
padding: 9px;
339+
width: 36px;
340+
height: 36px;
341+
}
342+
343+
.nav :global(button svg) {
344+
display: block;
345+
height: 18px;
346+
width: 18px;
347+
}
348+
349+
.form {
350+
overflow: auto;
351+
height: 100%;
352+
box-sizing: border-box;
353+
}
354+
355+
.form .divider {
356+
border-top: 1px solid var(--accents-2);
357+
}
358+
359+
.form .inputs {
360+
margin: 24px auto;
361+
padding: 0 24px;
362+
display: flex;
363+
flex-direction: column;
364+
}
365+
366+
.form :global(button) {
367+
margin: 8px 0 0;
368+
width: 100%;
369+
}
370+
`}</style>
371+
</div>
372+
</ValidationsContext.Provider>
373+
</Dialog>
374+
);
375+
}

0 commit comments

Comments
 (0)