From dcf7d29357b88b2cd564798ed4fdb73b927bd688 Mon Sep 17 00:00:00 2001 From: Charles Bachmeier Date: Mon, 11 Sep 2023 14:48:16 -0700 Subject: [PATCH] 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 * type feeTier --------- Co-authored-by: Nate Wienert --- src/featureFlags/flags/infoPoolPage.ts | 4 + src/graphql/data/util.test.tsx | 24 +- src/graphql/data/util.tsx | 9 + src/graphql/thegraph/PoolData.ts | 56 ++++ src/graphql/thegraph/apollo.ts | 33 +- src/pages/App.tsx | 4 + .../PoolDetails/PoolDetailsHeader.test.tsx | 33 ++ src/pages/PoolDetails/PoolDetailsHeader.tsx | 208 +++++++++++++ .../PoolDetailsHeader.test.tsx.snap | 273 +++++++++++++++++ .../__snapshots__/index.test.tsx.snap | 282 ++++++++++++++++++ src/pages/PoolDetails/icons.tsx | 15 + src/pages/PoolDetails/index.test.tsx | 98 ++++++ src/pages/PoolDetails/index.tsx | 46 +++ src/test-utils/pools/fixtures.ts | 37 +++ 14 files changed, 1120 insertions(+), 2 deletions(-) create mode 100644 src/graphql/thegraph/PoolData.ts create mode 100644 src/pages/PoolDetails/PoolDetailsHeader.test.tsx create mode 100644 src/pages/PoolDetails/PoolDetailsHeader.tsx create mode 100644 src/pages/PoolDetails/__snapshots__/PoolDetailsHeader.test.tsx.snap create mode 100644 src/pages/PoolDetails/__snapshots__/index.test.tsx.snap create mode 100644 src/pages/PoolDetails/icons.tsx create mode 100644 src/pages/PoolDetails/index.test.tsx create mode 100644 src/pages/PoolDetails/index.tsx create mode 100644 src/test-utils/pools/fixtures.ts diff --git a/src/featureFlags/flags/infoPoolPage.ts b/src/featureFlags/flags/infoPoolPage.ts index 13bbfb1576..2063741263 100644 --- a/src/featureFlags/flags/infoPoolPage.ts +++ b/src/featureFlags/flags/infoPoolPage.ts @@ -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 +} diff --git a/src/graphql/data/util.test.tsx b/src/graphql/data/util.test.tsx index 3799568fa9..bac5d506b6 100644 --- a/src/graphql/data/util.test.tsx +++ b/src/graphql/data/util.test.tsx @@ -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) + }) +}) diff --git a/src/graphql/data/util.tsx b/src/graphql/data/util.tsx index 978081f1d4..2e27b30dcf 100644 --- a/src/graphql/data/util.tsx +++ b/src/graphql/data/util.tsx @@ -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 diff --git a/src/graphql/thegraph/PoolData.ts b/src/graphql/thegraph/PoolData.ts new file mode 100644 index 0000000000..e0f681a7bb --- /dev/null +++ b/src/graphql/thegraph/PoolData.ts @@ -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]) +} diff --git a/src/graphql/thegraph/apollo.ts b/src/graphql/thegraph/apollo.ts index 5556fdd9bc..4938017c6d 100644 --- a/src/graphql/thegraph/apollo.ts +++ b/src/graphql/thegraph/apollo.ts @@ -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> = { + [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], + }), +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 0d7bb18409..b461bcbbdd 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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() { } /> + {infoPoolPageEnabled && } />} { + 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() + 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() + + await act(() => userEvent.click(screen.getByTestId('toggle-tokens-reverse-arrows'))) + + expect(mockProps.toggleReversed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/pages/PoolDetails/PoolDetailsHeader.tsx b/src/pages/PoolDetails/PoolDetailsHeader.tsx new file mode 100644 index 0000000000..861b3ef3eb --- /dev/null +++ b/src/pages/PoolDetails/PoolDetailsHeader.tsx @@ -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 ( + + + + + Explore + + +  {'>'}  + {/* TODO: When Explore Pool table is added, link directly back to it */} + + + Pool + + +  {'>'}  + + {token0?.symbol} / {token1?.symbol} ({shortenAddress(poolAddress)}) + + + + + {chainId && ( + + )} + + {token0?.symbol} / {token1?.symbol} + + + {!!feeTier && {feeTier / 10000}%} + + + + ) +} + +const StyledLogoParentContainer = styled.div` + position: relative; + top: 0; + left: 0; +` + +function DoubleCurrencyAndChainLogo({ + chainId, + currencies, +}: { + chainId: number + currencies: Array +}) { + return ( + + + + + ) +} + +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 ( + + {squareLogoUrl ? ( + + ) : ( + + )} + + ) +} + +function DoubleCurrencyLogo({ chainId, currencies }: { chainId: number; currencies: Array }) { + 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 +} + +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 ( + + + + + ) +} diff --git a/src/pages/PoolDetails/__snapshots__/PoolDetailsHeader.test.tsx.snap b/src/pages/PoolDetails/__snapshots__/PoolDetailsHeader.test.tsx.snap new file mode 100644 index 0000000000..33be9190b3 --- /dev/null +++ b/src/pages/PoolDetails/__snapshots__/PoolDetailsHeader.test.tsx.snap @@ -0,0 +1,273 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PoolDetailsHeader renders header text correctly 1`] = ` + + .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%; +} + +
+
+ +
+ Explore +
+
+
+  >  +
+ +
+ Pool +
+
+
+  >  +
+
+ USDC / WETH (0x88e6...5640) +
+
+
+
+
+
+ + +
+
+
+ USDC / WETH +
+
+
+ 0.05% +
+ + + +
+
+
+`; diff --git a/src/pages/PoolDetails/__snapshots__/index.test.tsx.snap b/src/pages/PoolDetails/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..e4b67ec655 --- /dev/null +++ b/src/pages/PoolDetails/__snapshots__/index.test.tsx.snap @@ -0,0 +1,282 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PoolDetailsPage pool header is displayed when data is received from thegraph 1`] = ` + + .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%; +} + +
+
+
+ +
+ Explore +
+
+
+  >  +
+ +
+ Pool +
+
+
+  >  +
+
+ USDC / WETH (0x88e6...5640) +
+
+
+
+
+
+ + +
+
+
+ USDC / WETH +
+
+
+ 0.05% +
+ + + +
+
+
+
+`; diff --git a/src/pages/PoolDetails/icons.tsx b/src/pages/PoolDetails/icons.tsx new file mode 100644 index 0000000000..9bfce0d406 --- /dev/null +++ b/src/pages/PoolDetails/icons.tsx @@ -0,0 +1,15 @@ +type SVGProps = React.SVGProps & { + fill?: string + height?: string | number + width?: string | number + gradientId?: string +} + +export const ReversedArrowsIcon = (props: SVGProps) => ( + + + +) diff --git a/src/pages/PoolDetails/index.test.tsx b/src/pages/PoolDetails/index.test.tsx new file mode 100644 index 0000000000..235b5969d6 --- /dev/null +++ b/src/pages/PoolDetails/index.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + waitFor(() => { + expect(screen.getByText(/not found/i)).not.toBeInTheDocument() + }) + }) + + it('pool header is displayed when data is received from thegraph', () => { + const { asFragment } = render() + 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() + }) + }) +}) diff --git a/src/pages/PoolDetails/index.tsx b/src/pages/PoolDetails/index.tsx new file mode 100644 index 0000000000..d1d7ce4622 --- /dev/null +++ b/src/pages/PoolDetails/index.tsx @@ -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 + return ( + + + + ) +} diff --git a/src/test-utils/pools/fixtures.ts b/src/test-utils/pools/fixtures.ts new file mode 100644 index 0000000000..ad59d4ddf7 --- /dev/null +++ b/src/test-utils/pools/fixtures.ts @@ -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, +}