feat: updating user info

This commit is contained in:
Sergey Yarkov 2022-02-03 22:31:32 +03:00
parent ab662a50f6
commit f3dc0f14c5
15 changed files with 279 additions and 391 deletions

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useEffect } from 'react';
import { Router, Switch } from 'react-router-dom'; import { Router, Switch } from 'react-router-dom';
import { routes } from './routes.config'; import { routes } from './routes.config';

View File

@ -0,0 +1,10 @@
import React from 'react';
import { Switch, SwitchProps, useColorMode } from '@chakra-ui/react';
const SwitchDarkThemeButton: React.FC<SwitchProps> = props => {
const { colorMode, toggleColorMode } = useColorMode();
return <Switch onChange={toggleColorMode} size='lg' isChecked={colorMode === 'dark'} {...props} />;
};
export { SwitchDarkThemeButton };

View File

@ -1,3 +1 @@
import { Base } from './Base'; export { Base } from './Base';
export { Base };

View File

@ -1,3 +1 @@
import { Settings } from './Settings'; export { Settings } from './Settings';
export { Settings };

View File

@ -29,6 +29,7 @@ import { useCreateUser } from './user/useCreateUser';
import { useDeleteUser } from './user/useDeleteUser'; import { useDeleteUser } from './user/useDeleteUser';
import { useUpdateUser } from './user/useUpdateUser'; import { useUpdateUser } from './user/useUpdateUser';
import { useUpdateUserEmail } from './user/useUpdateUserEmail'; import { useUpdateUserEmail } from './user/useUpdateUserEmail';
import { useUpdateUserInfo } from './user/useUpdateUserInfo';
/** /**
* Category * Category
@ -59,6 +60,7 @@ export {
useCreateUser, useCreateUser,
useDeleteUser, useDeleteUser,
useUpdateUser, useUpdateUser,
useUpdateUserInfo,
useUpdateUserEmail, useUpdateUserEmail,
useCreateCategory, useCreateCategory,
useDeleteCategory, useDeleteCategory,

View File

@ -0,0 +1,42 @@
import { useToast } from '@chakra-ui/react';
import useAsync from '@educt/hooks/useAsync';
import { IUserInfo } from '@educt/interfaces';
import { UserServiceInstance } from '@educt/services';
import { UpdateUserInfoParamsType } from '@educt/types';
import axios from 'axios';
import { useErrorHandler } from 'react-error-boundary';
const useUpdateUserInfo = () => {
const toast = useToast();
const handleError = useErrorHandler();
const fetch = async (data: UpdateUserInfoParamsType) => {
try {
const result = await UserServiceInstance.updateInfo(data);
toast({ title: 'Info updated.', status: 'info' });
return result.data;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response) {
switch (error.response.status) {
case 422:
toast({ title: error.response.data.errors[0].message, status: 'error', duration: 2000 });
break;
default:
handleError(error);
}
} else {
handleError(error);
}
}
return Promise.reject(error);
}
};
const { execute, ...state } = useAsync<Partial<IUserInfo>, Parameters<typeof fetch>>(fetch);
return { updateInfo: execute, ...state };
};
export { useUpdateUserInfo };

View File

@ -147,6 +147,10 @@ export interface IUserContacts {
vk_id: string | null; vk_id: string | null;
} }
export interface IUserInfo extends IUserContacts {
about: string | null;
}
export interface IUserRole { export interface IUserRole {
id: number; id: number;
name: string; name: string;

View File

@ -1,165 +0,0 @@
import React from 'react';
import * as helpers from '@educt/helpers';
import { observer } from 'mobx-react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { MdSave } from 'react-icons/md';
import { Text, Input } from '@chakra-ui/react';
import { Box, Stack } from '@chakra-ui/layout';
import { FormControl, FormHelperText } from '@chakra-ui/form-control';
import { yupResolver } from '@hookform/resolvers/yup';
/**
* Types
*/
import { IUserContacts } from '@educt/interfaces';
/**
* Hooks
*/
import { useState } from 'react';
import { useToast } from '@chakra-ui/toast';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useErrorHandler } from 'react-error-boundary';
/**
* Schema
*/
import UpdateUserContactsSchema from './UpdateUserContactsForm.validator';
import { SaveButton } from '@educt/components/Buttons';
type UpdateUserContactsInputType = {
phone_number: string | null;
twitter_id: string | null;
telegram_id: string | null;
vk_id: string | null;
};
type UpdateUserContactsFormPropsType = {
contacts: IUserContacts | null;
};
const UpdateUserContactsForm: React.FC<UpdateUserContactsFormPropsType> = ({ contacts }) => {
const {
register,
formState: { errors, dirtyFields, isDirty },
handleSubmit,
reset,
} = useForm<UpdateUserContactsInputType>({
resolver: yupResolver(UpdateUserContactsSchema),
defaultValues: {
phone_number: contacts && (contacts.phone_number ?? ''),
twitter_id: contacts && (contacts.twitter_id?.replace(/^/, '@') ?? ''),
telegram_id: contacts && (contacts.telegram_id?.replace(/^/, '@') ?? ''),
vk_id: contacts && (contacts.vk_id ?? ''),
},
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { userStore } = useRootStore();
const toast = useToast();
const handleError = useErrorHandler();
/**
* Sumbit form handler
*/
const onSubmit: SubmitHandler<UpdateUserContactsInputType> = async data => {
try {
setIsLoading(true);
const updatedFields = helpers.getDirtyFields(dirtyFields, data);
await userStore.updateCurrentUserContacts(updatedFields);
toast({ title: 'Contacts saved!', status: 'info' });
reset({
phone_number: data.phone_number ?? '',
twitter_id: data.twitter_id ?? '',
telegram_id: data.telegram_id ?? '',
vk_id: data.vk_id ?? '',
});
} catch (error: any) {
if (error.response) {
switch (error.response.status) {
case 422:
toast({ title: error.response.data.errors[0].message, status: 'error', duration: 2000 });
break;
default:
handleError(error);
}
} else {
handleError(error);
}
} finally {
setIsLoading(false);
}
};
return (
<Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Box borderRadius='lg' borderWidth='1px' padding='5'>
<Stack spacing='5px'>
<FormControl id='phone_number'>
<FormHelperText color='gray.500'>Phone number with country code</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='+1 (234) 555-1234'
isInvalid={errors.phone_number ? true : false}
{...register('phone_number')}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl>
<FormControl id='twitter_id'>
<FormHelperText color='gray.500'>Twitter</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='@username'
isInvalid={errors.twitter_id ? true : false}
{...register('twitter_id')}
/>
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl>
<FormControl id='telegram'>
<FormHelperText color='gray.500'>Telegram</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='@username'
isInvalid={errors.telegram_id ? true : false}
{...register('telegram_id')}
/>
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl>
<FormControl id='vk'>
<FormHelperText color='gray.500'>VKontakte</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='username'
isInvalid={errors.vk_id ? true : false}
{...register('vk_id')}
/>
<Text as='small' color='red.500'>
{errors.vk_id?.message}
</Text>
</FormControl>
</Stack>
</Box>
<SaveButton type='submit' mt='4' isLoading={isLoading} rightIcon={<MdSave />} isDisabled={!isDirty} />
</form>
</Box>
);
};
export default observer(UpdateUserContactsForm);

View File

@ -1,26 +0,0 @@
import yup from '@educt/schema';
const UpdateUserContactsSchema = yup.object().shape({
phone_number: yup
.string()
.isMobileValid(['en-US', 'ru-RU'], { strictMode: true })
.transform(val => (!val ? null : val))
.nullable(true),
twitter_id: yup
.string()
.matches(/(^|[^@\w])@(\w{1,15})\b/, 'Twitter username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
telegram_id: yup
.string()
.matches(/(^|[^@\w])@(\w{1,64})\b/, 'Telegram username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
vk_id: yup
.string()
.matches(/^([a-zA-Z0-9_]){1,64}$/, 'VKontakte username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
});
export default UpdateUserContactsSchema;

View File

@ -1,3 +0,0 @@
import UpdateUserContactsForm from './UpdateUserContactsForm';
export default UpdateUserContactsForm;

View File

@ -1,90 +0,0 @@
import React from 'react';
import { FlexProps, Flex, BoxProps, Box, Heading, Text } from '@chakra-ui/layout';
import { Avatar } from '@chakra-ui/avatar';
import { Button, ButtonProps } from '@chakra-ui/button';
import { Skeleton, SkeletonCircle, Spinner } from '@chakra-ui/react';
/**
* Types
*/
import { IUserRole } from '@educt/interfaces';
/**
* Components
*/
import UserBadge from '@educt/components/UserBadge';
/**
* Hooks
*/
import { useColorMode } from '@chakra-ui/react';
import { useLogout } from '@educt/hooks/queries';
export const ProfileBaseInfo: React.FC<FlexProps> = props => {
return (
<Flex flexDir={{ base: 'column', md: 'row' }} textAlign={{ base: 'center', md: 'left' }} {...props}>
{props.children}
</Flex>
);
};
interface IProfileAvatarProps extends BoxProps {
name: string;
}
export const ProfileAvatar: React.FC<IProfileAvatarProps> = props => {
const { colorMode } = useColorMode();
const isDark = colorMode === 'dark';
return (
<Box>
<Avatar border={`4px solid ${isDark ? '#2D3748' : '#E2E8F0'}`} size='2xl' name={props.name} />
</Box>
);
};
interface IProfileDescriptionProps extends BoxProps {
fullname: string;
email: string;
roles: IUserRole[];
}
export const ProfileDescription: React.FC<IProfileDescriptionProps> = props => {
const { fullname, email, roles, ...boxProps } = props;
return (
<Box w='full' ml={{ md: '50' }} mt={{ base: '2', md: '3' }} {...boxProps}>
<Flex alignItems='center' justifyContent={{ base: 'center', md: 'space-between' }}>
<Box>
<Heading lineHeight='1.5rem' fontWeight='bold' fontSize='24px'>
{fullname}
<br />
<Text as='span' fontSize='sm' fontWeight='normal'>
{email}
</Text>
</Heading>
<Box>
<UserBadge mt='2' roles={roles} />
</Box>
</Box>
</Flex>
</Box>
);
};
export const ProfileLoading: React.FC<BoxProps> = props => {
return (
<Box maxW='900px' {...props}>
<Skeleton height='35px' width='250px' borderRadius='md' />
<Flex alignItems='center' mt='20'>
<Box>
<SkeletonCircle size='128px' />
</Box>
<Flex ml='50' flexDirection='column'>
<Skeleton height='25px' width='200px' mb='5' borderRadius='md' />
<Skeleton height='25px' width='300px' borderRadius='md' />
</Flex>
</Flex>
<Flex mt='20' justifyContent='center'>
<Spinner size='xl' />
</Flex>
</Box>
);
};

View File

@ -1,13 +1,17 @@
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react';
import { runInAction } from 'mobx';
import yup from '@educt/schema';
import { yupResolver } from '@hookform/resolvers/yup';
import * as helpers from '@educt/helpers';
import { import {
InputGroup, InputGroup,
InputRightElement, InputRightElement,
Flex,
Switch,
FormControl, FormControl,
FormLabel, FormLabel,
Input, Input,
Textarea, Textarea,
Text,
FormHelperText, FormHelperText,
Select, Select,
Button, Button,
@ -17,21 +21,54 @@ import { Box, Stack } from '@chakra-ui/layout';
/** /**
* Types * Types
*/ */
import { IPageProps } from '@educt/interfaces'; import type { IPageProps } from '@educt/interfaces';
/** /**
* Components * Components
*/ */
import { Page } from '@educt/components/PageElements'; import { Page } from '@educt/components/PageElements';
import { ProfileLoading } from './components';
import { UserProfile } from '@educt/components/UserProfile'; import { UserProfile } from '@educt/components/UserProfile';
import { SwitchDarkThemeButton } from '@educt/components/SwitchDarkThemeButton';
import { SaveButton } from '@educt/components/Buttons';
import LoadingPage from '@educt/components/LoadingPage';
/** /**
* Hooks * Hooks
*/ */
import { useHistory } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { observer } from 'mobx-react'; import { useUpdateUserInfo } from '@educt/hooks/queries';
import { MdOutlineSave } from 'react-icons/md';
type UpdateProfileInputType = {
about: string;
phone_number: string;
twitter_id: string;
telegram_id: string;
};
const schema = yup.object().shape({
about: yup
.string()
.max(250, 'Description must be at most 250 characters')
.transform(val => (!val ? null : val))
.nullable(true),
phone_number: yup
.string()
.isMobileValid(['en-US', 'ru-RU'], { strictMode: true })
.transform(val => (!val ? null : val))
.nullable(true),
twitter_id: yup
.string()
.matches(/(^|[^@\w])@(\w{1,15})\b/, 'Twitter username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
telegram_id: yup
.string()
.matches(/(^|[^@\w])@(\w{1,64})\b/, 'Telegram username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
});
/** /**
* Profile page * Profile page
@ -40,12 +77,58 @@ const ProfilePage: React.FC<IPageProps> = () => {
const { const {
userStore: { me }, userStore: { me },
} = useRootStore(); } = useRootStore();
const { updateInfo, isLoading } = useUpdateUserInfo();
const history = useHistory();
if (me === null) return <ProfileLoading />; const {
register,
handleSubmit,
reset,
formState: { errors, isDirty, dirtyFields },
} = useForm<UpdateProfileInputType>({
resolver: yupResolver(schema),
defaultValues: {
about: me?.about || '',
phone_number: me?.contacts?.phone_number || '',
twitter_id: me?.contacts?.twitter_id || '',
telegram_id: me?.contacts?.telegram_id || '',
},
});
if (me === null) return <LoadingPage />;
const handleChangePassword = () => history.push(`/profile/change-password`);
const handleChangeEmail = () => history.push(`/profile/change-email`);
const handleUpdateProfile = async (data: UpdateProfileInputType) => {
try {
const updatedFields = helpers.getDirtyFields(dirtyFields, data);
const { about, phone_number, telegram_id, twitter_id } = await updateInfo(updatedFields);
/**
* Update info in store
*/
runInAction(() => {
if (me !== null) {
me.about = about === undefined ? me.about : about;
if (me.contacts) {
me.contacts.phone_number = phone_number === undefined ? me.contacts.phone_number : phone_number;
me.contacts.twitter_id = twitter_id === undefined ? me.contacts.twitter_id : twitter_id;
me.contacts.telegram_id = telegram_id === undefined ? me.contacts.telegram_id : telegram_id;
}
}
});
reset(data);
} catch (error) {
console.error(error);
}
};
return ( return (
<Page maxW={'900px'}> <Page maxW={'900px'}>
<Page.Heading heading='My account' description='Update you contacts information or password here.' /> <Page.Heading heading='My account' description='Update your profile information or password here.' />
<Page.Content> <Page.Content>
<UserProfile mt='14'> <UserProfile mt='14'>
<UserProfile.Base> <UserProfile.Base>
@ -57,22 +140,23 @@ const ProfilePage: React.FC<IPageProps> = () => {
</UserProfile.Info> </UserProfile.Info>
</UserProfile.Base> </UserProfile.Base>
<form onSubmit={handleSubmit(handleUpdateProfile)}>
<UserProfile.Settings> <UserProfile.Settings>
{/* Personal Info */} {/* Personal Info */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Personal Info'} /> <UserProfile.Settings.Title title={'Personal Info'} />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Stack spacing={'6'}> <Stack spacing={'6'}>
<FormControl> <FormControl isReadOnly>
<FormLabel>Fullname</FormLabel> <FormLabel>Fullname</FormLabel>
<Input /> <Input defaultValue={me.fullname} />
</FormControl> </FormControl>
<FormControl isReadOnly> <FormControl isReadOnly>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<InputGroup size='md'> <InputGroup size='md'>
<Input defaultValue={'serzh.yarkov@gmail.com'} /> <Input defaultValue={me.email} />
<InputRightElement width='4.5rem' pr='0.5rem'> <InputRightElement width='4.5rem' pr='0.5rem'>
<Button h='1.75rem' size='sm'> <Button onClick={handleChangeEmail} h='1.75rem' size='sm'>
Change Change
</Button> </Button>
</InputRightElement> </InputRightElement>
@ -80,7 +164,10 @@ const ProfilePage: React.FC<IPageProps> = () => {
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>About</FormLabel> <FormLabel>About</FormLabel>
<Textarea minH='130px' maxH='500px' /> <Textarea minH='130px' maxH='500px' isInvalid={!!errors.about} {...register('about')} />
<Text as='small' color='red.500'>
{errors.about?.message}
</Text>
<FormHelperText>Write description for your profile.</FormHelperText> <FormHelperText>Write description for your profile.</FormHelperText>
</FormControl> </FormControl>
</Stack> </Stack>
@ -94,15 +181,28 @@ const ProfilePage: React.FC<IPageProps> = () => {
<Stack spacing={'6'}> <Stack spacing={'6'}>
<FormControl> <FormControl>
<FormLabel>Phone</FormLabel> <FormLabel>Phone</FormLabel>
<Input placeholder='+1 (234) 555-1234' /> <Input
placeholder='+1 (234) 555-1234'
isInvalid={!!errors.phone_number}
{...register('phone_number')}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Twitter</FormLabel> <FormLabel>Twitter</FormLabel>
<Input placeholder='@sergeyyarkov' /> <Input placeholder='@username' isInvalid={!!errors.twitter_id} {...register('twitter_id')} />
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Telegram</FormLabel> <FormLabel>Telegram</FormLabel>
<Input placeholder='@sergeyyarkov' /> <Input placeholder='@username' isInvalid={!!errors.telegram_id} {...register('telegram_id')} />
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl> </FormControl>
</Stack> </Stack>
</UserProfile.Settings.EntryContent> </UserProfile.Settings.EntryContent>
@ -117,7 +217,7 @@ const ProfilePage: React.FC<IPageProps> = () => {
<InputGroup size='md'> <InputGroup size='md'>
<Input defaultValue={'**********'} /> <Input defaultValue={'**********'} />
<InputRightElement width='4.5rem' pr='0.5rem'> <InputRightElement width='4.5rem' pr='0.5rem'>
<Button h='1.75rem' size='sm'> <Button onClick={handleChangePassword} h='1.75rem' size='sm'>
Change Change
</Button> </Button>
</InputRightElement> </InputRightElement>
@ -143,23 +243,23 @@ const ProfilePage: React.FC<IPageProps> = () => {
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' /> <UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Flex mt={{ base: '6', md: '0' }}> <Box>
<Switch size='lg' /> <SwitchDarkThemeButton />
</Flex> </Box>
</UserProfile.Settings.EntryContent> </UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry> </UserProfile.Settings.Entry>
{/* Save Button */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title /> <UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Box> <Box>
<Button variant={'outline'} leftIcon={<MdOutlineSave />}> <SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} />
Save
</Button>
</Box> </Box>
</UserProfile.Settings.EntryContent> </UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry> </UserProfile.Settings.Entry>
</UserProfile.Settings> </UserProfile.Settings>
</form>
</UserProfile> </UserProfile>
</Page.Content> </Page.Content>
</Page> </Page>

View File

@ -1,12 +1,13 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { ApiServiceInstance } from '.'; import { ApiServiceInstance } from '.';
import * as helpers from '@educt/helpers'; import * as helpers from '@educt/helpers';
import { IApiRespose, IMe, IUser, IUserContacts } from '@educt/interfaces'; import { IApiRespose, IMe, IUser, IUserContacts, IUserInfo } from '@educt/interfaces';
import { import {
CreateUserParamsType, CreateUserParamsType,
FetchUsersParamsType, FetchUsersParamsType,
UpdateUserParamsType, UpdateUserParamsType,
UpdateUserContactsParamsType, UpdateUserContactsParamsType,
UpdateUserInfoParamsType,
} from '@educt/types'; } from '@educt/types';
class UserService { class UserService {
@ -108,6 +109,17 @@ class UserService {
return result.data; return result.data;
} }
/**
* Update user info
*
* @param data Fields to update
* @returns Updated fields
*/
public async updateInfo(data: UpdateUserInfoParamsType): Promise<IApiRespose<Partial<IUserInfo>>> {
const result = await this.api.patch('v1/me/info', data);
return result.data;
}
/** /**
* Update password of authorized user * Update password of authorized user
* *

View File

@ -8,6 +8,7 @@ import {
CreateUserParamsType, CreateUserParamsType,
FetchUsersParamsType, FetchUsersParamsType,
UpdateUserContactsParamsType, UpdateUserContactsParamsType,
UpdateUserInfoParamsType,
UpdateUserParamsType, UpdateUserParamsType,
} from '@educt/types'; } from '@educt/types';
import { IUser, IMe, IPaginationMeta } from '@educt/interfaces'; import { IUser, IMe, IPaginationMeta } from '@educt/interfaces';

View File

@ -134,6 +134,7 @@ export type CreateUserParamsType = {
password: string; password: string;
}; };
export type UpdateUserParamsType = { first_name?: string; last_name?: string; email?: string }; export type UpdateUserParamsType = { first_name?: string; last_name?: string; email?: string };
export type UpdateUserContactsParamsType = { export type UpdateUserContactsParamsType = {
phone_number?: string | null | undefined; phone_number?: string | null | undefined;
twitter_id?: string | null | undefined; twitter_id?: string | null | undefined;
@ -141,6 +142,10 @@ export type UpdateUserContactsParamsType = {
vk_id?: string | null | undefined; vk_id?: string | null | undefined;
}; };
export type UpdateUserInfoParamsType = {
about?: string | null | undefined;
} & UpdateUserContactsParamsType;
export type CreateCourseParamsType = { export type CreateCourseParamsType = {
title: string; title: string;
description: string; description: string;