feat: update user contacts

This commit is contained in:
Sergey Yarkov 2021-08-30 22:45:07 +03:00
parent cd27d81ccd
commit e402e6ac29
15 changed files with 299 additions and 46 deletions

11
package-lock.json generated
View File

@ -3133,6 +3133,12 @@
"source-map": "^0.6.1" "source-map": "^0.6.1"
} }
}, },
"@types/validator": {
"version": "13.6.3",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.6.3.tgz",
"integrity": "sha512-fWG42pMJOL4jKsDDZZREnXLjc3UE0R8LOJfARWYg6U966rxDT7TYejYzLnUF5cvSObGg34nd0+H2wHHU5Omdfw==",
"dev": true
},
"@types/warning": { "@types/warning": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
@ -15258,6 +15264,11 @@
"spdx-expression-parse": "^3.0.0" "spdx-expression-parse": "^3.0.0"
} }
}, },
"validator": {
"version": "13.6.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.6.0.tgz",
"integrity": "sha512-gVgKbdbHgtxpRyR8K0O6oFZPhhB5tT1jeEHZR0Znr9Svg03U0+r9DXWMrnRAB+HtCStDQKlaIZm42tVsVjqtjg=="
},
"value-equal": { "value-equal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",

View File

@ -28,6 +28,7 @@
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"typescript": "^3.9.5", "typescript": "^3.9.5",
"validator": "^13.6.0",
"web-vitals": "^0.2.2", "web-vitals": "^0.2.2",
"yup": "^0.32.9" "yup": "^0.32.9"
}, },
@ -58,6 +59,7 @@
"@types/js-cookie": "^2.2.7", "@types/js-cookie": "^2.2.7",
"@types/react": "^16.14.13", "@types/react": "^16.14.13",
"@types/react-helmet": "^6.1.2", "@types/react-helmet": "^6.1.2",
"@types/react-router-dom": "^5.1.8" "@types/react-router-dom": "^5.1.8",
"@types/validator": "^13.6.3"
} }
} }

View File

