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 { 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 };
export { Base } from './Base';

View File

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

View File

@ -29,6 +29,7 @@ import { useCreateUser } from './user/useCreateUser';
import { useDeleteUser } from './user/useDeleteUser';
import { useUpdateUser } from './user/useUpdateUser';
import { useUpdateUserEmail } from './user/useUpdateUserEmail';
import { useUpdateUserInfo } from './user/useUpdateUserInfo';
/**
* Category
@ -59,6 +60,7 @@ export {
useCreateUser,
useDeleteUser,
useUpdateUser,
useUpdateUserInfo,
useUpdateUserEmail,
useCreateCategory,
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;
}
export interface IUserInfo extends IUserContacts {
about: string | null;
}
export interface IUserRole {
id: number;
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 { 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 {
InputGroup,
InputRightElement,
Flex,
Switch,
FormControl,
FormLabel,
Input,
Textarea,
Text,
FormHelperText,
Select,
Button,
@ -17,21 +21,54 @@ import { Box, Stack } from '@chakra-ui/layout';
/**
* Types
*/
import { IPageProps } from '@educt/interfaces';
import type { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import { Page } from '@educt/components/PageElements';
import { ProfileLoading } from './components';
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
*/
import { useHistory } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useRootStore } from '@educt/hooks/useRootStore';
import { observer } from 'mobx-react';
import { MdOutlineSave } from 'react-icons/md';
import { useUpdateUserInfo } from '@educt/hooks/queries';
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
@ -40,12 +77,58 @@ const ProfilePage: React.FC<IPageProps> = () => {
const {
userStore: { me },
} = 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 (
<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>
<UserProfile mt='14'>
<UserProfile.Base>
@ -57,109 +140,126 @@ const ProfilePage: React.FC<IPageProps> = () => {
</UserProfile.Info>
</UserProfile.Base>
<UserProfile.Settings>
{/* Personal Info */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Personal Info'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<FormControl>
<FormLabel>Fullname</FormLabel>
<Input />
</FormControl>
<form onSubmit={handleSubmit(handleUpdateProfile)}>
<UserProfile.Settings>
{/* Personal Info */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Personal Info'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<FormControl isReadOnly>
<FormLabel>Fullname</FormLabel>
<Input defaultValue={me.fullname} />
</FormControl>
<FormControl isReadOnly>
<FormLabel>Email</FormLabel>
<InputGroup size='md'>
<Input defaultValue={me.email} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button onClick={handleChangeEmail} h='1.75rem' size='sm'>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
<FormControl>
<FormLabel>About</FormLabel>
<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>
</FormControl>
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Contacts */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Contacts'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<FormControl>
<FormLabel>Phone</FormLabel>
<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>
<FormLabel>Twitter</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.twitter_id} {...register('twitter_id')} />
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl>
<FormControl>
<FormLabel>Telegram</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.telegram_id} {...register('telegram_id')} />
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl>
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Security */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Security' />
<UserProfile.Settings.EntryContent>
<FormControl isReadOnly>
<FormLabel>Email</FormLabel>
<FormLabel>Password</FormLabel>
<InputGroup size='md'>
<Input defaultValue={'serzh.yarkov@gmail.com'} />
<Input defaultValue={'**********'} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button h='1.75rem' size='sm'>
<Button onClick={handleChangePassword} h='1.75rem' size='sm'>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Language */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Language' />
<UserProfile.Settings.EntryContent>
<FormControl>
<FormLabel>About</FormLabel>
<Textarea minH='130px' maxH='500px' />
<FormHelperText>Write description for your profile.</FormHelperText>
<FormLabel>Display Language</FormLabel>
<Select>
<option value='en'>English</option>
</Select>
</FormControl>
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Contacts */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Contacts'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<FormControl>
<FormLabel>Phone</FormLabel>
<Input placeholder='+1 (234) 555-1234' />
</FormControl>
<FormControl>
<FormLabel>Twitter</FormLabel>
<Input placeholder='@sergeyyarkov' />
</FormControl>
<FormControl>
<FormLabel>Telegram</FormLabel>
<Input placeholder='@sergeyyarkov' />
</FormControl>
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Night Theme */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent>
<Box>
<SwitchDarkThemeButton />
</Box>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Security */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Security' />
<UserProfile.Settings.EntryContent>
<FormControl isReadOnly>
<FormLabel>Password</FormLabel>
<InputGroup size='md'>
<Input defaultValue={'**********'} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button h='1.75rem' size='sm'>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Language */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Language' />
<UserProfile.Settings.EntryContent>
<FormControl>
<FormLabel>Display Language</FormLabel>
<Select>
<option value='en'>English</option>
</Select>
</FormControl>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Night Theme */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent>
<Flex mt={{ base: '6', md: '0' }}>
<Switch size='lg' />
</Flex>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent>
<Box>
<Button variant={'outline'} leftIcon={<MdOutlineSave />}>
Save
</Button>
</Box>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
</UserProfile.Settings>
{/* Save Button */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent>
<Box>
<SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} />
</Box>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
</UserProfile.Settings>
</form>
</UserProfile>
</Page.Content>
</Page>

View File

@ -1,12 +1,13 @@
import { AxiosInstance } from 'axios';
import { ApiServiceInstance } from '.';
import * as helpers from '@educt/helpers';
import { IApiRespose, IMe, IUser, IUserContacts } from '@educt/interfaces';
import { IApiRespose, IMe, IUser, IUserContacts, IUserInfo } from '@educt/interfaces';
import {
CreateUserParamsType,
FetchUsersParamsType,
UpdateUserParamsType,
UpdateUserContactsParamsType,
UpdateUserInfoParamsType,
} from '@educt/types';
class UserService {
@ -108,6 +109,17 @@ class UserService {
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
*

View File

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

View File

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