feat: [info] Initial Pool Details Page (#7250)

* feat: setup initial pool details page and route

* add pool data query and call on enw page

* make query dynamic to url chainId

* Get and display Header info

* add token symbols

* split header into its own file

* add helper function to not default to eth chain

* add helper function tests

* add header component tests

* add mocked test for PDP

* use valid values

* allow unsupported BE chains supported by thegraph

* typecheck

* remove useless row

* no longer needed child

* use first and last child

* move mock consts to their own file

* skele linear task

* return null

* descriptiive pool not found bool

* modify correct logo container

* update snapshots

* instantiate all chain apollo clients

* added snapshot test

* merge main and update snapshots

* Update src/pages/PoolDetails/PoolDetailsHeader.tsx

Co-authored-by: Nate Wienert <natewienert@gmail.com>

* type feeTier

---------

Co-authored-by: Nate Wienert <natewienert@gmail.com>
This commit is contained in:
Charles Bachmeier 2023-09-11 14:48:16 -07:00 committed by GitHub
parent 184d515e16
commit dcf7d29357
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1120 additions and 2 deletions

@ -3,3 +3,7 @@ import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useInfoPoolPageFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.infoPoolPage)
}
export function useInfoPoolPageEnabled(): boolean {
return useInfoPoolPageFlag() === BaseVariant.Enabled
}