@ -15,7 +15,7 @@ import {
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import useLoginQuery from 'hooks/useLoginQuery'; import useLoginQuery from 'hooks/useLoginQuery';
import LoginValidatorSchema from 'validators/LoginValidatorSchema'; import LoginSchema from 'validators/LoginSchema';
type AuthInputType = { type AuthInputType = {
login: string; login: string;
@ -29,7 +29,7 @@ const AuthForm: React.FC = () => {
formState: { errors }, formState: { errors },
handleSubmit, handleSubmit,
} = useForm<AuthInputType>({ } = useForm<AuthInputType>({
resolver: yupResolver(LoginValidatorSchema), resolver: yupResolver(LoginSchema),
}); });
const { login, loading } = useLoginQuery(); const { login, loading } = useLoginQuery();

View File

@ -1,39 +1,107 @@
import React from 'react'; import React from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { MdSave } from 'react-icons/md'; import { MdSave } from 'react-icons/md';
import { IUserContacts } from 'interfaces';
import { Box, Heading, Stack } from '@chakra-ui/layout'; import { Box, Heading, Stack } from '@chakra-ui/layout';
import { FormControl, FormHelperText } from '@chakra-ui/form-control'; import { FormControl, FormHelperText } from '@chakra-ui/form-control';
import { Input } from '@chakra-ui/input'; import { Input } from '@chakra-ui/input';
import { Text } from '@chakra-ui/react';
import { Button } from '@chakra-ui/button'; import { Button } from '@chakra-ui/button';
import { yupResolver } from '@hookform/resolvers/yup';
import UpdateUserContactsSchema from 'validators/UpdateUserContactsSchema';
import useUpdateUserContactsQuery from 'hooks/useUpdateUserContactsQuery';
type UpdateUserContactsInputType = {
phone_number: string | null;
twitter_id: string | null;
telegram_id: string | null;
vk_id: string | null;
};
type UpdateUserContactsFormProps = {
contacts: IUserContacts | null;
};
const UpdateUserContactsForm: React.FC<UpdateUserContactsFormProps> = ({ contacts }) => {
const {
register,
formState: { errors, 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 { updateContacts, loading } = useUpdateUserContactsQuery();
const onSubmit: SubmitHandler<UpdateUserContactsInputType> = data => {
updateContacts(data).finally(() => reset(data));
};
const UpdateUserContactsForm: React.FC = () => {
return ( return (
<Box as='form'> <Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Box borderRadius='md' borderWidth='1px' padding='20px'> <Box borderRadius='md' borderWidth='1px' padding='20px'>
<Heading as='h3' size='lg'> <Heading as='h3' size='lg'>
My Contacts My Contacts
</Heading> </Heading>
<Stack spacing='5px' margin='20px 0'> <Stack spacing='5px' margin='20px 0'>
<FormControl id='tel'> <FormControl id='phone_number'>
<FormHelperText color='gray.500'>Telephone</FormHelperText> <FormHelperText color='gray.500'>Phone number with country code</FormHelperText>
<Input type='number' size='md' variant='flushed' placeholder='123-456-7890' /> <Input
type='text'
size='md'
variant='flushed'
placeholder='+1 (234) 555-1234'
{...register('phone_number')}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='twitter'> <FormControl id='twitter_id'>
<FormHelperText color='gray.500'>Twitter</FormHelperText> <FormHelperText color='gray.500'>Twitter</FormHelperText>
<Input type='text' size='md' variant='flushed' placeholder='https://twitter.com/id' /> <Input type='text' size='md' variant='flushed' placeholder='@tw_username' {...register('twitter_id')} />
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='telegram'> <FormControl id='telegram'>
<FormHelperText color='gray.500'>Telegram</FormHelperText> <FormHelperText color='gray.500'>Telegram</FormHelperText>
<Input type='text' size='md' variant='flushed' placeholder='@tag' /> <Input type='text' size='md' variant='flushed' placeholder='@tg_username' {...register('telegram_id')} />
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl> </FormControl>
<FormControl id='vk'> <FormControl id='vk'>
<FormHelperText color='gray.500'>VKontakte</FormHelperText> <FormHelperText color='gray.500'>VKontakte</FormHelperText>
<Input type='text' size='md' variant='flushed' placeholder='https://vk.com/id' /> <Input type='text' size='md' variant='flushed' placeholder='VKontakte id' {...register('vk_id')} />
<Text as='small' color='red.500'>
{errors.vk_id?.message}
</Text>
</FormControl> </FormControl>
</Stack> </Stack>
</Box> </Box>
<Button colorScheme='blue' mt='4' type='submit' size='md' variant='outline' rightIcon={<MdSave />}> <Button
colorScheme='blue'
mt='4'
type='submit'
size='md'
variant='outline'
isLoading={loading}
loadingText='Saving...'
rightIcon={<MdSave />}
isDisabled={!isDirty}
>
Save Save
</Button> </Button>
</form>
</Box> </Box>
); );
}; };

View File

@ -12,3 +12,13 @@ export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boo
} }
return true; return true;
} }
/**
* Clean object values
*
* @param obj Current object that have null values
* @returns Clean object that don't have null values
*/
export function removeEmptyValues(obj: object) {
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== null));
}

View File

@ -0,0 +1,65 @@
import { useToast } from '@chakra-ui/react';
import { IDataResult, IUserContacts } from 'interfaces';
import { useState } from 'react';
import { useErrorHandler } from 'react-error-boundary';
import useIsMountedRef from './useIsMountedRef';
import { useRootStore } from './useRootStore';
type UpdateContactsStateType = {
result: IDataResult | null;
error: any;
loading: boolean;
fetched: boolean;
};
const useUpdateUserContactsQuery = () => {
const isMountedRef = useIsMountedRef();
const [state, setState] = useState<UpdateContactsStateType>({
result: null,
error: null,
loading: false,
fetched: false,
});
const { userStore } = useRootStore();
const toast = useToast();
const handleError = useErrorHandler();
const updateContacts = async (data: IUserContacts) => {
try {
setState(s => ({ ...s, loading: true }));
const result = await userStore.updateCurrentUserContacts(data);
toast({ title: `Contacts saved!`, isClosable: true, status: 'info' });
if (isMountedRef.current) {
setState(s => ({ ...s, result }));
}
} catch (error) {
if (isMountedRef.current) {
setState(s => ({ ...s, error }));
}
if (error.response) {
/**
* Catch response error
*/
switch (error.response.status) {
case 422:
toast({ title: 'Validation error.', status: 'error', duration: 2000 });
break;
default:
handleError(error);
}
} else {
console.error(error);
handleError(error);
}
} finally {
if (isMountedRef.current) {
setState(s => ({ ...s, loading: false, fetched: true }));
}
}
};
return { ...state, updateContacts };
};
export default useUpdateUserContactsQuery;

View File

