diff --git a/apps/trading-e2e/cypress.config.js b/apps/trading-e2e/cypress.config.js index 011e37c04..e91f4ea5a 100644 --- a/apps/trading-e2e/cypress.config.js +++ b/apps/trading-e2e/cypress.config.js @@ -20,7 +20,7 @@ module.exports = defineConfig({ chromeWebSecurity: false, projectId: 'et4snf', defaultCommandTimeout: 10000, - viewportWidth: 1440, + viewportWidth: 1800, viewportHeight: 900, responseTimeout: 50000, requestTimeout: 20000, diff --git a/apps/trading-e2e/src/integration/market-selector.cy.ts b/apps/trading-e2e/src/integration/market-selector.cy.ts new file mode 100644 index 000000000..0a6102d5e --- /dev/null +++ b/apps/trading-e2e/src/integration/market-selector.cy.ts @@ -0,0 +1,105 @@ +import { + AuctionTrigger, + MarketState, + MarketTradingMode, +} from '@vegaprotocol/types'; + +describe('markets selector', { tags: '@smoke' }, () => { + const list = 'market-selector-list'; + const searchInput = 'search-term'; + + beforeEach(() => { + cy.window().then((window) => { + window.localStorage.setItem('marketId', 'market-1'); + }); + cy.mockTradingPage( + MarketState.STATE_ACTIVE, + MarketTradingMode.TRADING_MODE_MONITORING_AUCTION, + AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY_TARGET_NOT_MET + ); + cy.mockSubscription(); + cy.visit('/'); + + cy.wait('@Markets'); + cy.wait('@MarketsData'); + cy.wait('@MarketsCandles'); + }); + + it('can toggle the sidebar', () => { + cy.getByTestId('market-selector').should('be.visible'); + cy.getByTestId('sidebar-toggle').click(); + cy.getByTestId('market-selector').should('not.exist'); + cy.getByTestId('sidebar-toggle').click(); + cy.getByTestId('market-selector').should('be.visible'); + }); + + // need function keyword as we need 'this' to access market data + it('displays data as expected', () => { + // TODO: load data from mocks in. Using alias and wrap intermittently fails + const data = [ + { + code: 'AAPL.MF21', + name: 'Apple Monthly (30 Jun 2022)', + markPrice: '46,126.90058', + change: '+200.00%', + }, + { + code: 'BTCUSD.MF21', + name: 'ACTIVE MARKET', + markPrice: '46,126.90058', + change: '+200.00%', + }, + { + code: 'ETHBTC.QM21', + name: 'ETHBTC Quarterly (30 Jun 2022)', + markPrice: '46,126.90058', + change: '+200.00%', + }, + { + code: 'SOLUSD', + name: 'SUSPENDED MARKET', + markPrice: '84.41', + change: '+200.00%', + }, + ]; + cy.getByTestId(list) + .find('a') + .each((item, i) => { + const market = data[i]; + // 6001-MARK-021 + expect(item.find('h3').text()).equals(market.code); + // 6001-MARK-022 + expect(item.find('h4').text()).equals(market.name); + // 6001-MARK-024 + expect(item.find('[data-testid="market-item-price"]').text()).equals( + market.markPrice + ); + // 6001-MARK-023 + expect(item.find('[data-testid="market-item-change"]').text()).equals( + market.change + ); + // 6001-MARK-025 + expect(item.find('[data-testid="sparkline-svg"]')).to.exist; + }); + }); + + // 6001-MARK-27 + it('can use the filter options', () => { + // product type + cy.getByTestId('product-Spot').click(); + cy.getByTestId(list).contains('Spot markets coming soon.'); + cy.getByTestId('product-Perpetual').click(); + cy.getByTestId(list).contains('Perpetual markets coming soon.'); + cy.getByTestId('product-Future').click(); + cy.getByTestId(list).find('a').should('have.length', 4); + + // 6001-MARK-29 + cy.getByTestId(searchInput).clear().type('btc'); + cy.getByTestId(list).find('a').should('have.length', 2); + cy.getByTestId(list).find('a').eq(0).contains('BTCUSD.MF21'); + cy.getByTestId(list).find('a').eq(1).contains('ETHBTC.QM21'); + + cy.getByTestId(searchInput).clear(); + cy.getByTestId(list).find('a').should('have.length', 4); + }); +}); diff --git a/apps/trading-e2e/src/integration/market-summary.cy.ts b/apps/trading-e2e/src/integration/market-summary.cy.ts index 2658a7682..7d68eff8b 100644 --- a/apps/trading-e2e/src/integration/market-summary.cy.ts +++ b/apps/trading-e2e/src/integration/market-summary.cy.ts @@ -31,7 +31,7 @@ describe('Market trading page', () => { // 7002-SORD-001 // 7002-SORD-002 it('must display market name', () => { - cy.getByTestId('popover-trigger').should('not.be.empty'); + cy.getByTestId('header-title').should('not.be.empty'); }); it('must see market expiry', () => { diff --git a/apps/trading-e2e/src/integration/markets.cy.ts b/apps/trading-e2e/src/integration/markets.cy.ts index a189ba737..92cbd2fbb 100644 --- a/apps/trading-e2e/src/integration/markets.cy.ts +++ b/apps/trading-e2e/src/integration/markets.cy.ts @@ -4,7 +4,6 @@ import { marketsQuery } from '@vegaprotocol/mock'; import { getDateTimeFormat } from '@vegaprotocol/utils'; const dialogCloseBtn = 'dialog-close'; -const popoverTrigger = 'popover-trigger'; describe('markets table', { tags: '@smoke' }, () => { beforeEach(() => { @@ -31,36 +30,6 @@ describe('markets table', { tags: '@smoke' }, () => { cy.getByTestId('sparkline-svg').should('be.visible'); }); - it('renders market list drop down', () => { - openMarketDropDown(); - cy.getByTestId('price').invoke('text').should('not.be.empty'); - cy.getByTestId('trading-mode-col').should('not.be.empty'); - cy.getByTestId('taker-fee').should('contain.text', '%'); - cy.getByTestId('market-volume').should('not.be.empty'); - cy.getByTestId('market-name').should('not.be.empty'); - - cy.getByTestId('trading-mode-col') - .contains('Monitoring auction - liquidity') - .eq(0) - .realHover(); - cy.get('[data-testid="trading-mode-tooltip"] p').should('have.class', ''); - cy.get( - '[data-testid="market-trading-mode"] [data-testid="item-value"]' - ).realHover(); - cy.get('[data-testid="trading-mode-tooltip"] p').should( - 'have.class', - 'mb-4' - ); - }); - - it('able to select market from dropdown', () => { - openMarketDropDown(); - cy.getByTestId('market-link-market-0').first().should('be.visible').click(); - cy.contains('ACTIVE MARKET').should('be.visible'); - cy.url().should('include', '/markets/market-0'); - cy.getByTestId('popover-trigger').should('not.be.empty'); - }); - it('able to open and sort full market list - market page', () => { const ExpectedSortedMarkets = [ 'AAPL.MF21', @@ -253,15 +222,3 @@ describe('markets table', { tags: '@smoke' }, () => { }); }); }); - -function openMarketDropDown() { - cy.getByTestId(dialogCloseBtn).then((button) => { - if (button.is(':visible')) { - cy.get('[data-testid^="market-link-"]').should('not.be.empty'); - cy.getByTestId(dialogCloseBtn).click(); - } - cy.get('[data-testid^="ask-vol-"]').should('be.visible'); - cy.getByTestId(popoverTrigger).click({ force: true }); - cy.contains('Loading market data...').should('not.exist'); - }); -} diff --git a/apps/trading/client-pages/market/asset-dropdown.spec.tsx b/apps/trading/client-pages/market/asset-dropdown.spec.tsx new file mode 100644 index 000000000..a3a8ccefa --- /dev/null +++ b/apps/trading/client-pages/market/asset-dropdown.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AssetDropdown } from './asset-dropdown'; + +const createAssets = (count = 3) => { + return new Array(count).fill(null).map((_, i) => ({ + id: i.toString(), + symbol: 'asset-1', + })); +}; + +describe('AssetDropdown', () => { + const assets = createAssets(); + + it('renders and selects chosen assets', async () => { + const mockOnSelect = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole('button')); + const items = screen.getAllByRole('menuitemcheckbox'); + expect(items).toHaveLength(assets.length); + expect(items.map((i) => i.textContent)).toEqual( + assets.map((a) => a.symbol) + ); + await userEvent.click(items[0]); + expect(mockOnSelect).toHaveBeenCalledWith(assets[0].id, true); + }); + + it('unselects already selected assets', async () => { + const mockOnSelect = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole('button')); + const items = screen.getAllByRole('menuitemcheckbox'); + await userEvent.click(items[0]); + expect(mockOnSelect).toHaveBeenCalledWith(assets[0].id, false); + }); + + it('can be reset clearing all assets', async () => { + const mockOnSelect = jest.fn(); + const mockOnReset = jest.fn(); + render( + a.id)} + assets={assets} + onSelect={mockOnSelect} + onReset={mockOnReset} + /> + ); + await userEvent.click(screen.getByRole('button')); + const items = screen.getAllByRole('menuitemcheckbox'); + // all should be checked + items.forEach((item) => { + expect(item).toBeChecked(); + }); + await userEvent.click(screen.getByText('Reset')); + expect(mockOnReset).toHaveBeenCalled(); + }); + + it('doesnt render if no assets provided', async () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/trading/client-pages/market/asset-dropdown.tsx b/apps/trading/client-pages/market/asset-dropdown.tsx new file mode 100644 index 000000000..f2fe74acc --- /dev/null +++ b/apps/trading/client-pages/market/asset-dropdown.tsx @@ -0,0 +1,54 @@ +import { t } from '@vegaprotocol/i18n'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemIndicator, + DropdownMenuTrigger, +} from '@vegaprotocol/ui-toolkit'; + +export const AssetDropdown = ({ + assets, + checkedAssets, + onSelect, + onReset, +}: { + assets: Array<{ id: string; symbol: string }> | undefined; + checkedAssets: string[]; + onSelect: (id: string, checked: boolean) => void; + onReset: () => void; +}) => { + if (!assets?.length) { + return null; + } + + return ( + + } + > + + {t('Reset')} + {assets?.map((a) => { + return ( + { + if (typeof checked === 'boolean') { + onSelect(a.id, checked); + } + }} + data-testid={`asset-id-${a.id}`} + > + {a.symbol} + + + ); + })} + + + ); +}; diff --git a/apps/trading/client-pages/market/trade-market-header.tsx b/apps/trading/client-pages/market/header-stats.tsx similarity index 52% rename from apps/trading/client-pages/market/trade-market-header.tsx rename to apps/trading/client-pages/market/header-stats.tsx index 94c44ea9d..ad567ce5c 100644 --- a/apps/trading/client-pages/market/trade-market-header.tsx +++ b/apps/trading/client-pages/market/header-stats.tsx @@ -5,13 +5,7 @@ import { MarketProposalNotification } from '@vegaprotocol/proposals'; import type { Market } from '@vegaprotocol/market-list'; import { getExpiryDate, getMarketExpiryDate } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; -import { - ColumnKind, - SelectMarketPopover, -} from '../../components/select-market'; -import type { OnCellClickHandler } from '../../components/select-market'; -import { Header, HeaderStat } from '../../components/header'; -import { NO_MARKET } from './constants'; +import { HeaderStat } from '../../components/header'; import { MarketMarkPrice } from '../../components/market-mark-price'; import { Last24hPriceChange, Last24hVolume } from '@vegaprotocol/market-info'; import { MarketState } from '../../components/market-state'; @@ -19,102 +13,90 @@ import { HeaderStatMarketTradingMode } from '../../components/market-trading-mod import { MarketLiquiditySupplied } from '../../components/liquidity-supplied'; import { MarketState as State } from '@vegaprotocol/types'; -interface TradeMarketHeaderProps { +interface HeaderStatsProps { market: Market | null; - onSelect: (marketId: string, metaKey?: boolean) => void; } -export const TradeMarketHeader = ({ - market, - onSelect, -}: TradeMarketHeaderProps) => { +export const HeaderStats = ({ market }: HeaderStatsProps) => { const { VEGA_EXPLORER_URL } = useEnvironment(); const { open: openAssetDetailsDialog } = useAssetDetailsDialogStore(); const asset = market?.tradableInstrument.instrument.product?.settlementAsset; - const onCellClick: OnCellClickHandler = (e, kind, value) => { - if (value && kind === ColumnKind.Asset) { - openAssetDetailsDialog(value, e.target as HTMLElement); - } - }; - return ( -
- } - > - - ) - } - testId="market-expiry" - > - - - - - - - - - - - - - - {asset ? ( - +
+
-
- { - openAssetDetailsDialog(asset.id, e.target as HTMLElement); - }} + + ) + } + testId="market-expiry" + > + + + + + + + + + + + + + + {asset ? ( + - {asset.symbol} - -
- - ) : null} - - -
+
+ { + openAssetDetailsDialog(asset.id, e.target as HTMLElement); + }} + > + {asset.symbol} + +
+ + ) : null} + + + + + ); }; diff --git a/apps/trading/client-pages/market/market-selector-item.spec.tsx b/apps/trading/client-pages/market/market-selector-item.spec.tsx new file mode 100644 index 000000000..d328ab3ae --- /dev/null +++ b/apps/trading/client-pages/market/market-selector-item.spec.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMarketFragment } from '@vegaprotocol/mock'; +import { MarketSelectorItem } from './market-selector-item'; +import { MemoryRouter } from 'react-router-dom'; +import type { MockedResponse } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing'; +import type { + MarketDataUpdateFieldsFragment, + MarketDataUpdateSubscription, +} from '@vegaprotocol/market-list'; +import { MarketDataUpdateDocument } from '@vegaprotocol/market-list'; +import { + AuctionTrigger, + MarketState, + MarketTradingMode, +} from '@vegaprotocol/types'; +import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; + +describe('MarketSelectorItem', () => { + const market = createMarketFragment({ + id: 'market-0', + decimalPlaces: 2, + // @ts-ignore fragment doesn't contain candles + candles: [{ close: '5' }, { close: '10' }], + }); + const marketData: MarketDataUpdateFieldsFragment = { + __typename: 'ObservableMarketData', + marketId: market.id, + auctionEnd: null, + auctionStart: null, + bestBidPrice: '100', + bestBidVolume: '100', + bestOfferPrice: '100', + bestOfferVolume: '100', + bestStaticBidPrice: '100', + bestStaticBidVolume: '100', + bestStaticOfferPrice: '100', + bestStaticOfferVolume: '100', + indicativePrice: '100', + indicativeVolume: '100', + marketState: MarketState.STATE_ACTIVE, + marketTradingMode: MarketTradingMode.TRADING_MODE_CONTINUOUS, + marketValueProxy: '100', + markPrice: '50000', + midPrice: '100', + staticMidPrice: '100', + openInterest: '100', + suppliedStake: '1000000', + targetStake: '1000000', + trigger: AuctionTrigger.AUCTION_TRIGGER_UNSPECIFIED, + priceMonitoringBounds: null, + }; + const mock: MockedResponse = { + request: { + query: MarketDataUpdateDocument, + variables: { + marketId: market.id, + }, + }, + result: { + data: { + marketsData: [marketData], + }, + }, + }; + + const mockOnSelect = jest.fn(); + + const renderJsx = () => { + return render( + + + + + + ); + }; + + it('renders market information', async () => { + renderJsx(); + + const link = screen.getByRole('link'); + // link renders and is styled + expect(link).toHaveAttribute('href', '/markets/' + market.id); + + expect(link).toHaveClass('ring-1'); + + expect(screen.getByTestId('market-item-price')).toHaveTextContent('-'); + + // candles are loaded immediately + expect(screen.getByTestId('market-item-change')).toHaveTextContent( + '+100.00%' + ); + + await waitFor(() => { + expect(screen.getByTestId('market-item-price')).toHaveTextContent( + addDecimalsFormatNumber(marketData.markPrice, market.decimalPlaces) + ); + }); + + await userEvent.click(link); + + expect(mockOnSelect).toHaveBeenCalledWith(market.id); + }); +}); diff --git a/apps/trading/client-pages/market/market-selector-item.tsx b/apps/trading/client-pages/market/market-selector-item.tsx new file mode 100644 index 000000000..c75da537a --- /dev/null +++ b/apps/trading/client-pages/market/market-selector-item.tsx @@ -0,0 +1,115 @@ +import type { CSSProperties } from 'react'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { + addDecimalsFormatNumber, + formatNumber, + priceChangePercentage, +} from '@vegaprotocol/utils'; +import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/market-list'; +import { useMarketDataUpdateSubscription } from '@vegaprotocol/market-list'; +import { Sparkline } from '@vegaprotocol/ui-toolkit'; + +export const MarketSelectorItem = ({ + market, + style, + currentMarketId, + onSelect, +}: { + market: MarketMaybeWithDataAndCandles; + style: CSSProperties; + currentMarketId?: string; + onSelect?: (marketId: string) => void; +}) => { + const wrapperClasses = classNames( + 'block bg-vega-light-100 dark:bg-vega-dark-100 rounded-lg p-4', + 'min-h-[120px]', + { + 'ring-1 ring-vega-light-300 dark:ring-vega-dark-300': + currentMarketId === market.id, + } + ); + return ( +
+ { + onSelect && onSelect(market.id); + }} + > +

{market.tradableInstrument.instrument.code}

+

+ {market.tradableInstrument.instrument.name} +

+ + +
+ ); +}; + +const MarketData = ({ market }: { market: MarketMaybeWithDataAndCandles }) => { + const { data } = useMarketDataUpdateSubscription({ + variables: { + marketId: market.id, + }, + }); + + const marketData = data?.marketsData[0]; + + // use market data price if available as this is comes from + // the subscription + const price = marketData + ? addDecimalsFormatNumber(marketData.markPrice, market.decimalPlaces) + : market.data + ? addDecimalsFormatNumber(market.data.markPrice, market.decimalPlaces) + : '-'; + + return ( +
+
+
+ {price} +
+ {market.candles && ( + c.close)} /> + )} +
+
+ {market.candles ? ( + Number(c.close))} + /> + ) : ( + '-' + )} +
+
+ ); +}; + +const PriceChange = ({ candles }: { candles: string[] }) => { + const priceChange = candles ? priceChangePercentage(candles) : undefined; + const priceChangeClasses = classNames('text-xs', { + 'text-vega-pink': priceChange && priceChange < 0, + 'text-vega-green': priceChange && priceChange > 0, + }); + let prefix = ''; + if (priceChange && priceChange > 0) { + prefix = '+'; + } + const formattedChange = formatNumber(Number(priceChange), 2); + return ( +
+ {priceChange ? `${prefix}${formattedChange}%` : '-'} +
+ ); +}; diff --git a/apps/trading/client-pages/market/market-selector.spec.tsx b/apps/trading/client-pages/market/market-selector.spec.tsx new file mode 100644 index 000000000..c49677d13 --- /dev/null +++ b/apps/trading/client-pages/market/market-selector.spec.tsx @@ -0,0 +1,289 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MarketSelector } from './market-selector'; +import { useMarketList } from '@vegaprotocol/market-list'; +import { createMarketFragment } from '@vegaprotocol/mock'; +import { MarketState } from '@vegaprotocol/types'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import type { SortType } from './sort-dropdown'; +import { SortTypeMapping } from './sort-dropdown'; +import { Sort } from './sort-dropdown'; +import { subDays } from 'date-fns'; + +jest.mock('@vegaprotocol/market-list'); +const mockUseMarketList = useMarketList as jest.Mock; + +// mock market list items to avoid subscriptions starting +jest.mock('./market-selector-item', () => ({ + MarketSelectorItem: (props: { market: { id: string } }) => ( +
+ ), +})); + +// without a real DOM autosize won't render with an actual height or width +jest.mock('react-virtualized-auto-sizer', () => { + // eslint-disable-next-line react/display-name + return ({ + children, + }: { + children: (size: { width: number; height: number }) => ReactNode; + }) =>
{children({ width: 300, height: 1000 })}
; +}); + +describe('MarketSelector', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + tradableInstrument: { + instrument: { + code: 'a', + name: 'a', + product: { + settlementAsset: { + id: 'asset-0', + }, + }, + }, + }, + // @ts-ignore candles get joined outside this type + candles: [{ close: '100' }, { close: '200' }], + marketTimestamps: { + open: subDays(new Date(), 1).toISOString(), + }, + }), + createMarketFragment({ + id: 'market-1', + state: MarketState.STATE_SUSPENDED, + tradableInstrument: { + instrument: { + code: 'b', + name: 'b', + product: { + settlementAsset: { + id: 'asset-0', + }, + }, + }, + }, + // @ts-ignore candles get joined outside this type + candles: [{ close: '100' }, { close: '400' }], + marketTimestamps: { + open: subDays(new Date(), 2).toISOString(), + }, + }), + createMarketFragment({ + id: 'market-2', + state: MarketState.STATE_CLOSED, + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + id: 'asset-1', + }, + }, + }, + }, + }), + createMarketFragment({ + id: 'market-3', + state: MarketState.STATE_ACTIVE, + tradableInstrument: { + instrument: { + code: 'c', + name: 'c', + product: { + settlementAsset: { + id: 'asset-1', + }, + }, + }, + }, + // @ts-ignore candles get joined outside this type + candles: [{ close: '100' }, { close: '1000' }], + marketTimestamps: { + open: subDays(new Date(), 0).toISOString(), + }, + }), + createMarketFragment({ + id: 'market-4', + tradableInstrument: { + instrument: { + code: 'cd', + name: 'cd', + product: { + settlementAsset: { + id: 'asset-2', + }, + }, + }, + }, + // @ts-ignore candles get joined outside this type + candles: [{ close: '100' }, { close: '300' }], + marketTimestamps: { + open: subDays(new Date(), 3).toISOString(), + }, + }), + ]; + + const activeMarkets = markets.filter((m) => + [MarketState.STATE_ACTIVE, MarketState.STATE_SUSPENDED].includes(m.state) + ); + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + + it('renders only active markets', () => { + render( + + + + ); + expect(screen.getAllByTestId(/market-\d/)).toHaveLength( + activeMarkets.length + ); + expect(screen.getByRole('link')).toHaveTextContent('All markets'); + }); + + it('filters by product type', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('product-Spot')); + expect(screen.queryAllByTestId(/market-\d/)).toHaveLength(0); + expect(screen.getByTestId('no-items')).toHaveTextContent( + 'Spot markets coming soon.' + ); + + await userEvent.click(screen.getByTestId('product-Perpetual')); + expect(screen.queryAllByTestId(/market-\d/)).toHaveLength(0); + expect(screen.getByTestId('no-items')).toHaveTextContent( + 'Perpetual markets coming soon.' + ); + + await userEvent.click(screen.getByTestId('product-Future')); + expect(screen.queryAllByTestId(/market-\d/)).toHaveLength( + activeMarkets.length + ); + expect(screen.queryByTestId('no-items')).not.toBeInTheDocument(); + }); + + it('filters by search term', async () => { + render( + + + + ); + + await userEvent.type(screen.getByTestId('search-term'), 'zzz'); + expect(screen.getByTestId('no-items')).toHaveTextContent('No markets'); + const input = screen.getByTestId('search-term'); + await userEvent.clear(input); + await userEvent.type(input, 'a'); + expect(input).toHaveValue('a'); + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(1); + expect(screen.getByTestId('market-0')).toBeInTheDocument(); + + await userEvent.clear(input); + await userEvent.type(input, 'b'); + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(1); + expect(screen.getByTestId('market-1')).toBeInTheDocument(); + + await userEvent.clear(input); + await userEvent.type(input, 'c'); + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(2); + expect(screen.getByTestId('market-3')).toBeInTheDocument(); + expect(screen.getByTestId('market-4')).toBeInTheDocument(); + }); + + it('filters by asset', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('asset-trigger')); + expect(screen.getAllByTestId(/asset-id/)).toHaveLength(3); + await userEvent.click(screen.getByTestId('asset-id-asset-0')); + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(2); + expect(screen.getByTestId('market-0')).toBeInTheDocument(); + expect(screen.getByTestId('market-1')).toBeInTheDocument(); + + // reopen asset dropdown and add asset-1 + await userEvent.click(screen.getByTestId('asset-trigger')); + await userEvent.click(screen.getByTestId('asset-id-asset-1')); + + // all markets with asset-0 or asset-1 shown (no market id as market is closed) + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(3); + expect(screen.getByTestId('market-0')).toBeInTheDocument(); + expect(screen.getByTestId('market-1')).toBeInTheDocument(); + expect(screen.getByTestId('market-3')).toBeInTheDocument(); + + // reopen and uncheck asset-0 + await userEvent.click(screen.getByTestId('asset-trigger')); + await userEvent.click(screen.getByTestId('asset-id-asset-0')); + + expect(screen.getAllByTestId(/market-\d/)).toHaveLength(1); + expect(screen.getByTestId('market-3')).toBeInTheDocument(); + }); + + it('sorts by gained', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sort-trigger')); + const options = screen.getAllByTestId(/sort-item/); + expect(options.map((o) => o.textContent)).toEqual( + Object.entries(Sort) + .filter(([key]) => key !== Sort.None) + .map(([key]) => SortTypeMapping[key as SortType]) + ); + await userEvent.click(screen.getByTestId('sort-item-Gained')); + expect( + screen + .getAllByTestId(/market-\d/) + .map((el) => el.getAttribute('data-testid')) + ).toEqual([markets[3].id, markets[1].id, markets[4].id, markets[0].id]); + }); + + it('sorts by lost', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sort-trigger')); + await userEvent.click(screen.getByTestId('sort-item-Lost')); + expect( + screen + .getAllByTestId(/market-\d/) + .map((el) => el.getAttribute('data-testid')) + ).toEqual([markets[0].id, markets[4].id, markets[1].id, markets[3].id]); + }); + + it('sorts by new', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sort-trigger')); + await userEvent.click(screen.getByTestId('sort-item-New')); + expect( + screen + .getAllByTestId(/market-\d/) + .map((el) => el.getAttribute('data-testid')) + ).toEqual([markets[3].id, markets[0].id, markets[1].id, markets[4].id]); + }); +}); diff --git a/apps/trading/client-pages/market/market-selector.tsx b/apps/trading/client-pages/market/market-selector.tsx new file mode 100644 index 000000000..05f4e4f39 --- /dev/null +++ b/apps/trading/client-pages/market/market-selector.tsx @@ -0,0 +1,253 @@ +import { t } from '@vegaprotocol/i18n'; +import uniqBy from 'lodash/uniqBy'; +import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/market-list'; +import { TinyScroll, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit'; +import type { CSSProperties } from 'react'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { FixedSizeList } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { useMarketSelectorList } from './use-market-selector-list'; +import type { ProductType } from './product-selector'; +import { Product, ProductSelector } from './product-selector'; +import { AssetDropdown } from './asset-dropdown'; +import type { SortType } from './sort-dropdown'; +import { Sort, SortDropdown } from './sort-dropdown'; +import { MarketSelectorItem } from './market-selector-item'; + +export type Filter = { + searchTerm: string; + product: ProductType; + sort: SortType; + assets: string[]; +}; + +/** + * Fetches market data and filters it given a set of filter properties + * defined in Filter + */ +export const MarketSelector = ({ + currentMarketId, + onSelect, +}: { + currentMarketId?: string; + onSelect?: (marketId: string) => void; +}) => { + const [filter, setFilter] = useState({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + + const { markets, data, loading, error } = useMarketSelectorList(filter); + + return ( +
+
+ { + setFilter((curr) => ({ ...curr, product })); + }} + /> +
+ + setFilter((curr) => ({ ...curr, searchTerm: e.target.value })) + } + type="text" + placeholder={t('Search')} + className="flex-1 block border border-vega-light-300 dark:border-vega-dark-300 p-2 rounded bg-transparent w-48" + data-testid="search-term" + /> + d.tradableInstrument.instrument.product.settlementAsset + ), + 'id' + )} + checkedAssets={filter.assets} + onSelect={(id: string, checked) => { + setFilter((curr) => { + if (checked) { + if (curr.assets.includes(id)) { + return curr; + } else { + return { ...curr, assets: [...curr.assets, id] }; + } + } else { + if (curr.assets.includes(id)) { + return { + ...curr, + assets: curr.assets.filter((x) => x !== id), + }; + } + } + return curr; + }); + }} + onReset={() => setFilter((curr) => ({ ...curr, assets: [] }))} + /> + { + setFilter((curr) => { + if (curr.sort === sort) { + return { ...curr, sort: Sort.None }; + } + return { + ...curr, + sort, + }; + }); + }} + /> +
+
+
+ +
+
+ + + {t('All markets')} + + + +
+
+ ); +}; + +const MarketList = ({ + data, + error, + loading, + currentMarketId, + onSelect, + noItems, +}: { + data: MarketMaybeWithDataAndCandles[]; + error: Error | undefined; + loading: boolean; + searchTerm: string; + currentMarketId?: string; + onSelect?: (marketId: string) => void; + noItems: string; +}) => { + if (error) { + return
{error.message}
; + } + + return ( + + {({ width, height }) => ( + + + + )} + + ); +}; + +const List = ({ + data, + loading, + width, + height, + onSelect, + noItems, + currentMarketId, +}: { + data: MarketMaybeWithDataAndCandles[]; + loading: boolean; + width: number; + height: number; + noItems: string; + onSelect?: (marketId: string) => void; + currentMarketId?: string; +}) => { + const row = ({ index, style }: { index: number; style: CSSProperties }) => { + const market = data[index]; + + return ( + + ); + }; + + if (!data || loading) { + return ( +
+ + +
+ ); + } + + if (!data.length) { + return ( +
+
+
+ {noItems} +
+
+
+ ); + } + + return ( + + {row} + + ); +}; + +const Skeleton = () => { + return ( +
+
+
+
+
+
+ ); +}; diff --git a/apps/trading/client-pages/market/market.tsx b/apps/trading/client-pages/market/market.tsx index 501892022..7fdb1faa0 100644 --- a/apps/trading/client-pages/market/market.tsx +++ b/apps/trading/client-pages/market/market.tsx @@ -9,7 +9,8 @@ import { import { AsyncRenderer, ExternalLink, Splash } from '@vegaprotocol/ui-toolkit'; import { marketProvider, marketDataProvider } from '@vegaprotocol/market-list'; import { useGlobalStore, usePageTitleStore } from '../../stores'; -import { TradeGrid, TradePanels } from './trade-grid'; +import { TradeGrid } from './trade-grid'; +import { TradePanels } from './trade-panels'; import { useNavigate, useParams } from 'react-router-dom'; import { Links, Routes } from '../../pages/client-router'; import { useMarketClickHandler } from '../../lib/hooks/use-market-click-handler'; @@ -100,6 +101,7 @@ export const MarketPage = () => { /> ); }, [largeScreen, data, onSelect, navigate]); + if (!data && marketId) { return ( diff --git a/apps/trading/client-pages/market/product-selector.tsx b/apps/trading/client-pages/market/product-selector.tsx new file mode 100644 index 000000000..035a3c49f --- /dev/null +++ b/apps/trading/client-pages/market/product-selector.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; + +// Make sure these match the available __typename properties on product +export const Product = { + Spot: 'Spot', + Future: 'Future', + Perpetual: 'Perpetual', +} as const; + +export type ProductType = keyof typeof Product; + +const ProductTypeMapping: { + [key in ProductType]: string; +} = { + [Product.Spot]: 'Spot', + [Product.Future]: 'Futures', + [Product.Perpetual]: 'Perpetuals', +}; + +export const ProductSelector = ({ + product, + onSelect, +}: { + product: ProductType; + onSelect: (product: ProductType) => void; +}) => { + return ( +
+ {Object.keys(Product).map((t) => { + const classes = classNames('py-1 border-b-2', { + 'border-vega-yellow text-black dark:text-white': t === product, + 'border-transparent text-vega-light-300 dark:text-vega-dark-300': + t !== product, + }); + return ( + + ); + })} +
+ ); +}; diff --git a/apps/trading/client-pages/market/sort-dropdown.tsx b/apps/trading/client-pages/market/sort-dropdown.tsx new file mode 100644 index 000000000..1014d373e --- /dev/null +++ b/apps/trading/client-pages/market/sort-dropdown.tsx @@ -0,0 +1,68 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItemIndicator, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@vegaprotocol/ui-toolkit'; + +export const Sort = { + None: 'None', + Gained: 'Gained', + Lost: 'Lost', + New: 'New', +} as const; + +export type SortType = keyof typeof Sort; + +export const SortTypeMapping: { + [key in SortType]: string; +} = { + [Sort.None]: 'None', + [Sort.Gained]: 'Top gaining', + [Sort.Lost]: 'Top losing', + [Sort.New]: 'New markets', +}; + +export const SortDropdown = ({ + currentSort, + onSelect, +}: { + currentSort: SortType; + onSelect: (sort: SortType) => void; +}) => { + return ( + + } + > + + onSelect(value as SortType)} + > + {Object.keys(Sort) + .filter((s) => s !== Sort.None) + .map((key) => { + return ( + + {SortTypeMapping[key as SortType]} + + + ); + })} + + + + ); +}; diff --git a/apps/trading/client-pages/market/trade-grid.tsx b/apps/trading/client-pages/market/trade-grid.tsx index 750185995..629bfc589 100644 --- a/apps/trading/client-pages/market/trade-grid.tsx +++ b/apps/trading/client-pages/market/trade-grid.tsx @@ -1,120 +1,37 @@ -import { DealTicketContainer } from '@vegaprotocol/deal-ticket'; -import { MarketInfoAccordionContainer } from '@vegaprotocol/market-info'; -import { OrderbookContainer } from '@vegaprotocol/market-depth'; -import { OrderListContainer, Filter } from '@vegaprotocol/orders'; -import type { OrderListContainerProps } from '@vegaprotocol/orders'; -import { FillsContainer } from '@vegaprotocol/fills'; -import { PositionsContainer } from '@vegaprotocol/positions'; -import { TradesContainer } from '@vegaprotocol/trades'; +import { memo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; import { LayoutPriority } from 'allotment'; import classNames from 'classnames'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { memo, useState } from 'react'; -import type { ReactNode, ComponentProps } from 'react'; -import { DepthChartContainer } from '@vegaprotocol/market-depth'; -import { CandlesChartContainer } from '@vegaprotocol/candles-chart'; -import { OracleBanner } from '@vegaprotocol/market-info'; -import { - Tab, - LocalStoragePersistTabs as Tabs, - Splash, -} from '@vegaprotocol/ui-toolkit'; -import { t } from '@vegaprotocol/i18n'; -import { AccountsContainer } from '../../components/accounts-container'; -import type { Market } from '@vegaprotocol/market-list'; -import { VegaWalletContainer } from '../../components/vega-wallet-container'; -import { TradeMarketHeader } from './trade-market-header'; -import { NO_MARKET } from './constants'; -import { LiquidityContainer } from '../liquidity/liquidity'; -import { useNavigate } from 'react-router-dom'; import type { PinnedAsset } from '@vegaprotocol/accounts'; +import { t } from '@vegaprotocol/i18n'; +import { OracleBanner } from '@vegaprotocol/market-info'; +import type { Market } from '@vegaprotocol/market-list'; +import { Filter } from '@vegaprotocol/orders'; import { usePaneLayout, useScreenDimensions, } from '@vegaprotocol/react-helpers'; +import { + Tab, + LocalStoragePersistTabs as Tabs, + VegaIcon, + VegaIconNames, +} from '@vegaprotocol/ui-toolkit'; import { useMarketClickHandler, useMarketLiquidityClickHandler, } from '../../lib/hooks/use-market-click-handler'; +import { VegaWalletContainer } from '../../components/vega-wallet-container'; +import { HeaderTitle } from '../../components/header'; import { ResizableGrid, ResizableGridPanel, } from '../../components/resizable-grid'; - -type MarketDependantView = - | typeof CandlesChartContainer - | typeof DepthChartContainer - | typeof DealTicketContainer - | typeof MarketInfoAccordionContainer - | typeof OrderbookContainer - | typeof TradesContainer; - -type MarketDependantViewProps = ComponentProps; - -const requiresMarket = (View: MarketDependantView) => { - const WrappedComponent = (props: MarketDependantViewProps) => - props.marketId ? : {NO_MARKET}; - WrappedComponent.displayName = `RequiresMarket(${View.name})`; - return WrappedComponent; -}; - -const TradingViews = { - candles: { - label: 'Candles', - component: requiresMarket(CandlesChartContainer), - }, - depth: { - label: 'Depth', - component: requiresMarket(DepthChartContainer), - }, - liquidity: { - label: 'Liquidity', - component: requiresMarket(LiquidityContainer), - }, - ticket: { - label: 'Ticket', - component: requiresMarket(DealTicketContainer), - }, - info: { - label: 'Info', - component: requiresMarket(MarketInfoAccordionContainer), - }, - orderbook: { - label: 'Orderbook', - component: requiresMarket(OrderbookContainer), - }, - trades: { - label: 'Trades', - component: requiresMarket(TradesContainer), - }, - positions: { label: 'Positions', component: PositionsContainer }, - activeOrders: { - label: 'Active', - component: (props: OrderListContainerProps) => ( - - ), - }, - closedOrders: { - label: 'Closed', - component: (props: OrderListContainerProps) => ( - - ), - }, - rejectedOrders: { - label: 'Rejected', - component: (props: OrderListContainerProps) => ( - - ), - }, - orders: { - label: 'All', - component: OrderListContainer, - }, - collateral: { label: 'Collateral', component: AccountsContainer }, - fills: { label: 'Fills', component: FillsContainer }, -}; - -type TradingView = keyof typeof TradingViews; +import { TradingViews } from './trade-views'; +import { MarketSelector } from './market-selector'; +import { HeaderStats } from './header-stats'; interface TradeGridProps { market: Market | null; @@ -410,18 +327,57 @@ const MainGrid = memo( ); MainGrid.displayName = 'MainGrid'; -export const TradeGrid = ({ - market, - onSelect, - pinnedAsset, -}: TradeGridProps) => { +export const TradeGrid = ({ market, pinnedAsset }: TradeGridProps) => { + const [sidebarOpen, setSidebarOpen] = useState(true); + const wrapperClasses = classNames( + 'h-full grid', + 'grid-rows-[min-content_min-content_1fr]', + 'grid-cols-[300px_1fr]' + ); + const paneWrapperClasses = classNames('min-h-0', { + 'col-span-2 col-start-1': !sidebarOpen, + }); + return ( -
-
- +
+
+
+ + +
+
+
+ +
+
- + {sidebarOpen && ( +
+
+ +
+
+ )} +
+ +
); }; @@ -439,88 +395,3 @@ const TradeGridChild = ({ children }: TradeGridChildProps) => { ); }; - -interface TradePanelsProps { - market: Market | null; - onSelect: (marketId: string, metaKey?: boolean) => void; - onMarketClick?: (marketId: string) => void; - onOrderTypeClick?: (marketId: string) => void; - onClickCollateral: () => void; - pinnedAsset?: PinnedAsset; -} - -export const TradePanels = ({ - market, - onSelect, - onClickCollateral, - pinnedAsset, -}: TradePanelsProps) => { - const onMarketClick = useMarketClickHandler(true); - const onOrderTypeClick = useMarketLiquidityClickHandler(true); - - const [view, setView] = useState('candles'); - const renderView = () => { - const Component = memo<{ - marketId: string; - onSelect: (marketId: string, metaKey?: boolean) => void; - onMarketClick?: (marketId: string) => void; - onOrderTypeClick?: (marketId: string) => void; - onClickCollateral: () => void; - pinnedAsset?: PinnedAsset; - }>(TradingViews[view].component); - - if (!Component) { - throw new Error(`No component for view: ${view}`); - } - - if (!market) return {NO_MARKET}; - - return ( - - ); - }; - - return ( -
-
- - -
-
- - {({ width, height }) => ( -
- {renderView()} -
- )} -
-
-
- {Object.keys(TradingViews).map((key) => { - const isActive = view === key; - const className = classNames('p-4 min-w-[100px] capitalize', { - 'text-black dark:text-vega-yellow': isActive, - 'bg-neutral-200 dark:bg-neutral-800': isActive, - }); - return ( - - ); - })} -
-
- ); -}; diff --git a/apps/trading/client-pages/market/trade-panels.tsx b/apps/trading/client-pages/market/trade-panels.tsx new file mode 100644 index 000000000..7fe3828d1 --- /dev/null +++ b/apps/trading/client-pages/market/trade-panels.tsx @@ -0,0 +1,146 @@ +import type { PinnedAsset } from '@vegaprotocol/accounts'; +import type { Market } from '@vegaprotocol/market-list'; +import { OracleBanner } from '@vegaprotocol/market-info'; +import { + useMarketClickHandler, + useMarketLiquidityClickHandler, +} from '../../lib/hooks/use-market-click-handler'; +import type { TradingView } from './trade-views'; +import { TradingViews } from './trade-views'; +import { memo, useState } from 'react'; +import { + Icon, + Splash, + VegaIcon, + VegaIconNames, +} from '@vegaprotocol/ui-toolkit'; +import { NO_MARKET } from './constants'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import classNames from 'classnames'; +import { HeaderStats } from './header-stats'; +import * as DialogPrimitives from '@radix-ui/react-dialog'; +import { HeaderTitle } from '../../components/header'; +import { MarketSelector } from './market-selector'; + +interface TradePanelsProps { + market: Market | null; + onSelect: (marketId: string, metaKey?: boolean) => void; + onMarketClick?: (marketId: string) => void; + onOrderTypeClick?: (marketId: string) => void; + onClickCollateral: () => void; + pinnedAsset?: PinnedAsset; +} + +export const TradePanels = ({ + market, + onSelect, + onClickCollateral, + pinnedAsset, +}: TradePanelsProps) => { + const [drawerOpen, setDrawerOpen] = useState(false); + const onMarketClick = useMarketClickHandler(true); + const onOrderTypeClick = useMarketLiquidityClickHandler(true); + + const [view, setView] = useState('candles'); + const renderView = () => { + const Component = memo<{ + marketId: string; + onSelect: (marketId: string, metaKey?: boolean) => void; + onMarketClick?: (marketId: string) => void; + onOrderTypeClick?: (marketId: string) => void; + onClickCollateral: () => void; + pinnedAsset?: PinnedAsset; + }>(TradingViews[view].component); + + if (!Component) { + throw new Error(`No component for view: ${view}`); + } + + if (!market) return {NO_MARKET}; + + return ( + + ); + }; + + return ( +
+
+
+ + +
+ +
+
+ +
+
+ + {({ width, height }) => ( +
+ {renderView()} +
+ )} +
+
+
+ {Object.keys(TradingViews).map((key) => { + const isActive = view === key; + const className = classNames('p-4 min-w-[100px] capitalize', { + 'text-black dark:text-vega-yellow': isActive, + 'bg-neutral-200 dark:bg-neutral-800': isActive, + }); + return ( + + ); + })} +
+ + + + + + + + {drawerOpen && ( + setDrawerOpen(false)} /> + )} + + + +
+ ); +}; diff --git a/apps/trading/client-pages/market/trade-views.tsx b/apps/trading/client-pages/market/trade-views.tsx new file mode 100644 index 000000000..136dc3bf6 --- /dev/null +++ b/apps/trading/client-pages/market/trade-views.tsx @@ -0,0 +1,90 @@ +import { DealTicketContainer } from '@vegaprotocol/deal-ticket'; +import { MarketInfoAccordionContainer } from '@vegaprotocol/market-info'; +import { OrderbookContainer } from '@vegaprotocol/market-depth'; +import { OrderListContainer, Filter } from '@vegaprotocol/orders'; +import type { OrderListContainerProps } from '@vegaprotocol/orders'; +import { FillsContainer } from '@vegaprotocol/fills'; +import { PositionsContainer } from '@vegaprotocol/positions'; +import { TradesContainer } from '@vegaprotocol/trades'; +import type { ComponentProps } from 'react'; +import { DepthChartContainer } from '@vegaprotocol/market-depth'; +import { CandlesChartContainer } from '@vegaprotocol/candles-chart'; +import { Splash } from '@vegaprotocol/ui-toolkit'; +import { AccountsContainer } from '../../components/accounts-container'; +import { NO_MARKET } from './constants'; +import { LiquidityContainer } from '../liquidity/liquidity'; + +type MarketDependantView = + | typeof CandlesChartContainer + | typeof DepthChartContainer + | typeof DealTicketContainer + | typeof MarketInfoAccordionContainer + | typeof OrderbookContainer + | typeof TradesContainer; + +type MarketDependantViewProps = ComponentProps; + +const requiresMarket = (View: MarketDependantView) => { + const WrappedComponent = (props: MarketDependantViewProps) => + props.marketId ? : {NO_MARKET}; + WrappedComponent.displayName = `RequiresMarket(${View.name})`; + return WrappedComponent; +}; + +export type TradingView = keyof typeof TradingViews; + +export const TradingViews = { + candles: { + label: 'Candles', + component: requiresMarket(CandlesChartContainer), + }, + depth: { + label: 'Depth', + component: requiresMarket(DepthChartContainer), + }, + liquidity: { + label: 'Liquidity', + component: requiresMarket(LiquidityContainer), + }, + ticket: { + label: 'Ticket', + component: requiresMarket(DealTicketContainer), + }, + info: { + label: 'Info', + component: requiresMarket(MarketInfoAccordionContainer), + }, + orderbook: { + label: 'Orderbook', + component: requiresMarket(OrderbookContainer), + }, + trades: { + label: 'Trades', + component: requiresMarket(TradesContainer), + }, + positions: { label: 'Positions', component: PositionsContainer }, + activeOrders: { + label: 'Active', + component: (props: OrderListContainerProps) => ( + + ), + }, + closedOrders: { + label: 'Closed', + component: (props: OrderListContainerProps) => ( + + ), + }, + rejectedOrders: { + label: 'Rejected', + component: (props: OrderListContainerProps) => ( + + ), + }, + orders: { + label: 'All', + component: OrderListContainer, + }, + collateral: { label: 'Collateral', component: AccountsContainer }, + fills: { label: 'Fills', component: FillsContainer }, +}; diff --git a/apps/trading/client-pages/market/use-market-selector-list.spec.tsx b/apps/trading/client-pages/market/use-market-selector-list.spec.tsx new file mode 100644 index 000000000..bc01ac605 --- /dev/null +++ b/apps/trading/client-pages/market/use-market-selector-list.spec.tsx @@ -0,0 +1,473 @@ +import merge from 'lodash/merge'; +import { renderHook } from '@testing-library/react'; +import { useMarketSelectorList } from './use-market-selector-list'; +import { Product } from './product-selector'; +import { Sort } from './sort-dropdown'; +import { createMarketFragment } from '@vegaprotocol/mock'; +import { MarketState } from '@vegaprotocol/types'; +import { useMarketList } from '@vegaprotocol/market-list'; +import type { Filter } from './market-selector'; +import { subDays } from 'date-fns'; + +jest.mock('@vegaprotocol/market-list'); +const mockUseMarketList = useMarketList as jest.Mock; + +describe('useMarketSelectorList', () => { + const setup = (initialArgs?: Partial) => { + const defaultArgs: Filter = { + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: [], + }; + return renderHook((args) => useMarketSelectorList(args), { + initialProps: merge(defaultArgs, initialArgs), + }); + }; + + it('returns all markets active and suspended markets', () => { + const markets = [ + createMarketFragment({ id: 'market-0' }), + createMarketFragment({ + id: 'market-1', + state: MarketState.STATE_SUSPENDED, + }), + createMarketFragment({ + id: 'market-2', + state: MarketState.STATE_CLOSED, + }), + createMarketFragment({ + id: 'market-3', + state: MarketState.STATE_PENDING, + }), + ]; + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result } = setup(); + const expectedFilteredMarkets = markets.filter((m) => + [MarketState.STATE_ACTIVE, MarketState.STATE_SUSPENDED].includes(m.state) + ); + expect(result.current).toEqual({ + data: markets, + markets: expectedFilteredMarkets, + loading: false, + error: undefined, + }); + }); + + it('filters by product', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + tradableInstrument: { + instrument: { + product: { + __typename: 'Future', + }, + }, + }, + }), + createMarketFragment({ + id: 'market-1', + tradableInstrument: { + instrument: { + product: { + __typename: 'Spot' as 'Future', // spot isn't in schema yet + }, + }, + }, + }), + createMarketFragment({ + id: 'market-2', + tradableInstrument: { + instrument: { + product: { + __typename: 'Perpetual' as 'Future', // spot isn't in schema yet + }, + }, + }, + }), + ]; + + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result, rerender } = setup(); + expect(result.current.markets).toEqual([markets[0]]); + rerender({ + searchTerm: '', + product: Product.Spot as 'Future', + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[1]]); + rerender({ + searchTerm: '', + product: Product.Perpetual as 'Future', + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[2]]); + }); + + it('filters by asset', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + id: 'asset-0', + }, + }, + }, + }, + }), + createMarketFragment({ + id: 'market-1', + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + id: 'asset-0', + }, + }, + }, + }, + }), + createMarketFragment({ + id: 'market-2', + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + id: 'asset-1', + }, + }, + }, + }, + }), + createMarketFragment({ + id: 'market-3', + tradableInstrument: { + instrument: { + product: { + settlementAsset: { + id: 'asset-2', + }, + }, + }, + }, + }), + ]; + + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result, rerender } = setup({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: ['asset-0'], + }); + expect(result.current.markets).toEqual([markets[0], markets[1]]); + + rerender({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: ['asset-0', 'asset-1'], + }); + + expect(result.current.markets).toEqual([ + markets[0], + markets[1], + markets[2], + ]); + + rerender({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: ['asset-0', 'asset-1', 'asset-2'], + }); + + // all assets selected + expect(result.current.markets).toEqual(markets); + + rerender({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: ['asset-invalid'], + }); + + expect(result.current.markets).toEqual([]); + }); + + it('filters by search term', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + tradableInstrument: { + instrument: { + code: 'abc', + name: 'aaa', + }, + }, + }), + createMarketFragment({ + id: 'market-1', + tradableInstrument: { + instrument: { + code: 'def', + name: 'ggg', + }, + }, + }), + createMarketFragment({ + id: 'market-2', + tradableInstrument: { + instrument: { + code: 'defg', + name: 'gggh', + }, + }, + }), + createMarketFragment({ + id: 'market-3', + tradableInstrument: { + instrument: { + code: 'ggg', + name: 'foo', + }, + }, + }), + ]; + + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result, rerender } = setup({ + searchTerm: 'abc', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[0]]); + rerender({ + searchTerm: 'def', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[1], markets[2]]); + rerender({ + searchTerm: 'defg', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[2]]); + rerender({ + searchTerm: 'zzz', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([]); + + // by name + rerender({ + searchTerm: 'aaa', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([markets[0]]); + rerender({ + searchTerm: 'ggg', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([ + markets[1], + markets[2], + markets[3], + ]); + }); + + it('sorts albphabetically', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + tradableInstrument: { + instrument: { + code: 'd', + }, + }, + }), + createMarketFragment({ + id: 'market-1', + tradableInstrument: { + instrument: { + code: 'b', + }, + }, + }), + createMarketFragment({ + id: 'market-2', + tradableInstrument: { + instrument: { + code: 'a', + }, + }, + }), + createMarketFragment({ + id: 'market-3', + tradableInstrument: { + instrument: { + code: 'c', + }, + }, + }), + ]; + + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + + const { result } = setup({ + searchTerm: '', + product: Product.Future, + sort: Sort.None, + assets: [], + }); + expect(result.current.markets).toEqual([ + markets[2], + markets[1], + markets[3], + markets[0], + ]); + }); + + it('sorts by gained', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + // @ts-ignore actual fragment doesnt contain candles and is joined later + candles: [ + { + close: '100', + }, + { + close: '200', + }, + ], + }), + createMarketFragment({ + id: 'market-1', + // @ts-ignore actual fragment doesnt contain candles and is joined later + candles: [ + { + close: '100', + }, + { + close: '1000', + }, + ], + }), + createMarketFragment({ + id: 'market-2', + // @ts-ignore actual fragment doesnt contain candles and is joined later + candles: [ + { + close: '100', + }, + { + close: '400', + }, + ], + }), + ]; + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result, rerender } = setup({ + searchTerm: '', + product: Product.Future, + sort: Sort.Gained, + assets: [], + }); + expect(result.current.markets).toEqual([ + markets[1], + markets[2], + markets[0], + ]); + rerender({ + searchTerm: '', + product: Product.Future, + sort: Sort.Lost as 'Gained', + assets: [], + }); + expect(result.current.markets).toEqual([ + markets[0], + markets[2], + markets[1], + ]); + }); + + it('sorts by open timestamp', () => { + const markets = [ + createMarketFragment({ + id: 'market-0', + marketTimestamps: { + open: subDays(new Date(), 3).toISOString(), + }, + }), + createMarketFragment({ + id: 'market-1', + marketTimestamps: { + open: subDays(new Date(), 1).toISOString(), + }, + }), + createMarketFragment({ + id: 'market-2', + marketTimestamps: { + open: subDays(new Date(), 2).toISOString(), + }, + }), + ]; + mockUseMarketList.mockReturnValue({ + data: markets, + loading: false, + error: undefined, + }); + const { result } = setup({ + searchTerm: '', + product: Product.Future, + sort: Sort.New, + assets: [], + }); + expect(result.current.markets).toEqual([ + markets[1], + markets[2], + markets[0], + ]); + }); +}); diff --git a/apps/trading/client-pages/market/use-market-selector-list.ts b/apps/trading/client-pages/market/use-market-selector-list.ts new file mode 100644 index 000000000..f823c6b58 --- /dev/null +++ b/apps/trading/client-pages/market/use-market-selector-list.ts @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import orderBy from 'lodash/orderBy'; +import { MarketState } from '@vegaprotocol/types'; +import { useMarketList } from '@vegaprotocol/market-list'; +import { priceChangePercentage } from '@vegaprotocol/utils'; +import type { Filter } from './market-selector'; +import { Sort } from './sort-dropdown'; + +export const useMarketSelectorList = ({ + product, + assets, + sort, + searchTerm, +}: Filter) => { + const { data, loading, error } = useMarketList(); + + const markets = useMemo(() => { + if (!data?.length) return []; + const markets = data + // only active + .filter((m) => { + return [MarketState.STATE_ACTIVE, MarketState.STATE_SUSPENDED].includes( + m.state + ); + }) + // only selected product type + .filter((m) => { + if (m.tradableInstrument.instrument.product.__typename === product) { + return true; + } + return false; + }) + .filter((m) => { + if (assets.length === 0) return true; + return assets.includes( + m.tradableInstrument.instrument.product.settlementAsset.id + ); + }) + // filter based on search term + .filter((m) => { + const code = m.tradableInstrument.instrument.code.toLowerCase(); + const name = m.tradableInstrument.instrument.name.toLowerCase(); + if ( + code.includes(searchTerm.toLowerCase()) || + name.includes(searchTerm.toLowerCase()) + ) { + return true; + } + return false; + }); + + if (sort === Sort.None) { + return orderBy(markets, ['tradableInstrument.instrument.code'], ['asc']); + } + + if (sort === Sort.Gained || sort === Sort.Lost) { + const dir = sort === Sort.Gained ? 'desc' : 'asc'; + return orderBy( + markets, + [ + (m) => { + if (!m.candles?.length) return 0; + return Number(priceChangePercentage(m.candles.map((c) => c.close))); + }, + ], + [dir] + ); + } + + if (sort === Sort.New) { + return orderBy( + markets, + [(m) => new Date(m.marketTimestamps.open).getTime()], + ['desc'] + ); + } + + return markets; + }, [data, product, searchTerm, assets, sort]); + + return { markets, data, loading, error }; +}; diff --git a/apps/trading/components/header/header.tsx b/apps/trading/components/header/header.tsx index 7348604ea..fa079597f 100644 --- a/apps/trading/components/header/header.tsx +++ b/apps/trading/components/header/header.tsx @@ -43,7 +43,7 @@ export const HeaderStat = ({ testId?: string; }) => { const itemClass = - 'min-w-min w-[120px] whitespace-nowrap pb-3 px-4 border-l border-default text-neutral-500 dark:text-neutral-400'; + 'min-w-min w-[120px] whitespace-nowrap pb-3 px-4 border-l border-default first:border-none text-neutral-500 dark:text-neutral-400'; const itemHeading = 'text-black dark:text-white'; return ( @@ -72,7 +72,7 @@ export const HeaderTitle = ({ secondaryContent: ReactNode; }) => { return ( -
+
{primaryContent}
diff --git a/apps/trading/components/select-market/index.ts b/apps/trading/components/select-market/index.ts index 48ae3cc35..5330316ed 100644 --- a/apps/trading/components/select-market/index.ts +++ b/apps/trading/components/select-market/index.ts @@ -1,3 +1,2 @@ export * from './select-market-columns'; export * from './select-market-table'; -export * from './select-market'; diff --git a/apps/trading/components/select-market/select-market-columns.tsx b/apps/trading/components/select-market/select-market-columns.tsx index 73df026b7..acec64645 100644 --- a/apps/trading/components/select-market/select-market-columns.tsx +++ b/apps/trading/components/select-market/select-market-columns.tsx @@ -7,7 +7,7 @@ import { } from '@vegaprotocol/market-list'; import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; -import { PriceCell, signedNumberCssClass } from '@vegaprotocol/datagrid'; +import { PriceCell } from '@vegaprotocol/datagrid'; import { Link as UILink, Sparkline, Tooltip } from '@vegaprotocol/ui-toolkit'; import isNil from 'lodash/isNil'; import type { CandleClose } from '@vegaprotocol/types'; @@ -341,187 +341,3 @@ export const columns = ( ]; return selectMarketColumns; }; - -export const columnsPositionMarkets = ( - market: MarketMaybeWithDataAndCandles, - onSelect: (id: string, metaKey?: boolean) => void, - inViewRoot?: RefObject, - openVolume?: string, - onCellClick?: OnCellClickHandler -) => { - const candlesClose = market.candles - ?.map((candle) => candle?.close) - .filter((c: string | undefined): c is CandleClose => !isNil(c)); - const candleLow = market.candles && calcCandleLow(market.candles); - const candleHigh = market.candles && calcCandleHigh(market.candles); - const candleVolume = market.candles && calcCandleVolume(market.candles); - const selectMarketColumns: Column[] = [ - { - kind: ColumnKind.Market, - value: ( - { - e.preventDefault(); - e.stopPropagation(); - onSelect(market.id, e.metaKey || e.ctrlKey); - }} - > - {market.tradableInstrument.instrument.code} - - ), - className: cellClassNames, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.ProductType, - value: market.tradableInstrument.instrument.product.__typename, - className: `py-2 first:text-left hidden sm:table-cell`, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.LastPrice, - value: ( - - ), - className: cellClassNames, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.Change24, - value: ( - - ), - className: cellClassNames, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.Sparkline, - value: candlesClose && ( - Number(c))} - /> - ), - className: `${cellClassNames} hidden lg:table-cell`, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.Asset, - value: ( - - ), - className: `${cellClassNames} hidden sm:table-cell`, - onlyOnDetailed: false, - }, - { - kind: ColumnKind.High24, - value: candleHigh ? ( - - ) : ( - '-' - ), - className: `${cellClassNames} hidden xl:table-cell font-mono`, - onlyOnDetailed: true, - }, - { - kind: ColumnKind.Low24, - value: candleLow ? ( - - ) : ( - '-' - ), - className: `${cellClassNames} hidden xl:table-cell font-mono`, - onlyOnDetailed: true, - }, - { - kind: ColumnKind.Volume, - value: ( - - ), - className: `${cellClassNames} hidden lg:table-cell font-mono`, - onlyOnDetailed: true, - dataTestId: 'market-volume', - }, - { - kind: ColumnKind.TradingMode, - value: ( - - ), - className: `${cellClassNames} hidden lg:table-cell`, - onlyOnDetailed: true, - dataTestId: 'trading-mode-col', - }, - { - kind: ColumnKind.Fee, - value: , - className: `${cellClassNames} hidden xl:table-cell font-mono`, - onlyOnDetailed: true, - }, - { - kind: ColumnKind.Position, - value: ( -

- {openVolume && - addDecimalsFormatNumber(openVolume, market.positionDecimalPlaces)} -

- ), - className: `${cellClassNames} hidden xxl:table-cell font-mono`, - onlyOnDetailed: true, - }, - ]; - return selectMarketColumns; -}; diff --git a/apps/trading/components/select-market/select-market-table.tsx b/apps/trading/components/select-market/select-market-table.tsx index 005318939..b7136ce3f 100644 --- a/apps/trading/components/select-market/select-market-table.tsx +++ b/apps/trading/components/select-market/select-market-table.tsx @@ -1,7 +1,6 @@ import { columnHeaders } from './select-market-columns'; import classNames from 'classnames'; import type { Column } from './select-market-columns'; -import type { ReactNode } from 'react'; export const SelectMarketTableHeader = ({ detailed = false, @@ -62,23 +61,3 @@ export const SelectMarketTableRow = ({ ); }; - -export const SelectMarketTableRowSplash = ({ - children, - colSpan, -}: { - children: ReactNode; - colSpan: number; -}) => { - return ( - - - {children} - - - ); -}; diff --git a/apps/trading/components/select-market/select-market.spec.tsx b/apps/trading/components/select-market/select-market.spec.tsx deleted file mode 100644 index 4340bb1e5..000000000 --- a/apps/trading/components/select-market/select-market.spec.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import * as Schema from '@vegaprotocol/types'; - -import { SelectAllMarketsTableBody } from './select-market'; - -import type { - MarketMaybeWithCandles, - MarketMaybeWithData, - MarketData, -} from '@vegaprotocol/market-list'; -import { MemoryRouter } from 'react-router-dom'; -import { MockedProvider } from '@apollo/client/testing'; -type Market = MarketMaybeWithCandles & MarketMaybeWithData; - -type PartialMarket = Partial< - Omit & { data: Partial } ->; - -const MARKET_A: PartialMarket = { - __typename: 'Market', - id: '1', - decimalPlaces: 2, - tradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, - tradableInstrument: { - __typename: 'TradableInstrument', - instrument: { - __typename: 'Instrument', - id: '1', - code: 'ABCDEF', - name: 'ABCDEF 1-Day', - product: { - __typename: 'Future', - quoteName: 'ABCDEF', - settlementAsset: { - __typename: 'Asset', - id: 'asset-ABC', - name: '', - decimals: 2, - symbol: 'ABC', - }, - dataSourceSpecForTradingTermination: { - __typename: 'DataSourceSpec', - id: 'oracleId', - }, - dataSourceSpecForSettlementData: { - __typename: 'DataSourceSpec', - id: 'oracleId', - }, - dataSourceSpecBinding: { - __typename: 'DataSourceSpecToFutureBinding', - tradingTerminationProperty: 'trading-termination-property', - settlementDataProperty: 'settlement-data-property', - }, - }, - metadata: { - __typename: 'InstrumentMetadata', - tags: ['ABC', 'DEF'], - }, - }, - }, - fees: { - __typename: 'Fees', - factors: { - __typename: 'FeeFactors', - infrastructureFee: '0.01', - liquidityFee: '0.01', - makerFee: '0.01', - }, - }, - data: { - __typename: 'MarketData', - market: { - __typename: 'Market', - id: '1', - }, - markPrice: '90', - trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_OPENING, - marketState: Schema.MarketState.STATE_PENDING, - marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, - indicativeVolume: '1000', - }, - candles: [ - { - __typename: 'Candle', - high: '100', - low: '10', - open: '10', - close: '80', - volume: '1000', - periodStart: '2022-11-01T15:49:00Z', - }, - { - __typename: 'Candle', - high: '10', - low: '1', - open: '1', - close: '100', - volume: '1000', - periodStart: '2022-11-01T15:50:00Z', - }, - ], -}; - -const MARKET_B: PartialMarket = { - __typename: 'Market', - id: '2', - decimalPlaces: 2, - positionDecimalPlaces: 0, - tradingMode: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, - tradableInstrument: { - __typename: 'TradableInstrument', - instrument: { - __typename: 'Instrument', - id: '2', - code: 'XYZ', - name: 'XYZ 1-Day', - product: { - __typename: 'Future', - quoteName: 'XYZ', - settlementAsset: { - __typename: 'Asset', - id: 'asset-XYZ', - name: 'asset-XYZ', - decimals: 2, - symbol: 'XYZ', - }, - dataSourceSpecForTradingTermination: { - __typename: 'DataSourceSpec', - id: 'oracleId', - }, - dataSourceSpecForSettlementData: { - __typename: 'DataSourceSpec', - id: 'oracleId', - }, - dataSourceSpecBinding: { - __typename: 'DataSourceSpecToFutureBinding', - tradingTerminationProperty: 'trading-termination-property', - settlementDataProperty: 'settlement-data-property', - }, - }, - metadata: { - __typename: 'InstrumentMetadata', - tags: ['XYZ'], - }, - }, - }, - fees: { - __typename: 'Fees', - factors: { - __typename: 'FeeFactors', - infrastructureFee: '0.01', - liquidityFee: '0.01', - makerFee: '0.01', - }, - }, - data: { - __typename: 'MarketData', - market: { - __typename: 'Market', - id: '2', - }, - markPrice: '123.123', - trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_OPENING, - marketState: Schema.MarketState.STATE_PENDING, - marketTradingMode: Schema.MarketTradingMode.TRADING_MODE_OPENING_AUCTION, - indicativeVolume: '2000', - }, - candles: [ - { - __typename: 'Candle', - high: '100', - low: '10', - open: '10', - close: '80', - volume: '1000', - periodStart: '2022-11-01T15:49:00Z', - }, - ], -}; - -describe('SelectMarket', () => { - const table = document.createElement('table'); - - it('should render the SelectAllMarketsTableBody', () => { - const onSelect = jest.fn(); - const onCellClick = jest.fn(); - const { container } = render( - - - , - { wrapper: MockedProvider, container: document.body.appendChild(table) } - ); - expect(screen.getByText('ABCDEF')).toBeTruthy(); // name - expect(screen.getByText('25.00%')).toBeTruthy(); // price change - expect(container).toHaveTextContent(/1,000/); // volume - fireEvent.click(screen.getAllByTestId(`market-link-1`)[0]); - expect(onSelect).toHaveBeenCalledWith('1', false); - }); -}); diff --git a/apps/trading/components/select-market/select-market.tsx b/apps/trading/components/select-market/select-market.tsx deleted file mode 100644 index e5bd07b30..000000000 --- a/apps/trading/components/select-market/select-market.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import type { RefObject } from 'react'; -import { useMarketList } from '@vegaprotocol/market-list'; -import { positionsDataProvider } from '@vegaprotocol/positions'; -import { t } from '@vegaprotocol/i18n'; -import { useDataProvider } from '@vegaprotocol/data-provider'; -import { ExternalLink, Icon, Loader, Popover } from '@vegaprotocol/ui-toolkit'; -import { useVegaWallet } from '@vegaprotocol/wallet'; -import { - columnHeaders, - columnHeadersPositionMarkets, - columns, - columnsPositionMarkets, -} from './select-market-columns'; -import { - SelectMarketTableHeader, - SelectMarketTableRow, - SelectMarketTableRowSplash, -} from './select-market-table'; -import type { ReactNode } from 'react'; -import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/market-list'; -import type { PositionFieldsFragment } from '@vegaprotocol/positions'; -import type { Column, OnCellClickHandler } from './select-market-columns'; -import { - DApp, - TOKEN_NEW_MARKET_PROPOSAL, - useLinks, -} from '@vegaprotocol/environment'; -import { HeaderTitle } from '../header'; - -export const SelectAllMarketsTableBody = ({ - markets, - positions, - onSelect, - onCellClick, - inViewRoot, - headers = columnHeaders, - tableColumns = (market) => columns(market, onSelect, onCellClick, inViewRoot), -}: { - markets?: MarketMaybeWithDataAndCandles[] | null; - positions?: PositionFieldsFragment[]; - title?: string; - onSelect: (id: string, metaKey?: boolean) => void; - onCellClick: OnCellClickHandler; - headers?: Column[]; - tableColumns?: ( - market: MarketMaybeWithDataAndCandles, - inViewRoot?: RefObject, - openVolume?: string - ) => Column[]; - inViewRoot?: RefObject; -}) => { - const tokenLink = useLinks(DApp.Token); - if (!markets) return null; - return ( - <> - - - - {/* Border styles required to create space between tbody elements margin/padding don't work */} - - {markets.length > 0 ? ( - markets?.map((market, i) => ( - p.market.id === market.id)?.openVolume - )} - /> - )) - ) : ( - - {t('No markets ')} - - {t('Propose a new market')} - - - )} - - - ); -}; - -export const SelectMarketPopover = ({ - marketCode, - marketName, - onSelect, - onCellClick, -}: { - marketCode: string; - marketName: string; - onSelect: (id: string, metaKey?: boolean) => void; - onCellClick: OnCellClickHandler; -}) => { - const { pubKey } = useVegaWallet(); - const [open, setOpen] = useState(false); - const inViewRoot = useRef(null); - const { - data, - loading: marketsLoading, - reload: marketListReload, - } = useMarketList(); - const { - data: positions, - loading: positionsLoading, - reload, - } = useDataProvider({ - dataProvider: positionsDataProvider, - variables: { partyId: pubKey || '' }, - skip: !pubKey, - }); - const onSelectMarket = useCallback( - (marketId: string, metaKey?: boolean) => { - onSelect(marketId, metaKey); - setOpen(false); - }, - [onSelect] - ); - - const iconClass = open ? 'rotate-180' : ''; - const markets = useMemo( - () => - data?.filter((market) => - positions?.find((node) => node.market.id === market.id) - ), - [data, positions] - ); - - useEffect(() => { - if (open) { - reload(); - marketListReload(); - } - }, [open, marketListReload, reload]); - - return ( - - - -
- } - > -
- {marketsLoading || (pubKey && positionsLoading) ? ( -
- - {t('Loading market data')} -
- ) : ( - - {pubKey && (positions?.length ?? 0) && (markets?.length ?? 0) ? ( - <> - {t('My markets')} - - columnsPositionMarkets( - market, - onSelectMarket, - inViewRoot, - openVolume, - onCellClick - ) - } - /> - - ) : null} - {t('All markets')} - -
- )} -
- - ); -}; - -const TableTitle = ({ children }: { children: ReactNode }) => { - return ( - - - -

{children}

- - - - ); -}; diff --git a/apps/trading/pages/_app.page.tsx b/apps/trading/pages/_app.page.tsx index 1d1074b86..f9c898474 100644 --- a/apps/trading/pages/_app.page.tsx +++ b/apps/trading/pages/_app.page.tsx @@ -81,7 +81,7 @@ function AppBody({ Component }: AppProps) { const gridClasses = classNames( 'h-full relative z-0 grid', - 'grid-rows-[repeat(3,min-content),1fr]' + 'grid-rows-[repeat(3,min-content),minmax(0,1fr)]' ); return ( @@ -93,7 +93,7 @@ function AppBody({ Component }: AppProps) { <div className={gridClasses}> <AnnouncementBanner /> - <Navbar theme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'system'} /> + <Navbar theme={VEGA_ENV === Networks.TESTNET ? 'yellow' : 'dark'} /> <div data-testid="banners"> <ProtocolUpgradeProposalNotification mode={ProtocolUpgradeCountdownMode.IN_ESTIMATED_TIME_REMAINING} diff --git a/apps/trading/pages/styles.css b/apps/trading/pages/styles.css index a3feb4e24..a892a321a 100644 --- a/apps/trading/pages/styles.css +++ b/apps/trading/pages/styles.css @@ -127,3 +127,24 @@ html [data-theme='light'] { .ag-theme-balham .ag-row.no-hover:hover { background: var(--ag-background-color); } + +.virtualized-list { + /* Works on Firefox */ + scrollbar-width: thin; + scrollbar-color: #999 #333; +} + +/* Works on Chrome, Edge, and Safari */ +.virtualized-list::-webkit-scrollbar { + width: 6px; + background-color: #999; +} + +.virtualized-list::-webkit-scrollbar-thumb { + width: 6px; + background-color: #333; +} +.virtualized-list::-webkit-scrollbar-track { + box-shadow: inset 0 0 6px rgb(0 0 0 / 30%); + background-color: #999; +} diff --git a/libs/accounts/src/lib/accounts-table.tsx b/libs/accounts/src/lib/accounts-table.tsx index 6889819ed..186726fe3 100644 --- a/libs/accounts/src/lib/accounts-table.tsx +++ b/libs/accounts/src/lib/accounts-table.tsx @@ -316,7 +316,7 @@ export const AccountTable = forwardRef<AgGridReact, AccountTableProps>( onClickDeposit && onClickDeposit(data.asset.id); }} > - <span> + <span className="flex gap-2"> <VegaIcon name={VegaIconNames.DEPOSIT} size={16} @@ -331,7 +331,7 @@ export const AccountTable = forwardRef<AgGridReact, AccountTableProps>( onClickWithdraw && onClickWithdraw(data.asset.id) } > - <span> + <span className="flex gap-2"> <VegaIcon name={VegaIconNames.WITHDRAW} size={16} @@ -347,7 +347,7 @@ export const AccountTable = forwardRef<AgGridReact, AccountTableProps>( setRow(data); }} > - <span> + <span className="flex gap-2"> <VegaIcon name={VegaIconNames.BREAKDOWN} size={16} diff --git a/libs/datagrid/src/lib/cells/price-change-cell.tsx b/libs/datagrid/src/lib/cells/price-change-cell.tsx index 2d6d21556..a5019c044 100644 --- a/libs/datagrid/src/lib/cells/price-change-cell.tsx +++ b/libs/datagrid/src/lib/cells/price-change-cell.tsx @@ -1,6 +1,8 @@ import { addDecimalsFormatNumber, formatNumberPercentage, + priceChange, + priceChangePercentage, } from '@vegaprotocol/utils'; import BigNumber from 'bignumber.js'; import { memo, forwardRef } from 'react'; @@ -13,29 +15,6 @@ export interface PriceChangeCellProps { decimalPlaces?: number; } -export const priceChangePercentage = (candles: string[]) => { - const change = priceChange(candles); - if (change && candles && candles.length > 0) { - const yesterdayLastPrice = candles[0] && BigInt(candles[0]); - if (yesterdayLastPrice) { - return new BigNumber(change.toString()) - .dividedBy(new BigNumber(yesterdayLastPrice.toString())) - .multipliedBy(100) - .toNumber(); - } - return 0; - } - return 0; -}; - -export const priceChange = (candles: string[]) => { - return candles && - candles[candles.length - 1] !== undefined && - candles[0] !== undefined - ? BigInt(candles[candles.length - 1] ?? 0) - BigInt(candles[0] ?? 0) - : 0; -}; - export const PriceChangeCell = memo( forwardRef<HTMLSpanElement, PriceChangeCellProps>( ({ candles, decimalPlaces }: PriceChangeCellProps, ref) => { diff --git a/libs/deal-ticket/src/components/deal-ticket/index.ts b/libs/deal-ticket/src/components/deal-ticket/index.ts index e28d29132..f2c27a654 100644 --- a/libs/deal-ticket/src/components/deal-ticket/index.ts +++ b/libs/deal-ticket/src/components/deal-ticket/index.ts @@ -4,7 +4,6 @@ export * from './deal-ticket-limit-amount'; export * from './deal-ticket-market-amount'; export * from './deal-ticket'; export * from './expiry-selector'; -export * from './market-selector'; export * from './side-selector'; export * from './time-in-force-selector'; export * from './type-selector'; diff --git a/libs/deal-ticket/src/components/deal-ticket/market-selector.tsx b/libs/deal-ticket/src/components/deal-ticket/market-selector.tsx deleted file mode 100644 index 04e4851ac..000000000 --- a/libs/deal-ticket/src/components/deal-ticket/market-selector.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import type { ReactNode } from 'react'; -import React, { - useCallback, - useState, - useEffect, - useRef, - useMemo, -} from 'react'; -import * as DialogPrimitives from '@radix-ui/react-dialog'; -import classNames from 'classnames'; -import { - ButtonLink, - Icon, - Input, - Loader, - Splash, -} from '@vegaprotocol/ui-toolkit'; -import { - useScreenDimensions, - useOutsideClick, -} from '@vegaprotocol/react-helpers'; -import { useDataProvider } from '@vegaprotocol/data-provider'; -import { t } from '@vegaprotocol/i18n'; -import { IconNames } from '@blueprintjs/icons'; -import * as Schema from '@vegaprotocol/types'; -import type { Market } from '@vegaprotocol/market-list'; -import { marketsProvider } from '@vegaprotocol/market-list'; - -interface Props { - market: Market; - setMarket: (marketId: string) => void; - ItemRenderer?: React.FC<{ - market: Market; - isMobile?: boolean; - }>; -} - -function escapeRegExp(str: string) { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export const MarketSelector = ({ market, setMarket, ItemRenderer }: Props) => { - const { isMobile } = useScreenDimensions(); - const contRef = useRef<HTMLDivElement | null>(null); - const inputRef = useRef<HTMLInputElement | null>(null); - const arrowButtonRef = useRef<HTMLButtonElement | null>(null); - const [results, setResults] = useState<Market[]>([]); - const [showPane, setShowPane] = useState(false); - const [lookup, setLookup] = useState( - market.tradableInstrument.instrument.name || '' - ); - const [dialogContent, setDialogContent] = useState<React.ReactNode | null>( - null - ); - - const { data, loading, error } = useDataProvider({ - dataProvider: marketsProvider, - variables: undefined, - skipUpdates: true, - }); - - const outsideClickCb = useCallback(() => { - if (!isMobile) { - setShowPane(false); - } - }, [setShowPane, isMobile]); - - useOutsideClick({ refs: [contRef, arrowButtonRef], func: outsideClickCb }); - - const handleOnChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { - const { - target: { value }, - } = event; - setLookup(value); - setShowPane(true); - }, - [setLookup, setShowPane] - ); - - const handleMarketSelect = useCallback( - (market: { - id: string; - tradableInstrument: { instrument: { name: string } }; - }) => { - setLookup(market.tradableInstrument.instrument.name); - setShowPane(false); - setMarket(market.id); - inputRef.current?.focus(); - }, - [setLookup, setShowPane, setMarket, inputRef] - ); - - const handleItemKeyDown = useCallback( - (event: React.KeyboardEvent, market: Market, index: number) => { - switch (event.key) { - case 'ArrowDown': - if (index < results.length - 1) { - (contRef.current?.children[index + 1] as HTMLDivElement).focus(); - } - break; - - case 'ArrowUp': - if (!index) { - inputRef.current?.focus(); - inputRef.current?.setSelectionRange( - inputRef.current?.value.length, - inputRef.current?.value.length - ); - return; - } - (contRef.current?.children[index - 1] as HTMLDivElement).focus(); - break; - - case 'Enter': - event.preventDefault(); - handleMarketSelect(market); - break; - default: - setShowPane(false); - setLookup(market.tradableInstrument.instrument.name); - } - }, - [results, handleMarketSelect] - ); - - const handleInputKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === 'ArrowDown') { - (contRef.current?.children[0] as HTMLDivElement)?.focus(); - } - }, - [contRef] - ); - - const handleOnBlur = useCallback(() => { - if (!lookup && !showPane) { - setLookup(market.tradableInstrument.instrument.name); - } - }, [market, lookup, showPane, setLookup]); - - const openPane = useCallback(() => { - setShowPane(!showPane); - inputRef.current?.focus(); - }, [showPane, setShowPane, inputRef]); - - const handleDialogOnchange = useCallback( - (isOpen: boolean) => { - setShowPane(isOpen); - if (!isOpen) { - setLookup(lookup || market.tradableInstrument.instrument.name); - inputRef.current?.focus(); - } - }, - [ - setShowPane, - lookup, - setLookup, - market.tradableInstrument.instrument.name, - inputRef, - ] - ); - - const selectorContent = useMemo(() => { - return ( - <div className="relative flex flex-col"> - <div className="relative w-full min-h-[30px]"> - <Input - className="h-[30px] w-[calc(100%-20px)] border-none outline-none" - ref={inputRef} - tabIndex={0} - value={lookup} - placeholder={t('Search')} - onChange={handleOnChange} - onKeyDown={handleInputKeyDown} - onBlur={handleOnBlur} - /> - <span className="absolute self-end top-0 right-0 z-10"> - <ButtonLink - onClick={openPane} - ref={arrowButtonRef} - data-testid="arrow-button" - > - <Icon - name={IconNames.ARROW_DOWN} - className={classNames('fill-current transition-transform', { - 'rotate-180': showPane, - })} - /> - </ButtonLink> - </span> - </div> - <hr className="mb-2" /> - <div - className={classNames( - 'md:absolute z-20 flex flex-col top-[30px] md:drop-shadow-md md:border md:border-black md:dark:border-white bg-white dark:bg-black text-black dark:text-white min-w-full md:max-h-[200px] overflow-y-auto', - showPane ? 'block' : 'hidden' - )} - data-testid="market-pane" - > - {loading && ( - <div className="p-4"> - <Loader size="small" /> - </div> - )} - {error && ( - <Splash>{t(`Something went wrong: ${error.message}`)}</Splash> - )} - <div ref={contRef} className="w-full"> - {results.map((market, i) => ( - <div - role="button" - tabIndex={0} - key={market.id} - className="bg-white dark:bg-black cursor-pointer px-4 py-2" - onClick={() => handleMarketSelect(market)} - onKeyDown={(e) => handleItemKeyDown(e, market, i)} - > - {ItemRenderer ? ( - <ItemRenderer market={market} /> - ) : ( - market.tradableInstrument.instrument.name - )} - </div> - ))} - </div> - </div> - </div> - ); - }, [ - ItemRenderer, - error, - handleInputKeyDown, - handleItemKeyDown, - handleMarketSelect, - handleOnBlur, - handleOnChange, - loading, - lookup, - openPane, - results, - showPane, - ]); - - useEffect(() => { - setResults( - data?.filter( - (item) => - item.state === Schema.MarketState.STATE_ACTIVE && - item.tradableInstrument.instrument.name.match( - new RegExp(escapeRegExp(lookup), 'i') - ) - ) || [] - ); - }, [data, lookup]); - - useEffect(() => { - inputRef.current?.focus(); - }, [inputRef]); - - useEffect(() => { - if (showPane && isMobile) { - setDialogContent(selectorContent); - inputRef.current?.focus(); - window.scrollTo(0, 0); - } else { - setDialogContent(null); - } - }, [selectorContent, showPane, isMobile, setDialogContent]); - - return ( - <> - {!dialogContent && selectorContent} - <MarketDrawer - open={Boolean(dialogContent)} - onChange={handleDialogOnchange} - > - {dialogContent} - </MarketDrawer> - </> - ); -}; - -interface MarketDrawerProps { - children: ReactNode; - open: boolean; - onChange?: (isOpen: boolean) => void; -} - -export const MarketDrawer = ({ - children, - open, - onChange, -}: MarketDrawerProps) => { - const contentClasses = classNames( - // Positions the modal in the center of screen - 'z-20 fixed p-8 inset-x-1/2 dark:text-white w-screen', - // Need to apply background and text colors again as content is rendered in a portal - 'dark:bg-black bg-white', - 'left-[0px] top-[99px] h-[calc(100%-99px)] overflow-y-auto' - ); - return ( - <DialogPrimitives.Root open={open} onOpenChange={(x) => onChange?.(x)}> - <DialogPrimitives.Portal> - <DialogPrimitives.Overlay - className="fixed inset-0 bg-black/50 z-10" - data-testid="dialog-overlay" - /> - <DialogPrimitives.Content className={contentClasses}> - <DialogPrimitives.Close - className="p-2 absolute top-2 right-2" - data-testid="dialog-close" - > - <Icon name="cross" /> - </DialogPrimitives.Close> - <div className="flex gap-4 max-w-full"> - <div data-testid="dialog-content" className="flex-1"> - <h1 className="text-xl uppercase mb-4" data-testid="dialog-title"> - {t('Select market')} - </h1> - <div>{children}</div> - </div> - </div> - </DialogPrimitives.Content> - </DialogPrimitives.Portal> - </DialogPrimitives.Root> - ); -}; diff --git a/libs/market-info/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx b/libs/market-info/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx index 49a19533a..b58d1576c 100644 --- a/libs/market-info/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx +++ b/libs/market-info/src/lib/components/oracle-basic-profile/oracle-basic-profile.tsx @@ -132,7 +132,7 @@ export const OracleBasicProfile = ({ data-testid={link.url} className="flex align-items-bottom underline text-sm" > - <span className="pt-1"> + <span className="pt-1 pr-1"> <VegaIcon name={getLinkIcon(link.type)} /> </span> <span className="underline capitalize"> diff --git a/libs/market-info/src/lib/components/oracle-full-profile/oracle-full-profile.tsx b/libs/market-info/src/lib/components/oracle-full-profile/oracle-full-profile.tsx index 8800d5864..dc2048149 100644 --- a/libs/market-info/src/lib/components/oracle-full-profile/oracle-full-profile.tsx +++ b/libs/market-info/src/lib/components/oracle-full-profile/oracle-full-profile.tsx @@ -139,7 +139,7 @@ export const OracleFullProfile = ({ href={link.url} className="flex align-items-bottom underline text-sm" > - <span className="pt-1"> + <span className="pt-1 pr-1"> <VegaIcon name={getLinkIcon(link.type)} /> </span> <span className="underline capitalize"> diff --git a/libs/market-list/src/lib/markets-candles.mock.ts b/libs/market-list/src/lib/markets-candles.mock.ts index 1e5aab348..4be43986d 100644 --- a/libs/market-list/src/lib/markets-candles.mock.ts +++ b/libs/market-list/src/lib/markets-candles.mock.ts @@ -20,7 +20,15 @@ export const marketsCandlesQuery = ( edges: [ { __typename: 'CandleEdge', - node: marketCandlesField, + node: createCandle({ close: '100' }), + }, + { + __typename: 'CandleEdge', + node: createCandle({ close: '200' }), + }, + { + __typename: 'CandleEdge', + node: createCandle({ close: '300' }), }, ], }, @@ -32,12 +40,17 @@ export const marketsCandlesQuery = ( return merge(defaultResult, override); }; -const marketCandlesField: MarketCandlesFieldsFragment = { - __typename: 'Candle', - open: '100', - close: '100', - high: '110', - low: '90', - volume: '1', - periodStart: '2022-11-01T15:49:00Z', +const createCandle = ( + override?: Partial<MarketCandlesFieldsFragment> +): MarketCandlesFieldsFragment => { + const defaultCandle = { + __typename: 'Candle', + open: '100', + close: '100', + high: '110', + low: '90', + volume: '1', + periodStart: '2022-11-01T15:49:00Z', + }; + return merge(defaultCandle, override); }; diff --git a/libs/market-list/src/lib/markets-data.mock.ts b/libs/market-list/src/lib/markets-data.mock.ts index 39aab494e..8e083b44c 100644 --- a/libs/market-list/src/lib/markets-data.mock.ts +++ b/libs/market-list/src/lib/markets-data.mock.ts @@ -28,7 +28,7 @@ export const marketsDataQuery = ( export const createMarketsDataFragment = ( override?: PartialDeep<MarketsDataFieldsFragment> ): MarketsDataFieldsFragment => { - const defaultResult = { + const defaultResult: MarketsDataFieldsFragment = { market: { id: 'market-0', __typename: 'Market', @@ -42,6 +42,10 @@ export const createMarketsDataFragment = ( bestBidPrice: '0', bestOfferPrice: '0', markPrice: '4612690058', + targetStake: '0', + suppliedStake: '0', + auctionStart: new Date().toISOString(), + auctionEnd: null, trigger: Schema.AuctionTrigger.AUCTION_TRIGGER_UNSPECIFIED, __typename: 'MarketData', }; diff --git a/libs/orders/src/lib/components/order-list-container.tsx b/libs/orders/src/lib/components/order-list-container.tsx index 9f745f427..2667625ca 100644 --- a/libs/orders/src/lib/components/order-list-container.tsx +++ b/libs/orders/src/lib/components/order-list-container.tsx @@ -1,8 +1,8 @@ import { t } from '@vegaprotocol/i18n'; import { Splash } from '@vegaprotocol/ui-toolkit'; import { useVegaWallet } from '@vegaprotocol/wallet'; -import { OrderListManager } from './order-list-manager'; import type { Filter } from './order-list-manager'; +import { OrderListManager } from './order-list-manager'; export interface OrderListContainerProps { marketId?: string; diff --git a/libs/react-helpers/src/hooks/use-yesterday.ts b/libs/react-helpers/src/hooks/use-yesterday.ts index 195e11e25..3b7b0cbc8 100644 --- a/libs/react-helpers/src/hooks/use-yesterday.ts +++ b/libs/react-helpers/src/hooks/use-yesterday.ts @@ -3,9 +3,9 @@ import { useEffect, useRef } from 'react'; const DEFAULT_ROUND_BY_MS = 5 * 60 * 1000; const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; -export const now = (roundBy = 1) => - Math.floor((Math.round(new Date().getTime() / 1000) * 1000) / roundBy) * - roundBy; +export const now = (roundBy = 1) => { + return Math.floor((Math.round(Date.now() / 1000) * 1000) / roundBy) * roundBy; +}; /** * Returns the yesterday's timestamp rounded by given number (in milliseconds; 5 minutes by default) diff --git a/libs/ui-toolkit/src/components/dropdown-menu/dropdown-menu.tsx b/libs/ui-toolkit/src/components/dropdown-menu/dropdown-menu.tsx index 70d48778e..1be7cb84e 100644 --- a/libs/ui-toolkit/src/components/dropdown-menu/dropdown-menu.tsx +++ b/libs/ui-toolkit/src/components/dropdown-menu/dropdown-menu.tsx @@ -77,7 +77,7 @@ export const DropdownMenuContent = forwardRef< <DropdownMenuPrimitive.Content {...contentProps} ref={forwardedRef} - className="min-w-[290px] bg-vega-light-100 dark:bg-vega-dark-100 p-2 rounded z-20 text-black dark:text-white border-vega-light-200 dark:border-vega-dark-200" + className="min-w-[290px] bg-vega-light-100 dark:bg-vega-dark-100 p-2 rounded z-20 text-black dark:text-white border border-vega-light-200 dark:border-vega-dark-200" align="start" sideOffset={10} /> diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-arrow-right.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-arrow-right.tsx new file mode 100644 index 000000000..1e278259b --- /dev/null +++ b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-arrow-right.tsx @@ -0,0 +1,7 @@ +export const IconArrowRight = ({ size = 16 }: { size: number }) => { + return ( + <svg width={size} height={size} viewBox="0 0 16 16"> + <path d="M12.3705 7.47001L7.27526 2.37478L8.02479 1.62524L14.3996 8.00001L8.02479 14.3748L7.27526 13.6252L12.3705 8.53001H1.65002V7.47001H12.3705Z" /> + </svg> + ); +}; diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-chevron-up.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-chevron-up.tsx new file mode 100644 index 000000000..f51d8a4c7 --- /dev/null +++ b/libs/ui-toolkit/src/components/icon/vega-icons/svg-icons/icon-chevron-up.tsx @@ -0,0 +1,7 @@ +export const IconChevronUp = ({ size = 16 }: { size: number }) => { + return ( + <svg width={size} height={size} viewBox="0 0 16 16"> + <path d="M14.3801 10.38L13.6201 11.13L8.00012 5.5L2.38012 11.13L1.62012 10.38L8.00012 4L14.3801 10.38Z" /> + </svg> + ); +}; diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts index 2b4723cbb..8fe474e92 100644 --- a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts +++ b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon-record.ts @@ -11,6 +11,8 @@ import { IconTwitter } from './svg-icons/icon-twitter'; import { IconQuestionMark } from './svg-icons/icon-question-mark'; import { IconForum } from './svg-icons/icon-forum'; import { IconOpenExternal } from './svg-icons/icon-open-external'; +import { IconArrowRight } from './svg-icons/icon-arrow-right'; +import { IconChevronUp } from './svg-icons/icon-chevron-up'; export enum VegaIconNames { BREAKDOWN = 'breakdown', @@ -26,6 +28,8 @@ export enum VegaIconNames { MOON = 'moon', OPEN_EXTERNAL = 'open-external', QUESTION_MARK = 'question-mark', + ARROW_RIGHT = 'arrow-right', + CHEVRON_UP = 'chevron-up', } export const VegaIconNameMap: Record< @@ -45,4 +49,6 @@ export const VegaIconNameMap: Record< 'question-mark': IconQuestionMark, forum: IconForum, 'open-external': IconOpenExternal, + 'arrow-right': IconArrowRight, + 'chevron-up': IconChevronUp, }; diff --git a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx index c82e7e229..496b1bcd9 100644 --- a/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx +++ b/libs/ui-toolkit/src/components/icon/vega-icons/vega-icon.tsx @@ -12,7 +12,6 @@ export const VegaIcon = ({ size = 16, name }: VegaIconProps) => { const effectiveClassName = classNames( 'inline-block', 'align-text-bottom', - 'pr-1', 'stroke-current' ); const Element = VegaIconNameMap[name]; diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index 21ca3cd2b..7a49c1ae0 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/links'; export * from './lib/local-logger'; export * from './lib/local-storage'; export * from './lib/markets'; +export * from './lib/price-change'; export * from './lib/remove-0x'; export * from './lib/remove-pagination-wrapper'; export * from './lib/time'; diff --git a/libs/utils/src/lib/price-change.ts b/libs/utils/src/lib/price-change.ts new file mode 100644 index 000000000..82dbfc551 --- /dev/null +++ b/libs/utils/src/lib/price-change.ts @@ -0,0 +1,24 @@ +import BigNumber from 'bignumber.js'; + +export const priceChangePercentage = (candles: string[]) => { + const change = priceChange(candles); + if (change && candles && candles.length > 0) { + const yesterdayLastPrice = candles[0] && BigInt(candles[0]); + if (yesterdayLastPrice) { + return new BigNumber(change.toString()) + .dividedBy(new BigNumber(yesterdayLastPrice.toString())) + .multipliedBy(100) + .toNumber(); + } + return 0; + } + return 0; +}; + +export const priceChange = (candles: string[]) => { + return candles && + candles[candles.length - 1] !== undefined && + candles[0] !== undefined + ? BigInt(candles[candles.length - 1] ?? 0) - BigInt(candles[0] ?? 0) + : 0; +};