diff --git a/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx index 35d9d17b..10e961b8 100644 --- a/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx +++ b/src/components/Modals/BlockUnblockUserModal/BlockUnblockUserModal.tsx @@ -1,7 +1,13 @@ -import { Dispatch, SetStateAction, useEffect } from 'react'; -import { api } from '@/hooks'; +import { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useShallow } from 'zustand/react/shallow'; +import { BUY_SELL_URL, ERROR_CODES } from '@/constants'; +import { api, useModalManager } from '@/hooks'; +import { useErrorStore } from '@/stores'; +import { getCurrentRoute } from '@/utils'; import { Localize, useTranslations } from '@deriv-com/translations'; import { Button, Modal, Text, useDevice } from '@deriv-com/ui'; +import { ErrorModal } from '../ErrorModal'; import './BlockUnblockUserModal.scss'; type TBlockUnblockUserModalProps = { @@ -11,7 +17,6 @@ type TBlockUnblockUserModalProps = { isModalOpen: boolean; onClickBlocked?: () => void; onRequestClose: () => void; - setErrorMessage?: Dispatch>; }; const BlockUnblockUserModal = ({ @@ -21,10 +26,9 @@ const BlockUnblockUserModal = ({ isModalOpen, onClickBlocked, onRequestClose, - setErrorMessage, }: TBlockUnblockUserModalProps) => { - const { isMobile } = useDevice(); const { localize } = useTranslations(); + const { isMobile } = useDevice(); const { mutate: blockAdvertiser, mutation: { error, isSuccess }, @@ -33,17 +37,28 @@ const BlockUnblockUserModal = ({ mutate: unblockAdvertiser, mutation: { error: unblockError, isSuccess: unblockIsSuccess }, } = api.counterparty.useUnblock(); + const { hideModal, isModalOpenFor, showModal } = useModalManager(); + const { errorMessages, setErrorMessages } = useErrorStore( + useShallow(state => ({ errorMessages: state.errorMessages, setErrorMessages: state.setErrorMessages })) + ); + const isAdvertiser = getCurrentRoute() === 'advertiser'; + const history = useHistory(); useEffect(() => { if (isSuccess || unblockIsSuccess) { onClickBlocked?.(); onRequestClose(); } else if (error || unblockError) { - setErrorMessage?.(error?.message || unblockError?.message); - onRequestClose(); + setErrorMessages(error || unblockError); + + if (error?.code === ERROR_CODES.PERMISSION_DENIED && isAdvertiser) { + showModal('ErrorModal'); + } else { + onRequestClose(); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSuccess, onClickBlocked, unblockIsSuccess, unblockError, error, setErrorMessage]); + }, [isSuccess, onClickBlocked, unblockIsSuccess, unblockError, error, setErrorMessages]); const textSize = isMobile ? 'md' : 'sm'; const getModalTitle = () => @@ -70,6 +85,24 @@ const BlockUnblockUserModal = ({ } }; + const permissionDeniedError = errorMessages.find(error => error.code === ERROR_CODES.PERMISSION_DENIED); + + if (permissionDeniedError && isModalOpenFor('ErrorModal')) { + return ( + { + hideModal(); + history.push(BUY_SELL_URL); + }} + title={localize('Unable to block advertiser')} + /> + ); + } + return ( ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ push: mockPush }), +})); jest.mock('@/hooks', () => ({ ...jest.requireActual('@/hooks'), @@ -12,10 +36,7 @@ jest.mock('@/hooks', () => ({ counterparty: { useBlock: jest.fn(() => ({ mutate: mockUseBlockMutate, - mutation: { - error: {}, - isSuccess: false, - }, + mutation: mockBlockMutation, })), useUnblock: jest.fn(() => ({ mutate: mockUseUnblockMutate, @@ -28,11 +49,25 @@ jest.mock('@/hooks', () => ({ }, })); +jest.mock('@/hooks/custom-hooks', () => ({ + useModalManager: jest.fn(() => mockModalManager), +})); + +jest.mock('@/utils', () => ({ + getCurrentRoute: jest.fn().mockReturnValue('my-profile'), +})); + jest.mock('@deriv-com/ui', () => ({ ...jest.requireActual('@deriv-com/ui'), useDevice: jest.fn(() => ({ isMobile: false })), })); +jest.mock('@/stores', () => ({ + useErrorStore: jest.fn(selector => (selector ? selector(mockStore) : mockStore)), +})); + +const mockGetCurrentRoute = getCurrentRoute as jest.Mock; + describe('BlockUnblockUserModal', () => { it('should render the modal with correct title and behaviour for blocking user', async () => { render( @@ -100,4 +135,67 @@ describe('BlockUnblockUserModal', () => { expect(mockOnRequestClose).toBeCalled(); }); + + it('should call onClickBlocked and onRequestClose if isSuccess or mutation returns success', async () => { + mockBlockMutation.isSuccess = true; + const mockOnClickBlocked = jest.fn(); + render( + + ); + + expect(mockOnRequestClose).toHaveBeenCalled(); + expect(mockOnClickBlocked).toHaveBeenCalled(); + }); + + it('should show error modal when permission is denied and current route is advertiser', async () => { + mockGetCurrentRoute.mockReturnValue('advertiser'); + mockModalManager.isModalOpenFor.mockImplementation((modalName: string) => modalName === 'ErrorModal'); + const error = { + code: 'PermissionDenied', + message: 'You are not allowed to block this user', + }; + // @ts-expect-error - mock values + mockStore.errorMessages = [error]; + mockBlockMutation.error = error; + mockBlockMutation.isSuccess = false; + render( + + ); + + expect(screen.queryByText('Unable to block advertiser')).toBeVisible(); + expect(screen.queryByText('You are not allowed to block this user')).toBeVisible(); + }); + + it('should call hideModal and history.push when user clicks on Got it button', async () => { + render( + + ); + + const gotItBtn = screen.getByRole('button', { + name: 'Got it', + }); + await userEvent.click(gotItBtn); + + expect(mockModalManager.hideModal).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith(BUY_SELL_URL); + }); }); diff --git a/src/constants/api-error-codes.ts b/src/constants/api-error-codes.ts index 437c5c2b..731ee6db 100644 --- a/src/constants/api-error-codes.ts +++ b/src/constants/api-error-codes.ts @@ -18,4 +18,5 @@ export const ERROR_CODES = { ORDER_CREATE_FAIL_RATE_SLIPPAGE: 'OrderCreateFailRateSlippage', ORDER_EMAIL_VERIFICATION_REQUIRED: 'OrderEmailVerificationRequired', PERMISSION_DENIED: 'PermissionDenied', + TEMPORARY_BAR: 'TemporaryBar', } as const; diff --git a/src/hooks/custom-hooks/useIsAdvertiserBarred.ts b/src/hooks/custom-hooks/useIsAdvertiserBarred.ts index 26009ad7..fa6b31b2 100644 --- a/src/hooks/custom-hooks/useIsAdvertiserBarred.ts +++ b/src/hooks/custom-hooks/useIsAdvertiserBarred.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useMemo } from 'react'; import useInvalidateQuery from '../api/useInvalidateQuery'; import { api } from '..'; @@ -8,16 +8,14 @@ import { api } from '..'; */ const useIsAdvertiserBarred = (): boolean => { const { data = {} } = api.advertiser.useGetInfo(); - const [isAdvertiserBarred, setIsAdvertiserBarred] = useState(false); const invalidate = useInvalidateQuery(); - useEffect(() => { - if (isAdvertiserBarred !== !!data.blocked_until) { - invalidate('p2p_advertiser_adverts'); - setIsAdvertiserBarred(!!data.blocked_until); - } + const isAdvertiserBarred = useMemo(() => { + invalidate('p2p_advertiser_adverts'); + return !!data.blocked_until; + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAdvertiserBarred, data.blocked_until]); + }, [data.blocked_until]); return isAdvertiserBarred; }; diff --git a/src/pages/advertiser/screens/AdvertiserBlockOverlay/AdvertiserBlockOverlay.tsx b/src/pages/advertiser/screens/AdvertiserBlockOverlay/AdvertiserBlockOverlay.tsx index 73edf3fb..480e605d 100644 --- a/src/pages/advertiser/screens/AdvertiserBlockOverlay/AdvertiserBlockOverlay.tsx +++ b/src/pages/advertiser/screens/AdvertiserBlockOverlay/AdvertiserBlockOverlay.tsx @@ -49,14 +49,16 @@ const AdvertiserBlockOverlay = ({ {children} - setShowOverlay(false)} - onRequestClose={hideModal} - /> + {isModalOpenFor('BlockUnblockUserModal') && ( + setShowOverlay(false)} + onRequestClose={hideModal} + /> + )} ); } diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx index 3fb98c44..085bddf2 100644 --- a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx +++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/MyProfileCounterpartiesTable.tsx @@ -1,6 +1,10 @@ -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { Table } from '@/components'; +import { ERROR_CODES } from '@/constants'; import { api } from '@/hooks'; +import { useIsAdvertiserBarred } from '@/hooks/custom-hooks'; +import { useErrorStore } from '@/stores'; import { DerivLightIcBlockedAdvertisersBarredIcon } from '@deriv/quill-icons'; import { Localize } from '@deriv-com/translations'; import { Loader, Text, useDevice } from '@deriv-com/ui'; @@ -18,21 +22,14 @@ type TMyProfileCounterpartiesTableRowRendererProps = { id?: string; is_blocked: boolean; name?: string; - setErrorMessage: Dispatch>; }; const MyProfileCounterpartiesTableRowRenderer = ({ id, is_blocked: isBlocked, name, - setErrorMessage, }: TMyProfileCounterpartiesTableRowRendererProps) => ( - + ); const MyProfileCounterpartiesTable = ({ @@ -50,8 +47,14 @@ const MyProfileCounterpartiesTable = ({ is_blocked: dropdownValue === 'blocked' ? 1 : 0, trade_partners: 1, }); - const [errorMessage, setErrorMessage] = useState(''); const { isDesktop } = useDevice(); + const { errorMessages, reset } = useErrorStore( + useShallow(state => ({ errorMessages: state.errorMessages, reset: state.reset })) + ); + const isAdvertiserBarred = useIsAdvertiserBarred(); + const errorMessage = errorMessages.find( + error => error.code === ERROR_CODES.TEMPORARY_BAR || error.code === ERROR_CODES.PERMISSION_DENIED + )?.message; useEffect(() => { if (data.length > 0) { @@ -62,6 +65,13 @@ const MyProfileCounterpartiesTable = ({ } }, [data, errorMessage, setShowHeader]); + useEffect(() => { + if (!isAdvertiserBarred && errorMessages.some(error => error.code === ERROR_CODES.TEMPORARY_BAR)) { + setShowHeader(true); + reset(); + } + }, [errorMessage, errorMessages, isAdvertiserBarred, reset, setShowHeader]); + if (isLoading) { return ; } @@ -93,7 +103,6 @@ const MyProfileCounterpartiesTable = ({ rowRender={(rowData: unknown) => ( )} tableClassname='my-profile-counterparties-table' diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx index a199320c..6c59ecd3 100644 --- a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx +++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTable/__tests__/MyProfileCounterpartiesTable.spec.tsx @@ -1,4 +1,5 @@ import { api } from '@/hooks'; +import { useIsAdvertiserBarred } from '@/hooks/custom-hooks'; import { render, screen, waitFor } from '@testing-library/react'; import MyProfileCounterpartiesTable from '../MyProfileCounterpartiesTable'; @@ -15,12 +16,18 @@ const mockApiValues = { loadMoreAdvertisers: jest.fn(), }; +const mockStore = { + errorMessages: [], + reset: jest.fn(), +}; + jest.mock('@deriv-com/ui', () => ({ ...jest.requireActual('@deriv-com/ui'), useDevice: jest.fn(() => ({ isMobile: false })), })); jest.mock('@/hooks', () => ({ + ...jest.requireActual('@/hooks'), api: { advertiser: { useGetList: jest.fn(() => mockApiValues), @@ -29,6 +36,14 @@ jest.mock('@/hooks', () => ({ useIsRtl: jest.fn(() => false), })); +jest.mock('@/hooks/custom-hooks', () => ({ + useIsAdvertiserBarred: jest.fn(() => false), +})); + +jest.mock('@/stores', () => ({ + useErrorStore: jest.fn(selector => (selector ? selector(mockStore) : mockStore)), +})); + const mockUseModalManager = { hideModal: jest.fn(), isModalOpenFor: jest.fn(), @@ -46,6 +61,8 @@ jest.mock('@/components/Modals/BlockUnblockUserModal', () => ({ })); const mockUseGetList = api.advertiser.useGetList as jest.Mock; +const mockUseIsAdvertiserBarred = useIsAdvertiserBarred as jest.Mock; + describe('MyProfileCounterpartiesTable', () => { it('should render the empty results when there is no data', () => { render(); @@ -82,4 +99,32 @@ describe('MyProfileCounterpartiesTable', () => { render(); expect(screen.getByText('There are no matching name.')).toBeInTheDocument(); }); + + it('should show error message when error code is TEMPORARY_BAR', () => { + // @ts-expect-error - mock values + mockStore.errorMessages = [{ code: 'TemporaryBar', message: 'Temporary Bar' }]; + mockUseIsAdvertiserBarred.mockReturnValue(true); + mockUseGetList.mockReturnValue({ + ...mockApiValues, + data: [{ id: 'id1', is_blocked: false, name: 'name1' }], + }); + + render(); + expect(screen.getByText('Temporary Bar')).toBeInTheDocument(); + }); + + it('should call reset and setShowHeader if isAdvertiserBarred is false and error code is', () => { + // @ts-expect-error - mock values + mockStore.errorMessages = [{ code: 'TemporaryBar', message: 'Temporary Bar' }]; + mockUseIsAdvertiserBarred.mockReturnValue(false); + mockUseGetList.mockReturnValue({ + ...mockApiValues, + data: [{ id: 'id1', is_blocked: false, name: 'name1' }], + }); + + render(); + + expect(mockProps.setShowHeader).toHaveBeenCalledWith(true); + expect(mockStore.reset).toHaveBeenCalled(); + }); }); diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx index 161b80c9..962e008e 100644 --- a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx +++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/MyProfileCounterpartiesTableRow.tsx @@ -1,4 +1,4 @@ -import { Dispatch, memo, SetStateAction } from 'react'; +import { memo } from 'react'; import clsx from 'clsx'; import { useHistory } from 'react-router-dom'; import { UserAvatar } from '@/components'; @@ -13,15 +13,9 @@ type TMyProfileCounterpartiesTableRowProps = { id: string; isBlocked: boolean; nickname: string; - setErrorMessage: Dispatch>; }; -const MyProfileCounterpartiesTableRow = ({ - id, - isBlocked, - nickname, - setErrorMessage, -}: TMyProfileCounterpartiesTableRowProps) => { +const MyProfileCounterpartiesTableRow = ({ id, isBlocked, nickname }: TMyProfileCounterpartiesTableRowProps) => { const { isDesktop } = useDevice(); const history = useHistory(); const { hideModal, isModalOpenFor, showModal } = useModalManager(); @@ -54,14 +48,15 @@ const MyProfileCounterpartiesTableRow = ({ {isBlocked ? localize('Unblock') : localize('Block')} - + {isModalOpenFor('BlockUnblockUserModal') && ( + + )} ); }; diff --git a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx index 56561134..76313e30 100644 --- a/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx +++ b/src/pages/my-profile/screens/MyProfileCounterparties/MyProfileCounterpartiesTableRow/__tests__/MyProfileCounterpartiesTableRow.spec.tsx @@ -34,6 +34,7 @@ const mockModalManager = { }; jest.mock('@/hooks', () => ({ + ...jest.requireActual('@/hooks'), api: { counterparty: { useBlock: () => ({ diff --git a/src/stores/__tests__/useErrorStore.spec.ts b/src/stores/__tests__/useErrorStore.spec.ts new file mode 100644 index 00000000..7084e4d4 --- /dev/null +++ b/src/stores/__tests__/useErrorStore.spec.ts @@ -0,0 +1,61 @@ +import { act } from 'react'; +import { renderHook } from '@testing-library/react'; +import useErrorStore from '../useErrorStore'; + +describe('useErrorStore', () => { + it('should update the store state correctly', () => { + const { result } = renderHook(() => useErrorStore()); + + expect(result.current.errorMessages).toEqual([]); + + act(() => { + result.current.setErrorMessages({ code: 'error-code', message: 'error-message' }); + }); + + expect(result.current.errorMessages).toEqual([{ code: 'error-code', message: 'error-message' }]); + + act(() => { + result.current.setErrorMessages(null); + }); + }); + + it('should not add the same error message twice', () => { + const { result } = renderHook(() => useErrorStore()); + + expect(result.current.errorMessages).toEqual([]); + + act(() => { + result.current.setErrorMessages({ code: 'error-code', message: 'error-message' }); + }); + + expect(result.current.errorMessages).toEqual([{ code: 'error-code', message: 'error-message' }]); + + act(() => { + result.current.setErrorMessages({ code: 'error-code', message: 'error-message' }); + }); + + expect(result.current.errorMessages).toEqual([{ code: 'error-code', message: 'error-message' }]); + + act(() => { + result.current.setErrorMessages(null); + }); + }); + + it('should reset the error messages', () => { + const { result } = renderHook(() => useErrorStore()); + + expect(result.current.errorMessages).toEqual([]); + + act(() => { + result.current.setErrorMessages({ code: 'error-code', message: 'error-message' }); + }); + + expect(result.current.errorMessages).toEqual([{ code: 'error-code', message: 'error-message' }]); + + act(() => { + result.current.reset(); + }); + + expect(result.current.errorMessages).toEqual([]); + }); +}); diff --git a/src/stores/index.ts b/src/stores/index.ts index 0d8fcad6..818cb5a4 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,2 +1,3 @@ export { default as useBuySellFiltersStore } from './useBuySellFiltersStore'; +export { default as useErrorStore } from './useErrorStore'; export { default as useTabsStore } from './useTabsStore'; diff --git a/src/stores/useErrorStore.ts b/src/stores/useErrorStore.ts new file mode 100644 index 00000000..8d3b3876 --- /dev/null +++ b/src/stores/useErrorStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; + +type TError = { + code: string; + message: string; +}; + +type TErrorState = { + errorMessages: TError[]; +}; + +type TErrorAction = { + reset: () => void; + setErrorMessages: (errorMessages: TError | null) => void; +}; + +const useErrorStore = create()(set => ({ + errorMessages: [], + reset: () => set({ errorMessages: [] }), + setErrorMessages: errorMessages => + set(state => { + if (!errorMessages) { + return { ...state, errorMessages: [] }; + } + + const isErrorMessageIncluded = state.errorMessages.some( + error => error.code === errorMessages.code && error.message === errorMessages.message + ); + + return { + ...state, + errorMessages: isErrorMessageIncluded + ? state.errorMessages + : [...state.errorMessages, { code: errorMessages.code, message: errorMessages.message }], + }; + }), +})); + +export default useErrorStore;