From 4970e7922f9c0730c7c82f5799407bb19158cc40 Mon Sep 17 00:00:00 2001 From: YaroslavChuiko Date: Thu, 11 Jul 2024 11:18:58 +0300 Subject: [PATCH] rewrited profile edit tab using redux --- .../edit-profile/profile-tab/ProfileTab.tsx | 104 +++++++------- .../edit-profile/EditProfile.constants.tsx | 2 +- src/pages/edit-profile/EditProfile.tsx | 20 ++- src/redux/features/editProfileSlice.ts | 55 +++++++- .../interfaces/editProfile.interfaces.ts | 4 - tests/test-utils.jsx | 4 +- .../edit-profile/ProfileTab.spec.jsx | 132 +++++++++++------- .../pages/edit-profile/EditProfile.spec.jsx | 14 +- tests/unit/redux/editProfileSlice.spec.js | 62 +++++++- 9 files changed, 273 insertions(+), 124 deletions(-) diff --git a/src/containers/edit-profile/profile-tab/ProfileTab.tsx b/src/containers/edit-profile/profile-tab/ProfileTab.tsx index 4c7f1894f9..c55a197ae3 100644 --- a/src/containers/edit-profile/profile-tab/ProfileTab.tsx +++ b/src/containers/edit-profile/profile-tab/ProfileTab.tsx @@ -1,40 +1,56 @@ +import Box from '@mui/material/Box' import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import Box from '@mui/material/Box' -import { useBlocker } from 'react-router-dom' -import useUpdateUser from '~/hooks/use-update-user' -import useConfirm from '~/hooks/use-confirm' import useForm from '~/hooks/use-form' import TitleWithDescription from '~/components/title-with-description/TitleWithDescription' -import AppButton from '~/components/app-button/AppButton' -import ProfileTabForm from '~/containers/edit-profile/profile-tab/profile-tab-form/ProfileTabForm' -import { - getProfileInitialValues, - getUserUpdatedData -} from '~/utils/get-profile-values' import { validations } from '~/components/user-steps-wrapper/constants' +import ProfileTabForm from '~/containers/edit-profile/profile-tab/profile-tab-form/ProfileTabForm' import { styles } from '~/containers/edit-profile/profile-tab/ProfileTab.styles' +import { useDebounce } from '~/hooks/use-debounce' +import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' import { - ButtonVariantEnum, - EditProfileForm, - SizeEnum, - EditProfileTabUserProps -} from '~/types' + updateProfileData, + updateValidityStatus +} from '~/redux/features/editProfileSlice' +import { EditProfileForm, MainUserRole } from '~/types' -const ProfileTab: FC = ({ user }) => { +const ProfileTab: FC = () => { const { t } = useTranslation() - const { setNeedConfirmation, checkConfirmation } = useConfirm() - const { handleSubmit, loading } = useUpdateUser(user._id, true) + const dispatch = useAppDispatch() + const { userRole } = useAppSelector((state) => state.appMain) + const { + city, + country, + firstName, + lastName, + nativeLanguage, + photo, + professionalSummary, + videoLink + } = useAppSelector((state) => state.editProfile) - const initialValues = getProfileInitialValues(user) + const initialValues: EditProfileForm = { + city, + country, + firstName, + lastName, + nativeLanguage, + photo: photo || null, + professionalSummary: professionalSummary || '', + videoLink: + typeof videoLink === 'string' + ? videoLink + : videoLink[userRole as MainUserRole] ?? '' + } const { - isDirty, + isValid, handleInputChange, handleBlur, handleNonInputValueChange, + trigger, data, errors } = useForm({ @@ -42,36 +58,23 @@ const ProfileTab: FC = ({ user }) => { validations }) - const blocker = useBlocker(isDirty) + const debouncedUpdateProfileData = useDebounce(() => { + void dispatch(updateProfileData(data)) + }, 300) useEffect(() => { - void (async () => { - if (blocker.state === 'blocked') { - const confirmed = await checkConfirmation({ - message: 'questions.goBackToProfile', - title: 'titles.discardChanges', - confirmButton: t('common.discard'), - cancelButton: t('common.cancel') - }) - if (confirmed) { - blocker.proceed() - } else { - blocker.reset() - } - } - })() - }, [blocker, checkConfirmation, t]) + debouncedUpdateProfileData() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]) useEffect(() => { - setNeedConfirmation(isDirty) - }, [setNeedConfirmation, isDirty]) - - const handleUpdateData = () => { - const updatedData = getUserUpdatedData(data) - handleSubmit(updatedData) - } + trigger() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) - const hasError = Object.values(errors).some((error) => error) + useEffect(() => { + void dispatch(updateValidityStatus({ tab: 'profileTab', value: isValid })) + }, [isValid, dispatch]) return ( @@ -89,17 +92,6 @@ const ProfileTab: FC = ({ user }) => { handleNonInputValueChange={handleNonInputValueChange} /> - - - {t('editProfilePage.profile.updateProfileBtn')} - ) } diff --git a/src/pages/edit-profile/EditProfile.constants.tsx b/src/pages/edit-profile/EditProfile.constants.tsx index 94a1c07462..de80c4d239 100644 --- a/src/pages/edit-profile/EditProfile.constants.tsx +++ b/src/pages/edit-profile/EditProfile.constants.tsx @@ -23,7 +23,7 @@ export const tabsData: UserProfileProps = { [UserProfileTabsEnum.Profile]: { icon: , title: 'editProfilePage.profile.generalTab.tabTitle', - content: (response) => + content: () => }, [UserProfileTabsEnum.ProfessionalInfo]: { icon: , diff --git a/src/pages/edit-profile/EditProfile.tsx b/src/pages/edit-profile/EditProfile.tsx index a29860c1f7..1d46352fa6 100644 --- a/src/pages/edit-profile/EditProfile.tsx +++ b/src/pages/edit-profile/EditProfile.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Link, useSearchParams } from 'react-router-dom' @@ -9,7 +9,7 @@ import Divider from '@mui/material/Divider' import useConfirm from '~/hooks/use-confirm' import { authRoutes } from '~/router/constants/authRoutes' -import { useAppSelector } from '~/hooks/use-redux' +import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' import { userService } from '~/services/user-service' import useAxios from '~/hooks/use-axios' import Loader from '~/components/loader/Loader' @@ -27,9 +27,13 @@ import { import { tabsData } from '~/pages/edit-profile/EditProfile.constants' import { styles } from '~/pages/edit-profile/EditProfile.styles' +import { fetchUserById } from '~/redux/features/editProfileSlice' +import { LoadingStatusEnum } from '~/redux/redux.constants' const EditProfile = () => { const { t } = useTranslation() + const dispatch = useAppDispatch() + const { loading } = useAppSelector((state) => state.editProfile) const [searchParams, setSearchParams] = useSearchParams({ tab: UserProfileTabsEnum.Profile @@ -57,14 +61,22 @@ const EditProfile = () => { () => userService.getUserById(userId, userRole as UserRole, true), [userId, userRole] ) + + useEffect(() => { + void dispatch( + fetchUserById({ userId, role: userRole as UserRole, isEdit: true }) + ) + }, [dispatch, userId, userRole]) + const { checkConfirmation } = useConfirm() - const { loading, response } = useAxios({ + //! delete when all tabs are ready + const { loading: userLoading, response } = useAxios({ service: getUserData, fetchOnMount: true }) - if (loading) { + if (loading === LoadingStatusEnum.Pending || userLoading) { return } diff --git a/src/redux/features/editProfileSlice.ts b/src/redux/features/editProfileSlice.ts index 44c1bb41c9..7ce54691e9 100644 --- a/src/redux/features/editProfileSlice.ts +++ b/src/redux/features/editProfileSlice.ts @@ -7,9 +7,11 @@ import { } from '~/redux/redux.constants' import { DataByRole, + EditProfileForm, ErrorResponse, MainUserRole, SubjectNameInterface, + UpdatedPhoto, UpdateUserParams, UserMainSubject, UserMainSubjectFieldValues, @@ -22,12 +24,12 @@ import { userService } from '~/services/user-service' interface EditProfileState { firstName: string lastName: string - country: string - city: string + country: string | null + city: string | null professionalSummary?: string nativeLanguage: string | null - videoLink: DataByRole - photo?: string | null + videoLink: DataByRole | string + photo?: string | UpdatedPhoto | null categories: DataByRole education?: string workExperience?: string @@ -39,6 +41,11 @@ interface EditProfileState { isEmailNotification: boolean loading: LoadingStatus error: string | null + tabValidityStatus: { + profileTab: boolean + professionalInfoTab: boolean + notificationTab: boolean + } } const initialState: EditProfileState = { @@ -60,7 +67,12 @@ const initialState: EditProfileState = { isSimilarOffersNotification: false, isEmailNotification: false, loading: LoadingStatusEnum.Idle, - error: null + error: null, + tabValidityStatus: { + profileTab: true, + professionalInfoTab: true, + notificationTab: true + } } const updateStateFromPayload = ( @@ -140,6 +152,37 @@ const editProfileSlice = createSlice({ const { field, value } = action.payload state[field] = value }, + updateValidityStatus: ( + state, + action: PayloadAction<{ + tab: keyof EditProfileState['tabValidityStatus'] + value: boolean + }> + ) => { + const { tab, value } = action.payload + state.tabValidityStatus[tab] = value + }, + updateProfileData: (state, action: PayloadAction) => { + const { + city, + country, + firstName, + lastName, + nativeLanguage, + photo, + professionalSummary, + videoLink + } = action.payload + + state.city = city + state.country = country + state.firstName = firstName + state.lastName = lastName + state.nativeLanguage = nativeLanguage + state.photo = photo + state.professionalSummary = professionalSummary + state.videoLink = videoLink + }, addCategory: ( state, action: PayloadAction<{ @@ -264,6 +307,8 @@ const { actions, reducer } = editProfileSlice export const { setField, + updateValidityStatus, + updateProfileData, addCategory, editCategory, addSubjectToCategory, diff --git a/src/types/edit-profile/interfaces/editProfile.interfaces.ts b/src/types/edit-profile/interfaces/editProfile.interfaces.ts index 1a03f5eff6..bc503873e2 100644 --- a/src/types/edit-profile/interfaces/editProfile.interfaces.ts +++ b/src/types/edit-profile/interfaces/editProfile.interfaces.ts @@ -24,7 +24,3 @@ export interface UserMainSubject extends ProfessionalCategory { isDeletionBlocked: boolean _id: string } - -export interface EditProfileTabUserProps { - user: UserResponse -} diff --git a/tests/test-utils.jsx b/tests/test-utils.jsx index 5e46c21458..0d2be51b46 100644 --- a/tests/test-utils.jsx +++ b/tests/test-utils.jsx @@ -8,6 +8,7 @@ import { theme } from '~/styles/app-theme/custom-mui.styles' import PopupsProvider from '~/PopupsProvider' import cooperationsReducer from '~/redux/features/cooperationsSlice' import snackbarReducer from '~/redux/features/snackbarSlice' +import editProfileReducer from '~/redux/features/editProfileSlice' import AppSnackbar from '~/containers/layout/app-snackbar/AppSnackbar' import MockAdapter from 'axios-mock-adapter' @@ -22,7 +23,8 @@ export const renderWithProviders = ( reducer: { appMain: reducer, cooperations: cooperationsReducer, - snackbar: snackbarReducer + snackbar: snackbarReducer, + editProfile: editProfileReducer }, preloadedState }), diff --git a/tests/unit/containers/edit-profile/ProfileTab.spec.jsx b/tests/unit/containers/edit-profile/ProfileTab.spec.jsx index bf080c4136..294b3fd97a 100644 --- a/tests/unit/containers/edit-profile/ProfileTab.spec.jsx +++ b/tests/unit/containers/edit-profile/ProfileTab.spec.jsx @@ -1,69 +1,101 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { UserRoleEnum } from '~/types' +import { LoadingStatusEnum } from '~/redux/redux.constants' import { renderWithProviders } from '~tests/test-utils' import ProfileTab from '~/containers/edit-profile/profile-tab/ProfileTab' -import { userDataMock } from '~tests/unit/containers/edit-profile/profile-tab/profile-tab-form/ProfileTabForm.spec.constants' -const resetMock = vi.fn() -const proceedMock = vi.fn() - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') - return { - ...actual, - useBlocker: () => ({ - state: 'blocked', - proceed: proceedMock, - reset: () => resetMock() - }) - } -}) +vi.mock('~/components/title-with-description/TitleWithDescription', () => ({ + default: ({ description, title }) => ( +
+ {title} + {description} +
+ ) +})) -const handleSubmitMock = vi.fn() -vi.mock('~/hooks/use-update-user', () => ({ - default: () => ({ - handleSubmit: handleSubmitMock, - loading: false +vi.mock( + '~/containers/edit-profile/profile-tab/profile-tab-form/ProfileTabForm', + () => ({ + default: ({ data, handleBlur, handleInputChange }) => ( +
+ +
+ ) }) +) + +const mockUseAppDispatch = vi.fn() +vi.mock('~/hooks/use-debounce', () => ({ + useDebounce: (value) => value })) -const checkConfirmationMock = vi.fn() -vi.mock('~/hooks/use-confirm', () => { +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') return { - default: () => ({ - setNeedConfirmation: () => true, - checkConfirmation: () => checkConfirmationMock() - }) + ...actual, + useAppDispatch: () => mockUseAppDispatch } }) -describe('ProfileTab', () => { - it('should handle data updates', () => { - checkConfirmationMock.mockResolvedValue(true) - renderWithProviders() - - const updateButton = screen.getByRole('button', { - name: 'editProfilePage.profile.updateProfileBtn' - }) - - fireEvent.click(updateButton) +const mockedUserProfileData = { + firstName: 'John', + lastName: 'Doe', + country: 'USA', + city: 'New York', + professionalSummary: 'Summary', + nativeLanguage: 'English', + videoLink: { [UserRoleEnum.Tutor]: 'link', [UserRoleEnum.Student]: '' }, + photo: 'photo_url', + categories: { [UserRoleEnum.Tutor]: [], [UserRoleEnum.Student]: [] }, + education: 'Education', + workExperience: 'Experience', + scientificActivities: 'Activities', + awards: 'Awards', + isOfferStatusNotification: false, + isChatNotification: false, + isSimilarOffersNotification: false, + isEmailNotification: false, + loading: LoadingStatusEnum.Fulfilled, + error: null, + tabValidityStatus: { + profileTab: true, + professionalInfoTab: true, + notificationTab: true + } +} - expect(handleSubmitMock).toHaveBeenCalled() +const renderWithMockData = () => { + renderWithProviders(, { + preloadedState: { + editProfile: mockedUserProfileData, + appMain: { + userId: '644e6b1778cc37f543f2f37c', + userRole: UserRoleEnum.Student + } + } }) +} - it('should call the reset function if the page leave was not confirmed', async () => { - checkConfirmationMock.mockResolvedValue(false) - renderWithProviders() +describe('ProfileTab', () => { + it('should render correctly', () => { + renderWithMockData() + const title = screen.getByTestId('title') + const form = screen.getByTestId('form') - await waitFor(() => { - expect(resetMock).toHaveBeenCalled() - }) + expect(title).toBeInTheDocument() + expect(form).toBeInTheDocument() }) - it('should proceed if the page leave was confirmed', async () => { - checkConfirmationMock.mockResolvedValue(true) - renderWithProviders() - await waitFor(() => { - expect(proceedMock).toHaveBeenCalled() - }) + it('should update store after input value change', async () => { + renderWithMockData() + const videoLinkInput = screen.getByPlaceholderText('firstName') + fireEvent.change(videoLinkInput, { target: { value: 'NewValue' } }) + expect(mockUseAppDispatch).toHaveBeenCalled() }) }) diff --git a/tests/unit/pages/edit-profile/EditProfile.spec.jsx b/tests/unit/pages/edit-profile/EditProfile.spec.jsx index 9ecc801513..3fe5cc261a 100644 --- a/tests/unit/pages/edit-profile/EditProfile.spec.jsx +++ b/tests/unit/pages/edit-profile/EditProfile.spec.jsx @@ -14,7 +14,19 @@ const mockState = { const userMock = { role: userRole, videoLink: { [userRole]: '' }, - mainSubjects: { [userRole]: [] } + mainSubjects: { [userRole]: [] }, + firstName: 'John', + lastName: 'Doe', + address: { country: 'USA', city: 'New York' }, + professionalSummary: 'Summary', + nativeLanguage: 'English', + photo: 'photo_url', + professionalBlock: { + education: 'Education', + workExperience: 'Experience', + scientificActivities: 'Activities', + awards: 'Awards' + } } vi.mock('~/containers/edit-profile/profile-tab/ProfileTab', () => ({ diff --git a/tests/unit/redux/editProfileSlice.spec.js b/tests/unit/redux/editProfileSlice.spec.js index 066540aa59..2d8648b184 100644 --- a/tests/unit/redux/editProfileSlice.spec.js +++ b/tests/unit/redux/editProfileSlice.spec.js @@ -1,6 +1,8 @@ import { configureStore } from '@reduxjs/toolkit' import reducer, { setField, + updateValidityStatus, + updateProfileData, addCategory, deleteCategory, editCategory, @@ -51,7 +53,12 @@ const expectedUserData = { isSimilarOffersNotification: false, isEmailNotification: false, loading: LoadingStatusEnum.Fulfilled, - error: null + error: null, + tabValidityStatus: { + profileTab: true, + professionalInfoTab: true, + notificationTab: true + } } const initialState = { @@ -73,7 +80,12 @@ const initialState = { isSimilarOffersNotification: false, isEmailNotification: false, loading: LoadingStatusEnum.Idle, - error: null + error: null, + tabValidityStatus: { + profileTab: true, + professionalInfoTab: true, + notificationTab: true + } } const mockedCategories = [ @@ -328,6 +340,52 @@ describe('editProfileSlice test', () => { ).toEqual(expectedState) }) + it('should set profile tab validity correctly', () => { + const expectedState = createState({ + tabValidityStatus: { + profileTab: false, + professionalInfoTab: true, + notificationTab: true + } + }) + + expect( + reducer( + undefined, + updateValidityStatus({ tab: 'profileTab', value: false }) + ) + ).toEqual(expectedState) + }) + + it('should set profile tab validity correctly', () => { + const expectedState = createState({ + city: 'city', + country: 'country', + firstName: 'firstName', + lastName: 'lastName', + nativeLanguage: 'nativeLanguage', + photo: 'photo', + professionalSummary: 'professionalSummary', + videoLink: 'videoLink' + }) + + expect( + reducer( + undefined, + updateProfileData({ + city: 'city', + country: 'country', + firstName: 'firstName', + lastName: 'lastName', + nativeLanguage: 'nativeLanguage', + photo: 'photo', + professionalSummary: 'professionalSummary', + videoLink: 'videoLink' + }) + ) + ).toEqual(expectedState) + }) + it('should add new category', () => { const expectedState = createState({ categories: {