feat: [info] add PDP loading skeleton (#7494)

* initial skeleton setup

* responsive table skeleton

* correct table widht

* right side column added

* add comments

* move loading components to their corresponding component

* remove extra bubble and adjust some styles

* move table skeleton to its own file

* add shared styles and skele file

* add loading skeleton tests

* design style nits

* update tests

* bips_base

* fix regression
This commit is contained in:
Charles Bachmeier 2023-10-20 13:11:22 -07:00 committed by GitHub
parent 8734ee5986
commit 6798bf3cf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1720 additions and 321 deletions

@ -13,6 +13,13 @@ describe('PoolDetailsHeader', () => {
toggleReversed: jest.fn(),
}
it('loading skeleton is shown', () => {
const { asFragment } = render(<PoolDetailsHeader {...mockProps} loading={true} />)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByTestId('pdp-header-loading-skeleton')).toBeInTheDocument()
})
it('renders header text correctly', () => {
const { asFragment } = render(<PoolDetailsHeader {...mockProps} />)
expect(asFragment()).toMatchSnapshot()

@ -4,6 +4,7 @@ import blankTokenUrl from 'assets/svg/blank_token.svg'
import Column from 'components/Column'
import { ChainLogo } from 'components/Logo/ChainLogo'
import Row from 'components/Row'
import { LoadingBubble } from 'components/Tokens/loading'
import { BIPS_BASE } from 'constants/misc'
import { chainIdToBackendName } from 'graphql/data/util'
import { useCurrency } from 'hooks/Tokens'
@ -15,6 +16,7 @@ import { ClickableStyle, ThemedText } from 'theme/components'
import { shortenAddress } from 'utils'
import { ReversedArrowsIcon } from './icons'
import { DetailBubble } from './shared'
const HeaderColumn = styled(Column)`
gap: 36px;
@ -35,6 +37,12 @@ const ToggleReverseArrows = styled(ReversedArrowsIcon)`
${ClickableStyle}
`
const IconBubble = styled(LoadingBubble)`
width: 32px;
height: 32px;
border-radius: 50%;
`
interface Token {
id: string
symbol: string
@ -47,6 +55,7 @@ interface PoolDetailsHeaderProps {
token1?: Token
feeTier?: number
toggleReversed: React.DispatchWithoutAction
loading?: boolean
}
export function PoolDetailsHeader({
@ -56,10 +65,25 @@ export function PoolDetailsHeader({
token1,
feeTier,
toggleReversed,
loading,
}: PoolDetailsHeaderProps) {
const currencies = [useCurrency(token0?.id, chainId) ?? undefined, useCurrency(token1?.id, chainId) ?? undefined]
const chainName = chainIdToBackendName(chainId)
const origin = `/tokens/${chainName}`
if (loading)
return (
<HeaderColumn data-testid="pdp-header-loading-skeleton">
<DetailBubble $width={300} />
<Column gap="sm">
<Row gap="8px">
<IconBubble />
<DetailBubble $width={137} />
</Row>
</Column>
</HeaderColumn>
)
return (
<HeaderColumn>
<Row>

@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Row from 'components/Row'
import { LoadingBubble } from 'components/Tokens/loading'
import { DeltaArrow } from 'components/Tokens/TokenDetails/Delta'
import { PoolData } from 'graphql/thegraph/PoolData'
import { useCurrency } from 'hooks/Tokens'
@ -15,6 +16,8 @@ import { colors } from 'theme/colors'
import { ThemedText } from 'theme/components'
import { NumberType, useFormatter } from 'utils/formatNumbers'
import { DetailBubble } from './shared'
const HeaderText = styled(Text)`
font-weight: 485;
font-size: 24px;
@ -90,13 +93,25 @@ const BalanceChartSide = styled.div<{ percent: number; $color: string; isLeft: b
${({ isLeft }) => (isLeft ? leftBarChartStyles : rightBarChartStyles)}
`
const StatSectionBubble = styled(LoadingBubble)`
width: 180px;
height: 40px;
`
const StatHeaderBubble = styled(LoadingBubble)`
width: 116px;
height: 24px;
border-radius: 8px;
`
interface PoolDetailsStatsProps {
poolData: PoolData
isReversed: boolean
poolData?: PoolData
isReversed?: boolean
chainId?: number
loading?: boolean
}
export function PoolDetailsStats({ poolData, isReversed, chainId }: PoolDetailsStatsProps) {
export function PoolDetailsStats({ poolData, isReversed, chainId, loading }: PoolDetailsStatsProps) {
const isScreenSize = useScreenSize()
const screenIsNotLarge = isScreenSize['lg']
const { formatNumber } = useFormatter()
@ -112,26 +127,46 @@ export function PoolDetailsStats({ poolData, isReversed, chainId }: PoolDetailsS
}
const [token0, token1] = useMemo(() => {
const fullWidth = poolData?.tvlToken0 / poolData?.token0Price + poolData?.tvlToken1
const token0FullData = {
...poolData?.token0,
price: poolData?.token0Price,
tvl: poolData?.tvlToken0,
color: color0,
percent: poolData?.tvlToken0 / poolData?.token0Price / fullWidth,
currency: currency0,
if (poolData) {
const fullWidth = poolData?.tvlToken0 / poolData?.token0Price + poolData?.tvlToken1
const token0FullData = {
...poolData?.token0,
price: poolData?.token0Price,
tvl: poolData?.tvlToken0,
color: color0,
percent: poolData?.tvlToken0 / poolData?.token0Price / fullWidth,
currency: currency0,
}
const token1FullData = {
...poolData?.token1,
price: poolData?.token1Price,
tvl: poolData?.tvlToken1,
color: color1,
percent: poolData?.tvlToken1 / fullWidth,
currency: currency1,
}
return isReversed ? [token1FullData, token0FullData] : [token0FullData, token1FullData]
} else {
return [undefined, undefined]
}
const token1FullData = {
...poolData?.token1,
price: poolData?.token1Price,
tvl: poolData?.tvlToken1,
color: color1,
percent: poolData?.tvlToken1 / fullWidth,
currency: currency1,
}
return isReversed ? [token1FullData, token0FullData] : [token0FullData, token1FullData]
}, [color0, color1, currency0, currency1, isReversed, poolData])
if (loading || !token0 || !token1 || !poolData) {
return (
<StatsWrapper>
<HeaderText>
<StatHeaderBubble />
</HeaderText>
{Array.from({ length: 4 }).map((_, i) => (
<Column gap="md" key={`loading-info-row-${i}`}>
<DetailBubble />
<StatSectionBubble />
</Column>
))}
</StatsWrapper>
)
}
return (
<StatsWrapper>
<HeaderText>

@ -26,6 +26,13 @@ describe('PoolDetailsStatsButton', () => {
mocked(useMultiChainPositions).mockReturnValue(useMultiChainPositionsReturnValue)
})
it('loading skeleton shown correctly', () => {
const { asFragment } = render(<PoolDetailsStatsButtons {...mockProps} loading={true} />)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByTestId('pdp-buttons-loading-skeleton')).toBeVisible()
})
it('renders both buttons correctly', () => {
const { asFragment } = render(<PoolDetailsStatsButtons {...mockProps} />)
expect(asFragment()).toMatchSnapshot()

@ -4,6 +4,7 @@ import { PositionInfo } from 'components/AccountDrawer/MiniPortfolio/Pools/cache
import useMultiChainPositions from 'components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions'
import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button'
import Row from 'components/Row'
import { LoadingBubble } from 'components/Tokens/loading'
import { Token } from 'graphql/thegraph/__generated__/types-and-hooks'
import { useCurrency } from 'hooks/Tokens'
import { useSwitchChain } from 'hooks/useSwitchChain'
@ -26,11 +27,18 @@ const PoolButton = styled(ThemeButton)`
width: 50%;
`
const ButtonBubble = styled(LoadingBubble)`
height: 44px;
width: 175px;
border-radius: 900px;
`
interface PoolDetailsStatsButtonsProps {
chainId?: number
token0?: Token
token1?: Token
feeTier?: number
loading?: boolean
}
function findMatchingPosition(positions: PositionInfo[], token0?: Token, token1?: Token, feeTier?: number) {
@ -45,7 +53,7 @@ function findMatchingPosition(positions: PositionInfo[], token0?: Token, token1?
)
}
export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier }: PoolDetailsStatsButtonsProps) {
export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, loading }: PoolDetailsStatsButtonsProps) {
const { chainId: walletChainId, connector, account } = useWeb3React()
const { positions: userOwnedPositions } = useMultiChainPositions(account ?? '', chainId ? [chainId] : undefined)
const position = userOwnedPositions && findMatchingPosition(userOwnedPositions, token0, token1, feeTier)
@ -64,7 +72,15 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier }: Po
)
}
}
if (!currency0 || !currency1) return null
if (loading || !currency0 || !currency1)
return (
<PoolDetailsStatsButtonsRow data-testid="pdp-buttons-loading-skeleton">
<ButtonBubble />
<ButtonBubble />
</PoolDetailsStatsButtonsRow>
)
return (
<PoolDetailsStatsButtonsRow>
<PoolButton
@ -75,7 +91,6 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier }: Po
>
<Trans>Add liquidity</Trans>
</PoolButton>
<PoolButton
size={ButtonSize.medium}
emphasis={ButtonEmphasis.highSoft}

@ -0,0 +1,98 @@
import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import { ScrollBarStyles } from 'components/Common'
import Row from 'components/Row'
import { ArrowDown } from 'react-feather'
import styled from 'styled-components'
import { ThemedText } from 'theme/components'
import { DetailBubble, SmallDetailBubble } from './shared'
const Table = styled(Column)`
gap: 24px;
border-radius: 20px;
border: 1px solid ${({ theme }) => theme.surface3};
padding-bottom: 12px;
overflow-y: hidden;
${ScrollBarStyles}
`
const TableRow = styled(Row)<{ $borderBottom?: boolean }>`
justify-content: space-between;
border-bottom: ${({ $borderBottom, theme }) => ($borderBottom ? `1px solid ${theme.surface3}` : 'none')}};
padding: 12px;
min-width: max-content;
`
const TableElement = styled(ThemedText.BodySecondary)<{
alignRight?: boolean
small?: boolean
large?: boolean
}>`
display: flex;
padding: 0px 8px;
flex: ${({ small }) => (small ? 'unset' : '1')};
width: ${({ small }) => (small ? '44px' : 'auto')};
min-width: ${({ large, small }) => (large ? '136px' : small ? 'unset' : '121px')} !important;
justify-content: ${({ alignRight }) => (alignRight ? 'flex-end' : 'flex-start')};
`
{
/* TODO(WEB-2735): When making real datatable, merge in this code and deprecate this skeleton file */
}
export function PoolDetailsTableSkeleton() {
return (
<Table $isHorizontalScroll>
<TableRow $borderBottom>
<TableElement large>
<Row>
<ArrowDown size={16} />
<Trans>Time</Trans>
</Row>
</TableElement>
<TableElement>
<Trans>Type</Trans>
</TableElement>
<TableElement alignRight>
<Trans>USD</Trans>
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<Trans>Maker</Trans>
</TableElement>
<TableElement alignRight small>
<Trans>Txn</Trans>
</TableElement>
</TableRow>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={`loading-table-row-${i}`}>
<TableElement large>
<DetailBubble />
</TableElement>
<TableElement>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight>
<DetailBubble />
</TableElement>
<TableElement alignRight small>
<SmallDetailBubble />
</TableElement>
</TableRow>
))}
</Table>
)
}

@ -1,5 +1,120 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PoolDetailsHeader loading skeleton is shown 1`] = `
<DocumentFragment>
.c5 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c6 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 8px;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 8px;
}
.c2 {
border-radius: 12px;
border-radius: 12px;
height: 24px;
width: 50%;
width: 50%;
-webkit-animation: fAQEyV 1.5s infinite;
animation: fAQEyV 1.5s infinite;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
background: linear-gradient( to left, #22222212 25%, rgba(53,53,53,0.07) 50%, #22222212 75% );
will-change: background-position;
background-size: 400%;
}
.c3 {
height: 16px;
width: 300px;
}
.c8 {
height: 16px;
width: 137px;
}
.c1 {
gap: 36px;
}
.c7 {
width: 32px;
height: 32px;
border-radius: 50%;
}
<div
class="c0 c1"
data-testid="pdp-header-loading-skeleton"
>
<div
class="c2 c3"
/>
<div
class="c4"
>
<div
class="c5 c6"
>
<div
class="c2 c7"
/>
<div
class="c2 c8"
/>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`PoolDetailsHeader renders header text correctly 1`] = `
<DocumentFragment>
.c2 {

@ -1,5 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PoolDetailsStatsButton loading skeleton shown correctly 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c3 {
border-radius: 12px;
border-radius: 12px;
height: 24px;
width: 50%;
width: 50%;
-webkit-animation: fAQEyV 1.5s infinite;
animation: fAQEyV 1.5s infinite;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
background: linear-gradient( to left, #22222212 25%, rgba(53,53,53,0.07) 50%, #22222212 75% );
will-change: background-position;
background-size: 400%;
}
.c2 {
gap: 12px;
}
.c4 {
height: 44px;
width: 175px;
border-radius: 900px;
}
@media (max-width:1023px) {
.c2 {
display: none;
}
}
<div
class="c0 c1 c2"
data-testid="pdp-buttons-loading-skeleton"
>
<div
class="c3 c4"
/>
<div
class="c3 c4"
/>
</div>
</DocumentFragment>
`;
exports[`PoolDetailsStatsButton renders both buttons correctly 1`] = `
<DocumentFragment>
.c0 {

File diff suppressed because it is too large Load Diff

@ -76,7 +76,6 @@ describe('PoolDetailsPage', () => {
})
})
// TODO replace with loading skeleton when designed
it('nothing displayed while data is loading', () => {
mocked(usePoolData).mockReturnValue({
data: undefined,
@ -86,7 +85,7 @@ describe('PoolDetailsPage', () => {
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).not.toBeInTheDocument()
expect(screen.getByTestId('pdp-links-loading-skeleton')).toBeInTheDocument()
})
})

@ -1,6 +1,8 @@
import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import Row from 'components/Row'
import { LoadingBubble } from 'components/Tokens/loading'
import { LoadingChart } from 'components/Tokens/TokenDetails/Skeleton'
import { TokenDescription } from 'components/Tokens/TokenDetails/TokenDescription'
import { getValidUrlChainName, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { usePoolData } from 'graphql/thegraph/PoolData'
@ -15,14 +17,18 @@ import { isAddress } from 'utils'
import { PoolDetailsHeader } from './PoolDetailsHeader'
import { PoolDetailsStats } from './PoolDetailsStats'
import { PoolDetailsStatsButtons } from './PoolDetailsStatsButtons'
import { PoolDetailsTableSkeleton } from './PoolDetailsTableSkeleton'
import { DetailBubble, SmallDetailBubble } from './shared'
const PageWrapper = styled(Row)`
padding: 48px;
width: 100%;
align-items: flex-start;
gap: 60px;
@media (max-width: ${BREAKPOINTS.lg - 1}px) {
flex-direction: column;
gap: unset;
}
@media (max-width: ${BREAKPOINTS.sm - 1}px) {
@ -30,6 +36,33 @@ const PageWrapper = styled(Row)`
}
`
const LeftColumn = styled(Column)`
gap: 24px;
width: 65vw;
overflow: hidden;
justify-content: flex-start;
@media (max-width: ${BREAKPOINTS.lg - 1}px) {
width: 100%;
}
`
const HR = styled.hr`
border: 0.5px solid ${({ theme }) => theme.surface3};
margin: 16px 0px;
width: 100%;
`
const ChartHeaderBubble = styled(LoadingBubble)`
width: 180px;
height: 32px;
`
const LinkColumn = styled(Column)`
gap: 16px;
padding: 20px;
`
const RightColumn = styled(Column)`
gap: 24px;
margin: 0 48px 0 auto;
@ -79,31 +112,55 @@ export default function PoolDetailsPage() {
const isInvalidPool = !chainName || !poolAddress || !getValidUrlChainName(chainName) || !isAddress(poolAddress)
const poolNotFound = (!loading && !poolData) || isInvalidPool
// TODO(WEB-2814): Add skeleton once designed
if (loading) return null
if (poolNotFound) return <NotFound />
return (
<PageWrapper>
<PoolDetailsHeader
chainId={chainId}
poolAddress={poolAddress}
token0={token0}
token1={token1}
feeTier={poolData?.feeTier}
toggleReversed={toggleReversed}
/>
<LeftColumn>
<Column gap="sm">
<PoolDetailsHeader
chainId={chainId}
poolAddress={poolAddress}
token0={token0}
token1={token1}
feeTier={poolData?.feeTier}
toggleReversed={toggleReversed}
loading={loading}
/>
<LoadingChart />
</Column>
<HR />
<ChartHeaderBubble />
<PoolDetailsTableSkeleton />
</LeftColumn>
<RightColumn>
<PoolDetailsStatsButtons chainId={chainId} token0={token0} token1={token1} feeTier={poolData?.feeTier} />
{poolData && <PoolDetailsStats poolData={poolData} isReversed={isReversed} chainId={chainId} />}
{(token0 || token1) && (
<TokenDetailsWrapper>
<TokenDetailsHeader>
<Trans>Info</Trans>
</TokenDetailsHeader>
{token0 && <TokenDescription tokenAddress={token0.id} chainId={chainId} />}
{token1 && <TokenDescription tokenAddress={token1.id} chainId={chainId} />}
</TokenDetailsWrapper>
)}
<PoolDetailsStatsButtons
chainId={chainId}
token0={token0}
token1={token1}
feeTier={poolData?.feeTier}
loading={loading}
/>
<PoolDetailsStats poolData={poolData} isReversed={isReversed} chainId={chainId} loading={loading} />
{(token0 || token1 || loading) &&
(loading ? (
<LinkColumn data-testid="pdp-links-loading-skeleton">
<DetailBubble $height={24} $width={116} />
{Array.from({ length: 3 }).map((_, i) => (
<Row gap="8px" key={`loading-link-row-${i}`}>
<SmallDetailBubble />
<DetailBubble $width={117} />
</Row>
))}
</LinkColumn>
) : (
<TokenDetailsWrapper>
<TokenDetailsHeader>
<Trans>Info</Trans>
</TokenDetailsHeader>
{token0 && <TokenDescription tokenAddress={token0.id} chainId={chainId} />}
{token1 && <TokenDescription tokenAddress={token1.id} chainId={chainId} />}
</TokenDetailsWrapper>
))}
</RightColumn>
</PageWrapper>
)

@ -0,0 +1,13 @@
import { LoadingBubble } from 'components/Tokens/loading'
import styled from 'styled-components'
export const DetailBubble = styled(LoadingBubble)<{ $height?: number; $width?: number }>`
height: ${({ $height }) => ($height ? `${$height}px` : '16px')};
width: ${({ $width }) => ($width ? `${$width}px` : '80px')};
`
export const SmallDetailBubble = styled(LoadingBubble)`
height: 20px;
width: 20px;
border-radius: 100px;
`

@ -139,7 +139,11 @@ export const routes: RouteDefinition[] = [
}),
createRouteDefinition({
path: 'explore/pools/:chainName/:poolAddress',
getElement: () => <PoolDetails />,
getElement: () => (
<Suspense fallback={null}>
<PoolDetails />
</Suspense>
),
enabled: (args) => Boolean(args.infoExplorePageEnabled && args.infoPoolPageEnabled),
}),
createRouteDefinition({