Feature/356 view a list of markets page and show additional useful information (#419)

* Initial commit after nx create

* Add .env files

* Add working commit for poc

* Make deal-ticket-manager.tsx accept children as props and export more components to be consumed by external apps

* Add stepper component to simple trading app

* Add basic prototype for simple trading app with stepper component

* simple market app - simple market list - initial commit

* simple market app - simple market list - add some new changes

* Resolve conflicts after rebase
Initial commit after nx create

* Add stepper component to simple trading app

* simple market app - simple market list - remove wrongly added file after rebase

* simple market app - simple market list - proposals of layout frame and percent change calculation

* simple market app - simple market list - proposals of working solution

* feat: [simple-app] - simple market list - clean up gQL queries

* feat: [simple-app] - simple market list - indicate no auctionEnd

* feat: [simple-app] - simple market list - a bunch of changes after review feedback

* feat: [simple-app] - simple market list - get expire date from instrument tag

* feat: [simple-app] - simple market list - a bunch of small improvements

Co-authored-by: Elmar Gasimov <elmar@vegaprotocol.io>
Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-05-20 11:21:46 +02:00 committed by GitHub
parent a352702bc5
commit ef18ac8483
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 646 additions and 12 deletions

View File

@ -1,6 +1,6 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"ignorePatterns": ["!**/*", "__generated__"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],

View File

@ -9,12 +9,14 @@ import {
VegaManageDialog,
VegaWalletProvider,
} from '@vegaprotocol/wallet';
import { DealTicketContainer } from './components/deal-ticket';
// import { DealTicketContainer } from './components/deal-ticket';
import { VegaWalletConnectButton } from './components/vega-wallet-connect-button';
import { ThemeSwitcher } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/react-helpers';
import { Connectors } from './lib/vega-connectors';
import '../styles.scss';
import { AppLoader } from './components/app-loader';
import SimpleMarketList from './components/simple-market-list';
function App() {
const [theme, toggleTheme] = useThemeSwitcher();
@ -30,8 +32,8 @@ function App() {
<ApolloProvider client={client}>
<VegaWalletProvider>
<AppLoader>
<div className="h-full dark:bg-black dark:text-white-60 bg-white text-black-60 grid grid-rows-[min-content,1fr]">
<div className="flex items-stretch border-b-[7px] border-vega-yellow">
<div className="h-full max-h-full dark:bg-black dark:text-white-60 bg-white text-black-60 grid md:grid-rows-[min-content_1fr_min-content] lg:grid-cols-[375px_1fr] md:grid-cols-[200px_1fr] sm:grid-rows-[min-content_min-content_1fr_min-content]">
<div className="flex items-stretch border-b-[7px] border-vega-yellow md:col-span-3">
<div className="flex items-center gap-4 ml-auto mr-8">
<VegaWalletConnectButton
setConnectDialog={(open) =>
@ -44,15 +46,25 @@ function App() {
<ThemeSwitcher onToggle={toggleTheme} className="-my-4" />
</div>
</div>
<main>
<div className="md:w-4/5 lg:w-3/5 xl:w-1/3 mx-auto">
<DealTicketContainer
<aside className="md:col-start-1 md:col-end-1 md:row-start-2 md:row-end-2">
<ul>
<li>{t('Markets')}</li>
<li>{t('Trade')}</li>
<li>{t('Liquid')}</li>
<li>{t('Markets')}</li>
</ul>
</aside>
<div className="md:col-start-2 md:col-end-2 md:row-start-2 md:row-end-2 overflow-auto">
<SimpleMarketList />
{/*<DealTicketContainer
marketId={
'0e4c4e0ce6626ea5c6bf5b5b510afadb3c91627aa9ff61e4c7e37ef8394f2c6f'
}
/>
</div>
</main>
/>*/}
</div>
<footer className="md:col-span-3">®</footer>
<VegaConnectDialog
connectors={Connectors}
dialogOpen={vegaWallet.connect}

View File

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketState } from "@vegaprotocol/types";
// ====================================================
// GraphQL fragment: SimpleMarketDataFields
// ====================================================
export interface SimpleMarketDataFields_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
}
export interface SimpleMarketDataFields {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: SimpleMarketDataFields_market;
}

View File

@ -0,0 +1,37 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { MarketState } from "@vegaprotocol/types";
// ====================================================
// GraphQL subscription operation: SimpleMarketDataSub
// ====================================================
export interface SimpleMarketDataSub_marketData_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
}
export interface SimpleMarketDataSub_marketData {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: SimpleMarketDataSub_marketData_market;
}
export interface SimpleMarketDataSub {
/**
* Subscribe to the mark price changes
*/
marketData: SimpleMarketDataSub_marketData;
}

View File

@ -0,0 +1,122 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { Interval, MarketState } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: SimpleMarkets
// ====================================================
export interface SimpleMarkets_markets_data_market {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Current state of the market
*/
state: MarketState;
}
export interface SimpleMarkets_markets_data {
__typename: "MarketData";
/**
* market id of the associated mark price
*/
market: SimpleMarkets_markets_data_market;
}
export interface SimpleMarkets_markets_tradableInstrument_instrument_metadata {
__typename: "InstrumentMetadata";
/**
* An arbitrary list of tags to associated to associate to the Instrument (string list)
*/
tags: string[] | null;
}
export interface SimpleMarkets_markets_tradableInstrument_instrument_product_settlementAsset {
__typename: "Asset";
/**
* The symbol of the asset (e.g: GBP)
*/
symbol: string;
}
export interface SimpleMarkets_markets_tradableInstrument_instrument_product {
__typename: "Future";
/**
* The name of the asset (string)
*/
settlementAsset: SimpleMarkets_markets_tradableInstrument_instrument_product_settlementAsset;
}
export interface SimpleMarkets_markets_tradableInstrument_instrument {
__typename: "Instrument";
/**
* Metadata for this instrument
*/
metadata: SimpleMarkets_markets_tradableInstrument_instrument_metadata;
/**
* A reference to or instance of a fully specified product, including all required product parameters for that product (Product union)
*/
product: SimpleMarkets_markets_tradableInstrument_instrument_product;
}
export interface SimpleMarkets_markets_tradableInstrument {
__typename: "TradableInstrument";
/**
* An instance of or reference to a fully specified instrument.
*/
instrument: SimpleMarkets_markets_tradableInstrument_instrument;
}
export interface SimpleMarkets_markets_candles {
__typename: "Candle";
/**
* Open price (uint64)
*/
open: string;
/**
* Close price (uint64)
*/
close: string;
}
export interface SimpleMarkets_markets {
__typename: "Market";
/**
* Market ID
*/
id: string;
/**
* Market full name
*/
name: string;
/**
* marketData for the given market
*/
data: SimpleMarkets_markets_data | null;
/**
* An instance of or reference to a tradable instrument.
*/
tradableInstrument: SimpleMarkets_markets_tradableInstrument;
/**
* Candles on a market, for the 'last' n candles, at 'interval' seconds as specified by params
*/
candles: (SimpleMarkets_markets_candles | null)[] | null;
}
export interface SimpleMarkets {
/**
* One or more instruments that are trading on the VEGA network
*/
markets: SimpleMarkets_markets[] | null;
}
export interface SimpleMarketsVariables {
CandleInterval: Interval;
CandleSince: string;
}

View File

@ -0,0 +1,15 @@
import { TailwindIntents } from '@vegaprotocol/ui-toolkit';
import { MarketState } from '@vegaprotocol/types';
export const MARKET_STATUS: Record<MarketState | '', TailwindIntents> = {
[MarketState.Active]: TailwindIntents.Success,
[MarketState.Cancelled]: TailwindIntents.Highlight,
[MarketState.Closed]: TailwindIntents.Help,
[MarketState.Pending]: TailwindIntents.Warning,
[MarketState.Proposed]: TailwindIntents.Prompt,
[MarketState.Rejected]: TailwindIntents.Danger,
[MarketState.Settled]: TailwindIntents.Highlight,
[MarketState.Suspended]: TailwindIntents.Warning,
[MarketState.TradingTerminated]: TailwindIntents.Danger,
'': TailwindIntents.Highlight,
};

View File

@ -0,0 +1,84 @@
import { gql } from '@apollo/client';
import { makeDataProvider } from '@vegaprotocol/react-helpers';
import type {
SimpleMarkets,
SimpleMarkets_markets,
} from './__generated__/SimpleMarkets';
import type {
SimpleMarketDataSub,
SimpleMarketDataSub_marketData,
} from './__generated__/SimpleMarketDataSub';
const MARKET_DATA_FRAGMENT = gql`
fragment SimpleMarketDataFields on MarketData {
market {
id
state
}
}
`;
const MARKETS_QUERY = gql`
${MARKET_DATA_FRAGMENT}
query SimpleMarkets($CandleInterval: Interval!, $CandleSince: String!) {
markets {
id
name
data {
...SimpleMarketDataFields
}
tradableInstrument {
instrument {
metadata {
tags
}
product {
... on Future {
settlementAsset {
symbol
}
}
}
}
}
candles(interval: $CandleInterval, since: $CandleSince) {
open
close
}
}
}
`;
const MARKET_DATA_SUB = gql`
${MARKET_DATA_FRAGMENT}
subscription SimpleMarketDataSub {
marketData {
...SimpleMarketDataFields
}
}
`;
const update = (
draft: SimpleMarkets_markets[],
delta: SimpleMarketDataSub_marketData
) => {
const index = draft.findIndex((m) => m.id === delta.market.id);
if (index !== -1) {
draft[index].data = delta;
}
// @TODO - else push new market to draft
};
const getData = (responseData: SimpleMarkets) => responseData.markets;
const getDelta = (
subscriptionData: SimpleMarketDataSub
): SimpleMarketDataSub_marketData => subscriptionData.marketData;
export const dataProvider = makeDataProvider<
SimpleMarkets,
SimpleMarkets_markets[],
SimpleMarketDataSub,
SimpleMarketDataSub_marketData
>(MARKETS_QUERY, MARKET_DATA_SUB, update, getData, getDelta);
export default dataProvider;

View File

@ -0,0 +1 @@
export { default } from './simple-market-list';

View File

@ -0,0 +1,53 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import SimpleMarketExpires from './simple-market-expires';
describe('SimpleMarketExpires', () => {
describe('should properly parse different tags', () => {
it('settlement:date', () => {
const tags = [
'foo:buzz',
'test:20220625T1200',
'settlement',
'settlement:notadate',
'settlement:20220525T1200',
];
render(<SimpleMarketExpires tags={tags} />);
expect(screen.getByText('expires 25 May 2022 12:00')).toBeInTheDocument();
});
it('settlement-date:date', () => {
const tags = [
'settlement',
'settlement:20220525T1200',
'settlement-date:2022-04-25T1200',
];
render(<SimpleMarketExpires tags={tags} />);
expect(
screen.getByText('expires 25 April 2022 12:00')
).toBeInTheDocument();
});
it('last one proper tag should matter', () => {
const tags = [
'settlement',
'settlement-date:20220525T1200',
'settlement-expiry-date:2022-03-25T12:00:00',
];
render(<SimpleMarketExpires tags={tags} />);
expect(
screen.getByText('expires 25 March 2022 12:00')
).toBeInTheDocument();
});
it('when no proper tag nor date should be null', () => {
const tags = [
'settlement',
'settlemenz:20220525T1200',
'settlemenx-date:20220425T1200',
];
const { container } = render(<SimpleMarketExpires tags={tags} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@ -0,0 +1,35 @@
import React from 'react';
import { t } from '@vegaprotocol/react-helpers';
import { format, isValid, parseISO } from 'date-fns';
import { DATE_FORMAT } from '../../constants';
const SimpleMarketExpires = ({
tags,
}: {
tags: ReadonlyArray<string> | null;
}) => {
if (tags) {
const dateFound = tags.reduce<Date | null>((agg, tag) => {
const parsed = parseISO(
(tag.match(/^settlement.*:/) &&
tag
.split(':')
.filter((item, i) => i)
.join(':')) as string
);
if (isValid(parsed)) {
agg = parsed;
}
return agg;
}, null);
return dateFound ? (
<div className="py-2">{`${t('expires')} ${format(
dateFound as Date,
DATE_FORMAT
)}`}</div>
) : null;
}
return null;
};
export default SimpleMarketExpires;

View File

@ -0,0 +1,80 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { MarketState } from '@vegaprotocol/types';
import SimpleMarketList from './simple-market-list';
import type { SimpleMarkets_markets } from './__generated__/SimpleMarkets';
jest.mock('./data-provider', () => jest.fn());
jest.mock('@vegaprotocol/react-helpers', () => ({
useDataProvider: jest.fn(),
t: (txt: string) => txt,
}));
describe('SimpleMarketList', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should be properly renderer as empty', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(useDataProvider as unknown as jest.SpyInstance<any>).mockImplementation(
() => ({ data: [], error: false, loading: false })
);
render(<SimpleMarketList />);
expect(screen.getByText('No data to display')).toBeInTheDocument();
});
it('should be properly rendered with some data', () => {
const data = [
{
id: '1',
data: {
market: {
state: MarketState.Active,
},
},
tradableInstrument: {
instrument: {
product: {
settlementAsset: {
symbol: 'tUSD',
},
},
metadata: {
tags: [],
},
},
},
},
{
id: '2',
data: {
market: {
state: MarketState.Proposed,
},
},
tradableInstrument: {
instrument: {
product: {
settlementAsset: {
symbol: 'ETH',
},
},
metadata: {
tags: [],
},
},
},
},
] as unknown as SimpleMarkets_markets[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(useDataProvider as unknown as jest.SpyInstance<any>).mockImplementation(
() => ({ data, error: false, loading: false })
);
render(<SimpleMarketList />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(2);
});
});

View File

@ -0,0 +1,78 @@
import React, { useCallback, useMemo } from 'react';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers';
import { AsyncRenderer, Lozenge, Splash } from '@vegaprotocol/ui-toolkit';
import { Button } from '@vegaprotocol/ui-toolkit';
import SimpleMarketPercentChange from './simple-market-percent-change';
import SimpleMarketExpires from './simple-market-expires';
import DataProvider from './data-provider';
import { MARKET_STATUS } from './constants';
const SimpleMarketList = () => {
const variables = useMemo(
() => ({
CandleInterval: 'I1H',
CandleSince: new Date(Date.now() - 24 * 60 * 60 * 1000).toJSON(),
}),
[]
);
const { data, error, loading } = useDataProvider(
DataProvider,
undefined, // @TODO - if we need a live update in the future
variables
);
const onClick = useCallback((marketId) => {
// @TODO - let's try to have navigation first
console.log('trigger market', marketId);
}, []);
return (
<AsyncRenderer loading={loading} error={error} data={data}>
{data && data.length > 0 ? (
<ul className="list-none relative pt-8 pb-8">
{data?.map((market) => (
<li
className="w-full relative flex justify-start items-center no-underline box-border text-left pt-8 pb-8 pl-16 pr-16 mb-10"
key={market.id}
>
<div className="w-full grid sm:grid-cols-2">
<div className="w-full grid sm:auto-rows-auto">
<div className="font-extrabold py-2">{market.name}</div>
<SimpleMarketExpires
tags={market.tradableInstrument.instrument.metadata.tags}
/>
<div className="py-2">{`${t('settled in')} ${
market.tradableInstrument.instrument.product.settlementAsset
.symbol
}`}</div>
</div>
<div className="w-full grid sm:grid-rows-2">
<div>
<SimpleMarketPercentChange candles={market.candles} />
</div>
<div>
<Lozenge
variant={MARKET_STATUS[market.data?.market.state || '']}
>
{market.data?.market.state}
</Lozenge>
</div>
</div>
</div>
<div className="absolute right-16 top-1/2 -translate-y-1/2">
<Button
onClick={() => onClick(market.id)}
variant="inline"
prependIconName="chevron-right"
/>
</div>
</li>
))}
</ul>
) : (
<Splash>{t('No data to display')}</Splash>
)}
</AsyncRenderer>
);
};
export default SimpleMarketList;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { theme } from '@vegaprotocol/tailwindcss-config';
import SimpleMarketPercentChange from './simple-market-percent-change';
import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets';
describe('SimpleMarketPercentChange should parse proper change', () => {
let candles: (SimpleMarkets_markets_candles | null)[] | null;
it('empty array', () => {
candles = [];
render(<SimpleMarketPercentChange candles={candles} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('null', () => {
candles = null;
render(<SimpleMarketPercentChange candles={candles} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('an appreciated one', () => {
candles = [
{ open: '50' } as SimpleMarkets_markets_candles,
{ close: '100' } as SimpleMarkets_markets_candles,
null,
];
render(<SimpleMarketPercentChange candles={candles} />);
expect(screen.getByText('100.000%')).toBeInTheDocument();
expect(screen.getByText('100.000%')).toHaveStyle(
`color: ${theme.colors.vega.green}`
);
});
it('a depreciated one', () => {
candles = [
{ open: '100' } as SimpleMarkets_markets_candles,
{ close: '50' } as SimpleMarkets_markets_candles,
null,
];
render(<SimpleMarketPercentChange candles={candles} />);
expect(screen.getByText('-50.000%')).toBeInTheDocument();
expect(screen.getByText('-50.000%')).toHaveStyle(
`color: ${theme.colors.vega.pink}`
);
});
});

View File

@ -0,0 +1,43 @@
import React from 'react';
import { theme } from '@vegaprotocol/tailwindcss-config';
import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets';
interface Props {
candles: (SimpleMarkets_markets_candles | null)[] | null;
}
const getChange = (
candles: (SimpleMarkets_markets_candles | null)[] | null
) => {
if (candles) {
const first = parseInt(candles.find((item) => item?.open)?.open || '-1');
const last = candles.reduceRight((aggr, item) => {
if (aggr === -1 && item?.close) {
aggr = parseInt(item.close);
}
return aggr;
}, -1);
if (first !== -1 && last !== -1) {
return Number(((last - first) / first) * 100).toFixed(3) + '%';
}
}
return ' - ';
};
const getColor = (change: number | string) => {
if (parseFloat(change as string) > 0) {
return theme.colors.vega.green;
}
if (parseFloat(change as string) < 0) {
return theme.colors.vega.pink;
}
return theme.colors.intent.highlight;
};
const SimpleMarketPercentChange = ({ candles }: Props) => {
const change = getChange(candles);
const color = getColor(change);
return <p style={{ color }}>{change}</p>;
};
export default SimpleMarketPercentChange;

View File

@ -0,0 +1 @@
export const DATE_FORMAT = 'dd MMMM yyyy HH:mm';

View File

@ -9,6 +9,6 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<div id="root"></div>
<div id="root" class="h-full max-h-full"></div>
</body>
</html>

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"],
"types": ["jest", "node", "@testing-library/jest-dom"],
"jsx": "react"
},
"include": [