From e402e6ac293ae53b9012cc252a8fcfc7b3ca14b0 Mon Sep 17 00:00:00 2001 From: Sergey Yarkov Date: Mon, 30 Aug 2021 22:45:07 +0300 Subject: [PATCH] feat: update user contacts --- package-lock.json | 11 ++ package.json | 4 +- src/components/Forms/Auth/AuthForm.tsx | 4 +- .../Forms/User/UpdateUserContactsForm.tsx | 124 ++++++++++++++---- src/helpers/index.ts | 10 ++ src/hooks/useUpdateUserContactsQuery.ts | 65 +++++++++ src/interfaces/index.ts | 20 ++- src/pages/profile.tsx | 2 +- src/services/UserService.ts | 14 +- src/stores/UserStore.ts | 18 ++- src/types/yup/index.d.ts | 10 ++ ...LoginValidatorSchema.ts => LoginSchema.ts} | 6 +- src/validators/UpdateUserContactsSchema.ts | 26 ++++ src/validators/index.ts | 25 ++++ tsconfig.json | 6 +- 15 files changed, 299 insertions(+), 46 deletions(-) create mode 100644 src/hooks/useUpdateUserContactsQuery.ts create mode 100644 src/types/yup/index.d.ts rename src/validators/{LoginValidatorSchema.ts => LoginSchema.ts} (59%) create mode 100644 src/validators/UpdateUserContactsSchema.ts create mode 100644 src/validators/index.ts diff --git a/package-lock.json b/package-lock.json index 1b7e9c2..6e42bd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3133,6 +3133,12 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", @@ -15258,6 +15264,11 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", diff --git a/package.json b/package.json index a2fd2a8..72ea233 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "4.0.3", "typescript": "^3.9.5", + "validator": "^13.6.0", "web-vitals": "^0.2.2", "yup": "^0.32.9" }, @@ -58,6 +59,7 @@ "@types/js-cookie": "^2.2.7", "@types/react": "^16.14.13", "@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" } } diff --git a/src/components/Forms/Auth/AuthForm.tsx b/src/components/Forms/Auth/AuthForm.tsx index e04712b..f0e01f5 100644 --- a/src/components/Forms/Auth/AuthForm.tsx +++ b/src/components/Forms/Auth/AuthForm.tsx @@ -15,7 +15,7 @@ import { import { SubmitHandler, useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import useLoginQuery from 'hooks/useLoginQuery'; -import LoginValidatorSchema from 'validators/LoginValidatorSchema'; +import LoginSchema from 'validators/LoginSchema'; type AuthInputType = { login: string; @@ -29,7 +29,7 @@ const AuthForm: React.FC = () => { formState: { errors }, handleSubmit, } = useForm({ - resolver: yupResolver(LoginValidatorSchema), + resolver: yupResolver(LoginSchema), }); const { login, loading } = useLoginQuery(); diff --git a/src/components/Forms/User/UpdateUserContactsForm.tsx b/src/components/Forms/User/UpdateUserContactsForm.tsx index 9f43541..52a0b0f 100644 --- a/src/components/Forms/User/UpdateUserContactsForm.tsx +++ b/src/components/Forms/User/UpdateUserContactsForm.tsx @@ -1,39 +1,107 @@ import React from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; import { MdSave } from 'react-icons/md'; +import { IUserContacts } from 'interfaces'; import { Box, Heading, Stack } from '@chakra-ui/layout'; import { FormControl, FormHelperText } from '@chakra-ui/form-control'; import { Input } from '@chakra-ui/input'; +import { Text } from '@chakra-ui/react'; 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 = ({ contacts }) => { + const { + register, + formState: { errors, isDirty }, + handleSubmit, + reset, + } = useForm({ + 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 = data => { + updateContacts(data).finally(() => reset(data)); + }; -const UpdateUserContactsForm: React.FC = () => { return ( - - - - My Contacts - - - - Telephone - - - - Twitter - - - - Telegram - - - - VKontakte - - - - - + +
+ + + My Contacts + + + + Phone number with country code + + + {errors.phone_number?.message} + + + + Twitter + + + {errors.twitter_id?.message} + + + + Telegram + + + {errors.telegram_id?.message} + + + + VKontakte + + + {errors.vk_id?.message} + + + + + +
); }; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index b51d053..80415cc 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -12,3 +12,13 @@ export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boo } 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)); +} diff --git a/src/hooks/useUpdateUserContactsQuery.ts b/src/hooks/useUpdateUserContactsQuery.ts new file mode 100644 index 0000000..ad4b02b --- /dev/null +++ b/src/hooks/useUpdateUserContactsQuery.ts @@ -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({ + 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; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 65e71d7..0b10ecf 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -24,6 +24,14 @@ export interface IUser { last_name: string; email: string; 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 { @@ -46,14 +54,12 @@ export interface ILoginResult extends IDataResult { }; } +export interface IUpdatedContactsResult extends IDataResult { + data: IUserContacts; +} + export interface IUserResult extends IDataResult { - data: { - id: string; - first_name: string; - last_name: string; - email: string; - roles: IUserRole[]; - }; + data: IUser; } export interface IPageProps extends RouteComponentProps { diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 89d109a..56932bd 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -108,7 +108,7 @@ const ProfilePage: React.FC = ({ title }) => { - + diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 04d3f87..c78886b 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -1,5 +1,6 @@ import { AxiosInstance } from 'axios'; -import { IUserResult } from 'interfaces'; +import { IUpdatedContactsResult, IUserContacts, IUserResult } from 'interfaces'; +import * as helpers from 'helpers'; export default class UserService { public api: AxiosInstance; @@ -17,4 +18,15 @@ export default class UserService { const result = await this.api.get('v1/me'); return result.data; } + + /** + * Update authorized user contacts data + * + * @param data User contacts + * @returns Updated user contacts data + */ + public async updateContacts(data: IUserContacts): Promise { + const result = await this.api.put('v1/me/contacts', helpers.removeEmptyValues(data)); + return result.data; + } } diff --git a/src/stores/UserStore.ts b/src/stores/UserStore.ts index e3b3a8a..18fd794 100644 --- a/src/stores/UserStore.ts +++ b/src/stores/UserStore.ts @@ -1,6 +1,6 @@ import { AxiosInstance } from 'axios'; import { makeAutoObservable, runInAction } from 'mobx'; -import { IUserResult, IUser } from 'interfaces'; +import { IUserResult, IUser, IUserContacts, IUpdatedContactsResult } from 'interfaces'; /** * Services @@ -41,6 +41,22 @@ export default class UserStore { } } + public async updateCurrentUserContacts(data: IUserContacts): Promise { + 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 { runInAction(() => { this.me = null; diff --git a/src/types/yup/index.d.ts b/src/types/yup/index.d.ts new file mode 100644 index 0000000..890a890 --- /dev/null +++ b/src/types/yup/index.d.ts @@ -0,0 +1,10 @@ +import validator from 'validator'; + +declare module 'yup' { + interface StringSchema { + isMobileValid( + locales: validator.MobilePhoneLocale[], + options?: validator.IsMobilePhoneOptions | undefined + ): StringSchema; + } +} diff --git a/src/validators/LoginValidatorSchema.ts b/src/validators/LoginSchema.ts similarity index 59% rename from src/validators/LoginValidatorSchema.ts rename to src/validators/LoginSchema.ts index fda13e3..58df305 100644 --- a/src/validators/LoginValidatorSchema.ts +++ b/src/validators/LoginSchema.ts @@ -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'), password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'), }); -export default LoginValidatorSchema; +export default LoginSchema; diff --git a/src/validators/UpdateUserContactsSchema.ts b/src/validators/UpdateUserContactsSchema.ts new file mode 100644 index 0000000..4726866 --- /dev/null +++ b/src/validators/UpdateUserContactsSchema.ts @@ -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; diff --git a/src/validators/index.ts b/src/validators/index.ts new file mode 100644 index 0000000..4d69584 --- /dev/null +++ b/src/validators/index.ts @@ -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( + 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; diff --git a/tsconfig.json b/tsconfig.json index 126ba43..50d8f67 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,9 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "typeRoots": ["./types", "./node_modules/@types"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["types"] }