refactor: profile page

This commit is contained in:
Sergey Yarkov 2022-02-04 22:46:46 +03:00
parent f3dc0f14c5
commit 4069ac5b45
7 changed files with 433 additions and 233 deletions

View File

@ -1,10 +0,0 @@
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

@ -0,0 +1,196 @@
import React from 'react';
import {
Box,
FormControl,
FormHelperText,
FormLabel,
Input,
InputGroup,
InputRightElement,
InputProps,
Button,
Textarea,
Text,
TextareaProps,
Select,
SelectProps,
Switch,
SwitchProps,
ButtonProps,
} from '@chakra-ui/react';
import { SaveButton } from '@educt/components/Buttons';
/**
* Hooks
*/
import { useColorMode } from '@chakra-ui/react';
import { useSettingsContext } from '../context';
import { useHistory } from 'react-router-dom';
const InputWithButton: React.FC<
InputProps & { buttonText?: string | undefined; buttonHandler?: () => void }
> = props => {
const { buttonText, buttonHandler, ...inputProps } = props;
return (
<InputGroup size='md'>
<Input {...inputProps} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button onClick={buttonHandler} h='1.75rem' size='sm'>
{buttonText || 'Click'}
</Button>
</InputRightElement>
</InputGroup>
);
};
export const FullnameInput: React.FC<InputProps> = props => (
<FormControl isReadOnly>
<FormLabel>Fullname</FormLabel>
<Input {...props} />
</FormControl>
);
export const EmailInput: React.FC<InputProps> = props => {
const history = useHistory();
const onChangeEmail = () => history.push(`/profile/change-email`);
return (
<FormControl isReadOnly>
<FormLabel>Email</FormLabel>
<InputWithButton buttonHandler={onChangeEmail} buttonText='Change' {...props} />
</FormControl>
);
};
export const AboutInput: React.FC<TextareaProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>About</FormLabel>
<Textarea minH='130px' maxH='500px' isInvalid={!!errors.about} {...register('about')} {...props} />
<Text as='small' color='red.500'>
{errors.about?.message}
</Text>
<FormHelperText>Write description for your profile.</FormHelperText>
</FormControl>
);
};
export const PhoneInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Phone</FormLabel>
<Input
placeholder='+1 (234) 555-1234'
isInvalid={!!errors.phone_number}
{...register('phone_number')}
{...props}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl>
);
};
export const TwitterInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Twitter</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.twitter_id} {...register('twitter_id')} {...props} />
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl>
);
};
export const TelegramInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Telegram</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.telegram_id} {...register('telegram_id')} {...props} />
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl>
);
};
export const PasswordInput: React.FC<InputProps> = props => {
const history = useHistory();
const onChangePassword = () => history.push(`/profile/change-password`);
return (
<FormControl isReadOnly>
<FormLabel>Password</FormLabel>
<InputWithButton buttonHandler={onChangePassword} buttonText='Change' {...props} />
</FormControl>
);
};
export const LanguageSelect: React.FC<SelectProps> = props => {
return (
<FormControl>
<FormLabel>Display Language</FormLabel>
<Select {...props}>
<option value='en'>English</option>
</Select>
</FormControl>
);
};
export const ThemeSwitcher: React.FC<SwitchProps> = props => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<Box>
<Switch onChange={toggleColorMode} size='lg' isChecked={colorMode === 'dark'} {...props} />
</Box>
);
};
export const SaveSettingsButton: React.FC<ButtonProps> = props => {
const {
form: {
formState: { isDirty },
},
isLoading,
} = useSettingsContext();
return (
<Box>
<SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} {...props} />
</Box>
);
};

View File

@ -1,17 +1,57 @@
import React from 'react';
import { Box, BoxProps, Heading as ChakraHeading, Divider, Stack, StackDivider } from '@chakra-ui/react';
import {
Box,
BoxProps,
Heading as ChakraHeading,
Divider,
InputProps,
TextareaProps,
Stack,
StackDivider,
SelectProps,
ButtonProps,
} from '@chakra-ui/react';
import { Title, ITitleProps } from './Title';
import { Entry } from './Entry';
import { EntryContent } from './EntryContent';
import { SettingsContextProvider } from './SettingsContextProvider';
/**
* Form input fields
*/
import {
FullnameInput,
EmailInput,
AboutInput,
PhoneInput,
TwitterInput,
TelegramInput,
PasswordInput,
LanguageSelect,
ThemeSwitcher,
SaveSettingsButton,
} from './Fields';
import { SwitchProps } from 'react-router-dom';
export interface ISettingsComposition {
Title: React.FC<ITitleProps & BoxProps>;
Entry: React.FC;
EntryContent: React.FC;
FullnameInput: React.FC<InputProps>;
EmailInput: React.FC<InputProps>;
AboutInput: React.FC<TextareaProps>;
PhoneInput: React.FC<InputProps>;
TwitterInput: React.FC<InputProps>;
TelegramInput: React.FC<InputProps>;
PasswordInput: React.FC<InputProps>;
LanguageSelect: React.FC<SelectProps>;
ThemeSwitcher: React.FC<SwitchProps>;
SaveSettingsButton: React.FC<ButtonProps>;
}
const Settings: React.FC<BoxProps> & ISettingsComposition = props => {
return (
<SettingsContextProvider>
<Box maxW={{ base: 'full', lg: '750px' }} {...props}>
<ChakraHeading as='h2' mt='12' fontSize={'2xl'} fontWeight={'bold'}>
Account Settings
@ -21,11 +61,22 @@ const Settings: React.FC<BoxProps> & ISettingsComposition = props => {
{props.children}
</Stack>
</Box>
</SettingsContextProvider>
);
};
Settings.Title = Title;
Settings.Entry = Entry;
Settings.EntryContent = EntryContent;
Settings.FullnameInput = FullnameInput;
Settings.EmailInput = EmailInput;
Settings.AboutInput = AboutInput;
Settings.PhoneInput = PhoneInput;
Settings.TwitterInput = TwitterInput;
Settings.TelegramInput = TelegramInput;
Settings.PasswordInput = PasswordInput;
Settings.LanguageSelect = LanguageSelect;
Settings.ThemeSwitcher = ThemeSwitcher;
Settings.SaveSettingsButton = SaveSettingsButton;
export { Settings };

View File

@ -0,0 +1,26 @@
import yup from '@educt/schema';
const SettingsSchema = 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),
});
export { SettingsSchema };

