Feat/439 simple market list live update (#503)

* feat: [simple-app] - simple market list - improvements in apollo client

* feat: [simple-app] - simple market list - add live update for state and price change

* feat: [simple-app] - simple market list - add live update for state and price change

* feat: [simple-app] - simple market list - add intersection observer, small improvements

* feat: [simple-app] - simple market list - small improvements after review feedback

* feat: [simple-app] - simple market list - small improvements after review feedback

Co-authored-by: maciek <maciek@vegaprotocol.io>
This commit is contained in:
macqbat 2022-06-06 11:14:44 +02:00 committed by GitHub
parent 3defb50739
commit 7b19d5e5e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 212 additions and 45 deletions

View File

@ -1,4 +1,7 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(true);
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,

View File

@ -43,7 +43,7 @@ function App() {
<ApolloProvider client={client}> <ApolloProvider client={client}>
<VegaWalletProvider> <VegaWalletProvider>
<AppLoader> <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="max-h-full min-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="flex items-stretch border-b-[7px] border-vega-yellow">
<DrawerToggle <DrawerToggle
onToggle={onToggle} onToggle={onToggle}

View File

@ -10,5 +10,5 @@ export const DrawerContainer = ({ children }: Props) => (
); );
export const DrawerWrapper = ({ children }: Props) => ( export const DrawerWrapper = ({ children }: Props) => (
<div className="flex">{children}</div> <div className="flex dark:bg-black">{children}</div>
); );

View File

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL subscription operation: CandleLive
// ====================================================
export interface CandleLive_candles {
__typename: "Candle";
/**
* Close price (uint64)
*/
close: string;
}
export interface CandleLive {
/**
* Subscribe to the candles updates
*/
candles: CandleLive_candles;
}
export interface CandleLiveVariables {
marketId: string;
}

View File

@ -3,7 +3,7 @@
// @generated // @generated
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { Interval, MarketState } from "@vegaprotocol/types"; import { MarketState } from "@vegaprotocol/types";
// ==================================================== // ====================================================
// GraphQL query operation: SimpleMarkets // GraphQL query operation: SimpleMarkets
@ -117,6 +117,5 @@ export interface SimpleMarkets {
} }
export interface SimpleMarketsVariables { export interface SimpleMarketsVariables {
CandleInterval: Interval;
CandleSince: string; CandleSince: string;
} }

View File

