From 6564b3a6b83ff324f5fc496ef2f20556003fe152 Mon Sep 17 00:00:00 2001 From: kate-deriv <121025168+kate-deriv@users.noreply.github.com> Date: Thu, 2 Jan 2025 15:12:19 +0300 Subject: [PATCH] GRWT-3133 / Kate / [DTrader-V2] Tech Debt: Refactor Stake component (#17620) * refactor: create new stake component * feat: add function for forming proposal request body * feat: add be and fe validation * refactor: decomposition * chore: uncommited trade param * chore: structure refactoring * feat: handle turbos and vanillas cases * refactor: rely on is_fetching flag * feat: add error handling in snackbar * revert: extra non-aligned file from master * feat: add logic for multipliers * refactor: stake delais structure * feat: add another proposal for rise fall * fix: types * revert: extra non-aligned file from master * feat: add logic for exctraction payout and max payout * feat: add rise fall * refactor: improve snackbar behaviour * chore: exctract function to utils * refactor: add tests for stake * refactor: add tests for payout function * chore: replace array method * chore: spelling * fix: reset commission and stop out * refactor: usereducer, created hook for fetching data and exctract logic for handling response * refactor: extract logic for state initiation into a function * refactor: handle more cases with fe error * feat: add max length restriction * refactor: rewrite with the usage of reducer * chore: add string wrapper --- .../Stake/__tests__/stake.spec.tsx | 136 ++---- .../Components/TradeParameters/Stake/index.ts | 1 + .../TradeParameters/Stake/stake-details.tsx | 54 +-- .../TradeParameters/Stake/stake-input.tsx | 440 ++++++++++++++++++ .../TradeParameters/Stake/stake.tsx | 329 ++----------- .../src/AppV2/Containers/Trade/trade.tsx | 16 +- .../__tests__/useFetchProposalData.spec.tsx | 157 +++++++ .../src/AppV2/Hooks/useContractsForCompany.ts | 5 +- .../src/AppV2/Hooks/useFetchProposalData.tsx | 47 ++ .../trader/src/AppV2/Hooks/useTradeError.ts | 5 +- .../__tests__/trade-param-utils.spec.tsx | 99 +++- .../src/AppV2/Utils/trade-params-utils.tsx | 26 +- .../Modules/Trading/Helpers/proposal.ts | 2 +- .../src/Stores/Modules/Trading/trade-store.ts | 10 - 14 files changed, 876 insertions(+), 451 deletions(-) create mode 100644 packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-input.tsx create mode 100644 packages/trader/src/AppV2/Hooks/__tests__/useFetchProposalData.spec.tsx create mode 100644 packages/trader/src/AppV2/Hooks/useFetchProposalData.tsx diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/__tests__/stake.spec.tsx b/packages/trader/src/AppV2/Components/TradeParameters/Stake/__tests__/stake.spec.tsx index f2ab352154d4..8b217b38ebb2 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/Stake/__tests__/stake.spec.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/__tests__/stake.spec.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; + import { CONTRACT_TYPES, TRADE_TYPES } from '@deriv/shared'; import { mockStore } from '@deriv/stores'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useDtraderQuery } from 'AppV2/Hooks/useDtraderQuery'; import ModulesProvider from 'Stores/Providers/modules-providers'; + import TraderProviders from '../../../../../trader-providers'; import Stake from '../stake'; @@ -29,6 +33,24 @@ jest.mock('AppV2/Hooks/useContractsForCompany', () => ({ }, })), })); +jest.mock('@deriv/shared', () => ({ + ...jest.requireActual('@deriv/shared'), + WS: { + send: jest.fn(), + authorized: { + send: jest.fn(), + }, + }, +})); +jest.mock('AppV2/Hooks/useDtraderQuery', () => ({ + ...jest.requireActual('AppV2/Hooks/useDtraderQuery'), + useDtraderQuery: jest.fn(() => ({ + data: { + proposal: {}, + error: {}, + }, + })), +})); describe('Stake', () => { let default_mock_store: ReturnType; @@ -45,14 +67,12 @@ describe('Stake', () => { currency: 'USD', proposal_info: { [CONTRACT_TYPES.CALL]: { - id: '53e8cb91-8c13-60a3-289f-778e8386367c', has_error: false, message: 'Win payout if Volatility 100 (1s) Index is strictly higher than entry spot at 5 minutes after contract start time.', payout: 19.55, }, [CONTRACT_TYPES.PUT]: { - id: '2b5dd806-7505-8af7-1bbb-5e24ac48bbbc', has_error: false, message: 'Win payout if Volatility 100 (1s) Index is strictly lower than entry spot at 5 minutes after contract start time.', @@ -63,14 +83,11 @@ describe('Stake', () => { [CONTRACT_TYPES.CALL]: 'Higher', [CONTRACT_TYPES.PUT]: 'Lower', }, - validation_errors: { amount: [] }, + trade_type_tab: 'CALL', validation_params: { [CONTRACT_TYPES.CALL]: { payout: { max: '50000.00' } }, [CONTRACT_TYPES.PUT]: { payout: { max: '50000.00' } }, }, - v2_params_initial_values: { - stake: 10, - }, }, }, })) @@ -84,15 +101,6 @@ describe('Stake', () => { ); - it('switches basis to stake if it is different', () => { - default_mock_store.modules.trade.basis = 'payout'; - render(); - - expect(default_mock_store.modules.trade.onChange).toHaveBeenCalledWith({ - target: { name: 'basis', value: 'stake' }, - }); - }); - it('renders trade param with "Stake" label and input with a value equal to the current stake amount value', () => { render(); const { amount, currency } = default_mock_store.modules.trade; @@ -109,16 +117,19 @@ describe('Stake', () => { expect(screen.getByTestId('dt-actionsheet-overlay')).toBeInTheDocument(); expect(screen.getByPlaceholderText(input_placeholder)).toBeInTheDocument(); - expect(screen.getAllByText(/payout/i)).toHaveLength(3); + expect(screen.getAllByText(/payout/i)).toHaveLength(2); expect(screen.getByRole('button', { name: save_button_label })).toBeInTheDocument(); }); - it('calls onChange when stake input changes', async () => { + it('calls onChange if user clicks on Save', async () => { render(); + await userEvent.click(screen.getByText(stake_param_label)); - await userEvent.type(screen.getByPlaceholderText(input_placeholder), '0'); + await userEvent.type(screen.getByPlaceholderText(input_placeholder), '10'); + await userEvent.click(screen.getByRole('button', { name: save_button_label })); + expect(default_mock_store.modules.trade.onChange).toHaveBeenCalledWith({ - target: { name: 'amount', value: '100' }, + target: { name: 'amount', value: 10 }, }); }); @@ -157,14 +168,12 @@ describe('Stake', () => { is_multiplier: true, proposal_info: { [CONTRACT_TYPES.MULTIPLIER.UP]: { - id: '3b09df15-b0b7-70a8-15c9-fad8e2bda5ef', has_error: false, message: "If you select 'Up', your total profit/loss will be the percentage increase in Volatility 100 (1s) Index, multiplied by 1000, minus commissions.", payout: 0, }, [CONTRACT_TYPES.MULTIPLIER.DOWN]: { - id: '873af3a9-a0da-5486-d1f9-fce8206e6ba2', has_error: false, message: "If you select 'Down', your total profit/loss will be the percentage decrease in Volatility 100 (1s) Index, multiplied by 1000, minus commissions.", @@ -202,84 +211,25 @@ describe('Stake', () => { expect(screen.getByText('Commission')).toBeInTheDocument(); }); - it('calls setV2ParamsInitialValues if v2_params_initial_values.stake !== amount on mount and on Save button click if no error', async () => { - default_mock_store.modules.trade.amount = '30'; - render(); - await userEvent.click(screen.getByText(stake_param_label)); - await userEvent.type(screen.getByPlaceholderText(input_placeholder), '0'); - - expect(default_mock_store.modules.trade.setV2ParamsInitialValues).toHaveBeenCalledTimes(2); - - await userEvent.click(screen.getByRole('button', { name: save_button_label })); - expect(default_mock_store.modules.trade.setV2ParamsInitialValues).toHaveBeenCalledTimes(3); - }); - - it('calls onChange on component mount if v2_params_initial_values.stake is not equal to amount', () => { - default_mock_store.modules.trade.amount = '30'; - render(); - expect(default_mock_store.modules.trade.onChange).toHaveBeenCalledWith({ - target: { name: 'amount', value: 10 }, - }); - }); - it('shows error in case of a validation error if input is non-empty', async () => { const error_text = "Please enter a stake amount that's at least 0.35."; + default_mock_store.modules.trade.contract_type = TRADE_TYPES.HIGH_LOW; + default_mock_store.modules.trade.trade_type_tab = 'CALL'; default_mock_store.modules.trade.proposal_info = { - PUT: { id: '', has_error: true, message: error_text }, - CALL: { id: '', has_error: true, message: error_text }, + CALL: { has_error: true, message: error_text, error_field: 'amount' }, }; - default_mock_store.modules.trade.validation_errors.amount = [error_text]; - default_mock_store.modules.trade.amount = 0; - - render(); - await userEvent.click(screen.getByText(stake_param_label)); - expect(screen.getByText(error_text)).toBeInTheDocument(); - expect(screen.getAllByText('- USD')).toHaveLength(2); - }); - - it('shows max payout error with the least current payout when both of the 2 contract types exceed max payout', async () => { - const error_text_rise = 'Minimum stake of 0.35 and maximum payout of 50000.00. Current payout is 50631.97.'; - const error_text_fall = 'Minimum stake of 0.35 and maximum payout of 50000.00. Current payout is 50513.21.'; - default_mock_store.modules.trade.proposal_info = { - CALL: { id: '', has_error: true, message: error_text_rise, error_field: 'amount' }, - PUT: { id: '', has_error: true, message: error_text_fall, error_field: 'amount' }, - }; - default_mock_store.modules.trade.validation_errors.amount = [error_text_fall]; - default_mock_store.modules.trade.amount = '26500'; - - render(); - await userEvent.click(screen.getByText(stake_param_label)); - - expect(screen.getByText(error_text_fall)).toBeInTheDocument(); - expect(screen.queryByText('- USD')).not.toBeInTheDocument(); - }); - it('does not show max payout error if one of the 2 contract types satisfies max payout', async () => { - const error_text_rise = 'Minimum stake of 0.35 and maximum payout of 50000.00. Current payout is 50058.77.'; - const success_text_fall = - 'Win payout if Volatility 100 (1s) Index is strictly lower than entry spot at 5 minutes after contract start time.'; - default_mock_store.modules.trade.proposal_info = { - CALL: { id: '', has_error: true, message: error_text_rise }, - PUT: { - id: 'b608baf2-ba5d-00e0-8035-9af5c0769664', - has_error: false, - message: success_text_fall, - payout: 49942.7, + (useDtraderQuery as jest.Mock).mockReturnValue({ + data: { + error: { has_error: true, message: error_text, details: { error_field: 'amount' } }, + proposal: {}, }, - }; - default_mock_store.modules.trade.amount = '26200'; - - render(); - await userEvent.click(screen.getByText(stake_param_label)); - - expect(screen.queryByText(error_text_rise)).not.toBeInTheDocument(); - }); - - it('sets default stake if available_contract_types object contains it', () => { - default_mock_store.modules.trade.contract_type = TRADE_TYPES.VANILLA.CALL; + }); render(); - expect(default_mock_store.modules.trade.setDefaultStake).toHaveBeenCalledWith(10); + await userEvent.click(screen.getByText(stake_param_label)); + expect(screen.getByText(error_text)).toBeInTheDocument(); + expect(screen.getByText('- USD')).toBeInTheDocument(); }); it('disables trade param if is_market_closed == true', () => { diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/index.ts b/packages/trader/src/AppV2/Components/TradeParameters/Stake/index.ts index 3d5500b3fde5..a5e5b7e45e41 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/Stake/index.ts +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/index.ts @@ -1,4 +1,5 @@ import Stake from './stake'; + import './stake.scss'; export default Stake; diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-details.tsx b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-details.tsx index e39e3b9cd3e3..7f16675e2180 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-details.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-details.tsx @@ -1,30 +1,33 @@ import React from 'react'; import clsx from 'clsx'; -import { Text } from '@deriv-com/quill-ui'; + import { formatMoney, getCurrencyDisplayCode, getTradeTypeName, TRADE_TYPES } from '@deriv/shared'; import { Localize } from '@deriv/translations'; +import { Text } from '@deriv-com/quill-ui'; + import { TTradeStore } from 'Types'; -type TStakeDetailsProps = Pick< - TTradeStore, - 'commission' | 'contract_type' | 'currency' | 'has_stop_loss' | 'is_multiplier' | 'stop_out' -> & { +type TStakeDetailsProps = Pick & { contract_types: string[]; details: { + commission?: string | number; + error_1?: string; + error_2?: string; first_contract_payout: number; + is_first_payout_exceeded?: boolean; + is_second_payout_exceeded?: boolean; max_payout: string | number; max_stake: string | number; min_stake: string | number; second_contract_payout: number; + stop_out?: number | string; }; is_loading_proposal: boolean; - is_max_payout_exceeded: boolean; + is_empty?: boolean; should_show_payout_details: boolean; - stake_error: string; }; const StakeDetails = ({ - commission, contract_type, contract_types, currency, @@ -32,22 +35,22 @@ const StakeDetails = ({ has_stop_loss, is_loading_proposal, is_multiplier, - is_max_payout_exceeded, + is_empty, should_show_payout_details, - stake_error, - stop_out, }: TStakeDetailsProps) => { const [displayed_values, setDisplayedValues] = React.useState({ - max_payout: '', + is_first_payout_exceeded: false, + is_second_payout_exceeded: false, commission: '', first_contract_payout: '', + max_payout: '', second_contract_payout: '', stop_out: '', }); React.useEffect(() => { const getDisplayedValue = (new_value?: number | string, current_value?: string) => { - return ((current_value === '-' && is_loading_proposal) || stake_error) && !is_max_payout_exceeded + return (current_value === '-' && is_loading_proposal) || !new_value || is_empty ? '-' : formatMoney(currency, Number(new_value), true); }; @@ -55,19 +58,23 @@ const StakeDetails = ({ const { commission: commission_value, first_contract_payout, + is_first_payout_exceeded, + is_second_payout_exceeded, second_contract_payout, stop_out: stop_out_value, max_payout, } = displayed_values; - const new_commission = getDisplayedValue(Math.abs(Number(commission)), commission_value); + const new_commission = getDisplayedValue(Math.abs(Number(details.commission)), commission_value); const new_payout_1 = getDisplayedValue(details.first_contract_payout, first_contract_payout); const new_payout_2 = getDisplayedValue(details.second_contract_payout, second_contract_payout); - const new_stop_out = getDisplayedValue(Math.abs(Number(stop_out)), stop_out_value); + const new_stop_out = getDisplayedValue(Math.abs(Number(details.stop_out)), stop_out_value); const new_max_payout = getDisplayedValue(details.max_payout, max_payout); if ( commission_value !== new_commission || first_contract_payout !== new_payout_1 || + displayed_values.is_first_payout_exceeded !== is_first_payout_exceeded || + displayed_values.is_second_payout_exceeded !== is_second_payout_exceeded || second_contract_payout !== new_payout_2 || stop_out_value !== new_stop_out || max_payout !== new_max_payout @@ -75,21 +82,14 @@ const StakeDetails = ({ setDisplayedValues({ commission: new_commission, first_contract_payout: new_payout_1, + is_first_payout_exceeded, + is_second_payout_exceeded, second_contract_payout: new_payout_2, stop_out: new_stop_out, max_payout: new_max_payout, }); } - }, [ - commission, - currency, - details, - displayed_values, - is_loading_proposal, - is_max_payout_exceeded, - stake_error, - stop_out, - ]); + }, [currency, details, displayed_values, is_loading_proposal, is_empty]); const payout_title = ; const content = [ @@ -114,7 +114,7 @@ const StakeDetails = ({ }), is_displayed: !!contract_types.length && should_show_payout_details, label: payout_title, - has_error: details.first_contract_payout > +details.max_payout && is_max_payout_exceeded, + has_error: details.is_first_payout_exceeded, value: displayed_values.first_contract_payout, }, { @@ -123,7 +123,7 @@ const StakeDetails = ({ }), is_displayed: contract_types.length > 1 && should_show_payout_details, label: payout_title, - has_error: details.second_contract_payout > +details.max_payout && is_max_payout_exceeded, + has_error: details.is_second_payout_exceeded, value: displayed_values.second_contract_payout, }, ]; diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-input.tsx b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-input.tsx new file mode 100644 index 000000000000..20766bf58447 --- /dev/null +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake-input.tsx @@ -0,0 +1,440 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; + +import { formatMoney, getCurrencyDisplayCode, getDecimalPlaces } from '@deriv/shared'; +import { Localize, localize } from '@deriv/translations'; +import { ActionSheet, TextFieldWithSteppers } from '@deriv-com/quill-ui'; + +import { useFetchProposalData } from 'AppV2/Hooks/useFetchProposalData'; +import useIsVirtualKeyboardOpen from 'AppV2/Hooks/useIsVirtualKeyboardOpen'; +import { getPayoutInfo } from 'AppV2/Utils/trade-params-utils'; +import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils'; +import { ExpandedProposal, getProposalInfo } from 'Stores/Modules/Trading/Helpers/proposal'; +import { useTraderStore } from 'Stores/useTraderStores'; +import { TTradeStore } from 'Types'; + +import StakeDetails from './stake-details'; + +type TResponse = Parameters[0]; +type TStakeInput = { + onClose: () => void; + is_open?: boolean; +}; +type TNewValues = { + amount?: string | number; + payout_per_point?: string | number; + barrier_1?: string | number; +}; +type TStakeState = { + proposal_request_values: TNewValues; + stake_error: string; + fe_stake_error: string; + max_length: number; + details: { + commission?: string | number; + error_1: string; + error_2: string; + first_contract_payout: number; + second_contract_payout: number; + is_first_payout_exceeded: boolean; + is_second_payout_exceeded: boolean; + max_payout: string | number; + max_stake: string | number; + min_stake: string | number; + stop_out?: string | number; + }; +}; +type TStakeAction = + | { type: 'SET_PROPOSAL_VALUES'; payload: Partial } + | { type: 'SET_STAKE_ERROR'; payload: string } + | { type: 'SET_FE_STAKE_ERROR'; payload: string } + | { type: 'UPDATE_DETAILS'; payload: Partial } + | { type: 'SET_MAX_LENGTH'; payload: number } + | { type: 'RESET_ERRORS' }; + +const reducer = (state: TStakeState, action: TStakeAction): TStakeState => { + switch (action.type) { + case 'SET_PROPOSAL_VALUES': + return { + ...state, + proposal_request_values: { + ...state.proposal_request_values, + ...action.payload, + }, + }; + case 'SET_STAKE_ERROR': + return { + ...state, + stake_error: action.payload, + }; + case 'SET_FE_STAKE_ERROR': + return { + ...state, + fe_stake_error: action.payload, + }; + case 'UPDATE_DETAILS': + return { + ...state, + details: { + ...state.details, + ...action.payload, + }, + }; + case 'SET_MAX_LENGTH': + return { + ...state, + max_length: action.payload, + }; + case 'RESET_ERRORS': + return { + ...state, + stake_error: '', + fe_stake_error: '', + }; + default: + return state; + } +}; + +const createInitialState = (trade_store: ReturnType, decimals: number) => { + const { + amount, + commission, + contract_type, + trade_type_tab, + trade_types, + proposal_info, + validation_params, + stop_out, + } = trade_store; + + const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); + const { + contract_payout: first_contract_payout, + max_payout, + error: first_payout_error, + } = getPayoutInfo(proposal_info[contract_types[0]]); + const { contract_payout: second_contract_payout, error: second_payout_error } = getPayoutInfo( + proposal_info[contract_types[1]] + ); + + const { stake } = (validation_params[contract_types[0]] || validation_params[contract_types[1]]) ?? {}; + const { max: max_stake = 0, min: min_stake = 0 } = stake ?? {}; + + return { + proposal_request_values: { amount }, + stake_error: '', + fe_stake_error: '', + max_length: calculateMaxLength(amount, decimals), + details: { + commission, + error_1: first_payout_error, + error_2: second_payout_error, + first_contract_payout, + second_contract_payout, + is_first_payout_exceeded: !!first_payout_error && first_contract_payout > max_payout, + is_second_payout_exceeded: !!second_payout_error && second_contract_payout > max_payout, + max_payout, + max_stake, + min_stake, + stop_out, + }, + }; +}; + +const calculateMaxLength = (amount: number | string, decimals: number): number => { + const is_decimal = String(amount).includes('.') || String(amount).includes(','); + return is_decimal ? 11 + decimals : 10; +}; + +const StakeInput = observer(({ onClose, is_open }: TStakeInput) => { + const trade_store = useTraderStore(); + const { + contract_type, + currency, + barrier_1, + has_stop_loss, + is_accumulator, + is_multiplier, + is_turbos, + is_vanilla, + onChange, + trade_type_tab, + trade_types, + } = trade_store; + + const decimals = getDecimalPlaces(currency); + const [state, dispatch] = React.useReducer(reducer, null, () => createInitialState(trade_store, decimals)); + const { proposal_request_values, stake_error, fe_stake_error, details } = state; + + const contract_types = React.useMemo( + () => getDisplayedContractTypes(trade_types, contract_type, trade_type_tab), + [trade_types, contract_type, trade_type_tab] + ); + + const should_show_payout_details = !is_accumulator && !is_multiplier && !is_turbos && !is_vanilla; + + // scroll the page when a virtual keyboard pops up + const input_id = 'stake_input'; + const { is_key_board_visible: should_scroll } = useIsVirtualKeyboardOpen(input_id); + + React.useEffect(() => { + if (should_scroll) window?.scrollTo({ top: 225, behavior: 'smooth' }); + }, [should_scroll]); + + React.useEffect(() => { + const initial_state = createInitialState(trade_store, decimals); + dispatch({ type: 'SET_PROPOSAL_VALUES', payload: initial_state.proposal_request_values }); + dispatch({ type: 'UPDATE_DETAILS', payload: initial_state.details }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Parallel proposal without subscription + // For Rise/Fall and all Digits we should do 2 proposal requests + const should_send_multiple_proposals = contract_types.length > 1 && !is_multiplier; + const has_both_errors = !!details.error_1 && !!details.error_2; + // Need for cases with Rise/Fall and Digits, when only one response contains error and we should allow to save the value + const should_show_stake_error = + !should_send_multiple_proposals || (should_send_multiple_proposals && has_both_errors); + + const { data: response_1, is_fetching: is_fetching_1 } = useFetchProposalData({ + trade_store, + proposal_request_values, + contract_type: contract_types[0], + contract_types, + is_enabled: is_open, + }); + const { data: response_2, is_fetching: is_fetching_2 } = useFetchProposalData({ + trade_store, + proposal_request_values, + contract_type: contract_types[1], + contract_types, + is_enabled: is_open && should_send_multiple_proposals, + }); + + const is_loading_proposal = is_fetching_1 || (should_send_multiple_proposals && is_fetching_2); + + const handleProposalResponse = (response: TResponse, contractType: 'first' | 'second') => { + const { error, proposal } = response; + + // In case if the value is empty we are showing custom error text from FE (in onSave function) + if (proposal_request_values.amount === '') { + dispatch({ type: 'SET_STAKE_ERROR', payload: '' }); + return; + } + + // Handle edge cases for Vanilla contracts + if (is_vanilla && error?.details?.barrier_choices) { + const { barrier_choices } = error.details; + if (!barrier_choices?.includes(barrier_1)) { + const index = Math.floor(barrier_choices.length / 2); + dispatch({ + type: 'SET_PROPOSAL_VALUES', + payload: { barrier_1: barrier_choices[index] }, + }); + return; + } + } + + // Handle edge cases for Turbo contracts + if (is_turbos && error?.details?.payout_per_point_choices && error?.details?.field === 'payout_per_point') { + const { payout_per_point_choices } = error.details; + const index = Math.floor(payout_per_point_choices.length / 2); + dispatch({ + type: 'SET_PROPOSAL_VALUES', + payload: { payout_per_point: payout_per_point_choices[index] }, + }); + return; + } + + // Set proposal error + const new_error = error?.message ?? ''; + const is_error_field_match = + ['amount', 'stake'].includes(error?.details?.field ?? '') || !error?.details?.field; + dispatch({ type: 'SET_STAKE_ERROR', payload: is_error_field_match ? new_error : '' }); + + // Handle old contracts with payout (Rise/Fall, Higher/Lower, Touch/No Touch, Digits) + if (should_show_payout_details) { + const new_proposal = getProposalInfo(trade_store, response as Parameters[1]); + const { contract_payout, max_payout, error } = getPayoutInfo(new_proposal); + + dispatch({ + type: 'UPDATE_DETAILS', + payload: { + ...(max_payout ? { max_payout } : {}), + [`${contractType}_contract_payout`]: contract_payout || 0, + [`is_${contractType}_payout_exceeded`]: !!error && contract_payout > max_payout, + [`error_${contractType === 'first' ? 1 : 2}`]: error, + }, + }); + } else { + // Recovery for minimum and maximum allowed values in case of errors + if ((!details.min_stake || !details.max_stake) && error?.details) { + const { max_stake, min_stake } = error.details; + if (max_stake && min_stake) { + dispatch({ + type: 'UPDATE_DETAILS', + payload: { + max_stake, + min_stake, + }, + }); + } + } + + // Update proposal details after a successful API call + if (proposal) { + const { commission, limit_order, validation_params } = proposal as ExpandedProposal; + const { max, min } = validation_params?.stake ?? {}; + const { order_amount } = limit_order?.stop_out ?? {}; + + dispatch({ + type: 'UPDATE_DETAILS', + payload: { + ...(is_multiplier && commission && order_amount ? { commission, stop_out: order_amount } : {}), + ...(details.max_stake || details.min_stake ? {} : { max_stake: max, min_stake: min }), + }, + }); + } else if (!proposal && is_multiplier) { + dispatch({ + type: 'UPDATE_DETAILS', + payload: { commission: 0, stop_out: 0 }, + }); + } + } + }; + + React.useEffect(() => { + if (response_1) handleProposalResponse(response_1, 'first'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [response_1]); + + React.useEffect(() => { + if (response_2) handleProposalResponse(response_2, 'second'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [response_2]); + + const getInputMessage = () => + !!details.min_stake && + !!details.max_stake && ( + + ); + + const onInputChange = (e: React.ChangeEvent) => { + const new_value = String(e.target.value); + dispatch({ + type: 'SET_MAX_LENGTH', + payload: calculateMaxLength(new_value, decimals), + }); + if (new_value.endsWith('.') || new_value.endsWith(',')) { + dispatch({ + type: 'SET_FE_STAKE_ERROR', + payload: localize('Should be a valid number.'), + }); + return; + } + // If a new value is equal to a previous one, then we won't send API request + const is_equal = new_value === String(proposal_request_values.amount); + if (is_equal) return; + + dispatch({ type: 'RESET_ERRORS' }); + dispatch({ + type: 'SET_PROPOSAL_VALUES', + payload: { amount: new_value }, + }); + }; + + const onBeforeInputChange = (e: React.FormEvent) => { + if ( + ['.', ','].includes((e.nativeEvent as InputEvent)?.data ?? '') && + (String(proposal_request_values.amount)?.length ?? 0) <= 10 + ) { + dispatch({ + type: 'SET_MAX_LENGTH', + payload: decimals ? 11 + decimals : 10, + }); + } + }; + + const onSave = () => { + // Prevent from saving if user clicks before we get theAPI response or if we get an error in response or the field is empty + if ( + is_fetching_1 || + (should_send_multiple_proposals && is_fetching_2) || + (should_show_stake_error && stake_error) || + fe_stake_error + ) + return; + if (proposal_request_values.amount === '') { + dispatch({ + type: 'SET_FE_STAKE_ERROR', + payload: localize('Amount is a required field.'), + }); + return; + } + + // Setting new stake value to the store and send it in streaming proposal + onChange({ target: { name: 'amount', value: proposal_request_values.amount } }); + onClose(); + }; + + return ( + + + + + + , + onAction: onSave, + }} + /> + + ); +}); + +export default StakeInput; diff --git a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx index 4591dff919c9..b8777977a567 100644 --- a/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx +++ b/packages/trader/src/AppV2/Components/TradeParameters/Stake/stake.tsx @@ -1,268 +1,45 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import clsx from 'clsx'; import { observer } from 'mobx-react-lite'; -import { useStore } from '@deriv/stores'; -import { ActionSheet, TextField, TextFieldWithSteppers, useSnackbar } from '@deriv-com/quill-ui'; -import { localize, Localize } from '@deriv/translations'; -import { formatMoney, getCurrencyDisplayCode, getDecimalPlaces, isCryptocurrency } from '@deriv/shared'; -import { useTraderStore } from 'Stores/useTraderStores'; + +import { getCurrencyDisplayCode } from '@deriv/shared'; +import { Localize } from '@deriv/translations'; +import { ActionSheet, TextField } from '@deriv-com/quill-ui'; + +import useTradeError from 'AppV2/Hooks/useTradeError'; import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils'; -import StakeDetails from './stake-details'; -import useContractsForCompany from 'AppV2/Hooks/useContractsForCompany'; +import { useTraderStore } from 'Stores/useTraderStores'; + import { TTradeParametersProps } from '../trade-parameters'; -import useIsVirtualKeyboardOpen from 'AppV2/Hooks/useIsVirtualKeyboardOpen'; + +import StakeInput from './stake-input'; const Stake = observer(({ is_minimized }: TTradeParametersProps) => { const { amount, - basis, - commission, - contract_type, currency, + contract_type, has_open_accu_contract, - has_stop_loss, - is_accumulator, - is_multiplier, - is_turbos, - is_vanilla, is_market_closed, - onChange, - proposal_info, - setDefaultStake, - setV2ParamsInitialValues, - stop_out, - symbol, - trade_type_tab, + is_multiplier, trade_types, - v2_params_initial_values, - validation_errors, - validation_params, + trade_type_tab, + proposal_info, } = useTraderStore(); - const { - client: { is_logged_in, currency: client_currency }, - } = useStore(); - const { addSnackbar } = useSnackbar(); - const [is_open, setIsOpen] = React.useState(false); - const [is_focused, setIsFocused] = React.useState(false); - const [should_show_error, setShouldShowError] = React.useState(true); - const { available_contract_types } = useContractsForCompany(); - const stake_ref = React.useRef(null); - - // default_stake resetting data - const is_crypto = isCryptocurrency(client_currency ?? ''); - const default_stake = is_crypto - ? Number(v2_params_initial_values.stake) - : available_contract_types?.[contract_type]?.config?.default_stake; + const { is_error_matching_field: has_error } = useTradeError({ error_fields: ['stake', 'amount'] }); - useEffect(() => { - if (client_currency !== currency) { - onChange({ target: { name: 'currency', value: client_currency } }); - if (!isCryptocurrency(client_currency ?? '')) { - onChange({ target: { name: 'amount', value: default_stake } }); - setV2ParamsInitialValues({ value: default_stake as number, name: 'stake' }); - } - } - }, [client_currency]); + const [is_open, setIsOpen] = React.useState(false); - const displayed_error = React.useRef(false); const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab); - // first contract type data: - const { - has_error: has_error_1, - id: id_1, - message: message_1 = '', - payout: payout_1 = 0, - error_field: error_field_1, - } = proposal_info[contract_types[0]] ?? {}; - // second contract type data: - const { - has_error: has_error_2, - id: id_2, - message: message_2 = '', - payout: payout_2 = 0, - error_field: error_field_2, - } = proposal_info[contract_types[1]] ?? {}; - const is_loading_proposal = !has_error_1 && !has_error_2 && (!id_1 || (!!contract_types[1] && !id_2)); - const proposal_error_message_1 = - has_error_1 && (error_field_1 === 'amount' || error_field_1 === 'stake') ? message_1 : ''; - const proposal_error_message_2 = - has_error_2 && (error_field_2 === 'amount' || error_field_2 === 'stake') ? message_2 : ''; - const has_both_errors = has_error_1 && has_error_2; - const proposal_error_with_two_contract = contract_types[1] && has_both_errors; - - const proposal_error_with_one_contract = !(contract_types[1] && !has_both_errors) && proposal_error_message_1; - - const proposal_error_message = proposal_error_with_two_contract - ? proposal_error_message_1 || proposal_error_message_2 || validation_errors?.amount?.[0] - : proposal_error_with_one_contract || validation_errors?.amount?.[0]; - /* TODO: stop using Max payout from error text as a default max payout and stop using error text for is_max_payout_exceeded after validation_params are added to proposal API (both success & error response): - E.g., for is_max_payout_exceeded, we have to temporarily check the error text: Max payout error always contains 3 numbers, the check will work for any languages: */ - const float_number_search_regex = /\d+(\.\d+)?/g; - const is_max_payout_exceeded = - proposal_error_message_1.match(float_number_search_regex)?.length === 3 || - proposal_error_message_2.match(float_number_search_regex)?.length === 3; - const error_max_payout = - is_max_payout_exceeded && proposal_error_message - ? Number(proposal_error_message.match(float_number_search_regex)?.[1]) - : 0; - const { payout, stake } = (validation_params[contract_types[0]] || validation_params[contract_types[1]]) ?? {}; - const { max: max_payout = error_max_payout } = payout ?? {}; - const { max: max_stake = 0, min: min_stake = 0 } = stake ?? {}; - const error_payout_1 = proposal_error_message_1 - ? Number(proposal_error_message_1.match(float_number_search_regex)?.[2]) - : 0; - const error_payout_2 = proposal_error_message_2 - ? Number(proposal_error_message_2.match(float_number_search_regex)?.[2]) - : 0; - const first_contract_payout = payout_1 || error_payout_1; - const second_contract_payout = payout_2 || error_payout_2; - const main_error_message = - (proposal_error_message && error_payout_1 > error_payout_2 - ? proposal_error_message_2 - : proposal_error_message_1) || proposal_error_message; - const two_contracts_error = has_both_errors || amount.toString() === '' ? main_error_message : ''; - const stake_error = - (has_both_errors - ? two_contracts_error - : (!contract_types[1] || amount.toString() === '') && proposal_error_message) || ''; - const [details, setDetails] = React.useState({ - first_contract_payout, - max_payout, - max_stake, - min_stake, - second_contract_payout, - }); - - // scroll the page when a virtual keyboard pop up - const input_id = 'stake_input'; - const { is_key_board_visible: should_scroll } = useIsVirtualKeyboardOpen(input_id); - - React.useEffect(() => { - if (should_scroll) window?.scrollTo({ top: 225, behavior: 'smooth' }); - }, [should_scroll]); - - React.useEffect(() => { - if (stake_error && !is_minimized && !is_open) { - displayed_error.current = true; - addSnackbar({ - message: , - status: 'fail', - hasCloseButton: true, - style: { - marginBottom: is_logged_in ? '48px' : '-8px', - width: 'calc(100% - var(--core-spacing-800)', - }, - }); - } - }, [stake_error]); - - React.useEffect(() => { - displayed_error.current = false; - }, [contract_type, symbol]); - - React.useEffect(() => { - if (default_stake) setDefaultStake(default_stake); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [default_stake]); - - React.useEffect(() => { - const initial_stake = v2_params_initial_values?.stake; - if (initial_stake && amount !== initial_stake) { - onChange({ target: { name: 'amount', value: initial_stake } }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const is_all_types_with_errors = contract_types.every(item => proposal_info?.[item]?.has_error); - React.useEffect(() => { - if (is_open && v2_params_initial_values.stake !== amount) { - setV2ParamsInitialValues({ value: amount, name: 'stake' }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [is_open]); + // Showing snackbar for all cases, except when it is Rise/Fall or Digits and only one subtype has error + const should_show_snackbar = contract_types.length === 1 || is_multiplier || is_all_types_with_errors; - React.useEffect(() => { - if (basis !== 'stake') onChange({ target: { name: 'basis', value: 'stake' } }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [basis]); - - React.useEffect(() => { - const stake_element = stake_ref.current; - const checkFocus = () => { - setIsFocused(!!(stake_element && stake_element.contains(document.activeElement))); - }; - document.addEventListener('focusin', checkFocus); - document.addEventListener('focusout', checkFocus); - - return () => { - document.removeEventListener('focusin', checkFocus); - document.removeEventListener('focusout', checkFocus); - }; - }); - - React.useEffect(() => { - if (is_open) { - if ( - (details.first_contract_payout !== first_contract_payout && first_contract_payout) || - (details.max_payout !== max_payout && max_payout) || - (details.max_stake !== max_stake && max_stake) || - (details.min_stake !== min_stake && min_stake) || - (details.second_contract_payout !== second_contract_payout && second_contract_payout) - ) { - setDetails({ - first_contract_payout, - max_payout, - max_stake, - min_stake, - second_contract_payout, - }); - } - } - }, [details, is_open, max_payout, max_stake, min_stake, first_contract_payout, second_contract_payout]); - - React.useEffect(() => { - if (is_focused) { - if (!amount) { - setShouldShowError(false); - } - } - }, [is_focused, amount]); - - const getInputMessage = () => - (should_show_error && stake_error) || - (!!details.min_stake && !!details.max_stake && ( - - )); - - const handleOnChange = (e: { target: { name: string; value: string } }) => { - setShouldShowError(!!e.target.value); - onChange({ target: { name: 'amount', value: e.target.value } }); - }; - - const onClose = React.useCallback( - (is_saved = false) => { - if (is_open) { - if (!is_saved) { - onChange({ target: { name: 'amount', value: v2_params_initial_values.stake } }); - } - if (v2_params_initial_values.stake !== amount) { - setV2ParamsInitialValues({ value: amount, name: 'stake' }); - } - setIsOpen(false); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [is_open, v2_params_initial_values.stake, amount] - ); + const onClose = React.useCallback(() => setIsOpen(false), []); return ( - <> + { label={} noStatusIcon onClick={() => setIsOpen(true)} - value={`${v2_params_initial_values?.stake ?? amount} ${getCurrencyDisplayCode(currency)}`} + value={`${amount} ${getCurrencyDisplayCode(currency)}`} className={clsx('trade-params__option', is_minimized && 'trade-params__option--minimized')} - status={stake_error && !is_open ? 'error' : undefined} + status={has_error && should_show_snackbar ? 'error' : 'neutral'} /> { > } /> - - - - - , - onAction: () => { - if (!stake_error) { - onClose(true); - onChange({ target: { name: 'amount', value: amount } }); - } else { - setShouldShowError(true); - } - }, - }} - /> + - + ); }); diff --git a/packages/trader/src/AppV2/Containers/Trade/trade.tsx b/packages/trader/src/AppV2/Containers/Trade/trade.tsx index 0ffb74b18704..738424c45580 100644 --- a/packages/trader/src/AppV2/Containers/Trade/trade.tsx +++ b/packages/trader/src/AppV2/Containers/Trade/trade.tsx @@ -18,6 +18,7 @@ import TradeErrorSnackbar from 'AppV2/Components/TradeErrorSnackbar'; import { TradeParameters, TradeParametersContainer } from 'AppV2/Components/TradeParameters'; import useContractsForCompany from 'AppV2/Hooks/useContractsForCompany'; import { getChartHeight, HEIGHT } from 'AppV2/Utils/layout-utils'; +import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils'; import { isDigitTradeType } from 'Modules/Trading/Helpers/digits'; import { useTraderStore } from 'Stores/useTraderStores'; @@ -39,11 +40,15 @@ const Trade = observer(() => { contract_type, has_cancellation, is_accumulator, + is_multiplier, is_market_closed, onChange, onMount, onUnmount, symbol, + proposal_info, + trade_types: trade_types_store, + trade_type_tab, } = useTraderStore(); const { trade_types, resetTradeTypes } = useContractsForCompany(); const [guide_dtrader_v2] = useLocalStorageData>('guide_dtrader_v2', { @@ -52,6 +57,12 @@ const Trade = observer(() => { positions_page: false, }); + // For handling edge cases of snackbar: + const contract_types = getDisplayedContractTypes(trade_types_store, contract_type, trade_type_tab); + const is_all_types_with_errors = contract_types.every(item => proposal_info?.[item]?.has_error); + // Showing snackbar for all cases, except when it is Rise/Fall or Digits and only one subtype has error + const should_show_snackbar = contract_types.length === 1 || is_multiplier || is_all_types_with_errors; + const symbols = React.useMemo( () => active_symbols.map(({ display_name, symbol: underlying }) => ({ @@ -145,7 +156,10 @@ const Trade = observer(() => { )} - + ); }); diff --git a/packages/trader/src/AppV2/Hooks/__tests__/useFetchProposalData.spec.tsx b/packages/trader/src/AppV2/Hooks/__tests__/useFetchProposalData.spec.tsx new file mode 100644 index 000000000000..04744191332f --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/__tests__/useFetchProposalData.spec.tsx @@ -0,0 +1,157 @@ +import React from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react-hooks'; + +import { getProposalRequestObject } from 'AppV2/Utils/trade-params-utils'; +import { useTraderStore } from 'Stores/useTraderStores'; +import type { TTradeStore } from 'Types'; + +import { useFetchProposalData } from '../useFetchProposalData'; + +jest.mock('Stores/useTraderStores', () => ({ + useTraderStore: jest.fn(), +})); +jest.mock('@deriv/shared', () => ({ + WS: { + send: jest.fn(), + authorized: { + send: jest.fn(), + }, + }, +})); +jest.mock('AppV2/Utils/trade-params-utils', () => ({ + getProposalRequestObject: jest.fn(), +})); + +const mockUseTraderStore = useTraderStore as jest.Mock; +const mockGetProposalRequestObject = getProposalRequestObject as jest.Mock; + +describe('useFetchProposalData', () => { + let queryClient: QueryClient, + wrapper: React.FC<{ children: React.ReactNode }>, + mockTradeStore: Partial; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, + }); + wrapper = ({ children }) => {children}; + + mockTradeStore = { + amount: 10, + basis: 'stake', + currency: 'USD', + duration: 5, + duration_unit: 'm', + symbol: 'frxEURUSD', + trade_types: { + CALL: 'Rise', + PUT: 'Fall', + }, + }; + + mockUseTraderStore.mockReturnValue(mockTradeStore); + }); + + afterEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + it('calls getProposalRequestObject with correct parameters', () => { + const proposal_request_values = { + amount: 20, + barrier_1: '100', + }; + const contract_type = 'CALL'; + const contract_types = ['CALL', 'PUT']; + + mockGetProposalRequestObject.mockReturnValue({ + proposal: 1, + subscribe: 1, + amount: 20, + barrier: '100', + contract_type: 'CALL', + }); + + renderHook( + () => + useFetchProposalData({ + trade_store: mockTradeStore as TTradeStore, + proposal_request_values, + contract_type, + contract_types, + is_enabled: true, + }), + { wrapper } + ); + + expect(mockGetProposalRequestObject).toHaveBeenCalledWith({ + new_values: proposal_request_values, + trade_store: mockTradeStore, + trade_type: contract_type, + }); + }); + + it('returns correct flag if is_enabled is false', () => { + const proposal_request_values = { + amount: 20, + }; + const contract_type = 'CALL'; + const contract_types = ['CALL', 'PUT']; + + mockGetProposalRequestObject.mockReturnValue({ + proposal: 1, + subscribe: 1, + amount: 20, + contract_type: 'CALL', + }); + + const { result } = renderHook( + () => + useFetchProposalData({ + trade_store: mockTradeStore as TTradeStore, + proposal_request_values, + contract_type, + contract_types, + is_enabled: false, + }), + { wrapper } + ); + + expect(result.current.is_fetching).toBe(false); + }); + + it('handles empty proposal_request_values', () => { + const contract_type = 'CALL'; + const contract_types = ['CALL', 'PUT']; + + mockGetProposalRequestObject.mockReturnValue({ + proposal: 1, + subscribe: 1, + contract_type: 'CALL', + }); + + const { result } = renderHook( + () => + useFetchProposalData({ + trade_store: mockTradeStore as TTradeStore, + proposal_request_values: {}, + contract_type, + contract_types, + is_enabled: true, + }), + { wrapper } + ); + + const { data, error } = result.current; + expect(data).toBe(undefined); + expect(error).toBe(null); + }); +}); diff --git a/packages/trader/src/AppV2/Hooks/useContractsForCompany.ts b/packages/trader/src/AppV2/Hooks/useContractsForCompany.ts index bce320535fbb..4c09499b20ea 100644 --- a/packages/trader/src/AppV2/Hooks/useContractsForCompany.ts +++ b/packages/trader/src/AppV2/Hooks/useContractsForCompany.ts @@ -28,7 +28,8 @@ const useContractsForCompany = () => { const [contract_types_list, setContractTypesList] = React.useState([]); const [trade_types, setTradeTypes] = React.useState([]); - const { contract_type, onChange, setContractTypesListV2, processContractsForV2, symbol } = useTraderStore(); + const { contract_type, onChange, setContractTypesListV2, setDefaultStake, processContractsForV2, symbol } = + useTraderStore(); const { client } = useStore(); const { loginid, is_switching, landing_company_shortcode } = client; const prev_landing_company_shortcode_ref = React.useRef(landing_company_shortcode); @@ -151,7 +152,7 @@ const useContractsForCompany = () => { ? 'euro_atm' : (contract.barrier_category as TConfig['barrier_category']); config.default_stake = contract.default_stake; - + if (type === contract_type) setDefaultStake(contract.default_stake); available_contract_types[type].config = config; }); diff --git a/packages/trader/src/AppV2/Hooks/useFetchProposalData.tsx b/packages/trader/src/AppV2/Hooks/useFetchProposalData.tsx new file mode 100644 index 000000000000..d0dd923bd8fe --- /dev/null +++ b/packages/trader/src/AppV2/Hooks/useFetchProposalData.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { getProposalRequestObject } from 'AppV2/Utils/trade-params-utils'; +import { useTraderStore } from 'Stores/useTraderStores'; +import { TTradeStore } from 'Types'; + +import { useDtraderQuery } from './useDtraderQuery'; + +type TOnProposalResponse = TTradeStore['onProposalResponse']; +type TNewValues = { + amount?: string | number; + payout_per_point?: string | number; + barrier_1?: string | number; +}; +// TODO: We can reuse it in TakeProfitAndStopLoss and PayoutPerPoint components. +export const useFetchProposalData = ({ + trade_store, + proposal_request_values, + contract_type, + contract_types, + is_enabled, +}: { + trade_store: ReturnType; + proposal_request_values: TNewValues; + contract_type: string; + contract_types: string[]; + is_enabled?: boolean; +}) => { + const proposal_request = getProposalRequestObject({ + new_values: proposal_request_values, + trade_store, + trade_type: contract_type, + }); + + const entries = proposal_request_values ? Object.entries(proposal_request_values) : []; + const query_key = [ + 'proposal', + ...entries.flat().join('-'), + `${proposal_request_values?.amount ?? ''}`, + JSON.stringify(proposal_request), + contract_types.join('-'), + ]; + + return useDtraderQuery[0]>(query_key, proposal_request, { + enabled: is_enabled, + }); +}; diff --git a/packages/trader/src/AppV2/Hooks/useTradeError.ts b/packages/trader/src/AppV2/Hooks/useTradeError.ts index 42066eaf362b..a4662fa108de 100644 --- a/packages/trader/src/AppV2/Hooks/useTradeError.ts +++ b/packages/trader/src/AppV2/Hooks/useTradeError.ts @@ -1,8 +1,9 @@ import React from 'react'; -import { useTraderStore } from 'Stores/useTraderStores'; + import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils'; +import { useTraderStore } from 'Stores/useTraderStores'; -export type TErrorFields = 'take_profit' | 'stop_loss' | 'date_start'; +export type TErrorFields = 'take_profit' | 'stop_loss' | 'date_start' | 'stake' | 'amount'; const useTradeError = ({ error_fields }: { error_fields: TErrorFields[] }) => { const { contract_type, proposal_info, validation_errors, trade_type_tab, trade_types } = useTraderStore(); diff --git a/packages/trader/src/AppV2/Utils/__tests__/trade-param-utils.spec.tsx b/packages/trader/src/AppV2/Utils/__tests__/trade-param-utils.spec.tsx index fd34885fd025..7bff303f40c4 100644 --- a/packages/trader/src/AppV2/Utils/__tests__/trade-param-utils.spec.tsx +++ b/packages/trader/src/AppV2/Utils/__tests__/trade-param-utils.spec.tsx @@ -1,22 +1,27 @@ -import React, { ReactElement, ReactNode } from 'react'; +import React, { ReactElement } from 'react'; +import moment from 'moment'; + +import { CONTRACT_TYPES, TRADE_TYPES } from '@deriv/shared'; +import { mockStore } from '@deriv/stores'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { CONTRACT_TYPES, TRADE_TYPES } from '@deriv/shared'; + +import { getProposalInfo } from 'Stores/Modules/Trading/Helpers/proposal'; + import { addUnit, focusAndOpenKeyboard, + getDatePickerStartDate, + getOptionPerUnit, + getPayoutInfo, + getProposalRequestObject, + getSmallestDuration, + getSnackBarText, getTradeParams, getTradeTypeTabsList, - getSnackBarText, isDigitContractWinning, isSmallScreen, - getOptionPerUnit, - getSmallestDuration, - getDatePickerStartDate, - getProposalRequestObject, } from '../trade-params-utils'; -import moment from 'moment'; -import { mockStore } from '@deriv/stores'; describe('getTradeParams', () => { it('should return correct object with keys for Rise/Fall', () => { @@ -96,7 +101,7 @@ describe('isDigitContractWinning', () => { }); describe('focusAndOpenKeyboard', () => { - it('should apply focus to the passed ReactElement', () => { + it('should apply focus to the passed ReactElement', async () => { jest.useFakeTimers(); const MockComponent = () => { @@ -119,7 +124,7 @@ describe('focusAndOpenKeyboard', () => { const input = screen.getByRole('spinbutton'); expect(input).not.toHaveFocus(); - userEvent.click(screen.getByText('Focus')); + await userEvent.click(screen.getByText('Focus')); jest.runAllTimers(); @@ -620,3 +625,75 @@ describe('getProposalRequestObject', () => { }); }); }); + +describe('getPayoutInfo', () => { + it('returns contract payout, max payout and empty string instead of error if proposal does not contain error', () => { + const proposal_info = { + has_error: false, + has_error_details: false, + payout: 19.53, + profit: '9.53', + stake: '10.00', + validation_params: { + payout: { + max: '80000.00', + }, + stake: { + min: '0.50', + }, + }, + } as ReturnType; + + expect(getPayoutInfo(proposal_info)).toEqual({ contract_payout: 19.53, max_payout: '80000.00', error: '' }); + }); + + it('returns contract payout, max payout equal to 0 if proposal_info was empty', () => { + expect(getPayoutInfo({} as ReturnType)).toEqual({ + contract_payout: 0, + max_payout: 0, + error: '', + }); + }); + + it('returns contract payout, max payout equal to 0 if proposal_info was not defined', () => { + expect(getPayoutInfo(undefined as unknown as ReturnType)).toEqual({ + contract_payout: 0, + max_payout: 0, + error: '', + }); + }); + + it('returns contract payout and max payout values, extracted from error text if it is in proposal_info and has amount field', () => { + const proposal_info = { + id: '', + has_error: true, + has_error_details: true, + error_code: 'ContractBuyValidationError', + error_field: 'amount', + message: 'Minimum stake of 0.35 and maximum payout of 5000.00. Current payout is 31263.39.', + }; + + expect(getPayoutInfo(proposal_info as ReturnType)).toEqual({ + contract_payout: 31263.39, + max_payout: 5000, + error: 'Minimum stake of 0.35 and maximum payout of 5000.00. Current payout is 31263.39.', + }); + }); + + it('returns contract payout and max payout values, extracted from error text if it is in proposal_info and has stake field', () => { + const proposal_info = { + id: '', + has_error: true, + has_error_details: true, + error_code: 'ContractBuyValidationError', + error_field: 'stake', + message: 'Minimum stake of 0.35 and maximum payout of 5000.00. Current payout is 31263.39.', + }; + + expect(getPayoutInfo(proposal_info as ReturnType)).toEqual({ + contract_payout: 31263.39, + max_payout: 5000, + error: 'Minimum stake of 0.35 and maximum payout of 5000.00. Current payout is 31263.39.', + }); + }); +}); diff --git a/packages/trader/src/AppV2/Utils/trade-params-utils.tsx b/packages/trader/src/AppV2/Utils/trade-params-utils.tsx index 9d851c423f5a..338070c5dde5 100644 --- a/packages/trader/src/AppV2/Utils/trade-params-utils.tsx +++ b/packages/trader/src/AppV2/Utils/trade-params-utils.tsx @@ -10,7 +10,7 @@ import { import { Localize, localize } from '@deriv/translations'; import { Moment } from 'moment'; import React from 'react'; -import { createProposalRequestForContract } from 'Stores/Modules/Trading/Helpers/proposal'; +import { createProposalRequestForContract, getProposalInfo } from 'Stores/Modules/Trading/Helpers/proposal'; import { TTradeStore } from 'Types'; export const DURATION_UNIT = { @@ -463,3 +463,27 @@ export const getProposalRequestObject = ({ return request; }; + +export const getPayoutInfo = (proposal_info: ReturnType) => { + // getting current payout + const { has_error, message = '', payout = 0, error_field } = proposal_info ?? {}; + const float_number_search_regex = /\d+(\.\d+)?/g; + const is_error_matching = has_error && (error_field === 'amount' || error_field === 'stake'); + const proposal_error_message = is_error_matching ? message : ''; + /* TODO: stop using error text for getting the payout value, need API changes */ + // Extracting the value of exceeded payout from error text + const error_payout = proposal_error_message + ? Number(proposal_error_message.match(float_number_search_regex)?.[2]) + : 0; + const contract_payout = payout || error_payout; + + // getting max allowed payout + const { payout: validation_payout } = (proposal_info?.validation_params || proposal_info?.validation_params) ?? {}; + const { max } = validation_payout ?? {}; + /* TODO: stop using error text for getting the max payout value, need API changes */ + // Extracting the value of max payout from error text + const error_max_payout = is_error_matching && message ? Number(message.match(float_number_search_regex)?.[1]) : 0; + const max_payout = max || error_max_payout; + + return { contract_payout, max_payout, error: proposal_error_message }; +}; diff --git a/packages/trader/src/Stores/Modules/Trading/Helpers/proposal.ts b/packages/trader/src/Stores/Modules/Trading/Helpers/proposal.ts index e869b76e1128..e4d612c1d31f 100644 --- a/packages/trader/src/Stores/Modules/Trading/Helpers/proposal.ts +++ b/packages/trader/src/Stores/Modules/Trading/Helpers/proposal.ts @@ -80,7 +80,7 @@ export const getProposalErrorField = (response: PriceProposalResponse) => { export const getProposalInfo = ( store: TTradeStore, response: PriceProposalResponse & TError, - obj_prev_contract_basis: TObjContractBasis + obj_prev_contract_basis?: TObjContractBasis ) => { const proposal: ExpandedProposal = response.proposal || ({} as ExpandedProposal); const profit = (proposal.payout || 0) - (proposal.ask_price || 0); diff --git a/packages/trader/src/Stores/Modules/Trading/trade-store.ts b/packages/trader/src/Stores/Modules/Trading/trade-store.ts index cc04e2dc23ec..6fd3fd145c65 100644 --- a/packages/trader/src/Stores/Modules/Trading/trade-store.ts +++ b/packages/trader/src/Stores/Modules/Trading/trade-store.ts @@ -163,7 +163,6 @@ export type TChartStateChangeOption = { }; export type TV2ParamsInitialValues = { growth_rate?: number; - stake?: string | number; strike?: string | number; multiplier?: number; barrier_1?: number; @@ -1351,11 +1350,6 @@ export default class TradeStore extends BaseStore { if (has_currency_changed && should_reset_stake) { obj_new_values.amount = obj_new_values.amount || getMinPayout(obj_new_values.currency ?? ''); - if (this.is_dtrader_v2) - this.setV2ParamsInitialValues({ - value: obj_new_values.amount ?? '', - name: 'stake', - }); } this.currency = obj_new_values.currency ?? ''; } @@ -1382,10 +1376,6 @@ export default class TradeStore extends BaseStore { if (has_symbol_changed || has_contract_type_changed) { const is_crypto = isCryptocurrency(this.currency ?? ''); const default_crypto_value = getMinPayout(this.currency ?? '') ?? ''; - this.setV2ParamsInitialValues({ - value: is_crypto ? default_crypto_value : (this.default_stake ?? ''), - name: 'stake', - }); obj_new_values.amount = is_crypto ? default_crypto_value : this.default_stake; } if (has_contract_type_changed) {