Feat/1657 show deal ticket errors contextually (#1900)

* feat: deal ticket contextual validation

* feat: deal ticket contextual validation

* feat: show deal ticket errors contextually

* feat: show deal ticket errors contextually

* feat: show deal ticket errors contextually

* feat: show deal ticket errors contextually - adjust int tests

* feat: show deal ticket errors contextually - adjust size and price sections

* feat: show deal ticket errors contextually - fix lin failings

* feat: show deal ticket errors contextually - use set timeout for create a transition effect

* feat: show deal ticket errors contextually - removing animations

* feat: show deal ticket errors contextually - reove unnecessary cast of section prop

* feat: show deal ticket errors contextually - reove unnecessary cast of section prop

* feat: show deal ticket errors contextually - add clickable order button, refactor error passing

* feat: show deal ticket errors contextually - fix market-info int tests

* feat: show deal ticket errors contextually - fix market-trade int tests

* feat: show deal ticket errors contextually - add back price after reset

* feat: show deal ticket errors contextually - remove reset after sent

Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-11-02 16:45:23 +01:00 committed by GitHub
parent 25eac19ac1
commit 0ee6773cb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 388 additions and 160 deletions

View File

@ -227,6 +227,7 @@ describe('market states', { tags: '@smoke' }, function () {
it.skip('must display correct market state'); it.skip('must display correct market state');
//7002-/SORD-/061 no state displayed //7002-/SORD-/061 no state displayed
it('must display that market is not accepting orders', function () { it('must display that market is not accepting orders', function () {
cy.getByTestId('place-order').click();
cy.getByTestId('dealticket-error-message').should( cy.getByTestId('dealticket-error-message').should(
'have.text', 'have.text',
`This market is ${marketState `This market is ${marketState
@ -234,8 +235,6 @@ describe('market states', { tags: '@smoke' }, function () {
.pop() .pop()
?.toLowerCase()} and not accepting orders` ?.toLowerCase()} and not accepting orders`
); );
});
it('must have place order button disabled', function () {
cy.getByTestId('place-order').should('be.disabled'); cy.getByTestId('place-order').should('be.disabled');
}); });
}); });

View File

@ -251,7 +251,7 @@ describe('deal ticket validation', { tags: '@smoke' }, () => {
}); });
describe('deal ticket size validation', { tags: '@smoke' }, function () { describe('deal ticket size validation', { tags: '@smoke' }, function () {
before(() => { beforeEach(() => {
cy.mockTradingPage(); cy.mockTradingPage();
cy.visit('/markets/market-0'); cy.visit('/markets/market-0');
cy.wait('@Market'); cy.wait('@Market');
@ -260,8 +260,10 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
it('must warn if order size input has too many digits after the decimal place', function () { it('must warn if order size input has too many digits after the decimal place', function () {
//7002-SORD-016 //7002-SORD-016
cy.getByTestId(orderSizeField).clear().type('1.234'); cy.getByTestId(orderSizeField).clear().type('1.234');
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId(placeOrderBtn).should('be.disabled'); cy.getByTestId(placeOrderBtn).should('be.disabled');
cy.getByTestId(errorMessage).should( cy.getByTestId('dealticket-error-message-price-market').should(
'have.text', 'have.text',
'Order sizes must be in whole numbers for this market' 'Order sizes must be in whole numbers for this market'
); );
@ -269,8 +271,10 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
it('must warn if order size is set to 0', function () { it('must warn if order size is set to 0', function () {
cy.getByTestId(orderSizeField).clear().type('0'); cy.getByTestId(orderSizeField).clear().type('0');
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId(placeOrderBtn).should('be.disabled'); cy.getByTestId(placeOrderBtn).should('be.disabled');
cy.getByTestId(errorMessage).should( cy.getByTestId('dealticket-error-message-price-market').should(
'have.text', 'have.text',
'Size cannot be lower than "1"' 'Size cannot be lower than "1"'
); );
@ -416,8 +420,10 @@ describe('suspended market validation', { tags: '@regression' }, () => {
it('should show warning for market order', function () { it('should show warning for market order', function () {
cy.getByTestId(toggleMarket).click(); cy.getByTestId(toggleMarket).click();
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId(placeOrderBtn).should('be.disabled'); cy.getByTestId(placeOrderBtn).should('be.disabled');
cy.getByTestId(errorMessage).should( cy.getByTestId('dealticket-error-message-type').should(
'have.text', 'have.text',
'This market is in auction until it reaches sufficient liquidity. Only limit orders are permitted when market is in auction' 'This market is in auction until it reaches sufficient liquidity. Only limit orders are permitted when market is in auction'
); );
@ -437,7 +443,7 @@ describe('suspended market validation', { tags: '@regression' }, () => {
TIFlist.filter((item) => item.code === 'FOK')[0].value TIFlist.filter((item) => item.code === 'FOK')[0].value
); );
cy.getByTestId(placeOrderBtn).should('be.disabled'); cy.getByTestId(placeOrderBtn).should('be.disabled');
cy.getByTestId(errorMessage).should( cy.getByTestId('dealticket-error-message-force').should(
'have.text', 'have.text',
'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders' 'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders'
); );
@ -464,14 +470,16 @@ describe('margin required validation', { tags: '@regression' }, () => {
}); });
it('should display info and button for deposit', () => { it('should display info and button for deposit', () => {
cy.getByTestId('place-order').should('not.be.disabled');
cy.getByTestId('place-order').click();
cy.getByTestId('place-order').should('be.disabled'); cy.getByTestId('place-order').should('be.disabled');
cy.getByTestId('dealticket-error-message').should( cy.getByTestId('deal-ticket-margin-invalidated').should(
'contain.text', 'contain.text',
"You don't have enough margin available to open this position" "You don't have enough margin available to open this position"
); );
cy.getByTestId('dealticket-error-message').should( cy.getByTestId('deal-ticket-margin-invalidated').should(
'contain.text', 'contain.text',
'0.01000 tBTC currently required, 0.00100 tBTC available' '0.01 tBTC currently required, 0.001 tBTC available'
); );
cy.getByTestId('deal-ticket-deposit-dialog-button').click(); cy.getByTestId('deal-ticket-deposit-dialog-button').click();
cy.getByTestId('dialog-content') cy.getByTestId('dialog-content')

View File

@ -19,3 +19,12 @@ export const EST_FEES_TOOLTIP_TEXT = t(
export const EST_SLIPPAGE = t( export const EST_SLIPPAGE = t(
'When you execute a trade on Vega, the price obtained in the market may differ from the best available price displayed at the time of placing the trade. The estimated slippage shows the difference between the best available price and the estimated execution price, determined by market liquidity and your chosen order size.' 'When you execute a trade on Vega, the price obtained in the market may differ from the best available price displayed at the time of placing the trade. The estimated slippage shows the difference between the best available price and the estimated execution price, determined by market liquidity and your chosen order size.'
); );
export const DEAL_TICKET_SECTION = {
TYPE: 'sec-type',
SIZE: 'sec-size',
PRICE: 'sec-price',
FORCE: 'sec-force',
EXPIRY: 'sec-expiry',
SUMMARY: 'sec-summary',
};

View File

@ -127,7 +127,11 @@ describe('useOrderValidation', () => {
.mockReturnValue(false); .mockReturnValue(false);
const { result } = setup(); const { result } = setup();
expect(result.current).toStrictEqual({ isDisabled: false, message: `` }); expect(result.current).toStrictEqual({
isDisabled: false,
message: ``,
section: '',
});
}); });
it('Returns an error message when no keypair found', () => { it('Returns an error message when no keypair found', () => {
@ -135,7 +139,11 @@ describe('useOrderValidation', () => {
.spyOn(OrderMarginValidation, 'useOrderMarginValidation') .spyOn(OrderMarginValidation, 'useOrderMarginValidation')
.mockReturnValue(false); .mockReturnValue(false);
const { result } = setup(defaultOrder, { pubKey: null }); const { result } = setup(defaultOrder, { pubKey: null });
expect(result.current).toStrictEqual({ isDisabled: false, message: `` }); expect(result.current).toStrictEqual({
isDisabled: false,
message: ``,
section: '',
});
}); });
it.each` it.each`
@ -154,6 +162,7 @@ describe('useOrderValidation', () => {
message: `This market is ${marketTranslations( message: `This market is ${marketTranslations(
state state
)} and not accepting orders`, )} and not accepting orders`,
section: 'sec-summary',
}); });
} }
); );
@ -177,6 +186,7 @@ describe('useOrderValidation', () => {
message: `This market is ${MarketStateMapping[ message: `This market is ${MarketStateMapping[
state as MarketState state as MarketState
].toLowerCase()} and only accepting liquidity commitment orders`, ].toLowerCase()} and only accepting liquidity commitment orders`,
section: 'sec-summary',
}); });
} }
); );
@ -220,19 +230,20 @@ describe('useOrderValidation', () => {
expect(result.current).toStrictEqual({ expect(result.current).toStrictEqual({
isDisabled: true, isDisabled: true,
message: errorMessage, message: errorMessage,
section: 'sec-force',
}); });
} }
); );
it.each` it.each`
fieldName | errorType | errorMessage fieldName | errorType | section | errorMessage
${`size`} | ${`required`} | ${ERROR.FIELD_SIZE_REQ} ${`size`} | ${`required`} | ${'sec-size'} | ${ERROR.FIELD_SIZE_REQ}
${`size`} | ${`min`} | ${ERROR.FIELD_SIZE_MIN} ${`size`} | ${`min`} | ${'sec-size'} | ${ERROR.FIELD_SIZE_MIN}
${`price`} | ${`required`} | ${ERROR.FIELD_PRICE_REQ} ${`price`} | ${`required`} | ${'sec-price'} | ${ERROR.FIELD_PRICE_REQ}
${`price`} | ${`min`} | ${ERROR.FIELD_PRICE_MIN} ${`price`} | ${`min`} | ${'sec-price'} | ${ERROR.FIELD_PRICE_MIN}
`( `(
`Returns an error message when the order $fieldName "$errorType" validation fails`, `Returns an error message when the order $fieldName "$errorType" validation fails`,
({ fieldName, errorType, errorMessage }) => { ({ fieldName, errorType, section, errorMessage }) => {
const { result } = setup({ const { result } = setup({
fieldErrors: { [fieldName]: { type: errorType } }, fieldErrors: { [fieldName]: { type: errorType } },
orderType: Schema.OrderType.TYPE_LIMIT, orderType: Schema.OrderType.TYPE_LIMIT,
@ -240,6 +251,7 @@ describe('useOrderValidation', () => {
expect(result.current).toStrictEqual({ expect(result.current).toStrictEqual({
isDisabled: true, isDisabled: true,
message: errorMessage, message: errorMessage,
section,
}); });
} }
); );
@ -252,6 +264,7 @@ describe('useOrderValidation', () => {
expect(result.current).toStrictEqual({ expect(result.current).toStrictEqual({
isDisabled: true, isDisabled: true,
message: ERROR.FIELD_PRICE_STEP_NULL, message: ERROR.FIELD_PRICE_STEP_NULL,
section: 'sec-size',
}); });
}); });
@ -262,6 +275,7 @@ describe('useOrderValidation', () => {
expect(result.current).toStrictEqual({ expect(result.current).toStrictEqual({
isDisabled: true, isDisabled: true,
message: ERROR.FIELD_PRICE_STEP_DECIMAL, message: ERROR.FIELD_PRICE_STEP_DECIMAL,
section: 'sec-size',
}); });
}); });

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { FieldErrors } from 'react-hook-form'; import type { FieldErrors } from 'react-hook-form';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { t, toDecimal } from '@vegaprotocol/react-helpers'; import { t, toDecimal } from '@vegaprotocol/react-helpers';
@ -18,6 +19,7 @@ import type { DealTicketMarketFragment } from '../deal-ticket/__generated___/Dea
import { ValidateMargin } from './validate-margin'; import { ValidateMargin } from './validate-margin';
import type { OrderMargin } from '../../hooks/use-order-margin'; import type { OrderMargin } from '../../hooks/use-order-margin';
import { useOrderMarginValidation } from './use-order-margin-validation'; import { useOrderMarginValidation } from './use-order-margin-validation';
import { DEAL_TICKET_SECTION } from '../constants';
export const isMarketInAuction = (market: DealTicketMarketFragment) => { export const isMarketInAuction = (market: DealTicketMarketFragment) => {
return [ return [
@ -45,6 +47,10 @@ export const marketTranslations = (marketState: MarketState) => {
} }
}; };
export type DealTicketSection =
| ''
| typeof DEAL_TICKET_SECTION[keyof typeof DEAL_TICKET_SECTION];
export const useOrderValidation = ({ export const useOrderValidation = ({
market, market,
fieldErrors = {}, fieldErrors = {},
@ -52,17 +58,25 @@ export const useOrderValidation = ({
orderTimeInForce, orderTimeInForce,
estMargin, estMargin,
}: ValidationProps): { }: ValidationProps): {
message: React.ReactNode | string; message: ReactNode | string;
isDisabled: boolean; isDisabled: boolean;
section: DealTicketSection;
} => { } => {
const { pubKey } = useVegaWallet(); const { pubKey } = useVegaWallet();
const minSize = toDecimal(market.positionDecimalPlaces); const minSize = toDecimal(market.positionDecimalPlaces);
const isInvalidOrderMargin = useOrderMarginValidation({ market, estMargin }); const isInvalidOrderMargin = useOrderMarginValidation({ market, estMargin });
const { message, isDisabled } = useMemo(() => { const { message, isDisabled, section } = useMemo<{
message: ReactNode | string;
isDisabled: boolean;
section: DealTicketSection;
}>(() => {
if (!pubKey) { if (!pubKey) {
return { message: t('No public key selected'), isDisabled: true }; return {
message: t('No public key selected'),
isDisabled: true,
section: DEAL_TICKET_SECTION.SUMMARY,
};
} }
if ( if (
@ -81,6 +95,7 @@ export const useOrderValidation = ({
market.state market.state
)} and not accepting orders` )} and not accepting orders`
), ),
section: DEAL_TICKET_SECTION.SUMMARY,
}; };
} }
@ -96,6 +111,7 @@ export const useOrderValidation = ({
market.state market.state
)} and only accepting liquidity commitment orders` )} and only accepting liquidity commitment orders`
), ),
section: DEAL_TICKET_SECTION.SUMMARY,
}; };
} }
@ -122,6 +138,7 @@ export const useOrderValidation = ({
{t('Only limit orders are permitted when market is in auction')} {t('Only limit orders are permitted when market is in auction')}
</span> </span>
), ),
section: DEAL_TICKET_SECTION.TYPE,
}; };
} }
if ( if (
@ -145,6 +162,7 @@ export const useOrderValidation = ({
{t('Only limit orders are permitted when market is in auction')} {t('Only limit orders are permitted when market is in auction')}
</span> </span>
), ),
section: DEAL_TICKET_SECTION.TYPE,
}; };
} }
return { return {
@ -152,6 +170,7 @@ export const useOrderValidation = ({
message: t( message: t(
'Only limit orders are permitted when market is in auction' 'Only limit orders are permitted when market is in auction'
), ),
section: DEAL_TICKET_SECTION.SUMMARY,
}; };
} }
if ( if (
@ -185,6 +204,7 @@ export const useOrderValidation = ({
)} )}
</span> </span>
), ),
section: DEAL_TICKET_SECTION.FORCE,
}; };
} }
if ( if (
@ -210,6 +230,7 @@ export const useOrderValidation = ({
)} )}
</span> </span>
), ),
section: DEAL_TICKET_SECTION.FORCE,
}; };
} }
return { return {
@ -217,6 +238,7 @@ export const useOrderValidation = ({
message: t( message: t(
`Until the auction ends, you can only place GFA, GTT, or GTC limit orders` `Until the auction ends, you can only place GFA, GTT, or GTC limit orders`
), ),
section: DEAL_TICKET_SECTION.FORCE,
}; };
} }
} }
@ -225,6 +247,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: t('You need to provide a size'), message: t('You need to provide a size'),
section: DEAL_TICKET_SECTION.SIZE,
}; };
} }
@ -232,6 +255,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: t(`Size cannot be lower than "${minSize}"`), message: t(`Size cannot be lower than "${minSize}"`),
section: DEAL_TICKET_SECTION.SIZE,
}; };
} }
@ -242,6 +266,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: t('You need to provide a price'), message: t('You need to provide a price'),
section: DEAL_TICKET_SECTION.PRICE,
}; };
} }
@ -252,6 +277,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: t(`The price cannot be negative`), message: t(`The price cannot be negative`),
section: DEAL_TICKET_SECTION.PRICE,
}; };
} }
@ -263,6 +289,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: t('Order sizes must be in whole numbers for this market'), message: t('Order sizes must be in whole numbers for this market'),
section: DEAL_TICKET_SECTION.SIZE,
}; };
} }
return { return {
@ -270,6 +297,7 @@ export const useOrderValidation = ({
message: t( message: t(
`The size field accepts up to ${market.positionDecimalPlaces} decimal places` `The size field accepts up to ${market.positionDecimalPlaces} decimal places`
), ),
section: DEAL_TICKET_SECTION.SIZE,
}; };
} }
@ -277,6 +305,7 @@ export const useOrderValidation = ({
return { return {
isDisabled: true, isDisabled: true,
message: <ValidateMargin {...isInvalidOrderMargin} />, message: <ValidateMargin {...isInvalidOrderMargin} />,
section: DEAL_TICKET_SECTION.PRICE,
}; };
} }
@ -292,10 +321,15 @@ export const useOrderValidation = ({
message: t( message: t(
'Any orders placed now will not trade until the auction ends' 'Any orders placed now will not trade until the auction ends'
), ),
section: DEAL_TICKET_SECTION.SUMMARY,
}; };
} }
return { isDisabled: false, message: '' }; return {
isDisabled: false,
message: '',
section: '',
};
}, [ }, [
minSize, minSize,
pubKey, pubKey,
@ -308,5 +342,5 @@ export const useOrderValidation = ({
isInvalidOrderMargin, isInvalidOrderMargin,
]); ]);
return { message, isDisabled }; return { message, isDisabled, section };
}; };

