Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Tokens page and dashboard component #546

Merged
merged 4 commits into from
Jun 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/546.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add token overview page and dashboard component
48 changes: 48 additions & 0 deletions src/app/components/LongDataDisplay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Collapse from '@mui/material/Collapse'
import Link from '@mui/material/Link'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Typography from '@mui/material/Typography'

export const LongDataDisplay: FC<{ data: string; threshold: number; fontWeight?: number }> = ({
data,
threshold,
fontWeight = 700,
}) => {
const { t } = useTranslation()
const [showData, setShowData] = useState(false)
const needsHiding = data.length > threshold
if (!needsHiding) {
return (
<Typography
variant="mono"
sx={{
fontWeight,
overflowWrap: 'anywhere',
}}
>
{data}
</Typography>
)
}
return (
<div>
<Collapse orientation={'vertical'} in={showData} collapsedSize={'3em'}>
<Typography
variant="mono"
sx={{
fontWeight,
overflowWrap: 'anywhere',
}}
>
{data}
</Typography>
</Collapse>
{data.length > threshold && (
<Link sx={{ cursor: 'pointer' }} onClick={() => setShowData(!showData)}>
{showData ? t('common.hide') : t('common.show')}
</Link>
)}
</div>
)
}
60 changes: 60 additions & 0 deletions src/app/components/Tokens/TokenDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FC } from 'react'
import { EvmToken } from '../../../oasis-indexer/api'
import { TextSkeleton } from '../Skeleton'
import { StyledDescriptionList } from '../StyledDescriptionList'
import { useScreenSize } from '../../hooks/useScreensize'
import { useTranslation } from 'react-i18next'
import { TokenLink } from './TokenLink'
import { CopyToClipboard } from '../CopyToClipboard'
import { AccountLink } from '../Account/AccountLink'
import { DashboardLink } from '../../pages/DashboardPage/DashboardLink'
import { LongDataDisplay } from '../LongDataDisplay'

export const TokenDetails: FC<{
isLoading?: boolean
token: EvmToken | undefined
showLayer?: boolean
standalone?: boolean
}> = ({ isLoading, token, showLayer, standalone = false }) => {
const { t } = useTranslation()
const { isMobile } = useScreenSize()

if (isLoading) return <TextSkeleton numberOfRows={7} />
if (!token) return null

return (
<StyledDescriptionList titleWidth={isMobile ? '100px' : '200px'} standalone={standalone}>
{showLayer && (
<>
<dt>{t('common.paratime')}</dt>
<dd>
<DashboardLink scope={token} />
</dd>
</>
)}
<dt>{t('common.name')}</dt>
<dd>
<TokenLink scope={token} address={token.evm_contract_addr} name={token.name} />
</dd>

<dt>{t(isMobile ? 'common.smartContract_short' : 'common.smartContract')}</dt>
<dd>
<span>
<AccountLink scope={token} address={token.evm_contract_addr} />
<CopyToClipboard value={token.evm_contract_addr} />
</span>
</dd>

<dt>{t(isMobile ? 'tokens.holdersCount_short' : 'tokens.holdersCount')}</dt>
<dd>{token.num_holders.toLocaleString()}</dd>

<dt>{t('tokens.supply')}</dt>
<dd>
<LongDataDisplay data={token.total_supply || t('common.missing')} threshold={100} fontWeight={400} />
</dd>

<dt>{t('common.ticker')}</dt>
<dd>{token.symbol}</dd>
</StyledDescriptionList>
)
}
18 changes: 18 additions & 0 deletions src/app/components/Tokens/TokenLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { FC } from 'react'
import { Link as RouterLink } from 'react-router-dom'
import Link from '@mui/material/Link'

import { RouteUtils } from '../../utils/route-utils'
import { SearchScope } from '../../../types/searchScope'

export const TokenLink: FC<{ scope: SearchScope; address: string; name: string | undefined }> = ({
scope,
address,
name,
}) => {
return (
<Link component={RouterLink} to={RouteUtils.getTokenRoute(scope, address)}>
{name || address}
</Link>
)
}
85 changes: 85 additions & 0 deletions src/app/components/Tokens/TokenList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useTranslation } from 'react-i18next'
import { EvmToken } from '../../../oasis-indexer/api'
import { Table, TableCellAlign, TableColProps } from '../../components/Table'
import { TablePaginationProps } from '../Table/TablePagination'
import { AccountLink } from '../Account/AccountLink'
import { TokenLink } from './TokenLink'
import { CopyToClipboard } from '../CopyToClipboard'

