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:
@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user