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,31 +1,82 @@
import React from 'react'; 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 { Title, ITitleProps } from './Title';
import { Entry } from './Entry'; import { Entry } from './Entry';
import { EntryContent } from './EntryContent'; 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 { export interface ISettingsComposition {
Title: React.FC<ITitleProps & BoxProps>; Title: React.FC<ITitleProps & BoxProps>;
Entry: React.FC; Entry: React.FC;
EntryContent: 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 => { const Settings: React.FC<BoxProps> & ISettingsComposition = props => {
return ( return (
<Box maxW={{ base: 'full', lg: '750px' }} {...props}> <SettingsContextProvider>
<ChakraHeading as='h2' mt='12' fontSize={'2xl'} fontWeight={'bold'}> <Box maxW={{ base: 'full', lg: '750px' }} {...props}>
Account Settings <ChakraHeading as='h2' mt='12' fontSize={'2xl'} fontWeight={'bold'}>
</ChakraHeading> Account Settings
<Divider my='5' /> </ChakraHeading>
<Stack divider={<StackDivider />} spacing={'5'}> <Divider my='5' />
{props.children} <Stack divider={<StackDivider />} spacing={'5'}>
</Stack> {props.children}
</Box> </Stack>
</Box>
</SettingsContextProvider>
); );
}; };
Settings.Title = Title; Settings.Title = Title;
Settings.Entry = Entry; Settings.Entry = Entry;
Settings.EntryContent = EntryContent; 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 }; 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 React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { runInAction } from 'mobx'; import { Stack } from '@chakra-ui/layout';
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';
/** /**
* Types * Types
@ -28,47 +12,12 @@ import type { IPageProps } from '@educt/interfaces';
*/ */
import { Page } from '@educt/components/PageElements'; import { Page } from '@educt/components/PageElements';
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'; 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 { 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 * Profile page
@ -77,55 +26,9 @@ const ProfilePage: React.FC<IPageProps> = () => {
const { const {
userStore: { me }, userStore: { me },
} = useRootStore(); } = 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 />; 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 your profile information or password here.' /> <Page.Heading heading='My account' description='Update your profile information or password here.' />
@ -140,126 +43,63 @@ 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'}> <UserProfile.Settings.FullnameInput defaultValue={me.fullname} />
<FormControl isReadOnly> <UserProfile.Settings.EmailInput defaultValue={me.email} />
<FormLabel>Fullname</FormLabel> <UserProfile.Settings.AboutInput />
<Input defaultValue={me.fullname} /> </Stack>
</FormControl> </UserProfile.Settings.EntryContent>
<FormControl isReadOnly> </UserProfile.Settings.Entry>
<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 */} {/* Contacts */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Contacts'} /> <UserProfile.Settings.Title title={'Contacts'} />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Stack spacing={'6'}> <Stack spacing={'6'}>
<FormControl> <UserProfile.Settings.PhoneInput />
<FormLabel>Phone</FormLabel> <UserProfile.Settings.TwitterInput />
<Input <UserProfile.Settings.TelegramInput />
placeholder='+1 (234) 555-1234' </Stack>
isInvalid={!!errors.phone_number} </UserProfile.Settings.EntryContent>
{...register('phone_number')} </UserProfile.Settings.Entry>
/>
<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 */} {/* Security */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Security' /> <UserProfile.Settings.Title title='Security' />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<FormControl isReadOnly> <UserProfile.Settings.PasswordInput defaultValue={'**********'} />
<FormLabel>Password</FormLabel> </UserProfile.Settings.EntryContent>
<InputGroup size='md'> </UserProfile.Settings.Entry>
<Input defaultValue={'**********'} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button onClick={handleChangePassword} h='1.75rem' size='sm'>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Language */} {/* Language */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Language' /> <UserProfile.Settings.Title title='Language' />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<FormControl> <UserProfile.Settings.LanguageSelect />
<FormLabel>Display Language</FormLabel> </UserProfile.Settings.EntryContent>
<Select> </UserProfile.Settings.Entry>
<option value='en'>English</option>
</Select>
</FormControl>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Night Theme */} {/* Night Theme */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' /> <UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Box> <UserProfile.Settings.ThemeSwitcher />
<SwitchDarkThemeButton /> </UserProfile.Settings.EntryContent>
</Box> </UserProfile.Settings.Entry>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Save Button */} {/* Save Button */}
<UserProfile.Settings.Entry> <UserProfile.Settings.Entry>
<UserProfile.Settings.Title /> <UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent> <UserProfile.Settings.EntryContent>
<Box> <UserProfile.Settings.SaveSettingsButton />
<SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} /> </UserProfile.Settings.EntryContent>
</Box> </UserProfile.Settings.Entry>
</UserProfile.Settings.EntryContent> </UserProfile.Settings>
</UserProfile.Settings.Entry>
</UserProfile.Settings>
</form>
</UserProfile> </UserProfile>
</Page.Content> </Page.Content>
</Page> </Page>