diff --git a/packages/trader/src/AppV2/Components/ServicesErrorSnackbar/services-error-snackbar.tsx b/packages/trader/src/AppV2/Components/ServicesErrorSnackbar/services-error-snackbar.tsx index 589ef4ae148e..dfe60c4fca96 100644 --- a/packages/trader/src/AppV2/Components/ServicesErrorSnackbar/services-error-snackbar.tsx +++ b/packages/trader/src/AppV2/Components/ServicesErrorSnackbar/services-error-snackbar.tsx @@ -26,12 +26,12 @@ const ServicesErrorSnackbar = observer(() => { const { code, message } = services_error || {}; const has_services_error = !isEmptyObject(services_error); const is_modal_error = checkIsServiceModalError({ services_error, is_mf_verification_pending_modal_visible }); - const contract_type_object = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); + const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); // Some BO errors comes inside of proposal and we store them inside of proposal_info. // Such error have no error_field and it is one of the main differences from trade parameters errors (duration, stake and etc). // Another difference is that trade params errors arrays in validation_errors are empty. - const { has_error, error_field, message: contract_error_message } = proposal_info[contract_type_object[0]] ?? {}; + const { has_error, error_field, message: contract_error_message } = proposal_info[contract_types[0]] ?? {}; const contract_error = has_error && !error_field && !Object.keys(validation_errors).some(key => validation_errors[key].length); diff --git a/packages/trader/src/AppV2/Components/TradeErrorSnackbar/__tests__/trade-error-snackbar.spec.tsx b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/__tests__/trade-error-snackbar.spec.tsx new file mode 100644 index 000000000000..04a097abc98b --- /dev/null +++ b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/__tests__/trade-error-snackbar.spec.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { mockStore } from '@deriv/stores'; +import { useSnackbar } from '@deriv-com/quill-ui'; +import TraderProviders from '../../../../trader-providers'; +import TradeErrorSnackbar from '../trade-error-snackbar'; +import { CONTRACT_TYPES, TRADE_TYPES } from '@deriv/shared'; + +jest.mock('@deriv-com/quill-ui', () => ({ + ...jest.requireActual('@deriv-com/quill-ui'), + useSnackbar: jest.fn(), +})); + +describe('TradeErrorSnackbar', () => { + let default_mock_store: ReturnType, + default_mock_props: React.ComponentProps; + let mockAddSnackbar = jest.fn(); + + beforeEach(() => { + default_mock_store = mockStore({ + client: { is_logged_in: true }, + modules: { + trade: { + contract_type: TRADE_TYPES.TURBOS.LONG, + proposal_info: { + TURBOSLONG: { + has_error: true, + has_error_details: false, + error_code: 'ContractBuyValidationError', + error_field: 'take_profit', + message: 'Enter an amount equal to or lower than 1701.11.', + }, + }, + validation_errors: { + amount: [], + barrier_1: [], + barrier_2: [], + duration: [], + start_date: [], + start_time: [], + stop_loss: [], + take_profit: [], + expiry_date: [], + expiry_time: [], + }, + trade_type_tab: CONTRACT_TYPES.TURBOS.LONG, + trade_types: { + [CONTRACT_TYPES.TURBOS.LONG]: 'Turbos Long', + }, + }, + }, + }); + default_mock_props = { error_fields: ['take_profit', 'stop_loss'], should_show_snackbar: true }; + mockAddSnackbar = jest.fn(); + (useSnackbar as jest.Mock).mockReturnValue({ addSnackbar: mockAddSnackbar }); + }); + + const mockTradeErrorSnackbar = () => { + return ( + + + + ); + }; + + it('calls useSnackbar if error field in proposal matches the passed error_fields', () => { + render(mockTradeErrorSnackbar()); + + expect(mockAddSnackbar).toHaveBeenCalled(); + }); + + it('calls useSnackbar if error field in proposal matches the passed error_fields even if user is log out', () => { + default_mock_store.client.is_logged_in = false; + render(mockTradeErrorSnackbar()); + + expect(mockAddSnackbar).toHaveBeenCalled(); + }); + + it('does not call useSnackbar if error field in proposal does not matches the passed error_fields', () => { + default_mock_store.modules.trade.proposal_info = { + TURBOSLONG: { + has_error: true, + has_error_details: false, + error_code: 'ContractBuyValidationError', + error_field: 'new_trade_param', + message: 'Enter an amount equal to or lower than 1701.11.', + }, + }; + render(mockTradeErrorSnackbar()); + + expect(mockAddSnackbar).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trader/src/AppV2/Components/TradeErrorSnackbar/index.ts b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/index.ts new file mode 100644 index 000000000000..f78e30e2fbee --- /dev/null +++ b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/index.ts @@ -0,0 +1,3 @@ +import TradeErrorSnackbar from './trade-error-snackbar'; + +export default TradeErrorSnackbar; diff --git a/packages/trader/src/AppV2/Components/TradeErrorSnackbar/trade-error-snackbar.tsx b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/trade-error-snackbar.tsx new file mode 100644 index 000000000000..aaa0478732d7 --- /dev/null +++ b/packages/trader/src/AppV2/Components/TradeErrorSnackbar/trade-error-snackbar.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { observer, useStore } from '@deriv/stores'; +import { SnackbarController, useSnackbar } from '@deriv-com/quill-ui'; +import useTradeError, { TErrorFields } from '../../Hooks/useTradeError'; + +const TradeErrorSnackbar = observer( + ({ error_fields, should_show_snackbar }: { error_fields: TErrorFields[]; should_show_snackbar?: boolean }) => { + const { + client: { is_logged_in }, + } = useStore(); + const { addSnackbar } = useSnackbar(); + const { is_error_matching_field: has_error, message } = useTradeError({ + error_fields, // array with BE error_fields, for which we will track errors. + }); + + React.useEffect(() => { + if (has_error && should_show_snackbar) { + addSnackbar({ + message, + status: 'fail', + hasCloseButton: true, + hasFixedHeight: false, + style: { + marginBottom: is_logged_in ? '48px' : '-8px', + width: 'calc(100% - var(--core-spacing-800)', + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [has_error, should_show_snackbar]); + + return ; + } +); + +export default TradeErrorSnackbar; diff --git a/packages/trader/src/AppV2/Components/TradeParameters/RiskManagement/risk-management.tsx b/packages/trader/src/AppV2/Components/TradeParameters/RiskManagement/risk-management.tsx index d994613c8873..25497beb0f91 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/RiskManagement/risk-management.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/RiskManagement/risk-management.tsx @@ -12,6 +12,7 @@ import { addUnit, isSmallScreen } from 'AppV2/Utils/trade-params-utils'; import RiskManagementPicker from './risk-management-picker'; import RiskManagementContent from './risk-management-content'; import { TTradeParametersProps } from '../trade-parameters'; +import useTradeError from 'AppV2/Hooks/useTradeError'; const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => { const [is_open, setIsOpen] = React.useState(false); @@ -27,6 +28,10 @@ const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => { stop_loss, } = useTraderStore(); + const { is_error_matching_field: has_error } = useTradeError({ + error_fields: ['stop_loss', 'take_profit'], + }); + const closeActionSheet = React.useCallback(() => setIsOpen(false), []); const getRiskManagementText = () => { if (has_cancellation) return `DC: ${addUnit({ value: cancellation_duration, unit: localize('minutes') })}`; @@ -82,6 +87,7 @@ const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => { readOnly value={getRiskManagementText()} variant='fill' + status={has_error ? 'error' : 'neutral'} /> (null); const focused_input_ref = React.useRef(null); const focus_timeout = React.useRef>(); - const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); const decimals = getDecimalPlaces(currency); const currency_display_code = getCurrencyDisplayCode(currency); const Component = has_actionsheet_wrapper ? ActionSheet.Content : 'div'; @@ -94,8 +99,21 @@ const TakeProfitAndStopLossInput = ({ trade_type: Object.keys(trade_types)[0], }); + // We need to exclude tp in case if type === sl and vise versa in limit order to validate them independently + if (is_take_profit_input && proposal_req.limit_order?.stop_loss) { + delete proposal_req.limit_order.stop_loss; + } + if (!is_take_profit_input && proposal_req.limit_order?.take_profit) { + delete proposal_req.limit_order.take_profit; + } + const { data: response } = useDtraderQuery[0]>( - ['proposal', ...Object.entries(new_values).flat().join('-'), Object.keys(trade_types)[0]], + [ + 'proposal', + ...Object.entries(new_values).flat().join('-'), + Object.keys(trade_types)[0], + JSON.stringify(proposal_req), + ], proposal_req, { enabled: is_enabled, @@ -139,15 +157,25 @@ const TakeProfitAndStopLossInput = ({ React.useEffect(() => { const onProposalResponse: TOnProposalResponse = response => { - const { error } = response; + const { error, proposal } = response; const new_error = error?.message ?? ''; - setErrorText(new_error); + const is_error_field_match = error?.details?.field === type || !error?.details?.field; + setErrorText(is_error_field_match ? new_error : ''); updateParentRef({ field_name: is_take_profit_input ? 'tp_error_text' : 'sl_error_text', - new_value: new_error, + new_value: is_error_field_match ? new_error : '', }); + // Recovery for min and max allowed values in case of error + if (!info.min_value || !info.max_value) { + const { min, max } = (proposal as ExpandedProposal)?.validation_params?.[type] ?? {}; + setInfo(info => + (info.min_value !== min && min) || (info.max_value !== max && max) + ? { min_value: min, max_value: max } + : info + ); + } is_api_response_received_ref.current = true; }; @@ -198,10 +226,6 @@ const TakeProfitAndStopLossInput = ({ React.useEffect(() => { setFEErrorText(initial_error_text ?? ''); - updateParentRef({ - field_name: is_take_profit_input ? 'tp_error_text' : 'sl_error_text', - new_value: initial_error_text ?? '', - }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initial_error_text]); diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx index 683b24deb893..0d93ad1c7b4b 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx @@ -321,6 +321,7 @@ const Stake = observer(({ is_minimized }: TTradeParametersProps) => { onAction: () => { if (!stake_error) { onClose(true); + onChange({ target: { name: 'amount', value: amount } }); } else { setShouldShowError(true); } diff --git a/packages/trader/src/AppV2/Components/TradeParameters/TakeProfit/take-profit.tsx b/packages/trader/src/AppV2/Components/TradeParameters/TakeProfit/take-profit.tsx index 902d7b13f3b7..a8e3cc358598 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/TakeProfit/take-profit.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/TakeProfit/take-profit.tsx @@ -10,10 +10,11 @@ import CarouselHeader from 'AppV2/Components/Carousel/carousel-header'; import TakeProfitAndStopLossInput from '../RiskManagement/take-profit-and-stop-loss-input'; import TradeParamDefinition from 'AppV2/Components/TradeParamDefinition'; import { TTradeParametersProps } from '../trade-parameters'; +import useTradeError from 'AppV2/Hooks/useTradeError'; const TakeProfit = observer(({ is_minimized }: TTradeParametersProps) => { const { currency, has_open_accu_contract, has_take_profit, is_market_closed, take_profit } = useTraderStore(); - + const { is_error_matching_field: has_error } = useTradeError({ error_fields: ['take_profit'] }); const [is_open, setIsOpen] = React.useState(false); const onActionSheetClose = React.useCallback(() => setIsOpen(false), []); @@ -47,6 +48,7 @@ const TakeProfit = observer(({ is_minimized }: TTradeParametersProps) => { readOnly variant='fill' value={has_take_profit && take_profit ? `${take_profit} ${getCurrencyDisplayCode(currency)}` : '-'} + status={has_error ? 'error' : 'neutral'} /> { const [is_minimized_params_visible, setIsMinimizedParamsVisible] = React.useState(false); @@ -130,6 +131,7 @@ const Trade = observer(() => { )} + ); }); diff --git a/packages/trader/src/AppV2/Hooks/__tests__/useTradeError.spec.tsx b/packages/trader/src/AppV2/Hooks/__tests__/useTradeError.spec.tsx new file mode 100644 index 000000000000..4fa59f2f2c3d --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/__tests__/useTradeError.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useTradeError from '../useTradeError'; +import { mockStore } from '@deriv/stores'; +import TraderProviders from '../../../trader-providers'; +import { CONTRACT_TYPES, TRADE_TYPES } from '@deriv/shared'; + +describe('useTradeError', () => { + let mocked_store: ReturnType; + + beforeEach(() => { + mocked_store = mockStore({ + client: { + is_logged_in: false, + }, + modules: { + trade: { + contract_type: TRADE_TYPES.TURBOS.LONG, + proposal_info: { + TURBOSLONG: { + has_error: true, + has_error_details: false, + error_code: 'ContractBuyValidationError', + error_field: 'take_profit', + message: 'Enter an amount equal to or lower than 1701.11.', + }, + }, + validation_errors: { + amount: [], + barrier_1: [], + barrier_2: [], + duration: [], + start_date: [], + start_time: [], + stop_loss: [], + take_profit: [], + expiry_date: [], + expiry_time: [], + }, + trade_type_tab: CONTRACT_TYPES.TURBOS.LONG, + trade_types: { + [CONTRACT_TYPES.TURBOS.LONG]: 'Turbos Long', + }, + }, + }, + }); + }); + + const wrapper = ({ children }: { children: JSX.Element }) => { + return {children}; + }; + + it('returns "true" if error field is matching the passed error_fields and an error message from proposal', () => { + const { result } = renderHook(() => useTradeError({ error_fields: ['take_profit'] }), { + wrapper, + }); + + expect(result.current.is_error_matching_field).toBeTruthy(); + expect(result.current.message).toBe(mocked_store.modules.trade.proposal_info.TURBOSLONG.message); + }); + + it('returns "true" if error field is matching at least one item from the passed error_fields and an error message from proposal', () => { + const { result } = renderHook(() => useTradeError({ error_fields: ['take_profit', 'stop_loss'] }), { + wrapper, + }); + + expect(result.current.is_error_matching_field).toBeTruthy(); + expect(result.current.message).toBe(mocked_store.modules.trade.proposal_info.TURBOSLONG.message); + }); + + it('returns "true" if validation_errors field for the passed error_fields contains error and an error message from it (in case if proposal was empty)', () => { + mocked_store.modules.trade.proposal_info = undefined; + mocked_store.modules.trade.validation_errors = { + stop_loss: [], + take_profit: ["Please enter a stake amount that's at least 1.00."], + amount: [], + barrier_1: [], + barrier_2: [], + duration: [], + start_date: [], + start_time: [], + expiry_date: [], + expiry_time: [], + }; + const { result } = renderHook(() => useTradeError({ error_fields: ['take_profit'] }), { + wrapper, + }); + + expect(result.current.is_error_matching_field).toBeTruthy(); + expect(result.current.message).toBe(mocked_store.modules.trade.validation_errors.take_profit[0]); + }); + + it('returns "false" if error field is not matching at least one item from the passed error_fields and an empty error message', () => { + mocked_store.modules.trade.proposal_info.TURBOSLONG.error_field = 'stake'; + const { result } = renderHook(() => useTradeError({ error_fields: ['take_profit', 'stop_loss'] }), { + wrapper, + }); + + expect(result.current.is_error_matching_field).toBeFalsy(); + expect(result.current.message).toBe(''); + }); + + it('returns "false" if there is no error', () => { + mocked_store.modules.trade.proposal_info.TURBOSLONG = { + has_error: false, + has_error_details: false, + }; + const { result } = renderHook(() => useTradeError({ error_fields: ['stop_loss'] }), { + wrapper, + }); + + expect(result.current.is_error_matching_field).toBeFalsy(); + expect(result.current.message).toBe(''); + }); +}); diff --git a/packages/trader/src/AppV2/Hooks/useTradeError.ts b/packages/trader/src/AppV2/Hooks/useTradeError.ts new file mode 100644 index 000000000000..42066eaf362b --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/useTradeError.ts @@ -0,0 +1,34 @@ +import React from 'react'; +import { useTraderStore } from 'Stores/useTraderStores'; +import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils'; + +export type TErrorFields = 'take_profit' | 'stop_loss' | 'date_start'; + +const useTradeError = ({ error_fields }: { error_fields: TErrorFields[] }) => { + const { contract_type, proposal_info, validation_errors, trade_type_tab, trade_types } = useTraderStore(); + const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); + + const { + has_error: proposal_has_error, + error_field: proposal_error_field, + message: proposal_error_message, + } = proposal_info?.[contract_types[0]] ?? {}; + + const checkErrorForField = (field: TErrorFields) => { + const validation_has_error = validation_errors?.[field]?.length > 0; + const is_error_matching_field = (proposal_has_error && proposal_error_field === field) || validation_has_error; + + const message = proposal_error_message ?? validation_errors?.[field]?.[0] ?? ''; + + return { is_error_matching_field, message }; + }; + + const error = error_fields + .map(field => checkErrorForField(field)) // Mapping each param to its error result + .find(result => result.is_error_matching_field); // Find the first match + + // If an error was found, return the error; otherwise return no error + return error || { is_error_matching_field: false, message: '' }; +}; + +export default useTradeError; diff --git a/packages/trader/src/AppV2/Utils/trade-params-utils.tsx b/packages/trader/src/AppV2/Utils/trade-params-utils.tsx index 3fc6747b1032..9d851c423f5a 100644 --- a/packages/trader/src/AppV2/Utils/trade-params-utils.tsx +++ b/packages/trader/src/AppV2/Utils/trade-params-utils.tsx @@ -422,7 +422,7 @@ export const getDatePickerStartDate = ( const getMomentContractStartDateTime = () => { const minDurationDate = getMinDuration(server_time, duration_units_list); - const time = isTimeValid(start_time ?? '') ? start_time : server_time?.toISOString().substr(11, 8) ?? ''; + const time = isTimeValid(start_time ?? '') ? start_time : (server_time?.toISOString().substr(11, 8) ?? ''); return setMinTime(minDurationDate, time ?? ''); }; @@ -449,7 +449,15 @@ export const getProposalRequestObject = ({ const request = createProposalRequestForContract( store as Parameters[0], trade_type - ) as Omit, 'subscribe'> & { subscribe?: number }; + ) as Omit, 'subscribe'> & { + subscribe?: number; + limit_order: + | { + take_profit?: number; + stop_loss?: number; + } + | undefined; + }; if (!should_subscribe) delete request.subscribe; diff --git a/packages/trader/src/Stores/Modules/Trading/trade-store.ts b/packages/trader/src/Stores/Modules/Trading/trade-store.ts index 05c9561c3ba3..5d67bf8a2b7b 100644 --- a/packages/trader/src/Stores/Modules/Trading/trade-store.ts +++ b/packages/trader/src/Stores/Modules/Trading/trade-store.ts @@ -932,12 +932,18 @@ export default class TradeStore extends BaseStore { async onChange(e: { target: { name: string; value: unknown } }) { const { name, value } = e.target; if ( - name == 'contract_type' && + name === 'contract_type' && ['accumulator', 'match_diff', 'even_odd', 'over_under'].includes(value as string) ) { this.prev_contract_type = this.contract_type; } + // reset stop loss after trade type was changed + if (name === 'contract_type' && this.has_stop_loss) { + this.has_stop_loss = false; + this.stop_loss = ''; + } + if (name === 'symbol' && value) { // set trade params skeleton and chart loader to true until processNewValuesAsync resolves this.setChartStatus(true);