View File

@ -0,0 +1,71 @@
import React from 'react';
import { runInAction } from 'mobx';
import * as helpers from '@educt/helpers';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormTypes, SettingsContext } from './context';
/**
* Hooks
*/
import { useForm } from 'react-hook-form';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useUpdateUserInfo } from '@educt/hooks/queries';
/**
* Validator
*/
import { SettingsSchema } from './Settings.validator';
const SettingsContextProvider: React.FC = ({ children }) => {
const {
userStore: { me },
} = useRootStore();
const form = useForm<FormTypes>({
resolver: yupResolver(SettingsSchema),
defaultValues: {
about: me?.about || '',
phone_number: me?.contacts?.phone_number || '',
twitter_id: me?.contacts?.twitter_id || '',
telegram_id: me?.contacts?.telegram_id || '',
},
});
const { updateInfo, isLoading } = useUpdateUserInfo();
const {
formState: { dirtyFields },
reset,
} = form;
const onSubmit = async (data: FormTypes) => {
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 (
<SettingsContext.Provider value={{ form, isLoading }}>
<form onSubmit={form.handleSubmit(onSubmit)}>{children}</form>
</SettingsContext.Provider>
);
};
export { SettingsContextProvider };

View File

@ -0,0 +1,26 @@
import { createContext, useContext } from 'react';
import { UseFormReturn } from 'react-hook-form';
interface ISettingsContext {
form: UseFormReturn<FormTypes, object>;
isLoading: boolean;
}
export type FormTypes = {
about: string;
phone_number: string;
twitter_id: string;
telegram_id: string;
};
export const SettingsContext = createContext<ISettingsContext | undefined>(undefined);
export const useSettingsContext = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('SettingsContext was outside of its provider');
}
return context;
};

View File

@ -1,22 +1,6 @@
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,
FormControl,
FormLabel,
Input,
Textarea,
Text,
FormHelperText,
Select,
Button,
} from '@chakra-ui/react';
import { Box, Stack } from '@chakra-ui/layout';
import { Stack } from '@chakra-ui/layout';
/**
* Types
@ -28,47 +12,12 @@ import type { IPageProps } from '@educt/interfaces';
*/
import { Page } from '@educt/components/PageElements';
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 { 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
@ -77,55 +26,9 @@ const ProfilePage: React.FC<IPageProps> = () => {
const {
userStore: { me },
} = useRootStore();
const { updateInfo, isLoading } = useUpdateUserInfo();
const history = useHistory();
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 your profile information or password here.' />
@ -140,36 +43,15 @@ const ProfilePage: React.FC<IPageProps> = () => {
</UserProfile.Info>
</UserProfile.Base>
<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>
<UserProfile.Settings.FullnameInput defaultValue={me.fullname} />
<UserProfile.Settings.EmailInput defaultValue={me.email} />
<UserProfile.Settings.AboutInput />
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
@ -179,31 +61,9 @@ const ProfilePage: React.FC<IPageProps> = () => {
<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>
<UserProfile.Settings.PhoneInput />
<UserProfile.Settings.TwitterInput />
<UserProfile.Settings.TelegramInput />
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
@ -212,17 +72,7 @@ const ProfilePage: React.FC<IPageProps> = () => {
<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 onClick={handleChangePassword} h='1.75rem' size='sm'>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
<UserProfile.Settings.PasswordInput defaultValue={'**********'} />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
@ -230,12 +80,7 @@ const ProfilePage: React.FC<IPageProps> = () => {
<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.LanguageSelect />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
@ -243,9 +88,7 @@ const ProfilePage: React.FC<IPageProps> = () => {
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent>
<Box>
<SwitchDarkThemeButton />
</Box>
<UserProfile.Settings.ThemeSwitcher />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
@ -253,13 +96,10 @@ const ProfilePage: React.FC<IPageProps> = () => {
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent>
<Box>
<SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} />
</Box>
<UserProfile.Settings.SaveSettingsButton />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
</UserProfile.Settings>
</form>
</UserProfile>
</Page.Content>
</Page>