diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index a9bf1d3..c2855cc 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -21,9 +21,14 @@ const App = (): JSX.Element => { useEffect(() => { dispatch(checkAuthAction()); - dispatch(fetchFavoritesAction()); }, [dispatch]); + useEffect(() => { + if (isAuth(authorizationStatus)) { + dispatch(fetchFavoritesAction()); + } + }, [dispatch, authorizationStatus]); + if (authorizationStatus === AuthorizationStatus.Unknown) { return ( diff --git a/src/components/cities/cities.tsx b/src/components/cities/cities.tsx index 3081c4f..9fb2cc0 100644 --- a/src/components/cities/cities.tsx +++ b/src/components/cities/cities.tsx @@ -50,7 +50,7 @@ const Cities = (): JSX.Element => { data-testid="places" >

Places

- {placesCount} place{placesCount === 1 ? '' : 's'} to stay in Amsterdam + {placesCount} place{placesCount === 1 ? '' : 's'} to stay in {currentCityName}
diff --git a/src/components/favorite-button/favorite-button.test.tsx b/src/components/favorite-button/favorite-button.test.tsx index 335331d..322cb57 100644 --- a/src/components/favorite-button/favorite-button.test.tsx +++ b/src/components/favorite-button/favorite-button.test.tsx @@ -1,15 +1,35 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { withHistory, withStore } from '../../utils/mock-component'; +import { withHistory } from '../../utils/mock-component'; import FavoriteButton from './favorite-button'; -import { makeFakeStore, makeFakeUser } from '../../utils/mocks'; -import { AuthorizationStatus, RequestStatus } from '../../const'; +import { makeFakeOffer, makeFakeUser } from '../../utils/mocks'; +import { AppRoute, AuthorizationStatus, RequestStatus } from '../../const'; +import { createMemoryHistory } from 'history'; +import type { State } from '../../types/state-type'; const mockDispatch = vi.fn(); -vi.mock('../../hooks', () => ({ - useAppDispatch: () => mockDispatch, - useAppSelector: vi.fn(), -})); +const mockUseAppSelector = vi.fn<[selector: (state: State) => unknown], unknown>(); + +vi.mock('../../hooks', async () => { + const actual = await vi.importActual('../../hooks'); + return { + ...actual, + useAppSelector: (selector: (state: State) => unknown): unknown => mockUseAppSelector(selector), + useAppDispatch: () => mockDispatch, + }; +}); + +const buildMockState = (isAuthorized = true, favoriteIds: string[] = []) => ({ + USER: { + user: isAuthorized ? makeFakeUser() : null, + authorizationStatus: isAuthorized ? AuthorizationStatus.Auth : AuthorizationStatus.NoAuth, + requestStatus: RequestStatus.Idle, + }, + FAVORITE: { + favorites: favoriteIds.map((id) => ({ ...makeFakeOffer(), id })), + favoritesStatus: RequestStatus.Idle, + }, +}); describe('Component: FavoriteButton', () => { const mockId = 'offer123'; @@ -19,21 +39,23 @@ describe('Component: FavoriteButton', () => { beforeEach(() => { vi.clearAllMocks(); + mockUseAppSelector.mockImplementation((selector: (state: State) => unknown) => selector(buildMockState(true) as State)); }); - it('should render correctly when isFavorite = false', () => { - const fakeStore = makeFakeStore(); + it('should render correctly when item is not favorite', () => { + const history = createMemoryHistory(); + history.push(AppRoute.Root); const withHistoryComponent = withHistory( + isFavorite={false} + />, + history ); - const { withStoreComponent } = withStore(withHistoryComponent, fakeStore); - render(withStoreComponent); + render(withHistoryComponent); const button = screen.getByRole('button'); expect(button).toHaveClass(mockClassName); @@ -43,79 +65,84 @@ describe('Component: FavoriteButton', () => { expect(screen.getByText('To bookmarks')).toBeInTheDocument(); }); - it('should render with active class when isFavorite = true', () => { - const fakeStore = makeFakeStore({ - USER: { - user: makeFakeUser(), - authorizationStatus: AuthorizationStatus.Auth, - requestStatus: RequestStatus.Success, - }, - }); + it('should render with active class when isFavorite prop is true', () => { + mockUseAppSelector.mockImplementation((selector: (state: State) => unknown) => selector(buildMockState(true, [mockId]) as State)); + const history = createMemoryHistory(); + history.push(AppRoute.Root); const withHistoryComponent = withHistory( + isFavorite + />, + history ); - const { withStoreComponent } = withStore(withHistoryComponent, fakeStore); - render(withStoreComponent); + render(withHistoryComponent); const button = screen.getByRole('button'); expect(button).toHaveClass(mockClassName); expect(button).toHaveClass(mockActiveClassName); }); + it('should use isFavorite prop before switching to store value', () => { + const history = createMemoryHistory(); + history.push(AppRoute.Root); + const withHistoryComponent = withHistory( + , + history + ); + render(withHistoryComponent); + + expect(screen.getByRole('button')).toHaveClass(mockActiveClassName); + }); + it('should navigate to login when user is not authorized and button is clicked', async () => { - const fakeStore = makeFakeStore({ - USER: { - user: null, - authorizationStatus: AuthorizationStatus.NoAuth, - requestStatus: RequestStatus.Idle, - }, - }); + mockUseAppSelector.mockImplementation((selector: (state: State) => unknown) => selector(buildMockState(false) as State)); + const history = createMemoryHistory(); + history.push(AppRoute.Root); const withHistoryComponent = withHistory( + isFavorite={false} + />, + history ); - const { withStoreComponent } = withStore(withHistoryComponent, fakeStore); - render(withStoreComponent); + render(withHistoryComponent); const button = screen.getByRole('button'); await userEvent.click(button); + expect(history.location.pathname).toBe(AppRoute.Login); expect(mockDispatch).not.toHaveBeenCalled(); }); it('should disable button during request and re-enable after success', async () => { const mockUnwrap = vi.fn().mockResolvedValue(undefined); mockDispatch.mockReturnValue({ unwrap: mockUnwrap }); - - const fakeStore = makeFakeStore({ - USER: { - user: null, - authorizationStatus: AuthorizationStatus.NoAuth, - requestStatus: RequestStatus.Idle, - }, - }); + const history = createMemoryHistory(); + history.push(AppRoute.Root); const withHistoryComponent = withHistory( + isFavorite={false} + />, + history ); - const { withStoreComponent } = withStore(withHistoryComponent, fakeStore); - render(withStoreComponent); + render(withHistoryComponent); const button = screen.getByRole('button'); expect(button).not.toBeDisabled(); @@ -123,5 +150,6 @@ describe('Component: FavoriteButton', () => { await userEvent.click(button); expect(button).not.toBeDisabled(); + expect(mockDispatch).toHaveBeenCalled(); }); }); diff --git a/src/components/favorite-button/favorite-button.tsx b/src/components/favorite-button/favorite-button.tsx index 1347c3f..0232def 100644 --- a/src/components/favorite-button/favorite-button.tsx +++ b/src/components/favorite-button/favorite-button.tsx @@ -5,25 +5,36 @@ import { selectAuthorizationStatus } from '../../store/user-process/selectors'; import { AppRoute } from '../../const'; import { useNavigate } from 'react-router-dom'; import { isAuth } from '../../utils/common'; +import { toast } from 'react-toastify'; +import { selectFavorites } from '../../store/favorite/selectors'; + +const Icon = { + Width: 31, + Heigh: 33, +} as const; type FavoriteButtonProps = { id: string; - isFavorite: boolean; className: string; activeClassName: string; svgClassName: string; imgWidth?: number; imgHeight?: number; + testid?: string; + isFavorite: boolean; } -const FavoriteButton = ({id, isFavorite, className, activeClassName, svgClassName, imgWidth, imgHeight}: FavoriteButtonProps): JSX.Element => { +const FavoriteButton = ({id, className, activeClassName, svgClassName, imgWidth, imgHeight, isFavorite, testid }: FavoriteButtonProps): JSX.Element => { const dispatch = useAppDispatch(); - const [favStatus, setFavStatus] = useState(isFavorite); const [isDisabled, setIsDisabled] = useState(false); + const [shouldUseStoreFavorite, setShouldUseStoreFavorite] = useState(false); const navigate = useNavigate(); const authorizationStatus = useAppSelector(selectAuthorizationStatus); + const favorites = useAppSelector(selectFavorites); + const favoriteFromStore = favorites.some((favorite) => favorite.id === id) && isAuth(authorizationStatus); + const isActualFavorite = shouldUseStoreFavorite ? favoriteFromStore : (isFavorite && isAuth(authorizationStatus)); - const status = Number(!favStatus); + const status = Number(!isActualFavorite); const handleButtonClick = async (evt: MouseEvent): Promise => { evt.preventDefault(); @@ -36,9 +47,9 @@ const FavoriteButton = ({id, isFavorite, className, activeClassName, svgClassNam try { setIsDisabled(true); await dispatch(changeFavoriteStatusAction({ id, status })).unwrap(); - setFavStatus((prev) => !prev); + setShouldUseStoreFavorite(true); } catch (error) { - throw new Error('Ошибка сохранения/удаления избранного'); + toast.error('Ошибка сохранения/удаления избранного'); } finally { setIsDisabled(false); } @@ -49,11 +60,12 @@ const FavoriteButton = ({id, isFavorite, className, activeClassName, svgClassNam onClick={(evt) => { handleButtonClick(evt); }} - className={`${className} ${favStatus ? activeClassName : ''} button`} + className={`${className} ${isActualFavorite ? activeClassName : ''} button`} type="button" disabled={isDisabled} + data-testid={testid} > - + To bookmarks diff --git a/src/components/login-form/login-form.test.tsx b/src/components/login-form/login-form.test.tsx index 1034e37..e56d508 100644 --- a/src/components/login-form/login-form.test.tsx +++ b/src/components/login-form/login-form.test.tsx @@ -2,13 +2,14 @@ import { render, screen } from '@testing-library/react'; import LoginForm from './login-form'; import userEvent from '@testing-library/user-event'; import { withStore } from '../../utils/mock-component'; +import { makeFakeStore } from '../../utils/mocks'; describe('Component: LoginForm', () => { it('should render correctly', () => { const signInText = 'Sign in'; const emailText = 'E-mail'; const passwordText = 'Password'; - const {withStoreComponent} = withStore(); + const {withStoreComponent} = withStore(, makeFakeStore()); render(withStoreComponent); @@ -22,7 +23,7 @@ describe('Component: LoginForm', () => { const passwordElementTestId = 'passwordElement'; const expectedLoginValue = 'email@test.com'; const expectedPasswordValue = '123456'; - const {withStoreComponent} = withStore(); + const {withStoreComponent} = withStore(, makeFakeStore()); render(withStoreComponent); diff --git a/src/components/login-form/login-form.tsx b/src/components/login-form/login-form.tsx index bece365..30ccae2 100644 --- a/src/components/login-form/login-form.tsx +++ b/src/components/login-form/login-form.tsx @@ -1,6 +1,8 @@ import { FormEventHandler, ReactEventHandler, useState } from 'react'; -import { useAppDispatch } from '../../hooks'; +import { RequestStatus } from '../../const'; +import { useAppDispatch, useAppSelector } from '../../hooks'; import { loginAction } from '../../store/api-actions'; +import { selectRequestStatus } from '../../store/user-process/selectors'; type ChangeHandler = ReactEventHandler type SubmitHandler = FormEventHandler @@ -16,14 +18,19 @@ const LoginForm = (): JSX.Element => { const handleFormDataChange: ChangeHandler = (evt) => { setFormData({ ...formData, - [evt.currentTarget.name]: evt.currentTarget.value, + [evt.currentTarget.name !== 'email' ? evt.currentTarget.name : 'login']: evt.currentTarget.value, }); }; const dispatch = useAppDispatch(); + const requestStatus = useAppSelector(selectRequestStatus); + const isAuthRequestLoading = requestStatus === RequestStatus.Loading; const handleFormSubmit: SubmitHandler = (evt) => { evt.preventDefault(); + if (isAuthRequestLoading) { + return; + } dispatch(loginAction(formData)); }; @@ -37,7 +44,7 @@ const LoginForm = (): JSX.Element => { value={formData.login} className="login__input form__input" id="email" type="email" - name="login" + name="email" placeholder="Email" required data-testid="loginElement" @@ -54,6 +61,7 @@ const LoginForm = (): JSX.Element => { placeholder="Password" required data-testid="passwordElement" + pattern="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]+$" />
diff --git a/src/components/map/map.tsx b/src/components/map/map.tsx index fd30678..7130d2a 100644 --- a/src/components/map/map.tsx +++ b/src/components/map/map.tsx @@ -45,7 +45,7 @@ const Map = ({offers = [], activeOffer, city, className}: MapProps): JSX.Element useEffect(() => { if (map) { - + markerLayer.current.clearLayers(); offers.forEach((offer) => { const {location} = offer; const {latitude, longitude} = location; diff --git a/src/components/places/places.tsx b/src/components/places/places.tsx index 7b376a6..3e01929 100644 --- a/src/components/places/places.tsx +++ b/src/components/places/places.tsx @@ -16,7 +16,14 @@ const Places = ({ offers, className, listClassName, cardClassName, imgClassName, {children}
{offers.map((offer) => - ( + ( + ))}
diff --git a/src/components/review-form/review-form.test.tsx b/src/components/review-form/review-form.test.tsx index 9bbb655..05f5f6a 100644 --- a/src/components/review-form/review-form.test.tsx +++ b/src/components/review-form/review-form.test.tsx @@ -3,17 +3,19 @@ import userEvent from '@testing-library/user-event'; import { withHistory, withStore } from '../../utils/mock-component'; import ReviewForm from './review-form'; import { makeFakeStore } from '../../utils/mocks'; -import { ReviewLength } from '../../const'; +import { RequestStatus, ReviewLength } from '../../const'; import { vi } from 'vitest'; +import { State } from '../../types/state-type'; vi.mock('../../store/api-actions', () => ({ sendReviewAction: vi.fn(), })); -// Мокаем useAppDispatch const mockDispatch = vi.fn(); +const mockUseAppSelector = vi.fn<[selector: (state: State) => unknown], unknown>(); vi.mock('../../hooks', () => ({ useAppDispatch: () => mockDispatch, + useAppSelector: (selector: (state: unknown) => unknown): unknown => mockUseAppSelector(selector) , })); describe('Component: ReviewForm', () => { @@ -24,6 +26,7 @@ describe('Component: ReviewForm', () => { beforeEach(() => { vi.clearAllMocks(); + mockUseAppSelector.mockReturnValue(RequestStatus.Idle); }); it('should render form correctly', () => { diff --git a/src/components/review-form/review-form.tsx b/src/components/review-form/review-form.tsx index 7b12d5d..20db132 100644 --- a/src/components/review-form/review-form.tsx +++ b/src/components/review-form/review-form.tsx @@ -1,15 +1,17 @@ import { Fragment, ReactEventHandler, useState } from 'react'; -import { ReviewLength } from '../../const'; -import { useAppDispatch } from '../../hooks'; +import { RequestStatus, ReviewLength } from '../../const'; +import { useAppDispatch, useAppSelector } from '../../hooks'; import { sendReviewAction } from '../../store/api-actions'; import { NewReview } from '../../types/review-type'; +import { toast } from 'react-toastify'; +import { selectReviewStatus } from '../../store/reviews/selectors'; type ReviewFormProps = { id: string; } type ChangeHandler = ReactEventHandler -const RaitingValues = [ +const RATING_VALUES = [ { value: 5, description: 'perfect' @@ -34,27 +36,23 @@ const RaitingValues = [ const ReviewForm = ({id}: ReviewFormProps): JSX.Element => { const [formData, setFormData] = useState({rating: 0, review: ''}); - const [isSubmitting, setIsSubmitting] = useState(false); const dispatch = useAppDispatch(); + const reviewStatus = useAppSelector(selectReviewStatus); + const isSubmitting = reviewStatus === RequestStatus.Loading; - const shouldButtonDisabled = + const isButtonDisabled = formData.review.length < ReviewLength.Min || formData.review.length >= ReviewLength.Max || formData.rating === 0; - const isButtonDisabled = isSubmitting || shouldButtonDisabled; - const handleFormSubmit = async (evt: React.FormEvent): Promise => { evt.preventDefault(); try { - setIsSubmitting(true); await dispatch(sendReviewAction({ id, formData })).unwrap(); setFormData({ rating: 0, review: '' }); } catch (error) { - throw new Error('Ошибка отправки отзыва'); - } finally { - setIsSubmitting(false); + toast.error('Ошибка отправки отзыва'); } }; @@ -76,7 +74,7 @@ const ReviewForm = ({id}: ReviewFormProps): JSX.Element => { Your review
- {RaitingValues.map(({value, description}) => ( + {RATING_VALUES.map(({value, description}) => ( { onChange={handleFormDataChange} checked={value === Number(formData.rating)} data-testid={description} + disabled={isSubmitting} />
- + Rating
{rating}
    -
  • {capitalizeValue(type)}
  • +
  • + {capitalizeValue(type)} +
  • {bedrooms} Bedrooms
  • @@ -122,16 +132,16 @@ const OfferPage = (): JSX.Element => { Host avatar {capitalizeValue(name)} - {isPro && Pro} + {isPro && Pro}
    -

    +

    {description}

    @@ -140,7 +150,7 @@ const OfferPage = (): JSX.Element => {

    Reviews · {reviews.length}

    - {!!reviews?.length && } + {!!reviews?.length && } {isUserSignIn && } diff --git a/src/services/api.ts b/src/services/api.ts index ccdbeec..e4ecf80 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -43,7 +43,7 @@ export const createAPI = (): AxiosInstance => { if (error.response && shouldDisplayError(error.response)) { const detailMessage = (error.response?.data); - toast.warn(detailMessage?.message ?? 'unknown message'); + toast.warn(detailMessage?.message); } throw error; diff --git a/src/store/favorite/favorite.selectors.test.ts b/src/store/favorite/favorite.selectors.test.ts index 352a56b..b9c432c 100644 --- a/src/store/favorite/favorite.selectors.test.ts +++ b/src/store/favorite/favorite.selectors.test.ts @@ -1,13 +1,12 @@ import { NameSpace, RequestStatus } from '../../const'; import { makeFakeOffer } from '../../utils/mocks'; -import { selectFavorites, selectFavoritesStatus, selectFavoriteStatus } from './selectors'; +import { selectFavorites, selectFavoritesStatus } from './selectors'; describe('Favorite selectors', () => { const mockOffer = makeFakeOffer(); const state = { [NameSpace.Favorite]: { favorites: [mockOffer], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, } }; @@ -23,10 +22,4 @@ describe('Favorite selectors', () => { const result = selectFavoritesStatus(state); expect(result).toEqual(favoritesStatus); }); - - it('should return favorite data loading status', () => { - const { favoriteStatus } = state[NameSpace.Favorite]; - const result = selectFavoriteStatus(state); - expect(result).toEqual(favoriteStatus); - }); }); diff --git a/src/store/favorite/favorite.test.ts b/src/store/favorite/favorite.test.ts index fea805a..9fc2db7 100644 --- a/src/store/favorite/favorite.test.ts +++ b/src/store/favorite/favorite.test.ts @@ -9,12 +9,10 @@ describe('Favorite Slice', () => { const emptyAction = { type: '' }; const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; @@ -27,7 +25,6 @@ describe('Favorite Slice', () => { const emptyAction = { type: '' }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; @@ -40,13 +37,11 @@ describe('Favorite Slice', () => { const mockOffer = makeFakeOffer(); const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [mockOffer], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Success, }; @@ -58,13 +53,11 @@ describe('Favorite Slice', () => { it('should set "favoritesStatus" to "Loading" when "fetchFavoritesAction.pending"', () => { const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Loading, }; @@ -76,13 +69,11 @@ describe('Favorite Slice', () => { it('should set "favoritesStatus" to "Failed" when "fetchFavoritesAction.rejected"', () => { const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Failed, }; @@ -91,16 +82,14 @@ describe('Favorite Slice', () => { expect(result).toEqual(expectedState); }); - it('should set "favoriteStatus" to "Loading" when "changeFavoriteStatusAction.pending"', () => { + it('should keep state when "changeFavoriteStatusAction.pending"', () => { const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Loading, favoritesStatus: RequestStatus.Idle, }; @@ -109,16 +98,14 @@ describe('Favorite Slice', () => { expect(result).toEqual(expectedState); }); - it('should set "favoriteStatus" to "Failed" when "changeFavoriteStatusAction.rejected"', () => { + it('should keep state when "changeFavoriteStatusAction.rejected"', () => { const initialState = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Failed, favoritesStatus: RequestStatus.Idle, }; @@ -131,12 +118,10 @@ describe('Favorite Slice', () => { const mockOffer = makeFakeFavoriteOffer(); const initialState: FavoriteData = { favorites: [], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState: FavoriteData = { favorites: [mockOffer], - favoriteStatus: RequestStatus.Success, favoritesStatus: RequestStatus.Idle, }; @@ -151,12 +136,10 @@ describe('Favorite Slice', () => { const initialState = { favorites: [mockOffer], - favoriteStatus: RequestStatus.Idle, favoritesStatus: RequestStatus.Idle, }; const expectedState = { favorites: [], - favoriteStatus: RequestStatus.Success, favoritesStatus: RequestStatus.Idle, }; diff --git a/src/store/favorite/favorite.ts b/src/store/favorite/favorite.ts index a08a62a..56cc930 100644 --- a/src/store/favorite/favorite.ts +++ b/src/store/favorite/favorite.ts @@ -5,8 +5,7 @@ import { changeFavoriteStatusAction, fetchFavoritesAction } from '../api-actions const initialState: FavoriteData = { favorites: [], - favoritesStatus: RequestStatus.Idle, - favoriteStatus: RequestStatus.Idle + favoritesStatus: RequestStatus.Idle }; export const favorite = createSlice({ @@ -25,20 +24,12 @@ export const favorite = createSlice({ .addCase(fetchFavoritesAction.rejected, (state) => { state.favoritesStatus = RequestStatus.Failed; }) - .addCase(changeFavoriteStatusAction.pending, (state) => { - state.favoriteStatus = RequestStatus.Loading; - }) .addCase(changeFavoriteStatusAction.fulfilled, (state, action) => { if (action.payload.isFavorite) { state.favorites.push(action.payload); } else { state.favorites = state.favorites.filter((item) => item.id !== action.payload.id); } - - state.favoriteStatus = RequestStatus.Success; - }) - .addCase(changeFavoriteStatusAction.rejected, (state) => { - state.favoriteStatus = RequestStatus.Failed; }); }, }); diff --git a/src/store/favorite/selectors.ts b/src/store/favorite/selectors.ts index b287ded..19392e4 100644 --- a/src/store/favorite/selectors.ts +++ b/src/store/favorite/selectors.ts @@ -3,4 +3,3 @@ import { State } from '../../types/state-type'; export const selectFavorites = (state: Pick) => state[NameSpace.Favorite].favorites; export const selectFavoritesStatus = (state: Pick) => state[NameSpace.Favorite].favoritesStatus; -export const selectFavoriteStatus = (state: Pick) => state[NameSpace.Favorite].favoriteStatus; diff --git a/src/store/offers/offers.test.ts b/src/store/offers/offers.test.ts index 2ecce82..6c2d0b3 100644 --- a/src/store/offers/offers.test.ts +++ b/src/store/offers/offers.test.ts @@ -1,6 +1,6 @@ import { CityName, RequestStatus } from '../../const'; -import { makeFakeOffer } from '../../utils/mocks'; -import { fetchOffersAction } from '../api-actions'; +import { makeFakeFavoriteOffer, makeFakeOffer } from '../../utils/mocks'; +import { changeFavoriteStatusAction, fetchOffersAction } from '../api-actions'; import { changeCity, offers } from './offers'; describe('Offers Slice', () => { @@ -79,4 +79,24 @@ describe('Offers Slice', () => { expect(result).toEqual(expectedState); }); + + it('should change isFavorite when "changeFavoriteStatusAction.fulfiled"', () => { + const offer = makeFakeOffer({ id: 'offer-1', isFavorite: false }); + const mockOffer = { ...makeFakeFavoriteOffer(), id: 'offer-1', isFavorite: true }; + const initialState = { + offers: [offer], + status: RequestStatus.Failed, + city: CityName[0], + }; + + const expectedState = { + offers: [{ ...offer, isFavorite: true }], + status: RequestStatus.Failed, + city: CityName[0], + }; + + const result = offers.reducer(initialState, changeFavoriteStatusAction.fulfilled(mockOffer, '', {id: mockOffer.id, status: 0})); + + expect(result).toEqual(expectedState); + }); }); diff --git a/src/store/offers/offers.ts b/src/store/offers/offers.ts index b73ba7a..38fc8fb 100644 --- a/src/store/offers/offers.ts +++ b/src/store/offers/offers.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { CityName, NameSpace, RequestStatus } from '../../const'; import { OffersData } from '../../types/state-type'; -import { fetchOffersAction } from '../api-actions'; +import { changeFavoriteStatusAction, fetchOffersAction } from '../api-actions'; import { CityNameType } from '../../types/offer-type'; const initialState: OffersData = { @@ -29,6 +29,13 @@ export const offers = createSlice({ }) .addCase(fetchOffersAction.rejected, (state) => { state.status = RequestStatus.Failed; + }) + .addCase(changeFavoriteStatusAction.fulfilled, (state, action) => { + const currentOffer = state.offers.find((offer) => offer.id === action.payload.id); + + if (currentOffer) { + currentOffer.isFavorite = action.payload.isFavorite; + } }); }, }); diff --git a/src/store/reviews/selectors.ts b/src/store/reviews/selectors.ts index e5bd2c1..86c35c6 100644 --- a/src/store/reviews/selectors.ts +++ b/src/store/reviews/selectors.ts @@ -1,10 +1,11 @@ import { createSelector } from '@reduxjs/toolkit'; import { NameSpace } from '../../const'; import { State } from '../../types/state-type'; +import { getSortReviewsByDate } from '../../utils/sorting'; export const selectReviews = createSelector( [(state: Pick) => state[NameSpace.Reviews].reviews], - (reviews) => [...reviews].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) + (reviews) => getSortReviewsByDate(reviews) ); export const selectReviewsStatus = (state: Pick) => state[NameSpace.Reviews].reviewsStatus; export const selectReviewStatus = (state: Pick) => state[NameSpace.Reviews].reviewStatus; diff --git a/src/types/state-type.ts b/src/types/state-type.ts index 8abdf59..37d369d 100644 --- a/src/types/state-type.ts +++ b/src/types/state-type.ts @@ -33,7 +33,6 @@ export type ReviewsData = { export type FavoriteData = { favorites: OfferAndFavorite[]; favoritesStatus: RequestStatus; - favoriteStatus: RequestStatus; } export type State = ReturnType diff --git a/src/ui/place-card/place-card.test.tsx b/src/ui/place-card/place-card.test.tsx index f8d955f..7d3b088 100644 --- a/src/ui/place-card/place-card.test.tsx +++ b/src/ui/place-card/place-card.test.tsx @@ -3,19 +3,32 @@ import { withHistory, withStore } from '../../utils/mock-component'; import PlaceCard from './place-card'; import { makeFakeOffer, makeFakeStore } from '../../utils/mocks'; import userEvent from '@testing-library/user-event'; +import { capitalizeValue, getRatingPercentage } from '../../utils/common'; +import { AuthorizationStatus, RequestStatus } from '../../const'; describe('Component: PlaceCard', () => { it('should render correctly', () => { const expectedAltText = 'Place image'; const expectedText = 'Rating'; - const mockOffer = makeFakeOffer(); + const mockOffer = makeFakeOffer({ isFavorite: true }); + const fakeStore = makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Auth, + user: null, + requestStatus: RequestStatus.Idle, + }, + FAVORITE: { + favorites: [{ ...mockOffer }], + favoritesStatus: RequestStatus.Idle, + }, + }); const { withStoreComponent } = withStore( , makeFakeStore() + />, fakeStore ); const preparedComponent = withHistory( withStoreComponent @@ -28,6 +41,12 @@ describe('Component: PlaceCard', () => { expect(screen.getByRole('article')).toBeInTheDocument(); expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole('img')).toBeInTheDocument(); + expect(screen.getByText(mockOffer.title)).toBeInTheDocument(); + expect(screen.getByText(`€${mockOffer.price}`)).toBeInTheDocument(); + expect(screen.getByText(capitalizeValue(mockOffer.type))).toBeInTheDocument(); + expect(screen.getByTestId('rating-stars')).toHaveStyle(`width: ${getRatingPercentage(mockOffer.rating)}`); + expect(screen.getByTestId('image')).toHaveAttribute('src', mockOffer.previewImage); + expect(screen.getByRole('button')).toHaveClass('place-card__bookmark-button--active'); }); it('should call handleActiveCardChange when hovered', async () => { @@ -38,7 +57,7 @@ describe('Component: PlaceCard', () => { offer={mockOffer} className="test-class" imgClassName="test-img-class" - handleActiveCardChange={mockHandleActiveCardChange} + onActiveCardChange={mockHandleActiveCardChange} />, makeFakeStore() ); const preparedComponent = withHistory( @@ -62,7 +81,7 @@ describe('Component: PlaceCard', () => { offer={mockOffer} className="test-class" imgClassName="test-img-class" - handleActiveCardChange={mockHandleActiveCardChange} + onActiveCardChange={mockHandleActiveCardChange} />, makeFakeStore() ); const preparedComponent = withHistory( @@ -117,14 +136,24 @@ describe('Component: PlaceCard', () => { }); it('should render bookmark button with active class when offer is favorite', () => { - const mockOffer = makeFakeOffer(); - mockOffer.isFavorite = true; + const mockOffer = makeFakeOffer({ isFavorite: true }); + const fakeStore = makeFakeStore({ + USER: { + authorizationStatus: AuthorizationStatus.Auth, + user: null, + requestStatus: RequestStatus.Idle, + }, + FAVORITE: { + favorites: [{ ...mockOffer }], + favoritesStatus: RequestStatus.Idle, + }, + }); const { withStoreComponent } = withStore( , makeFakeStore() + />, fakeStore ); const preparedComponent = withHistory( withStoreComponent diff --git a/src/ui/place-card/place-card.tsx b/src/ui/place-card/place-card.tsx index a094ce7..874d30d 100644 --- a/src/ui/place-card/place-card.tsx +++ b/src/ui/place-card/place-card.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { Offer } from '../../types/offer-type'; -import { getRaitingPercentage, capitalizeValue } from '../../utils/common'; +import { capitalizeValue, getRatingPercentage } from '../../utils/common'; import { AppRoute } from '../../const'; import FavoriteButton from '../../components/favorite-button/favorite-button'; import { OfferAndFavorite } from '../../types/favorite-offer'; @@ -11,25 +11,21 @@ type PlaceCardProps = { imgClassName: string; imgWidth?: number; imgHeight?: number; - handleActiveCardChange?: (offer?: Offer) => void; + onActiveCardChange?: (offer?: Offer) => void; } -const PlaceCard = ({offer, className, imgClassName, imgWidth = 260, imgHeight = 200, handleActiveCardChange}: PlaceCardProps): JSX.Element => { - const {id, isPremium, previewImage, price, isFavorite, rating, title, type} = offer; - const starsWidth = getRaitingPercentage(rating); +const PlaceCard = ({offer, className, imgClassName, imgWidth = 260, imgHeight = 200, onActiveCardChange}: PlaceCardProps): JSX.Element => { + const {id, isPremium, isFavorite, previewImage, price, rating, title, type} = offer; + const starsWidth = getRatingPercentage(rating); const capitalizedType = capitalizeValue(type); const linkRoute = AppRoute.Offer.replace(':id', id); const handleCardMouseOver = () => { - if (handleActiveCardChange) { - handleActiveCardChange(offer); - } + onActiveCardChange?.(offer); }; const handleCardMouseOut = () => { - if (handleActiveCardChange) { - handleActiveCardChange(); - } + onActiveCardChange?.(); }; return ( @@ -40,7 +36,7 @@ const PlaceCard = ({offer, className, imgClassName, imgWidth = 260, imgHeight = }
    - Place image + Place image
    @@ -49,11 +45,19 @@ const PlaceCard = ({offer, className, imgClassName, imgWidth = 260, imgHeight = €{price} / night
    - +
    - + Rating
    diff --git a/src/ui/review/review.tsx b/src/ui/review/review.tsx index f7ccad6..c59c188 100644 --- a/src/ui/review/review.tsx +++ b/src/ui/review/review.tsx @@ -1,4 +1,4 @@ -import { getRaitingPercentage } from '../../utils/common'; +import { getRatingPercentage } from '../../utils/common'; type ReviewProps = { date: string; @@ -13,7 +13,7 @@ type ReviewProps = { const Review = ({user, rating, comment, date}: ReviewProps): JSX.Element => { const {avatarUrl, name} = user; - const starsWidth = getRaitingPercentage(rating); + const starsWidth = getRatingPercentage(rating); const formattedDate = new Date(date); const finallyFormatedDate = formattedDate.toLocaleString('en-US', {month: 'long', year: 'numeric'}); diff --git a/src/ui/tab/tab.test.tsx b/src/ui/tab/tab.test.tsx deleted file mode 100644 index 1d4f819..0000000 --- a/src/ui/tab/tab.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { withHistory } from '../../utils/mock-component'; -import Tab from './tab'; -import { CityName } from '../../const'; -import userEvent from '@testing-library/user-event'; - -describe('Component: Tab', () => { - it('should render correctly', () => { - const expectedText = CityName[0]; - const preparedComponent = withHistory(); - - render(preparedComponent); - - expect(screen.getByText(expectedText)).toBeInTheDocument(); - }); - - it('should call onTabClick when clicked', async () => { - const mockOnTabClick = vi.fn(); - const preparedComponent = withHistory( - - ); - - render(preparedComponent); - await userEvent.click(screen.getByTestId('tab-link')); - - expect(mockOnTabClick).toBeCalledTimes(1); - }); -}); diff --git a/src/utils/common.ts b/src/utils/common.ts index 9fd7358..b3d38cc 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,7 +1,7 @@ import { AuthorizationStatus } from '../const'; import { CityNameType, Offer } from '../types/offer-type'; -const getRaitingPercentage = (raiting: number): string => `${Math.round(raiting) / 5 * 100}%`; +const getRatingPercentage = (raiting: number): string => `${Math.round(raiting) / 5 * 100}%`; const capitalizeValue = (value: string): string => value[0].toUpperCase() + value.slice(1, value.length); @@ -11,4 +11,4 @@ const isAuth = (authStatus: AuthorizationStatus): boolean => authStatus === Auth const filterOffersByCity = (offers: Offer[], currentCityName: CityNameType): Offer[] => offers.filter((offer) => offer.city.name === currentCityName); -export {getRaitingPercentage, capitalizeValue, isEscKey, isAuth, filterOffersByCity}; +export {getRatingPercentage, capitalizeValue, isEscKey, isAuth, filterOffersByCity}; diff --git a/src/utils/mocks.ts b/src/utils/mocks.ts index a3603d5..798bdf0 100644 --- a/src/utils/mocks.ts +++ b/src/utils/mocks.ts @@ -1,4 +1,4 @@ -import {name, internet} from 'faker'; +import {name, internet, lorem} from 'faker'; import { Offer } from '../types/offer-type'; import { AuthorizationStatus, CityName, Housing, RequestStatus } from '../const'; import { ExtraOffer } from '../types/extra-offer'; @@ -11,7 +11,7 @@ import { State } from '../types/state-type'; export type AppThunkDispatch = ThunkDispatch, Action>; -export const makeFakeOffer = (): Offer => ({ +export const makeFakeOffer = (offersOptions?: Partial): Offer => ({ id: name.title(), title: name.title(), type: Housing.Apartment, @@ -32,7 +32,8 @@ export const makeFakeOffer = (): Offer => ({ }, isFavorite: true, isPremium: false, - rating: 4.9 + rating: 4.9, + ...offersOptions }); export const makeFakeExtraOffer = (): ExtraOffer => ({ @@ -102,7 +103,7 @@ export const makeFakeFavoriteOffer = (): FavoriteOffer => ({ images: new Array(3).fill(null).map(() => internet.url()), }); -export const makeFakeReview = (): ReviewType => ({ +export const makeFakeReview = (options?: Partial): ReviewType => ({ id: name.title(), date: new Date().toISOString(), user: { @@ -111,7 +112,8 @@ export const makeFakeReview = (): ReviewType => ({ avatarUrl: internet.avatar(), }, rating: 4.6, - comment: 'A new spacious villa, one floor. All commodities, jacuzzi and beautiful scenery. Ideal for families or friends.' + comment: lorem.sentence(), + ...options }); export const makeFakeUser = (): UserData => ({ @@ -147,8 +149,7 @@ export const makeFakeStore = (initialState?: Partial): State => ({ }, FAVORITE: { favorites: new Array(3).fill(null).map(() => makeFakeFavoriteOffer()), - favoritesStatus: RequestStatus.Idle, - favoriteStatus: RequestStatus.Idle + favoritesStatus: RequestStatus.Idle }, ...initialState ?? {}, }); diff --git a/src/utils/sorting.test.ts b/src/utils/sorting.test.ts new file mode 100644 index 0000000..184da40 --- /dev/null +++ b/src/utils/sorting.test.ts @@ -0,0 +1,81 @@ +import { getSortReviewsByDate, sortOffers } from './sorting'; +import { makeFakeOffer, makeFakeReview } from '../utils/mocks'; +import { SortingOption } from '../const'; + +describe('sortOffers', () => { + const offers = [ + makeFakeOffer({ price: 100, rating: 4.5, title: 'A' }), + makeFakeOffer({ price: 50, rating: 5, title: 'B' }), + makeFakeOffer({ price: 200, rating: 3, title: 'C' }), + ]; + + it('should return original array for "Popular" sorting', () => { + const result = sortOffers(SortingOption[0], offers); + expect(result).toEqual(offers); + }); + + it('should sort by price low to high', () => { + const result = sortOffers('Price: low to high', offers); + expect(result[0].price).toBe(50); + expect(result[1].price).toBe(100); + expect(result[2].price).toBe(200); + }); + + it('should sort by price high to low', () => { + const result = sortOffers('Price: high to low', offers); + expect(result[0].price).toBe(200); + expect(result[1].price).toBe(100); + expect(result[2].price).toBe(50); + }); + + it('should sort by rating top rated first', () => { + const result = sortOffers('Top rated first', offers); + expect(result[0].rating).toBe(5); + expect(result[1].rating).toBe(4.5); + expect(result[2].rating).toBe(3); + }); +}); + +describe('getSortReviewsByDate', () => { + it('should return empty array when input is empty', () => { + const result = getSortReviewsByDate([]); + expect(result).toEqual([]); + }); + + it('should return the same array when input has one element', () => { + const review = makeFakeReview(); + const result = getSortReviewsByDate([review]); + expect(result).toEqual([review]); + }); + + it('should sort reviews from oldest to newest by date', () => { + const oldReview = makeFakeReview({ date: '2023-01-01T00:00:00.000Z' }); + const middleReview = makeFakeReview({ date: '2023-06-01T00:00:00.000Z' }); + const newReview = makeFakeReview({ date: '2023-12-31T23:59:59.999Z' }); + + const unsorted = [newReview, oldReview, middleReview]; + const sorted = getSortReviewsByDate(unsorted); + + expect(sorted[0]).toBe(newReview); + expect(sorted[1]).toBe(middleReview); + expect(sorted[2]).toBe(oldReview); + }); + + it('should not mutate the original array', () => { + const original = [makeFakeReview({ date: '2023-12-01' }), makeFakeReview({ date: '2023-01-01' })]; + const originalCopy = [...original]; + getSortReviewsByDate(original); + expect(original).toEqual(originalCopy); + }); + + it('should handle equal dates (stable sort is not required, but should not crash)', () => { + const date = '2023-05-05T12:00:00.000Z'; + const reviewA = makeFakeReview({ date }); + const reviewB = makeFakeReview({ date }); + const result = getSortReviewsByDate([reviewA, reviewB]); + + expect(result).toHaveLength(2); + expect(result).toContain(reviewA); + expect(result).toContain(reviewB); + }); +}); diff --git a/src/utils/sorting.ts b/src/utils/sorting.ts index 32a61c6..061d6cf 100644 --- a/src/utils/sorting.ts +++ b/src/utils/sorting.ts @@ -1,5 +1,6 @@ import { SortingOption } from '../const'; import { Offer } from '../types/offer-type'; +import { ReviewType } from '../types/review-type'; import { SortingOptionType } from '../types/sorting-option-type'; const sortOffers = (sorting: SortingOptionType, offers: Offer[]): Offer[] => { @@ -17,4 +18,6 @@ const sortOffers = (sorting: SortingOptionType, offers: Offer[]): Offer[] => { } }; -export {sortOffers}; +const getSortReviewsByDate = (elements: ReviewType[]) => [...elements].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + +export {sortOffers, getSortReviewsByDate};