Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GRWT-2395 / Kate / [Dtrader -V2] Risk management changes #17567

Merged
merged 10 commits into from
Nov 26, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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 TradeParamErrorSnackbar from '../trade-param-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('TradeParamErrorSnackbar', () => {
let default_mock_store: ReturnType<typeof mockStore>,
default_mock_props: React.ComponentProps<typeof TradeParamErrorSnackbar>;
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 = { trade_params: ['take_profit', 'stop_loss'], should_show_snackbar: true };
mockAddSnackbar = jest.fn();
(useSnackbar as jest.Mock).mockReturnValue({ addSnackbar: mockAddSnackbar });
});

const mockTradeParamErrorSnackbar = () => {
return (
<TraderProviders store={default_mock_store}>
<TradeParamErrorSnackbar {...default_mock_props} />
</TraderProviders>
);
};

it('calls useSnackbar if error field in proposal matches the passed trade_params', () => {
render(mockTradeParamErrorSnackbar());

expect(mockAddSnackbar).toHaveBeenCalled();
});

it('calls useSnackbar if error field in proposal matches the passed trade_params even if user is log out', () => {
default_mock_store.client.is_logged_in = false;
render(mockTradeParamErrorSnackbar());

expect(mockAddSnackbar).toHaveBeenCalled();
});

it('does not call useSnackbar if error field in proposal does not matches the passed trade_params', () => {
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(mockTradeParamErrorSnackbar());

expect(mockAddSnackbar).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import TradeParamErrorSnackbar from './trade-param-error-snackbar';

export default TradeParamErrorSnackbar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { observer, useStore } from '@deriv/stores';
import { SnackbarController, useSnackbar } from '@deriv-com/quill-ui';
import useTradeParamError, { TTradeParams } from '../../Hooks/useTradeParamError';

const TradeParamErrorSnackbar = observer(
nijil-deriv marked this conversation as resolved.
Show resolved Hide resolved
({ trade_params, should_show_snackbar }: { trade_params: TTradeParams[]; should_show_snackbar?: boolean }) => {
const {
client: { is_logged_in },
} = useStore();
const { addSnackbar } = useSnackbar();
const { is_error_matching_trade_param: has_error, message } = useTradeParamError({
trade_params, // array with trade params, for which we will track errors. They should match error_field
});

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 <SnackbarController />;
}
);

export default TradeParamErrorSnackbar;
Original file line number Diff line number Diff line change
Expand Up @@ -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 useTradeParamError from 'AppV2/Hooks/useTradeParamError';

const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => {
const [is_open, setIsOpen] = React.useState(false);
Expand All @@ -27,6 +28,10 @@ const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => {
stop_loss,
} = useTraderStore();

const { is_error_matching_trade_param: has_error } = useTradeParamError({
nijil-deriv marked this conversation as resolved.
Show resolved Hide resolved
trade_params: ['stop_loss', 'take_profit'],
});

const closeActionSheet = React.useCallback(() => setIsOpen(false), []);
const getRiskManagementText = () => {
if (has_cancellation) return `DC: ${addUnit({ value: cancellation_duration, unit: localize('minutes') })}`;
Expand Down Expand Up @@ -82,6 +87,7 @@ const RiskManagement = observer(({ is_minimized }: TTradeParametersProps) => {
readOnly
value={getRiskManagementText()}
variant='fill'
status={has_error ? 'error' : 'neutral'}
/>
<ActionSheet.Root
isOpen={is_open}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Localize, localize } from '@deriv/translations';
import { TTradeStore } from 'Types';
import { getDisplayedContractTypes } from 'AppV2/Utils/trade-types-utils';
import { useDtraderQuery } from 'AppV2/Hooks/useDtraderQuery';
import useTradeParamError from 'AppV2/Hooks/useTradeParamError';
import { ExpandedProposal } from 'Stores/Modules/Trading/Helpers/proposal';

type TTakeProfitAndStopLossInputProps = {
classname?: string;
Expand Down Expand Up @@ -55,6 +57,10 @@ const TakeProfitAndStopLossInput = ({
} = trade_store;

const is_take_profit_input = type === 'take_profit';
const contract_types = getDisplayedContractTypes(trade_types, contract_type, trade_type_tab);

// For tracking errors, that are coming from proposal for take profit and stop loss
const { message } = useTradeParamError({ trade_params: [type] });

// For handling cases when user clicks on Save btn before we got response from API
const is_api_response_received = React.useRef(false);
Expand All @@ -63,14 +69,13 @@ const TakeProfitAndStopLossInput = ({
const [is_enabled, setIsEnabled] = React.useState(is_take_profit_input ? has_take_profit : has_stop_loss);
const [new_input_value, setNewInputValue] = React.useState(is_take_profit_input ? take_profit : stop_loss);
const [error_text, setErrorText] = React.useState('');
const [fe_error_text, setFEErrorText] = React.useState(initial_error_text ?? '');
const [fe_error_text, setFEErrorText] = React.useState(initial_error_text ?? message ?? '');

// Refs for handling focusing and bluring input
const input_ref = React.useRef<HTMLInputElement>(null);
const focused_input_ref = React.useRef<HTMLInputElement>(null);
const focus_timeout = React.useRef<ReturnType<typeof setTimeout>>();

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';
Expand All @@ -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<Parameters<TOnProposalResponse>[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),
nijil-deriv marked this conversation as resolved.
Show resolved Hide resolved
],
proposal_req,
{
enabled: is_enabled,
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ const Stake = observer(({ is_minimized }: TTradeParametersProps) => {
onAction: () => {
if (!stake_error) {
onClose(true);
onChange({ target: { name: 'amount', value: amount } });
} else {
setShouldShowError(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 useTradeParamError from 'AppV2/Hooks/useTradeParamError';

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_trade_param: has_error } = useTradeParamError({ trade_params: ['take_profit'] });
const [is_open, setIsOpen] = React.useState(false);

const onActionSheetClose = React.useCallback(() => setIsOpen(false), []);
Expand Down Expand Up @@ -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'}
/>
<ActionSheet.Root
isOpen={is_open}
Expand Down
2 changes: 2 additions & 0 deletions packages/trader/src/AppV2/Containers/Trade/trade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import AccumulatorStats from 'AppV2/Components/AccumulatorStats';
import OnboardingGuide from 'AppV2/Components/OnboardingGuide/GuideForPages';
import ServiceErrorSheet from 'AppV2/Components/ServiceErrorSheet';
import { sendSelectedTradeTypeToAnalytics } from '../../../Analytics';
import TradeParamErrorSnackbar from 'AppV2/Components/TradeParamErrorSnackbar';

const Trade = observer(() => {
const [is_minimized_params_visible, setIsMinimizedParamsVisible] = React.useState(false);
Expand Down Expand Up @@ -130,6 +131,7 @@ const Trade = observer(() => {
)}
<ServiceErrorSheet />
<ClosedMarketMessage />
<TradeParamErrorSnackbar trade_params={['stop_loss', 'take_profit']} should_show_snackbar />
</BottomNav>
);
});
Expand Down
Loading
Loading