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 { defaultFallbackInView } from 'react-intersection-observer';
defaultFallbackInView(true);
Object.defineProperty(window, 'matchMedia', {
writable: true,

View File

@ -43,7 +43,7 @@ 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="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">
<DrawerToggle
onToggle={onToggle}

View File

@ -10,5 +10,5 @@ export const DrawerContainer = ({ 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
// 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
@ -117,6 +117,5 @@ export interface SimpleMarkets {
}
export interface SimpleMarketsVariables {
CandleInterval: Interval;
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}
query SimpleMarkets($CandleInterval: Interval!, $CandleSince: String!) {
query SimpleMarkets($CandleSince: String!) {
markets {
id
name
@ -41,7 +41,7 @@ const MARKETS_QUERY = gql`
}
}
}
candles(interval: $CandleInterval, since: $CandleSince) {
candles(interval: I1H, since: $CandleSince) {
open
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 = (
draft: SimpleMarkets_markets[],
delta: SimpleMarketDataSub_marketData

View File

@ -1,9 +1,12 @@
import React from '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 SimpleMarketList from './simple-market-list';
import { MARKETS_QUERY } from './data-provider';
import type { SimpleMarkets_markets } from './__generated__/SimpleMarkets';
import type { SimpleMarkets } from './__generated__/SimpleMarkets';
const mockedNavigate = jest.fn();
@ -12,11 +15,8 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate,
}));
jest.mock('./data-provider', () => jest.fn());
jest.mock('@vegaprotocol/react-helpers', () => ({
useDataProvider: jest.fn(),
t: (txt: string) => txt,
jest.mock('date-fns', () => ({
subDays: () => new Date('2022-06-02T11:11:21.721Z'),
}));
describe('SimpleMarketList', () => {
@ -24,16 +24,31 @@ describe('SimpleMarketList', () => {
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 })
it('should be properly renderer as empty', async () => {
const mocks: MockedResponse<SimpleMarkets> = {
request: {
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();
});
it('should be properly rendered with some data', () => {
it('should be properly rendered with some data', async () => {
const data = [
{
id: '1',
@ -76,11 +91,26 @@ describe('SimpleMarketList', () => {
},
},
] 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 })
const mocks: MockedResponse<SimpleMarkets> = {
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.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 { subDays } from 'date-fns';
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 type { MarketState } from '@vegaprotocol/types';
import SimpleMarketPercentChange from './simple-market-percent-change';
import SimpleMarketExpires from './simple-market-expires';
import DataProvider from './data-provider';
@ -11,18 +13,36 @@ import { MARKET_STATUS } from './constants';
const SimpleMarketList = () => {
const navigate = useNavigate();
const statusesRef = useRef<Record<string, MarketState | ''>>({});
const variables = useMemo(
() => ({
CandleInterval: 'I1H',
CandleSince: new Date(Date.now() - 24 * 60 * 60 * 1000).toJSON(),
CandleSince: subDays(Date.now(), 1).toJSON(),
}),
[]
);
const update = useCallback(
(delta) => {
if (statusesRef.current[delta.market.id] !== delta.market.state) {
return false;
}
return true;
},
[statusesRef]
);
const { data, error, loading } = useDataProvider(
DataProvider,
undefined, // @TODO - if we need a live update in the future
update,
variables
);
useEffect(() => {
const statuses: Record<string, MarketState | ''> = {};
data?.forEach((market) => {
statuses[market.id] = market.data?.market.state || '';
});
statusesRef.current = statuses;
}, [data]);
const onClick = useCallback(
(marketId) => {
navigate(`/trading/${marketId}`);
@ -52,7 +72,10 @@ const SimpleMarketList = () => {
</div>
<div className="w-full grid sm:grid-rows-2">
<div>
<SimpleMarketPercentChange candles={market.candles} />
<SimpleMarketPercentChange
candles={market.candles}
marketId={market.id}
/>
</div>
<div>
<Lozenge

View File

@ -1,6 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { theme } from '@vegaprotocol/tailwindcss-config';
import { MockedProvider } from '@apollo/react-testing';
import SimpleMarketPercentChange from './simple-market-percent-change';
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;
it('empty array', () => {
candles = [];
render(<SimpleMarketPercentChange candles={candles} />);
render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('null', () => {
candles = null;
render(<SimpleMarketPercentChange candles={candles} />);
render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('an appreciated one', () => {
@ -22,7 +31,11 @@ describe('SimpleMarketPercentChange should parse proper change', () => {
{ close: '100' } as SimpleMarkets_markets_candles,
null,
];
render(<SimpleMarketPercentChange candles={candles} />);
render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('100.000%')).toBeInTheDocument();
expect(screen.getByText('100.000%')).toHaveStyle(
`color: ${theme.colors.vega.green}`
@ -34,7 +47,11 @@ describe('SimpleMarketPercentChange should parse proper change', () => {
{ close: '50' } as SimpleMarkets_markets_candles,
null,
];
render(<SimpleMarketPercentChange candles={candles} />);
render(
<MockedProvider>
<SimpleMarketPercentChange candles={candles} marketId={'1'} />
</MockedProvider>
);
expect(screen.getByText('-50.000%')).toBeInTheDocument();
expect(screen.getByText('-50.000%')).toHaveStyle(
`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 type { SimpleMarkets_markets_candles } from './__generated__/SimpleMarkets';
import type {
CandleLive,
CandleLiveVariables,
} from './__generated__/CandleLive';
import { CANDLE_SUB } from './data-provider';
interface Props {
candles: (SimpleMarkets_markets_candles | null)[] | null;
marketId: string;
}
const getChange = (
candles: (SimpleMarkets_markets_candles | null)[] | null
candles: (SimpleMarkets_markets_candles | null)[] | null,
lastClose?: string
) => {
if (candles) {
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) {
aggr = parseInt(item.close);
}
return aggr;
}, -1);
}, -1)
: parseInt(lastClose);
if (first !== -1 && last !== -1) {
return Number(((last - first) / first) * 100).toFixed(3) + '%';
}
@ -34,10 +46,25 @@ const getColor = (change: number | string) => {
return theme.colors.intent.highlight;
};
const SimpleMarketPercentChange = ({ candles }: Props) => {
const change = getChange(candles);
const SimpleMarketPercentChangeWrapper = (props: Props) => {
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);
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 { 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) {
if (!base) {
@ -40,6 +49,24 @@ export function createClient(base?: string) {
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 }) => {
console.log(graphQLErrors);
console.log(networkError);
@ -47,7 +74,7 @@ export function createClient(base?: string) {
return new ApolloClient({
connectToDevTools: process.env['NODE_ENV'] === 'development',
link: from([errorLink, retryLink, httpLink]),
link: from([errorLink, retryLink, splitLink]),
cache,
});
}

View File

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

View File

@ -18049,6 +18049,11 @@ react-inspector@^5.1.0:
is-dom "^1.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:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"