@ -1,7 +1,7 @@
import { ChainId } from '@uniswap/sdk-core'
import { Chain } from './__generated__/types-and-hooks'
import { isSupportedGQLChain, supportedChainIdFromGQLChain } from './util'
import { getValidUrlChainName, isSupportedGQLChain, supportedChainIdFromGQLChain } from './util'
describe('fromGraphQLChain', () => {
it('should return the corresponding chain ID for supported chains', () => {
@ -34,3 +34,25 @@ describe('fromGraphQLChain', () => {
}
})
})
describe('isValidUrlChainParam', () => {
it('should return true for valid chain name', () => {
const validChainName = 'ethereum'
expect(getValidUrlChainName(validChainName)).toBe(Chain.Ethereum)
})
it('should return false for undefined chain name', () => {
const undefinedChainName = undefined
expect(getValidUrlChainName(undefinedChainName)).toBe(undefined)
})
it('should return false for invalid chain name', () => {
const invalidChainName = 'invalidchain'
expect(getValidUrlChainName(invalidChainName)).toBe(undefined)
})
it('should return false for a misconfigured chain name', () => {
const invalidChainName = 'eThErEuM'
expect(getValidUrlChainName(invalidChainName)).toBe(undefined)
})
})

@ -132,6 +132,15 @@ const URL_CHAIN_PARAM_TO_BACKEND: { [key: string]: InterfaceGqlChain } = {
base: Chain.Base,
}
/**
* @param chainName parsed in chain name from url query parameter
* @returns if chainName is a valid chain name, returns the backend chain name, otherwise returns undefined
*/
export function getValidUrlChainName(chainName: string | undefined): Chain | undefined {
const validChainName = chainName && URL_CHAIN_PARAM_TO_BACKEND[chainName]
return validChainName ? validChainName : undefined
}
/**
* @param chainName parsed in chain name from url query parameter
* @returns if chainName is a valid chain name supported by the backend, returns the backend chain name, otherwise returns Chain.Ethereum

@ -0,0 +1,56 @@
import { ChainId } from '@uniswap/sdk-core'
import gql from 'graphql-tag'
import { useMemo } from 'react'
import { usePoolDataQuery } from './__generated__/types-and-hooks'
import { chainToApolloClient } from './apollo'
gql`
query PoolData($poolId: [ID!]) {
data: pools(where: { id_in: $poolId }, orderBy: totalValueLockedUSD, orderDirection: desc, subgraphError: allow) {
id
feeTier
liquidity
sqrtPrice
tick
token0 {
id
symbol
name
decimals
derivedETH
}
token1 {
id
symbol
name
decimals
derivedETH
}
token0Price
token1Price
volumeUSD
volumeToken0
volumeToken1
txCount
totalValueLockedToken0
totalValueLockedToken1
totalValueLockedUSD
}
bundles(where: { id: "1" }) {
ethPriceUSD
}
}
`
export function usePoolData(poolAddress: string, chainId?: ChainId) {
const poolId = [poolAddress]
const apolloClient = chainToApolloClient[chainId || ChainId.MAINNET]
const { data, loading } = usePoolDataQuery({ variables: { poolId }, client: apolloClient })
return useMemo(() => {
return {
data: data?.data[0],
loading,
}
}, [data, loading])
}

@ -1,4 +1,4 @@
import { ApolloClient, ApolloLink, concat, HttpLink, InMemoryCache } from '@apollo/client'
import { ApolloClient, ApolloLink, concat, HttpLink, InMemoryCache, NormalizedCacheObject } from '@apollo/client'
import { ChainId } from '@uniswap/sdk-core'
import store from '../../state/index'
@ -32,3 +32,34 @@ export const apolloClient = new ApolloClient({
cache: new InMemoryCache(),
link: concat(authMiddleware, httpLink),
})
export const chainToApolloClient: Record<number, ApolloClient<NormalizedCacheObject>> = {
[ChainId.MAINNET]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.MAINNET],
}),
[ChainId.ARBITRUM_ONE]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.ARBITRUM_ONE],
}),
[ChainId.OPTIMISM]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.OPTIMISM],
}),
[ChainId.POLYGON]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.POLYGON],
}),
[ChainId.CELO]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.CELO],
}),
[ChainId.BNB]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.BNB],
}),
[ChainId.AVALANCHE]: new ApolloClient({
cache: new InMemoryCache(),
uri: CHAIN_SUBGRAPH_URL[ChainId.AVALANCHE],
}),
}

@ -4,6 +4,7 @@ import { getDeviceId, sendAnalyticsEvent, Trace, user } from 'analytics'
import Loader from 'components/Icons/LoadingSpinner'
import TopLevelModals from 'components/TopLevelModals'
import { useFeatureFlagsIsLoaded } from 'featureFlags'
import { useInfoPoolPageEnabled } from 'featureFlags/flags/infoPoolPage'
import { useAtom } from 'jotai'
import { useBag } from 'nft/hooks/useBag'
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useState } from 'react'
@ -46,6 +47,7 @@ import { RedirectPathToSwapOnly } from './Swap/redirects'
import Tokens from './Tokens'
const TokenDetails = lazy(() => import('./TokenDetails'))
const PoolDetails = lazy(() => import('./PoolDetails'))
const Vote = lazy(() => import('./Vote'))
const NftExplore = lazy(() => import('nft/pages/explore'))
const Collection = lazy(() => import('nft/pages/collection'))
@ -118,6 +120,7 @@ export default function App() {
const isDarkMode = useIsDarkMode()
const [routerPreference] = useRouterPreference()
const [scrolledState, setScrolledState] = useState(false)
const infoPoolPageEnabled = useInfoPoolPageEnabled()
useAnalyticsReporter()
@ -236,6 +239,7 @@ export default function App() {
<Route path=":chainName" />
</Route>
<Route path="tokens/:chainName/:tokenAddress" element={<TokenDetails />} />
{infoPoolPageEnabled && <Route path="pools/:chainName/:poolAddress" element={<PoolDetails />} />}
<Route
path="vote/*"
element={

@ -0,0 +1,33 @@
import userEvent from '@testing-library/user-event'
import { act, render, screen } from 'test-utils/render'
import { PoolDetailsHeader } from './PoolDetailsHeader'
describe('PoolDetailsHeader', () => {
const mockProps = {
chainId: 1,
poolAddress: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640',
token0: { id: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', symbol: 'USDC' },
token1: { id: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', symbol: 'WETH' },
feeTier: 500,
toggleReversed: jest.fn(),
}
it('renders header text correctly', () => {
const { asFragment } = render(<PoolDetailsHeader {...mockProps} />)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText(/Explore/i)).toBeInTheDocument()
expect(screen.getByText(/Pool/i)).toBeInTheDocument()
expect(screen.getByText(/USDC \/ WETH \(0x88e6...5640\)/i)).toBeInTheDocument()
expect(screen.getByText('0.05%')).toBeInTheDocument()
})
it('calls toggleReversed when arrows are clicked', async () => {
render(<PoolDetailsHeader {...mockProps} />)
await act(() => userEvent.click(screen.getByTestId('toggle-tokens-reverse-arrows')))
expect(mockProps.toggleReversed).toHaveBeenCalledTimes(1)
})
})

@ -0,0 +1,208 @@
import { Trans } from '@lingui/macro'
import { ChainId, Currency } from '@uniswap/sdk-core'
import blankTokenUrl from 'assets/svg/blank_token.svg'
import Column from 'components/Column'
import Row from 'components/Row'
import { getChainInfo } from 'constants/chainInfo'
import { chainIdToBackendName } from 'graphql/data/util'
import { useCurrency } from 'hooks/Tokens'
import useTokenLogoSource from 'hooks/useAssetLogoSource'
import React from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { ClickableStyle, ThemedText } from 'theme'
import { shortenAddress } from 'utils'
import { ReversedArrowsIcon } from './icons'
const HeaderColumn = styled(Column)`
gap: 36px;
`
const StyledLink = styled(Link)`
text-decoration: none;
${ClickableStyle}
`
const FeeTier = styled(ThemedText.LabelMicro)`
background: ${({ theme }) => theme.surface2};
padding: 2px 6px;
border-radius: 4px;
`
const ToggleReverseArrows = styled(ReversedArrowsIcon)`
${ClickableStyle}
`
interface Token {
id: string
symbol: string
}
interface PoolDetailsHeaderProps {
chainId?: number
poolAddress?: string
token0?: Token
token1?: Token
feeTier?: number
toggleReversed: React.DispatchWithoutAction
}
export function PoolDetailsHeader({
chainId,
poolAddress,
token0,
token1,
feeTier,
toggleReversed,
}: PoolDetailsHeaderProps) {
const currencies = [useCurrency(token0?.id, chainId) ?? undefined, useCurrency(token1?.id, chainId) ?? undefined]
const chainName = chainIdToBackendName(chainId)
const origin = `/tokens/${chainName}`
return (
<HeaderColumn>
<Row>
<StyledLink to={origin}>
<ThemedText.BodySecondary>
<Trans>Explore</Trans>
</ThemedText.BodySecondary>
</StyledLink>
<ThemedText.BodySecondary>&nbsp;{'>'}&nbsp;</ThemedText.BodySecondary>
{/* TODO: When Explore Pool table is added, link directly back to it */}
<StyledLink to={origin}>
<ThemedText.BodySecondary>
<Trans>Pool</Trans>
</ThemedText.BodySecondary>
</StyledLink>
<ThemedText.BodySecondary>&nbsp;{'>'}&nbsp;</ThemedText.BodySecondary>
<ThemedText.BodyPrimary>
{token0?.symbol} / {token1?.symbol} ({shortenAddress(poolAddress)})
</ThemedText.BodyPrimary>
</Row>
<Row gap="18px">
<Row gap="8px" width="max-content">
{chainId && (
<DoubleCurrencyAndChainLogo data-testid="double-token-logo" chainId={chainId} currencies={currencies} />
)}
<ThemedText.HeadlineSmall>
{token0?.symbol} / {token1?.symbol}
</ThemedText.HeadlineSmall>
</Row>
{!!feeTier && <FeeTier>{feeTier / 10000}%</FeeTier>}
<ToggleReverseArrows data-testid="toggle-tokens-reverse-arrows" onClick={toggleReversed} />
</Row>
</HeaderColumn>
)
}
const StyledLogoParentContainer = styled.div`
position: relative;
top: 0;
left: 0;
`
function DoubleCurrencyAndChainLogo({
chainId,
currencies,
}: {
chainId: number
currencies: Array<Currency | undefined>
}) {
return (
<StyledLogoParentContainer>
<DoubleCurrencyLogo chainId={chainId} currencies={currencies} />
<SquareL2Logo chainId={chainId} />
</StyledLogoParentContainer>
)
}
const L2LogoContainer = styled.div<{ hasSquareLogo?: boolean }>`
background-color: ${({ theme, hasSquareLogo }) => (hasSquareLogo ? theme.surface2 : theme.neutral1)};
border-radius: 2px;
height: 12px;
left: 60%;
position: absolute;
top: 60%;
outline: 2px solid ${({ theme }) => theme.surface1};
width: 12px;
display: flex;
align-items: center;
justify-content: center;
`
const StyledChainLogo = styled.img`
height: 12px;
width: 12px;
`
const SquareChainLogo = styled.img`
height: 100%;
width: 100%;
`
function SquareL2Logo({ chainId }: { chainId: ChainId }) {
if (chainId === ChainId.MAINNET) return null
const { squareLogoUrl, logoUrl } = getChainInfo(chainId)
const chainLogo = squareLogoUrl ?? logoUrl
return (
<L2LogoContainer hasSquareLogo={!!squareLogoUrl}>
{squareLogoUrl ? (
<SquareChainLogo src={chainLogo} alt="chainLogo" />
) : (
<StyledChainLogo src={chainLogo} alt="chainLogo" />
)}
</L2LogoContainer>
)
}
function DoubleCurrencyLogo({ chainId, currencies }: { chainId: number; currencies: Array<Currency | undefined> }) {
const [src, nextSrc] = useTokenLogoSource(currencies?.[0]?.wrapped.address, chainId, currencies?.[0]?.isNative)
const [src2, nextSrc2] = useTokenLogoSource(currencies?.[1]?.wrapped.address, chainId, currencies?.[1]?.isNative)
return <DoubleLogo logo1={src} onError1={nextSrc} logo2={src2} onError2={nextSrc2} />
}
const DoubleLogoContainer = styled.div`
display: flex;
gap: 2px;
position: relative;
top: 0;
left: 0;
img {
width: 16px;
height: 32px;
object-fit: cover;
}
img:first-child {
border-radius: 16px 0 0 16px;
object-position: 0 0;
}
img:last-child {
border-radius: 0 16px 16px 0;
object-position: 100% 0;
}
`
const CircleLogoImage = styled.img`
width: 32px;
height: 32px;
border-radius: 50%;
`
interface DoubleLogoProps {
logo1?: string
logo2?: string
onError1?: () => void
onError2?: () => void
}
function DoubleLogo({ logo1, onError1, logo2, onError2 }: DoubleLogoProps) {
return (
<DoubleLogoContainer>
<CircleLogoImage src={logo1 ?? blankTokenUrl} onError={onError1} />
<CircleLogoImage src={logo2 ?? blankTokenUrl} onError={onError2} />
</DoubleLogoContainer>
)
}