@ -18,9 +18,9 @@ const MARKET_DATA_FRAGMENT = gql`
} }
`; `;
const MARKETS_QUERY = gql` export const MARKETS_QUERY = gql`
${MARKET_DATA_FRAGMENT} ${MARKET_DATA_FRAGMENT}
query SimpleMarkets($CandleInterval: Interval!, $CandleSince: String!) { query SimpleMarkets($CandleSince: String!) {
markets { markets {
id id
name name
@ -41,7 +41,7 @@ const MARKETS_QUERY = gql`
} }
} }
} }
candles(interval: $CandleInterval, since: $CandleSince) { candles(interval: I1H, since: $CandleSince) {
open open
close close
} }
@ -58,6 +58,14 @@ const MARKET_DATA_SUB = gql`
} }
`; `;
export const CANDLE_SUB = gql`
subscription CandleLive($marketId: ID!) {
candles(marketId: $marketId, interval: I1H) {
close
}
}
`;
const update = ( const update = (
draft: SimpleMarkets_markets[], draft: SimpleMarkets_markets[],
delta: SimpleMarketDataSub_marketData delta: SimpleMarketDataSub_marketData

View File

@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { useDataProvider } from '@vegaprotocol/react-helpers'; import { MockedProvider } from '@apollo/client/testing';
import type { MockedResponse } from '@apollo/client/testing';
import { MarketState } from '@vegaprotocol/types'; import { MarketState } from '@vegaprotocol/types';
import SimpleMarketList from './simple-market-list'; import SimpleMarketList from './simple-market-list';
import { MARKETS_QUERY } from './data-provider';
import type { SimpleMarkets_markets } from './__generated__/SimpleMarkets'; import type { SimpleMarkets_markets } from './__generated__/SimpleMarkets';
import type { SimpleMarkets } from './__generated__/SimpleMarkets';
const mockedNavigate = jest.fn(); const mockedNavigate = jest.fn();
@ -12,11 +15,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('./data-provider', () => jest.fn()); jest.mock('date-fns', () => ({
subDays: () => new Date('2022-06-02T11:11:21.721Z'),
jest.mock('@vegaprotocol/react-helpers', () => ({
useDataProvider: jest.fn(),
t: (txt: string) => txt,
})); }));
describe('SimpleMarketList', () => { describe('SimpleMarketList', () => {
@ -24,16 +24,31 @@ describe('SimpleMarketList', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should be properly renderer as empty', () => { it('should be properly renderer as empty', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const mocks: MockedResponse<SimpleMarkets> = {
(useDataProvider as unknown as jest.SpyInstance<any>).mockImplementation( request: {
() => ({ data: [], error: false, loading: false }) query: MARKETS_QUERY,
variables: {
CandleSince: '2022-06-02T11:11:21.721Z',
},
},
result: {
data: { markets: [] },
},
};
render(
<MockedProvider mocks={[mocks]}>
<SimpleMarketList />
</MockedProvider>
); );
render(<SimpleMarketList />);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByText('No data to display')).toBeInTheDocument(); expect(screen.getByText('No data to display')).toBeInTheDocument();
}); });
it('should be properly rendered with some data', () => { it('should be properly rendered with some data', async () => {
const data = [ const data = [
{ {
id: '1', id: '1',
@ -76,11 +91,26 @@ describe('SimpleMarketList', () => {
}, },
}, },
] as unknown as SimpleMarkets_markets[]; ] as unknown as SimpleMarkets_markets[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(useDataProvider as unknown as jest.SpyInstance<any>).mockImplementation( const mocks: MockedResponse<SimpleMarkets> = {
() => ({ data, error: false, loading: false }) request: {
query: MARKETS_QUERY,
variables: {
CandleSince: '2022-06-02T11:11:21.721Z',
},
},
result: {
data: { markets: data },
},
};
render(
<MockedProvider mocks={[mocks]}>
<SimpleMarketList />
</MockedProvider>
); );
render(<SimpleMarketList />);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByRole('list')).toBeInTheDocument(); expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(2); expect(screen.getAllByRole('listitem')).toHaveLength(2);
}); });

View File

@ -1,9 +1,11 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { subDays } from 'date-fns';
import { useDataProvider } from '@vegaprotocol/react-helpers'; import { useDataProvider } from '@vegaprotocol/react-helpers';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { AsyncRenderer, Lozenge, Splash } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer, Lozenge, Splash } from '@vegaprotocol/ui-toolkit';
import { Button } from '@vegaprotocol/ui-toolkit'; import { Button } from '@vegaprotocol/ui-toolkit';
import type { MarketState } from '@vegaprotocol/types';
import SimpleMarketPercentChange from './simple-market-percent-change'; import SimpleMarketPercentChange from './simple-market-percent-change';
import SimpleMarketExpires from './simple-market-expires'; import SimpleMarketExpires from './simple-market-expires';
import DataProvider from './data-provider'; import DataProvider from './data-provider';
@ -11,18 +13,36 @@ import { MARKET_STATUS } from './constants';
const SimpleMarketList = () => { const SimpleMarketList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const statusesRef = useRef<Record<string, MarketState | ''>>({});
const variables = useMemo( const variables = useMemo(
() => ({ () => ({
CandleInterval: 'I1H', CandleSince: subDays(Date.now(), 1).toJSON(),
CandleSince: new Date(Date.now() - 24 * 60 * 60 * 1000).toJSON(),
}), }),
[] []
); );
const update = useCallback(
(delta) => {
if (statusesRef.current[delta.market.id] !== delta.market.state) {
return false;
}
return true;
},
[statusesRef]
);
const { data, error, loading } = useDataProvider( const { data, error, loading } = useDataProvider(
DataProvider, DataProvider,
undefined, // @TODO - if we need a live update in the future update,
variables variables
); );
useEffect(() => {
const statuses: Record<string, MarketState | ''> = {};
data?.forEach((market) => {
statuses[market.id] = market.data?.market.state || '';
});
statusesRef.current = statuses;
}, [data]);
const onClick = useCallback( const onClick = useCallback(
(marketId) => { (marketId) => {
navigate(`/trading/${marketId}`); navigate(`/trading/${marketId}`);
@ -52,7 +72,10 @@ const SimpleMarketList = () => {
</div> </div>
<div className="w-full grid sm:grid-rows-2"> <div className="w-full grid sm:grid-rows-2">
<div> <div>
<SimpleMarketPercentChange candles={market.candles} /> <SimpleMarketPercentChange
candles={market.candles}
marketId={market.id}
/>
</div> </div>
<div> <div>
<Lozenge <Lozenge

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { theme } from '@vegaprotocol/tailwindcss-config'; import { theme } from '@vegaprotocol/tailwindcss-config';
import { MockedProvider } from '@apollo/react-testing';
import SimpleMarketPercentChange from './simple-market-percent-change'; import SimpleMarketPercentChange from './simple-market-percent-change';
import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets'; import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets';
@ -8,12 +9,20 @@ describe('SimpleMarketPercentChange should parse proper change', () => {
let candles: (SimpleMarkets_markets_candles | null)[] | null; let candles: (SimpleMarkets_markets_candles | null)[] | null;
it('empty array', () => { it('empty array', () => {
candles = []; candles = [];
render(<SimpleMarketPercentChange candles={candles} />); render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-')).toBeInTheDocument(); expect(screen.getByText('-')).toBeInTheDocument();
}); });
it('null', () => { it('null', () => {
candles = null; candles = null;
render(<SimpleMarketPercentChange candles={candles} />); render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-')).toBeInTheDocument(); expect(screen.getByText('-')).toBeInTheDocument();
}); });
it('an appreciated one', () => { it('an appreciated one', () => {
@ -22,7 +31,11 @@ describe('SimpleMarketPercentChange should parse proper change', () => {
{ close: '100' } as SimpleMarkets_markets_candles, { close: '100' } as SimpleMarkets_markets_candles,
null, null,
]; ];
render(<SimpleMarketPercentChange candles={candles} />); render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('100.000%')).toBeInTheDocument(); expect(screen.getByText('100.000%')).toBeInTheDocument();
expect(screen.getByText('100.000%')).toHaveStyle( expect(screen.getByText('100.000%')).toHaveStyle(
`color: ${theme.colors.vega.green}` `color: ${theme.colors.vega.green}`
@ -34,7 +47,11 @@ describe('SimpleMarketPercentChange should parse proper change', () => {
{ close: '50' } as SimpleMarkets_markets_candles, { close: '50' } as SimpleMarkets_markets_candles,
null, null,
]; ];
render(<SimpleMarketPercentChange candles={candles} />); render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-50.000%')).toBeInTheDocument(); expect(screen.getByText('-50.000%')).toBeInTheDocument();
expect(screen.getByText('-50.000%')).toHaveStyle( expect(screen.getByText('-50.000%')).toHaveStyle(
`color: ${theme.colors.vega.pink}` `color: ${theme.colors.vega.pink}`

View File

@ -1,22 +1,34 @@
import React from 'react'; import React, { useState } from 'react';
import { InView } from 'react-intersection-observer';
import { useSubscription } from '@apollo/client';
import { theme } from '@vegaprotocol/tailwindcss-config'; import { theme } from '@vegaprotocol/tailwindcss-config';
import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets'; import type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets';
import type {
CandleLive,
CandleLiveVariables,
} from './__generated__/CandleLive';
import { CANDLE_SUB } from './data-provider';
interface Props { interface Props {
candles: (SimpleMarkets_markets_candles | null)[] | null; candles: (SimpleMarkets_markets_candles | null)[] | null;
marketId: string;
} }
const getChange = ( const getChange = (
candles: (SimpleMarkets_markets_candles | null)[] | null candles: (SimpleMarkets_markets_candles | null)[] | null,
lastClose?: string
) => { ) => {
if (candles) { if (candles) {
const first = parseInt(candles.find((item) => item?.open)?.open || '-1'); const first = parseInt(candles.find((item) => item?.open)?.open || '-1');
const last = candles.reduceRight((aggr, item) => { const last =
typeof lastClose === 'undefined'
? candles.reduceRight((aggr, item) => {
if (aggr === -1 && item?.close) { if (aggr === -1 && item?.close) {
aggr = parseInt(item.close); aggr = parseInt(item.close);
} }
return aggr; return aggr;
}, -1); }, -1)
: parseInt(lastClose);
if (first !== -1 && last !== -1) { if (first !== -1 && last !== -1) {
return Number(((last - first) / first) * 100).toFixed(3) + '%'; return Number(((last - first) / first) * 100).toFixed(3) + '%';
} }
@ -34,10 +46,25 @@ const getColor = (change: number | string) => {
return theme.colors.intent.highlight; return theme.colors.intent.highlight;
}; };
const SimpleMarketPercentChange = ({ candles }: Props) => { const SimpleMarketPercentChangeWrapper = (props: Props) => {
const change = getChange(candles); const [inView, setInView] = useState(false);
return (
// @ts-ignore falsy wrong type?
<InView onChange={setInView}>
{inView && <SimpleMarketPercentChange {...props} />}
</InView>
);
};
const SimpleMarketPercentChange = ({ candles, marketId }: Props) => {
const { data: { candles: { close = undefined } = {} } = {} } =
useSubscription<CandleLive, CandleLiveVariables>(CANDLE_SUB, {
variables: { marketId },
});
const change = getChange(candles, close);
const color = getColor(change); const color = getColor(change);
return <p style={{ color }}>{change}</p>; return <p style={{ color }}>{change}</p>;
}; };
export default SimpleMarketPercentChange; export default SimpleMarketPercentChangeWrapper;

View File

@ -1,6 +1,15 @@
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client'; import {
ApolloClient,
from,
HttpLink,
InMemoryCache,
split,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error'; import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry'; import { RetryLink } from '@apollo/client/link/retry';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient as createWSClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
export function createClient(base?: string) { export function createClient(base?: string) {
if (!base) { if (!base) {
@ -40,6 +49,24 @@ export function createClient(base?: string) {
credentials: 'same-origin', credentials: 'same-origin',
}); });
const wsLink = new GraphQLWsLink(
createWSClient({
url: urlWS.href,
})
);
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const errorLink = onError(({ graphQLErrors, networkError }) => { const errorLink = onError(({ graphQLErrors, networkError }) => {
console.log(graphQLErrors); console.log(graphQLErrors);
console.log(networkError); console.log(networkError);
@ -47,7 +74,7 @@ export function createClient(base?: string) {
return new ApolloClient({ return new ApolloClient({
connectToDevTools: process.env['NODE_ENV'] === 'development', connectToDevTools: process.env['NODE_ENV'] === 'development',
link: from([errorLink, retryLink, httpLink]), link: from([errorLink, retryLink, splitLink]),
cache, cache,
}); });
} }

View File

@ -60,6 +60,7 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-hook-form": "^7.27.0", "react-hook-form": "^7.27.0",
"react-i18next": "^11.11.4", "react-i18next": "^11.11.4",
"react-intersection-observer": "^9.2.2",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-syntax-highlighter": "^15.4.5", "react-syntax-highlighter": "^15.4.5",
"react-use-websocket": "^3.0.0", "react-use-websocket": "^3.0.0",

View File

@ -18049,6 +18049,11 @@ react-inspector@^5.1.0:
is-dom "^1.0.0" is-dom "^1.0.0"
prop-types "^15.0.0" prop-types "^15.0.0"
react-intersection-observer@^9.2.2:
version "9.2.2"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.2.2.tgz#4f72487e2e9c62b6a6f55f38c5b0b6b7dc6c196d"
integrity sha512-xrFOWo5DbDzayAuDn6WpLrXfw73mvfx4WjuAjjy7dY7jWeTTle5+18nGtvMlR7q0npJanmmh6/CaSaSg4JxTJQ==
react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2: react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2:
version "17.0.2" version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"