Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LoadingPage />
Expand Down
2 changes: 1 addition & 1 deletion src/components/cities/cities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const Cities = (): JSX.Element => {
data-testid="places"
>
<h2 className="visually-hidden">Places</h2>
<b className="places__found">{placesCount} place{placesCount === 1 ? '' : 's'} to stay in Amsterdam</b>
<b className="places__found">{placesCount} place{placesCount === 1 ? '' : 's'} to stay in {currentCityName}</b>
<Sorting currentOption={currentSorting} onSortingOptionClick={handleSortingOptionClick} />
</Places>
<div className="cities__right-section">
Expand Down
124 changes: 76 additions & 48 deletions src/components/favorite-button/favorite-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../hooks')>('../../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';
Expand All @@ -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(
<FavoriteButton
id={mockId}
isFavorite={false}
className={mockClassName}
activeClassName={mockActiveClassName}
svgClassName={mockSvgClassName}
/>
isFavorite={false}
/>,
history
);
const { withStoreComponent } = withStore(withHistoryComponent, fakeStore);
render(withStoreComponent);
render(withHistoryComponent);

const button = screen.getByRole('button');
expect(button).toHaveClass(mockClassName);
Expand All @@ -43,85 +65,91 @@ 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(
<FavoriteButton
id={mockId}
isFavorite
className={mockClassName}
activeClassName={mockActiveClassName}
svgClassName={mockSvgClassName}
/>
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(
<FavoriteButton
id={mockId}
className={mockClassName}
activeClassName={mockActiveClassName}
svgClassName={mockSvgClassName}
isFavorite
/>,
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(
<FavoriteButton
id={mockId}
isFavorite={false}
className={mockClassName}
activeClassName={mockActiveClassName}
svgClassName={mockSvgClassName}
/>
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(
<FavoriteButton
id={mockId}
isFavorite={false}
className={mockClassName}
activeClassName={mockActiveClassName}
svgClassName={mockSvgClassName}
/>
isFavorite={false}
/>,
history
);
const { withStoreComponent } = withStore(withHistoryComponent, fakeStore);
render(withStoreComponent);
render(withHistoryComponent);

const button = screen.getByRole('button');
expect(button).not.toBeDisabled();

await userEvent.click(button);

expect(button).not.toBeDisabled();
expect(mockDispatch).toHaveBeenCalled();
});
});
28 changes: 20 additions & 8 deletions src/components/favorite-button/favorite-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>): Promise<void> => {
evt.preventDefault();
Expand All @@ -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);
}
Expand All @@ -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}
>
<svg className={svgClassName} width={imgWidth || 31} height={imgHeight || 33}>
<svg className={svgClassName} width={imgWidth || Icon.Width} height={imgHeight || Icon.Heigh}>
<use xlinkHref="#icon-bookmark" />
</svg>
<span className="visually-hidden">To bookmarks</span>
Expand Down
5 changes: 3 additions & 2 deletions src/components/login-form/login-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LoginForm />);
const {withStoreComponent} = withStore(<LoginForm />, makeFakeStore());

render(withStoreComponent);

Expand All @@ -22,7 +23,7 @@ describe('Component: LoginForm', () => {
const passwordElementTestId = 'passwordElement';
const expectedLoginValue = 'email@test.com';
const expectedPasswordValue = '123456';
const {withStoreComponent} = withStore(<LoginForm />);
const {withStoreComponent} = withStore(<LoginForm />, makeFakeStore());

render(withStoreComponent);

Expand Down
14 changes: 11 additions & 3 deletions src/components/login-form/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>
type SubmitHandler = FormEventHandler<HTMLFormElement>
Expand All @@ -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));
};
Expand All @@ -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"
Expand All @@ -54,6 +61,7 @@ const LoginForm = (): JSX.Element => {
placeholder="Password"
required
data-testid="passwordElement"
pattern="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]+$"
/>
</div>
<button className="login__submit form__submit button" type="submit">Sign in</button>
Expand Down
2 changes: 1 addition & 1 deletion src/components/map/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/components/places/places.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ const Places = ({ offers, className, listClassName, cardClassName, imgClassName,
{children}
<div className={`${listClassName} cities__places-list places__list tabs__content`}>
{offers.map((offer) =>
(<PlaceCard key={offer.id} offer={offer} className={cardClassName} imgClassName={imgClassName} handleActiveCardChange={onActiveCardChange} />
(
<PlaceCard
key={offer.id}
offer={offer}
className={cardClassName}
imgClassName={imgClassName}
onActiveCardChange={onActiveCardChange}
/>
))}
</div>
</section>
Expand Down
Loading
Loading