@ -0,0 +1,273 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PoolDetailsHeader renders header text correctly 1`] = `
<DocumentFragment>
.c2 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c8 {
box-sizing: border-box;
margin: 0;
min-width: 0;
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
}
.c3 {
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;
}
.c7 {
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: 18px;
}
.c9 {
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
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;
}
.c5 {
color: #7D7D7D;
}
.c6 {
color: #222222;
}
.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;
}
.c1 {
gap: 36px;
}
.c4 {
-webkit-text-decoration: none;
text-decoration: none;
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
}
.c4:hover {
opacity: 0.6;
}
.c4:active {
opacity: 0.4;
}
.c13 {
background: #F9F9F9;
padding: 2px 6px;
border-radius: 4px;
}
.c14 {
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
}
.c14:hover {
opacity: 0.6;
}
.c14:active {
opacity: 0.4;
}
.c10 {
position: relative;
top: 0;
left: 0;
}
.c11 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 2px;
position: relative;
top: 0;
left: 0;
}
.c11 img {
width: 16px;
height: 32px;
object-fit: cover;
}
.c11 img:first-child {
border-radius: 16px 0 0 16px;
object-position: 0 0;
}
.c11 img:last-child {
border-radius: 0 16px 16px 0;
object-position: 100% 0;
}
.c12 {
width: 32px;
height: 32px;
border-radius: 50%;
}
<div
class="c0 c1"
>
<div
class="c2 c3"
>
<a
class="c4"
href="/tokens/ETHEREUM"
>
<div
class="c5 css-1urox24"
>
Explore
</div>
</a>
<div
class="c5 css-1urox24"
>
 &gt; 