@ -24,6 +24,14 @@ export interface IUser {
last_name: string; last_name: string;
email: string; email: string;
roles: IUserRole[]; roles: IUserRole[];
contacts: IUserContacts | null;
}
export interface IUserContacts {
phone_number: string | null;
twitter_id: string | null;
telegram_id: string | null;
vk_id: string | null;
} }
export interface IUserRole { export interface IUserRole {
@ -46,14 +54,12 @@ export interface ILoginResult extends IDataResult {
}; };
} }
export interface IUpdatedContactsResult extends IDataResult {
data: IUserContacts;
}
export interface IUserResult extends IDataResult { export interface IUserResult extends IDataResult {
data: { data: IUser;
id: string;
first_name: string;
last_name: string;
email: string;
roles: IUserRole[];
};
} }
export interface IPageProps extends RouteComponentProps { export interface IPageProps extends RouteComponentProps {

View File

@ -108,7 +108,7 @@ const ProfilePage: React.FC<IPageProps> = ({ title }) => {
<UserAccountInfo user={userStore.me} /> <UserAccountInfo user={userStore.me} />
</TabPanel> </TabPanel>
<TabPanel padding='10px 0' mt='20px'> <TabPanel padding='10px 0' mt='20px'>
<UpdateUserContactsForm /> <UpdateUserContactsForm contacts={userStore.me.contacts} />
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>

View File

@ -1,5 +1,6 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { IUserResult } from 'interfaces'; import { IUpdatedContactsResult, IUserContacts, IUserResult } from 'interfaces';
import * as helpers from 'helpers';
export default class UserService { export default class UserService {
public api: AxiosInstance; public api: AxiosInstance;
@ -17,4 +18,15 @@ export default class UserService {
const result = await this.api.get('v1/me'); const result = await this.api.get('v1/me');
return result.data; return result.data;
} }
/**
* Update authorized user contacts data
*
* @param data User contacts
* @returns Updated user contacts data
*/
public async updateContacts(data: IUserContacts): Promise<IUpdatedContactsResult> {
const result = await this.api.put('v1/me/contacts', helpers.removeEmptyValues(data));
return result.data;
}
} }

View File

@ -1,6 +1,6 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { makeAutoObservable, runInAction } from 'mobx'; import { makeAutoObservable, runInAction } from 'mobx';
import { IUserResult, IUser } from 'interfaces'; import { IUserResult, IUser, IUserContacts, IUpdatedContactsResult } from 'interfaces';
/** /**
* Services * Services
@ -41,6 +41,22 @@ export default class UserStore {
} }
} }
public async updateCurrentUserContacts(data: IUserContacts): Promise<IUpdatedContactsResult> {
try {
const result = await this.userService.updateContacts(data);
runInAction(() => {
if (this.me) {
this.me.contacts = result.data;
}
});
return result;
} catch (error) {
throw error;
}
}
public reset(): void { public reset(): void {
runInAction(() => { runInAction(() => {
this.me = null; this.me = null;

10
src/types/yup/index.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import validator from 'validator';
declare module 'yup' {
interface StringSchema {
isMobileValid(
locales: validator.MobilePhoneLocale[],
options?: validator.IsMobilePhoneOptions | undefined
): StringSchema;
}
}

View File

@ -1,8 +1,8 @@
import * as yup from 'yup'; import yup from 'validators';
const LoginValidatorSchema = yup.object().shape({ const LoginSchema = yup.object().shape({
login: yup.string().required('Login is required'), login: yup.string().required('Login is required'),
password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'), password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'),
}); });
export default LoginValidatorSchema; export default LoginSchema;

View File

@ -0,0 +1,26 @@
import yup from 'validators';
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;

25
src/validators/index.ts Normal file
View File

@ -0,0 +1,25 @@
import * as yup from 'yup';
import validator from 'validator';
import isMobilePhone from 'validator/lib/isMobilePhone';
import { string, StringSchema } from 'yup';
/**
* Delcare custom methods
*/
yup.addMethod<StringSchema>(
string,
'isMobileValid',
function (
this: StringSchema,
locales: validator.MobilePhoneLocale[],
options?: validator.IsMobilePhoneOptions | undefined
) {
return this.test({
name: 'isMobileValid',
message: 'Phone number is not valid',
test: value => (!value ? true : isMobilePhone(value, locales, options)),
});
}
);
export default yup;

View File

@ -15,7 +15,9 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react" "jsx": "react",
"typeRoots": ["./types", "./node_modules/@types"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["types"]
} }