Skip to content

Commit

Permalink
GRWT-2395 / Kate / [Dtrader -V2] Risk management changes (deriv-com#1…
Browse files Browse the repository at this point in the history
…7567)

* refactor: reset the stop loss value on trade type change

* feat: create error observer component for trade params error and a helper hook

* refactor: tak

* refactor: add tests for new component

* refactor: hook

* fix: independent tp and sl validation

* refactor: add tests

* chore: remove unused code

* fix: add date start field to the observes

* refactor: rename componnets and params
  • Loading branch information
kate-deriv authored Nov 26, 2024
1 parent b9603d4 commit 291993e
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 16 deletions.
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 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<typeof mockStore>,
default_mock_props: React.ComponentProps<typeof TradeErrorSnackbar>;
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 (
<TraderProviders store={default_mock_store}>
<TradeErrorSnackbar {...default_mock_props} />
</TraderProviders>
);
};

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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import TradeErrorSnackbar from './trade-error-snackbar';

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

export default TradeErrorSnackbar;
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 useTradeError from 'AppV2/Hooks/useTradeError';

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_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') })}`;
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 useTradeError from 'AppV2/Hooks/useTradeError';
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 } = useTradeError({ error_fields: [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),
],
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 @@ -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);
}
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 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), []);
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 TradeErrorSnackbar from 'AppV2/Components/TradeErrorSnackbar';

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 />
<TradeErrorSnackbar error_fields={['stop_loss', 'take_profit', 'date_start']} should_show_snackbar />
</BottomNav>
);
});
Expand Down
Loading

0 comments on commit 291993e

Please sign in to comment.