</div>
<a
class="c4"
href="/tokens/ETHEREUM"
>
<div
class="c5 css-1urox24"
>
Pool
</div>
</a>
<div
class="c5 css-1urox24"
>
 &gt; 
</div>
<div
class="c6 css-1urox24"
>
USDC / WETH (0x88e6...5640)
</div>
</div>
<div
class="c2 c7"
>
<div
class="c8 c9"
width="max-content"
>
<div
class="c10"
>
<div
class="c11"
>
<img
class="c12"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png"
/>
<img
class="c12"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png"
/>
</div>
</div>
<div
class="c6 css-1jyz67g"
>
USDC / WETH
</div>
</div>
<div
class="c5 c13 css-1m65e73"
>
0.05%
</div>
<svg
class="c14"
data-testid="toggle-tokens-reverse-arrows"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.125 10V12.5C18.125 14.2233 16.7225 15.625 15 15.625H4.0092L5.4425 17.0583C5.68667 17.3025 5.68667 17.6983 5.4425 17.9425C5.32084 18.0642 5.16081 18.1258 5.00081 18.1258C4.84081 18.1258 4.68079 18.065 4.55912 17.9425L2.05912 15.4425C2.00162 15.385 1.9559 15.3159 1.92424 15.2393C1.8609 15.0868 1.8609 14.9143 1.92424 14.7618C1.9559 14.6851 2.00162 14.6158 2.05912 14.5583L4.55912 12.0583C4.80329 11.8141 5.19915 11.8141 5.44332 12.0583C5.68749 12.3025 5.68749 12.6983 5.44332 12.9425L4.01001 14.3758H15C16.0333 14.3758 16.875 13.535 16.875 12.5008V10.0008C16.875 9.65581 17.155 9.37581 17.5 9.37581C17.845 9.37581 18.125 9.655 18.125 10ZM2.5 10.625C2.845 10.625 3.125 10.345 3.125 10V7.5C3.125 6.46583 3.96667 5.625 5 5.625H15.9908L14.5575 7.05831C14.3133 7.30247 14.3133 7.69834 14.5575 7.9425C14.6792 8.06417 14.8392 8.12581 14.9992 8.12581C15.1592 8.12581 15.3192 8.065 15.4409 7.9425L17.9409 5.4425C17.9984 5.385 18.0441 5.31592 18.0758 5.23926C18.1391 5.08676 18.1391 4.91426 18.0758 4.76176C18.0441 4.68509 17.9984 4.61581 17.9409 4.55831L15.4409 2.05831C15.1967 1.81414 14.8008 1.81414 14.5567 2.05831C14.3125 2.30247 14.3125 2.69834 14.5567 2.9425L15.99 4.37581H5C3.2775 4.37581 1.875 5.77748 1.875 7.50081V10.0008C1.875 10.345 2.155 10.625 2.5 10.625Z"
fill="#5E5E5E"
/>
</svg>
</div>
</div>
</DocumentFragment>
`;

@ -0,0 +1,282 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PoolDetailsPage pool header is displayed when data is received from thegraph 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c9 {
box-sizing: border-box;
margin: 0;
min-width: 0;
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
}
.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;
}
.c8 {
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: 18px;
}
.c10 {
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
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;
}
.c6 {
color: #7D7D7D;
}
.c7 {
color: #222222;
}
.c3 {
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 {
gap: 36px;
}
.c5 {
-webkit-text-decoration: none;
text-decoration: none;
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
}
.c5:hover {
opacity: 0.6;
}
.c5:active {
opacity: 0.4;
}
.c14 {
background: #F9F9F9;
padding: 2px 6px;
border-radius: 4px;
}
.c15 {
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
}
.c15:hover {
opacity: 0.6;
}
.c15:active {
opacity: 0.4;
}
.c11 {
position: relative;
top: 0;
left: 0;
}
.c12 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: 2px;
position: relative;
top: 0;
left: 0;
}
.c12 img {
width: 16px;
height: 32px;
object-fit: cover;
}
.c12 img:first-child {
border-radius: 16px 0 0 16px;
object-position: 0 0;
}
.c12 img:last-child {
border-radius: 0 16px 16px 0;
object-position: 100% 0;
}
.c13 {
width: 32px;
height: 32px;
border-radius: 50%;
}
.c2 {
padding: 40px 56px;
width: 100%;
}
<div
class="c0 c1 c2"
>
<div
class="c3 c4"
>
<div
class="c0 c1"
>
<a
class="c5"
href="/tokens/ETHEREUM"
>
<div
class="c6 css-1urox24"
>
Explore
</div>
</a>
<div
class="c6 css-1urox24"
>
 &gt; 
</div>
<a
class="c5"
href="/tokens/ETHEREUM"
>
<div
class="c6 css-1urox24"
>
Pool
</div>
</a>
<div
class="c6 css-1urox24"
>
 &gt; 
</div>
<div
class="c7 css-1urox24"
>
USDC / WETH (0x88e6...5640)
</div>
</div>
<div
class="c0 c8"
>
<div
class="c9 c10"
width="max-content"
>
<div
class="c11"
>
<div
class="c12"
>
<img
class="c13"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png"
/>
<img
class="c13"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png"
/>
</div>
</div>
<div
class="c7 css-1jyz67g"
>
USDC / WETH
</div>
</div>
<div
class="c6 c14 css-1m65e73"
>
0.05%
</div>
<svg
class="c15"
data-testid="toggle-tokens-reverse-arrows"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.125 10V12.5C18.125 14.2233 16.7225 15.625 15 15.625H4.0092L5.4425 17.0583C5.68667 17.3025 5.68667 17.6983 5.4425 17.9425C5.32084 18.0642 5.16081 18.1258 5.00081 18.1258C4.84081 18.1258 4.68079 18.065 4.55912 17.9425L2.05912 15.4425C2.00162 15.385 1.9559 15.3159 1.92424 15.2393C1.8609 15.0868 1.8609 14.9143 1.92424 14.7618C1.9559 14.6851 2.00162 14.6158 2.05912 14.5583L4.55912 12.0583C4.80329 11.8141 5.19915 11.8141 5.44332 12.0583C5.68749 12.3025 5.68749 12.6983 5.44332 12.9425L4.01001 14.3758H15C16.0333 14.3758 16.875 13.535 16.875 12.5008V10.0008C16.875 9.65581 17.155 9.37581 17.5 9.37581C17.845 9.37581 18.125 9.655 18.125 10ZM2.5 10.625C2.845 10.625 3.125 10.345 3.125 10V7.5C3.125 6.46583 3.96667 5.625 5 5.625H15.9908L14.5575 7.05831C14.3133 7.30247 14.3133 7.69834 14.5575 7.9425C14.6792 8.06417 14.8392 8.12581 14.9992 8.12581C15.1592 8.12581 15.3192 8.065 15.4409 7.9425L17.9409 5.4425C17.9984 5.385 18.0441 5.31592 18.0758 5.23926C18.1391 5.08676 18.1391 4.91426 18.0758 4.76176C18.0441 4.68509 17.9984 4.61581 17.9409 4.55831L15.4409 2.05831C15.1967 1.81414 14.8008 1.81414 14.5567 2.05831C14.3125 2.30247 14.3125 2.69834 14.5567 2.9425L15.99 4.37581H5C3.2775 4.37581 1.875 5.77748 1.875 7.50081V10.0008C1.875 10.345 2.155 10.625 2.5 10.625Z"
fill="#5E5E5E"
/>
</svg>
</div>
</div>
</div>
</DocumentFragment>
`;

