feat: add user profile settings tab
This commit is contained in:
@ -3,11 +3,20 @@ import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import { getAccessToken } from 'shared/utils/accessToken';
|
||||
import Settings from 'shared/components/Settings';
|
||||
import { useMeQuery, useClearProfileAvatarMutation, useUpdateUserPasswordMutation } from 'shared/generated/graphql';
|
||||
import {
|
||||
useMeQuery,
|
||||
useClearProfileAvatarMutation,
|
||||
useUpdateUserPasswordMutation,
|
||||
useUpdateUserInfoMutation,
|
||||
MeQuery,
|
||||
MeDocument,
|
||||
} from 'shared/generated/graphql';
|
||||
import axios from 'axios';
|
||||
import { useCurrentUser } from 'App/context';
|
||||
import NOOP from 'shared/utils/noop';
|
||||
import { toast } from 'react-toastify';
|
||||
import updateApolloCache from 'shared/utils/cache';
|
||||
import produce from 'immer';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
@ -19,6 +28,7 @@ const Projects = () => {
|
||||
const $fileUpload = useRef<HTMLInputElement>(null);
|
||||
const [clearProfileAvatar] = useClearProfileAvatarMutation();
|
||||
const { user } = useCurrentUser();
|
||||
const [updateUserInfo] = useUpdateUserInfoMutation();
|
||||
const [updateUserPassword] = useUpdateUserPasswordMutation();
|
||||
const { loading, data, refetch } = useMeQuery();
|
||||
useEffect(() => {
|
||||
@ -69,6 +79,13 @@ const Projects = () => {
|
||||
toast('Password was changed!');
|
||||
done();
|
||||
}}
|
||||
onChangeUserInfo={(d, done) => {
|
||||
updateUserInfo({
|
||||
variables: { name: d.full_name, bio: d.bio, email: d.email, initials: d.initials },
|
||||
});
|
||||
toast('User info was saved!');
|
||||
done();
|
||||
}}
|
||||
onProfileAvatarRemove={() => {
|
||||
clearProfileAvatar();
|
||||
}}
|
||||
|
@ -557,6 +557,7 @@ const Admin: React.FC<AdminProps> = ({
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
key={item.name}
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
|
@ -78,6 +78,7 @@ const Icon = styled.div`
|
||||
|
||||
type InputProps = {
|
||||
variant?: 'normal' | 'alternate';
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
width?: string;
|
||||
floatingLabel?: boolean;
|
||||
@ -116,6 +117,7 @@ function useCombinedRefs(...refs: any) {
|
||||
const Input = React.forwardRef(
|
||||
(
|
||||
{
|
||||
disabled = false,
|
||||
width = 'auto',
|
||||
variant = 'normal',
|
||||
type = 'text',
|
||||
@ -160,6 +162,7 @@ const Input = React.forwardRef(
|
||||
onChange={e => {
|
||||
setHasValue((e.currentTarget.value !== '' || floatingLabel) ?? false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
hasValue={hasValue}
|
||||
ref={combinedRef}
|
||||
id={id}
|
||||
|
@ -27,6 +27,7 @@ export const Default = () => {
|
||||
<BaseStyles />
|
||||
<Settings
|
||||
profile={profile}
|
||||
onChangeUserInfo={action('change user info')}
|
||||
onResetPassword={action('reset password')}
|
||||
onProfileAvatarRemove={action('remove')}
|
||||
onProfileAvatarChange={action('profile avatar change')}
|
||||
|
@ -10,6 +10,11 @@ const PasswordInput = styled(Input)`
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const UserInfoInput = styled(Input)`
|
||||
margin-top: 30px;
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const FormError = styled.span`
|
||||
font-size: 12px;
|
||||
color: rgba(${props => props.theme.colors.warning});
|
||||
@ -240,6 +245,7 @@ const SaveButton = styled(Button)`
|
||||
type SettingsProps = {
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
|
||||
onResetPassword: (password: string, done: () => void) => void;
|
||||
profile: TaskUser;
|
||||
};
|
||||
@ -300,9 +306,93 @@ const ResetPasswordTab: React.FC<ResetPasswordTabProps> = ({ onResetPassword })
|
||||
);
|
||||
};
|
||||
|
||||
type UserInfoData = {
|
||||
full_name: string;
|
||||
bio: string;
|
||||
initials: string;
|
||||
email: string;
|
||||
};
|
||||
type UserInfoTabProps = {
|
||||
profile: TaskUser;
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
onChangeUserInfo: (data: UserInfoData, done: () => void) => void;
|
||||
};
|
||||
|
||||
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||
const INITIALS_PATTERN = /^[a-zA-Z]{2,3}$/i;
|
||||
|
||||
const UserInfoTab: React.FC<UserInfoTabProps> = ({
|
||||
profile,
|
||||
onProfileAvatarRemove,
|
||||
onProfileAvatarChange,
|
||||
onChangeUserInfo,
|
||||
}) => {
|
||||
const [active, setActive] = useState(true);
|
||||
const { register, handleSubmit, errors } = useForm<UserInfoData>();
|
||||
const done = () => {
|
||||
setActive(true);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<AvatarSettings
|
||||
onProfileAvatarRemove={onProfileAvatarRemove}
|
||||
onProfileAvatarChange={onProfileAvatarChange}
|
||||
profile={profile.profileIcon}
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit(data => {
|
||||
setActive(false);
|
||||
onChangeUserInfo(data, done);
|
||||
})}
|
||||
>
|
||||
<UserInfoInput
|
||||
ref={register({ required: 'Full name is required' })}
|
||||
name="full_name"
|
||||
defaultValue={profile.fullName}
|
||||
width="100%"
|
||||
label="Name"
|
||||
/>
|
||||
{errors.full_name && <FormError>{errors.full_name.message}</FormError>}
|
||||
<UserInfoInput
|
||||
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||
ref={register({
|
||||
required: 'Initials is required',
|
||||
pattern: { value: INITIALS_PATTERN, message: 'Intials must be between two to four characters' },
|
||||
})}
|
||||
name="initials"
|
||||
width="100%"
|
||||
label="Initials "
|
||||
/>
|
||||
{errors.initials && <FormError>{errors.initials.message}</FormError>}
|
||||
<UserInfoInput disabled defaultValue={profile.username ?? ''} width="100%" label="Username " />
|
||||
<UserInfoInput
|
||||
width="100%"
|
||||
name="email"
|
||||
ref={register({
|
||||
required: 'Email is required',
|
||||
pattern: { value: EMAIL_PATTERN, message: 'Must be a valid email' },
|
||||
})}
|
||||
defaultValue={profile.email ?? ''}
|
||||
label="Email"
|
||||
/>
|
||||
{errors.email && <FormError>{errors.email.message}</FormError>}
|
||||
<UserInfoInput width="100%" name="bio" ref={register()} defaultValue={profile.bio ?? ''} label="Bio" />
|
||||
{errors.bio && <FormError>{errors.bio.message}</FormError>}
|
||||
<SettingActions>
|
||||
<SaveButton disabled={!active} type="submit">
|
||||
Save Change
|
||||
</SaveButton>
|
||||
</SettingActions>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({
|
||||
onProfileAvatarRemove,
|
||||
onProfileAvatarChange,
|
||||
onChangeUserInfo,
|
||||
onResetPassword,
|
||||
profile,
|
||||
}) => {
|
||||
@ -315,6 +405,7 @@ const Settings: React.FC<SettingsProps> = ({
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
key={item.name}
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
@ -332,23 +423,12 @@ const Settings: React.FC<SettingsProps> = ({
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<Tab tab={0} currentTab={currentTab}>
|
||||
<AvatarSettings
|
||||
onProfileAvatarRemove={onProfileAvatarRemove}
|
||||
<UserInfoTab
|
||||
onProfileAvatarChange={onProfileAvatarChange}
|
||||
profile={profile.profileIcon}
|
||||
onProfileAvatarRemove={onProfileAvatarRemove}
|
||||
profile={profile}
|
||||
onChangeUserInfo={onChangeUserInfo}
|
||||
/>
|
||||
<Input defaultValue={profile.fullName} width="100%" label="Name" />
|
||||
<Input
|
||||
defaultValue={profile.profileIcon && profile.profileIcon.initials ? profile.profileIcon.initials : ''}
|
||||
width="100%"
|
||||
label="Initials "
|
||||
/>
|
||||
<Input defaultValue={profile.username ?? ''} width="100%" label="Username " />
|
||||
<Input width="100%" label="Email" />
|
||||
<Input width="100%" label="Bio" />
|
||||
<SettingActions>
|
||||
<SaveButton>Save Change</SaveButton>
|
||||
</SettingActions>
|
||||
</Tab>
|
||||
<Tab tab={1} currentTab={currentTab}>
|
||||
<ResetPasswordTab onResetPassword={onResetPassword} />
|
||||
|
@ -105,6 +105,7 @@ export type UserAccount = {
|
||||
createdAt: Scalars['Time'];
|
||||
fullName: Scalars['String'];
|
||||
initials: Scalars['String'];
|
||||
bio: Scalars['String'];
|
||||
role: Role;
|
||||
username: Scalars['String'];
|
||||
profileIcon: ProfileIcon;
|
||||
@ -303,6 +304,7 @@ export type Mutation = {
|
||||
updateTaskLocation: UpdateTaskLocationPayload;
|
||||
updateTaskName: Task;
|
||||
updateTeamMemberRole: UpdateTeamMemberRolePayload;
|
||||
updateUserInfo: UpdateUserInfoPayload;
|
||||
updateUserPassword: UpdateUserPasswordPayload;
|
||||
updateUserRole: UpdateUserRolePayload;
|
||||
};
|
||||
@ -548,6 +550,11 @@ export type MutationUpdateTeamMemberRoleArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserInfoArgs = {
|
||||
input: UpdateUserInfo;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateUserPasswordArgs = {
|
||||
input: UpdateUserPassword;
|
||||
};
|
||||
@ -979,6 +986,18 @@ export type UpdateTeamMemberRolePayload = {
|
||||
member: Member;
|
||||
};
|
||||
|
||||
export type UpdateUserInfoPayload = {
|
||||
__typename?: 'UpdateUserInfoPayload';
|
||||
user: UserAccount;
|
||||
};
|
||||
|
||||
export type UpdateUserInfo = {
|
||||
name: Scalars['String'];
|
||||
initials: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
bio: Scalars['String'];
|
||||
};
|
||||
|
||||
export type UpdateUserPassword = {
|
||||
userID: Scalars['UUID'];
|
||||
password: Scalars['String'];
|
||||
@ -1354,7 +1373,7 @@ export type MeQuery = (
|
||||
{ __typename?: 'MePayload' }
|
||||
& { user: (
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'id' | 'fullName'>
|
||||
& Pick<UserAccount, 'id' | 'fullName' | 'username' | 'email' | 'bio'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
|
||||
@ -2080,7 +2099,7 @@ export type CreateUserAccountMutation = (
|
||||
{ __typename?: 'Mutation' }
|
||||
& { createUserAccount: (
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username'>
|
||||
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'initials' | 'username' | 'bio'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
|
||||
@ -2127,6 +2146,29 @@ export type DeleteUserAccountMutation = (
|
||||
) }
|
||||
);
|
||||
|
||||
export type UpdateUserInfoMutationVariables = {
|
||||
name: Scalars['String'];
|
||||
initials: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
bio: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type UpdateUserInfoMutation = (
|
||||
{ __typename?: 'Mutation' }
|
||||
& { updateUserInfo: (
|
||||
{ __typename?: 'UpdateUserInfoPayload' }
|
||||
& { user: (
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'bio'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'initials'>
|
||||
) }
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type UpdateUserPasswordMutationVariables = {
|
||||
userID: Scalars['UUID'];
|
||||
password: Scalars['String'];
|
||||
@ -2795,6 +2837,9 @@ export const MeDocument = gql`
|
||||
user {
|
||||
id
|
||||
fullName
|
||||
username
|
||||
email
|
||||
bio
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
@ -4271,6 +4316,7 @@ export const CreateUserAccountDocument = gql`
|
||||
fullName
|
||||
initials
|
||||
username
|
||||
bio
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -4369,6 +4415,49 @@ export function useDeleteUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
|
||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||
export const UpdateUserInfoDocument = gql`
|
||||
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
|
||||
updateUserInfo(input: {name: $name, initials: $initials, email: $email, bio: $bio}) {
|
||||
user {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
bio
|
||||
profileIcon {
|
||||
initials
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdateUserInfoMutationFn = ApolloReactCommon.MutationFunction<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateUserInfoMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateUserInfoMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateUserInfoMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateUserInfoMutation, { data, loading, error }] = useUpdateUserInfoMutation({
|
||||
* variables: {
|
||||
* name: // value for 'name'
|
||||
* initials: // value for 'initials'
|
||||
* email: // value for 'email'
|
||||
* bio: // value for 'bio'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateUserInfoMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>) {
|
||||
return ApolloReactHooks.useMutation<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>(UpdateUserInfoDocument, baseOptions);
|
||||
}
|
||||
export type UpdateUserInfoMutationHookResult = ReturnType<typeof useUpdateUserInfoMutation>;
|
||||
export type UpdateUserInfoMutationResult = ApolloReactCommon.MutationResult<UpdateUserInfoMutation>;
|
||||
export type UpdateUserInfoMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserInfoMutation, UpdateUserInfoMutationVariables>;
|
||||
export const UpdateUserPasswordDocument = gql`
|
||||
mutation updateUserPassword($userID: UUID!, $password: String!) {
|
||||
updateUserPassword(input: {userID: $userID, password: $password}) {
|
||||
|
@ -3,6 +3,9 @@ query me {
|
||||
user {
|
||||
id
|
||||
fullName
|
||||
username
|
||||
email
|
||||
bio
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
|
@ -24,6 +24,7 @@ export const CREATE_USER_MUTATION = gql`
|
||||
fullName
|
||||
initials
|
||||
username
|
||||
bio
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
|
19
frontend/src/shared/graphql/user/updateUserInfo.ts
Normal file
19
frontend/src/shared/graphql/user/updateUserInfo.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const UPDATE_USER_INFO_MUTATION = gql`
|
||||
mutation updateUserInfo($name: String!, $initials: String!, $email: String!, $bio: String!) {
|
||||
updateUserInfo(input: { name: $name, initials: $initials, email: $email, bio: $bio }) {
|
||||
user {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
bio
|
||||
profileIcon {
|
||||
initials
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default UPDATE_USER_INFO_MUTATION;
|
2
frontend/src/taskcafe.d.ts
vendored
2
frontend/src/taskcafe.d.ts
vendored
@ -46,6 +46,8 @@ type OwnedList = {
|
||||
type TaskUser = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
bio?: string;
|
||||
profileIcon: ProfileIcon;
|
||||
username?: string;
|
||||
role?: Role;
|
||||
|
Reference in New Issue
Block a user