From ad56b45829488afe9e8d9e4b3992c65b80c95b9d Mon Sep 17 00:00:00 2001 From: isstuev Date: Fri, 6 Sep 2024 16:08:51 +0400 Subject: [PATCH] stats updates --- configs/envs/.env.eth_sepolia | 4 +- lib/metadata/templates/description.ts | 1 + lib/metadata/templates/title.ts | 2 + lib/metadata/types.ts | 2 + lib/mixpanel/utils.ts | 3 + nextjs/nextjs-routes.d.ts | 1 + nextjs/utils/fetchApi.ts | 4 +- package.json | 2 +- pages/stats/[id].tsx | 52 ++++ public/static/logo.svg | 3 + ui/pages/Chart.tsx | 236 ++++++++++++++ ui/shared/Page/PageTitle.tsx | 4 +- ui/shared/chart/ChartIntervalSelect.tsx | 43 +++ ui/shared/chart/ChartMenu.tsx | 169 ++++++++++ ui/shared/chart/ChartResolutionSelect.tsx | 28 ++ ui/shared/chart/ChartWidget.tsx | 325 ++++++-------------- ui/shared/chart/ChartWidgetContent.tsx | 93 ++++++ ui/shared/chart/FullscreenChartModal.tsx | 12 +- ui/shared/chart/useChartQuery.tsx | 61 ++++ ui/shared/chart/useZoomReset.tsx | 19 ++ ui/shared/chart/utils/formatIntervalDate.ts | 3 + ui/stats/ChartWidgetContainer.tsx | 54 ++-- ui/stats/ChartsWidgetsList.tsx | 1 + ui/stats/StatsDropdownMenu.tsx | 4 +- ui/stats/StatsFilters.tsx | 23 +- ui/stats/constants/index.ts | 27 +- yarn.lock | 8 +- 27 files changed, 888 insertions(+), 296 deletions(-) create mode 100644 pages/stats/[id].tsx create mode 100644 public/static/logo.svg create mode 100644 ui/pages/Chart.tsx create mode 100644 ui/shared/chart/ChartIntervalSelect.tsx create mode 100644 ui/shared/chart/ChartMenu.tsx create mode 100644 ui/shared/chart/ChartResolutionSelect.tsx create mode 100644 ui/shared/chart/ChartWidgetContent.tsx create mode 100644 ui/shared/chart/useChartQuery.tsx create mode 100644 ui/shared/chart/useZoomReset.tsx create mode 100644 ui/shared/chart/utils/formatIntervalDate.ts diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia index 1304b37c72..01698d12c4 100644 --- a/configs/envs/.env.eth_sepolia +++ b/configs/envs/.env.eth_sepolia @@ -14,7 +14,7 @@ NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP={ "id": "632019", "width": "728", "height NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE={ "id": "632018", "width": "320", "height": "100" } NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_API_BASE_PATH=/ -NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com +NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com @@ -59,7 +59,7 @@ NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-c NEXT_PUBLIC_OTHER_LINKS=[{'url':'https://sepolia.drpc.org?ref=559183','text':'Public RPC'}] NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-sepolia.safe.global NEXT_PUBLIC_SENTRY_ENABLE_TRACING=true -NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s.blockscout.com +NEXT_PUBLIC_STATS_API_HOST=https://stats-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com \ No newline at end of file diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index 77e483bcba..93097b4b19 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -26,6 +26,7 @@ const TEMPLATE_MAP: Record = { '/apps': DEFAULT_TEMPLATE, '/apps/[id]': DEFAULT_TEMPLATE, '/stats': DEFAULT_TEMPLATE, + '/stats/[id]': DEFAULT_TEMPLATE, '/api-docs': DEFAULT_TEMPLATE, '/graphiql': DEFAULT_TEMPLATE, '/search-results': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index ce937291c2..736ca6ee43 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -22,6 +22,7 @@ const TEMPLATE_MAP: Record = { '/apps': '%network_name% DApps - Explore top apps', '/apps/[id]': '%network_name% marketplace app', '/stats': '%network_name% stats - %network_name% network insights', + '/stats/[id]': '%network_name% stats - %id% chart', '/api-docs': '%network_name% API docs - %network_name% developer tools', '/graphiql': 'GraphQL for %network_name% - %network_name% data query', '/search-results': '%network_name% search result for %q%', @@ -71,6 +72,7 @@ const TEMPLATE_MAP_ENHANCED: Partial> = { '/token/[hash]/instance/[id]': '%network_name% token instance for %symbol%', '/apps/[id]': '%network_name% - %app_name%', '/address/[hash]': '%network_name% address details for %domain_name%', + '/stats/[id]': '%title% chart on %network_name%', }; export function make(pathname: Route['pathname'], isEnriched = false) { diff --git a/lib/metadata/types.ts b/lib/metadata/types.ts index fda74301ba..ddb29c852a 100644 --- a/lib/metadata/types.ts +++ b/lib/metadata/types.ts @@ -1,3 +1,4 @@ +import type { LineChart } from '@blockscout/stats-types'; import type { TokenInfo } from 'types/api/token'; import type { Route } from 'nextjs-routes'; @@ -9,6 +10,7 @@ export type ApiData = Pathname extends '/token/[hash]' ? TokenInfo : Pathname extends '/token/[hash]/instance/[id]' ? { symbol: string } : Pathname extends '/apps/[id]' ? { app_name: string } : + Pathname extends '/stats/[id]' ? LineChart['info'] : never ) | null; diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index 4d59ec007e..f29756f1b3 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -116,6 +116,9 @@ Type extends EventTypes.PAGE_WIDGET ? ( 'Type': 'Address tag'; 'Info': string; 'URL': string; + } | { + 'Type': 'Share chart'; + 'Info': string; } ) : Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index a94fb52dbe..fb04b4d796 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -54,6 +54,7 @@ declare module "nextjs-routes" { | StaticRoute<"/public-tags/submit"> | StaticRoute<"/search-results"> | StaticRoute<"/sprite"> + | DynamicRoute<"/stats/[id]", { "id": string }> | StaticRoute<"/stats"> | DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]/instance/[id]", { "hash": string; "id": string }> diff --git a/nextjs/utils/fetchApi.ts b/nextjs/utils/fetchApi.ts index 63eff42384..5597cf8ede 100644 --- a/nextjs/utils/fetchApi.ts +++ b/nextjs/utils/fetchApi.ts @@ -12,6 +12,7 @@ type Params = ( { resource: R; pathParams?: ResourcePathParams; + queryParams?: Record; } | { url: string; route: string; @@ -22,12 +23,11 @@ type Params = ( export default async function fetchApi>(params: Params): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => { controller.abort(); }, params.timeout || SECOND); - const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams); + const url = 'url' in params ? params.url : buildUrl(params.resource, params.pathParams, params.queryParams); const route = 'route' in params ? params.route : RESOURCES[params.resource]['path']; const end = metrics?.apiRequestDuration.startTimer(); diff --git a/package.json b/package.json index dce8636f60..458100baab 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@blockscout/bens-types": "1.4.1", - "@blockscout/stats-types": "1.6.0", + "@blockscout/stats-types": "^1.6.2-alpha", "@blockscout/visualizer-types": "0.2.0", "@chakra-ui/react": "2.7.1", "@chakra-ui/theme-tools": "^2.0.18", diff --git a/pages/stats/[id].tsx b/pages/stats/[id].tsx new file mode 100644 index 0000000000..df1e7d8dc5 --- /dev/null +++ b/pages/stats/[id].tsx @@ -0,0 +1,52 @@ +import type { GetServerSideProps, NextPage } from 'next'; +import dynamic from 'next/dynamic'; +import React from 'react'; + +import type { Route } from 'nextjs-routes'; +import * as gSSP from 'nextjs/getServerSideProps'; +import type { Props } from 'nextjs/getServerSideProps'; +import PageNextJs from 'nextjs/PageNextJs'; +import detectBotRequest from 'nextjs/utils/detectBotRequest'; +import fetchApi from 'nextjs/utils/fetchApi'; + +import config from 'configs/app'; +import dayjs from 'lib/date/dayjs'; +import getQueryParamString from 'lib/router/getQueryParamString'; + +const Chart = dynamic(() => import('ui/pages/Chart'), { ssr: false }); + +const pathname: Route['pathname'] = '/stats/[id]'; + +const Page: NextPage> = (props: Props) => { + return ( + + + + ); +}; + +export default Page; + +export const getServerSideProps: GetServerSideProps> = async(ctx) => { + const baseResponse = await gSSP.base(ctx); + + if ('props' in baseResponse) { + if ( + config.meta.seo.enhancedDataEnabled || + (config.meta.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview') + ) { + const chartData = await fetchApi({ + resource: 'stats_line', + pathParams: { id: getQueryParamString(ctx.query.id) }, + queryParams: { from: dayjs().format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') }, + timeout: 1000, + }); + + (await baseResponse.props).apiData = chartData?.info ?? null; + } + } + + return baseResponse; +}; + +// export { base as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/public/static/logo.svg b/public/static/logo.svg new file mode 100644 index 0000000000..4545aeb9c1 --- /dev/null +++ b/public/static/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/pages/Chart.tsx b/ui/pages/Chart.tsx new file mode 100644 index 0000000000..c162817ad8 --- /dev/null +++ b/ui/pages/Chart.tsx @@ -0,0 +1,236 @@ +import { Button, Flex, IconButton, Text } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import { Resolution } from '@blockscout/stats-types'; +import type { StatsIntervalIds } from 'types/client/stats'; + +import config from 'configs/app'; +import { useAppContext } from 'lib/contexts/app'; +import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; +import useIsMobile from 'lib/hooks/useIsMobile'; +import isBrowser from 'lib/isBrowser'; +import * as metadata from 'lib/metadata'; +import * as mixpanel from 'lib/mixpanel/index'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import isCustomAppError from 'ui/shared/AppError/isCustomAppError'; +import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect'; +import ChartMenu from 'ui/shared/chart/ChartMenu'; +import ChartResolutionSelect from 'ui/shared/chart/ChartResolutionSelect'; +import ChartWidgetContent from 'ui/shared/chart/ChartWidgetContent'; +import useChartQuery from 'ui/shared/chart/useChartQuery'; +import useZoomReset from 'ui/shared/chart/useZoomReset'; +import CopyToClipboard from 'ui/shared/CopyToClipboard'; +import IconSvg from 'ui/shared/IconSvg'; +import PageTitle from 'ui/shared/Page/PageTitle'; + +const DEFAULT_RESOLUTION = Resolution.DAY; + +const getIntervalByResolution = (resolution: Resolution): StatsIntervalIds => { + switch (resolution) { + case 'DAY': + return 'oneMonth'; + case 'WEEK': + return 'oneMonth'; + case 'MONTH': + return 'oneYear'; + case 'YEAR': + return 'all'; + default: + return 'oneMonth'; + } +}; + +const Chart = () => { + const router = useRouter(); + const id = getQueryParamString(router.query.id); + const [ intervalState, setIntervalState ] = React.useState(); + const [ resolution, setResolution ] = React.useState(DEFAULT_RESOLUTION); + const { isZoomResetInitial, handleZoom, handleZoomReset } = useZoomReset(); + + const interval = intervalState || getIntervalByResolution(resolution); + + const ref = React.useRef(null); + + const isMobile = useIsMobile(); + const isInBrowser = isBrowser(); + + const appProps = useAppContext(); + const backLink = React.useMemo(() => { + const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/stats'); + + if (!hasGoBackLink) { + return; + } + + return { + label: 'Back to charts list', + url: appProps.referrer, + }; + }, [ appProps.referrer ]); + + const handleReset = React.useCallback(() => { + handleZoomReset(); + setResolution(DEFAULT_RESOLUTION); + }, [ handleZoomReset ]); + + const { items, info, lineQuery } = useChartQuery(id, resolution, interval); + + React.useEffect(() => { + if (info && !config.meta.seo.enhancedDataEnabled) { + metadata.update({ pathname: '/stats/[id]', query: { id } }, info); + } + }, [ info, id ]); + + const onShare = React.useCallback(async() => { + mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Share chart', Info: id }); + try { + await window.navigator.share({ + title: info?.title, + text: info?.description, + url: window.location.href, + }); + } catch (error) {} + }, [ info, id ]); + + if (lineQuery.isError) { + if (isCustomAppError(lineQuery.error)) { + throwOnResourceLoadError({ resource: 'stats_line', error: lineQuery.error, isError: true }); + } + } + + const hasItems = (items && items.length > 2) || lineQuery.isPending; + + const isInfoLoading = !info && lineQuery.isPlaceholderData; + + const shareButton = isMobile ? ( + } + onClick={ onShare } + /> + ) : ( + + ); + + const shareAndMenu = ( + + { /* TS thinks window.navigator.share can't be undefined, but it can */ } + { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ } + { (isInBrowser && ((window.navigator.share as any) ? + shareButton : + ( + + ) + )) } + { (hasItems || lineQuery.isPlaceholderData) && ( + + ) } + + ); + + return ( + <> + + + + Period + + { isMobile ? 'Res.' : 'Resolution' } + + { (!isZoomResetInitial || resolution !== 'DAY') && ( + isMobile ? ( + } + onClick={ handleReset } + /> + ) : ( + + ) + ) } + + { !isMobile && shareAndMenu } + + + + + + ); +}; + +export default Chart; diff --git a/ui/shared/Page/PageTitle.tsx b/ui/shared/Page/PageTitle.tsx index e406b098eb..844f301498 100644 --- a/ui/shared/Page/PageTitle.tsx +++ b/ui/shared/Page/PageTitle.tsx @@ -154,9 +154,9 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa { withTextAd && } { secondRow && ( - + { secondRow } - + ) } ); diff --git a/ui/shared/chart/ChartIntervalSelect.tsx b/ui/shared/chart/ChartIntervalSelect.tsx new file mode 100644 index 0000000000..041cc06fc6 --- /dev/null +++ b/ui/shared/chart/ChartIntervalSelect.tsx @@ -0,0 +1,43 @@ +import { Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { StatsInterval, StatsIntervalIds } from 'types/client/stats'; + +import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect'; +import { STATS_INTERVALS } from 'ui/stats/constants'; +import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu'; + +const intervalList = Object.keys(STATS_INTERVALS).map((id: string) => ({ + id: id, + title: STATS_INTERVALS[id as StatsIntervalIds].title, +})) as Array; + +const intervalListShort = Object.keys(STATS_INTERVALS).map((id: string) => ({ + id: id, + title: STATS_INTERVALS[id as StatsIntervalIds].shortTitle, +})) as Array; + +type Props = { + interval: StatsIntervalIds; + onIntervalChange: (newInterval: StatsIntervalIds) => void; + isLoading?: boolean; +} + +const ChartIntervalSelect = ({ interval, onIntervalChange, isLoading }: Props) => { + return ( + <> + + items={ intervalListShort } onChange={ onIntervalChange } value={ interval }/> + + + + + + ); +}; + +export default React.memo(ChartIntervalSelect); diff --git a/ui/shared/chart/ChartMenu.tsx b/ui/shared/chart/ChartMenu.tsx new file mode 100644 index 0000000000..eaa5d73e14 --- /dev/null +++ b/ui/shared/chart/ChartMenu.tsx @@ -0,0 +1,169 @@ +import { + IconButton, + MenuButton, + MenuItem, + MenuList, + Skeleton, + useColorModeValue, + VisuallyHidden, +} from '@chakra-ui/react'; +import domToImage from 'dom-to-image'; +import React from 'react'; + +import type { TimeChartItem } from './types'; + +import type { Route } from 'nextjs-routes'; +import { route } from 'nextjs-routes'; + +import config from 'configs/app'; +import dayjs from 'lib/date/dayjs'; +import saveAsCSV from 'lib/saveAsCSV'; +import Menu from 'ui/shared/chakra/Menu'; +import IconSvg from 'ui/shared/IconSvg'; + +import FullscreenChartModal from './FullscreenChartModal'; + +export type Props = { + items?: Array; + title: string; + description?: string; + units?: string; + isLoading: boolean; + chartRef: React.RefObject; + href?: Route; +} + +const DOWNLOAD_IMAGE_SCALE = 5; + +const ChartMenu = ({ items, title, description, units, isLoading, chartRef, href }: Props) => { + const pngBackgroundColor = useColorModeValue('white', 'black'); + const [ isFullscreen, setIsFullscreen ] = React.useState(false); + + const showChartFullscreen = React.useCallback(() => { + setIsFullscreen(true); + }, []); + + const clearFullscreenChart = React.useCallback(() => { + setIsFullscreen(false); + }, []); + + const handleFileSaveClick = React.useCallback(() => { + // wait for context menu to close + setTimeout(() => { + if (chartRef.current) { + domToImage.toPng(chartRef.current, + { + quality: 100, + bgcolor: pngBackgroundColor, + width: chartRef.current.offsetWidth * DOWNLOAD_IMAGE_SCALE, + height: chartRef.current.offsetHeight * DOWNLOAD_IMAGE_SCALE, + filter: (node) => node.nodeName !== 'BUTTON', + style: { + borderColor: 'transparent', + transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`, + 'transform-origin': 'top left', + }, + }) + .then((dataUrl) => { + const link = document.createElement('a'); + link.download = `${ title } (Blockscout chart).png`; + link.href = dataUrl; + link.click(); + link.remove(); + }); + } + }, 100); + }, [ pngBackgroundColor, title, chartRef ]); + + const handleSVGSavingClick = React.useCallback(() => { + if (items) { + const headerRows = [ + 'Date', 'Value', + ]; + const dataRows = items.map((item) => [ + dayjs(item.date).format('YYYY-MM-DD'), String(item.value), + ]); + + saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`); + } + }, [ items, title ]); + + const handleShare = React.useCallback(async() => { + try { + await window.navigator.share({ + title: title, + text: description, + url: href ? config.app.baseUrl + route(href) : '', + }); + } catch (error) {} + }, [ title, description, href ]); + + return ( + <> + + + } + colorScheme="gray" + variant="ghost" + as={ IconButton } + > + + Open chart options menu + + + + + { href && ( + + + Share + + ) } + + + View fullscreen + + + + Save as PNG + + + + Save as CSV + + + + { items && ( + + ) } + + ); +}; + +export default ChartMenu; diff --git a/ui/shared/chart/ChartResolutionSelect.tsx b/ui/shared/chart/ChartResolutionSelect.tsx new file mode 100644 index 0000000000..999ecf0136 --- /dev/null +++ b/ui/shared/chart/ChartResolutionSelect.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { Resolution } from '@blockscout/stats-types'; + +import { STATS_RESOLUTIONS } from 'ui/stats/constants'; +import StatsDropdownMenu from 'ui/stats/StatsDropdownMenu'; + +type Props = { + resolution: Resolution; + resolutions: Array; + onResolutionChange: (resolution: Resolution) => void; + isLoading?: boolean; +} + +const ChartResolutionSelect = ({ resolution, resolutions, onResolutionChange, isLoading }: Props) => { + return ( + + resolutions.includes(r.id)) } + selectedId={ resolution } + onSelect={ onResolutionChange } + /> + + ); +}; + +export default React.memo(ChartResolutionSelect); diff --git a/ui/shared/chart/ChartWidget.tsx b/ui/shared/chart/ChartWidget.tsx index e32a1e29ae..28f1829812 100644 --- a/ui/shared/chart/ChartWidget.tsx +++ b/ui/shared/chart/ChartWidget.tsx @@ -1,31 +1,23 @@ import { - Box, - Center, chakra, Flex, - IconButton, Link, - MenuButton, - MenuItem, - MenuList, + IconButton, Skeleton, - Text, Tooltip, useColorModeValue, - VisuallyHidden, } from '@chakra-ui/react'; -import domToImage from 'dom-to-image'; -import React, { useRef, useCallback, useState } from 'react'; +import NextLink from 'next/link'; +import React, { useRef } from 'react'; import type { TimeChartItem } from './types'; -import dayjs from 'lib/date/dayjs'; -import { apos } from 'lib/html-entities'; -import saveAsCSV from 'lib/saveAsCSV'; -import Menu from 'ui/shared/chakra/Menu'; +import type { Route } from 'nextjs-routes'; + import IconSvg from 'ui/shared/IconSvg'; -import ChartWidgetGraph from './ChartWidgetGraph'; -import FullscreenChartModal from './FullscreenChartModal'; +import ChartMenu from './ChartMenu'; +import ChartWidgetContent from './ChartWidgetContent'; +import useZoomReset from './useZoomReset'; export type Props = { items?: Array; @@ -37,236 +29,113 @@ export type Props = { isError: boolean; emptyText?: string; noAnimation?: boolean; + href?: Route; } -const DOWNLOAD_IMAGE_SCALE = 5; - -const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText, noAnimation }: Props) => { +const ChartWidget = ({ + items, + title, + description, + isLoading, + className, + isError, + units, + emptyText, + noAnimation, + href, +}: Props) => { const ref = useRef(null); - const [ isFullscreen, setIsFullscreen ] = useState(false); - const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); + const { isZoomResetInitial, handleZoom, handleZoomReset } = useZoomReset(); - const pngBackgroundColor = useColorModeValue('white', 'black'); const borderColor = useColorModeValue('gray.200', 'gray.600'); - const handleZoom = useCallback(() => { - setIsZoomResetInitial(false); - }, []); - - const handleZoomResetClick = useCallback(() => { - setIsZoomResetInitial(true); - }, []); - - const showChartFullscreen = useCallback(() => { - setIsFullscreen(true); - }, []); - - const clearFullscreenChart = useCallback(() => { - setIsFullscreen(false); - }, []); - - const handleFileSaveClick = useCallback(() => { - // wait for context menu to close - setTimeout(() => { - if (ref.current) { - domToImage.toPng(ref.current, - { - quality: 100, - bgcolor: pngBackgroundColor, - width: ref.current.offsetWidth * DOWNLOAD_IMAGE_SCALE, - height: ref.current.offsetHeight * DOWNLOAD_IMAGE_SCALE, - filter: (node) => node.nodeName !== 'BUTTON', - style: { - borderColor: 'transparent', - transform: `scale(${ DOWNLOAD_IMAGE_SCALE })`, - 'transform-origin': 'top left', - }, - }) - .then((dataUrl) => { - const link = document.createElement('a'); - link.download = `${ title } (Blockscout chart).png`; - link.href = dataUrl; - link.click(); - link.remove(); - }); - } - }, 100); - }, [ pngBackgroundColor, title ]); - - const handleSVGSavingClick = useCallback(() => { - if (items) { - const headerRows = [ - 'Date', 'Value', - ]; - const dataRows = items.map((item) => [ - dayjs(item.date).format('YYYY-MM-DD'), String(item.value), - ]); - - saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`); - } - }, [ items, title ]); - const hasItems = items && items.length > 2; - const content = (() => { - if (isError) { - return ( - - - { `The data didn${ apos }t load. Please, ` } - try to reload the page. - - - ); - } - - if (isLoading) { - return ; - } - - if (!hasItems) { - return ( -
- { emptyText || 'No data' } -
- ); - } - - return ( - - - - ); - })(); + const content = ( + + ); return ( - <> - - - + + + + + { href ? ( + + { title } + + ) : title + } + + + { description && ( - { title } + { description } - - { description && ( - - { description } - - ) } - - - - - - - { hasItems && ( - - - } - colorScheme="gray" - variant="ghost" - as={ IconButton } - > - - Open chart options menu - - - - - - - View fullscreen - - - - - Save as PNG - - - - - Save as CSV - - - - ) } - + ) } - { content } + + + + + { hasItems && ( + + ) } + - { hasItems && ( - - ) } - + { content } + ); }; diff --git a/ui/shared/chart/ChartWidgetContent.tsx b/ui/shared/chart/ChartWidgetContent.tsx new file mode 100644 index 0000000000..ac881fce11 --- /dev/null +++ b/ui/shared/chart/ChartWidgetContent.tsx @@ -0,0 +1,93 @@ +import { Box, Center, Flex, Link, Skeleton, Text, Image } from '@chakra-ui/react'; +import React from 'react'; + +import type { TimeChartItem } from './types'; + +import { apos } from 'lib/html-entities'; + +import ChartWidgetGraph from './ChartWidgetGraph'; + +export type Props = { + items?: Array; + title: string; + units?: string; + isLoading?: boolean; + isError?: boolean; + emptyText?: string; + handleZoom: () => void; + isZoomResetInitial: boolean; + isEnlarged?: boolean; + noAnimation?: boolean; +} + +const ChartWidgetContent = ({ + items, + title, + isLoading, + isError, + units, + emptyText, + handleZoom, + isZoomResetInitial, + isEnlarged, + noAnimation, +}: Props) => { + const hasItems = items && items.length > 2; + + if (isError) { + return ( + + + { `The data didn${ apos }t load. Please, ` } + try to reload the page. + + + ); + } + + if (isLoading) { + return ; + } + + if (!hasItems) { + return ( +
+ { emptyText || 'No data' } +
+ ); + } + + return ( + + + + + ); +}; + +export default React.memo(ChartWidgetContent); diff --git a/ui/shared/chart/FullscreenChartModal.tsx b/ui/shared/chart/FullscreenChartModal.tsx index 6835474851..da9d7d01e0 100644 --- a/ui/shared/chart/FullscreenChartModal.tsx +++ b/ui/shared/chart/FullscreenChartModal.tsx @@ -5,7 +5,7 @@ import type { TimeChartItem } from './types'; import IconSvg from 'ui/shared/IconSvg'; -import ChartWidgetGraph from './ChartWidgetGraph'; +import ChartWidgetContent from './ChartWidgetContent'; type Props = { isOpen: boolean; @@ -30,7 +30,7 @@ const FullscreenChartModal = ({ setIsZoomResetInitial(false); }, []); - const handleZoomResetClick = useCallback(() => { + const handleZoomReset = useCallback(() => { setIsZoomResetInitial(true); }, []); @@ -79,7 +79,7 @@ const FullscreenChartModal = ({ gridRow="1/3" size="sm" variant="outline" - onClick={ handleZoomResetClick } + onClick={ handleZoomReset } > Reset zoom @@ -91,13 +91,13 @@ const FullscreenChartModal = ({ - diff --git a/ui/shared/chart/useChartQuery.tsx b/ui/shared/chart/useChartQuery.tsx new file mode 100644 index 0000000000..6f9819de30 --- /dev/null +++ b/ui/shared/chart/useChartQuery.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import type { LineChart, Resolution } from '@blockscout/stats-types'; +import type { StatsIntervalIds } from 'types/client/stats'; + +import useApiQuery from 'lib/api/useApiQuery'; +import { useAppContext } from 'lib/contexts/app'; +import { STATS_INTERVALS } from 'ui/stats/constants'; + +import formatDate from './utils/formatIntervalDate'; + +export default function useChartQuery(id: string, resolution: Resolution, interval: StatsIntervalIds, enabled = true) { + const { apiData } = useAppContext<'/stats/[id]'>(); + + const selectedInterval = STATS_INTERVALS[interval]; + + const endDate = selectedInterval.start ? formatDate(new Date()) : undefined; + const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined; + + const [ info, setInfo ] = React.useState(apiData || undefined); + + const lineQuery = useApiQuery('stats_line', { + pathParams: { id }, + queryParams: { + from: startDate, + to: endDate, + resolution, + }, + queryOptions: { + enabled: enabled, + refetchOnMount: false, + placeholderData: { + info: { + title: 'Chart title placeholder', + description: 'Chart placeholder description chart placeholder description', + resolutions: [ 'DAY', 'WEEK', 'MONTH', 'YEAR' ], + id: 'placeholder', + units: undefined, + }, + chart: [], + }, + }, + }); + + React.useEffect(() => { + if (!info && lineQuery.data?.info && !lineQuery.isPlaceholderData) { + // save info to keep title and description when change query params + setInfo(lineQuery.data?.info); + } + }, [ info, lineQuery.data?.info, lineQuery.isPlaceholderData ]); + + const items = React.useMemo(() => lineQuery.data?.chart?.map((item) => { + return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate }; + }), [ lineQuery ]); + + return { + items, + info, + lineQuery, + }; +} diff --git a/ui/shared/chart/useZoomReset.tsx b/ui/shared/chart/useZoomReset.tsx new file mode 100644 index 0000000000..02db2f7a07 --- /dev/null +++ b/ui/shared/chart/useZoomReset.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export default function useZoomReset() { + const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); + + const handleZoom = React.useCallback(() => { + setIsZoomResetInitial(false); + }, []); + + const handleZoomReset = React.useCallback(() => { + setIsZoomResetInitial(true); + }, []); + + return { + isZoomResetInitial, + handleZoom, + handleZoomReset, + }; +} diff --git a/ui/shared/chart/utils/formatIntervalDate.ts b/ui/shared/chart/utils/formatIntervalDate.ts new file mode 100644 index 0000000000..701fa9a829 --- /dev/null +++ b/ui/shared/chart/utils/formatIntervalDate.ts @@ -0,0 +1,3 @@ +export default function formatDate(date: Date) { + return date.toISOString().substring(0, 10); +} diff --git a/ui/stats/ChartWidgetContainer.tsx b/ui/stats/ChartWidgetContainer.tsx index 5f41a5c2af..7dd953773b 100644 --- a/ui/stats/ChartWidgetContainer.tsx +++ b/ui/stats/ChartWidgetContainer.tsx @@ -1,12 +1,13 @@ import { chakra } from '@chakra-ui/react'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import type { StatsIntervalIds } from 'types/client/stats'; -import useApiQuery from 'lib/api/useApiQuery'; +import type { Route } from 'nextjs-routes'; + +import useChartQuery from 'ui/shared/chart/useChartQuery'; import ChartWidget from '../shared/chart/ChartWidget'; -import { STATS_INTERVALS } from './constants'; type Props = { id: string; @@ -17,50 +18,39 @@ type Props = { onLoadingError: () => void; isPlaceholderData: boolean; className?: string; + href?: Route; } -function formatDate(date: Date) { - return date.toISOString().substring(0, 10); -} - -const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError, units, isPlaceholderData, className }: Props) => { - const selectedInterval = STATS_INTERVALS[interval]; - - const endDate = selectedInterval.start ? formatDate(new Date()) : undefined; - const startDate = selectedInterval.start ? formatDate(selectedInterval.start) : undefined; - - const { data, isPending, isError } = useApiQuery('stats_line', { - pathParams: { id }, - queryParams: { - from: startDate, - to: endDate, - }, - queryOptions: { - enabled: !isPlaceholderData, - refetchOnMount: false, - }, - }); - - const items = useMemo(() => data?.chart?.map((item) => { - return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate }; - }), [ data ]); +const ChartWidgetContainer = ({ + id, + title, + description, + interval, + onLoadingError, + units, + isPlaceholderData, + className, + href, +}: Props) => { + const { items, lineQuery } = useChartQuery(id, 'DAY', interval, !isPlaceholderData); useEffect(() => { - if (isError) { + if (lineQuery.isError) { onLoadingError(); } - }, [ isError, onLoadingError ]); + }, [ lineQuery.isError, onLoadingError ]); return ( ); }; diff --git a/ui/stats/ChartsWidgetsList.tsx b/ui/stats/ChartsWidgetsList.tsx index 3d40f550ba..f1001774d1 100644 --- a/ui/stats/ChartsWidgetsList.tsx +++ b/ui/stats/ChartsWidgetsList.tsx @@ -95,6 +95,7 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in units={ chart.units || undefined } isPlaceholderData={ isPlaceholderData } onLoadingError={ handleChartLoadingError } + href={{ pathname: '/stats/[id]', query: { id: chart.id } }} /> )) } diff --git a/ui/stats/StatsDropdownMenu.tsx b/ui/stats/StatsDropdownMenu.tsx index 203e3c79f0..89c93c2ef2 100644 --- a/ui/stats/StatsDropdownMenu.tsx +++ b/ui/stats/StatsDropdownMenu.tsx @@ -5,7 +5,7 @@ import Menu from 'ui/shared/chakra/Menu'; import IconSvg from 'ui/shared/IconSvg'; type Props = { - items: Array<{id: T; title: string}>; + items: ReadonlyArray<{id: T; title: string}>; selectedId: T; onSelect: (id: T) => void; } @@ -23,7 +23,7 @@ export function StatsDropdownMenu({ items, selectedId, onSelec > ({ - id: id, - title: STATS_INTERVALS[id as StatsIntervalIds].title, -})) as Array; - type Props = { sections?: Array; currentSection: string; @@ -37,7 +32,7 @@ const StatsFilters = ({ }: Props) => { const sectionsList = [ { id: 'all', - title: 'All', + title: 'All stats', }, ... (sections || []) ]; return ( @@ -49,12 +44,13 @@ const StatsFilters = ({ lg: `"section interval input"`, }} gridTemplateColumns={{ base: 'repeat(2, minmax(0, 1fr))', lg: 'auto auto 1fr' }} + alignItems="center" > - { isLoading ? : ( + { isLoading ? : ( - { isLoading ? : ( - - ) } + diff --git a/ui/stats/constants/index.ts b/ui/stats/constants/index.ts index bf77117c4c..23d52006c6 100644 --- a/ui/stats/constants/index.ts +++ b/ui/stats/constants/index.ts @@ -1,23 +1,48 @@ +import { Resolution } from '@blockscout/stats-types'; import type { StatsIntervalIds } from 'types/client/stats'; -export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; start?: Date } } = { +export const STATS_RESOLUTIONS: Array<{id: Resolution; title: string }> = [ + { + id: Resolution.DAY, + title: 'Day', + }, + { + id: Resolution.WEEK, + title: 'Week', + }, + { + id: Resolution.MONTH, + title: 'Month', + }, + { + id: Resolution.YEAR, + title: 'Year', + }, +]; + +export const STATS_INTERVALS: { [key in StatsIntervalIds]: { title: string; shortTitle: string; start?: Date } } = { all: { title: 'All time', + shortTitle: 'All time', }, oneMonth: { title: '1 month', + shortTitle: '1M', start: getStartDateInPast(1), }, threeMonths: { title: '3 months', + shortTitle: '3M', start: getStartDateInPast(3), }, sixMonths: { title: '6 months', + shortTitle: '6M', start: getStartDateInPast(6), }, oneYear: { title: '1 year', + shortTitle: '1Y', start: getStartDateInPast(12), }, }; diff --git a/yarn.lock b/yarn.lock index abd094c24a..ea08d83d6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1332,10 +1332,10 @@ resolved "https://registry.yarnpkg.com/@blockscout/bens-types/-/bens-types-1.4.1.tgz#9182a79d9015b7fa2339edf0bfa3cd0c32045e66" integrity sha512-TlZ1HVdZ2Cswm/CcvNoxS+Ydiht/YGaLo//PJR/UmkmihlEFoY4HfVJvVcUnOQXi+Si7FwJ486DPii889nTJsQ== -"@blockscout/stats-types@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-1.6.0.tgz#cdb27ab3d3cb1eef7b8b069c39d4e09afda1aec9" - integrity sha512-MzItYOsLa3zgoFzRgFAgg7gynSXG0w/GqHzg5BGHcBPbPSp/g7A6mMtyIchI6TnZxxnCwziHHvzmJFXz11emUg== +"@blockscout/stats-types@^1.6.2-alpha": + version "1.6.2-alpha" + resolved "https://registry.yarnpkg.com/@blockscout/stats-types/-/stats-types-1.6.2-alpha.tgz#e0ec8d12921255943a3b7fc860e1b97e73171a69" + integrity sha512-3rFDgCt0sP2pbPcZ6s3m/zdZxH6hs8PlEchDyqYvKIqVBiBmRwFnXWY22W/Y71r5DJkCjWYbLzxij0WXQxwlnA== "@blockscout/visualizer-types@0.2.0": version "0.2.0"