Add validation on onboarding flow inputs (#556)

* feat: wip react-hook-form

* feat: use react-hook-form for password login

* feat: clean regex

* feat: add react-hook-form on create workspace

* feat: add react-hook-form on create profile page

* fix: clean rebased code

* fix: rebase issue

* fix: add new stories to go over 65%

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-07-13 01:53:48 +02:00
committed by GitHub
parent ab3d326000
commit e7d48d5373
14 changed files with 498 additions and 301 deletions

View File

@ -1,9 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { yupResolver } from '@hookform/resolvers/yup';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import * as Yup from 'yup';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
@ -12,9 +15,9 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { NameFields } from '@/settings/profile/components/NameFields';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
import { GET_CURRENT_USER } from '@/users/queries';
import { useUpdateUserMutation } from '~/generated/graphql';
@ -36,6 +39,27 @@ const StyledButtonContainer = styled.div`
width: 200px;
`;
const StyledComboInputContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(4)};
}
`;
const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
firstName: Yup.string().required('First name can not be empty'),
lastName: Yup.string().required('Last name can not be empty'),
})
.required();
type Form = Yup.InferType<typeof validationSchema>;
export function CreateProfile() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
@ -44,50 +68,69 @@ export function CreateProfile() {
const [updateUser] = useUpdateUserMutation();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
setError,
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
firstName: currentUser?.firstName ?? '',
lastName: currentUser?.lastName ?? '',
},
resolver: yupResolver(validationSchema),
});
const handleCreate = useCallback(async () => {
try {
if (!currentUser?.id) {
throw new Error('User is not logged in');
}
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
if (!currentUser?.id) {
throw new Error('User is not logged in');
}
if (!data.firstName || !data.lastName) {
throw new Error('First name or last name is missing');
}
const { data, errors } = await updateUser({
variables: {
where: {
id: currentUser?.id,
},
data: {
firstName: {
set: firstName,
const result = await updateUser({
variables: {
where: {
id: currentUser?.id,
},
lastName: {
set: lastName,
data: {
firstName: {
set: data.firstName,
},
lastName: {
set: data.lastName,
},
},
},
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
awaitRefetchQueries: true,
});
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
awaitRefetchQueries: true,
});
if (errors || !data?.updateUser) {
throw errors;
if (result.errors || !result.data?.updateUser) {
throw result.errors;
}
navigate('/');
} catch (error: any) {
setError('root', { message: error?.message });
}
navigate('/');
} catch (error) {
console.error(error);
}
}, [currentUser?.id, firstName, lastName, navigate, updateUser]);
},
[currentUser?.id, navigate, setError, updateUser],
);
useScopedHotkeys(
Key.Enter,
() => {
handleCreate();
onSubmit(getValues());
},
InternalHotkeysScope.CreateProfile,
[handleCreate],
[onSubmit],
);
useEffect(() => {
@ -110,21 +153,63 @@ export function CreateProfile() {
title="Name"
description="Your name as it will be displayed on the app"
/>
<NameFields
autoSave={false}
onFirstNameUpdate={setFirstName}
onLastNameUpdate={setLastName}
/>
{/* TODO: When react-hook-form is added to edit page we should create a dedicated component with context */}
<StyledComboInputContainer>
<Controller
name="firstName"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
label="First Name"
value={value}
onBlur={onBlur}
onChange={onChange}
placeholder="Tim"
error={error?.message}
fullWidth
/>
)}
/>
<Controller
name="lastName"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
label="Last Name"
value={value}
onBlur={onBlur}
onChange={onChange}
placeholder="Cook"
error={error?.message}
fullWidth
/>
)}
/>
</StyledComboInputContainer>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
onClick={handleCreate}
disabled={!firstName || !lastName}
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
{/* Will be replaced by error snack bar */}
<Controller
name="firstName"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</>
);
}

View File

@ -1,7 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
@ -33,49 +36,73 @@ const StyledButtonContainer = styled.div`
width: 200px;
`;
const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
name: Yup.string().required('Name can not be empty'),
})
.required();
type Form = Yup.InferType<typeof validationSchema>;
export function CreateWorkspace() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
const [workspaceName, setWorkspaceName] = useState('');
const [updateWorkspace] = useUpdateWorkspaceMutation();
const handleCreate = useCallback(async () => {
try {
if (!workspaceName) {
throw new Error('Workspace name is required');
}
// Form
const {
control,
handleSubmit,
formState: { isValid, isSubmitting },
setError,
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
name: '',
},
resolver: yupResolver(validationSchema),
});
const { data, errors } = await updateWorkspace({
variables: {
data: {
displayName: {
set: workspaceName,
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
const result = await updateWorkspace({
variables: {
data: {
displayName: {
set: data.name,
},
},
},
},
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
awaitRefetchQueries: true,
});
refetchQueries: [getOperationName(GET_CURRENT_USER) ?? ''],
awaitRefetchQueries: true,
});
if (errors || !data?.updateWorkspace) {
throw errors;
if (result.errors || !result.data?.updateWorkspace) {
throw result.errors ?? new Error('Unknown error');
}
navigate('/auth/create/profile');
} catch (error: any) {
setError('root', { message: error?.message });
}
navigate('/auth/create/profile');
} catch (error) {
console.error(error);
}
}, [navigate, updateWorkspace, workspaceName]);
},
[navigate, setError, updateWorkspace],
);
useScopedHotkeys(
'enter',
() => {
handleCreate();
onSubmit(getValues());
},
InternalHotkeysScope.CreateWokspace,
[handleCreate],
[onSubmit],
);
useEffect(() => {
@ -94,6 +121,7 @@ export function CreateWorkspace() {
<StyledContentContainer>
<StyledSectionContainer>
<SubSectionTitle title="Workspace logo" />
{/* Picture is actually uploaded on the fly */}
<WorkspaceLogoUploader />
</StyledSectionContainer>
<StyledSectionContainer>
@ -101,22 +129,41 @@ export function CreateWorkspace() {
title="Workspace name"
description="The name of your organization"
/>
<TextInput
value={workspaceName}
placeholder="Apple"
onChange={setWorkspaceName}
fullWidth
<Controller
name="name"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
value={value}
placeholder="Apple"
onBlur={onBlur}
onChange={onChange}
error={error?.message}
fullWidth
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
onClick={handleCreate}
disabled={!workspaceName}
onClick={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
{/* Will be replaced by error snack bar */}
<Controller
name="name"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</>
);
}

View File

@ -1,14 +1,17 @@
import { useCallback, useState } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { yupResolver } from '@hookform/resolvers/yup';
import { useRecoilState } from 'recoil';
import * as Yup from 'yup';
import { Logo } from '@/auth/components/ui/Logo';
import { SubTitle } from '@/auth/components/ui/SubTitle';
import { Title } from '@/auth/components/ui/Title';
import { useAuth } from '@/auth/hooks/useAuth';
import { authFlowUserEmailState } from '@/auth/states/authFlowUserEmailState';
import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
@ -24,7 +27,7 @@ const StyledContentContainer = styled.div`
}
`;
const StyledAnimatedContent = styled(motion.div)`
const StyledForm = styled.form`
align-items: center;
display: flex;
flex-direction: column;
@ -48,65 +51,88 @@ const StyledErrorContainer = styled.div`
color: ${({ theme }) => theme.color.red};
`;
const validationSchema = Yup.object()
.shape({
exist: Yup.boolean().required(),
email: Yup.string().email('Email must be a valid email').required(),
password: Yup.string()
.matches(
PASSWORD_REGEX,
'Password must contain at least 8 characters, one uppercase and one number',
)
.required(),
})
.required();
type Form = Yup.InferType<typeof validationSchema>;
export function PasswordLogin() {
const navigate = useNavigate();
const [isDemoMode] = useRecoilState(isDemoModeState);
const [authFlowUserEmail, setAuthFlowUserEmail] = useRecoilState(
authFlowUserEmailState,
);
const [internalPassword, setInternalPassword] = useState(
isDemoMode ? 'Applecar2025' : '',
);
const [formError, setFormError] = useState('');
const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState);
const [showErrors, setShowErrors] = useState(false);
const { login, signUp, signUpToWorkspace } = useAuth();
const { loading, data } = useCheckUserExistsQuery({
const workspaceInviteHash = useParams().workspaceInviteHash;
const { data: checkUserExistsData } = useCheckUserExistsQuery({
variables: {
email: authFlowUserEmail,
},
});
const workspaceInviteHash = useParams().workspaceInviteHash;
const { login, signUp } = useAuth();
const handleSubmit = useCallback(async () => {
try {
if (data?.checkUserExists.exists) {
await login(authFlowUserEmail, internalPassword);
} else {
if (workspaceInviteHash) {
await signUpToWorkspace(
authFlowUserEmail,
internalPassword,
workspaceInviteHash,
);
} else {
await signUp(authFlowUserEmail, internalPassword);
// Form
const {
control,
handleSubmit,
formState: { isSubmitting },
setError,
watch,
getValues,
} = useForm<Form>({
mode: 'onChange',
defaultValues: {
exist: false,
email: authFlowUserEmail,
password: isDemoMode ? 'Applecar2025' : '',
},
resolver: yupResolver(validationSchema),
});
const onSubmit: SubmitHandler<Form> = useCallback(
async (data) => {
try {
if (!data.email || !data.password) {
throw new Error('Email and password are required');
}
if (checkUserExistsData?.checkUserExists.exists) {
await login(data.email, data.password);
} else {
await signUp(data.email, data.password, workspaceInviteHash);
}
navigate('/auth/create/workspace');
} catch (err: any) {
setError('root', { message: err?.message });
}
navigate('/auth/create/workspace');
} catch (err: any) {
setFormError(err.message);
}
}, [
login,
signUp,
signUpToWorkspace,
authFlowUserEmail,
internalPassword,
navigate,
data?.checkUserExists.exists,
workspaceInviteHash,
]);
},
[
login,
navigate,
setError,
signUp,
workspaceInviteHash,
checkUserExistsData,
],
);
useScopedHotkeys(
'enter',
() => {
handleSubmit();
onSubmit(getValues());
},
InternalHotkeysScope.PasswordLogin,
[handleSubmit],
[onSubmit],
);
return (
@ -115,40 +141,74 @@ export function PasswordLogin() {
<Title>Welcome to Twenty</Title>
<SubTitle>
Enter your credentials to sign{' '}
{data?.checkUserExists.exists ? 'in' : 'up'}
{checkUserExistsData?.checkUserExists.exists ? 'in' : 'up'}
</SubTitle>
<StyledAnimatedContent>
<StyledForm
onSubmit={(event) => {
setShowErrors(true);
return handleSubmit(onSubmit)(event);
}}
>
<StyledContentContainer>
<StyledSectionContainer>
<SubSectionTitle title="Email" />
<TextInput
value={authFlowUserEmail}
placeholder="Email"
onChange={(value) => setAuthFlowUserEmail(value)}
fullWidth
<Controller
name="email"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
value={value}
placeholder="Email"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
/>
)}
/>
</StyledSectionContainer>
<StyledSectionContainer>
<SubSectionTitle title="Password" />
<TextInput
value={internalPassword}
placeholder="Password"
onChange={(value) => setInternalPassword(value)}
fullWidth
type="password"
<Controller
name="password"
control={control}
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<TextInput
value={value}
type="password"
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
error={showErrors ? error?.message : undefined}
fullWidth
/>
)}
/>
</StyledSectionContainer>
</StyledContentContainer>
<StyledButtonContainer>
<MainButton
title="Continue"
onClick={handleSubmit}
disabled={!authFlowUserEmail || !internalPassword || loading}
type="submit"
disabled={!watch('email') || !watch('password') || isSubmitting}
fullWidth
/>
</StyledButtonContainer>
{formError && <StyledErrorContainer>{formError}</StyledErrorContainer>}
</StyledAnimatedContent>
{/* Will be replaced by error snack bar */}
<Controller
name="exist"
control={control}
render={({ formState: { errors } }) => (
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
)}
/>
</StyledForm>
</>
);
}