type TokensProps = {
tokens?: EvmToken[]
isLoading: boolean
limit: number
pagination: false | TablePaginationProps
}

export const TokenList = (props: TokensProps) => {
const { isLoading, tokens, pagination, limit } = props
const { t } = useTranslation()
const tableColumns: TableColProps[] = [
{ content: '' },
{ content: t('common.name') },
{ content: t('common.smartContract') },
{
content: t('tokens.holdersCount'),
align: TableCellAlign.Right,
},
{ content: t('tokens.supply'), align: TableCellAlign.Right },
{ content: t('common.ticker'), align: TableCellAlign.Right },
]

const tableRows = tokens?.map((token, index) => {
return {
key: token.contract_addr,
data: [
{
content: (
(pagination ? (pagination.selectedPage - 1) * pagination.rowsPerPage : 0) +
index +
1
).toLocaleString(),
key: 'index',
},
{
content: <TokenLink scope={token} address={token.evm_contract_addr} name={token.name} />,
key: 'name',
},
{
content: (
<span>
<AccountLink scope={token} address={token.evm_contract_addr} />
<CopyToClipboard value={token.evm_contract_addr} />
</span>
),
key: 'contactAddress',
},
{
content: token.num_holders.toLocaleString(),
key: 'holdersCount',
align: TableCellAlign.Right,
},
{
content: token.total_supply,
key: 'supply',
align: TableCellAlign.Right,
},
{
content: token.symbol,
key: 'ticker',
align: TableCellAlign.Right,
},
],
}
})

return (
<Table
columns={tableColumns}
rows={tableRows}
rowsNumber={limit}
name={t('tokens.title')}
isLoading={isLoading}
pagination={pagination}
/>
)
}
55 changes: 55 additions & 0 deletions src/app/pages/DashboardPage/TopTokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import Card from '@mui/material/Card'
import CardHeader from '@mui/material/CardHeader'
import CardContent from '@mui/material/CardContent'
import { Link as RouterLink } from 'react-router-dom'
import Link from '@mui/material/Link'
import { Layer, useGetRuntimeEvmTokens } from '../../../oasis-indexer/api'
import { NUMBER_OF_ITEMS_ON_DASHBOARD } from '../../config'
import { COLORS } from '../../../styles/theme/colors'
import { AppErrors } from '../../../types/errors'
import { useRequiredScopeParam } from '../../hooks/useScopeParam'
import { RouteUtils } from '../../utils/route-utils'
import { TokenList } from '../../components/Tokens/TokenList'

const limit = NUMBER_OF_ITEMS_ON_DASHBOARD

export const TopTokens: FC = () => {
const { t } = useTranslation()
const scope = useRequiredScopeParam()
const { network, layer } = scope
if (layer === Layer.consensus) {
throw AppErrors.UnsupportedLayer
// Listing the latest consensus transactions is not yet supported.
// We should use useGetConsensusTransactions()
}
const tokensQuery = useGetRuntimeEvmTokens(network, layer, { limit })

return (
<Card>
<CardHeader
disableTypography
component="h3"
title={t('tokens.title')}
action={
<Link
component={RouterLink}
to={RouteUtils.getTopTokensRoute(scope)}
sx={{ color: COLORS.brandExtraDark }}
>
{t('common.viewAll')}
</Link>
}
/>
<CardContent>
<TokenList
tokens={tokensQuery.data?.data.evm_tokens}
isLoading={tokensQuery.isLoading}
limit={limit}
pagination={false}
/>
</CardContent>
</Card>
)
}
2 changes: 2 additions & 0 deletions src/app/pages/DashboardPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TransactionsStats } from './TransactionsStats'
import { TotalTransactions } from './TotalTransactions'
import { PageLayout } from '../../components/PageLayout'
import { ParaTimeSnapshot } from './ParaTimeSnapshot'
import { TopTokens } from './TopTokens'