@ -0,0 +1,15 @@
type SVGProps = React.SVGProps<SVGSVGElement> & {
fill?: string
height?: string | number
width?: string | number
gradientId?: string
}
export const ReversedArrowsIcon = (props: SVGProps) => (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M18.125 10V12.5C18.125 14.2233 16.7225 15.625 15 15.625H4.0092L5.4425 17.0583C5.68667 17.3025 5.68667 17.6983 5.4425 17.9425C5.32084 18.0642 5.16081 18.1258 5.00081 18.1258C4.84081 18.1258 4.68079 18.065 4.55912 17.9425L2.05912 15.4425C2.00162 15.385 1.9559 15.3159 1.92424 15.2393C1.8609 15.0868 1.8609 14.9143 1.92424 14.7618C1.9559 14.6851 2.00162 14.6158 2.05912 14.5583L4.55912 12.0583C4.80329 11.8141 5.19915 11.8141 5.44332 12.0583C5.68749 12.3025 5.68749 12.6983 5.44332 12.9425L4.01001 14.3758H15C16.0333 14.3758 16.875 13.535 16.875 12.5008V10.0008C16.875 9.65581 17.155 9.37581 17.5 9.37581C17.845 9.37581 18.125 9.655 18.125 10ZM2.5 10.625C2.845 10.625 3.125 10.345 3.125 10V7.5C3.125 6.46583 3.96667 5.625 5 5.625H15.9908L14.5575 7.05831C14.3133 7.30247 14.3133 7.69834 14.5575 7.9425C14.6792 8.06417 14.8392 8.12581 14.9992 8.12581C15.1592 8.12581 15.3192 8.065 15.4409 7.9425L17.9409 5.4425C17.9984 5.385 18.0441 5.31592 18.0758 5.23926C18.1391 5.08676 18.1391 4.91426 18.0758 4.76176C18.0441 4.68509 17.9984 4.61581 17.9409 4.55831L15.4409 2.05831C15.1967 1.81414 14.8008 1.81414 14.5567 2.05831C14.3125 2.30247 14.3125 2.69834 14.5567 2.9425L15.99 4.37581H5C3.2775 4.37581 1.875 5.77748 1.875 7.50081V10.0008C1.875 10.345 2.155 10.625 2.5 10.625Z"
fill="#5E5E5E"
/>
</svg>
)

