Skip to content

Commit d61f535

Browse files
authored
Cross-chain: ICTT users index, bridged tokens tab, and stats layout (#3351)
* fix stats counters layout * ICTT users index * bridged tokens page * move bridged tokens to the tab on tokens page * migrate to new API version * add support for sankey chart on stats page * rename Chart into LineChart * create reusable chart components * sankey widget * cross chain txs paths chart page * add filter for bridged tokens and fix sorting for ictt users * hide chain select for paths chart * add chain filter to paths chart page * show zero-value nodes on sankey chart * add tests * refactoring * fix tests * review fixes * Update ChainStatsDetails to handle counterparty chain IDs as an array and change chart sorting to ascending order
1 parent 7c2c631 commit d61f535

164 files changed

Lines changed: 3941 additions & 2131 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ui/shared/chart/ChartIntervalSelect.tsx renamed to client/features/chain-stats/components/ChartIntervalSelect.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { createListCollection } from '@chakra-ui/react';
22
import React from 'react';
33

4-
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
4+
import type { StatsInterval, StatsIntervalIds } from '../types/client';
55

66
import { Select } from 'toolkit/chakra/select';
77
import { Skeleton } from 'toolkit/chakra/skeleton';
88
import type { TagProps } from 'toolkit/chakra/tag';
99
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
10-
import { STATS_INTERVALS } from 'ui/stats/constants';
10+
11+
import { STATS_INTERVALS } from '../utils/interval';
1112

1213
const intervalCollection = createListCollection({
1314
items: Object.keys(STATS_INTERVALS).map((id: string) => ({
@@ -28,7 +29,7 @@ type Props = {
2829
selectTagSize?: TagProps['size'];
2930
};
3031

31-
const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize }: Props) => {
32+
const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading, selectTagSize = 'lg' }: Props) => {
3233

3334
const handleItemSelect = React.useCallback(({ value }: { value: Array<string> }) => {
3435
onIntervalChange(value[0] as StatsIntervalIds);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { chakra } from '@chakra-ui/react';
2+
import React from 'react';
3+
4+
import type { StatsIntervalIds } from '../types/client';
5+
6+
import { route, type Route } from 'nextjs-routes';
7+
8+
import { ChartResolution } from 'toolkit/components/charts';
9+
import { LineChartWidget } from 'toolkit/components/charts/line/LineChartWidget';
10+
import { useChartsConfig } from 'ui/shared/chart/config';
11+
12+
import useChartQuery from '../hooks/useChartQuery';
13+
import { getChartUrl } from '../utils/chart';
14+
15+
export interface Props {
16+
id: string;
17+
title: string;
18+
description: string;
19+
interval: StatsIntervalIds;
20+
onLoadingError: () => void;
21+
isLoading: boolean;
22+
className?: string;
23+
href?: Route;
24+
};
25+
26+
const ChartWidgetContainer = ({
27+
id,
28+
title,
29+
description,
30+
interval,
31+
onLoadingError,
32+
isLoading,
33+
className,
34+
href,
35+
}: Props) => {
36+
const query = useChartQuery({ id, resolution: ChartResolution.DAY, interval, enabled: !isLoading });
37+
38+
React.useEffect(() => {
39+
if (query.isError) {
40+
onLoadingError();
41+
}
42+
}, [ query.isError, onLoadingError ]);
43+
44+
const chartsConfig = useChartsConfig();
45+
46+
const data = query.data?.data;
47+
const units = query.data?.info?.units;
48+
49+
const charts = React.useMemo(() => {
50+
if (!data || data.length === 0) {
51+
return [];
52+
}
53+
54+
return [
55+
{
56+
id,
57+
name: 'Value',
58+
items: data,
59+
charts: chartsConfig,
60+
units,
61+
},
62+
];
63+
}, [ data, id, chartsConfig, units ]);
64+
65+
return (
66+
<LineChartWidget
67+
id={ id }
68+
charts={ charts }
69+
title={ title }
70+
description={ description }
71+
isLoading={ query.isPlaceholderData }
72+
isError={ query.isError }
73+
minH="230px"
74+
className={ className }
75+
href={ href ? route(href) : undefined }
76+
chartUrl={ getChartUrl(href) }
77+
/>
78+
);
79+
};
80+
81+
export default chakra(ChartWidgetContainer);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { uniqBy } from 'es-toolkit';
2+
import { useRouter } from 'next/router';
3+
import React from 'react';
4+
5+
import type { ChainStatsChart, ChainStatsPayload, ChainStatsSection, StatsIntervalIds } from '../types/client';
6+
import type { ClusterChainConfig } from 'types/multichain';
7+
8+
import { CROSS_CHAIN_TXS_SECTIONS } from 'client/features/cross-chain-txs/utils/chain-stats';
9+
import config from 'configs/app';
10+
import useApiQuery from 'lib/api/useApiQuery';
11+
import getQueryParamString from 'lib/router/getQueryParamString';
12+
13+
import { CHAIN_STATS_CHARTS } from '../stubs/charts';
14+
15+
function isSectionMatches(section: ChainStatsSection, currentSection: string): boolean {
16+
return currentSection === 'all' || section.id === currentSection;
17+
}
18+
19+
function isChartNameMatches(q: string, chart: ChainStatsChart) {
20+
return chart.title.toLowerCase().includes(q.toLowerCase());
21+
}
22+
23+
interface Props {
24+
chain?: ClusterChainConfig;
25+
}
26+
27+
export default function useChainStats({ chain }: Props = {}) {
28+
const router = useRouter();
29+
30+
const [ sectionId, setSectionId ] = React.useState('all');
31+
const [ filterQuery, setFilterQuery ] = React.useState('');
32+
const [ initialFilterQuery, setInitialFilterQuery ] = React.useState('');
33+
const [ interval, setInterval ] = React.useState<StatsIntervalIds>('oneMonth');
34+
35+
const { data, isPlaceholderData, isError } = useApiQuery<'stats:lines', unknown, ChainStatsPayload>('stats:lines', {
36+
queryOptions: {
37+
placeholderData: CHAIN_STATS_CHARTS,
38+
select: (data) => {
39+
const crossChainTxsFeature = (chain?.app_config || config).features.crossChainTxs;
40+
if (!crossChainTxsFeature.isEnabled) {
41+
return data;
42+
}
43+
44+
const sections: Array<ChainStatsSection> = data.sections.slice();
45+
46+
for (const extraSection of CROSS_CHAIN_TXS_SECTIONS) {
47+
const existingSection = sections.find((section) => section.id === extraSection.id);
48+
if (existingSection) {
49+
existingSection.charts = uniqBy([
50+
...existingSection.charts,
51+
...extraSection.charts,
52+
], (chart) => chart.id);
53+
} else {
54+
sections.push(extraSection);
55+
}
56+
}
57+
58+
return {
59+
sections,
60+
};
61+
},
62+
},
63+
chain,
64+
});
65+
66+
React.useEffect(() => {
67+
if (!isPlaceholderData && !isError) {
68+
const chartId = getQueryParamString(router.query.chartId);
69+
const chartName = data?.sections.map((section) => section.charts.find((chart) => chart.id === chartId)).filter(Boolean)[0]?.title;
70+
if (chartName) {
71+
setInitialFilterQuery(chartName);
72+
setFilterQuery(chartName);
73+
router.replace({ pathname: '/stats' }, undefined, { scroll: false });
74+
}
75+
}
76+
// run only when data is loaded
77+
// eslint-disable-next-line react-hooks/exhaustive-deps
78+
}, [ isPlaceholderData ]);
79+
80+
const displayedSections = React.useMemo(() => {
81+
return data?.sections
82+
?.map((section) => {
83+
const charts = section.charts.filter((chart) => isSectionMatches(section, sectionId) && isChartNameMatches(filterQuery, chart));
84+
85+
return {
86+
...section,
87+
charts,
88+
};
89+
}).filter((section) => section.charts.length > 0);
90+
}, [ sectionId, data?.sections, filterQuery ]);
91+
92+
return React.useMemo(() => ({
93+
sections: data?.sections,
94+
displayedSections,
95+
sectionId,
96+
97+
isLoading: isPlaceholderData,
98+
isError,
99+
100+
initialFilterQuery,
101+
filterQuery,
102+
103+
interval,
104+
105+
onFilterChange: setFilterQuery,
106+
onSectionChange: setSectionId,
107+
onIntervalChange: setInterval,
108+
}), [ data?.sections, displayedSections, sectionId, isPlaceholderData, isError, initialFilterQuery, filterQuery, interval ]);
109+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ChartDataPayloadLine, StatsIntervalIds } from '../types/client';
2+
import type { ChartResolution } from 'toolkit/components/charts/types';
3+
4+
import useApiQuery from 'lib/api/useApiQuery';
5+
6+
import { CHAIN_STATS_LINE_CHART } from '../stubs/charts';
7+
import { getDatesFromInterval } from '../utils/interval';
8+
9+
interface Props {
10+
id: string;
11+
resolution: ChartResolution;
12+
interval: StatsIntervalIds;
13+
enabled?: boolean;
14+
}
15+
16+
export default function useChartQuery({ id, resolution, interval, enabled = true }: Props) {
17+
18+
const { start: startDate, end: endDate } = getDatesFromInterval(interval);
19+
const resourceName = 'stats:line';
20+
21+
return useApiQuery<typeof resourceName, unknown, ChartDataPayloadLine | undefined>(resourceName, {
22+
pathParams: { id },
23+
queryParams: {
24+
from: startDate,
25+
to: endDate,
26+
resolution,
27+
},
28+
queryOptions: {
29+
enabled: enabled,
30+
refetchOnMount: false,
31+
placeholderData: (prevData) => {
32+
return prevData ?? CHAIN_STATS_LINE_CHART;
33+
},
34+
select: (data) => {
35+
return {
36+
type: 'line',
37+
info: data.info ?? {
38+
id: id,
39+
title: 'Chart title',
40+
description: 'Chart description',
41+
resolutions: [ ],
42+
},
43+
data: data.chart.map((item) => {
44+
return {
45+
date: new Date(item.date),
46+
date_to: new Date(item.date_to),
47+
value: Number(item.value),
48+
isApproximate: item.is_approximate,
49+
};
50+
}),
51+
};
52+
},
53+
},
54+
});
55+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { GetMessagePathsResponse } from '@blockscout/interchain-indexer-types';
2+
3+
import { chainA, chainB, chainC, chainD } from 'mocks/multichain/chains';
4+
5+
export const incomingMessagesPaths: GetMessagePathsResponse = {
6+
items: [
7+
{
8+
source_chain: chainB,
9+
destination_chain: chainA,
10+
messages_count: 7282,
11+
},
12+
{
13+
source_chain: chainC,
14+
destination_chain: chainA,
15+
messages_count: 0,
16+
},
17+
{
18+
source_chain: chainD,
19+
destination_chain: chainA,
20+
messages_count: 420,
21+
},
22+
],
23+
};

mocks/stats/lines.ts renamed to client/features/chain-stats/mocks/lines.ts

File renamed without changes.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react';
2+
3+
import { ChartResolution } from 'toolkit/components/charts/types';
4+
5+
import { CROSS_CHAIN_TXS_CHARTS } from 'client/features/cross-chain-txs/utils/chain-stats';
6+
import * as chainsMock from 'mocks/multichain/chains';
7+
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
8+
import { test, expect } from 'playwright/lib';
9+
10+
import * as crossChainTxsPathsMock from '../../mocks/cross-chain-txs-paths';
11+
import * as statsLineMock from '../../mocks/line';
12+
import ChainStatsDetails from './ChainStatsDetails';
13+
14+
test.beforeEach(async({ mockTextAd }) => {
15+
await mockTextAd();
16+
});
17+
18+
test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page }) => {
19+
20+
const CHART_ID = 'averageGasPrice';
21+
const hooksConfig = {
22+
router: {
23+
query: { id: CHART_ID, interval: 'all' },
24+
},
25+
};
26+
27+
const chartApiUrl = await mockApiResponse(
28+
'stats:line',
29+
statsLineMock.averageGasPrice,
30+
{
31+
pathParams: { id: CHART_ID },
32+
queryParams: {
33+
resolution: ChartResolution.DAY,
34+
},
35+
},
36+
);
37+
38+
const component = await render(<ChainStatsDetails/>, { hooksConfig });
39+
await page.waitForResponse(chartApiUrl);
40+
await page.waitForFunction(() => {
41+
return document.querySelector('path[data-name="chart-fullscreen"]')?.getAttribute('opacity') === '1';
42+
});
43+
await expect(component).toHaveScreenshot();
44+
});
45+
46+
test('cross-chain txs paths view +@dark-mode +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
47+
const CHART = CROSS_CHAIN_TXS_CHARTS[0];
48+
const hooksConfig = {
49+
router: {
50+
query: { id: CHART.id, interval: 'all' },
51+
},
52+
};
53+
54+
await mockEnvs([
55+
...ENVS_MAP.crossChainTxs,
56+
[ 'NEXT_PUBLIC_NETWORK_NAME', chainsMock.chainA.name ],
57+
[ 'NEXT_PUBLIC_NETWORK_ID', chainsMock.chainA.id ],
58+
]);
59+
await mockApiResponse('interchainIndexer:chains', { items: [ chainsMock.chainA, chainsMock.chainB, chainsMock.chainC, chainsMock.chainD ] });
60+
await mockApiResponse(
61+
CHART.resourceName!,
62+
crossChainTxsPathsMock.incomingMessagesPaths,
63+
{
64+
pathParams: { chainId: chainsMock.chainA.id },
65+
},
66+
);
67+
68+
const component = await render(<ChainStatsDetails/>, { hooksConfig });
69+
await expect(component).toHaveScreenshot();
70+
});

0 commit comments

Comments
 (0)