Feat/305 add console v2 first view screen (#424)

* [#305] add initial landing dialog on markets page and fix some typos

* [#305] market-list utils and generate schema

* [#305] initial styling of the landing dialog and add arrows

* [#305] routing to markets and add hover and market list tests

* [#305] fix z-index on dialog overlay

* [#305] default market shoulde be oldest market that is currently trading in continuous mode

* [#305] refactor market-list library

* [#305] add arrow unit tests

* Update libs/market-list/src/lib/components/landing/landing-dialog.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Update libs/market-list/src/lib/components/landing/select-market-list.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* Update libs/market-list/src/lib/components/landing/select-market-list.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* test: fix failing tests from homepage change

* [#305] sort by id after sorting by date

* test: increase timeout for failing tests in CI

* [#305] destructuring all over the place and some code tweaks, arrows and percentage changes

* [#305] update sparkline to show colour

* [#305] fix order of market list

* [#305] stretchedLink class plus a-tag href for navigation - accessibility updates

* [#305] use href only and remove log

* [#305] use bignumber.js for price calculations

* [#305] change to bg-white/50 on dark mode overlay as asked from UX

* [#305] change to bg-white/50 on dark mode overlay as asked from UX

* [#305] toLocaleString fix

* [#305] toLocaleString fix

* [#305] add price-change-cell and use formatNumber

* [#305] add extra test for select market list

* Update apps/trading/specs/index.spec.tsx

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>

* [#305] use memo, sort by date and id lodash

Co-authored-by: Dexter Edwards <dexter.edwards93@gmail.com>
Co-authored-by: Joe <joe@vega.xyz>
This commit is contained in:
m.ray 2022-05-23 15:21:54 +03:00 committed by GitHub
parent a6a21bed46
commit 9ab6337e42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 941 additions and 63 deletions

View File

@ -67,6 +67,16 @@ Run `nx build my-app` to build the project. The build artifacts will be stored i
Run `nx serve my-app` for a dev server. Navigate to the port specified in `app/<project-name>/project.json`. The app will automatically reload if you change any of the source files.
### Using Apollo GraphQL and Generate Types
In order to generate the schemas for your GraphQL queries, you can run `nx run types:generate`.
If it is the first time you are running the command, make sure you are setting up the environment variable from `apollo.config.js`.
```bash
export NX_VEGA_URL=https://lb.testnet.vega.xyz/query
yarn nx run types:generate
```
### Running tests
Run `yarn nx run <my-app>-e2e:e2e` to execute the e2e tests with [cypress](https://docs.cypress.io/), or `nx affected:e2e` will execute just the end-to-end tests affected by a change. You can use the `--watch` flag to open the cypress tests UI in watch mode, see [cypress executor](https://nx.dev/packages/cypress/executors/cypress) for all CLI flags.

View File

@ -1,7 +1,14 @@
export default class BasePage {
closeDialogBtn = 'dialog-close';
porfolioUrl = '/portfolio';
marketsUrl = '/markets';
closeDialog() {
cy.getByTestId(this.closeDialogBtn, { timeout: 8000 }).click({
force: true,
});
}
navigateToPortfolio() {
cy.get(`a[href='${this.porfolioUrl}']`).should('be.visible').click();
cy.url().should('include', '/portfolio');

View File

@ -1,5 +1,9 @@
import { Given } from 'cypress-cucumber-preprocessor/steps';
import BasePage from '../pages/base-page';
const basePage = new BasePage();
Given('I am on the homepage', () => {
cy.visit('/');
basePage.closeDialog();
});

View File

@ -7,7 +7,7 @@ interface AppLoaderProps {
}
/**
* Component to handle any app initialization, startup querys and other things
* Component to handle any app initialization, startup queries and other things
* that must happen for it can be used
*/
export function AppLoader({ children }: AppLoaderProps) {

View File

@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: MarketsLanding
// ====================================================
export interface MarketsLanding_markets_marketTimestamps {
__typename: "MarketTimestamps";
/**
* Time when the market is open and ready to accept trades
*/
open: string | null;
}
export interface MarketsLanding_markets {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current mode of execution of the market
*/
tradingMode: MarketTradingMode;
/**
* timestamps for state changes in the market
*/
marketTimestamps: MarketsLanding_markets_marketTimestamps;
}
export interface MarketsLanding {
/**
* One or more instruments that are trading on the VEGA network
*/
markets: MarketsLanding_markets[] | null;
}

View File

@ -1,24 +1,44 @@
import { Button, Callout, Intent } from '@vegaprotocol/ui-toolkit';
import { gql, useQuery } from '@apollo/client';
import { LandingDialog } from '@vegaprotocol/market-list';
import { MarketTradingMode } from '@vegaprotocol/types';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import sortBy from 'lodash/sortBy';
import MarketPage from './markets/[marketId].page';
import type { MarketsLanding } from './__generated__/MarketsLanding';
const MARKETS_QUERY = gql`
query MarketsLanding {
markets {
id
tradingMode
marketTimestamps {
open
}
}
}
`;
const marketList = ({ markets }: MarketsLanding) =>
sortBy(
markets?.filter(
({ marketTimestamps, tradingMode }) =>
marketTimestamps.open && tradingMode === MarketTradingMode.Continuous
) || [],
'marketTimestamps.open',
'id'
);
export function Index() {
// The default market selected in the platform behind the overlay
// should be the oldest market that is currently trading in continuous mode(i.e. not in auction).
const { data, error, loading } = useQuery<MarketsLanding>(MARKETS_QUERY);
return (
<div className="m-24">
<div className="mb-24">
<Callout
intent={Intent.Help}
title="Welcome to Vega Trading App"
iconName="endorsed"
headingLevel={1}
>
<div className="flex flex-col">
<div>With a longer explaination</div>
<Button className="block mt-8" variant="secondary">
Action
</Button>
</div>
</Callout>
</div>
</div>
<>
<LandingDialog />
<AsyncRenderer data={data} error={error} loading={loading}>
<MarketPage id={data && marketList(data)[0]?.id} />
</AsyncRenderer>
</>
);
}

View File

@ -18,14 +18,13 @@ const MARKET_QUERY = gql`
}
`;
const MarketPage = () => {
const MarketPage = ({ id }: { id?: string }) => {
const { query } = useRouter();
const { w } = useWindowSize();
// Default to first marketId query item if found
const marketId = Array.isArray(query.marketId)
? query.marketId[0]
: query.marketId;
const marketId =
id || (Array.isArray(query.marketId) ? query.marketId[0] : query.marketId);
if (!marketId) {
return (

View File

@ -23,7 +23,7 @@ export const GridTabs = ({ children, group }: GridTabsProps) => {
});
// Update the query string in the url when the active tab changes
// uses group property as the query stirng key
// uses group property as the query string key
useEffect(() => {
const [url, queryString] = asPath.split('?');
const searchParams = new URLSearchParams(queryString);

View File

@ -1,6 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import Index from '../pages/index.page';
import { MockedProvider } from '@apollo/react-testing';
jest.mock('@vegaprotocol/ui-toolkit', () => {
const original = jest.requireActual('@vegaprotocol/ui-toolkit');
@ -10,9 +11,26 @@ jest.mock('@vegaprotocol/ui-toolkit', () => {
};
});
jest.mock('next/router', () => ({
useRouter() {
return {
route: '/',
pathname: '',
query: '',
asPath: '',
};
},
}));
describe('Index', () => {
it('should render successfully', () => {
render(<Index />);
expect(true).toBeTruthy();
it('should render successfully', async () => {
act(() => {
const { baseElement } = render(
<MockedProvider>
<Index />
</MockedProvider>
);
expect(baseElement).toBeTruthy();
});
});
});

View File

@ -143,7 +143,7 @@ export const compactData = (
),
[]
);
// order by price, it's safe to cast to number price diff sould not exceed Number.MAX_SAFE_INTEGER
// order by price, it's safe to cast to number price diff should not exceed Number.MAX_SAFE_INTEGER
orderbookData.sort((a, b) => Number(BigInt(b.price) - BigInt(a.price)));
// count cumulative volumes
if (orderbookData.length > 1) {

View File

@ -1,5 +1,2 @@
export * from './lib/market-list-table';
export * from './lib/markets-container';
export * from './lib/__generated__/Markets';
export * from './lib/__generated__/MarketDataFields';
export * from './lib/__generated__/MarketDataSub';
export * from './lib/components';
export * from './lib/utils';

View File

@ -0,0 +1,103 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { Interval } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: MarketList
// ====================================================
export interface MarketList_markets_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Full and fairly descriptive name for the instrument
*/
name: string;
/**
* A short non necessarily unique code used to easily describe the instrument (e.g: FX:BTCUSD/DEC18) (string)
*/
code: string;
}
export interface MarketList_markets_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: MarketList_markets_tradableInstrument_instrument;
}
export interface MarketList_markets_marketTimestamps {
__typename: "MarketTimestamps";
/**
* Time when the market is open and ready to accept trades
*/
open: string | null;
/**
* Time when the market is closed
*/
close: string | null;
}
export interface MarketList_markets_candles {
__typename: "Candle";
/**
* Open price (uint64)
*/
open: string;
/**
* Close price (uint64)
*/
close: string;
}
export interface MarketList_markets {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* decimalPlaces indicates the number of decimal places that an integer must be shifted by in order to get a correct
* number denominated in the currency of the Market. (uint64)
*
* Examples:
* Currency Balance decimalPlaces Real Balance
* GBP 100 0 GBP 100
* GBP 100 2 GBP 1.00
* GBP 100 4 GBP 0.01
* GBP 1 4 GBP 0.0001 ( 0.01p )
*
* GBX (pence) 100 0 GBP 1.00 (100p )
* GBX (pence) 100 2 GBP 0.01 ( 1p )
* GBX (pence) 100 4 GBP 0.0001 ( 0.01p )
* GBX (pence) 1 4 GBP 0.000001 ( 0.0001p)
*/
decimalPlaces: number;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: MarketList_markets_tradableInstrument;
/**
* timestamps for state changes in the market
*/
marketTimestamps: MarketList_markets_marketTimestamps;
/**
* Candles on a market, for the 'last' n candles, at 'interval' seconds as specified by params
*/
candles: (MarketList_markets_candles | null)[] | null;
}
export interface MarketList {
/**
* One or more instruments that are trading on the VEGA network
*/
markets: MarketList_markets[] | null;
}
export interface MarketListVariables {
interval: Interval;
since: string;
}

View File

@ -0,0 +1,4 @@
export * from './MarketDataFields';
export * from './MarketDataSub';
export * from './MarketList';
export * from './Markets';

View File

@ -0,0 +1,3 @@
export * from './__generated__';
export * from './landing';
export * from './markets-container';

View File

@ -0,0 +1,2 @@
export * from './landing-dialog';
export * from './select-market-list';

View File

@ -0,0 +1,58 @@
import { gql, useQuery } from '@apollo/client';
import { Interval } from '@vegaprotocol/types';
import { AsyncRenderer, Dialog, Intent } from '@vegaprotocol/ui-toolkit';
import { useState } from 'react';
import type { MarketList } from '../__generated__/MarketList';
import { SelectMarketList } from './select-market-list';
const MARKET_LIST_QUERY = gql`
query MarketList($interval: Interval!, $since: String!) {
markets {
id
decimalPlaces
tradableInstrument {
instrument {
name
code
}
}
marketTimestamps {
open
close
}
candles(interval: $interval, since: $since) {
open
close
}
}
}
`;
export const LandingDialog = () => {
const [open, setOpen] = useState(true);
const setClose = () => setOpen(false);
const yesterday = Math.round(new Date().getTime() / 1000) - 24 * 3600;
const yTimestamp = new Date(yesterday * 1000).toISOString();
const { data, loading, error } = useQuery<MarketList>(MARKET_LIST_QUERY, {
variables: { interval: Interval.I1H, since: yTimestamp },
});
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{
<Dialog
title="Select a market to get started"
intent={Intent.Prompt}
open={open}
onChange={setClose}
titleClassNames="font-bold font-sans text-3xl tracking-tight mb-0 pl-8"
>
<SelectMarketList data={data} />
</Dialog>
}
</AsyncRenderer>
);
};

View File

@ -0,0 +1,120 @@
import { render, screen } from '@testing-library/react';
import type { MarketList } from '../__generated__/MarketList';
import { SelectMarketList } from './select-market-list';
describe('SelectMarketList', () => {
it('should render', () => {
render(<SelectMarketList data={mockData.data as MarketList} />);
expect(screen.getByText('AAPL.MF21')).toBeTruthy();
expect(screen.getByText('-3.14%')).toBeTruthy();
expect(screen.getByText('141.75')).toBeTruthy();
expect(screen.getByText('Or view full market list')).toBeTruthy();
});
});
const mockData = {
data: {
markets: [
{
__typename: 'Market',
id: '062ddcb97beae5b7cc4fa20621fe0c83b2a6f7e76cf5b129c6bd3dc14e8111ef',
decimalPlaces: 2,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'APEUSD (May 2022)',
code: 'APEUSD',
},
},
marketTimestamps: {
__typename: 'MarketTimestamps',
open: '2022-05-18T13:08:27.693537312Z',
close: null,
},
candles: [
{
__typename: 'Candle',
open: '822',
close: '798',
},
{
__typename: 'Candle',
open: '793',
close: '792',
},
{
__typename: 'Candle',
open: '794',
close: '776',
},
{
__typename: 'Candle',
open: '785',
close: '786',
},
{
__typename: 'Candle',
open: '803',
close: '770',
},
{
__typename: 'Candle',
open: '785',
close: '774',
},
],
},
{
__typename: 'Market',
id: '3e6671566ccf5c33702e955fe8b018683fcdb812bfe3ed283fc250bb4f798ff3',
decimalPlaces: 5,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Apple Monthly (30 Jun 2022)',
code: 'AAPL.MF21',
},
},
marketTimestamps: {
__typename: 'MarketTimestamps',
open: '2022-05-18T13:00:39.328347732Z',
close: null,
},
candles: [
{
__typename: 'Candle',
open: '14707175',
close: '14633864',
},
{
__typename: 'Candle',
open: '14658400',
close: '14550193',
},
{
__typename: 'Candle',
open: '14550193',
close: '14373526',
},
{
__typename: 'Candle',
open: '14307141',
close: '14339846',
},
{
__typename: 'Candle',
open: '14357485',
close: '14179971',
},
{
__typename: 'Candle',
open: '14179972',
close: '14174855',
},
],
},
],
},
};

View File

@ -0,0 +1,95 @@
import {
addDecimalsFormatNumber,
PriceCell,
} from '@vegaprotocol/react-helpers';
import { PriceCellChange, Sparkline } from '@vegaprotocol/ui-toolkit';
import { mapDataToMarketList } from '../../utils';
import type { MarketList } from '../__generated__/MarketList';
export interface SelectMarketListProps {
data: MarketList | undefined;
}
type CandleClose = Required<string>;
export const SelectMarketList = ({ data }: SelectMarketListProps) => {
const thClassNames = (direction: 'left' | 'right') =>
`px-8 text-${direction} font-sans font-normal text-ui-small leading-9 mb-0 text-dark/80 dark:text-white/80`;
const tdClassNames =
'px-8 font-sans leading-9 capitalize text-ui-small text-right';
const boldUnderlineClassNames =
'px-8 underline font-sans text-base leading-9 font-bold tracking-tight decoration-solid text-ui light:hover:text-black/80 dark:hover:text-white/80';
const stretchedLink = `after:content-[''] after:inset-0 after:z-[1] after:absolute after:box-border`;
return (
<div className="max-h-[40rem] overflow-x-auto">
<table className="relative h-full min-w-full whitespace-nowrap">
<thead className="sticky top-0 z-10 dark:bg-black bg-white">
<tr>
<th className={thClassNames('left')}>Market</th>
<th className={thClassNames('right')}>Last price</th>
<th className={thClassNames('right')}>Change (24h)</th>
<th className={thClassNames('right')}></th>
</tr>
</thead>
<tbody>
{data &&
mapDataToMarketList(data)
.slice(0, 12)
?.map(({ id, marketName, lastPrice, candles, decimalPlaces }) => {
const candlesClose: string[] = candles
.map((candle) => candle?.close)
.filter((c): c is CandleClose => c !== null);
return (
<tr
key={id}
className={`hover:bg-black/20 dark:hover:bg-white/20 cursor-pointer relative`}
>
<td className={`${boldUnderlineClassNames} relative`}>
<a
href={`/markets/${id}?portfolio=orders&trade=orderbook&chart=candles`}
className={stretchedLink}
>
{marketName}
</a>
</td>
<td className={tdClassNames}>
{lastPrice && (
<PriceCell
value={BigInt(lastPrice)}
valueFormatted={addDecimalsFormatNumber(
lastPrice.toString(),
decimalPlaces,
2
)}
/>
)}
</td>
<td className={`${tdClassNames} `}>
<PriceCellChange
candles={candlesClose}
decimalPlaces={decimalPlaces}
/>
</td>
<td className="px-8">
{candles && (
<Sparkline
width={100}
height={20}
muted={false}
data={candlesClose.map((c) => Number(c))}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<a className={`${boldUnderlineClassNames} text-ui-small`} href="/markets">
{'Or view full market list'}
</a>
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from './market-list-table';
export * from './markets-container';
export * from './markets-data-provider';
export * from './summary-cell';

View File

@ -8,7 +8,10 @@ describe('MarketListTable', () => {
await act(async () => {
const { baseElement } = render(
<MockedProvider>
<MarketListTable />
<MarketListTable
data={[]}
onRowClicked={jest.fn((marketId: string) => null)}
/>
</MockedProvider>
);
expect(baseElement).toBeTruthy();

View File

@ -6,12 +6,12 @@ import {
t,
} from '@vegaprotocol/react-helpers';
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
import type {
Markets_markets,
Markets_markets_data_market,
} from './__generated__/Markets';
import { AgGridColumn } from 'ag-grid-react';
import type { AgGridReact } from 'ag-grid-react';
} from '../__generated__/Markets';
interface MarketListTableProps {
data: Markets_markets[] | null;

View File

@ -9,7 +9,7 @@ import type { AgGridReact } from 'ag-grid-react';
import type {
Markets_markets,
Markets_markets_data,
} from './__generated__/Markets';
} from '../../components/__generated__/Markets';
import { marketsDataProvider } from './markets-data-provider';
export const MarketsContainer = () => {

View File

@ -1,11 +1,14 @@
import { gql } from '@apollo/client';
import type { Markets, Markets_markets } from './__generated__/Markets';
import type {
Markets,
Markets_markets,
} from '../../components/__generated__/Markets';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
import type {
MarketDataSub,
MarketDataSub_marketData,
} from './__generated__/MarketDataSub';
} from '../../components/__generated__/MarketDataSub';
const MARKET_DATA_FRAGMENT = gql`
fragment MarketDataFields on MarketData {

View File

@ -0,0 +1 @@
export * from './market-list.utils';

View File

@ -0,0 +1,151 @@
import type { MarketList } from '../components/__generated__/MarketList';
import { mapDataToMarketList } from './market-list.utils';
describe('mapDataToMarketList', () => {
it('should map queried data to market list format', () => {
const result = mapDataToMarketList(mockData.data as unknown as MarketList);
expect(result).toEqual(mockList);
});
});
const mockList = [
{
candles: [
{ __typename: 'Candle', close: '14633864', open: '14707175' },
{ __typename: 'Candle', close: '14550193', open: '14658400' },
{ __typename: 'Candle', close: '14373526', open: '14550193' },
{ __typename: 'Candle', close: '14339846', open: '14307141' },
{ __typename: 'Candle', close: '14179971', open: '14357485' },
{ __typename: 'Candle', close: '14174855', open: '14179972' },
],
close: null,
decimalPlaces: 5,
id: '3e6671566ccf5c33702e955fe8b018683fcdb812bfe3ed283fc250bb4f798ff3',
lastPrice: '14174855',
marketName: 'AAPL.MF21',
open: 1652878839328,
},
{
candles: [
{ __typename: 'Candle', close: '798', open: '822' },
{ __typename: 'Candle', close: '792', open: '793' },
{ __typename: 'Candle', close: '776', open: '794' },
{ __typename: 'Candle', close: '786', open: '785' },
{ __typename: 'Candle', close: '770', open: '803' },
{ __typename: 'Candle', close: '774', open: '785' },
],
close: null,
decimalPlaces: 2,
id: '062ddcb97beae5b7cc4fa20621fe0c83b2a6f7e76cf5b129c6bd3dc14e8111ef',
lastPrice: '774',
marketName: 'APEUSD',
open: 1652879307693,
},
];
const mockData = {
data: {
markets: [
{
__typename: 'Market',
id: '062ddcb97beae5b7cc4fa20621fe0c83b2a6f7e76cf5b129c6bd3dc14e8111ef',
decimalPlaces: 2,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'APEUSD (May 2022)',
code: 'APEUSD',
},
},
marketTimestamps: {
__typename: 'MarketTimestamps',
open: '2022-05-18T13:08:27.693537312Z',
close: null,
},
candles: [
{
__typename: 'Candle',
open: '822',
close: '798',
},
{
__typename: 'Candle',
open: '793',
close: '792',
},
{
__typename: 'Candle',
open: '794',
close: '776',
},
{
__typename: 'Candle',
open: '785',
close: '786',
},
{
__typename: 'Candle',
open: '803',
close: '770',
},
{
__typename: 'Candle',
open: '785',
close: '774',
},
],
},
{
__typename: 'Market',
id: '3e6671566ccf5c33702e955fe8b018683fcdb812bfe3ed283fc250bb4f798ff3',
decimalPlaces: 5,
tradableInstrument: {
__typename: 'TradableInstrument',
instrument: {
__typename: 'Instrument',
name: 'Apple Monthly (30 Jun 2022)',
code: 'AAPL.MF21',
},
},
marketTimestamps: {
__typename: 'MarketTimestamps',
open: '2022-05-18T13:00:39.328347732Z',
close: null,
},
candles: [
{
__typename: 'Candle',
open: '14707175',
close: '14633864',
},
{
__typename: 'Candle',
open: '14658400',
close: '14550193',
},
{
__typename: 'Candle',
open: '14550193',
close: '14373526',
},
{
__typename: 'Candle',
open: '14307141',
close: '14339846',
},
{
__typename: 'Candle',
open: '14357485',
close: '14179971',
},
{
__typename: 'Candle',
open: '14179972',
close: '14174855',
},
],
},
],
},
};

View File

@ -0,0 +1,31 @@
import sortBy from 'lodash/sortBy';
import type {
MarketList,
MarketList_markets,
} from '../components/__generated__/MarketList';
export const lastPrice = ({ candles }: MarketList_markets) =>
candles && candles.length > 0
? candles && candles[candles?.length - 1]?.close
: undefined;
export const mapDataToMarketList = ({ markets }: MarketList) =>
sortBy(
markets?.map((m) => {
return {
id: m.id,
decimalPlaces: m.decimalPlaces,
marketName: m.tradableInstrument.instrument?.code,
lastPrice: lastPrice(m),
candles: (m.candles || []).filter((c) => c),
open: m.marketTimestamps.open
? new Date(m.marketTimestamps.open).getTime()
: null,
close: m.marketTimestamps.close
? new Date(m.marketTimestamps.close).getTime()
: null,
};
}) || [],
'open',
'id'
);

View File

@ -40,3 +40,9 @@ export const addDecimalsFormatNumber = (
return formatNumber(x, formatDecimals);
};
export const formatNumberPercentage = (value: BigNumber, decimals?: number) => {
const decimalPlaces =
typeof decimals === 'undefined' ? Math.max(value.dp(), 2) : decimals;
return `${value.dp(decimalPlaces).toFormat(decimalPlaces)}%`;
};

View File

@ -12,8 +12,6 @@ module.exports = {
colors: {
transparent: 'transparent',
current: 'currentColor',
bullish: '#26FF8A',
bearish: '#ED1515',
vega: {
yellow: '#EDFF22',
pink: '#FF2D5E',
@ -23,11 +21,12 @@ module.exports = {
DEFAULT: '#ED1515',
transparent: 'rgba(255, 38, 65, 0.3)',
vega: '#FF261A',
dark: '#EB001B',
},
green: {
DEFAULT: '#26FF8A',
transparent: 'rgba(38, 255, 138, 0.3)',
dark: '#246340',
dark: '#008545',
vega: '#00F780',
},
text: '#C7C7C7',

View File

@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { Arrow } from './arrow';
describe('Arrow', () => {
it('should render successfully for positive values', () => {
render(<Arrow value={0.5} />);
expect(screen.queryByTestId('arrow-up')).toBeTruthy();
expect(screen.queryByTestId('arrow-down')).toBeFalsy();
});
it('should render successfully for negative values', () => {
render(<Arrow value={-0.5} />);
expect(screen.queryByTestId('arrow-down')).toBeTruthy();
expect(screen.queryByTestId('arrow-up')).toBeFalsy();
});
it('should not render successfully for zero values', () => {
render(<Arrow value={0} />);
expect(screen.queryByTestId('arrow-down')).toBeNull();
expect(screen.queryByTestId('arrow-up')).toBeNull();
});
});

View File

@ -0,0 +1,20 @@
export const ArrowUp = () => (
<span
data-testid="arrow-up"
className="w-0 h-0 border-x border-x-[4px] border-solid border-x-transparent border-b-[4px] border-b-green-dark dark:border-b-green"
></span>
);
export const ArrowDown = () => (
<span
data-testid="arrow-down"
className="w-0 h-0 border-x border-x-[4px] border-solid border-x-transparent border-t-[4px] border-t-red-dark dark:border-t-red"
></span>
);
// Arrow
export interface ArrowProps {
value: number | bigint;
}
export const Arrow = ({ value }: ArrowProps) =>
value === 0 ? null : value > 0 ? <ArrowUp /> : <ArrowDown />;

View File

@ -0,0 +1 @@
export * from './arrow';

View File

@ -24,7 +24,6 @@ export const Default = Template.bind({});
Default.args = {
open: false,
title: 'Title',
setOpen: () => undefined,
children: <p>Some content</p>,
};
@ -32,7 +31,6 @@ export const Danger = Template.bind({});
Danger.args = {
open: false,
title: 'Danger',
setOpen: () => undefined,
children: <p>Some content</p>,
intent: Intent.Danger,
};
@ -41,7 +39,6 @@ export const Success = Template.bind({});
Success.args = {
open: false,
title: 'Success',
setOpen: () => undefined,
children: <p>Some content</p>,
intent: Intent.Success,
};
@ -50,7 +47,14 @@ export const Warning = Template.bind({});
Warning.args = {
open: false,
title: 'Warning',
setOpen: () => undefined,
children: <p>Some content</p>,
intent: Intent.Warning,
};
export const Modal = Template.bind({});
Modal.args = {
open: false,
title: 'Modal (Prompt)',
children: <p>Some content</p>,
intent: Intent.Prompt,
};

View File

@ -11,6 +11,7 @@ interface DialogProps {
onChange: (isOpen: boolean) => void;
title?: string;
intent?: Intent;
titleClassNames?: string;
}
export function Dialog({
@ -19,10 +20,11 @@ export function Dialog({
onChange,
title,
intent,
titleClassNames,
}: DialogProps) {
const contentClasses = classNames(
// Positions the modal in the center of screen
'fixed w-[520px] px-28 py-24 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'z-20 fixed w-full md:w-[520px] px-28 py-24 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
// Need to apply background and text colors again as content is rendered in a portal
'dark:bg-black dark:text-white-95 bg-white text-black-95',
getIntentShadow(intent)
@ -30,7 +32,7 @@ export function Dialog({
return (
<DialogPrimitives.Root open={open} onOpenChange={(x) => onChange(x)}>
<DialogPrimitives.Portal>
<DialogPrimitives.Overlay className="fixed inset-0 bg-black/50 dark:bg-white/15" />
<DialogPrimitives.Overlay className="fixed inset-0 bg-black/50 z-10" />
<DialogPrimitives.Content className={contentClasses}>
<DialogPrimitives.Close
className="p-12 absolute top-0 right-0"
@ -39,7 +41,9 @@ export function Dialog({
<Icon name="cross" />
</DialogPrimitives.Close>
{title && (
<h1 className="text-h5 text-black-95 dark:text-white-95 mt-0 mb-20">
<h1
className={`text-h5 text-black-95 dark:text-white-95 mt-0 mb-20 ${titleClassNames}`}
>
{title}
</h1>
)}

View File

@ -1,4 +1,5 @@
export * from './ag-grid';
export * from './arrows';
export * from './async-renderer';
export * from './button';
export * from './callout';
@ -15,13 +16,14 @@ export * from './input-error';
export * from './key-value-table';
export * from './loader';
export * from './lozenge';
export * from './price-change';
export * from './radio-group';
export * from './select';
export * from './sparkline';
export * from './splash';
export * from './syntax-highlighter';
export * from './text-area';
export * from './theme-switcher';
export * from './toggle';
export * from './tooltip';
export * from './vega-logo';
export * from './syntax-highlighter';
export * from './sparkline';

View File

@ -0,0 +1 @@
export * from './price-change-cell';

View File

@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import { PriceCellChange } from '..';
describe('PriceChangeCell', () => {
it('renders correctly and calculates the price change', () => {
render(
<PriceCellChange
candles={['45556', '678678', '23456']}
decimalPlaces={3}
/>
);
expect(screen.getByText('-48.51%')).toBeInTheDocument();
expect(screen.getByText('-22.100')).toBeInTheDocument();
});
it('renders correctly and calculates the price change without decimals', () => {
render(<PriceCellChange candles={['45556', '678678', '23456']} />);
expect(screen.getByText('-48.51%')).toBeInTheDocument();
expect(screen.getByText('-22,100.000')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,23 @@
import type { Story, Meta } from '@storybook/react';
import { PriceCellChange } from './price-change-cell';
export default {
component: PriceCellChange,
title: 'PriceCellChange',
} as Meta;
const Template: Story = (args) => (
<PriceCellChange candles={[args['candles']]} {...args} />
);
export const Increased = Template.bind({});
Increased.args = {
candles: ['4564', '5674', '6784'],
decimalPlaces: 3,
};
export const Decreased = Template.bind({});
Decreased.args = {
candles: ['6784', '4564', '5674'],
decimalPlaces: 3,
};

View File

@ -0,0 +1,91 @@
import {
addDecimalsFormatNumber,
formatNumberPercentage,
PriceCell,
} from '@vegaprotocol/react-helpers';
import BigNumber from 'bignumber.js';
import React from 'react';
import { Arrow } from '../arrows/arrow';
export interface PriceChangeCellProps {
/** either candle `close`or `open` values to be filtered and used here in order to calculate the price change */
candles: string[];
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;
};
const priceChangeClassNames = (value: number | bigint) =>
value === 0
? 'text-black dark:text-white'
: value > 0
? `text-green-dark dark:text-green-vega `
: `text-red-dark dark:text-red-vega`;
export const PriceCellChange = React.memo(
({ candles, decimalPlaces }: PriceChangeCellProps) => {
const change = priceChange(candles);
const changePercentage = priceChangePercentage(candles);
return (
<span
className={`${priceChangeClassNames(
change
)} flex items-center gap-4 justify-end`}
>
<Arrow value={change} />
<span className="flex items-center gap-6">
<span>
{
<PriceCell
value={changePercentage}
valueFormatted={formatNumberPercentage(
new BigNumber(changePercentage.toString()),
2
)}
/>
}
&nbsp;
</span>
<span>
(
{
<PriceCell
value={BigInt(change)}
valueFormatted={addDecimalsFormatNumber(
change.toString(),
decimalPlaces ?? 0,
3
)}
/>
}
)
</span>
</span>
</span>
);
}
);
PriceCellChange.displayName = 'PriceCellChange';

View File

@ -7,6 +7,7 @@ const props = {
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 12,
],
muted: true,
};
it('Renders an svg with a single path', () => {
@ -17,7 +18,7 @@ it('Renders an svg with a single path', () => {
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute('d', expect.any(String));
expect(path).toHaveAttribute('stroke', expect.any(String));
expect(path).toHaveAttribute('stroke-width', '2');
expect(path).toHaveAttribute('stroke-width', '1');
expect(path).toHaveAttribute('fill', 'transparent');
});
@ -34,16 +35,21 @@ it('Renders a red line if the last value is less than the first', () => {
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass('stroke-bearish');
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-red-dark dark:stroke-red'
);
});
it('Renders a green line if the last value is greater than the first', () => {
props.data[0] = 5;
props.data[props.data.length - 1] = 10;
props.muted = true;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass('stroke-bullish');
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-green-dark dark:stroke-green'
);
});
it('Renders a white line if the first and last values are equal', () => {

View File

@ -8,8 +8,8 @@ function colorByChange(a: number, b: number) {
return a === b
? 'stroke-black/40 dark:stroke-white/40'
: a < b
? 'stroke-bullish'
: 'stroke-bearish';
? 'stroke-green-dark dark:stroke-green'
: 'stroke-red-dark dark:stroke-red';
}
export interface SparklineProps {
@ -18,6 +18,7 @@ export interface SparklineProps {
height?: number;
points?: number;
className?: string;
muted?: boolean;
}
export const SparklineView = ({
@ -25,6 +26,7 @@ export const SparklineView = ({
width = 60,
height = 15,
points = 25,
muted = false,
className,
}: SparklineProps) => {
// How many points are missing. If market is 12 hours old the 25 - 12
@ -69,10 +71,11 @@ export const SparklineView = ({
// Get the color of the marketData line
const [firstVal, lastVal] = [data[0], data[data.length - 1]];
const strokeClassName =
data.length >= 24
const strokeClassName = muted
? data.length >= 24
? colorByChange(firstVal, lastVal)
: 'stroke-black/40 dark:stroke-white/40';
: 'stroke-black/40 dark:stroke-white/40'
: colorByChange(firstVal, lastVal);
// Create paths
const preMarketCreationPath = lineSeries(preMarketData);
@ -93,7 +96,7 @@ export const SparklineView = ({
className={`[vector-effect:non-scaling-stroke] ${strokeClassName}`}
d={preMarketCreationPath}
stroke="strokeCurrent"
strokeWidth={2}
strokeWidth={1}
fill="transparent"
/>
)}
@ -103,7 +106,7 @@ export const SparklineView = ({
d={mainPath}
className={strokeClassName}
stroke="strokeCurrent"
strokeWidth={1}
strokeWidth={2}
fill="transparent"
/>
)}