Compare commits

...
Author SHA1 Message Date
Matthew Russell 74ec55df50 chore: fix lint 2023-09-04 12:36:40 -07:00
Matthew Russell 2e8768bfcb chore: reduce font size of product selector 2023-09-04 12:30:09 -07:00
Matthew Russell 206a3339b3 fix: alignment of icon in trading input 2023-09-04 12:26:40 -07:00
Matthew Russell 7e84d58f58 chore: fix alignment of market selector items 2023-09-04 12:20:00 -07:00
Matthew Russell e9aea846f3 chore: widen market selector to match design 2023-09-04 12:18:28 -07:00
Matthew Russell 3d99b32a3f chore: remove memo for trigger text 2023-09-04 12:18:09 -07:00
Matthew Russell 95e4f3e82d chore: make button look like design 2023-09-04 12:13:33 -07:00
Madalina Raicu a0a3817ece chore: add a small spec 2023-09-04 19:36:06 +01:00
Madalina Raicu 4068b6ca6b chore(trading): update dropdowns 2023-09-04 19:32:27 +01:00
Madalina Raicu 3e9d8def92 chore(trading): update dropdowns 2023-09-04 18:39:20 +01:00
Matthew Russell 1208d3f2a8 feat(market-depth): additional orderbook grouping levels (#4658) 2023-09-04 07:47:27 -07:00
Sam Keen b5ba4f99d9 fix(governance,utils,react-helpers): token locale formatting issue (#4678) 2023-09-04 12:09:38 +01:00
Joe Tsang 66f25603d3 test(explorer): add e2e test for explorer oracles (#4657) 2023-09-04 09:41:04 +00:00
Edd 4afd469404 chore(explorer,trading,governance): reconfigure hooks (#4655) 2023-09-04 09:36:13 +00:00
Maciek 9e5bc9c8d1 chore(trading): handle negative decimals (#4659) 2023-09-04 09:46:02 +02:00
Matthew Russell e41aff88b1 chore(trading): make top traded default sort for market selector (#4637) 2023-09-03 11:15:41 -07:00
Matthew Russell 9209074332 chore(trading): change pane context buttons color (#4680) 2023-09-01 17:00:11 -07:00
Art 39e5836edb fix(wallet): check if snaps are supported (#4671) 2023-09-01 11:22:13 +00:00
Art 85a4981700 fix(proposals): protocol upgrade notification block querying (#4665) 2023-09-01 12:30:01 +02:00
Ben 247927e939 chore(trading): get started specs update (#4681) 2023-09-01 10:55:56 +01:00
Maciek 6523490d96 chore(trading): 4349 delayed telemetry opt in (#4642) 2023-09-01 09:00:20 +00:00
daro-maj 105a758e8d test(trading): add stop order oco spec (#4669) 2023-09-01 10:48:38 +02:00
60 changed files with 1058 additions and 561 deletions
-3
View File
@@ -3,6 +3,3 @@
# Lint commit messages to ensure they follow conventional commit standards
yarn commitlint --edit "${1}"
# Lint all staged files
yarn lint-staged
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Lint all staged files
yarn lint-staged
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Lint all staged files
yarn nx format:check
# Test all projects with changes
yarn nx affected -t test --exclude trading
@@ -0,0 +1,68 @@
context('Oracle page', { tags: '@smoke' }, () => {
describe('Verify elements on page', () => {
before('create market and navigate to oracle page', () => {
cy.createMarket();
cy.visit('/oracles');
});
it('should see oracle data', () => {
cy.getByTestId('oracle-details').should('have.length.at.least', 2);
cy.getByTestId('oracle-details')
.should('exist')
.eq(0)
.within(() => {
cy.get('tr')
.eq(0)
.within(() => {
cy.get('th').should('have.text', 'ID');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/oracles/');
});
cy.get('tr')
.eq(1)
.within(() => {
cy.get('th').should('have.text', 'Type');
cy.get('td').should('have.text', 'External data');
});
cy.get('tr')
.eq(2)
.within(() => {
cy.get('th').should('have.text', 'Signer');
cy.getByTestId('keytype').should('have.text', 'Vega');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/parties/');
});
cy.get('tr')
.eq(3)
.within(() => {
cy.get('th').should('have.text', 'Settlement for');
cy.get('a').invoke('text').should('have.length', 64);
cy.get('a')
.should('have.attr', 'href')
.and('contain', '/markets/');
});
cy.get('tr')
.eq(4)
.within(() => {
cy.get('th').should('have.text', 'Matched data');
cy.get('td').should('have.text', '❌');
});
cy.get('details')
.eq(0)
.within(() => {
cy.contains('Filter').click();
cy.get('.language-json').should('exist');
});
cy.get('details')
.eq(1)
.within(() => {
cy.contains('JSON').click();
cy.get('.language-json').should('exist');
});
});
});
});
});
@@ -38,7 +38,12 @@ const Oracles = () => {
const dataConnection = o?.node.dataConnection;
return (
<div id={id} key={id} className="mb-10">
<div
id={id}
key={id}
className="mb-10"
data-testid="oracle-details"
>
<OracleDetails
id={id}
dataSource={o?.node}
@@ -54,7 +54,7 @@ export const WalletCardRow = ({
}) => {
const ref = React.useRef<HTMLDivElement | null>(null);
useAnimateValue(ref, value);
const [integers, decimalsPlaces] = useNumberParts(value, decimals);
const [integers, decimalsPlaces, separator] = useNumberParts(value, decimals);
return (
<div
@@ -75,7 +75,10 @@ export const WalletCardRow = ({
className="font-mono flex-1 text-right"
data-testid="associated-amount"
>
<span>{integers}.</span>
<span>
{integers}
{separator}
</span>
<span>{decimalsPlaces}</span>
</span>
)}
@@ -110,7 +113,10 @@ export const WalletCardAsset = ({
border,
subheading,
}: WalletCardAssetProps) => {
const [integers, decimalsPlaces] = useNumberParts(balance, decimals);
const [integers, decimalsPlaces, separator] = useNumberParts(
balance,
decimals
);
return (
<div className="flex flex-nowrap mt-2 mb-4">
@@ -132,7 +138,10 @@ export const WalletCardAsset = ({
</div>
</div>
<div className="px-2 basis-full font-mono" data-testid="currency-value">
<span>{integers}.</span>
<span>
{integers}
{separator}
</span>
<span className="text-neutral-400">{decimalsPlaces}</span>
</div>
</div>
@@ -1,117 +0,0 @@
const orderbookTab = 'Orderbook';
const orderbookTable = 'tab-orderbook';
const askPrice = 'price-9894185';
const bidPrice = 'price-9889001';
const askVolume = 'ask-vol-9894185';
const bidVolume = 'bid-vol-9889001';
const askCumulative = 'cumulative-vol-9894185';
const bidCumulative = 'cumulative-vol-9889001';
const midPrice = 'last-traded-4612690000';
const priceResolution = 'resolution';
const dealTicketPrice = 'order-price';
const dealTicketSize = 'order-size';
const resPrice = 'price-990';
describe('order book', { tags: '@smoke' }, () => {
before(() => {
cy.setOnBoardingViewed();
cy.mockTradingPage();
cy.mockSubscription();
cy.visit('/#/markets/market-0');
cy.wait('@Markets');
});
beforeEach(() => {
cy.mockTradingPage();
});
it('show order book', () => {
// 6003-ORDB-001
// 6003-ORDB-002
cy.getByTestId(orderbookTab).click();
cy.getByTestId(orderbookTable).should('be.visible');
cy.getByTestId(orderbookTable).should('not.be.empty');
});
it('show orders prices', () => {
// 6003-ORDB-003
cy.getByTestId(askPrice).should('have.text', '98.94185');
cy.getByTestId(bidPrice).should('have.text', '98.89001');
});
it('show prices volumes', () => {
// 6003-ORDB-004
cy.getByTestId(askVolume).should('have.text', '1');
cy.getByTestId(bidVolume).should('have.text', '1');
});
it('show prices cumulative volumes', () => {
// 6003-ORDB-005
cy.getByTestId(askCumulative).should('have.text', '38');
cy.getByTestId(bidCumulative).should('have.text', '7');
});
it('show mid price', () => {
// 6003-ORDB-006
cy.getByTestId(midPrice).should('have.text', '46,126.90');
});
it('sort prices descending', () => {
// 6003-ORDB-007
const prices: number[] = [];
cy.getByTestId(orderbookTable).within(() => {
cy.get('[data-testid*=price]')
.each(($el) => {
prices.push(Number($el.text()));
})
.then(() => {
expect(prices).to.deep.equal(prices.sort((a, b) => b - a));
});
});
});
it('copy price to deal ticket form', () => {
// 6003-ORDB-009
cy.getByTestId(askPrice).click();
cy.getByTestId(dealTicketPrice).should('have.value', '98.94185');
});
it('copy size to deal ticket form', () => {
// 6003-ORDB-009
cy.getByTestId(bidCumulative).click();
cy.getByTestId(dealTicketSize).should('have.value', '7');
});
it('copy size to deal ticket form', () => {
// 6003-ORDB-009
cy.getByTestId(bidVolume).click();
cy.getByTestId(dealTicketSize).should('have.value', '1');
});
it('change price resolution', () => {
// 6003-ORDB-008
const resolutions = [
'0.00000',
'0.0000',
'0.000',
'0.00',
'0.0',
'0',
'10',
'100',
'1,000',
'10,000',
];
cy.getByTestId(priceResolution).click();
cy.get('[role="menu"]')
.find('[role="menuitem"]')
.each(($el, index) => {
expect($el.text()).to.equal(resolutions[index]);
});
cy.get('[role="menuitem"]').eq(4).click();
cy.getByTestId(resPrice).should('have.text', '99.0');
cy.getByTestId(askPrice).should('not.exist');
cy.getByTestId(bidPrice).should('not.exist');
});
});
@@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/i18n';
import { Intent, TradingButton } from '@vegaprotocol/ui-toolkit';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { ViewType, useSidebar } from '../sidebar';
export const AccountsMenu = () => {
@@ -8,7 +8,6 @@ export const AccountsMenu = () => {
return (
<>
<TradingButton
intent={Intent.Primary}
size="extra-small"
data-testid="open-transfer"
onClick={() => setView({ type: ViewType.Transfer })}
@@ -16,7 +15,6 @@ export const AccountsMenu = () => {
{t('Transfer')}
</TradingButton>
<TradingButton
intent={Intent.Primary}
size="extra-small"
onClick={() => setView({ type: ViewType.Deposit })}
>
@@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/i18n';
import { Intent, TradingButton } from '@vegaprotocol/ui-toolkit';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { ViewType, useSidebar } from '../sidebar';
export const DepositsMenu = () => {
@@ -7,7 +7,6 @@ export const DepositsMenu = () => {
return (
<TradingButton
intent={Intent.Primary}
size="extra-small"
onClick={() => setView({ type: ViewType.Deposit })}
data-testid="deposit-button"
@@ -1,13 +1,12 @@
import { t } from '@vegaprotocol/i18n';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuTrigger,
VegaIcon,
VegaIconNames,
TradingDropdown,
TradingDropdownCheckboxItem,
TradingDropdownContent,
TradingDropdownItemIndicator,
TradingDropdownTrigger,
} from '@vegaprotocol/ui-toolkit';
import { MarketSelectorButton } from './market-selector-button';
type Assets = Array<{ id: string; symbol: string }>;
@@ -25,17 +24,19 @@ export const AssetDropdown = ({
}
return (
<DropdownMenu
<TradingDropdown
trigger={
<DropdownMenuTrigger data-testid="asset-trigger">
<TriggerText assets={assets} checkedAssets={checkedAssets} />
</DropdownMenuTrigger>
<TradingDropdownTrigger data-testid="asset-trigger">
<MarketSelectorButton>
{triggerText({ assets, checkedAssets })}
</MarketSelectorButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent>
<TradingDropdownContent>
{assets?.map((a) => {
return (
<DropdownMenuCheckboxItem
<TradingDropdownCheckboxItem
key={a.id}
checked={checkedAssets.includes(a.id)}
onCheckedChange={(checked) => {
@@ -46,16 +47,16 @@ export const AssetDropdown = ({
data-testid={`asset-id-${a.id}`}
>
{a.symbol}
<DropdownMenuItemIndicator />
</DropdownMenuCheckboxItem>
<TradingDropdownItemIndicator />
</TradingDropdownCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</TradingDropdownContent>
</TradingDropdown>
);
};
const TriggerText = ({
const triggerText = ({
assets,
checkedAssets,
}: {
@@ -72,9 +73,5 @@ const TriggerText = ({
text = t(`${checkedAssets.length} Assets`);
}
return (
<span className="flex justify-between items-center">
{text} <VegaIcon name={VegaIconNames.CHEVRON_DOWN} />
</span>
);
return text;
};
@@ -1,2 +1,3 @@
export * from './market-selector';
export * from './market-selector-item';
export * from './market-selector-button';
@@ -0,0 +1,23 @@
import { VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import type { ButtonHTMLAttributes } from 'react';
import { forwardRef } from 'react';
export const MarketSelectorButton = forwardRef<
HTMLButtonElement,
ButtonHTMLAttributes<HTMLButtonElement>
>((props, ref) => (
<button
{...props}
className={classNames(
'flex items-center justify-between px-2 border rounded gap-1',
'border-vega-clight-600 dark:border-vega-cdark-600 bg-vega-clight-700 dark:bg-vega-cdark-700',
'text-secondary data-[state=open]:text-vega-clight-50 dark:data-[state=open]:text-vega-cdark-50'
)}
ref={ref}
>
{props.children}
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} />
</button>
));
MarketSelectorButton.displayName = 'MarketSelectorButton';
@@ -31,7 +31,7 @@ export const MarketSelectorItem = ({
<div style={style} role="row">
<Link
to={`/markets/${market.id}`}
className={classNames('h-full flex items-center gap-2 px-4', {
className={classNames('h-full flex items-center gap-2 mx-2 px-2', {
'hover:bg-vega-clight-700 dark:hover:bg-vega-cdark-700':
market.id !== currentMarketId,
'bg-vega-clight-600 dark:bg-vega-cdark-600':
@@ -94,7 +94,7 @@ const MarketData = ({
return (
<>
<div className="w-2/5" role="gridcell">
<h3 className="text-ellipsis text-sm lg:text-base whitespace-nowrap overflow-hidden">
<h3 className="overflow-hidden text-sm text-ellipsis lg:text-base whitespace-nowrap">
{market.tradableInstrument.instrument.code}{' '}
{allProducts && productType && (
<MarketProductPill productType={productType} />
@@ -107,7 +107,7 @@ const MarketData = ({
)}
</div>
<div
className="w-1/5 text-xs lg:text-sm whitespace-nowrap text-ellipsis overflow-hidden"
className="w-1/5 overflow-hidden text-xs lg:text-sm whitespace-nowrap text-ellipsis"
title={instrument.product.settlementAsset.symbol}
data-testid="market-selector-price"
role="gridcell"
@@ -115,14 +115,14 @@ const MarketData = ({
{price} {instrument.product.settlementAsset.symbol}
</div>
<div
className="w-1/5 text-xs lg:text-sm text-right whitespace-nowrap text-ellipsis overflow-hidden"
className="w-1/5 overflow-hidden text-xs text-right lg:text-sm whitespace-nowrap text-ellipsis"
title={t('24h vol')}
data-testid="market-selector-volume"
role="gridcell"
>
{volume}
</div>
<div className="w-1/5 flex justify-end" role="gridcell">
<div className="flex justify-end w-1/5" role="gridcell">
{oneDayCandles && (
<Sparkline
width={64}
@@ -262,9 +262,7 @@ describe('MarketSelector', () => {
await userEvent.click(screen.getByTestId('sort-trigger'));
const options = screen.getAllByTestId(/sort-item/);
expect(options.map((o) => o.textContent?.trim())).toEqual(
Object.entries(Sort)
.filter(([key]) => key !== Sort.None)
.map(([key]) => SortTypeMapping[key as SortType])
Object.entries(Sort).map(([key]) => SortTypeMapping[key as SortType])
);
await userEvent.click(screen.getByTestId('sort-item-Gained'));
expect(
@@ -40,7 +40,7 @@ export const MarketSelector = ({
const [filter, setFilter] = useState<Filter>({
searchTerm: '',
product: Product.All,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
const allProducts = filter.product === Product.All;
@@ -52,8 +52,8 @@ export const MarketSelector = ({
}, [reload]);
return (
<div data-testid="market-selector">
<div className="pt-2 px-2 mb-2">
<div data-testid="market-selector" className="md:w-[580px]">
<div className="px-2 pt-2 mb-2">
<ProductSelector
product={filter.product}
onSelect={(product) => {
@@ -106,9 +106,6 @@ export const MarketSelector = ({
currentSort={filter.sort}
onSelect={(sort) => {
setFilter((curr) => {
if (curr.sort === sort) {
return { ...curr, sort: Sort.None };
}
return {
...curr,
sort,
@@ -294,9 +291,9 @@ const List = ({
const Skeleton = () => {
return (
<div className="mb-2 px-2">
<div className="bg-vega-light-100 dark:bg-vega-dark-100 rounded-lg p-4">
<div className="w-full h-3 bg-vega-light-200 dark:bg-vega-dark-200 mb-2" />
<div className="px-2 mb-2">
<div className="p-4 rounded-lg bg-vega-light-100 dark:bg-vega-dark-100">
<div className="w-full h-3 mb-2 bg-vega-light-200 dark:bg-vega-dark-200" />
<div className="w-2/3 h-3 bg-vega-light-200 dark:bg-vega-dark-200" />
</div>
</div>
@@ -33,11 +33,14 @@ export const ProductSelector = ({
return (
<div className="flex mb-2">
{Object.keys(Product).map((t) => {
const classes = classNames('px-3 py-1.5 rounded', {
'bg-vega-clight-500 dark:bg-vega-cdark-500 text-default':
t === product,
'text-secondary': t !== product,
});
const classes = classNames(
'text-sm px-3 py-1.5 rounded hover:text-vega-clight-50 dark:hover:text-vega-cdark-50',
{
'bg-vega-clight-500 dark:bg-vega-cdark-500 text-default':
t === product,
'text-secondary': t !== product,
}
);
return (
<button
key={t}
@@ -53,7 +56,7 @@ export const ProductSelector = ({
})}
<Link
to={Routes.MARKETS}
className="flex items-center gap-2 ml-auto"
className="flex items-center ml-auto text-sm gap-2"
title={t('See all markets')}
>
<span className="underline underline-offset-4">{t('Browse')}</span>
@@ -1,17 +1,16 @@
import { t } from '@vegaprotocol/i18n';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
TradingDropdown,
TradingDropdownContent,
TradingDropdownItemIndicator,
TradingDropdownRadioGroup,
TradingDropdownRadioItem,
TradingDropdownTrigger,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { MarketSelectorButton } from './market-selector-button';
export const Sort = {
None: 'None',
Gained: 'Gained',
Lost: 'Lost',
New: 'New',
@@ -23,17 +22,15 @@ export type SortType = keyof typeof Sort;
export const SortTypeMapping: {
[key in SortType]: string;
} = {
[Sort.None]: 'None',
[Sort.TopTraded]: 'Top traded',
[Sort.Gained]: 'Top gaining',
[Sort.Lost]: 'Top losing',
[Sort.New]: 'New markets',
[Sort.TopTraded]: 'Top traded',
};
const SortIconMapping: {
[key in SortType]: VegaIconNames;
} = {
[Sort.None]: null as unknown as VegaIconNames, // not shown in list
[Sort.Gained]: VegaIconNames.TREND_UP,
[Sort.Lost]: VegaIconNames.TREND_DOWN,
[Sort.New]: VegaIconNames.STAR,
@@ -48,43 +45,38 @@ export const SortDropdown = ({
onSelect: (sort: SortType) => void;
}) => {
return (
<DropdownMenu
<TradingDropdown
trigger={
<DropdownMenuTrigger data-testid="sort-trigger">
<span className="flex justify-between items-center">
{currentSort === SortTypeMapping.None
? t('Sort')
: SortTypeMapping[currentSort]}{' '}
<VegaIcon name={VegaIconNames.CHEVRON_DOWN} />
</span>
</DropdownMenuTrigger>
<TradingDropdownTrigger data-testid="sort-trigger">
<MarketSelectorButton>
{SortTypeMapping[currentSort]}
</MarketSelectorButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent>
<DropdownMenuRadioGroup
<TradingDropdownContent>
<TradingDropdownRadioGroup
value={currentSort}
onValueChange={(value) => onSelect(value as SortType)}
>
{Object.keys(Sort)
.filter((s) => s !== Sort.None)
.map((key) => {
return (
<DropdownMenuRadioItem
inset
key={key}
value={key}
data-testid={`sort-item-${key}`}
>
<span className="flex gap-2">
<VegaIcon name={SortIconMapping[key as SortType]} />{' '}
{SortTypeMapping[key as SortType]}
</span>
<DropdownMenuItemIndicator />
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{Object.keys(Sort).map((key) => {
return (
<TradingDropdownRadioItem
inset
key={key}
value={key}
data-testid={`sort-item-${key}`}
>
<span className="flex gap-2">
<VegaIcon name={SortIconMapping[key as SortType]} />{' '}
{SortTypeMapping[key as SortType]}
</span>
<TradingDropdownItemIndicator />
</TradingDropdownRadioItem>
);
})}
</TradingDropdownRadioGroup>
</TradingDropdownContent>
</TradingDropdown>
);
};
@@ -12,7 +12,10 @@ import { useMarketList } from '@vegaprotocol/markets';
import type { Filter } from '../../components/market-selector';
import { subDays } from 'date-fns';
jest.mock('@vegaprotocol/markets');
jest.mock('@vegaprotocol/markets', () => ({
...jest.requireActual('@vegaprotocol/markets'),
useMarketList: jest.fn(),
}));
const mockUseMarketList = useMarketList as jest.Mock;
describe('useMarketSelectorList', () => {
@@ -20,7 +23,7 @@ describe('useMarketSelectorList', () => {
const defaultArgs: Filter = {
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
};
return renderHook((args) => useMarketSelectorList(args), {
@@ -109,21 +112,21 @@ describe('useMarketSelectorList', () => {
rerender({
searchTerm: '',
product: Product.Spot as 'Future',
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[1]]);
rerender({
searchTerm: '',
product: Product.Perpetual as 'Future',
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[2]]);
rerender({
searchTerm: '',
product: Product.All,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual(markets);
@@ -189,7 +192,7 @@ describe('useMarketSelectorList', () => {
const { result, rerender } = setup({
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: ['asset-0'],
});
expect(result.current.markets).toEqual([markets[0], markets[1]]);
@@ -197,7 +200,7 @@ describe('useMarketSelectorList', () => {
rerender({
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: ['asset-0', 'asset-1'],
});
@@ -210,7 +213,7 @@ describe('useMarketSelectorList', () => {
rerender({
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: ['asset-0', 'asset-1', 'asset-2'],
});
@@ -220,7 +223,7 @@ describe('useMarketSelectorList', () => {
rerender({
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: ['asset-invalid'],
});
@@ -275,28 +278,28 @@ describe('useMarketSelectorList', () => {
const { result, rerender } = setup({
searchTerm: 'abc',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[0]]);
rerender({
searchTerm: 'def',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[1], markets[2]]);
rerender({
searchTerm: 'defg',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[2]]);
rerender({
searchTerm: 'zzz',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([]);
@@ -305,14 +308,14 @@ describe('useMarketSelectorList', () => {
rerender({
searchTerm: 'aaa',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([markets[0]]);
rerender({
searchTerm: 'ggg',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([
@@ -322,11 +325,15 @@ describe('useMarketSelectorList', () => {
]);
});
it('sorts by state and volume by default', () => {
it('sorts by top traded by default', () => {
const markets = [
createMarketFragment({
id: 'market-0',
state: MarketState.STATE_PENDING,
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
markPrice: '1',
},
// @ts-ignore candles not on fragment
candles: [
{
@@ -337,30 +344,42 @@ describe('useMarketSelectorList', () => {
createMarketFragment({
id: 'market-1',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
markPrice: '1',
},
// @ts-ignore candles not on fragment
candles: [
{
volume: '200',
volume: '100',
},
],
}),
createMarketFragment({
id: 'market-2',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
markPrice: '1',
},
// @ts-ignore candles not on fragment
candles: [
{
volume: '100',
volume: '300',
},
],
}),
createMarketFragment({
state: MarketState.STATE_PENDING,
id: 'market-3',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
markPrice: '1',
},
// @ts-ignore candles not on fragment
candles: [
{
volume: '100',
volume: '400',
},
],
}),
@@ -375,14 +394,15 @@ describe('useMarketSelectorList', () => {
const { result } = setup({
searchTerm: '',
product: Product.Future,
sort: Sort.None,
sort: Sort.TopTraded,
assets: [],
});
expect(result.current.markets).toEqual([
markets[1],
markets[3],
markets[2],
markets[0],
markets[3],
markets[1],
]);
});
@@ -1,11 +1,7 @@
import { useMemo } from 'react';
import orderBy from 'lodash/orderBy';
import { MarketState } from '@vegaprotocol/types';
import {
calcCandleVolume,
calcTradedFactor,
useMarketList,
} from '@vegaprotocol/markets';
import { calcTradedFactor, useMarketList } from '@vegaprotocol/markets';
import { priceChangePercentage } from '@vegaprotocol/utils';
import type { Filter } from '../../components/market-selector/market-selector';
import { Sort } from './sort-dropdown';
@@ -60,22 +56,6 @@ export const useMarketSelectorList = ({
return false;
});
if (sort === Sort.None) {
// Sort by market state primarily and AtoZ secondarily
return orderBy(
markets,
[
(m) => MARKET_TEMPLATE.indexOf(m.state),
(m) => {
if (!m.candles?.length) return 0;
const vol = calcCandleVolume(m.candles);
return Number(vol || 0);
},
],
['asc', 'desc']
);
}
if (sort === Sort.Gained || sort === Sort.Lost) {
const dir = sort === Sort.Gained ? 'desc' : 'asc';
return orderBy(
@@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/i18n';
import { Intent, TradingButton } from '@vegaprotocol/ui-toolkit';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { usePositionsStore } from '../positions-container';
export const PositionsMenu = () => {
@@ -7,7 +7,6 @@ export const PositionsMenu = () => {
const toggle = usePositionsStore((store) => store.toggleClosedMarkets);
return (
<TradingButton
intent={Intent.Primary}
size="extra-small"
data-testid="open-transfer"
onClick={toggle}
@@ -24,8 +24,8 @@ export const Settings = () => {
>
<Switch
name="settings-telemetry-switch"
onCheckedChange={(isOn) => setIsApproved(isOn)}
checked={isApproved}
onCheckedChange={(isOn) => setIsApproved(isOn ? 'true' : 'false')}
checked={isApproved === 'true'}
/>
</SettingsGroup>
<SettingsGroup label={t('Toast location')}>
@@ -23,6 +23,7 @@ import { Links, Routes } from '../../pages/client-router';
import { useGlobalStore } from '../../stores';
import { useSidebar, ViewType } from '../sidebar';
import * as constants from '../constants';
import { useOnboardingStore } from './welcome-dialog';
interface Props {
lead?: string;
@@ -35,7 +36,7 @@ const GetStartedButton = ({ step }: { step: OnboardingStep }) => {
constants.ONBOARDING_VIEWED_KEY
);
const update = useGlobalStore((store) => store.update);
const dismiss = useOnboardingStore((store) => store.dismiss);
const marketId = useGlobalStore((store) => store.marketId);
const link = marketId ? Links[Routes.MARKET](marketId) : Links[Routes.HOME]();
const openVegaWalletDialog = useVegaWalletDialogStore(
@@ -61,7 +62,7 @@ const GetStartedButton = ({ step }: { step: OnboardingStep }) => {
onClickHandle = () => {
navigate(link);
setView({ type: ViewType.Deposit });
update({ onBoardingDismissed: true });
dismiss();
};
} else if (step === OnboardingStep.ONBOARDING_ORDER_STEP) {
buttonText = t('Dismiss');
@@ -2,29 +2,35 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TelemetryApproval } from './telemetry-approval';
jest.mock('@vegaprotocol/logger', () => ({
SentryInit: () => undefined,
SentryClose: () => undefined,
}));
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({ VEGA_ENV: 'test', SENTRY_DSN: 'sentry-dsn' }),
}));
describe('TelemetryApproval', () => {
it('click on checkbox should be properly handled', async () => {
const helpText = 'My help text';
render(<TelemetryApproval helpText={helpText} />);
expect(screen.getByRole('checkbox')).toHaveAttribute(
'data-state',
'unchecked'
it('click on buttons should be properly handled', async () => {
const mockSetTelemetryValue = jest.fn();
render(
<TelemetryApproval
telemetryValue="false"
setTelemetryValue={mockSetTelemetryValue}
/>
);
await userEvent.click(screen.getByRole('checkbox'));
expect(screen.getByRole('checkbox')).toHaveAttribute(
'data-state',
'checked'
expect(
screen.getByRole('button', { name: 'No thanks' })
).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'No thanks' }));
expect(mockSetTelemetryValue).toHaveBeenCalledWith('false');
expect(screen.getByText('Share data')).toBeInTheDocument();
await userEvent.click(screen.getByText('Share data'));
expect(mockSetTelemetryValue).toHaveBeenCalledWith('true');
});
it('confirm button should have proper text', async () => {
const mockSetTelemetryValue = jest.fn();
render(
<TelemetryApproval
telemetryValue="true"
setTelemetryValue={mockSetTelemetryValue}
/>
);
expect(screen.getByText('Share usage data')).toBeInTheDocument();
expect(screen.getByText(helpText)).toBeInTheDocument();
expect(screen.getByText('Continue sharing data')).toBeInTheDocument();
await userEvent.click(screen.getByText('Continue sharing data'));
expect(mockSetTelemetryValue).toHaveBeenCalledWith('true');
});
});
@@ -1,21 +1,66 @@
import { TradingCheckbox } from '@vegaprotocol/ui-toolkit';
import {
Intent,
TradingButton,
VegaIcon,
VegaIconNames,
} from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
import { useTelemetryApproval } from '../../lib/hooks/use-telemetry-approval';
export const TelemetryApproval = ({ helpText }: { helpText: string }) => {
const [isApproved, setIsApproved] = useTelemetryApproval();
interface Props {
telemetryValue: string;
setTelemetryValue: (value: string) => void;
}
export const TelemetryApproval = ({
telemetryValue,
setTelemetryValue,
}: Props) => {
return (
<div className="flex flex-col py-3">
<div className="mr-4" role="form">
<TradingCheckbox
label={<span className="text-lg pl-1">{t('Share usage data')}</span>}
checked={isApproved}
name="telemetry-approval"
onCheckedChange={() => setIsApproved(!isApproved)}
/>
</div>
<div className="text-sm text-vega-light-300 dark:text-vega-dark-300 ml-6">
<span>{helpText}</span>
<div className="mt-2">
{t(
'Help us identify bugs and improve Vega Governance by sharing anonymous usage data.'
)}
</div>
<div className="flex items-center mt-2">
<VegaIcon name={VegaIconNames.EYE_OFF} size={24} />
<div className="flex flex-col gap-1 ml-6">
<div className="font-semibold">{t('Anonymous')}</div>
<div>{t('Your identity is always anonymous on Vega')}</div>
</div>
</div>
<div className="flex items-center mt-2">
<VegaIcon name={VegaIconNames.COG} size={24} />
<div className="flex flex-col gap-1 ml-6">
<div className="font-semibold">{t('Optional')}</div>
<div>{t('You can opt out any time via settings')}</div>
</div>
</div>
<div className="flex flex-col justify-around items-center mt-10 gap-4 w-full px-4">
<TradingButton
onClick={() => setTelemetryValue('false')}
size="small"
intent={Intent.None}
data-testid="do-not-share-data-button"
fill
>
{t('No thanks')}
</TradingButton>
<TradingButton
onClick={() => setTelemetryValue('true')}
intent={Intent.Info}
data-testid="share-data-button"
size="small"
fill
>
{telemetryValue === 'true'
? t('Continue sharing data')
: t('Share data')}
</TradingButton>
</div>
</div>
</div>
);
@@ -5,17 +5,17 @@ import { useNavigate } from 'react-router-dom';
import { Links, Routes } from '../../pages/client-router';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import type { ReactNode } from 'react';
import { useGlobalStore } from '../../stores';
import { useOnboardingStore } from './welcome-dialog';
export const WelcomeDialogContent = () => {
const { VEGA_ENV } = useEnvironment();
const update = useGlobalStore((store) => store.update);
const dismiss = useOnboardingStore((store) => store.dismiss);
const navigate = useNavigate();
const browseMarkets = () => {
const link = Links[Routes.MARKETS]();
navigate(link);
update({ onBoardingDismissed: true });
dismiss();
};
const lead =
VEGA_ENV === Networks.MAINNET
@@ -1,7 +1,9 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Dialog, Intent } from '@vegaprotocol/ui-toolkit';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { Dialog, Intent, useToasts } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useEnvironment } from '@vegaprotocol/environment';
import { useLocalStorage } from '@vegaprotocol/react-helpers';
import { WelcomeDialogContent } from './welcome-dialog-content';
@@ -12,15 +14,41 @@ import {
OnboardingStep,
} from './use-get-onboarding-step';
import * as constants from '../constants';
import { TelemetryApproval } from './telemetry-approval';
import { useTelemetryApproval } from '../../lib/hooks/use-telemetry-approval';
import { useCallback } from 'react';
const ONBOARDING_STORAGE_KEY = 'vega_onboarding_dismiss_store';
export const useOnboardingStore = create<{
dismissed: boolean;
dismiss: () => void;
}>()(
persist(
(set) => ({
dismissed: false,
dismiss: () => set(() => ({ dismissed: true })),
}),
{
name: ONBOARDING_STORAGE_KEY,
}
)
);
const TELEMETRY_APPROVAL_TOAST_ID = 'telemetry_tost_id';
export const WelcomeDialog = () => {
const { VEGA_ENV } = useEnvironment();
const [onBoardingViewed] = useLocalStorage(constants.ONBOARDING_VIEWED_KEY);
const update = useGlobalStore((store) => store.update);
const dismissed = useGlobalStore((store) => store.onBoardingDismissed);
const currentStep = useGetOnboardingStep();
const navigate = useNavigate();
const [telemetryValue, setTelemetryValue, isTelemetryNeeded, closeTelemetry] =
useTelemetryApproval();
const [onBoardingViewed] = useLocalStorage(constants.ONBOARDING_VIEWED_KEY);
const dismiss = useOnboardingStore((store) => store.dismiss);
const dismissed = useOnboardingStore((store) => store.dismissed);
const currentStep = useGetOnboardingStep();
const isTelemetryPopupNeeded =
isTelemetryNeeded &&
(onBoardingViewed === 'true' ||
currentStep > OnboardingStep.ONBOARDING_ORDER_STEP);
const isOnboardingDialogNeeded =
onBoardingViewed !== 'true' &&
currentStep &&
@@ -29,12 +57,58 @@ export const WelcomeDialog = () => {
const marketId = useGlobalStore((store) => store.marketId);
const onClose = () => {
const link = marketId
? Links[Routes.MARKET](marketId)
: Links[Routes.HOME]();
navigate(link);
update({ onBoardingDismissed: true });
if (isTelemetryPopupNeeded) {
closeTelemetry();
} else {
const link = marketId
? Links[Routes.MARKET](marketId)
: Links[Routes.HOME]();
navigate(link);
dismiss();
}
};
const [setToast, hasToast, removeToast] = useToasts((store) => [
store.setToast,
store.hasToast,
store.remove,
]);
const onApprovalClose = useCallback(() => {
closeTelemetry();
removeToast(TELEMETRY_APPROVAL_TOAST_ID);
}, [removeToast, closeTelemetry]);
const setTelemetryApprovalAndClose = useCallback(
(value: string) => {
setTelemetryValue(value);
onApprovalClose();
},
[setTelemetryValue, onApprovalClose]
);
if (isTelemetryPopupNeeded) {
const toast: Toast = {
id: TELEMETRY_APPROVAL_TOAST_ID,
intent: Intent.Primary,
content: (
<>
<h3 className="text-sm uppercase mb-1">
{t('Improve vega console')}
</h3>
<TelemetryApproval
telemetryValue={telemetryValue}
setTelemetryValue={setTelemetryApprovalAndClose}
/>
</>
),
onClose: onApprovalClose,
};
if (!hasToast(TELEMETRY_APPROVAL_TOAST_ID)) {
setToast(toast);
}
return;
}
const title = (
<span className="font-alpha calt" data-testid="welcome-title">
{t('Console')}{' '}
@@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/i18n';
import { Intent, TradingButton } from '@vegaprotocol/ui-toolkit';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { ViewType, useSidebar } from '../sidebar';
export const WithdrawalsMenu = () => {
@@ -7,7 +7,6 @@ export const WithdrawalsMenu = () => {
return (
<TradingButton
intent={Intent.Primary}
size="extra-small"
onClick={() => setView({ type: ViewType.Withdraw })}
data-testid="withdraw-dialog-button"
@@ -1,19 +1,40 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useLocalStorage } from '@vegaprotocol/react-helpers';
import { SentryInit, SentryClose } from '@vegaprotocol/logger';
import { STORAGE_KEY, useTelemetryApproval } from './use-telemetry-approval';
import {
STORAGE_KEY,
STORAGE_SECOND_KEY,
useTelemetryApproval,
} from './use-telemetry-approval';
import { Networks } from '@vegaprotocol/environment';
const mockSetValue = jest.fn();
const mockRemoveValue = jest.fn();
let mockStorageHookApprovalResult: [string | null, jest.Mock] = [
null,
mockSetValue,
];
const mockSetSecondValue = jest.fn();
let mockStorageHookViewedResult: [string | null, jest.Mock] = [
null,
mockSetSecondValue,
];
jest.mock('@vegaprotocol/logger');
jest.mock('@vegaprotocol/react-helpers', () => ({
...jest.requireActual('@vegaprotocol/react-helpers'),
useLocalStorage: jest
.fn()
.mockImplementation(() => [false, mockSetValue, mockRemoveValue]),
useLocalStorage: jest.fn((key: string) => {
if (key === 'vega_telemetry_approval') {
return mockStorageHookApprovalResult;
}
return mockStorageHookViewedResult;
}),
}));
let mockVegaEnv = 'test';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({ VEGA_ENV: 'test', SENTRY_DSN: 'sentry-dsn' }),
...jest.requireActual('@vegaprotocol/environment'),
useEnvironment: jest.fn(() => ({
VEGA_ENV: mockVegaEnv,
SENTRY_DSN: 'sentry-dsn',
})),
}));
describe('useTelemetryApproval', () => {
@@ -21,32 +42,71 @@ describe('useTelemetryApproval', () => {
jest.clearAllMocks();
});
it('hook should return proper array', () => {
it('when empty hook should return proper array', () => {
const { result } = renderHook(() => useTelemetryApproval());
expect(result.current[0]).toEqual(false);
expect(result.current[0]).toEqual('');
expect(result.current[1]).toEqual(expect.any(Function));
expect(result.current[2]).toEqual(true);
expect(result.current[3]).toEqual(expect.any(Function));
expect(useLocalStorage).toHaveBeenCalledWith(STORAGE_KEY);
expect(useLocalStorage).toHaveBeenCalledWith(STORAGE_SECOND_KEY);
expect(mockSetValue).toHaveBeenCalledWith('true');
expect(mockSetSecondValue).not.toHaveBeenCalledWith('true');
});
it('when approval not empty but viewed is empty should return proper array', () => {
mockStorageHookApprovalResult = ['false', mockSetValue];
const { result } = renderHook(() => useTelemetryApproval());
expect(result.current[0]).toEqual('false');
expect(result.current[1]).toEqual(expect.any(Function));
expect(result.current[2]).toEqual(true);
expect(result.current[3]).toEqual(expect.any(Function));
expect(useLocalStorage).toHaveBeenCalledWith(STORAGE_KEY);
expect(useLocalStorage).toHaveBeenCalledWith(STORAGE_SECOND_KEY);
expect(mockSetValue).not.toHaveBeenCalled();
expect(mockSetSecondValue).not.toHaveBeenCalled();
});
it('when NOT empty hook should return proper array', () => {
mockStorageHookApprovalResult = ['false', mockSetValue];
mockStorageHookViewedResult = ['true', mockSetSecondValue];
const { result } = renderHook(() => useTelemetryApproval());
expect(result.current[0]).toEqual('false');
expect(result.current[1]).toEqual(expect.any(Function));
expect(result.current[2]).toEqual(false);
expect(result.current[3]).toEqual(expect.any(Function));
expect(useLocalStorage).toHaveBeenCalledWith(STORAGE_KEY);
expect(mockSetValue).not.toHaveBeenCalled();
});
it('on mainnet hook should init properly', () => {
mockStorageHookApprovalResult = [null, mockSetValue];
mockVegaEnv = Networks.MAINNET;
renderHook(() => useTelemetryApproval());
expect(mockSetValue).toHaveBeenCalledWith('false');
});
it('hook should init stuff properly', async () => {
const { result } = renderHook(() => useTelemetryApproval());
await act(() => {
result.current[1](true);
result.current[1]('true');
});
await waitFor(() => {
expect(SentryInit).toHaveBeenCalled();
expect(mockSetValue).toHaveBeenCalledWith('1');
expect(mockSetValue).toHaveBeenCalledWith('true');
expect(mockSetSecondValue).toHaveBeenCalledWith('true');
});
});
it('hook should close stuff properly', async () => {
const { result } = renderHook(() => useTelemetryApproval());
await act(() => {
result.current[1](false);
result.current[1]('false');
});
await waitFor(() => {
expect(SentryClose).toHaveBeenCalled();
expect(mockRemoveValue).toHaveBeenCalledWith();
expect(mockSetValue).toHaveBeenCalledWith('false');
expect(mockSetSecondValue).toHaveBeenCalledWith('true');
});
});
});
@@ -1,25 +1,51 @@
import { useLocalStorage } from '@vegaprotocol/react-helpers';
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { SentryInit, SentryClose } from '@vegaprotocol/logger';
import { useEnvironment } from '@vegaprotocol/environment';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
export const STORAGE_KEY = 'vega_telemetry_approval';
export const STORAGE_SECOND_KEY = 'vega_telemetry_viewed';
export const useTelemetryApproval = (): [
value: boolean,
setValue: (value: boolean) => void
value: string,
setValue: (value: string) => void,
shouldOpen: boolean,
close: () => void
] => {
const { VEGA_ENV, SENTRY_DSN } = useEnvironment();
const [value, setValue, removeValue] = useLocalStorage(STORAGE_KEY);
const setApprove = useCallback(
(value: boolean) => {
if (value && SENTRY_DSN) {
const defaultTelemetryValue =
VEGA_ENV === Networks.MAINNET ? 'false' : 'true';
const [value, setValue] = useLocalStorage(STORAGE_KEY);
const [viewedValue, setViewedValue] = useLocalStorage(STORAGE_SECOND_KEY);
const [shouldOpen, setShouldOpen] = useState(!value || !viewedValue);
const close = useCallback(() => {
setShouldOpen(false);
setViewedValue('true');
}, [setViewedValue]);
const manageValue = useCallback(
(value: string) => {
if (value === 'true' && SENTRY_DSN) {
SentryInit(SENTRY_DSN, VEGA_ENV);
return setValue('1');
return setValue('true');
}
SentryClose();
removeValue();
setValue('false');
},
[setValue, removeValue, SENTRY_DSN, VEGA_ENV]
[setValue, SENTRY_DSN, VEGA_ENV]
);
return [Boolean(value), setApprove];
const setTelemetryValue = useCallback(
(value: string) => {
setShouldOpen(false);
setViewedValue('true');
manageValue(value);
},
[manageValue, setViewedValue]
);
useEffect(() => {
if (!value) {
manageValue(defaultTelemetryValue);
}
}, [value, manageValue, defaultTelemetryValue]);
return [value || '', setTelemetryValue, shouldOpen, close];
};
-2
View File
@@ -4,7 +4,6 @@ import produce from 'immer';
interface GlobalStore {
marketId: string | null;
onBoardingDismissed: boolean;
eagerConnecting: boolean;
update: (store: Partial<Omit<GlobalStore, 'update'>>) => void;
}
@@ -16,7 +15,6 @@ interface PageTitleStore {
export const useGlobalStore = create<GlobalStore>()((set) => ({
marketId: LocalStorage.getItem('marketId') || null,
onBoardingDismissed: false,
eagerConnecting: false,
update: (newState) => {
set(
@@ -7,8 +7,8 @@ describe('CandlesMenu', () => {
render(<CandlesMenu />);
await userEvent.click(
screen.getByText('Studies', {
selector: '[type="button"]',
screen.getByRole('button', {
name: 'Studies',
})
);
expect(await screen.findByRole('menu')).toBeInTheDocument();
+61 -51
View File
@@ -10,13 +10,14 @@ import {
studyLabels,
} from 'pennant';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
TradingButton,
TradingDropdown,
TradingDropdownCheckboxItem,
TradingDropdownContent,
TradingDropdownItemIndicator,
TradingDropdownRadioGroup,
TradingDropdownRadioItem,
TradingDropdownTrigger,
Icon,
} from '@vegaprotocol/ui-toolkit';
import type { IconName } from '@blueprintjs/icons';
@@ -44,69 +45,76 @@ export const CandlesMenu = () => {
} = useCandlesChartSettings();
const triggerClasses = 'text-xs';
const contentAlign = 'end';
const triggerButtonProps = { size: 'extra-small' } as const;
return (
<>
<DropdownMenu
<TradingDropdown
trigger={
<DropdownMenuTrigger className={triggerClasses}>
{t(`Interval: ${intervalLabels[interval]}`)}
</DropdownMenuTrigger>
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
{t(`Interval: ${intervalLabels[interval]}`)}
</TradingButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent align={contentAlign}>
<DropdownMenuRadioGroup
<TradingDropdownContent align={contentAlign}>
<TradingDropdownRadioGroup
value={interval}
onValueChange={(value) => {
setInterval(value as Interval);
}}
>
{Object.values(Interval).map((timeInterval) => (
<DropdownMenuRadioItem
<TradingDropdownRadioItem
key={timeInterval}
inset
value={timeInterval}
>
{intervalLabels[timeInterval]}
<DropdownMenuItemIndicator />
</DropdownMenuRadioItem>
<TradingDropdownItemIndicator />
</TradingDropdownRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
</TradingDropdownRadioGroup>
</TradingDropdownContent>
</TradingDropdown>
<TradingDropdown
trigger={
<DropdownMenuTrigger className={triggerClasses}>
<Icon name={chartTypeIcon.get(chartType) as IconName} />
</DropdownMenuTrigger>
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
<Icon name={chartTypeIcon.get(chartType) as IconName} />
</TradingButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent align={contentAlign}>
<DropdownMenuRadioGroup
<TradingDropdownContent align={contentAlign}>
<TradingDropdownRadioGroup
value={chartType}
onValueChange={(value) => {
setType(value as ChartType);
}}
>
{Object.values(ChartType).map((type) => (
<DropdownMenuRadioItem key={type} inset value={type}>
<TradingDropdownRadioItem key={type} inset value={type}>
{chartTypeLabels[type]}
<DropdownMenuItemIndicator />
</DropdownMenuRadioItem>
<TradingDropdownItemIndicator />
</TradingDropdownRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
</TradingDropdownRadioGroup>
</TradingDropdownContent>
</TradingDropdown>
<TradingDropdown
trigger={
<DropdownMenuTrigger className={triggerClasses}>
{t('Overlays')}
</DropdownMenuTrigger>
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
{t('Overlays')}
</TradingButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent align={contentAlign}>
<TradingDropdownContent align={contentAlign}>
{Object.values(Overlay).map((overlay) => (
<DropdownMenuCheckboxItem
<TradingDropdownCheckboxItem
key={overlay}
checked={overlays.includes(overlay)}
onCheckedChange={() => {
@@ -121,21 +129,23 @@ export const CandlesMenu = () => {
}}
>
{overlayLabels[overlay]}
<DropdownMenuItemIndicator />
</DropdownMenuCheckboxItem>
<TradingDropdownItemIndicator />
</TradingDropdownCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
</TradingDropdownContent>
</TradingDropdown>
<TradingDropdown
trigger={
<DropdownMenuTrigger className={triggerClasses}>
{t('Studies')}
</DropdownMenuTrigger>
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
{t('Studies')}
</TradingButton>
</TradingDropdownTrigger>
}
>
<DropdownMenuContent align={contentAlign}>
<TradingDropdownContent align={contentAlign}>
{Object.values(Study).map((study) => (
<DropdownMenuCheckboxItem
<TradingDropdownCheckboxItem
key={study}
checked={studies.includes(study)}
onCheckedChange={() => {
@@ -150,11 +160,11 @@ export const CandlesMenu = () => {
}}
>
{studyLabels[study]}
<DropdownMenuItemIndicator />
</DropdownMenuCheckboxItem>
<TradingDropdownItemIndicator />
</TradingDropdownCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TradingDropdownContent>
</TradingDropdown>
</>
);
};
@@ -65,6 +65,8 @@ export function addSetVegaWallet() {
Cypress.Commands.add('setVegaWallet', () => {
cy.window().then((win) => {
win.localStorage.setItem('vega_onboarding_viewed', 'true');
win.localStorage.setItem('vega_telemetry_approval', 'false');
win.localStorage.setItem('vega_telemetry_viewed', 'true');
win.localStorage.setItem(
'vega_wallet_config',
JSON.stringify({
@@ -81,6 +83,8 @@ export function addSetOnBoardingViewed() {
Cypress.Commands.add('setOnBoardingViewed', () => {
cy.window().then((win) => {
win.localStorage.setItem('vega_onboarding_viewed', 'true');
win.localStorage.setItem('vega_telemetry_approval', 'false');
win.localStorage.setItem('vega_telemetry_viewed', 'true');
});
});
}
@@ -21,6 +21,8 @@ import {
import * as positionsTools from '@vegaprotocol/positions';
import { OrdersDocument } from '@vegaprotocol/orders';
import { formatForInput } from '@vegaprotocol/utils';
import type { PartialDeep } from 'type-fest';
import type { Market } from '@vegaprotocol/markets';
jest.mock('zustand');
jest.mock('./deal-ticket-fee-details', () => ({
@@ -36,12 +38,19 @@ const market = generateMarket();
const marketData = generateMarketData();
const submit = jest.fn();
function generateJsx(mocks: MockedResponse[] = []) {
function generateJsx(
mocks: MockedResponse[] = [],
marketOverrides: PartialDeep<Market> = {}
) {
const joinedMarket: Market = {
...market,
...marketOverrides,
} as Market;
return (
<MockedProvider mocks={[...mocks]}>
<VegaWalletContext.Provider value={{ pubKey, isReadOnly: false } as any}>
<DealTicket
market={market}
market={joinedMarket}
marketData={marketData}
marketPrice={marketPrice}
submit={submit}
@@ -367,7 +376,6 @@ describe('DealTicket', () => {
expect(screen.getByTestId('iceberg')).toBeDisabled();
});
// eslint-disable-next-line jest/no-disabled-tests
it('handles TIF select box dependent on order type', async () => {
render(generateJsx());
@@ -533,6 +541,25 @@ describe('DealTicket', () => {
expect(screen.queryByTestId(priceErrorMessage)).toBeNull();
});
it('validates size when positionDecimalPlaces is negative', async () => {
render(generateJsx([], { positionDecimalPlaces: -4 }));
const sizeErrorMessage = 'deal-ticket-error-message-size';
const sizeInput = 'order-size';
await userEvent.click(screen.getByTestId('place-order'));
// default value should be invalid
expect(screen.getByTestId(sizeErrorMessage)).toBeInTheDocument();
expect(screen.getByTestId(sizeErrorMessage)).toHaveTextContent(
'Size cannot be lower than 10000'
);
await userEvent.type(screen.getByTestId(sizeInput), '10001');
expect(screen.getByTestId(sizeErrorMessage)).toHaveTextContent(
'Size must be a multiple of 10000 for this market'
);
await userEvent.clear(screen.getByTestId(sizeInput));
await userEvent.type(screen.getByTestId(sizeInput), '10000');
expect(screen.queryByTestId(sizeErrorMessage)).toBeNull();
});
it('validates iceberg field', async () => {
const peakSizeErrorMessage = 'deal-ticket-peak-error-message';
const minimumSizeErrorMessage = 'deal-ticket-minimum-error-message';
+21 -1
View File
@@ -4,7 +4,6 @@ import { getDateTimeFormat } from '@vegaprotocol/utils';
import * as Schema from '@vegaprotocol/types';
import type { PartialDeep } from 'type-fest';
import type { Trade } from './fills-data-provider';
import { FillsTable, getFeesBreakdown } from './fills-table';
import { generateFill } from './test-helpers';
@@ -215,6 +214,27 @@ describe('FillsTable', () => {
).toBeInTheDocument();
});
it('negative positionDecimalPoints should be properly rendered in size column', async () => {
const partyId = 'party-id';
const negativeDecimalPositionFill = generateFill({
...defaultFill,
market: {
...defaultFill.market,
positionDecimalPlaces: -4,
},
});
await act(async () => {
render(
<FillsTable partyId={partyId} rowData={[negativeDecimalPositionFill]} />
);
});
const sizeCell = screen
.getAllByRole('gridcell')
.find((c) => c.getAttribute('col-id') === 'size');
expect(sizeCell).toHaveTextContent('3,000,000,000');
});
describe('getFeesBreakdown', () => {
it('should return correct fees breakdown for a taker', () => {
const fees = {
@@ -7,7 +7,7 @@ import {
TradingDropdownContent,
TradingDropdownItem,
} from '@vegaprotocol/ui-toolkit';
import { formatNumberFixed } from '@vegaprotocol/utils';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
export const OrderbookControls = ({
lastTradedPrice,
@@ -15,27 +15,14 @@ export const OrderbookControls = ({
decimalPlaces,
setResolution,
}: {
lastTradedPrice: string | undefined;
lastTradedPrice: string;
resolution: number;
decimalPlaces: number;
setResolution: (resolution: number) => void;
}) => {
const [isOpen, setOpen] = useState(false);
const resolutions = new Array(
Math.max(lastTradedPrice?.toString().length ?? 0, decimalPlaces + 1)
)
.fill(null)
.map((v, i) => Math.pow(10, i));
const formatResolution = (r: number) => {
return formatNumberFixed(
Math.log10(r) - decimalPlaces > 0
? Math.pow(10, Math.log10(r) - decimalPlaces)
: 0,
decimalPlaces - Math.log10(r)
);
};
const resolutions = createResolutions(lastTradedPrice, decimalPlaces);
const increaseResolution = () => {
const index = resolutions.indexOf(resolution);
@@ -56,7 +43,7 @@ export const OrderbookControls = ({
<button
onClick={increaseResolution}
disabled={resolutions.indexOf(resolution) >= resolutions.length - 1}
className="flex items-center px-2 border-r cursor-pointer border-default"
className="flex items-center px-2 border-r cursor-pointer border-default disabled:cursor-default"
data-testid="plus-button"
>
<VegaIcon size={12} name={VegaIconNames.PLUS} />
@@ -67,12 +54,14 @@ export const OrderbookControls = ({
trigger={
<TradingDropdownTrigger data-testid="resolution">
<button
className="flex items-center px-2 text-left gap-1"
className="flex items-center justify-between px-2 gap-1"
style={{
minWidth: `${
Math.max.apply(
null,
resolutions.map((item) => formatResolution(item).length)
resolutions.map(
(item) => formatResolution(item, decimalPlaces).length
)
) + 5
}ch`,
}}
@@ -83,15 +72,19 @@ export const OrderbookControls = ({
isOpen ? VegaIconNames.CHEVRON_UP : VegaIconNames.CHEVRON_DOWN
}
/>
{formatResolution(resolution)}
{formatResolution(resolution, decimalPlaces)}
</button>
</TradingDropdownTrigger>
}
>
<TradingDropdownContent align="start">
{resolutions.map((r) => (
<TradingDropdownItem key={r} onClick={() => setResolution(r)}>
{formatResolution(r)}
<TradingDropdownItem
key={r}
onClick={() => setResolution(r)}
className="justify-end"
>
{formatResolution(r, decimalPlaces)}
</TradingDropdownItem>
))}
</TradingDropdownContent>
@@ -99,7 +92,7 @@ export const OrderbookControls = ({
<button
onClick={decreaseResolution}
disabled={resolutions.indexOf(resolution) <= 0}
className="flex items-center px-2 cursor-pointer border-x border-default"
className="flex items-center px-2 cursor-pointer border-x border-default disabled:cursor-default"
data-testid="minus-button"
>
<VegaIcon size={12} name={VegaIconNames.MINUS} />
@@ -107,3 +100,49 @@ export const OrderbookControls = ({
</div>
);
};
export const formatResolution = (r: number, decimalPlaces: number) => {
let num = addDecimalsFormatNumber(r, decimalPlaces);
// Remove trailing zeroes
num = num.replace(/\.?0+$/, '');
return num;
};
/**
* Create a list of resolutions based on the largest and smallest
* possible values using the last traded price and the market
* decimal places
*/
export const createResolutions = (
lastTradedPrice: string,
decimalPlaces: number
) => {
// number of levels determined by either the number
// of digits in the last traded price OR the number of decimal
// places. For example:
//
// last traded = 1 (0.001)
// dps = 3
// result = 3
//
// last traded = 100001 (1000.01
// dps = 2
// result = 6
const levelCount = Math.max(lastTradedPrice.length ?? 0, decimalPlaces + 1);
const generatedResolutions = new Array(levelCount)
.fill(null)
.map((_, i) => Math.pow(10, i));
const customResolutions = [2, 5, 20, 50, 200, 500];
const combined = customResolutions.concat(generatedResolutions);
combined.sort((a, b) => a - b);
// Remove any resolutions higher than the generated ones as
// we dont want a custom resolution higher than necessary
const resolutions = combined.filter((r) => {
return r <= generatedResolutions[generatedResolutions.length - 1];
});
return resolutions;
};
+2 -2
View File
@@ -12,7 +12,7 @@ export interface OrderbookRowData {
cumulativeVol: number;
}
export const getPriceLevel = (price: string | bigint, resolution: number) => {
export const getPriceLevel = (price: string, resolution: number) => {
const p = BigInt(price);
const r = BigInt(resolution);
let priceLevel = (p / r) * r;
@@ -43,7 +43,7 @@ const updateCumulativeVolumeByType = (
};
export const compactRows = (
data: PriceLevelFieldsFragment[] | null | undefined,
data: PriceLevelFieldsFragment[],
dataType: VolumeType,
resolution: number
) => {
+5 -1
View File
@@ -13,6 +13,7 @@ interface OrderbookRowProps {
cumulativeVolume: number;
decimalPlaces: number;
positionDecimalPlaces: number;
priceFormatDecimalPlaces: number;
price: string;
onClick: (args: { price?: string; size?: string }) => void;
type: VolumeType;
@@ -26,6 +27,7 @@ export const OrderbookRow = memo(
cumulativeVolume,
decimalPlaces,
positionDecimalPlaces,
priceFormatDecimalPlaces,
price,
onClick,
type,
@@ -35,6 +37,7 @@ export const OrderbookRow = memo(
const txtId = type === VolumeType.bid ? 'bid' : 'ask';
const cols =
width >= HIDE_CUMULATIVE_VOL_WIDTH ? 3 : width >= HIDE_VOL_WIDTH ? 2 : 1;
return (
<div className="relative px-1">
<CumulationBar
@@ -54,7 +57,8 @@ export const OrderbookRow = memo(
value={BigInt(price)}
valueFormatted={addDecimalsFixedFormatNumber(
price,
decimalPlaces
decimalPlaces,
priceFormatDecimalPlaces
)}
className={classNames({
'text-market-red dark:text-market-red': type === VolumeType.ask,
+79 -16
View File
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { generateMockData, VolumeType } from './orderbook-data';
import { Orderbook, OrderbookMid } from './orderbook';
import * as orderbookData from './orderbook-data';
import { createResolutions, formatResolution } from './orderbook-controls';
function mockOffsetSize(width: number, height: number) {
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
@@ -37,7 +38,8 @@ describe('Orderbook', () => {
jest.clearAllMocks();
mockOffsetSize(800, 768);
});
it('markPrice should be in the middle', async () => {
it('lastTradedPrice should be in the middle', async () => {
render(
<Orderbook
decimalPlaces={decimalPlaces}
@@ -71,6 +73,7 @@ describe('Orderbook', () => {
expect(
await screen.findByTestId(`last-traded-${params.lastTradedPrice}`)
).toBeInTheDocument();
// Before resolution change the price is 122.934
await userEvent.click(screen.getByTestId('price-122901'));
expect(onClickSpy).toBeCalledWith({ price: '122.901' });
@@ -86,15 +89,16 @@ describe('Orderbook', () => {
expect(orderbookData.compactRows).toHaveBeenCalledWith(
mockedData.bids,
VolumeType.bid,
10
2
);
expect(orderbookData.compactRows).toHaveBeenCalledWith(
mockedData.asks,
VolumeType.ask,
10
2
);
await userEvent.click(screen.getByTestId('price-12294'));
expect(onClickSpy).toBeCalledWith({ price: '122.94' });
await userEvent.click(screen.getByTestId('price-122938'));
expect(onClickSpy).toBeCalledWith({ price: '122.938' });
});
it('plus - minus buttons should change resolution', async () => {
@@ -114,26 +118,30 @@ describe('Orderbook', () => {
1
);
expect(screen.getByTestId('minus-button')).toBeDisabled();
userEvent.click(screen.getByTestId('plus-button'));
await userEvent.click(screen.getByTestId('plus-button'));
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
2
);
await userEvent.click(screen.getByTestId('plus-button'));
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
5
);
expect(screen.getByTestId('minus-button')).not.toBeDisabled();
await userEvent.click(screen.getByTestId('minus-button'));
await waitFor(() => {
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
10
2
);
});
expect(screen.getByTestId('minus-button')).not.toBeDisabled();
userEvent.click(screen.getByTestId('minus-button'));
await waitFor(() => {
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
1
);
});
expect(screen.getByTestId('minus-button')).toBeDisabled();
await userEvent.click(screen.getByTestId('resolution'));
await userEvent.click(screen.getByTestId('resolution'));
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
});
await userEvent.click(screen.getAllByRole('menuitem')[5]);
await userEvent.click(screen.getAllByRole('menuitem')[11]);
await waitFor(() => {
expect((orderbookData.compactRows as jest.Mock).mock.lastCall[2]).toEqual(
100000
@@ -223,3 +231,58 @@ describe('OrderbookMid', () => {
expect(screen.getByTestId('icon-arrow-down')).toBeInTheDocument();
});
});
describe('createResolutions', () => {
it('create resolutions relative to the market', () => {
expect(
createResolutions(
'1', // 0.001
3
)
).toEqual([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]);
expect(
createResolutions(
'190017', // 1900.17
2
)
).toEqual([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 10000, 100000]);
expect(
createResolutions(
'123456789', // 1234.56789
5
)
).toEqual([
1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 10000, 100000, 1000000,
10000000, 100000000,
]);
});
it('removes resolutions that arent precise enough for the market', () => {
expect(
createResolutions(
'1', // 0.01
2
)
).toEqual([1, 2, 5, 10, 20, 50, 100]);
});
});
describe('formatResolution', () => {
it('formats less than 1', () => {
expect(formatResolution(1, 2)).toEqual('0.01');
expect(formatResolution(1, 3)).toEqual('0.001');
expect(formatResolution(2, 4)).toEqual('0.0002');
expect(formatResolution(5, 8)).toEqual('0.00000005');
expect(formatResolution(10000, 5)).toEqual('0.1');
});
it('formats greater than 1', () => {
expect(formatResolution(1000, 2)).toEqual('10');
expect(formatResolution(100000, 4)).toEqual('10');
expect(formatResolution(10000000, 2)).toEqual('100,000');
expect(formatResolution(500, 2)).toEqual('5');
expect(formatResolution(500, 1)).toEqual('50');
});
});
+13 -2
View File
@@ -23,6 +23,7 @@ const OrderbookSide = ({
type,
decimalPlaces,
positionDecimalPlaces,
priceFormatDecimalPlaces,
onClick,
width,
maxVol,
@@ -31,6 +32,7 @@ const OrderbookSide = ({
resolution: number;
decimalPlaces: number;
positionDecimalPlaces: number;
priceFormatDecimalPlaces: number;
type: VolumeType;
onClick: (args: { price?: string; size?: string }) => void;
width: number;
@@ -53,10 +55,11 @@ const OrderbookSide = ({
{rows.map((data) => (
<OrderbookRow
key={data.price}
price={(BigInt(data.price) / BigInt(resolution)).toString()}
price={data.price}
onClick={onClick}
decimalPlaces={decimalPlaces - Math.log10(resolution)}
decimalPlaces={decimalPlaces}
positionDecimalPlaces={positionDecimalPlaces}
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
volume={data.volume}
cumulativeVolume={data.cumulativeVol}
type={type}
@@ -165,6 +168,12 @@ export const Orderbook = ({
const bestAskPrice = asks[0] ? asks[0].price : '0';
const bestBidPrice = bids[0] ? bids[0].price : '0';
// we'll want to only display a relevant number of dps based on the
// current resolution selection
const priceFormatDecimalPlaces = Math.ceil(
decimalPlaces - Math.log10(resolution)
);
return (
<div className="h-full text-xs grid grid-rows-[1fr_min-content]">
<div>
@@ -203,6 +212,7 @@ export const Orderbook = ({
resolution={resolution}
decimalPlaces={decimalPlaces}
positionDecimalPlaces={positionDecimalPlaces}
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
onClick={onClick}
width={width}
maxVol={maxVol}
@@ -220,6 +230,7 @@ export const Orderbook = ({
resolution={resolution}
decimalPlaces={decimalPlaces}
positionDecimalPlaces={positionDecimalPlaces}
priceFormatDecimalPlaces={priceFormatDecimalPlaces}
onClick={onClick}
width={width}
maxVol={maxVol}
+1 -1
View File
@@ -199,7 +199,7 @@ export const allMarketsWithLiveDataProvider = makeDerivedDataProvider<
return data.find(
(market) =>
market.id ===
(parts[1].delta as MarketDataUpdateFieldsFragment).marketId
(parts[1].delta as MarketDataUpdateFieldsFragment)?.marketId
);
}
);
@@ -1,5 +1,5 @@
import { t } from '@vegaprotocol/i18n';
import { Intent, TradingButton } from '@vegaprotocol/ui-toolkit';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { useVegaTransactionStore, useVegaWallet } from '@vegaprotocol/wallet';
import { useHasAmendableOrder } from '../../order-hooks';
@@ -28,12 +28,7 @@ export const OpenOrdersMenu = ({ marketId }: { marketId: string }) => {
};
const CancelAllOrdersButton = ({ onClick }: { onClick: () => void }) => (
<TradingButton
intent={Intent.Primary}
size="extra-small"
onClick={onClick}
data-testid="cancelAll"
>
<TradingButton size="extra-small" onClick={onClick} data-testid="cancelAll">
{t('Cancel all')}
</TradingButton>
);
@@ -6,7 +6,7 @@ import type { PartialDeep } from 'type-fest';
import type { VegaWalletContextShape } from '@vegaprotocol/wallet';
import { VegaWalletContext } from '@vegaprotocol/wallet';
import { MockedProvider } from '@apollo/client/testing';
import type { OrderFieldsFragment, OrderListTableProps } from '../';
import type { Order, OrderFieldsFragment, OrderListTableProps } from '../';
import { OrderListTable } from '../';
import {
generateOrder,
@@ -164,6 +164,24 @@ describe('OrderListTable', () => {
);
});
it('negative positionDecimalPoints should be properly rendered in size column', async () => {
const localMarketOrder = {
...marketOrder,
size: '3000',
market: {
...marketOrder.market,
positionDecimalPlaces: -4,
},
} as Order;
await act(async () => {
render(generateJsx({ rowData: [localMarketOrder] }));
});
const cells = screen.getAllByRole('gridcell');
expect(cells[2]).toHaveTextContent('+30,000,000');
});
describe('amend cell', () => {
it('allows cancelling and editing for permitted orders', async () => {
const mockEdit = jest.fn();
@@ -201,6 +201,19 @@ describe('Positions', () => {
).not.toBeInTheDocument();
});
it('handle negative positionDecimalPlaces', async () => {
await renderComponent({
...singleRow,
openVolume: '-2000',
positionDecimalPlaces: -4,
});
const cells = screen.getAllByRole('gridcell');
const cell = cells[1];
expect(within(cell).getByTestId('stack-cell-primary')).toHaveTextContent(
'-20,000,000'
);
});
describe('PNLCell', () => {
const props = {
data: undefined,
@@ -26,22 +26,36 @@ export const ProtocolUpgradeInProgressNotification = () => {
const [nextUpgrade] = useLocalStorageSnapshot(
NEXT_PROTOCOL_UPGRADE_PROPOSAL_SNAPSHOT
);
const { blocksRising, block } = useBlockRising();
const detailsLink = useProtocolUpgradeProposalLink();
let vegaReleaseTag: string | undefined;
let upgradeBlockHeight: string | undefined;
if (error && !data && nextUpgrade && ALLOW_STORED_PROPOSAL_DATA) {
const hasData = data && !error;
const hasStoredData = nextUpgrade && ALLOW_STORED_PROPOSAL_DATA;
if (hasData) {
// gets tag and height from the data api
vegaReleaseTag = data.vegaReleaseTag;
upgradeBlockHeight = data.upgradeBlockHeight;
} else if (hasStoredData) {
// gets tag and height from stored value if data api is unavailable
try {
const stored = JSON.parse(nextUpgrade) as StoredNextProtocolUpgradeData;
vegaReleaseTag = stored.vegaReleaseTag;
upgradeBlockHeight = stored.upgradeBlockHeight;
} catch {
// no op
// NOOP - could not parse stored data
}
}
const hasUpgradeInfo = vegaReleaseTag && upgradeBlockHeight;
const { blocksRising, block } = useBlockRising(
// skips querying blocks if there's no upgrade information available
!hasUpgradeInfo
);
/**
* If upgrade is in progress then none of the nodes should produce blocks,
* same should be with the tendermint block info otherwise it's a network
@@ -50,10 +64,7 @@ export const ProtocolUpgradeInProgressNotification = () => {
* Once the networks is back then the notification disappears.
*/
const upgradeInProgress =
vegaReleaseTag &&
upgradeBlockHeight &&
!blocksRising &&
block <= Number(upgradeBlockHeight);
hasUpgradeInfo && !blocksRising && block <= Number(upgradeBlockHeight);
if (!upgradeInProgress) return null;
@@ -15,31 +15,33 @@ const CHECK_INTERVAL = 5000; // ms
*/
const ALLOW_STALE = 2; // times -> MAX(this, 1) * CHECK_INTERVAL ~> min check time
export const useBlockRising = () => {
export const useBlockRising = (skip = false) => {
const [blocksRising, setBlocksRising] = useState(true);
const [block, setBlock] = useState(0);
const nodes = useEnvironment((state) => state.nodes);
const clients = useMemo(() => {
return nodes.map(
(n) =>
n &&
n.length > 0 &&
createClient({
return nodes.map((n) => {
if (n && n.length > 0) {
const client = createClient({
url: n,
cacheConfig: undefined,
retry: false,
connectToDevTools: false,
connectToHeaderStore: true,
})
);
});
return client;
}
return undefined;
});
}, [nodes]);
const { refetch: fetchBlockInfo } = useBlockInfo();
useEffect(() => {
if (skip) return;
let stale = 0;
let prev = 0;
const check = async () => {
const queries = clients.map((client, index) =>
const queries = clients.map((client) =>
client
? client
.query<BlockStatisticsQuery>({
@@ -47,18 +49,16 @@ export const useBlockRising = () => {
fetchPolicy: 'network-only',
errorPolicy: 'ignore',
})
.catch((err) =>
Promise.reject(
`could not retrieve statistics from ${nodes[index]}`
)
)
.catch(() => {
// NOOP - could not retrieve statistics for that node (network error)
})
: undefined
);
const blockInfo = await fetchBlockInfo();
const results = (await Promise.allSettled(compact(queries))).map(
(res) => {
if (res && res.status === 'fulfilled') {
if (res && res.status === 'fulfilled' && res.value) {
return res.value.data.statistics;
} else {
return undefined;
@@ -86,7 +86,7 @@ export const useBlockRising = () => {
return () => {
clearInterval(interval);
};
}, [clients, fetchBlockInfo, blocksRising, nodes]);
}, [clients, fetchBlockInfo, blocksRising, nodes, skip]);
return { blocksRising, block };
};
@@ -5,6 +5,6 @@ import { toNumberParts } from '@vegaprotocol/utils';
export const useNumberParts = (
value: BigNumber | null | undefined,
decimals: number
): [integers: string, decimalPlaces: string] => {
): [integers: string, decimalPlaces: string, separator: string | undefined] => {
return useMemo(() => toNumberParts(value, decimals), [decimals, value]);
};
@@ -0,0 +1,7 @@
export const IconEyeOff = ({ size = 16 }: { size: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16">
<path d="M16 7.97v-.02-.01-.02-.02a.672.672 0 00-.17-.36c-.49-.63-1.07-1.2-1.65-1.72l-3.16 2.26a2.978 2.978 0 01-2.98 2.9c-.31 0-.6-.06-.88-.15L5.09 12.3c.44.19.9.36 1.37.47.97.23 1.94.24 2.92.05.88-.17 1.74-.54 2.53-.98 1.25-.7 2.39-1.67 3.38-2.75.18-.2.37-.41.53-.62.09-.1.15-.22.17-.36v-.02-.02-.01-.02-.03c.01-.02.01-.03.01-.04zm-.43-4.17c.25-.18.43-.46.43-.8 0-.55-.45-1-1-1-.22 0-.41.08-.57.2l-.01-.01-2.67 1.91c-.69-.38-1.41-.69-2.17-.87a6.8 6.8 0 00-2.91-.05c-.88.18-1.74.54-2.53.99-1.25.7-2.39 1.67-3.38 2.75-.18.2-.37.41-.53.62-.23.29-.23.63-.01.92.51.66 1.11 1.25 1.73 1.79.18.16.38.29.56.44l-2.09 1.5.01.01c-.25.18-.43.46-.43.8 0 .55.45 1 1 1 .22 0 .41-.08.57-.2l.01.01 14-10-.01-.01zm-10.41 5a3.03 3.03 0 01-.11-.8 2.99 2.99 0 012.99-2.98c.62 0 1.19.21 1.66.53L5.16 8.8z" />
</svg>
);
};
@@ -15,6 +15,7 @@ import { IconDeposit } from './svg-icons/icon-deposit';
import { IconEdit } from './svg-icons/icon-edit';
import { IconExclaimationMark } from './svg-icons/icon-exclaimation-mark';
import { IconEye } from './svg-icons/icon-eye';
import { IconEyeOff } from './svg-icons/icon-eye-off';
import { IconForum } from './svg-icons/icon-forum';
import { IconGlobe } from './svg-icons/icon-globe';
import { IconInfo } from './svg-icons/icon-info';
@@ -55,6 +56,7 @@ export enum VegaIconNames {
EDIT = 'edit',
EXCLAIMATION_MARK = 'exclaimation-mark',
EYE = 'eye',
EYE_OFF = 'eye-off',
FORUM = 'forum',
GLOBE = 'globe',
INFO = 'info',
@@ -90,6 +92,7 @@ export const VegaIconNameMap: Record<
'chevron-down': IconChevronDown,
'chevron-left': IconChevronLeft,
'chevron-up': IconChevronUp,
'eye-off': IconEyeOff,
'exclaimation-mark': IconExclaimationMark,
'open-external': IconOpenExternal,
'question-mark': IconQuestionMark,
@@ -84,10 +84,8 @@ const getAffixElement = ({
'absolute z-10 top-0 bottom-0 flex items-center',
{
'fill-black dark:fill-white': prependIconName || appendIconName,
'left-3': prependIconName,
'right-3': appendIconName,
'left-1': prependElement,
'right-1': appendElement,
'left-3': prependIconName || prependElement,
'right-3': appendIconName || appendElement,
}
);
+36 -8
View File
@@ -7,6 +7,8 @@ import {
formatNumberPercentage,
getUnlimitedThreshold,
isNumeric,
removeDecimal,
toBigNum,
quantumDecimalPlaces,
toDecimal,
toNumberParts,
@@ -84,17 +86,17 @@ describe('number utils', () => {
describe('toNumberParts', () => {
it.each([
{ v: null, d: 3, o: ['0', '000'] },
{ v: undefined, d: 3, o: ['0', '000'] },
{ v: new BigNumber(123), d: 3, o: ['123', '00'] },
{ v: new BigNumber(123.123), d: 3, o: ['123', '123'] },
{ v: new BigNumber(123.123), d: 6, o: ['123', '123'] },
{ v: new BigNumber(123.123), d: 0, o: ['123', ''] },
{ v: new BigNumber(123), d: undefined, o: ['123', '00'] },
{ v: null, d: 3, o: ['0', '000', '.'] },
{ v: undefined, d: 3, o: ['0', '000', '.'] },
{ v: new BigNumber(123), d: 3, o: ['123', '00', '.'] },
{ v: new BigNumber(123.123), d: 3, o: ['123', '123', '.'] },
{ v: new BigNumber(123.123), d: 6, o: ['123', '123', '.'] },
{ v: new BigNumber(123.123), d: 0, o: ['123', '', '.'] },
{ v: new BigNumber(123), d: undefined, o: ['123', '00', '.'] },
{
v: new BigNumber(30000),
d: undefined,
o: ['30,000', '00'],
o: ['30,000', '00', '.'],
},
])('returns correct tuple given the different arguments', ({ v, d, o }) => {
expect(toNumberParts(v, d)).toStrictEqual(o);
@@ -153,10 +155,36 @@ describe('number utils', () => {
{ v: 7, o: '0.0000001' },
{ v: 8, o: '0.00000001' },
{ v: 9, o: '0.000000001' },
{ v: -1, o: '10' },
{ v: -2, o: '100' },
{ v: -3, o: '1000' },
])('formats with toNumber given number correctly', ({ v, o }) => {
expect(toDecimal(v)).toStrictEqual(o);
});
});
describe('positive and negative decimals should be handled correctly', () => {
const baseNum = '2000';
const methods = [removeDecimal, toBigNum];
it.each([
{ decimals: 0, result: ['2000', '2000'] },
{ decimals: 1, result: ['20000', '200'] },
{ decimals: -1, result: ['200', '20000'] },
{ decimals: 2, result: ['200000', '20'] },
{ decimals: -2, result: ['20', '200000'] },
{ decimals: 3, result: ['2000000', '2'] },
{ decimals: -3, result: ['2', '2000000'] },
{ decimals: 4, result: ['20000000', '0.2'] },
{ decimals: -4, result: ['0', '20000000'] }, // removeDecimal has toFixed(0) at the end
])(
'number methods should handle negative decimals',
({ decimals, result }) => {
methods.forEach((method, i) => {
expect(method(baseNum, decimals).toString()).toEqual(result[i]);
});
}
);
});
});
describe('quantumDecimalPlaces', () => {
+8 -6
View File
@@ -21,7 +21,7 @@ const MAX_FRACTION_DIGITS = 20;
export function toDecimal(numberOfDecimals: number) {
return new BigNumber(1)
.dividedBy(Math.pow(10, numberOfDecimals))
.dividedBy(new BigNumber(10).exponentiatedBy(numberOfDecimals))
.toString(10);
}
@@ -29,7 +29,8 @@ export function toBigNum(
rawValue: string | number,
decimals: number
): BigNumber {
return new BigNumber(rawValue || 0).dividedBy(Math.pow(10, decimals));
const divides = new BigNumber(10).exponentiatedBy(decimals);
return new BigNumber(rawValue || 0).dividedBy(divides);
}
export function addDecimal(
@@ -48,7 +49,8 @@ export function removeDecimal(
value: string | BigNumber,
decimals: number
): string {
return new BigNumber(value || 0).times(Math.pow(10, decimals)).toFixed(0);
const times = new BigNumber(10).exponentiatedBy(decimals);
return new BigNumber(value || 0).times(times).toFixed(0);
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
@@ -163,15 +165,15 @@ export const formatNumberPercentage = (value: BigNumber, decimals?: number) => {
export const toNumberParts = (
value: BigNumber | null | undefined,
decimals = 18
): [integers: string, decimalPlaces: string] => {
): [integers: string, decimalPlaces: string, separator: string] => {
if (!value) {
return ['0', '0'.repeat(decimals)];
return ['0', '0'.repeat(decimals), '.'];
}
const separator = getDecimalSeparator() || '.';
const [integers, decimalsPlaces] = formatNumber(value, decimals)
.toString()
.split(separator);
return [integers, decimalsPlaces || ''];
return [integers, decimalsPlaces || '', separator];
};
export const isNumeric = (
@@ -4,6 +4,12 @@ export const validateAmount = (step: number | string, field: string) => {
const [, stepDecimals = ''] = String(step).split('.');
return (value?: string) => {
if (Number(step) > 1) {
if (Number(value) % Number(step) > 0) {
return t(`${field} must be a multiple of ${step} for this market`);
}
return true;
}
const [, valueDecimals = ''] = (value || '').split('.');
if (stepDecimals.length < valueDecimals.length) {
if (stepDecimals === '') {
@@ -1,6 +1,7 @@
import classNames from 'classnames';
import {
Dialog,
ExternalLink,
Intent,
Pill,
TradingButton,
@@ -39,7 +40,7 @@ import { useVegaWallet } from '../use-vega-wallet';
import { InjectedConnectorForm } from './injected-connector-form';
import { isBrowserWalletInstalled } from '../utils';
import { useIsWalletServiceRunning } from '../use-is-wallet-service-running';
import { useIsSnapRunning } from '../use-is-snap-running';
import { SnapStatus, useSnapStatus } from '../use-snap-status';
import { useVegaWalletDialogStore } from './vega-wallet-dialog-store';
export const CLOSE_DELAY = 1700;
@@ -158,7 +159,7 @@ const ConnectDialogContainer = ({
appChainId
);
const isSnapRunning = useIsSnapRunning(
const snapStatus = useSnapStatus(
DEFAULT_SNAP_ID,
Boolean(connectors['snap'])
);
@@ -183,7 +184,7 @@ const ConnectDialogContainer = ({
setWalletUrl={setWalletUrl}
onSelect={handleSelect}
isDesktopWalletRunning={isDesktopWalletRunning}
isSnapRunning={isSnapRunning}
snapStatus={snapStatus}
/>
)}
</ConnectDialogContent>
@@ -198,14 +199,14 @@ const ConnectorList = ({
walletUrl,
setWalletUrl,
isDesktopWalletRunning,
isSnapRunning,
snapStatus,
}: {
connectors: Connectors;
onSelect: (type: WalletType) => void;
walletUrl: string;
setWalletUrl: (value: string) => void;
isDesktopWalletRunning: boolean | null;
isSnapRunning: boolean | null;
snapStatus: SnapStatus;
}) => {
const { pubKey, links } = useVegaWallet();
const title = isBrowserWalletInstalled()
@@ -249,7 +250,7 @@ const ConnectorList = ({
</div>
{connectors['snap'] !== undefined ? (
<div>
{isSnapRunning ? (
{snapStatus === SnapStatus.INSTALLED ? (
<ConnectionOption
type="snap"
text={
@@ -267,22 +268,34 @@ const ConnectorList = ({
}}
/>
) : (
<ConnectionOption
type="snap"
text={
<>
<div className="flex items-center justify-center w-full h-full text-base gap-1">
{t('Install Vega MetaMask Snap')}
</div>
<div className="absolute top-0 flex items-center h-8 right-1">
<VegaIcon name={VegaIconNames.METAMASK} size={24} />
</div>
</>
}
onClick={() => {
requestSnap(DEFAULT_SNAP_ID);
}}
/>
<>
<ConnectionOption
type="snap"
disabled={snapStatus === SnapStatus.NOT_SUPPORTED}
text={
<>
<div className="flex items-center justify-center w-full h-full text-base gap-1">
{t('Install Vega MetaMask Snap')}
</div>
<div className="absolute top-0 flex items-center h-8 right-1">
<VegaIcon name={VegaIconNames.METAMASK} size={24} />
</div>
</>
}
onClick={() => {
requestSnap(DEFAULT_SNAP_ID);
}}
/>
{snapStatus === SnapStatus.NOT_SUPPORTED ? (
<p className="pt-2 text-sm text-default">
{t('No MetaMask version that supports snaps detected.')}{' '}
{t('Learn more about')}{' '}
<ExternalLink href="https://metamask.io/snaps/">
MetaMask Snaps
</ExternalLink>
</p>
) : null}
</>
)}
</div>
) : null}
+4 -8
View File
@@ -118,14 +118,10 @@ export const getSnap = async (
snapId: string,
version?: string
): Promise<Snap | undefined> => {
try {
const snaps = await getSnaps();
return Object.values(snaps).find(
(snap) => snap.id === snapId && (!version || snap.version === version)
);
} catch (e) {
return undefined;
}
const snaps = await getSnaps();
return Object.values(snaps).find(
(snap) => snap.id === snapId && (!version || snap.version === version)
);
};
export const invokeSnap = async <T>(
-27
View File
@@ -1,27 +0,0 @@
import { useEffect, useState } from 'react';
import { getSnap } from './connectors';
const INTERVAL = 2_000;
export const useIsSnapRunning = (snapId: string, shouldCheck: boolean) => {
const [running, setRunning] = useState(false);
useEffect(() => {
if (!shouldCheck) return;
const checkState = async () => {
const snap = await getSnap(snapId);
setRunning(!!snap);
};
const i = setInterval(() => {
checkState();
}, INTERVAL);
checkState();
return () => {
clearInterval(i);
};
}, [snapId, shouldCheck]);
return running;
};
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { getSnap } from './connectors';
const INTERVAL = 2_000;
export enum SnapStatus {
NOT_SUPPORTED,
INSTALLED,
NOT_INSTALLED,
}
export const useSnapStatus = (snapId: string, shouldCheck: boolean) => {
const [status, setStatus] = useState<SnapStatus>(SnapStatus.NOT_INSTALLED);
useEffect(() => {
if (!shouldCheck) return;
const checkState = async () => {
try {
const snap = await getSnap(snapId);
setStatus(snap ? SnapStatus.INSTALLED : SnapStatus.NOT_INSTALLED);
} catch (err) {
setStatus(SnapStatus.NOT_SUPPORTED);
}
};
const i = setInterval(() => {
checkState();
}, INTERVAL);
checkState();
return () => {
clearInterval(i);
};
}, [snapId, shouldCheck]);
return status;
};
+2
View File
@@ -13,6 +13,8 @@ When looking to use Vega via a user interface e.g. Dapp (Decentralized web App),
- If there is not running desktop wallet or CLI detected, I can see that I need to open my wallet. (<a name="0002-WCON-0013" href="#0002-WCON-0013">0002-WCON-0013</a>)
- I can find out more about supported browsers i.e. there is a link (Issue "List compatible browsers" vegawallet-browser#360 has to be implemented). (<a name="0002-WCON-0013" href="#0002-WCON-0013">0002-WCON-0013</a>)
- I can find out more about the Vega Wallet and see what "other" versions there are i.e. there is a link to the page on the website (currently - https://vega.xyz/wallet#overview). (<a name="0002-WCON-0014" href="#0002-WCON-0014">0002-WCON-0014</a>)
- I can install and connect vega metamask snap wallet (<a name="0002-WCON-0015" href="#0002-WCON-0015">0002-WCON-0015</a>)
- Browser wallet:
- The browser extension you need is automatically detected if you are using Chrome or Firefox, presenting the specific call to action to install that browser extension in a visible way e.g. with a Chrome or Firefox icon. (<a name="0002-WCON-041" href="#0002-WCON-041">0002-WCON-041</a>)
+8 -5
View File
@@ -23,13 +23,16 @@
- **Must** There is a link to try out trading on Fairground when I'm on Mainnet (<a name="0007-FUGS-008" href="#0007-FUGS-008">0007-FUGS-008</a>)
- **Must** There is a link to trade with real funds on Mainnet when I am on Fairground (<a name="0007-FUGS-010" href="#0007-FUGS-010">0007-FUGS-010</a>)
- **Must** When I am on the Fairground version, I can see a warning / call out that this is Fairground meaning I can try out with virtual assets at no risk (<a name="0007-FUGS-011" href="#0007-FUGS-011">0007-FUGS-011</a>)
- If I dismiss the popup, I **must** not see it unless I NOT accomplish full "onboarding"
- If I dismiss the popup, I **must** not see it anymore (<a name="0007-FUGS-018" href="#0007-FUGS-018">0007-FUGS-018</a>)
- If I dismiss the popup, I land on the default market (<a name="0007-FUGS-012" href="#0007-FUGS-012">0007-FUGS-012</a>)
## When the popup has been dismissed:
- **Must** I can see the steps to get started with a visible call to action (according to my progress) in the context of the deal ticket, deposit, withdraw, transfer components in the sidebar (<a name="0007-FUGS-013" href="#0007-FUGS-013">0007-FUGS-013</a>)
- **Must** Remove buttons from pane containers that prompt to connect wallet (<a name="0007-FUGS-014" href="#0007-FUGS-014">0007-FUGS-014</a>)
- **Must** We've replaced "connect wallet" in the top right with "get started" (<a name="0007-FUGS-015" href="#0007-FUGS-015">0007-FUGS-015</a>)
- **Must** When I press the get started CTA, I see the wallet connect popup (<a name="0007-FUGS-016" href="#0007-FUGS-016">0007-FUGS-016</a>)
- **Must** If I have a wallet installed already I don't see this quick start onboarding, and instead call(s) to action in Console revert to connect wallet, not "get started" (button in nav header) (<a name="0007-FUGS-017" href="#0007-FUGS-017">0007-FUGS-017</a>)
- **Must** We've replaced "connect wallet" in the top right with "get started" (<a name="0007-FUGS-014" href="#0007-FUGS-014">0007-FUGS-014</a>)
- **Must** When I press the get started CTA, I see the wallet connect popup (<a name="0007-FUGS-015" href="#0007-FUGS-015">0007-FUGS-015</a>)
- **Must** If I have a wallet installed already I don't see this quick start onboarding, and instead call(s) to action in Console revert to connect wallet, not "get started" (button in nav header) (<a name="0007-FUGS-016" href="#0007-FUGS-016">0007-FUGS-016</a>)
## When onboarding process has been accomplished:
- I can see telemetry approval toast: on environment other than mainnet telemetry is enabled by default (<a name="0007-FUGS-018" href="#0007-FUGS-018">0007-FUGS-018</a>)
+23
View File
@@ -174,11 +174,34 @@ When populating a deal ticket I...
- **must** see that selecting "Post only" or "reduce only" de-select the other (if selected). As is it not possibly to apply them both to an order (<a name="7003-SORD-059" href="#7003-SORD-059">7003-SORD-059</a>)
- **must** see an option to select expire for stop order limit and market (<a name="7002-SORD-071" href="#7002-SORD-071">7002-SORD-071</a>)
- **must** select strategy for stop order limit and market (<a name="7002-SORD-072" href="#7002-SORD-072">7002-SORD-072</a>)
- **must** see strategy submit (<a name="7002-SORD-073" href="#7002-SORD-073">7002-SORD-073</a>)
if it is oco then submit strategy is not available (<a name="7002-SORD-081" href="#7002-SORD-081">7002-SORD-081</a>)
- **must** see strategy cancel (<a name="7002-SORD-074" href="#7002-SORD-074">7002-SORD-074</a>)
- **must** see expiry time/date (<a name="7002-SORD-075" href="#7002-SORD-075">7002-SORD-075</a>)
- **must** see an option to select oco for stop order limit and market (<a name="7002-SORD-082" href="#7002-SORD-082">7002-SORD-082</a>)
- **must** see a type to select market or limit (<a name="7002-SORD-083" href="#7002-SORD-083">7002-SORD-083</a>)
- **must** see for market type
- **must** see rises above (<a name="7002-SORD-084" href="#7002-SORD-084">7002-SORD-084</a>)
- **must** see falls below (<a name="7002-SORD-085" href="#7002-SORD-085">7002-SORD-085</a>)
- **must** enter a trigger value (<a name="7002-SORD-086" href="#7002-SORD-086">7002-SORD-086</a>)
- **must** select a trigger price (<a name="7002-SORD-087" href="#7002-SORD-087">7002-SORD-087</a>)
- **must** select a trigger trailing percent offset (<a name="7002-SORD-088" href="#7002-SORD-088">7002-SORD-088</a>)
- **must** see for limit type
- **must** enter a price (<a name="7002-SORD-089" href="#7002-SORD-089">7002-SORD-089</a>)
- **must** select a trigger direction (<a name="7002-SORD-090" href="#7002-SORD-090">7002-SORD-090</a>)
- **must** see rises above (<a name="7002-SORD-099" href="#7002-SORD-099">7002-SORD-099</a>)
- **must** see falls below (<a name="7002-SORD-091" href="#7002-SORD-091">7002-SORD-091</a>)
- if the user has not set a preference: trigger direction **must** default to `opposite to the one selected in the stop order` (<a name="7002-SORD-092" href="#7002-SORD-092">7002-SORD-092</a>)
- **must** enter a trigger value (<a name="7002-SORD-093" href="#7002-SORD-093">7002-SORD-093</a>)
- **must** select a trigger type (<a name="7002-SORD-094" href="#7002-SORD-094">7002-SORD-094</a>)
- **must** see a trigger price (<a name="7002-SORD-095" href="#7002-SORD-095">7002-SORD-095</a>)
- **must** see a trigger trailing percent offset (<a name="7002-SORD-096" href="#7002-SORD-096">7002-SORD-096</a>)
- if the user has not set a preference: trigger type **must** default to `price` (<a name="7002-SORD-097" href="#7002-SORD-097">7002-SORD-097</a>)
- if the trigger price is already met on submission, **must** warn the user that the stop order will be triggered immediately (<a name="7002-SORD-098" href="#7002-SORD-098">7002-SORD-098</a>)
... so I can control in more detail how my order is executed
## Auto Populating a deal ticket non-manual methods