Merge pull request #114 from vegaprotocol/feature/29-market-list-table-improvments
Feature/29 market list table improvments
This commit is contained in:
commit
73e778de34
@ -1,128 +0,0 @@
|
|||||||
import { gql, useApolloClient } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
Markets,
|
|
||||||
Markets_markets,
|
|
||||||
MarketDataSub,
|
|
||||||
MarketDataSub_marketData,
|
|
||||||
} from '@vegaprotocol/graphql';
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
const MARKET_DATA_FRAGMENT = gql`
|
|
||||||
fragment MarketDataFields on MarketData {
|
|
||||||
market {
|
|
||||||
id
|
|
||||||
state
|
|
||||||
tradingMode
|
|
||||||
}
|
|
||||||
bestBidPrice
|
|
||||||
bestOfferPrice
|
|
||||||
markPrice
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MARKETS_QUERY = gql`
|
|
||||||
${MARKET_DATA_FRAGMENT}
|
|
||||||
query Markets {
|
|
||||||
markets {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
decimalPlaces
|
|
||||||
data {
|
|
||||||
...MarketDataFields
|
|
||||||
}
|
|
||||||
tradableInstrument {
|
|
||||||
instrument {
|
|
||||||
code
|
|
||||||
product {
|
|
||||||
... on Future {
|
|
||||||
settlementAsset {
|
|
||||||
symbol
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const MARKET_DATA_SUB = gql`
|
|
||||||
${MARKET_DATA_FRAGMENT}
|
|
||||||
subscription MarketDataSub {
|
|
||||||
marketData {
|
|
||||||
...MarketDataFields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface UseMarkets {
|
|
||||||
markets: Markets_markets[];
|
|
||||||
error: Error | null;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMarkets = (): UseMarkets => {
|
|
||||||
const client = useApolloClient();
|
|
||||||
const [markets, setMarkets] = useState<Markets_markets[]>([]);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const mergeMarketData = useCallback((update: MarketDataSub_marketData) => {
|
|
||||||
setMarkets((curr) => {
|
|
||||||
return curr.map((m) => {
|
|
||||||
if (update.market.id === m.id) {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
data: update,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Make initial fetch
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchOrders = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await client.query<Markets>({
|
|
||||||
query: MARKETS_QUERY,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.data.markets?.length) return;
|
|
||||||
|
|
||||||
setMarkets(res.data.markets);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchOrders();
|
|
||||||
}, [mergeMarketData, client]);
|
|
||||||
|
|
||||||
// Start subscription
|
|
||||||
useEffect(() => {
|
|
||||||
const sub = client
|
|
||||||
// This data callback will unfortunately be called separately with an update for every market,
|
|
||||||
// perhaps we should batch this somehow...
|
|
||||||
.subscribe<MarketDataSub>({
|
|
||||||
query: MARKET_DATA_SUB,
|
|
||||||
})
|
|
||||||
.subscribe(({ data }) => {
|
|
||||||
mergeMarketData(data.marketData);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (sub) {
|
|
||||||
sub.unsubscribe();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [client, mergeMarketData]);
|
|
||||||
|
|
||||||
return { markets, error, loading };
|
|
||||||
};
|
|
@ -1,17 +1,76 @@
|
|||||||
import { Markets } from '@vegaprotocol/graphql';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import assign from 'assign-deep';
|
||||||
|
import { useApolloClient } from '@apollo/client';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { MarketListTable } from '@vegaprotocol/market-list';
|
|
||||||
import { useMarkets } from '../../hooks/use-markets';
|
|
||||||
import { AsyncRenderer } from '../../components/async-renderer';
|
import { AsyncRenderer } from '../../components/async-renderer';
|
||||||
|
import { MarketListTable, getRowNodeId } from '@vegaprotocol/market-list';
|
||||||
|
import {
|
||||||
|
Markets_markets,
|
||||||
|
Markets_markets_data,
|
||||||
|
MarketsDataProviderCallbackArg,
|
||||||
|
marketsDataProvider,
|
||||||
|
} from '@vegaprotocol/graphql';
|
||||||
|
|
||||||
|
import type { AgGridReact } from 'ag-grid-react';
|
||||||
|
|
||||||
const Markets = () => {
|
const Markets = () => {
|
||||||
const { pathname, push } = useRouter();
|
const { pathname, push } = useRouter();
|
||||||
const { markets, error, loading } = useMarkets();
|
const [markets, setMarkets] = useState<Markets_markets[]>(undefined);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<Error>(undefined);
|
||||||
|
const client = useApolloClient();
|
||||||
|
const gridRef = useRef<AgGridReact>();
|
||||||
|
const initialized = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return marketsDataProvider(
|
||||||
|
client,
|
||||||
|
({ data, error, loading, delta }: MarketsDataProviderCallbackArg) => {
|
||||||
|
setError(error);
|
||||||
|
setLoading(loading);
|
||||||
|
if (!error && !loading) {
|
||||||
|
if (!initialized.current || !gridRef.current) {
|
||||||
|
initialized.current = true;
|
||||||
|
setMarkets(data);
|
||||||
|
} else {
|
||||||
|
const update: Markets_markets[] = [];
|
||||||
|
const add: Markets_markets[] = [];
|
||||||
|
|
||||||
|
// split into updates and adds
|
||||||
|
if (!gridRef.current) return;
|
||||||
|
const rowNode = gridRef.current.api.getRowNode(
|
||||||
|
getRowNodeId(delta.market)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rowNode) {
|
||||||
|
const updatedData = produce(
|
||||||
|
rowNode.data.data,
|
||||||
|
(draft: Markets_markets_data) => assign(draft, delta)
|
||||||
|
);
|
||||||
|
if (updatedData !== rowNode.data.data) {
|
||||||
|
update.push({ ...rowNode.data, data: delta });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// @TODO - else add new market
|
||||||
|
if (update.length || add.length) {
|
||||||
|
gridRef.current.api.applyTransactionAsync({
|
||||||
|
update,
|
||||||
|
add,
|
||||||
|
addIndex: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [client, initialized]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncRenderer loading={loading} error={error} data={markets}>
|
<AsyncRenderer loading={loading} error={error} data={markets}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<MarketListTable
|
<MarketListTable
|
||||||
|
ref={gridRef}
|
||||||
markets={data}
|
markets={data}
|
||||||
onRowClicked={(id) =>
|
onRowClicked={(id) =>
|
||||||
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
|
push(`${pathname}/${id}?portfolio=orders&trade=orderbook`)
|
||||||
|
1
libs/graphql/src/data-providers/index.ts
Normal file
1
libs/graphql/src/data-providers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './markets-data-provider';
|
193
libs/graphql/src/data-providers/markets-data-provider.ts
Normal file
193
libs/graphql/src/data-providers/markets-data-provider.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import type { ApolloClient } from '@apollo/client';
|
||||||
|
import type { Subscription } from 'zen-observable-ts';
|
||||||
|
import { Markets, Markets_markets } from '../__generated__/Markets';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MarketDataSub,
|
||||||
|
MarketDataSub_marketData,
|
||||||
|
} from '../__generated__/MarketDataSub';
|
||||||
|
|
||||||
|
const MARKET_DATA_FRAGMENT = gql`
|
||||||
|
fragment MarketDataFields on MarketData {
|
||||||
|
market {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
tradingMode
|
||||||
|
}
|
||||||
|
bestBidPrice
|
||||||
|
bestOfferPrice
|
||||||
|
markPrice
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MARKETS_QUERY = gql`
|
||||||
|
${MARKET_DATA_FRAGMENT}
|
||||||
|
query Markets {
|
||||||
|
markets {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
decimalPlaces
|
||||||
|
data {
|
||||||
|
...MarketDataFields
|
||||||
|
}
|
||||||
|
tradableInstrument {
|
||||||
|
instrument {
|
||||||
|
code
|
||||||
|
product {
|
||||||
|
... on Future {
|
||||||
|
settlementAsset {
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MARKET_DATA_SUB = gql`
|
||||||
|
${MARKET_DATA_FRAGMENT}
|
||||||
|
subscription MarketDataSub {
|
||||||
|
marketData {
|
||||||
|
...MarketDataFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface MarketsDataProviderCallbackArg {
|
||||||
|
data: Markets_markets[] | null;
|
||||||
|
error?: Error;
|
||||||
|
loading: boolean;
|
||||||
|
delta?: MarketDataSub_marketData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketsDataProviderCallback {
|
||||||
|
(arg: MarketsDataProviderCallbackArg): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbacks: MarketsDataProviderCallback[] = [];
|
||||||
|
const updateQueue: MarketDataSub_marketData[] = [];
|
||||||
|
|
||||||
|
let data: Markets_markets[] | null = null;
|
||||||
|
let error: Error | undefined = undefined;
|
||||||
|
let loading = false;
|
||||||
|
let client: ApolloClient<object> | undefined = undefined;
|
||||||
|
let subscription: Subscription | undefined = undefined;
|
||||||
|
|
||||||
|
const notify = (
|
||||||
|
callback: MarketsDataProviderCallback,
|
||||||
|
delta?: MarketDataSub_marketData
|
||||||
|
) => {
|
||||||
|
callback({
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
delta,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyAll = (delta?: MarketDataSub_marketData) => {
|
||||||
|
callbacks.forEach((callback) => notify(callback, delta));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (
|
||||||
|
draft: Markets_markets[] | null,
|
||||||
|
delta: MarketDataSub_marketData
|
||||||
|
) => {
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 initialize = async () => {
|
||||||
|
if (subscription) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading = true;
|
||||||
|
error = undefined;
|
||||||
|
notifyAll();
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subscription = client
|
||||||
|
.subscribe<MarketDataSub>({
|
||||||
|
query: MARKET_DATA_SUB,
|
||||||
|
})
|
||||||
|
.subscribe(({ data: delta }) => {
|
||||||
|
if (!delta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
updateQueue.push(delta.marketData);
|
||||||
|
} else {
|
||||||
|
const newData = produce(data, (draft) => {
|
||||||
|
update(draft, delta.marketData);
|
||||||
|
});
|
||||||
|
if (newData === data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = newData;
|
||||||
|
notifyAll(delta.marketData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await client.query<Markets>({
|
||||||
|
query: MARKETS_QUERY,
|
||||||
|
});
|
||||||
|
data = res.data.markets;
|
||||||
|
if (updateQueue && updateQueue.length > 0) {
|
||||||
|
data = produce(data, (draft) => {
|
||||||
|
while (updateQueue.length) {
|
||||||
|
const delta = updateQueue.shift();
|
||||||
|
if (delta) {
|
||||||
|
update(draft, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
subscription = undefined;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = (callback: MarketsDataProviderCallback) => {
|
||||||
|
callbacks.splice(callbacks.indexOf(callback), 1);
|
||||||
|
if (callbacks.length === 0) {
|
||||||
|
if (subscription) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
subscription = undefined;
|
||||||
|
}
|
||||||
|
data = null;
|
||||||
|
error = undefined;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const marketsDataProvider = (
|
||||||
|
c: ApolloClient<object>,
|
||||||
|
callback: MarketsDataProviderCallback
|
||||||
|
) => {
|
||||||
|
if (!client) {
|
||||||
|
client = c;
|
||||||
|
}
|
||||||
|
callbacks.push(callback);
|
||||||
|
if (callbacks.length === 1) {
|
||||||
|
initialize();
|
||||||
|
} else {
|
||||||
|
notify(callback);
|
||||||
|
}
|
||||||
|
return () => unsubscribe(callback);
|
||||||
|
};
|
@ -14,3 +14,5 @@ export * from './__generated__/Orders';
|
|||||||
export * from './__generated__/OrderSub';
|
export * from './__generated__/OrderSub';
|
||||||
export * from './__generated__/PartyAssetsQuery';
|
export * from './__generated__/PartyAssetsQuery';
|
||||||
export * from './__generated__/ProposalsQuery';
|
export * from './__generated__/ProposalsQuery';
|
||||||
|
|
||||||
|
export * from './data-providers';
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
import { MockedProvider } from '@apollo/react-testing';
|
import { MockedProvider } from '@apollo/react-testing';
|
||||||
import MarketListTable from './market-list-table';
|
import MarketListTable from './market-list-table';
|
||||||
|
|
||||||
describe('MarketListTable', () => {
|
describe('MarketListTable', () => {
|
||||||
it('should render successfully', () => {
|
it('should render successfully', async () => {
|
||||||
const { baseElement } = render(
|
await act(async () => {
|
||||||
<MockedProvider>
|
const { baseElement } = render(
|
||||||
<MarketListTable width={100} height={100} />
|
<MockedProvider>
|
||||||
</MockedProvider>
|
<MarketListTable />
|
||||||
);
|
</MockedProvider>
|
||||||
expect(baseElement).toBeTruthy();
|
);
|
||||||
|
expect(baseElement).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,89 +1,83 @@
|
|||||||
import type { GridApi, ValueFormatterParams } from 'ag-grid-community';
|
import { forwardRef } from 'react';
|
||||||
import {
|
import type { ValueFormatterParams } from 'ag-grid-community';
|
||||||
PriceCell,
|
import { PriceCell, formatNumber } from '@vegaprotocol/react-helpers';
|
||||||
formatNumber,
|
|
||||||
useApplyGridTransaction,
|
|
||||||
} from '@vegaprotocol/react-helpers';
|
|
||||||
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
import { AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit';
|
||||||
import { Markets_markets } from '@vegaprotocol/graphql';
|
import { Markets_markets } from '@vegaprotocol/graphql';
|
||||||
import { AgGridColumn } from 'ag-grid-react';
|
import { AgGridColumn } from 'ag-grid-react';
|
||||||
import { useRef, useState } from 'react';
|
import type { AgGridReact } from 'ag-grid-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface MarketListTableProps {
|
interface MarketListTableProps {
|
||||||
markets: Markets_markets[];
|
markets: Markets_markets[];
|
||||||
onRowClicked: (marketId: string) => void;
|
onRowClicked: (marketId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MarketListTable = ({
|
export const getRowNodeId = (data: { id: string }) => data.id;
|
||||||
markets,
|
|
||||||
onRowClicked,
|
|
||||||
}: MarketListTableProps) => {
|
|
||||||
const [initialMarkets] = useState(markets);
|
|
||||||
const gridApi = useRef<GridApi | null>(null);
|
|
||||||
useApplyGridTransaction<Markets_markets>(markets, gridApi.current);
|
|
||||||
|
|
||||||
return (
|
export const MarketListTable = forwardRef<AgGridReact, MarketListTableProps>(
|
||||||
<AgGrid
|
({ markets, onRowClicked }, ref) => {
|
||||||
style={{ width: '100%', height: '100%' }}
|
return (
|
||||||
overlayNoRowsTemplate="No markets"
|
<AgGrid
|
||||||
rowData={initialMarkets}
|
style={{ width: '100%', height: '100%' }}
|
||||||
getRowNodeId={(data) => data.id}
|
overlayNoRowsTemplate="No markets"
|
||||||
suppressCellFocus={true}
|
rowData={markets}
|
||||||
defaultColDef={{
|
getRowNodeId={getRowNodeId}
|
||||||
flex: 1,
|
ref={ref}
|
||||||
resizable: true,
|
defaultColDef={{
|
||||||
}}
|
flex: 1,
|
||||||
onGridReady={(params) => {
|
resizable: true,
|
||||||
gridApi.current = params.api;
|
}}
|
||||||
}}
|
onRowClicked={({ data }: { data: Markets_markets }) =>
|
||||||
onRowClicked={({ data }) => onRowClicked(data.id)}
|
onRowClicked(data.id)
|
||||||
components={{ PriceCell }}
|
|
||||||
>
|
|
||||||
<AgGridColumn
|
|
||||||
headerName="Market"
|
|
||||||
field="tradableInstrument.instrument.code"
|
|
||||||
/>
|
|
||||||
<AgGridColumn
|
|
||||||
headerName="Settlement asset"
|
|
||||||
field="tradableInstrument.instrument.product.settlementAsset.symbol"
|
|
||||||
/>
|
|
||||||
<AgGridColumn
|
|
||||||
headerName="State"
|
|
||||||
field="data"
|
|
||||||
valueFormatter={({ value }: ValueFormatterParams) =>
|
|
||||||
`${value.market.state} (${value.market.tradingMode})`
|
|
||||||
}
|
}
|
||||||
/>
|
components={{ PriceCell }}
|
||||||
<AgGridColumn
|
>
|
||||||
headerName="Best bid"
|
<AgGridColumn
|
||||||
field="data.bestBidPrice"
|
headerName="Market"
|
||||||
type="rightAligned"
|
field="tradableInstrument.instrument.code"
|
||||||
cellRenderer="PriceCell"
|
/>
|
||||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
<AgGridColumn
|
||||||
formatNumber(value, data.decimalPlaces)
|
headerName="Settlement asset"
|
||||||
}
|
field="tradableInstrument.instrument.product.settlementAsset.symbol"
|
||||||
/>
|
/>
|
||||||
<AgGridColumn
|
<AgGridColumn
|
||||||
headerName="Best offer"
|
headerName="State"
|
||||||
field="data.bestOfferPrice"
|
field="data"
|
||||||
type="rightAligned"
|
valueFormatter={({ value }: ValueFormatterParams) =>
|
||||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
`${value.market.state} (${value.market.tradingMode})`
|
||||||
formatNumber(value, data.decimalPlaces)
|
}
|
||||||
}
|
/>
|
||||||
cellRenderer="PriceCell"
|
<AgGridColumn
|
||||||
/>
|
headerName="Best bid"
|
||||||
<AgGridColumn
|
field="data.bestBidPrice"
|
||||||
headerName="Mark price"
|
type="rightAligned"
|
||||||
field="data.markPrice"
|
cellRenderer="PriceCell"
|
||||||
type="rightAligned"
|
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||||
cellRenderer="PriceCell"
|
formatNumber(value, data.decimalPlaces)
|
||||||
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
}
|
||||||
formatNumber(value, data.decimalPlaces)
|
/>
|
||||||
}
|
<AgGridColumn
|
||||||
/>
|
headerName="Best offer"
|
||||||
<AgGridColumn headerName="Description" field="name" />
|
field="data.bestOfferPrice"
|
||||||
</AgGrid>
|
type="rightAligned"
|
||||||
);
|
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||||
};
|
formatNumber(value, data.decimalPlaces)
|
||||||
|
}
|
||||||
|
cellRenderer="PriceCell"
|
||||||
|
/>
|
||||||
|
<AgGridColumn
|
||||||
|
headerName="Mark price"
|
||||||
|
field="data.markPrice"
|
||||||
|
type="rightAligned"
|
||||||
|
cellRenderer="PriceCell"
|
||||||
|
valueFormatter={({ value, data }: ValueFormatterParams) =>
|
||||||
|
formatNumber(value, data.decimalPlaces)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AgGridColumn headerName="Description" field="name" />
|
||||||
|
</AgGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default MarketListTable;
|
export default MarketListTable;
|
||||||
|
21
libs/react-helpers/src/lib/grid-cells/flash-cell.stories.tsx
Normal file
21
libs/react-helpers/src/lib/grid-cells/flash-cell.stories.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { FlashCell } from './flash-cell';
|
||||||
|
import { Meta, Story } from '@storybook/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Component/FlashCell',
|
||||||
|
argTypes: {
|
||||||
|
value: {
|
||||||
|
control: { type: 'range', min: -20, max: 20, step: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Meta;
|
||||||
|
|
||||||
|
const Template: Story<{ value: number }> = ({ value }) => (
|
||||||
|
<FlashCell value={value}>{value.toFixed(0)}</FlashCell>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Basic = Template.bind({});
|
||||||
|
Basic.args = {
|
||||||
|
value: 100,
|
||||||
|
};
|
34
libs/react-helpers/src/lib/grid-cells/flash-cell.test.tsx
Normal file
34
libs/react-helpers/src/lib/grid-cells/flash-cell.test.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { findFirstDiffPos } from './flash-cell';
|
||||||
|
|
||||||
|
describe('findFirstDiffPos', () => {
|
||||||
|
it('Returns -1 for matching strings', () => {
|
||||||
|
const a = 'test';
|
||||||
|
const b = 'test';
|
||||||
|
|
||||||
|
expect(findFirstDiffPos(a, b)).toEqual(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns -1 if a string is undefined (just in case)', () => {
|
||||||
|
const a = 'test';
|
||||||
|
const b = undefined as any as string;
|
||||||
|
|
||||||
|
expect(findFirstDiffPos(a, b)).toEqual(-1);
|
||||||
|
expect(findFirstDiffPos(b, a)).toEqual(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Returns -1 if one string is empty', () => {
|
||||||
|
const a = 'test';
|
||||||
|
const b = '';
|
||||||
|
|
||||||
|
expect(findFirstDiffPos(a, b)).toEqual(-1);
|
||||||
|
expect(findFirstDiffPos(b, a)).toEqual(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Happy path', () => {
|
||||||
|
const a = 'test';
|
||||||
|
|
||||||
|
expect(findFirstDiffPos(a, 'test')).toEqual(-1);
|
||||||
|
expect(findFirstDiffPos(a, '!est')).toEqual(0);
|
||||||
|
expect(findFirstDiffPos(a, 't!st')).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
21
libs/react-helpers/src/lib/grid-cells/price-cell.test.tsx
Normal file
21
libs/react-helpers/src/lib/grid-cells/price-cell.test.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { PriceCell } from './price-cell';
|
||||||
|
|
||||||
|
describe('<PriceCell />', () => {
|
||||||
|
it('Displayes formatted value', () => {
|
||||||
|
render(<PriceCell value={100} valueFormatted="100.00" />);
|
||||||
|
expect(screen.getByTestId('price')).toHaveTextContent('100.00');
|
||||||
|
});
|
||||||
|
it('Displayes 0', () => {
|
||||||
|
render(<PriceCell value={0} valueFormatted="0.00" />);
|
||||||
|
expect(screen.getByTestId('price')).toHaveTextContent('0.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Displayes - if value is not a number', () => {
|
||||||
|
render(<PriceCell value={null} valueFormatted="" />);
|
||||||
|
expect(screen.getByTestId('price')).toHaveTextContent('-');
|
||||||
|
});
|
||||||
|
});
|
@ -6,12 +6,12 @@ export interface IPriceCellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PriceCell = ({ value, valueFormatted }: IPriceCellProps) => {
|
export const PriceCell = ({ value, valueFormatted }: IPriceCellProps) => {
|
||||||
if (!value || isNaN(Number(value))) return <span>-</span>;
|
if ((!value && value !== 0) || isNaN(Number(value))) {
|
||||||
|
return <span data-testid="price">-</span>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="font-mono">
|
<span className="font-mono" data-testid="price">
|
||||||
<FlashCell value={Number(value)} data-testid="price">
|
<FlashCell value={Number(value)}>{valueFormatted}</FlashCell>
|
||||||
{valueFormatted}
|
|
||||||
</FlashCell>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -18,10 +18,12 @@ const AgGridDarkTheme = dynamic<{ children: React.ReactElement }>(
|
|||||||
export const AgGridThemed = ({
|
export const AgGridThemed = ({
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
|
gridRef,
|
||||||
...props
|
...props
|
||||||
}: (AgGridReactProps | AgReactUiProps) & {
|
}: (AgGridReactProps | AgReactUiProps) & {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
gridRef?: React.ForwardedRef<AgGridReact>;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = React.useContext(ThemeContext);
|
const theme = React.useContext(ThemeContext);
|
||||||
const defaultProps = { rowHeight: 20, headerHeight: 22 };
|
const defaultProps = { rowHeight: 20, headerHeight: 22 };
|
||||||
@ -34,11 +36,11 @@ export const AgGridThemed = ({
|
|||||||
>
|
>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<AgGridDarkTheme>
|
<AgGridDarkTheme>
|
||||||
<AgGridReact {...defaultProps} {...props} />
|
<AgGridReact {...defaultProps} {...props} ref={gridRef} />
|
||||||
</AgGridDarkTheme>
|
</AgGridDarkTheme>
|
||||||
) : (
|
) : (
|
||||||
<AgGridLightTheme>
|
<AgGridLightTheme>
|
||||||
<AgGridReact {...defaultProps} {...props} />
|
<AgGridReact {...defaultProps} {...props} ref={gridRef} />
|
||||||
</AgGridLightTheme>
|
</AgGridLightTheme>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,15 +1,28 @@
|
|||||||
|
import * as React from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react';
|
import type {
|
||||||
|
AgGridReactProps,
|
||||||
|
AgReactUiProps,
|
||||||
|
AgGridReact,
|
||||||
|
} from 'ag-grid-react';
|
||||||
|
|
||||||
|
type Props = (AgGridReactProps | AgReactUiProps) & {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
gridRef?: React.Ref<AgGridReact>;
|
||||||
|
};
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/69433673/nextjs-reactdomserver-does-not-yet-support-suspense
|
// https://stackoverflow.com/questions/69433673/nextjs-reactdomserver-does-not-yet-support-suspense
|
||||||
export const AgGridDynamic = dynamic<
|
const AgGridDynamicInternal = dynamic<Props>(
|
||||||
(AgGridReactProps | AgReactUiProps) & {
|
() => import('./ag-grid-dynamic-themed').then((mod) => mod.AgGridThemed),
|
||||||
style?: React.CSSProperties;
|
{
|
||||||
className?: string;
|
ssr: false,
|
||||||
|
// https://nextjs.org/docs/messages/invalid-dynamic-suspense
|
||||||
|
// suspense: true
|
||||||
}
|
}
|
||||||
>(() => import('./ag-grid-dynamic-themed').then((mod) => mod.AgGridThemed), {
|
);
|
||||||
ssr: false,
|
|
||||||
// https://nextjs.org/docs/messages/invalid-dynamic-suspense
|
export const AgGridDynamic = React.forwardRef<AgGridReact, Props>(
|
||||||
// suspense: true
|
(props, ref) => <AgGridDynamicInternal {...props} gridRef={ref} />
|
||||||
});
|
);
|
||||||
|
@ -16,14 +16,13 @@ const AgGridDarkTheme = React.lazy(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AgGridThemed = ({
|
export const AgGridThemed = React.forwardRef<
|
||||||
style,
|
AgGridReact,
|
||||||
className,
|
(AgGridReactProps | AgReactUiProps) & {
|
||||||
...props
|
style?: React.CSSProperties;
|
||||||
}: (AgGridReactProps | AgReactUiProps) & {
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
}
|
||||||
className?: string;
|
>(({ style, className, ...props }, ref) => {
|
||||||
}) => {
|
|
||||||
const theme = React.useContext(ThemeContext);
|
const theme = React.useContext(ThemeContext);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -34,13 +33,13 @@ export const AgGridThemed = ({
|
|||||||
>
|
>
|
||||||
{theme === 'dark' ? (
|
{theme === 'dark' ? (
|
||||||
<AgGridDarkTheme>
|
<AgGridDarkTheme>
|
||||||
<AgGridReact {...props} />
|
<AgGridReact {...props} ref={ref} />
|
||||||
</AgGridDarkTheme>
|
</AgGridDarkTheme>
|
||||||
) : (
|
) : (
|
||||||
<AgGridLightTheme>
|
<AgGridLightTheme>
|
||||||
<AgGridReact {...props} />
|
<AgGridReact {...props} ref={ref} />
|
||||||
</AgGridLightTheme>
|
</AgGridLightTheme>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { AgGridReactProps, AgReactUiProps } from 'ag-grid-react';
|
import type { AgGridReact } from 'ag-grid-react';
|
||||||
|
|
||||||
const LazyAgGridStyled = React.lazy(() =>
|
export const AgGridLazyInternal = React.lazy(() =>
|
||||||
import('./ag-grid-lazy-themed').then((module) => ({
|
import('./ag-grid-lazy-themed').then((module) => ({
|
||||||
default: module.AgGridThemed,
|
default: module.AgGridThemed,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const AgGridLazy = (
|
export const AgGridLazy = React.forwardRef<AgGridReact>((props, ref) => (
|
||||||
props: (AgGridReactProps | AgReactUiProps) & { style: React.CSSProperties }
|
<AgGridLazyInternal {...props} ref={ref} />
|
||||||
) => <LazyAgGridStyled {...props} />;
|
));
|
||||||
|
@ -14,7 +14,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.5.8",
|
"@apollo/client": "^3.5.8",
|
||||||
"@apollo/react-testing": "^4.0.0",
|
|
||||||
"@blueprintjs/icons": "^3.32.0",
|
"@blueprintjs/icons": "^3.32.0",
|
||||||
"@nrwl/next": "13.8.1",
|
"@nrwl/next": "13.8.1",
|
||||||
"@radix-ui/react-dialog": "^0.1.5",
|
"@radix-ui/react-dialog": "^0.1.5",
|
||||||
@ -32,6 +31,7 @@
|
|||||||
"ag-grid-community": "^27.0.1",
|
"ag-grid-community": "^27.0.1",
|
||||||
"ag-grid-react": "^27.0.1",
|
"ag-grid-react": "^27.0.1",
|
||||||
"apollo": "^2.33.9",
|
"apollo": "^2.33.9",
|
||||||
|
"assign-deep": "^1.0.1",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"bignumber.js": "^9.0.2",
|
"bignumber.js": "^9.0.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
@ -42,6 +42,7 @@
|
|||||||
"graphql": "^15.7.2",
|
"graphql": "^15.7.2",
|
||||||
"graphql-ws": "^5.6.3",
|
"graphql-ws": "^5.6.3",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"immer": "^9.0.12",
|
||||||
"next": "12.0.7",
|
"next": "12.0.7",
|
||||||
"nx": "^13.8.3",
|
"nx": "^13.8.3",
|
||||||
"postcss": "^8.4.6",
|
"postcss": "^8.4.6",
|
||||||
@ -59,6 +60,7 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@apollo/react-testing": "^4.0.0",
|
||||||
"@babel/core": "7.12.13",
|
"@babel/core": "7.12.13",
|
||||||
"@babel/preset-typescript": "7.12.13",
|
"@babel/preset-typescript": "7.12.13",
|
||||||
"@nrwl/cli": "13.8.1",
|
"@nrwl/cli": "13.8.1",
|
||||||
|
17
yarn.lock
17
yarn.lock
@ -7026,11 +7026,23 @@ assertion-error@^1.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
|
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
|
||||||
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
|
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
|
||||||
|
|
||||||
|
assign-deep@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/assign-deep/-/assign-deep-1.0.1.tgz#b6d21d74e2f28bf6592e4c0c541bed6ab59c5f27"
|
||||||
|
integrity sha512-CSXAX79mibneEYfqLT5FEmkqR5WXF+xDRjgQQuVf6wSCXCYU8/vHttPidNar7wJ5BFmKAo8Wei0rCtzb+M/yeA==
|
||||||
|
dependencies:
|
||||||
|
assign-symbols "^2.0.2"
|
||||||
|
|
||||||
assign-symbols@^1.0.0:
|
assign-symbols@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
|
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
|
||||||
integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
|
integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
|
||||||
|
|
||||||
|
assign-symbols@^2.0.2:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-2.0.2.tgz#0fb9191dd9d617042746ecfc354f3a3d768a0c98"
|
||||||
|
integrity sha512-9sBQUQZMKFKcO/C3Bo6Rx4CQany0R0UeVcefNGRRdW2vbmaMOhV1sbmlXcQLcD56juLXbSGTBm0GGuvmrAF8pA==
|
||||||
|
|
||||||
ast-types-flow@^0.0.7:
|
ast-types-flow@^0.0.7:
|
||||||
version "0.0.7"
|
version "0.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
|
resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad"
|
||||||
@ -12484,6 +12496,11 @@ image-size@~0.5.0:
|
|||||||
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
|
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
|
||||||
integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=
|
integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=
|
||||||
|
|
||||||
|
immer@^9.0.12:
|
||||||
|
version "9.0.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
|
||||||
|
integrity sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==
|
||||||
|
|
||||||
immutable@^4.0.0:
|
immutable@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"
|
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"
|
||||||
|
Loading…
Reference in New Issue
Block a user