export const DashboardPage: FC = () => {
const { isMobile } = useScreenSize()
Expand All @@ -27,6 +28,7 @@ export const DashboardPage: FC = () => {
<LatestBlocks />
</Grid>
</Grid>
<TopTokens />
<TransactionsStats />
<TotalTransactions />
<Social />
Expand Down
109 changes: 109 additions & 0 deletions src/app/pages/TokensPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@mui/material/Divider'
import { useScreenSize } from '../../hooks/useScreensize'
import { styled } from '@mui/material/styles'
import { PageLayout } from '../../components/PageLayout'
import { SubPageCard } from '../../components/SubPageCard'
import { Layer, useGetRuntimeEvmTokens } from '../../../oasis-indexer/api'
import { NUMBER_OF_ITEMS_ON_SEPARATE_PAGE, REFETCH_INTERVAL } from '../../config'
import { useSearchParamsPagination } from '../../components/Table/useSearchParamsPagination'
import Box from '@mui/material/Box'
import { COLORS } from '../../../styles/theme/colors'
import { AppErrors } from '../../../types/errors'
import { TableLayout, TableLayoutButton } from '../../components/TableLayoutButton'
import { LoadMoreButton } from '../../components/LoadMoreButton'
import { useRequiredScopeParam } from '../../hooks/useScopeParam'
import { TokenList } from '../../components/Tokens/TokenList'
import { TokenDetails } from '../../components/Tokens/TokenDetails'

const PAGE_SIZE = NUMBER_OF_ITEMS_ON_SEPARATE_PAGE

const TokenDetailsBox = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: `0 ${theme.spacing(2)}`,
backgroundColor: COLORS.brandDark,
}))

export const TokensPage: FC = () => {
const [tableView, setTableView] = useState<TableLayout>(TableLayout.Horizontal)
const { isMobile } = useScreenSize()
const { t } = useTranslation()
const pagination = useSearchParamsPagination('page')
const offset = (pagination.selectedPage - 1) * PAGE_SIZE
const scope = useRequiredScopeParam()
// Consensus is not yet enabled in ENABLED_LAYERS, just some preparation
if (scope.layer === Layer.consensus) {
throw AppErrors.UnsupportedLayer
// Listing the latest consensus blocks is not yet implemented.
// we should call useGetConsensusBlocks()
}

useEffect(() => {
if (!isMobile) {
setTableView(TableLayout.Horizontal)
}
}, [isMobile, setTableView])

const tokensQuery = useGetRuntimeEvmTokens(
scope.network,
scope.layer, // This is OK, since consensus is already handled separately
{
limit: tableView === TableLayout.Vertical ? offset + PAGE_SIZE : PAGE_SIZE,
offset: tableView === TableLayout.Vertical ? 0 : offset,
},
{
query: {
refetchInterval: REFETCH_INTERVAL,
// Keep showing data while loading more
keepPreviousData: tableView === TableLayout.Vertical,
},
},
)

return (
<PageLayout
mobileFooterAction={
tableView === TableLayout.Vertical && (
<LoadMoreButton pagination={pagination} isLoading={tokensQuery.isLoading} />
)
}
>
{!isMobile && <Divider variant="layout" />}
<SubPageCard
title={t('tokens.title')}
action={isMobile && <TableLayoutButton tableView={tableView} setTableView={setTableView} />}
noPadding={tableView === TableLayout.Vertical}
>
{tableView === TableLayout.Horizontal && (
<TokenList
isLoading={tokensQuery.isLoading}
tokens={tokensQuery.data?.data.evm_tokens}
limit={PAGE_SIZE}
pagination={{
selectedPage: pagination.selectedPage,
linkToPage: pagination.linkToPage,
totalCount: tokensQuery.data?.data.total_count,
isTotalCountClipped: tokensQuery.data?.data.is_total_count_clipped,
rowsPerPage: NUMBER_OF_ITEMS_ON_SEPARATE_PAGE,
}}
/>
)}
{tableView === TableLayout.Vertical && (
<TokenDetailsBox>
{tokensQuery.isLoading &&
[...Array(PAGE_SIZE).keys()].map(key => (
<TokenDetails key={key} isLoading={true} token={undefined} standalone />
))}

{!tokensQuery.isLoading &&
tokensQuery.data?.data.evm_tokens.map(token => (
<TokenDetails key={token.contract_addr} token={token} standalone />
))}
</TokenDetailsBox>
)}
</SubPageCard>
</PageLayout>
)
}
Loading