@ -0,0 +1,98 @@
import { usePoolData } from 'graphql/thegraph/PoolData'
import Router from 'react-router-dom'
import { mocked } from 'test-utils/mocked'
import { validParams, validPoolDataResponse } from 'test-utils/pools/fixtures'
import { render, screen, waitFor } from 'test-utils/render'
import PoolDetails from '.'
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}))
jest.mock('graphql/thegraph/PoolData', () => {
const originalModule = jest.requireActual('graphql/thegraph/PoolData')
return {
...originalModule,
usePoolData: jest.fn(),
}
})
describe('PoolDetailsPage', () => {
beforeEach(() => {
jest.spyOn(Router, 'useParams').mockReturnValue(validParams)
mocked(usePoolData).mockReturnValue(validPoolDataResponse)
})
it('not found page displayed when given no poolAddress', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ chainName: validParams.chainName })
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
it('not found page displayed when given no chainName', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ poolAddress: validParams.poolAddress })
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
it('not found page displayed when given invalid chainName', () => {
jest
.spyOn(Router, 'useParams')
.mockReturnValue({ poolAddress: validParams.poolAddress, chainName: 'invalid-chain' })
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
it('not found page displayed when given invalid pool address', () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ poolAddress: '0xFakeAddress', chainName: validParams.chainName })
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
it('not found page displayed when no data is received from thegraph', () => {
mocked(usePoolData).mockReturnValue({
data: undefined,
loading: false,
})
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument()
})
})
// TODO replace with loading skeleton when designed
it('nothing displayed while data is loading', () => {
mocked(usePoolData).mockReturnValue({ data: undefined, loading: true })
render(<PoolDetails />)
waitFor(() => {
expect(screen.getByText(/not found/i)).not.toBeInTheDocument()
})
})
it('pool header is displayed when data is received from thegraph', () => {
const { asFragment } = render(<PoolDetails />)
expect(asFragment()).toMatchSnapshot()
waitFor(() => {
expect(screen.getByText(/Explore/i)).toBeInTheDocument()
expect(screen.getByText(/Pool/i)).toBeInTheDocument()
expect(screen.getByText(/USDC \/ WETH \(0x88e6...5640\)/i)).toBeInTheDocument()
})
})
})

