Compare commits

..
28 changed files with 592 additions and 254 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
* @vegaprotocol/frontend @vegaprotocol/frontend-qa
* @vegaprotocol/frontend
*.graphql @vegaprotocol/core
@@ -1,91 +0,0 @@
import { selectAsset } from '../support/helpers';
const amountField = 'input[name="amount"]';
const transferText = 'transfer-intro-text';
const errorText = 'input-error-text';
const formFieldError = 'input-error-text';
const keyID = `[data-testid="${transferText}"] > .rounded-md`;
const manageVegaWallet = 'manage-vega-wallet';
const submitTransferBtn = '[type="submit"]';
const toAddressField = '[name="toAddress"]';
const transferForm = 'transfer-form';
const walletTransfer = 'wallet-transfer';
const ASSET_EURO = 1;
const ASSET_SEPOLIA_TBTC = 2;
describe(
'transfer form validation',
{ tags: '@regression', testIsolation: true },
() => {
beforeEach(() => {
cy.mockWeb3Provider();
cy.mockTradingPage();
cy.mockSubscription();
cy.setVegaWallet();
cy.visit('/#/portfolio');
cy.getByTestId(manageVegaWallet).click();
cy.getByTestId(walletTransfer).click();
cy.wait('@Accounts');
cy.wait('@Assets');
cy.mockVegaWalletTransaction();
});
it('transfer Text', () => {
// 1003-TRAN-003
cy.getByTestId(transferText)
.should('exist')
.get(keyID)
.invoke('text')
.should('match', /[\w.]{6}…[\w.]{6}/);
});
it('invalid vega key validation', () => {
//1003-TRAN-013
//1003-TRAN-004
cy.getByTestId(transferForm).should('be.visible');
cy.contains('Enter manually').click();
cy.getByTestId(transferForm).find(toAddressField).type('asd');
cy.getByTestId(transferForm).find(submitTransferBtn).click();
cy.getByTestId(formFieldError).should('contain.text', 'Invalid Vega key');
cy.contains('label', 'Vega key').should('be.visible');
cy.contains('label', 'Asset').should('be.visible');
cy.contains('label', 'Amount').should('be.visible');
});
it('empty fields', () => {
// 1003-TRAN-012
cy.getByTestId(transferForm).find(submitTransferBtn).click();
cy.getByTestId(formFieldError).should('contain.text', 'Required');
cy.getByTestId(formFieldError).should('have.length', 3);
});
it('min amount', () => {
// 1002-WITH-010
// 1003-TRAN-014
selectAsset(ASSET_SEPOLIA_TBTC);
cy.get(amountField).clear().type('0');
cy.getByTestId(transferForm).find(submitTransferBtn).click();
cy.getByTestId(errorText).should(
'contain.text',
'Value is below minimum'
);
});
it('max amount', () => {
// 1003-TRAN-002
// 1003-TRAN-011
// 1003-TRAN-002
selectAsset(ASSET_EURO); // Will be above maximum because the vega wallet doesn't have any collateral
cy.get(amountField).clear().type('1001', { delay: 100 });
cy.getByTestId(transferForm).find(submitTransferBtn).click();
cy.getByTestId(errorText).should(
'contain.text',
'You cannot transfer more than your available collateral'
);
});
}
);
@@ -21,6 +21,7 @@ const market = {
} as unknown as Market;
let mockDataSuccessorMarket: PartialDeep<Market> | null = null;
let mockDataMarketState: Market['state'] | null = null;
jest.mock('@vegaprotocol/data-provider', () => ({
...jest.requireActual('@vegaprotocol/data-provider'),
useDataProvider: jest.fn().mockImplementation((args) => {
@@ -43,6 +44,12 @@ jest.mock('@vegaprotocol/utils', () => ({
let mockCandles = {};
jest.mock('@vegaprotocol/markets', () => ({
...jest.requireActual('@vegaprotocol/markets'),
useMarketState: (marketId: string) =>
marketId
? {
data: mockDataMarketState,
}
: { data: undefined },
useSuccessorMarket: (marketId: string) =>
marketId
? {
@@ -81,30 +88,6 @@ describe('MarketSuccessorBanner', () => {
});
expect(container).toBeEmptyDOMElement();
});
it('successor market not in continuous mode', () => {
mockDataSuccessorMarket = {
...mockDataSuccessorMarket,
tradingMode: Types.MarketTradingMode.TRADING_MODE_NO_TRADING,
};
const { container } = render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(container).toBeEmptyDOMElement();
expect(allUtils.getMarketExpiryDate).toHaveBeenCalled();
});
it('successor market is not active', () => {
mockDataSuccessorMarket = {
...mockDataSuccessorMarket,
state: Types.MarketState.STATE_PENDING,
};
const { container } = render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(container).toBeEmptyDOMElement();
expect(allUtils.getMarketExpiryDate).toHaveBeenCalled();
});
});
describe('should be displayed', () => {
@@ -120,6 +103,17 @@ describe('MarketSuccessorBanner', () => {
).toHaveAttribute('href', '/#/markets/successorMarketID');
});
it('no successor market data, market settled', () => {
mockDataSuccessorMarket = null;
mockDataMarketState = Types.MarketState.STATE_SETTLED;
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(
screen.getByText('This market has been settled')
).toBeInTheDocument();
});
it('should display optionally successor volume', () => {
mockDataSuccessorMarket = {
...mockDataSuccessorMarket,
@@ -137,7 +131,9 @@ describe('MarketSuccessorBanner', () => {
render(<MarketSuccessorBanner market={market} />, {
wrapper: MockedProvider,
});
expect(screen.getByText('has 101.367 24h vol.')).toBeInTheDocument();
expect(
screen.getByText('has a 24h trading volume of 101.367')
).toBeInTheDocument();
});
it('should display optionally duration', () => {
@@ -4,6 +4,7 @@ import type { Market } from '@vegaprotocol/markets';
import {
calcCandleVolume,
useCandles,
useMarketState,
useSuccessorMarket,
} from '@vegaprotocol/markets';
import {
@@ -29,7 +30,9 @@ export const MarketSuccessorBanner = ({
}: {
market: Market | null;
}) => {
const { data: successorData } = useSuccessorMarket(market?.id);
const { data: marketState } = useMarketState(market?.id);
const isSettled = marketState === Types.MarketState.STATE_SETTLED;
const { data: successorData, loading } = useSuccessorMarket(market?.id);
const [visible, setVisible] = useState(true);
@@ -45,11 +48,6 @@ export const MarketSuccessorBanner = ({
? intervalToDuration({ start: new Date(), end: expiry })
: null;
const isInContinuesMode =
successorData?.state === Types.MarketState.STATE_ACTIVE &&
successorData?.tradingMode ===
Types.MarketTradingMode.TRADING_MODE_CONTINUOUS;
const { oneDayCandles } = useCandles({
marketId: successorData?.id,
});
@@ -66,7 +64,7 @@ export const MarketSuccessorBanner = ({
)
: null;
if (isInContinuesMode && visible) {
if (!loading && (isSettled || successorData) && visible) {
return (
<NotificationBanner
intent={Intent.Primary}
@@ -74,34 +72,47 @@ export const MarketSuccessorBanner = ({
setVisible(false);
}}
>
<div className="uppercase mb-1">
{t('This market has been succeeded')}
</div>
<div>
{duration && (
<span>
{t('This market expires in %s.', [
formatDuration(duration, {
format: [
'years',
'months',
'weeks',
'days',
'hours',
'minutes',
],
}),
])}
</span>
)}{' '}
{t('The successor market')}{' '}
<ExternalLink href={`/#/markets/${successorData?.id}`}>
{successorData?.tradableInstrument.instrument.name}
</ExternalLink>
{successorVolume && (
<span> {t('has %s 24h vol.', [successorVolume])}</span>
)}
<div className="uppercase">
{successorData
? t('This market has been succeeded')
: t('This market has been settled')}
</div>
{(duration || successorData) && (
<div className="mt-1">
{duration && (
<span>
{t('This market expires in %s.', [
formatDuration(duration, {
format: [
'years',
'months',
'weeks',
'days',
'hours',
'minutes',
],
}),
])}
</span>
)}
{successorData && (
<>
{' '}
{t('The successor market')}
{!successorVolume ? ' is ' : ' '}
<ExternalLink href={`/#/markets/${successorData?.id}`}>
{successorData?.tradableInstrument.instrument.name}
</ExternalLink>
{successorVolume && (
<span>
{' '}
{t('has a 24h trading volume of %s', [successorVolume])}
</span>
)}
</>
)}
</div>
)}
</NotificationBanner>
);
}
@@ -56,6 +56,7 @@ const MarketData = ({
variables: {
marketId: market.id,
},
fetchPolicy: 'no-cache',
});
const marketData = data?.marketsData[0];
@@ -70,6 +71,8 @@ const MarketData = ({
const marketTradingMode = marketData
? marketData.marketTradingMode
: market.data
? market.data.marketTradingMode
: market.tradingMode;
const mode = [
@@ -95,7 +98,7 @@ const MarketData = ({
<>
<div className="w-2/5" role="gridcell">
<h3 className="flex items-baseline">
<span className="text-sm lg:text-base text-ellipsis whitespace-nowrap overflow-hidden">
<span className="overflow-hidden text-sm lg:text-base text-ellipsis whitespace-nowrap">
{market.tradableInstrument.instrument.code}
</span>
{allProducts && productType && (
@@ -2,7 +2,10 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MarketSelector } from './market-selector';
import { useMarketList } from '@vegaprotocol/markets';
import { createMarketFragment } from '@vegaprotocol/mock';
import {
createMarketFragment,
createMarketsDataFragment,
} from '@vegaprotocol/mock';
import { MarketState } from '@vegaprotocol/types';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
@@ -36,6 +39,10 @@ describe('MarketSelector', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'a',
@@ -56,7 +63,10 @@ describe('MarketSelector', () => {
}),
createMarketFragment({
id: 'market-1',
state: MarketState.STATE_SUSPENDED,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_SUSPENDED,
}),
tradableInstrument: {
instrument: {
code: 'b',
@@ -77,7 +87,10 @@ describe('MarketSelector', () => {
}),
createMarketFragment({
id: 'market-2',
state: MarketState.STATE_CLOSED,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_CLOSED,
}),
tradableInstrument: {
instrument: {
product: {
@@ -91,7 +104,10 @@ describe('MarketSelector', () => {
}),
createMarketFragment({
id: 'market-3',
state: MarketState.STATE_ACTIVE,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'c',
@@ -112,6 +128,10 @@ describe('MarketSelector', () => {
}),
createMarketFragment({
id: 'market-4',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'cd',
@@ -132,7 +152,10 @@ describe('MarketSelector', () => {
}),
];
const activeMarkets = markets.filter((m) => isMarketActive(m.state));
const activeMarkets = markets.filter((m) =>
// @ts-ignore candles get joined outside this type
isMarketActive(m.data.marketState)
);
mockUseMarketList.mockReturnValue({
data: markets,
loading: false,
@@ -4,7 +4,10 @@ import { useMarketSelectorList } from './use-market-selector-list';
import { isMarketActive } from '../../lib/utils';
import { Product } from './product-selector';
import { Sort } from './sort-dropdown';
import { createMarketFragment } from '@vegaprotocol/mock';
import {
createMarketFragment,
createMarketsDataFragment,
} from '@vegaprotocol/mock';
import { MarketState } from '@vegaprotocol/types';
import { useMarketList } from '@vegaprotocol/markets';
import type { Filter } from './market-selector';
@@ -31,22 +34,40 @@ describe('useMarketSelectorList', () => {
it('returns all markets active and suspended markets', () => {
const markets = [
createMarketFragment({ id: 'market-0' }),
createMarketFragment({
id: 'market-0',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
}),
createMarketFragment({
id: 'market-1',
state: MarketState.STATE_SUSPENDED,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_SUSPENDED,
}),
}),
createMarketFragment({
id: 'market-2',
state: MarketState.STATE_CLOSED,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_CLOSED,
}),
}),
createMarketFragment({
id: 'market-3',
state: MarketState.STATE_CLOSED,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_CLOSED,
}),
}),
createMarketFragment({
id: 'market-4',
state: MarketState.STATE_PENDING,
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_PENDING,
}),
}),
];
mockUseMarketList.mockReturnValue({
@@ -56,7 +77,8 @@ describe('useMarketSelectorList', () => {
});
const { result } = setup();
const expectedFilteredMarkets = markets.filter((m) =>
isMarketActive(m.state)
// @ts-ignore candles get joined outside this type
isMarketActive(m.data.marketState)
);
expect(result.current).toEqual({
data: markets,
@@ -70,6 +92,10 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -90,6 +116,10 @@ describe('useMarketSelectorList', () => {
// }),
createMarketFragment({
id: 'market-2',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -135,6 +165,10 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -148,6 +182,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-1',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -161,6 +199,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-2',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -174,6 +216,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-3',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
product: {
@@ -238,6 +284,10 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'abc',
@@ -247,6 +297,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-1',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'def',
@@ -256,6 +310,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-2',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'defg',
@@ -265,6 +323,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-3',
// @ts-ignore candles get joined outside this type
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
tradableInstrument: {
instrument: {
code: 'ggg',
@@ -333,11 +395,11 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
markPrice: '1',
},
}),
// @ts-ignore candles not on fragment
candles: [
{
@@ -349,9 +411,10 @@ describe('useMarketSelectorList', () => {
id: 'market-1',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
markPrice: '1',
},
}),
// @ts-ignore candles not on fragment
candles: [
{
@@ -363,9 +426,10 @@ describe('useMarketSelectorList', () => {
id: 'market-2',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
markPrice: '1',
},
}),
// @ts-ignore candles not on fragment
candles: [
{
@@ -377,9 +441,10 @@ describe('useMarketSelectorList', () => {
id: 'market-3',
state: MarketState.STATE_ACTIVE,
// @ts-ignore data not on fragment
data: {
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
markPrice: '1',
},
}),
// @ts-ignore candles not on fragment
candles: [
{
@@ -414,6 +479,10 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
// @ts-ignore actual fragment doesn't contain candles and is joined later
candles: [
{
@@ -426,6 +495,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-1',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
// @ts-ignore actual fragment doesn't contain candles and is joined later
candles: [
{
@@ -438,6 +511,10 @@ describe('useMarketSelectorList', () => {
}),
createMarketFragment({
id: 'market-2',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
// @ts-ignore actual fragment doesn't contain candles and is joined later
candles: [
{
@@ -482,18 +559,30 @@ describe('useMarketSelectorList', () => {
const markets = [
createMarketFragment({
id: 'market-0',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
marketTimestamps: {
open: subDays(new Date(), 3).toISOString(),
},
}),
createMarketFragment({
id: 'market-1',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
marketTimestamps: {
open: subDays(new Date(), 1).toISOString(),
},
}),
createMarketFragment({
id: 'market-2',
// @ts-ignore data not on fragment
data: createMarketsDataFragment({
marketState: MarketState.STATE_ACTIVE,
}),
marketTimestamps: {
open: subDays(new Date(), 2).toISOString(),
},
@@ -22,8 +22,12 @@ export const useMarketSelectorList = ({
const markets = useMemo(() => {
if (!data?.length) return [];
const markets = data
// only active
.filter((m) => isMarketActive(m.state))
// show only active markets, using m.data.marketState as this will be
// data that will get refreshed when calling reload
.filter((m) => {
if (!m.data) return false;
return isMarketActive(m.data.marketState);
})
// only selected product type
.filter((m) => {
if (
@@ -6,7 +6,7 @@ import { TelemetryApproval } from './telemetry-approval';
import { t } from '@vegaprotocol/i18n';
import { useOnboardingStore } from '../welcome-dialog/use-get-onboarding-step';
const TELEMETRY_APPROVAL_TOAST_ID = 'telemetry_tost_id';
const TELEMETRY_APPROVAL_TOAST_ID = 'telemetry_toast_id';
export const Telemetry = () => {
const onboardingDissmissed = useOnboardingStore((store) => store.dismissed);
+5 -6
View File
@@ -1,8 +1,8 @@
export const SSRLoader = () => {
const randomDelay = () => {
return parseFloat((Math.random() * (4 - 1) + 1).toFixed(2));
};
import { pseudoRandom } from '@vegaprotocol/ui-toolkit';
const generate = pseudoRandom(1);
export const SSRLoader = () => {
return (
<div
style={{
@@ -38,10 +38,9 @@ export const SSRLoader = () => {
width: 10,
height: 10,
animation: 'flickering 0.4s linear alternate infinite',
animationDelay: `-${randomDelay()}s`,
animationDelay: `-${generate()}s`,
animationDirection: i % 2 === 0 ? 'reverse' : 'alternate',
background: 'black',
opacity: Math.random() > 0.5 ? 1 : 0,
}}
/>
);
@@ -7,6 +7,7 @@ import {
useEthTransactionUpdater,
useEthWithdrawApprovalsManager,
} from '@vegaprotocol/web3';
import { useLedgerDownloadManager } from '@vegaprotocol/ledger';
export const TransactionHandlers = () => {
useVegaTransactionManager();
@@ -14,5 +15,6 @@ export const TransactionHandlers = () => {
useEthTransactionManager();
useEthTransactionUpdater();
useEthWithdrawApprovalsManager();
useLedgerDownloadManager();
return null;
};
@@ -88,6 +88,9 @@ describe('TransferForm', () => {
});
it('validates a manually entered address', async () => {
// 1003-TRAN-012
// 1003-TRAN-013
// 1003-TRAN-004
render(<TransferForm {...props} />);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
@@ -116,6 +119,11 @@ describe('TransferForm', () => {
});
it('validates fields and submits', async () => {
// 1003-TRAN-002
// 1003-TRAN-003
// 1002-WITH-010
// 1003-TRAN-011
// 1003-TRAN-014
render(<TransferForm {...props} />);
// check current pubkey not shown
+10 -1
View File
@@ -57,7 +57,16 @@ export const rows: Rows = [
key: AssetDetail.ID,
label: t('ID'),
tooltip: '',
value: (asset) => truncateMiddle(asset.id),
value: (asset) => (
<>
{truncateMiddle(asset.id)}{' '}
<CopyWithTooltip text={asset.id}>
<button title={t('Copy id to clipboard')}>
<VegaIcon size={14} name={VegaIconNames.COPY} />
</button>
</CopyWithTooltip>
</>
),
},
{
key: AssetDetail.TYPE,
@@ -14,16 +14,17 @@ export const MarketProductPill = ({
}: {
productType?: ProductType;
}) => {
if (!productType) {
return null;
}
return (
productType && (
<Pill
size="xxs"
className="uppercase ml-0.5"
title={ProductTypeMapping[productType]}
>
{ProductTypeShortName[productType]}
</Pill>
)
<Pill
size="xxs"
className="uppercase ml-0.5"
title={ProductTypeMapping[productType]}
>
{ProductTypeShortName[productType]}
</Pill>
);
};
@@ -37,6 +37,7 @@ import {
AccordionChevron,
AccordionPanel,
Intent,
ExternalLink,
Pill,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
@@ -385,7 +386,21 @@ export const DealTicketMarginDetails = ({
value={liquidationPriceEstimateRange}
formattedValue={liquidationPriceEstimate}
symbol={quoteName}
labelDescription={LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT}
labelDescription={
<>
<span>{LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT}</span>{' '}
<span>
{t('For full details please see ')}
<ExternalLink
href={
'https://github.com/vegaprotocol/specs/blob/master/non-protocol-specs/0012-NP-LIPE-liquidation-price-estimate.md'
}
>
{t('liquidation price estimate documentation.')}
</ExternalLink>
</span>
</>
}
/>
{partyId && (
<AccountBreakdownDialog
+1 -1
View File
@@ -60,7 +60,7 @@ export const EST_FEES_TOOLTIP_TEXT = t(
);
export const LIQUIDATION_PRICE_ESTIMATE_TOOLTIP_TEXT = t(
'This is an approximation (or a range) for the liquidation price for that particular contract position, assuming nothing else changes, which may affect your margin and collateral balances.'
'This is an approximation for the liquidation price for that particular contract position, assuming nothing else changes, which may affect your margin and collateral balances.'
);
export const EST_SLIPPAGE = t(
+1
View File
@@ -1 +1,2 @@
export * from './lib/ledger-export-form';
export * from './lib/ledger-download-store';
@@ -0,0 +1,142 @@
import { create } from 'zustand';
import type { ReactNode } from 'react';
import { useCallback, useEffect } from 'react';
import type { Toast } from '@vegaprotocol/ui-toolkit';
import { useToasts, Intent } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
import { subscribeWithSelector } from 'zustand/middleware';
type DownloadSettings = {
title: string;
link: string;
filename?: string;
isDownloaded?: boolean;
isChanged?: boolean;
isError?: boolean;
errorMessage?: string;
isDelayed?: boolean;
intent?: Intent;
blob?: Blob;
};
export type LedgerDownloadFileStore = {
queue: DownloadSettings[];
hasItem: (link: string) => boolean;
removeItem: (link: string) => void;
updateQueue: (item: DownloadSettings) => void;
};
export const useLedgerDownloadFile = create<LedgerDownloadFileStore>()(
subscribeWithSelector((set, get) => ({
queue: [],
hasItem: (link: string) =>
get().queue.findIndex((item) => item.link === link) > -1,
removeItem: (link: string) => {
const queue = get().queue;
const index = queue.findIndex((item) => item.link === link);
if (index > -1) {
queue.splice(index, 1);
set({ queue: [...queue] });
}
},
updateQueue: (newitem: DownloadSettings) => {
const queue = get().queue;
const index = queue.findIndex((item) => item.link === newitem.link);
if (index > -1) {
queue[index] = { ...queue[index], ...newitem };
set({ queue: [...queue] });
} else {
set({ queue: [newitem, ...queue] });
}
},
}))
);
const ErrorContent = ({ message }: { message?: string }) => (
<>
<h4 className="mb-1 text-sm">{t('Something went wrong')}</h4>
<p>{message || t('Try again later')}</p>
</>
);
const InfoContent = ({ progress = false }) => (
<>
<p>{t('Please note this can take several minutes.')}</p>
<p>{t('You will be notified here when your file is ready.')}</p>
<h4 className="my-2">
{progress ? t('Still in progress') : t('Download has been started')}
</h4>
</>
);
export const useLedgerDownloadManager = () => {
const queue = useLedgerDownloadFile((store) => store.queue);
const updateQueue = useLedgerDownloadFile((store) => store.updateQueue);
const removeItem = useLedgerDownloadFile((store) => store.removeItem);
const [setToast, updateToast, hasToast, removeToast] = useToasts((store) => [
store.setToast,
store.update,
store.hasToast,
store.remove,
]);
const onDownloadClose = useCallback(
(id: string) => {
removeToast(id);
removeItem(id);
},
[removeToast, removeItem]
);
const createToast = (item: DownloadSettings) => {
let content: ReactNode;
switch (true) {
case item.isError:
content = <ErrorContent message={item.errorMessage} />;
break;
case Boolean(item.blob):
content = (
<>
<h4 className="mb-1 text-sm">{t('Your file is ready')}</h4>
<a
onClick={() => onDownloadClose(item.link)}
href={URL.createObjectURL(item.blob as Blob)}
download={item.filename}
className="underline"
>
{t('Get file here')}
</a>
</>
);
break;
default:
content = <InfoContent progress={item.isDelayed} />;
}
const toast: Toast = {
id: item.link,
intent: item.intent || Intent.Primary,
content: (
<>
<h3 className="mb-1 text-md uppercase">{item.title}</h3>
{content}
</>
),
onClose: () => onDownloadClose(item.link),
loader: !item.isDownloaded && !item.isError,
};
if (hasToast(toast.id)) {
updateToast(toast.id, toast);
} else {
setToast(toast);
}
};
useEffect(() => {
queue.forEach((item) => {
if (item.isChanged) {
createToast(item);
updateQueue({ ...item, isChanged: false });
}
});
}, [queue, createToast, updateQueue]);
};
+56 -20
View File
@@ -1,10 +1,21 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { createDownloadUrl, LedgerExportForm } from './ledger-export-form';
import { formatForInput, toNanoSeconds } from '@vegaprotocol/utils';
import {
useLedgerDownloadManager,
useLedgerDownloadFile,
} from './ledger-download-store';
import { Intent } from '@vegaprotocol/ui-toolkit';
const mockSetToast = jest.fn();
jest.mock('@vegaprotocol/ui-toolkit', () => ({
...jest.requireActual('@vegaprotocol/ui-toolkit'),
useToasts: jest.fn(() => [mockSetToast, jest.fn(), jest.fn(() => false)]),
}));
const vegaUrl = 'https://vega-url.co.uk/querystuff';
const mockResponse = {
ok: true,
headers: { get: jest.fn() },
blob: () => '',
};
@@ -28,6 +39,7 @@ describe('LedgerExportForm', () => {
afterAll(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
it('should be properly rendered', async () => {
@@ -43,8 +55,6 @@ describe('LedgerExportForm', () => {
// userEvent does not work with faked timers
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@@ -52,9 +62,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('assetID should be properly change request url', async () => {
@@ -75,8 +82,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@@ -84,9 +89,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=1691057410000000000`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('date-from should properly change request url', async () => {
@@ -110,8 +112,6 @@ describe('LedgerExportForm', () => {
fireEvent.click(screen.getByTestId('ledger-download-button'));
expect(screen.getByTestId('download-spinner')).toBeInTheDocument();
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
`https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
@@ -119,10 +119,6 @@ describe('LedgerExportForm', () => {
}&dateRange.startTimestamp=${toNanoSeconds(newDate)}`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('date-to should properly change request url', async () => {
@@ -156,10 +152,6 @@ describe('LedgerExportForm', () => {
)}`
);
});
await waitFor(() => {
expect(screen.queryByTestId('download-spinner')).not.toBeInTheDocument();
});
});
it('Time zone sentence should be properly displayed', () => {
@@ -205,6 +197,50 @@ describe('LedgerExportForm', () => {
screen.queryByText(/^The downloaded file uses the UTC/)
).not.toBeInTheDocument();
});
it('A toast notification should be displayed', async () => {
useLedgerDownloadFile.setState({ queue: [] });
const TestWrapper = () => {
useLedgerDownloadManager();
return (
<LedgerExportForm
partyId={partyId}
vegaUrl={vegaUrl}
assets={assetsMock}
/>
);
};
render(<TestWrapper />);
expect(screen.getByText('symbol asset-id')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('ledger-download-button'));
const link = `https://vega-url.co.uk/api/v2/ledgerentry/export?partyId=${partyId}&assetId=${
Object.keys(assetsMock)[0]
}&dateRange.startTimestamp=1691057410000000000`;
await waitFor(() => {
expect(mockSetToast).toHaveBeenCalledWith({
id: link,
content: expect.any(Object),
onClose: expect.any(Function),
intent: Intent.Primary,
loader: true,
});
expect(global.fetch).toHaveBeenCalledWith(link);
});
mockSetToast.mockClear();
(global.fetch as jest.Mock).mockClear();
fireEvent.click(screen.getByTestId('ledger-download-button')); // click again
await waitFor(() => {
expect(mockSetToast).toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
});
});
describe('createDownloadUrl', () => {
+75 -31
View File
@@ -1,17 +1,22 @@
import { useRef, useState } from 'react';
import { z } from 'zod';
import { format, subDays } from 'date-fns';
import {
TradingButton,
Intent,
Loader,
TradingButton,
TradingFormGroup,
TradingInput,
TradingSelect,
} from '@vegaprotocol/ui-toolkit';
import { toNanoSeconds, VEGA_ID_REGEX } from '@vegaprotocol/utils';
import { z } from 'zod';
import {
formatForInput,
toNanoSeconds,
VEGA_ID_REGEX,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { localLoggerFactory } from '@vegaprotocol/logger';
import { formatForInput } from '@vegaprotocol/utils';
import { subDays } from 'date-fns';
import { useLedgerDownloadFile } from './ledger-download-store';
const DEFAULT_EXPORT_FILE_NAME = 'ledger_entries.csv';
@@ -73,10 +78,14 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
const maxFromDate = formatForInput(new Date(dateTo || now.current));
const maxToDate = formatForInput(now.current);
const [isDownloading, setIsDownloading] = useState(false);
const [assetId, setAssetId] = useState(Object.keys(assets)[0]);
const protohost = getProtoHost(vegaUrl);
const disabled = Boolean(!assetId || isDownloading);
const disabled = Boolean(!assetId);
const hasItem = useLedgerDownloadFile((store) => store.hasItem);
const updateDownloadQueue = useLedgerDownloadFile(
(store) => store.updateQueue
);
const assetDropDown = (
<TradingSelect
@@ -87,7 +96,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
}}
className="w-full"
data-testid="select-ledger-asset"
disabled={isDownloading}
>
{Object.keys(assets).map((assetKey) => (
<option key={assetKey} value={assetKey}>
@@ -97,32 +105,78 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
</TradingSelect>
);
const link = createDownloadUrl({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
});
const startDownload = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const link = createDownloadUrl({
protohost,
partyId,
assetId,
dateFrom,
dateTo,
const title = t('Downloading for %s from %s till %s', [
assets[assetId],
format(new Date(dateFrom), 'dd MMMM yyyy HH:mm'),
format(new Date(dateTo || Date.now()), 'dd MMMM yyyy HH:mm'),
]);
const downloadStoreItem = {
title,
link,
isChanged: true,
};
if (hasItem(link)) {
updateDownloadQueue(downloadStoreItem);
return;
}
const ts = setTimeout(() => {
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Warning,
isDelayed: true,
isChanged: true,
});
setIsDownloading(true);
}, 1000 * 30);
try {
updateDownloadQueue(downloadStoreItem);
const resp = await fetch(link);
if (!resp?.ok) {
if (resp?.status === 429) {
throw new Error('Too many requests. Try again later.');
}
throw new Error('Download of ledger entries failed');
}
const { headers } = resp;
const nameHeader = headers.get('content-disposition');
const filename = nameHeader?.split('=').pop() ?? DEFAULT_EXPORT_FILE_NAME;
updateDownloadQueue({
...downloadStoreItem,
filename,
});
const blob = await resp.blob();
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
updateDownloadQueue({
...downloadStoreItem,
blob,
isDownloaded: true,
isChanged: true,
intent: Intent.Success,
});
}
} catch (err) {
localLoggerFactory({ application: 'ledger' }).error('Download file', err);
updateDownloadQueue({
...downloadStoreItem,
intent: Intent.Danger,
isError: true,
isChanged: true,
errorMessage: (err as Error).message || undefined,
});
} finally {
setIsDownloading(false);
clearTimeout(ts);
}
};
@@ -145,7 +199,6 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-from"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
disabled={disabled}
max={maxFromDate}
/>
</TradingFormGroup>
@@ -156,19 +209,10 @@ export const LedgerExportForm = ({ partyId, vegaUrl, assets }: Props) => {
id="date-to"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
disabled={disabled}
max={maxToDate}
/>
</TradingFormGroup>
<div className="relative text-sm" title={t('Download all to .csv file')}>
{isDownloading && (
<div
className="absolute flex items-center justify-center w-full h-full"
data-testid="download-spinner"
>
<Loader size="small" />
</div>
)}
<TradingButton
fill
disabled={disabled}
+3 -2
View File
@@ -3,12 +3,12 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type MarketsDataFieldsFragment = { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } };
export type MarketsDataFieldsFragment = { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketState: Types.MarketState, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } };
export type MarketsDataQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type MarketsDataQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', data?: { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } } | null } }> } | null };
export type MarketsDataQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', data?: { __typename?: 'MarketData', bestBidPrice: string, bestOfferPrice: string, markPrice: string, trigger: Types.AuctionTrigger, staticMidPrice: string, marketState: Types.MarketState, marketTradingMode: Types.MarketTradingMode, indicativeVolume: string, indicativePrice: string, bestStaticBidPrice: string, bestStaticOfferPrice: string, targetStake?: string | null, suppliedStake?: string | null, auctionStart?: string | null, auctionEnd?: string | null, market: { __typename?: 'Market', id: string } } | null } }> } | null };
export const MarketsDataFieldsFragmentDoc = gql`
fragment MarketsDataFields on MarketData {
@@ -20,6 +20,7 @@ export const MarketsDataFieldsFragmentDoc = gql`
markPrice
trigger
staticMidPrice
marketState
marketTradingMode
indicativeVolume
indicativePrice
@@ -126,6 +126,16 @@ export const marketTradingModeProvider = makeDerivedDataProvider<
(parts[0] as ReturnType<typeof getData>)?.marketTradingMode
);
export const marketStateProvider = makeDerivedDataProvider<
MarketDataFieldsFragment['marketState'] | undefined,
never,
MarketDataQueryVariables
>(
[marketDataProvider],
(parts, variables, prevData) =>
(parts[0] as ReturnType<typeof getData>)?.marketState
);
export const fundingRateProvider = makeDerivedDataProvider<
string,
never,
@@ -176,3 +186,11 @@ export const useMarketTradingMode = (marketId?: string, skip?: boolean) => {
skip: skip || !marketId,
});
};
export const useMarketState = (marketId?: string, skip?: boolean) => {
return useDataProvider({
dataProvider: marketStateProvider,
variables: { marketId: marketId || '' },
skip: skip || !marketId,
});
};
@@ -7,6 +7,7 @@ fragment MarketsDataFields on MarketData {
markPrice
trigger
staticMidPrice
marketState
marketTradingMode
indicativeVolume
indicativePrice
@@ -34,6 +34,7 @@ export const createMarketsDataFragment = (
__typename: 'Market',
},
marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS,
marketState: Schema.MarketState.STATE_ACTIVE,
staticMidPrice: '0',
indicativePrice: '0',
bestStaticBidPrice: '0',
@@ -2,8 +2,8 @@ import { render, act, screen } from '@testing-library/react';
import { AsyncRenderer } from './async-renderer';
describe('AsyncRenderer', () => {
const reload = jest.fn();
it('timeout error should render button', async () => {
const reload = jest.fn();
await act(() => {
render(
<AsyncRenderer
@@ -20,4 +20,22 @@ describe('AsyncRenderer', () => {
});
expect(reload).toHaveBeenCalled();
});
it('errors should be handled properly', async () => {
const message = 'Node has been collapsed';
await act(() => {
render(
<AsyncRenderer
reload={reload}
error={new Error(message)}
loading={false}
data={[]}
/>
);
});
expect(
screen.getByText(`Something went wrong: ${message}`)
).toBeInTheDocument();
});
});
@@ -29,7 +29,7 @@ export function AsyncRenderer<T = object>({
reload,
}: AsyncRendererProps<T>) {
if (error) {
if (!data) {
if (!data || (Array.isArray(data) && !data.length)) {
return (
<div className="h-full flex items-center justify-center">
<div className="h-12 flex flex-col items-center">
@@ -1,8 +1,7 @@
import classNames from 'classnames';
import { useMemo } from 'react';
import styles from './loader.module.scss';
const pseudoRandom = (seed: number) => {
export const pseudoRandom = (seed: number) => {
let value = seed;
return () => {
value = (value * 16807) % 2147483647;
@@ -10,6 +9,8 @@ const pseudoRandom = (seed: number) => {
};
};
const generate = pseudoRandom(1);
export interface LoaderProps {
size?: 'small' | 'large';
forceTheme?: 'dark' | 'light';
@@ -27,8 +28,6 @@ export const Loader = ({ size = 'large', forceTheme }: LoaderProps) => {
size === 'small' ? 'w-[15px] h-[15px]' : 'w-[50px] h-[50px]';
const items = size === 'small' ? 9 : 25;
const generate = useMemo(() => pseudoRandom(1), []);
return (
<div className="flex flex-col items-center" data-testid="loader">
<div className={`${wrapperClasses} flex flex-wrap`}>
+9 -1
View File
@@ -8,5 +8,13 @@ When I enter on ledger entries tab in portfolio page
- in the form **Must** see a dropdown for select an asset, in which reports will be downloaded (<a name="7007-LEEN-002" href="#7007-LEEN-002">7007-LEEN-002</a>)
- in the form **Must** see inputs for select time period, in which reports will be downloaded (<a name="7007-LEEN-003" href="#7007-LEEN-003">7007-LEEN-003</a>)
- default preselected period **Must** be the last 7 days (<a name="7007-LEEN-004" href="#7007-LEEN-004">7007-LEEN-004</a>)
- during download a loader component **Must** be visible and all interactive elements in the form **Must** be disabled (<a name="7007-LEEN-005" href="#7007-LEEN-005">7007-LEEN-005</a>)
- **Must** see a note about time in file are in UTC and timezone of the user relative to UTC (<a name="7007-LEEN-006" href="#7007-LEEN-006">7007-LEEN-006</a>)
- As a user, I **must** see a message saying that this can take several minutes (<a name="7007-LEEN-007" href="#7007-LEEN-007">7007-LEEN-007</a>)
- After half a minute, the message is updated to say something like 'Still in progress' (<a name="7007-LEEN-008" href="#7007-LEEN-008">7007-LEEN-008</a>)
- A toast is shown when the download is complete (<a name="7007-LEEN-009" href="#7007-LEEN-009">7007-LEEN-009</a>)
- The download button should never be disabled
- If user tries to download file which is already in download: (<a name="7007-LEEN-010" href="#7007-LEEN-010">7007-LEEN-010</a>)
- if notification stayed open, nothing happens
- If notification was closed, will be open, no any new request will be fired
- If something has changed in the form (asset, dates, `Date.now`) new download will start.
- The state of the download form should be in sync with the download itself if you navigate away from the page or reload (<a name="7007-LEEN-011" href="#7007-LEEN-011">7007-LEEN-011</a>)