View File

@ -4,6 +4,7 @@ import { DealTicketMarketAmount } from './deal-ticket-market-amount';
import { DealTicketLimitAmount } from './deal-ticket-limit-amount'; import { DealTicketLimitAmount } from './deal-ticket-limit-amount';
import type { DealTicketMarketFragment } from './__generated___/DealTicket'; import type { DealTicketMarketFragment } from './__generated___/DealTicket';
import { Schema } from '@vegaprotocol/types'; import { Schema } from '@vegaprotocol/types';
import type { DealTicketErrorMessage } from './deal-ticket-error';
export interface DealTicketAmountProps { export interface DealTicketAmountProps {
orderType: Schema.OrderType; orderType: Schema.OrderType;
@ -11,6 +12,7 @@ export interface DealTicketAmountProps {
register: UseFormRegister<OrderSubmissionBody['orderSubmission']>; register: UseFormRegister<OrderSubmissionBody['orderSubmission']>;
quoteName: string; quoteName: string;
price?: string; price?: string;
errorMessage?: DealTicketErrorMessage;
} }
export const DealTicketAmount = ({ export const DealTicketAmount = ({

View File

@ -0,0 +1,52 @@
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Button } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
interface Props {
transactionStatus: 'default' | 'pending';
isDisabled: boolean;
errorMessage?: DealTicketErrorMessage;
}
export const DealTicketButton = ({
transactionStatus,
errorMessage,
isDisabled,
}: Props) => {
const { pubKey } = useVegaWallet();
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
return pubKey ? (
<div className="mb-6">
<Button
variant="primary"
fill
type="submit"
disabled={isDisabled}
data-testid="place-order"
>
{transactionStatus === 'pending' ? t('Pending...') : t('Place order')}
</Button>
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message"
section={DEAL_TICKET_SECTION.SUMMARY}
/>
</div>
) : (
<Button
variant="default"
fill
type="button"
data-testid="order-connect-wallet"
onClick={openVegaWalletDialog}
className="mb-6"
>
{t('Connect wallet')}
</Button>
);
};

View File

@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { InputError } from '@vegaprotocol/ui-toolkit';
import type { DealTicketSection } from '../deal-ticket-validation';
export interface DealTicketErrorMessage {
message: ReactNode | string;
isDisabled: boolean;
errorSection: DealTicketSection;
}
interface Props {
errorMessage?: DealTicketErrorMessage;
'data-testid'?: string;
section: DealTicketSection | DealTicketSection[];
}
export const DealTicketError = ({
errorMessage,
'data-testid': dataTestId = 'deal-ticket-error-message',
section,
}: Props) =>
errorMessage &&
(Array.isArray(section) ? section : [section]).includes(
errorMessage.errorSection
) ? (
<div className="-mt-1">
<InputError
intent={errorMessage.isDisabled ? 'danger' : 'warning'}
data-testid={dataTestId}
>
{errorMessage.message}
</InputError>
</div>
) : null;

View File

@ -2,6 +2,8 @@ import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { t, toDecimal } from '@vegaprotocol/react-helpers'; import { t, toDecimal } from '@vegaprotocol/react-helpers';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { validateSize } from '../deal-ticket-validation'; import { validateSize } from '../deal-ticket-validation';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
export type DealTicketLimitAmountProps = Omit< export type DealTicketLimitAmountProps = Omit<
DealTicketAmountProps, DealTicketAmountProps,
@ -12,14 +14,20 @@ export const DealTicketLimitAmount = ({
register, register,
market, market,
quoteName, quoteName,
errorMessage,
}: DealTicketLimitAmountProps) => { }: DealTicketLimitAmountProps) => {
const priceStep = toDecimal(market?.decimalPlaces); const priceStep = toDecimal(market?.decimalPlaces);
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
return ( return (
<div className="mb-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
<FormGroup label={t('Size')} labelFor="input-order-size-limit"> <FormGroup
label={t('Size')}
labelFor="input-order-size-limit"
className="!mb-1"
>
<Input <Input
id="input-order-size-limit" id="input-order-size-limit"
className="w-full" className="w-full"
@ -36,12 +44,16 @@ export const DealTicketLimitAmount = ({
/> />
</FormGroup> </FormGroup>
</div> </div>
<div>@</div> <div className="flex-0 items-center">
<div className="flex">&nbsp;</div>
<div className="flex">@</div>
</div>
<div className="flex-1"> <div className="flex-1">
<FormGroup <FormGroup
labelFor="input-price-quote" labelFor="input-price-quote"
label={t(`Price (${quoteName})`)} label={t(`Price (${quoteName})`)}
labelAlign="right" labelAlign="right"
className="!mb-1"
> >
<Input <Input
id="input-price-quote" id="input-price-quote"
@ -58,5 +70,11 @@ export const DealTicketLimitAmount = ({
</FormGroup> </FormGroup>
</div> </div>
</div> </div>
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message-price-limit"
section={[DEAL_TICKET_SECTION.SIZE, DEAL_TICKET_SECTION.PRICE]}
/>
</div>
); );
}; };

View File

@ -3,6 +3,8 @@ import { formatNumber, t, toDecimal } from '@vegaprotocol/react-helpers';
import type { DealTicketAmountProps } from './deal-ticket-amount'; import type { DealTicketAmountProps } from './deal-ticket-amount';
import { validateSize } from '../deal-ticket-validation/validate-size'; import { validateSize } from '../deal-ticket-validation/validate-size';
import { isMarketInAuction } from '../deal-ticket-validation/use-order-validation'; import { isMarketInAuction } from '../deal-ticket-validation/use-order-validation';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
export type DealTicketMarketAmountProps = Omit< export type DealTicketMarketAmountProps = Omit<
DealTicketAmountProps, DealTicketAmountProps,
@ -14,12 +16,18 @@ export const DealTicketMarketAmount = ({
price, price,
market, market,
quoteName, quoteName,
errorMessage,
}: DealTicketMarketAmountProps) => { }: DealTicketMarketAmountProps) => {
const sizeStep = toDecimal(market?.positionDecimalPlaces); const sizeStep = toDecimal(market?.positionDecimalPlaces);
return ( return (
<div className="mb-6">
<div className="flex items-center gap-4 relative"> <div className="flex items-center gap-4 relative">
<div className="flex-1"> <div className="flex-1">
<FormGroup label={t('Size')} labelFor="input-order-size-market"> <FormGroup
label={t('Size')}
labelFor="input-order-size-market"
className="!mb-1"
>
<Input <Input
id="input-order-size-market" id="input-order-size-market"
className="w-full" className="w-full"
@ -36,9 +44,12 @@ export const DealTicketMarketAmount = ({
/> />
</FormGroup> </FormGroup>
</div> </div>
<div>@</div> <div className="flex-0 items-center">
<div className="flex">&nbsp;</div>
<div className="flex">@</div>
</div>
<div className="flex-1" data-testid="last-price"> <div className="flex-1" data-testid="last-price">
{isMarketInAuction(market) && ( {isMarketInAuction(market) ? (
<Tooltip <Tooltip
description={t( description={t(
'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.' 'This market is in auction. The uncrossing price is an indication of what the price is expected to be when the auction ends.'
@ -48,6 +59,8 @@ export const DealTicketMarketAmount = ({
{t(`Estimated uncrossing price`)} {t(`Estimated uncrossing price`)}
</div> </div>
</Tooltip> </Tooltip>
) : (
<div>&nbsp;</div>
)} )}
<div className="text-sm text-right"> <div className="text-sm text-right">
{price && quoteName ? ( {price && quoteName ? (
@ -60,5 +73,11 @@ export const DealTicketMarketAmount = ({
</div> </div>
</div> </div>
</div> </div>
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message-price-market"
section={[DEAL_TICKET_SECTION.SIZE, DEAL_TICKET_SECTION.PRICE]}
/>
</div>
); );
}; };

View File

@ -1,28 +1,27 @@
import { addDecimal, removeDecimal, t } from '@vegaprotocol/react-helpers'; import { useCallback, useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { removeDecimal, addDecimal } from '@vegaprotocol/react-helpers';
import { TypeSelector } from './type-selector';
import { SideSelector } from './side-selector';
import { DealTicketAmount } from './deal-ticket-amount';
import { TimeInForceSelector } from './time-in-force-selector';
import type { DealTicketMarketFragment } from './__generated___/DealTicket';
import { ExpirySelector } from './expiry-selector';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { Schema } from '@vegaprotocol/types'; import { Schema } from '@vegaprotocol/types';
import { Button, InputError } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useCallback, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import {
getFeeDetailsValues,
useFeeDealTicketDetails,
} from '../../hooks/use-fee-deal-ticket-details';
import { getDefaultOrder } from '../deal-ticket-validation'; import { getDefaultOrder } from '../deal-ticket-validation';
import { import {
isMarketInAuction, isMarketInAuction,
useOrderValidation, useOrderValidation,
} from '../deal-ticket-validation/use-order-validation'; } from '../deal-ticket-validation/use-order-validation';
import { DealTicketAmount } from './deal-ticket-amount';
import { DealTicketFeeDetails } from './deal-ticket-fee-details'; import { DealTicketFeeDetails } from './deal-ticket-fee-details';
import { ExpirySelector } from './expiry-selector'; import {
import { SideSelector } from './side-selector'; useFeeDealTicketDetails,
import { TimeInForceSelector } from './time-in-force-selector'; getFeeDetailsValues,
import { TypeSelector } from './type-selector'; } from '../../hooks/use-fee-deal-ticket-details';
import { DealTicketButton } from './deal-ticket-button';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import type { DealTicketMarketFragment } from './__generated___/DealTicket';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
export type TransactionStatus = 'default' | 'pending'; export type TransactionStatus = 'default' | 'pending';
export interface DealTicketProps { export interface DealTicketProps {
@ -37,17 +36,18 @@ export const DealTicket = ({
submit, submit,
transactionStatus, transactionStatus,
}: DealTicketProps) => { }: DealTicketProps) => {
const { pubKey } = useVegaWallet(); const [errorMessage, setErrorMessage] = useState<
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({ DealTicketErrorMessage | undefined
openVegaWalletDialog: store.openVegaWalletDialog, >(undefined);
}));
const { const {
register, register,
control, control,
handleSubmit, handleSubmit,
watch, watch,
setValue, setValue,
formState: { errors }, clearErrors,
setError,
formState: { errors, isSubmitted },
} = useForm<OrderSubmissionBody['orderSubmission']>({ } = useForm<OrderSubmissionBody['orderSubmission']>({
mode: 'onChange', mode: 'onChange',
defaultValues: getDefaultOrder(market), defaultValues: getDefaultOrder(market),
@ -57,13 +57,34 @@ export const DealTicket = ({
const feeDetails = useFeeDealTicketDetails(order, market); const feeDetails = useFeeDealTicketDetails(order, market);
const details = getFeeDetailsValues(feeDetails); const details = getFeeDetailsValues(feeDetails);
const { message, isDisabled: disabled } = useOrderValidation({ const {
message,
isDisabled: disabled,
section: errorSection,
} = useOrderValidation({
market, market,
orderType: order.type, orderType: order.type,
orderTimeInForce: order.timeInForce, orderTimeInForce: order.timeInForce,
fieldErrors: errors, fieldErrors: errors,
estMargin: feeDetails.estMargin, estMargin: feeDetails.estMargin,
}); });
useEffect(() => {
if (disabled) {
setError('marketId', {});
} else {
clearErrors('marketId');
}
}, [disabled, setError, clearErrors]);
useEffect(() => {
if (isSubmitted) {
setErrorMessage({ message, isDisabled: disabled, errorSection });
} else {
setErrorMessage(undefined);
}
}, [disabled, message, errorSection, isSubmitted]);
const isDisabled = transactionStatus === 'pending' || disabled; const isDisabled = transactionStatus === 'pending' || disabled;
const onSubmit = useCallback( const onSubmit = useCallback(
@ -113,7 +134,11 @@ export const DealTicket = ({
name="type" name="type"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<TypeSelector value={field.value} onSelect={field.onChange} /> <TypeSelector
value={field.value}
onSelect={field.onChange}
errorMessage={errorMessage}
/>
)} )}
/> />
<Controller <Controller
@ -129,6 +154,7 @@ export const DealTicket = ({
register={register} register={register}
price={order.price} price={order.price}
quoteName={market.tradableInstrument.instrument.product.quoteName} quoteName={market.tradableInstrument.instrument.product.quoteName}
errorMessage={errorMessage}
/> />
<Controller <Controller
name="timeInForce" name="timeInForce"
@ -138,6 +164,7 @@ export const DealTicket = ({
value={field.value} value={field.value}
orderType={order.type} orderType={order.type}
onSelect={field.onChange} onSelect={field.onChange}
errorMessage={errorMessage}
/> />
)} )}
/> />
@ -147,43 +174,19 @@ export const DealTicket = ({
name="expiresAt" name="expiresAt"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<ExpirySelector value={field.value} onSelect={field.onChange} /> <ExpirySelector
value={field.value}
onSelect={field.onChange}
errorMessage={errorMessage}
/>
)} )}
/> />
)} )}
{pubKey ? ( <DealTicketButton
<> transactionStatus={transactionStatus}
<Button isDisabled={isSubmitted && isDisabled}
variant="primary" errorMessage={errorMessage}
fill={true} />
type="submit"
disabled={isDisabled}
data-testid="place-order"
>
{transactionStatus === 'pending'
? t('Pending...')
: t('Place order')}
</Button>
{message && (
<InputError
intent={isDisabled ? 'danger' : 'warning'}
data-testid="dealticket-error-message"
>
{message}
</InputError>
)}
</>
) : (
<Button
variant="default"
fill
type="button"
data-testid="order-connect-wallet"
onClick={openVegaWalletDialog}
>
{t('Connect wallet')}
</Button>
)}
<DealTicketFeeDetails details={details} /> <DealTicketFeeDetails details={details} />
</form> </form>
); );

View File

@ -1,13 +1,21 @@
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit'; import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/react-helpers'; import { formatForInput } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
interface ExpirySelectorProps { interface ExpirySelectorProps {
value?: string; value?: string;
onSelect: (expiration: string | null) => void; onSelect: (expiration: string | null) => void;
errorMessage?: DealTicketErrorMessage;
} }
export const ExpirySelector = ({ value, onSelect }: ExpirySelectorProps) => { export const ExpirySelector = ({
value,
onSelect,
errorMessage,
}: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date(); const date = value ? new Date(value) : new Date();
const dateFormatted = formatForInput(date); const dateFormatted = formatForInput(date);
const minDate = formatForInput(date); const minDate = formatForInput(date);
@ -22,6 +30,11 @@ export const ExpirySelector = ({ value, onSelect }: ExpirySelectorProps) => {
onChange={(e) => onSelect(e.target.value)} onChange={(e) => onSelect(e.target.value)}
min={minDate} min={minDate}
/> />
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message-force"
section={DEAL_TICKET_SECTION.EXPIRY}
/>
</FormGroup> </FormGroup>
); );
}; };

View File

@ -3,11 +3,15 @@ import { FormGroup, Select } from '@vegaprotocol/ui-toolkit';
import { Schema } from '@vegaprotocol/types'; import { Schema } from '@vegaprotocol/types';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { timeInForceLabel } from '@vegaprotocol/orders'; import { timeInForceLabel } from '@vegaprotocol/orders';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
interface TimeInForceSelectorProps { interface TimeInForceSelectorProps {
value: Schema.OrderTimeInForce; value: Schema.OrderTimeInForce;
orderType: Schema.OrderType; orderType: Schema.OrderType;
onSelect: (tif: Schema.OrderTimeInForce) => void; onSelect: (tif: Schema.OrderTimeInForce) => void;
errorMessage?: DealTicketErrorMessage;
} }
type PossibleOrderKeys = Exclude< type PossibleOrderKeys = Exclude<
@ -22,6 +26,7 @@ export const TimeInForceSelector = ({
value, value,
orderType, orderType,
onSelect, onSelect,
errorMessage,
}: TimeInForceSelectorProps) => { }: TimeInForceSelectorProps) => {
const [prevValue, setPrevValue] = useState<PrevSelectedValue>({ const [prevValue, setPrevValue] = useState<PrevSelectedValue>({
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC, [Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
@ -59,6 +64,11 @@ export const TimeInForceSelector = ({
</option> </option>
))} ))}
</Select> </Select>
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message-force"
section={DEAL_TICKET_SECTION.FORCE}
/>
</FormGroup> </FormGroup>
); );
}; };

View File

@ -2,10 +2,14 @@ import { FormGroup } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { Schema } from '@vegaprotocol/types'; import { Schema } from '@vegaprotocol/types';
import { Toggle } from '@vegaprotocol/ui-toolkit'; import { Toggle } from '@vegaprotocol/ui-toolkit';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
interface TypeSelectorProps { interface TypeSelectorProps {
value: Schema.OrderType; value: Schema.OrderType;
onSelect: (type: Schema.OrderType) => void; onSelect: (type: Schema.OrderType) => void;
errorMessage?: DealTicketErrorMessage;
} }
const toggles = [ const toggles = [
@ -13,7 +17,11 @@ const toggles = [
{ label: t('Limit'), value: Schema.OrderType.TYPE_LIMIT }, { label: t('Limit'), value: Schema.OrderType.TYPE_LIMIT },
]; ];
export const TypeSelector = ({ value, onSelect }: TypeSelectorProps) => { export const TypeSelector = ({
value,
onSelect,
errorMessage,
}: TypeSelectorProps) => {
return ( return (
<FormGroup label={t('Order type')} labelFor="order-type"> <FormGroup label={t('Order type')} labelFor="order-type">
<Toggle <Toggle
@ -23,6 +31,11 @@ export const TypeSelector = ({ value, onSelect }: TypeSelectorProps) => {
checkedValue={value} checkedValue={value}
onChange={(e) => onSelect(e.target.value as Schema.OrderType)} onChange={(e) => onSelect(e.target.value as Schema.OrderType)}
/> />
<DealTicketError
errorMessage={errorMessage}
data-testid="dealticket-error-message-type"
section={DEAL_TICKET_SECTION.TYPE}
/>
</FormGroup> </FormGroup>
); );
}; };