feat: 1655 persist deal ticket, 1694 GTT in the past (#1865)

This commit is contained in:
Art 2022-11-10 19:06:10 +01:00 committed by GitHub
parent 82c8b4a0e5
commit 0ec511a72f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 325 additions and 81 deletions

View File

@ -121,6 +121,43 @@ const testOrder = (order: Order, expected?: Partial<Order>) => {
cy.getByTestId('dialog-close').click();
};
const clearPersistedOrder = () => {
cy.clearLocalStorage().should((ls) => {
expect(ls.getItem('deal-ticket-order-market-0')).to.be.null;
});
};
beforeEach(() => clearPersistedOrder());
afterEach(() => clearPersistedOrder());
describe('time in force default values', () => {
before(() => {
cy.mockTradingPage();
cy.mockGQLSubscription();
cy.visit('/#/markets/market-0');
cy.wait('@Market');
connectVegaWallet();
});
it('must have market order set up to IOC by default', function () {
//7002-SORD-031
cy.getByTestId(toggleMarket).click();
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
'have.text',
TIFlist.filter((item) => item.code === 'IOC')[0].text
);
});
it('must have time in force set to GTC for limit order', function () {
//7002-SORD-031
cy.getByTestId(toggleLimit).click();
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
'have.text',
TIFlist.filter((item) => item.code === 'GTC')[0].text
);
});
});
describe('must submit order', { tags: '@smoke' }, () => {
// 7002-SORD-039
before(() => {
@ -181,13 +218,14 @@ describe('must submit order', { tags: '@smoke' }, () => {
it('successfully places GTT limit buy order', () => {
cy.mockVegaCommandSync(mockTx);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const order: Order = {
type: 'TYPE_LIMIT',
side: 'SIDE_SELL',
size: '100',
price: '1.00',
timeInForce: 'TIME_IN_FORCE_GTT',
expiresAt: '2022-01-01T00:00',
expiresAt: expiresAt.toISOString().substring(0, 16),
};
testOrder(order, {
price: '100000',
@ -436,6 +474,7 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
});
it('must warn if order size input has too many digits after the decimal place', function () {
//7002-SORD-016
cy.getByTestId('order-type-TYPE_MARKET').click();
cy.getByTestId(orderSizeField).clear().type('1.234');
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
cy.getByTestId(placeOrderBtn).click();
@ -447,6 +486,7 @@ describe('deal ticket size validation', { tags: '@smoke' }, function () {
});
it('must warn if order size is set to 0', function () {
cy.getByTestId('order-type-TYPE_MARKET').click();
cy.getByTestId(orderSizeField).clear().type('0');
cy.getByTestId(placeOrderBtn).should('not.be.disabled');
cy.getByTestId(placeOrderBtn).click();
@ -462,6 +502,7 @@ describe('limit order validations', { tags: '@smoke' }, () => {
before(() => {
cy.mockTradingPage();
cy.visit('/#/markets/market-0');
connectVegaWallet();
cy.wait('@Market');
cy.getByTestId(toggleLimit).click();
});
@ -473,9 +514,23 @@ describe('limit order validations', { tags: '@smoke' }, () => {
.should('have.text', 'Price (BTC)');
});
it.skip('must see warning when placing an order with expiry date in past', function () {
// Test to be created after the bug below is fixed
// https://github.com/vegaprotocol/frontend-monorepo/issues/1694
it('must see warning when placing an order with expiry date in past', function () {
const expiresAt = new Date(Date.now() - 24 * 60 * 60 * 1000);
const expiresAtInputValue = expiresAt.toISOString().substring(0, 16);
cy.getByTestId(toggleLimit).click();
cy.getByTestId(orderPriceField).clear().type('0.1');
cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(orderTIFDropDown).select('TIME_IN_FORCE_GTT');
cy.log('choosing yesterday');
cy.getByTestId('date-picker-field').type(expiresAtInputValue);
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('dealticket-error-message-force').should(
'have.text',
'The expiry date that you have entered appears to be in the past'
);
});
it.skip('must receive warning if price has too many digits after decimal place', function () {
@ -484,14 +539,6 @@ describe('limit order validations', { tags: '@smoke' }, () => {
});
describe('time in force validations', function () {
it('must have limit order set to GTC by default', function () {
//7002-SORD-031
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
'have.text',
TIFlist.filter((item) => item.code === 'GTC')[0].text
);
});
const validTIF = TIFlist;
validTIF.forEach((tif) => {
//7002-SORD-023
@ -545,14 +592,6 @@ describe('market order validations', { tags: '@smoke' }, () => {
});
describe('time in force validations', function () {
it('must have market order set up to IOC by default', function () {
//7002-SORD-031
cy.get(`[data-testid=${orderTIFDropDown}] option:selected`).should(
'have.text',
TIFlist.filter((item) => item.code === 'IOC')[0].text
);
});
const validTIF = TIFlist.filter((tif) => ['FOK', 'IOC'].includes(tif.code));
const invalidTIF = TIFlist.filter(
(tif) => !['FOK', 'IOC'].includes(tif.code)
@ -607,6 +646,8 @@ describe('suspended market validation', { tags: '@regression' }, () => {
});
it('should show info for allowed TIF', function () {
cy.getByTestId(toggleLimit).click();
cy.getByTestId(orderPriceField).clear().type('0.1');
cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(errorMessage).should(
'have.text',

View File

@ -1,6 +1,6 @@
import { toDecimal } from '@vegaprotocol/react-helpers';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { Schema } from '@vegaprotocol/types';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
export const getDefaultOrder = (market: {
id: string;

View File

@ -1,3 +1,5 @@
export * from './get-default-order';
export * from './use-order-validation';
export * from './validate-size';
export * from './use-persisted-order';
export * from './validate-expiration';

View File

@ -18,6 +18,7 @@ import type { DealTicketMarketFragment } from '../deal-ticket/__generated__/Deal
import { ValidateMargin } from './validate-margin';
import type { OrderMargin } from '../../hooks/use-order-margin';
import { useOrderMarginValidation } from './use-order-margin-validation';
import { ERROR_EXPIRATION_IN_THE_PAST } from './validate-expiration';
import { DEAL_TICKET_SECTION, ERROR_SIZE_DECIMAL } from '../constants';
export const isMarketInAuction = (market: DealTicketMarketFragment) => {
@ -129,13 +130,28 @@ export const useOrderValidation = ({
};
}
}
if (
fieldErrors?.expiresAt?.type === 'validate' &&
fieldErrors?.expiresAt.message === ERROR_EXPIRATION_IN_THE_PAST
) {
return {
isDisabled: false,
message: t(
'The expiry date that you have entered appears to be in the past'
),
section: DEAL_TICKET_SECTION.EXPIRY,
};
}
return null;
}, [
fieldErrors?.size?.type,
fieldErrors?.size?.message,
fieldErrors?.price?.type,
minSize,
fieldErrors?.expiresAt?.type,
fieldErrors?.expiresAt?.message,
orderType,
minSize,
market.positionDecimalPlaces,
]);
@ -356,9 +372,9 @@ export const useOrderValidation = ({
pubKey,
market,
fieldErrorChecking,
isInvalidOrderMargin,
orderType,
orderTimeInForce,
isInvalidOrderMargin,
]);
return { message, isDisabled, section };

View File

@ -0,0 +1,16 @@
import { useLocalStorage } from '@vegaprotocol/react-helpers';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { useMemo } from 'react';
type OrderData = OrderSubmissionBody['orderSubmission'] | null;
export const usePersistedOrder = (market: {
id: string;
}): [OrderData, (value: OrderData) => void] => {
const [value, setValue] = useLocalStorage(`deal-ticket-order-${market.id}`);
const order = value != null ? (JSON.parse(value) as OrderData) : null;
return useMemo<[OrderData, (value: OrderData) => void]>(
() => [order, (order: OrderData) => setValue(JSON.stringify(order))],
[order, setValue]
);
};

View File

@ -0,0 +1,14 @@
import type { Validate } from 'react-hook-form';
export const ERROR_EXPIRATION_IN_THE_PAST = 'ERROR_EXPIRATION_IN_THE_PAST';
export const validateExpiration: Validate<string | undefined> = (
value?: string
) => {
const now = new Date();
const valueAsDate = value ? new Date(value) : now;
if (now > valueAsDate) {
return ERROR_EXPIRATION_IN_THE_PAST;
}
return true;
};

View File

@ -84,6 +84,8 @@ function generateJsx(order?: OrderSubmissionBody['orderSubmission']) {
}
describe('DealTicket', () => {
beforeEach(() => window.localStorage.clear());
afterEach(() => window.localStorage.clear());
it('should display ticket defaults', () => {
render(generateJsx());
@ -148,20 +150,20 @@ describe('DealTicket', () => {
it('handles TIF select box dependent on order type', () => {
render(generateJsx());
// Check only IOC and
// Only FOK and IOC should be present by default (type market order)
expect(
Array.from(screen.getByTestId('order-tif').children).map(
(o) => o.textContent
)
).toEqual(['Fill or Kill (FOK)', 'Immediate or Cancel (IOC)']);
// Switch to limit order and check all TIF options shown
// Switch to type limit order -> all TIF options should be shown
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif').children).toHaveLength(
Object.keys(Schema.OrderTimeInForce).length
);
// Change to GTC
// Select GTC -> GTC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC },
});
@ -169,24 +171,38 @@ describe('DealTicket', () => {
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Switch back to market order and TIF should now be IOC
// Switch to type market order -> IOC should be selected (default)
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Switch tif to FOK
// Select IOC -> IOC should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_FOK },
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_FOK
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
// Change back to limit and check we are still on FOK
// Switch to type limit order -> GTC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_LIMIT'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTC
);
// Select GTT -> GTT should be selected
fireEvent.change(screen.getByTestId('order-tif'), {
target: { value: Schema.OrderTimeInForce.TIME_IN_FORCE_GTT },
});
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_GTT
);
// Switch to type market order -> IOC should be selected
fireEvent.click(screen.getByTestId('order-type-TYPE_MARKET'));
expect(screen.getByTestId('order-tif')).toHaveValue(
Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
});
});

View File

@ -7,7 +7,7 @@ import {
getFeeDetailsValues,
useFeeDealTicketDetails,
} from '../../hooks/use-fee-deal-ticket-details';
import { getDefaultOrder } from '../deal-ticket-validation';
import { getDefaultOrder, usePersistedOrder } from '../deal-ticket-validation';
import {
isMarketInAuction,
useOrderValidation,
@ -23,6 +23,7 @@ import { TypeSelector } from './type-selector';
import type { DealTicketMarketFragment } from './__generated__/DealTicket';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
export type TransactionStatus = 'default' | 'pending';
@ -41,6 +42,7 @@ export const DealTicket = ({
const [errorMessage, setErrorMessage] = useState<
DealTicketErrorMessage | undefined
>(undefined);
const [persistedOrder, setOrder] = usePersistedOrder(market);
const {
register,
control,
@ -52,16 +54,18 @@ export const DealTicket = ({
formState: { errors, isSubmitted },
} = useForm<OrderSubmissionBody['orderSubmission']>({
mode: 'onChange',
defaultValues: getDefaultOrder(market),
defaultValues: persistedOrder || getDefaultOrder(market),
});
const order = watch();
const feeDetails = useFeeDealTicketDetails(order, market);
const details = getFeeDetailsValues(feeDetails);
useEffect(() => setOrder(order), [order, setOrder]);
const {
message,
isDisabled: disabled,
message,
section: errorSection,
} = useOrderValidation({
market,
@ -80,7 +84,7 @@ export const DealTicket = ({
}, [disabled, setError, clearErrors]);
useEffect(() => {
if (isSubmitted) {
if (isSubmitted || errorSection === DEAL_TICKET_SECTION.SUMMARY) {
setErrorMessage({ message, isDisabled: disabled, errorSection });
} else {
setErrorMessage(undefined);
@ -180,6 +184,7 @@ export const DealTicket = ({
value={field.value}
onSelect={field.onChange}
errorMessage={errorMessage}
register={register}
/>
)}
/>

View File

@ -1,6 +1,9 @@
import { FormGroup, Input } from '@vegaprotocol/ui-toolkit';
import { formatForInput } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import type { UseFormRegister } from 'react-hook-form';
import type { OrderSubmissionBody } from '@vegaprotocol/wallet';
import { validateExpiration } from '../deal-ticket-validation/validate-expiration';
import type { DealTicketErrorMessage } from './deal-ticket-error';
import { DealTicketError } from './deal-ticket-error';
import { DEAL_TICKET_SECTION } from '../constants';
@ -9,12 +12,14 @@ interface ExpirySelectorProps {
value?: string;
onSelect: (expiration: string | null) => void;
errorMessage?: DealTicketErrorMessage;
register?: UseFormRegister<OrderSubmissionBody['orderSubmission']>;
}
export const ExpirySelector = ({
value,
onSelect,
errorMessage,
register,
}: ExpirySelectorProps) => {
const date = value ? new Date(value) : new Date();
const dateFormatted = formatForInput(date);
@ -24,11 +29,13 @@ export const ExpirySelector = ({
<Input
data-testid="date-picker-field"
id="expiration"
name="expiration"
type="datetime-local"
value={dateFormatted}
onChange={(e) => onSelect(e.target.value)}
min={minDate}
{...register?.('expiresAt', {
validate: validateExpiration,
})}
/>
<DealTicketError
errorMessage={errorMessage}

View File

@ -14,12 +14,13 @@ interface TimeInForceSelectorProps {
errorMessage?: DealTicketErrorMessage;
}
type PossibleOrderKeys = Exclude<
Schema.OrderType,
Schema.OrderType.TYPE_NETWORK
>;
type PrevSelectedValue = {
[key in PossibleOrderKeys]: Schema.OrderTimeInForce;
type OrderType = Schema.OrderType.TYPE_MARKET | Schema.OrderType.TYPE_LIMIT;
type PreviousTimeInForce = {
[key in OrderType]: Schema.OrderTimeInForce;
};
const DEFAULT_TIME_IN_FORCE: PreviousTimeInForce = {
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
};
export const TimeInForceSelector = ({
@ -28,10 +29,6 @@ export const TimeInForceSelector = ({
onSelect,
errorMessage,
}: TimeInForceSelectorProps) => {
const [prevValue, setPrevValue] = useState<PrevSelectedValue>({
[Schema.OrderType.TYPE_LIMIT]: Schema.OrderTimeInForce.TIME_IN_FORCE_GTC,
[Schema.OrderType.TYPE_MARKET]: Schema.OrderTimeInForce.TIME_IN_FORCE_IOC,
});
const options =
orderType === Schema.OrderType.TYPE_LIMIT
? Object.entries(Schema.OrderTimeInForce)
@ -40,20 +37,40 @@ export const TimeInForceSelector = ({
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_FOK ||
timeInForce === Schema.OrderTimeInForce.TIME_IN_FORCE_IOC
);
const [previousOrderType, setPreviousOrderType] = useState(
Schema.OrderType.TYPE_MARKET
);
const [previousTimeInForce, setPreviousTimeInForce] =
useState<PreviousTimeInForce>({
...DEFAULT_TIME_IN_FORCE,
[orderType]: value,
});
useEffect(() => {
onSelect(prevValue[orderType as PossibleOrderKeys]);
}, [onSelect, prevValue, orderType]);
if (previousOrderType !== orderType) {
setPreviousOrderType(orderType);
const prev = previousTimeInForce[orderType as OrderType];
onSelect(prev);
}
}, [
onSelect,
orderType,
previousTimeInForce,
previousOrderType,
setPreviousOrderType,
]);
return (
<FormGroup label={t('Time in force')} labelFor="select-time-in-force">
<Select
id="select-time-in-force"
value={value}
onChange={(e) => {
const selectedValue = e.target.value as Schema.OrderTimeInForce;
setPrevValue({
...prevValue,
[orderType]: selectedValue,
setPreviousTimeInForce({
...previousTimeInForce,
[orderType]: e.target.value,
});
onSelect(e.target.value as Schema.OrderTimeInForce);
}}
className="w-full"
data-testid="order-tif"

View File

@ -1,29 +1 @@
// TODO: fine for now however will leak state between tests (we don't really have) in future. Ideally should use a provider
export const LocalStorage = {
getItem: (key: string) => {
if (typeof window === 'undefined') return;
try {
const item = window.localStorage.getItem(key);
return item;
} catch (error) {
console.error(error);
return null;
}
},
setItem: (key: string, value: string) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, value);
} catch (error) {
console.error(error);
}
},
removeItem: (key: string) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(key);
} catch (error) {
console.error(error);
}
},
};
export * from './local-storage';

View File

@ -0,0 +1,52 @@
import { act, renderHook } from '@testing-library/react';
import { useLocalStorage } from './local-storage';
describe('useLocalStorage', () => {
afterEach(() => window.localStorage.clear());
it("should return null if there's no value set", () => {
const { result } = renderHook(() => useLocalStorage('test'));
const [value] = result.current;
expect(value).toBeNull();
});
it('should return already saved value', () => {
window.localStorage.setItem('test', '123');
const { result } = renderHook(() => useLocalStorage('test'));
const [value] = result.current;
expect(value).toEqual('123');
});
it('should save given value', () => {
const { result } = renderHook(() => useLocalStorage('test'));
const setValue = result.current[1];
expect(result.current[0]).toBeNull();
act(() => setValue('123'));
expect(result.current[0]).toEqual('123');
act(() => setValue('456'));
expect(result.current[0]).toEqual('456');
});
it('should remove given value', () => {
const { result } = renderHook(() => useLocalStorage('test'));
const setValue = result.current[1];
const removeValue = result.current[2];
act(() => setValue('123'));
expect(result.current[0]).toEqual('123');
act(() => removeValue());
expect(result.current[0]).toBeNull();
});
it('should return value set by storage event (by another tab)', () => {
const { result: A } = renderHook(() => useLocalStorage('test-a'));
const { result: B } = renderHook(() => useLocalStorage('test-b'));
act(() => {
window.localStorage.setItem('test-a', '123');
window.dispatchEvent(
new StorageEvent('storage', {
key: 'test-a',
oldValue: window.localStorage.getItem('test-a'),
storageArea: window.localStorage,
newValue: '123',
})
);
});
expect(A.current[0]).toEqual('123');
expect(B.current[0]).toBeNull();
});
});

View File

@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react';
export const LocalStorage = {
getItem: (key: string) => {
if (typeof window === 'undefined') return;
try {
const item = window.localStorage.getItem(key);
return item;
} catch (error) {
console.error(error);
return null;
}
},
setItem: (key: string, value: string) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, value);
} catch (error) {
console.error(error);
}
},
removeItem: (key: string) => {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(key);
} catch (error) {
console.error(error);
}
},
};
type LocalStorageCallback = (key: string) => void;
const LOCAL_STORAGE_CALLBACKS = new Set<LocalStorageCallback>();
const registerCallback = (callback: LocalStorageCallback) =>
LOCAL_STORAGE_CALLBACKS.add(callback);
const unregisterCallback = (callback: LocalStorageCallback) =>
LOCAL_STORAGE_CALLBACKS.delete(callback);
const triggerCallbacks = (key: string) =>
LOCAL_STORAGE_CALLBACKS.forEach((cb) => cb(key));
export const useLocalStorage = (key: string) => {
const subscribe = useCallback(
(onStoreChange: () => void) => {
const callback = (cbKey: string) => {
if (cbKey === key) onStoreChange();
};
registerCallback(callback);
return () => unregisterCallback(callback);
},
[key]
);
const getSnapshot = () => {
const item = LocalStorage.getItem(key);
return item;
};
const setValue = useCallback(
(value: string) => {
LocalStorage.setItem(key, value);
triggerCallbacks(key);
},
[key]
);
const removeValue = useCallback(() => {
LocalStorage.removeItem(key);
triggerCallbacks(key);
}, [key]);
const value = useSyncExternalStore(subscribe, getSnapshot, () => null);
// sync in all tabs
const onStorage = useCallback(
(ev: StorageEvent) => {
if (ev.storageArea === window.localStorage && ev.key === key) {
triggerCallbacks(key);
}
},
[key]
);
useEffect(() => {
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [key, onStorage]);
return useMemo<
[string | null | undefined, (value: string) => void, () => void]
>(() => [value, setValue, removeValue], [removeValue, setValue, value]);
};