@ -0,0 +1,46 @@
import Row from 'components/Row'
import { getValidUrlChainName, supportedChainIdFromGQLChain } from 'graphql/data/util'
import { usePoolData } from 'graphql/thegraph/PoolData'
import NotFound from 'pages/NotFound'
import { useReducer } from 'react'
import { useParams } from 'react-router-dom'
import styled from 'styled-components'
import { isAddress } from 'utils'
import { PoolDetailsHeader } from './PoolDetailsHeader'
const PageWrapper = styled(Row)`
padding: 40px 56px;
width: 100%;
`
export default function PoolDetailsPage() {
const { poolAddress, chainName } = useParams<{
poolAddress: string
chainName: string
}>()
const chain = getValidUrlChainName(chainName)
const chainId = chain && supportedChainIdFromGQLChain(chain)
const { data: poolData, loading } = usePoolData(poolAddress ?? '', chainId)
const [isReversed, toggleReversed] = useReducer((x) => !x, false)
const token0 = isReversed ? poolData?.token1 : poolData?.token0
const token1 = isReversed ? poolData?.token0 : poolData?.token1
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}
/>
</PageWrapper>
)
}

@ -0,0 +1,37 @@
export const validParams = { poolAddress: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640', chainName: 'ethereum' }
export const validPoolDataResponse = {
data: {
__typename: 'Pool' as const,
id: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640',
feeTier: '500',
liquidity: '32118065613640312417',
sqrtPrice: '1943494374075311739809880994923792',
tick: '202163',
token0: {
__typename: 'Token' as const,
id: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
symbol: 'USDC',
name: 'USD Coin',
decimals: '6',
derivedETH: '0.000602062055419695968472438533210813',
},
token1: {
__typename: 'Token' as const,
id: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
symbol: 'WETH',
name: 'Wrapped Ether',
decimals: '18',
derivedETH: '1',
},
token0Price: '1661.85294822715829371652214854595',
token1Price: '0.0006017379582632664031212782038199158',
volumeUSD: '394920157156.0515346899898790592366',
volumeToken0: '394894081779.781168',
volumeToken1: '190965971.266407832255075308',
txCount: '5406827',
totalValueLockedToken0: '180078648.881221',
totalValueLockedToken1: '142782.017882048454494774',
totalValueLockedUSD: '417233634.1468435997761171520463339',
},
loading: false,
}