Merge pull request #404 from blockscout/custom-select
address page: token balances
@@ -0,0 +1,29 @@
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
interface Params {
|
||||
value: string;
|
||||
exchangeRate?: string | null;
|
||||
accuracy?: number;
|
||||
accuracyUsd?: number;
|
||||
decimals?: string | null;
|
||||
}
|
||||
|
||||
export default function getCurrencyValue({ value, accuracy, accuracyUsd, decimals, exchangeRate }: Params) {
|
||||
const valueCurr = BigNumber(value).div(BigNumber(10 ** Number(decimals || '18')));
|
||||
const valueResult = accuracy ? valueCurr.dp(accuracy).toFormat() : valueCurr.toFormat();
|
||||
|
||||
let usdResult: string | undefined;
|
||||
|
||||
if (exchangeRate) {
|
||||
const exchangeRateBn = new BigNumber(exchangeRate);
|
||||
const usdBn = valueCurr.times(exchangeRateBn);
|
||||
if (accuracyUsd && !usdBn.isEqualTo(0)) {
|
||||
const usdBnDp = usdBn.dp(accuracyUsd);
|
||||
usdResult = usdBnDp.isEqualTo(0) ? usdBn.precision(accuracyUsd).toFormat() : usdBnDp.toFormat();
|
||||
} else {
|
||||
usdResult = usdBn.toFormat();
|
||||
}
|
||||
}
|
||||
|
||||
return { valueStr: valueResult, usd: usdResult };
|
||||
}
|
||||
@@ -7,6 +7,8 @@ SocketMessage.BlocksIndexStatus |
|
||||
SocketMessage.TxStatusUpdate |
|
||||
SocketMessage.NewTx |
|
||||
SocketMessage.NewPendingTx |
|
||||
SocketMessage.AddressTokenBalance |
|
||||
SocketMessage.AddressCoinBalance |
|
||||
SocketMessage.Unknown;
|
||||
|
||||
interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> {
|
||||
@@ -15,6 +17,16 @@ interface SocketMessageParamsGeneric<Event extends string | undefined, Payload e
|
||||
handler: (payload: Payload) => void;
|
||||
}
|
||||
|
||||
export interface AddressCoinBalancePayload {
|
||||
coin_balance: {
|
||||
block_number: number;
|
||||
block_timestamp?: string;
|
||||
delta?: string;
|
||||
transaction_hash?: string | null;
|
||||
value?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace SocketMessage {
|
||||
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
|
||||
@@ -22,5 +34,7 @@ export namespace SocketMessage {
|
||||
export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>;
|
||||
export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>;
|
||||
export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: number }>;
|
||||
export type AddressTokenBalance = SocketMessageParamsGeneric<'token_balance', { block_number: number }>;
|
||||
export type AddressCoinBalance = SocketMessageParamsGeneric<'coin_balance', AddressCoinBalancePayload>;
|
||||
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
|
||||
export const erc20a: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xb2a90505dc6680a7a695f7975d0d32EeF610f456',
|
||||
decimals: '18',
|
||||
exchange_rate: null,
|
||||
holders: '23',
|
||||
name: 'hyfi.token',
|
||||
symbol: 'HyFi',
|
||||
total_supply: '369000000000000000000000000',
|
||||
type: 'ERC-20',
|
||||
},
|
||||
token_id: null,
|
||||
value: '1169320000000000000000000',
|
||||
};
|
||||
|
||||
export const erc20b: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
|
||||
decimals: '6',
|
||||
exchange_rate: '0.982',
|
||||
holders: '17',
|
||||
name: 'USD Coin',
|
||||
symbol: 'USDC',
|
||||
total_supply: '900000000000000000000000000',
|
||||
type: 'ERC-20',
|
||||
},
|
||||
token_id: null,
|
||||
value: '872500000000',
|
||||
};
|
||||
|
||||
export const erc20c: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
|
||||
decimals: '18',
|
||||
exchange_rate: '1328.89',
|
||||
holders: '17',
|
||||
name: 'Ethereum',
|
||||
symbol: 'ETH',
|
||||
total_supply: '1000000000000000000000000',
|
||||
type: 'ERC-20',
|
||||
},
|
||||
token_id: null,
|
||||
value: '9852000000000000000000',
|
||||
};
|
||||
|
||||
export const erc20d: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
|
||||
decimals: '18',
|
||||
exchange_rate: null,
|
||||
holders: '102625',
|
||||
name: 'Zeta',
|
||||
symbol: 'ZETA',
|
||||
total_supply: '2100000000000000000000000000',
|
||||
type: 'ERC-20',
|
||||
},
|
||||
token_id: null,
|
||||
value: '39000000000000000000',
|
||||
};
|
||||
|
||||
export const erc721a: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xDe7cAc71E072FCBd4453E5FB3558C2684d1F88A0',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '7',
|
||||
name: 'HyFi Athena',
|
||||
symbol: 'HYFI_ATHENA',
|
||||
total_supply: '105',
|
||||
type: 'ERC-721',
|
||||
},
|
||||
token_id: null,
|
||||
value: '51',
|
||||
};
|
||||
|
||||
export const erc721b: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xA8d5C7beEA8C9bB57f5fBa35fB638BF45550b11F',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '2',
|
||||
name: 'World Of Women Galaxy',
|
||||
symbol: 'WOWG',
|
||||
total_supply: null,
|
||||
type: 'ERC-721',
|
||||
},
|
||||
token_id: null,
|
||||
value: '1',
|
||||
};
|
||||
|
||||
export const erc721c: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4992',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '12',
|
||||
name: 'Puma',
|
||||
symbol: 'PUMA',
|
||||
total_supply: null,
|
||||
type: 'ERC-721',
|
||||
},
|
||||
token_id: null,
|
||||
value: '5',
|
||||
};
|
||||
|
||||
export const erc1155a: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '22',
|
||||
name: 'HyFi Membership',
|
||||
symbol: 'HYFI_MEMBERSHIP',
|
||||
total_supply: '482',
|
||||
type: 'ERC-1155',
|
||||
},
|
||||
token_id: '42',
|
||||
value: '24',
|
||||
};
|
||||
|
||||
export const erc1155b: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0xf4b71b179132ad457f6bcae2a55efa9e4b26eefc',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '100',
|
||||
name: 'WinkyVerse Collections',
|
||||
symbol: 'WVC',
|
||||
total_supply: '4943',
|
||||
type: 'ERC-1155',
|
||||
},
|
||||
token_id: '100010000000001',
|
||||
value: '11',
|
||||
};
|
||||
|
||||
export const erc1155withoutName: AddressTokenBalance = {
|
||||
token: {
|
||||
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
|
||||
decimals: null,
|
||||
exchange_rate: null,
|
||||
holders: '22',
|
||||
name: null,
|
||||
symbol: null,
|
||||
total_supply: '482',
|
||||
type: 'ERC-1155',
|
||||
},
|
||||
token_id: '64532245',
|
||||
value: '42',
|
||||
};
|
||||
|
||||
export const baseList = [
|
||||
erc20a,
|
||||
erc20b,
|
||||
erc20c,
|
||||
erc721a,
|
||||
erc721b,
|
||||
erc721c,
|
||||
erc1155withoutName,
|
||||
erc1155a,
|
||||
erc1155b,
|
||||
];
|
||||
@@ -29,6 +29,7 @@ export const erc20: TokenTransfer = {
|
||||
name: 'ARIANEE',
|
||||
symbol: 'ARIA',
|
||||
type: 'ERC-20',
|
||||
total_supply: '0',
|
||||
},
|
||||
total: {
|
||||
decimals: '18',
|
||||
@@ -67,6 +68,7 @@ export const erc721: TokenTransfer = {
|
||||
name: 'Arianee Smart-Asset',
|
||||
symbol: 'AriaSA',
|
||||
type: 'ERC-721',
|
||||
total_supply: '0',
|
||||
},
|
||||
total: {
|
||||
token_id: '875879856',
|
||||
@@ -104,6 +106,7 @@ export const erc1155: TokenTransfer = {
|
||||
name: null,
|
||||
symbol: null,
|
||||
type: 'ERC-1155',
|
||||
total_supply: '0',
|
||||
},
|
||||
total: {
|
||||
token_id: '123',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TestFixture, Page } from '@playwright/test';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import type { AddressCoinBalancePayload } from 'lib/socket/types';
|
||||
import type { NewBlockSocketResponse } from 'types/api/block';
|
||||
|
||||
type ReturnType = () => Promise<WebSocket>;
|
||||
@@ -53,6 +54,8 @@ export const joinChannel = async(socket: WebSocket, channelName: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'coin_balance', payload: AddressCoinBalancePayload): void;
|
||||
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_balance', payload: { block_number: number }): void;
|
||||
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
|
||||
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
|
||||
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
|
||||
|
||||
@@ -77,11 +77,17 @@ const variantOutline = defineStyle((props) => {
|
||||
bg: props.isActive ? activeBg : 'transparent',
|
||||
borderColor: props.isActive ? activeBg : 'blue.400',
|
||||
color: props.isActive ? activeColor : 'blue.400',
|
||||
p: {
|
||||
color: 'blue.400',
|
||||
},
|
||||
},
|
||||
_disabled: {
|
||||
color,
|
||||
borderColor,
|
||||
},
|
||||
p: {
|
||||
color: 'blue.400',
|
||||
},
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.2,
|
||||
@@ -94,6 +100,9 @@ const variantOutline = defineStyle((props) => {
|
||||
color,
|
||||
borderColor,
|
||||
},
|
||||
p: {
|
||||
color: activeColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ export interface Address {
|
||||
}
|
||||
|
||||
export interface AddressCounters {
|
||||
transaction_count: string;
|
||||
token_transfer_count: string;
|
||||
transactions_count: string;
|
||||
token_transfers_count: string;
|
||||
gas_usage_count: string;
|
||||
validation_count: string | null;
|
||||
validations_count: string | null;
|
||||
}
|
||||
|
||||
export interface AddressTokenBalance {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface TokenInfo {
|
||||
decimals: string | null;
|
||||
holders: string | null;
|
||||
exchange_rate: string | null;
|
||||
total_supply: string | null;
|
||||
}
|
||||
|
||||
export type TokenInfoGeneric<Type extends TokenType> = Omit<TokenInfo, 'type'> & { type: Type };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Flex, Text, Icon, Button, Grid, Select } from '@chakra-ui/react';
|
||||
import { Box, Flex, Text, Icon, Button, Grid } from '@chakra-ui/react';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import BigNumber from 'bignumber.js';
|
||||
@@ -12,15 +12,18 @@ import appConfig from 'configs/app/config';
|
||||
import metamaskIcon from 'icons/metamask.svg';
|
||||
import qrCodeIcon from 'icons/qr_code.svg';
|
||||
import starOutlineIcon from 'icons/star_outline.svg';
|
||||
import walletIcon from 'icons/wallet.svg';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import AddressIcon from 'ui/shared/address/AddressIcon';
|
||||
import CopyToClipboard from 'ui/shared/CopyToClipboard';
|
||||
import DataFetchAlert from 'ui/shared/DataFetchAlert';
|
||||
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
|
||||
import ExternalLink from 'ui/shared/ExternalLink';
|
||||
import HashStringShorten from 'ui/shared/HashStringShorten';
|
||||
|
||||
import AddressDetailsSkeleton from './details/AddressDetailsSkeleton';
|
||||
import TokenSelect from './tokenSelect/TokenSelect';
|
||||
|
||||
interface Props {
|
||||
addressQuery: UseQueryResult<TAddress>;
|
||||
}
|
||||
@@ -47,14 +50,15 @@ const AddressDetails = ({ addressQuery }: Props) => {
|
||||
);
|
||||
|
||||
if (countersQuery.isLoading || addressQuery.isLoading || tokenBalancesQuery.isLoading) {
|
||||
return <Box>loading</Box>;
|
||||
return <AddressDetailsSkeleton/>;
|
||||
}
|
||||
|
||||
if (countersQuery.isError || addressQuery.isError || tokenBalancesQuery.isError) {
|
||||
return <Box>error</Box>;
|
||||
return <DataFetchAlert/>;
|
||||
}
|
||||
|
||||
const explorers = appConfig.network.explorers.filter(({ paths }) => paths.address);
|
||||
const validationsCount = Number(countersQuery.data.validations_count);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -74,7 +78,7 @@ const AddressDetails = ({ addressQuery }: Props) => {
|
||||
</Flex>
|
||||
{ explorers.length > 0 && (
|
||||
<Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap">
|
||||
<Text>Verify with other explorers</Text>
|
||||
<Text fontSize="sm">Verify with other explorers</Text>
|
||||
{ explorers.map((explorer) => {
|
||||
const url = new URL(explorer.paths.tx + '/' + router.query.id, explorer.baseUrl);
|
||||
return <ExternalLink key={ explorer.baseUrl } title={ explorer.title } href={ url.toString() }/>;
|
||||
@@ -91,39 +95,21 @@ const AddressDetails = ({ addressQuery }: Props) => {
|
||||
title="Tokens"
|
||||
hint="All tokens in the account and total value."
|
||||
alignSelf="center"
|
||||
py="2px"
|
||||
>
|
||||
{ tokenBalancesQuery.data.length > 0 ? (
|
||||
<>
|
||||
{ /* TODO will be fixed later when we implement select with custom menu */ }
|
||||
<Select
|
||||
size="sm"
|
||||
borderRadius="base"
|
||||
focusBorderColor="none"
|
||||
display="inline-block"
|
||||
w="auto"
|
||||
>
|
||||
{ tokenBalancesQuery.data.map((token) =>
|
||||
<option key={ token.token.address } value={ token.token.address }>{ token.token.symbol }</option>) }
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" ml={ 3 }>
|
||||
<Icon as={ walletIcon } boxSize={ 5 }/>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
) }
|
||||
<TokenSelect/>
|
||||
</DetailsInfoItem>
|
||||
<DetailsInfoItem
|
||||
title="Transactions"
|
||||
hint="Number of transactions related to this address."
|
||||
>
|
||||
{ Number(countersQuery.data.transaction_count).toLocaleString() }
|
||||
{ Number(countersQuery.data.transactions_count).toLocaleString() }
|
||||
</DetailsInfoItem>
|
||||
<DetailsInfoItem
|
||||
title="Transfers"
|
||||
hint="Number of transfers to/from this address."
|
||||
>
|
||||
{ Number(countersQuery.data.token_transfer_count).toLocaleString() }
|
||||
{ Number(countersQuery.data.token_transfers_count).toLocaleString() }
|
||||
</DetailsInfoItem>
|
||||
<DetailsInfoItem
|
||||
title="Gas used"
|
||||
@@ -131,12 +117,12 @@ const AddressDetails = ({ addressQuery }: Props) => {
|
||||
>
|
||||
{ BigNumber(countersQuery.data.gas_usage_count).toFormat() }
|
||||
</DetailsInfoItem>
|
||||
{ countersQuery.data.validation_count && (
|
||||
{ !Object.is(validationsCount, NaN) && validationsCount > 0 && (
|
||||
<DetailsInfoItem
|
||||
title="Blocks validated"
|
||||
hint="Number of blocks validated by this validator."
|
||||
>
|
||||
{ Number(countersQuery.data.validation_count).toLocaleString() }
|
||||
{ validationsCount.toLocaleString() }
|
||||
</DetailsInfoItem>
|
||||
) }
|
||||
</Grid>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Box, Flex, Grid, Skeleton, SkeletonCircle } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
|
||||
|
||||
const AddressDetailsSkeleton = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="center">
|
||||
<SkeletonCircle boxSize={ 6 } flexShrink={ 0 }/>
|
||||
<Skeleton h={ 6 } w="420px" ml={ 2 }/>
|
||||
<Skeleton h={ 6 } w="24px" ml={ 2 } flexShrink={ 0 }/>
|
||||
<Skeleton h={ 8 } w="48px" ml={ 3 } flexShrink={ 0 }/>
|
||||
<Skeleton h={ 8 } w="48px" ml={ 3 } flexShrink={ 0 }/>
|
||||
</Flex>
|
||||
<Flex align="center" columnGap={ 4 } mt={ 8 }>
|
||||
<Skeleton h={ 6 } w="200px"/>
|
||||
<Skeleton h={ 6 } w="80px"/>
|
||||
</Flex>
|
||||
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '150px 1fr' }} maxW="1000px" mt={ 8 }>
|
||||
<DetailsSkeletonRow w="30%"/>
|
||||
<DetailsSkeletonRow w="10%"/>
|
||||
<DetailsSkeletonRow w="10%"/>
|
||||
<DetailsSkeletonRow w="20%"/>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddressDetailsSkeleton;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
import { QueryKeys } from 'types/client/queries';
|
||||
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
|
||||
const MockAddressPage = ({ children }: { children: JSX.Element }): JSX.Element => {
|
||||
const router = useRouter();
|
||||
const fetch = useFetch();
|
||||
|
||||
const { data } = useQuery(
|
||||
[ QueryKeys.address, router.query.id ],
|
||||
async() => await fetch(`/node-api/addresses/${ router.query.id }`),
|
||||
{
|
||||
enabled: Boolean(router.query.id),
|
||||
},
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return <div/>;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default MockAddressPage;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
|
||||
import React from 'react';
|
||||
|
||||
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
|
||||
import * as socketServer from 'playwright/fixtures/socketServer';
|
||||
import TestApp from 'playwright/TestApp';
|
||||
import MockAddressPage from 'ui/address/testUtils/MockAddressPage';
|
||||
|
||||
import TokenSelect from './TokenSelect';
|
||||
|
||||
const ASSET_URL = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/poa/assets/0xb2a90505dc6680a7a695f7975d0d32EeF610f456/logo.png';
|
||||
const TOKENS_API_URL = '/node-api/addresses/1/token-balances';
|
||||
const ADDRESS_API_URL = '/node-api/addresses/1';
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { id: '1' },
|
||||
},
|
||||
};
|
||||
const CLIPPING_AREA = { x: 0, y: 0, width: 360, height: 500 };
|
||||
|
||||
const test = base.extend({
|
||||
page: async({ page }, use) => {
|
||||
await page.route(ASSET_URL, (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
path: './playwright/image_s.jpg',
|
||||
});
|
||||
});
|
||||
await page.route(ADDRESS_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ hash: '1' }),
|
||||
}), { times: 1 });
|
||||
await page.route(TOKENS_API_URL, async(route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(tokenBalanceMock.baseList),
|
||||
}), { times: 1 });
|
||||
|
||||
use(page);
|
||||
},
|
||||
});
|
||||
|
||||
test('base view +@dark-mode', async({ mount, page }) => {
|
||||
await mount(
|
||||
<TestApp>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /select/i }).click();
|
||||
await page.getByText('USD Coin').hover();
|
||||
|
||||
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
|
||||
|
||||
await page.mouse.move(100, 200);
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
|
||||
});
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
|
||||
|
||||
test('base view', async({ mount, page }) => {
|
||||
await mount(
|
||||
<TestApp>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: /select/i }).click();
|
||||
await page.getByText('USD Coin').hover();
|
||||
|
||||
await expect(page).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('sort', async({ mount, page }) => {
|
||||
await mount(
|
||||
<TestApp>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
await page.getByRole('button', { name: /select/i }).click();
|
||||
await page.locator('a[aria-label="Sort ERC-20 tokens"]').click();
|
||||
|
||||
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
|
||||
|
||||
await page.mouse.move(100, 200);
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await page.locator('a[aria-label="Sort ERC-1155 tokens"]').click();
|
||||
|
||||
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
|
||||
});
|
||||
|
||||
test('filter', async({ mount, page }) => {
|
||||
await mount(
|
||||
<TestApp>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
await page.getByRole('button', { name: /select/i }).click();
|
||||
await page.getByPlaceholder('Search by token name').type('c');
|
||||
|
||||
await expect(page).toHaveScreenshot({ clip: CLIPPING_AREA });
|
||||
});
|
||||
|
||||
test.describe('socket', () => {
|
||||
const testWithSocket = test.extend<socketServer.SocketServerFixture>({
|
||||
createSocket: socketServer.createSocket,
|
||||
});
|
||||
testWithSocket.describe.configure({ mode: 'serial' });
|
||||
|
||||
testWithSocket('new item after token balance update', async({ page, mount, createSocket }) => {
|
||||
await mount(
|
||||
<TestApp withSocket>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
await page.route(TOKENS_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
...tokenBalanceMock.baseList,
|
||||
tokenBalanceMock.erc20d,
|
||||
]),
|
||||
}));
|
||||
|
||||
const socket = await createSocket();
|
||||
const channel = await socketServer.joinChannel(socket, 'addresses:1');
|
||||
socketServer.sendMessage(socket, channel, 'token_balance', {
|
||||
block_number: 1,
|
||||
});
|
||||
|
||||
const button = page.getByRole('button', { name: /select/i });
|
||||
const text = await button.innerText();
|
||||
expect(text).toContain('10');
|
||||
});
|
||||
|
||||
testWithSocket('new item after coin balance update', async({ page, mount, createSocket }) => {
|
||||
await mount(
|
||||
<TestApp withSocket>
|
||||
<MockAddressPage>
|
||||
<Flex>
|
||||
<TokenSelect/>
|
||||
</Flex>
|
||||
</MockAddressPage>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
await page.route(TOKENS_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
...tokenBalanceMock.baseList,
|
||||
tokenBalanceMock.erc20d,
|
||||
]),
|
||||
}));
|
||||
|
||||
const socket = await createSocket();
|
||||
const channel = await socketServer.joinChannel(socket, 'addresses:1');
|
||||
socketServer.sendMessage(socket, channel, 'coin_balance', {
|
||||
coin_balance: {
|
||||
block_number: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const button = page.getByRole('button', { name: /select/i });
|
||||
const text = await button.innerText();
|
||||
expect(text).toContain('10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Box, Icon, IconButton, Skeleton, Tooltip } from '@chakra-ui/react';
|
||||
import { useQuery, useQueryClient, useIsFetching } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
import type { SocketMessage } from 'lib/socket/types';
|
||||
import type { Address, AddressTokenBalance } from 'types/api/address';
|
||||
import { QueryKeys } from 'types/client/queries';
|
||||
|
||||
import walletIcon from 'icons/wallet.svg';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import useSocketChannel from 'lib/socket/useSocketChannel';
|
||||
import useSocketMessage from 'lib/socket/useSocketMessage';
|
||||
|
||||
import TokenSelectDesktop from './TokenSelectDesktop';
|
||||
import TokenSelectMobile from './TokenSelectMobile';
|
||||
|
||||
const TokenSelect = () => {
|
||||
const router = useRouter();
|
||||
const fetch = useFetch();
|
||||
const isMobile = useIsMobile();
|
||||
const queryClient = useQueryClient();
|
||||
const [ blockNumber, setBlockNumber ] = React.useState<number>();
|
||||
|
||||
const addressQueryData = queryClient.getQueryData<Address>([ QueryKeys.address, router.query.id ]);
|
||||
|
||||
const { data, isError, isLoading, refetch } = useQuery<unknown, unknown, Array<AddressTokenBalance>>(
|
||||
[ QueryKeys.addressTokenBalances, addressQueryData?.hash ],
|
||||
async() => await fetch(`/node-api/addresses/${ addressQueryData?.hash }/token-balances`),
|
||||
{
|
||||
enabled: Boolean(addressQueryData),
|
||||
},
|
||||
);
|
||||
const balancesIsFetching = useIsFetching({ queryKey: [ QueryKeys.addressTokenBalances, addressQueryData?.hash ] });
|
||||
|
||||
const handleTokenBalanceMessage: SocketMessage.AddressTokenBalance['handler'] = React.useCallback((payload) => {
|
||||
if (payload.block_number !== blockNumber) {
|
||||
refetch();
|
||||
setBlockNumber(payload.block_number);
|
||||
}
|
||||
}, [ blockNumber, refetch ]);
|
||||
const handleCoinBalanceMessage: SocketMessage.AddressCoinBalance['handler'] = React.useCallback((payload) => {
|
||||
if (payload.coin_balance.block_number !== blockNumber) {
|
||||
refetch();
|
||||
setBlockNumber(payload.coin_balance.block_number);
|
||||
}
|
||||
}, [ blockNumber, refetch ]);
|
||||
|
||||
const channel = useSocketChannel({
|
||||
topic: `addresses:${ addressQueryData?.hash.toLowerCase() }`,
|
||||
isDisabled: !addressQueryData,
|
||||
});
|
||||
useSocketMessage({
|
||||
channel,
|
||||
event: 'coin_balance',
|
||||
handler: handleCoinBalanceMessage,
|
||||
});
|
||||
useSocketMessage({
|
||||
channel,
|
||||
event: 'token_balance',
|
||||
handler: handleTokenBalanceMessage,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton h={ 8 } w="160px"/>;
|
||||
}
|
||||
|
||||
if (isError || data.length === 0) {
|
||||
return <Box py="6px">0</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ isMobile ?
|
||||
<TokenSelectMobile data={ data } isLoading={ balancesIsFetching === 1 }/> :
|
||||
<TokenSelectDesktop data={ data } isLoading={ balancesIsFetching === 1 }/>
|
||||
}
|
||||
<Tooltip label="Show all tokens">
|
||||
<IconButton
|
||||
aria-label="Show all tokens"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pl="6px"
|
||||
pr="6px"
|
||||
ml={ 3 }
|
||||
icon={ <Icon as={ walletIcon } boxSize={ 5 }/> }
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TokenSelect);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Box, Button, Icon, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import arrowIcon from 'icons/arrows/east-mini.svg';
|
||||
import tokensIcon from 'icons/tokens.svg';
|
||||
import { ZERO } from 'lib/consts';
|
||||
|
||||
import type { EnhancedData } from './utils';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
onClick: () => void;
|
||||
data: Array<EnhancedData>;
|
||||
}
|
||||
|
||||
const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
|
||||
const totalBn = data.reduce((result, item) => !item.usd ? result : result.plus(BigNumber(item.usd)), ZERO);
|
||||
const skeletonBgColor = useColorModeValue('white', 'black');
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (isLoading && !isOpen) {
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}, [ isLoading, isOpen, onClick ]);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Button
|
||||
ref={ ref }
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
onClick={ handleClick }
|
||||
aria-label="Token select"
|
||||
>
|
||||
<Icon as={ tokensIcon } boxSize={ 4 } mr={ 2 }/>
|
||||
<Text fontWeight={ 600 }>{ data.length }</Text>
|
||||
<Text whiteSpace="pre" variant="secondary" fontWeight={ 400 }> (${ totalBn.toFormat(2) })</Text>
|
||||
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 3 }/>
|
||||
</Button>
|
||||
{ isLoading && !isOpen && <Skeleton h="100%" w="100%" position="absolute" top={ 0 } left={ 0 } bgColor={ skeletonBgColor }/> }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.forwardRef(TokenSelectButton);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useColorModeValue, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
|
||||
import TokenSelectButton from './TokenSelectButton';
|
||||
import TokenSelectMenu from './TokenSelectMenu';
|
||||
import useTokenSelect from './useTokenSelect';
|
||||
|
||||
interface Props {
|
||||
data: Array<AddressTokenBalance>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const TokenSelectDesktop = ({ data, isLoading }: Props) => {
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.900');
|
||||
|
||||
const result = useTokenSelect(data);
|
||||
|
||||
return (
|
||||
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
|
||||
<PopoverTrigger>
|
||||
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.modifiedData } isLoading={ isLoading }/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent w="355px" maxH="450px" overflowY="scroll">
|
||||
<PopoverBody px={ 4 } py={ 6 } bgColor={ bgColor } boxShadow="2xl" >
|
||||
<TokenSelectMenu { ...result }/>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TokenSelectDesktop);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Flex, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import link from 'lib/link/link';
|
||||
import HashStringShorten from 'ui/shared/HashStringShorten';
|
||||
import TokenLogo from 'ui/shared/TokenLogo';
|
||||
|
||||
import type { EnhancedData } from './utils';
|
||||
|
||||
interface Props {
|
||||
data: EnhancedData;
|
||||
}
|
||||
|
||||
const TokenSelectItem = ({ data }: Props) => {
|
||||
|
||||
const secondRow = (() => {
|
||||
switch (data.token.type) {
|
||||
case 'ERC-20': {
|
||||
const tokenDecimals = Number(data.token.decimals) || 18;
|
||||
return (
|
||||
<>
|
||||
<Text >{ BigNumber(data.value).dividedBy(10 ** tokenDecimals).toFormat(2) } { data.token.symbol }</Text>
|
||||
{ data.token.exchange_rate && <Text >@{ data.token.exchange_rate }</Text> }
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'ERC-721': {
|
||||
return <Text >{ BigNumber(data.value).toFormat() } { data.token.symbol }</Text>;
|
||||
}
|
||||
case 'ERC-1155': {
|
||||
return (
|
||||
<>
|
||||
<Text >#{ data.token_id || 0 }</Text>
|
||||
<Text >{ BigNumber(data.value).toFormat() }</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// TODO add filter param when token page is ready
|
||||
const url = link('token_index', { hash: data.token.address });
|
||||
|
||||
return (
|
||||
<Flex
|
||||
px={ 1 }
|
||||
py="10px"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
rowGap={ 2 }
|
||||
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
|
||||
borderBottomWidth="1px"
|
||||
_hover={{
|
||||
bgColor: useColorModeValue('blue.50', 'gray.800'),
|
||||
}}
|
||||
fontSize="sm"
|
||||
cursor="pointer"
|
||||
as="a"
|
||||
href={ url }
|
||||
>
|
||||
<Flex alignItems="center" w="100%">
|
||||
<TokenLogo hash={ data.token.address } name={ data.token.name } boxSize={ 6 }/>
|
||||
<Text fontWeight={ 700 } ml={ 2 }>{ data.token.name || <HashStringShorten hash={ data.token.address }/> }</Text>
|
||||
{ data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
|
||||
</Flex>
|
||||
<Flex alignItems="center" justifyContent="space-between" w="100%">
|
||||
{ secondRow }
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TokenSelectItem);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Icon, Text, Box, Input, InputGroup, InputLeftElement, useColorModeValue, Flex, Link } from '@chakra-ui/react';
|
||||
import type { Dictionary } from 'lodash';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { TokenType } from 'types/api/tokenInfo';
|
||||
|
||||
import arrowIcon from 'icons/arrows/east.svg';
|
||||
import searchIcon from 'icons/search.svg';
|
||||
|
||||
import TokenSelectItem from './TokenSelectItem';
|
||||
import type { Sort, EnhancedData } from './utils';
|
||||
import { sortTokenGroups, sortingFns } from './utils';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
erc20sort: Sort;
|
||||
erc1155sort: Sort;
|
||||
modifiedData: Array<EnhancedData>;
|
||||
groupedData: Dictionary<Array<EnhancedData>>;
|
||||
onInputChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSortClick: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
const TokenSelectMenu = ({ erc20sort, erc1155sort, modifiedData, groupedData, onInputChange, onSortClick, searchTerm }: Props) => {
|
||||
const searchIconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
|
||||
const inputBorderColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup size="xs" mb={ 5 }>
|
||||
<InputLeftElement >
|
||||
<Icon as={ searchIcon } boxSize={ 4 } color={ searchIconColor }/>
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
paddingInlineStart="38px"
|
||||
placeholder="Search by token name"
|
||||
ml="1px"
|
||||
onChange={ onInputChange }
|
||||
borderColor={ inputBorderColor }
|
||||
/>
|
||||
</InputGroup>
|
||||
<Flex flexDir="column" rowGap={ 6 }>
|
||||
{ Object.entries(groupedData).sort(sortTokenGroups).map(([ tokenType, tokenInfo ]) => {
|
||||
const type = tokenType as TokenType;
|
||||
const arrowTransform = (type === 'ERC-1155' && erc1155sort === 'desc') || (type === 'ERC-20' && erc20sort === 'desc') ?
|
||||
'rotate(90deg)' :
|
||||
'rotate(-90deg)';
|
||||
const sortDirection: Sort = (() => {
|
||||
switch (type) {
|
||||
case 'ERC-1155':
|
||||
return erc1155sort;
|
||||
case 'ERC-20':
|
||||
return erc20sort;
|
||||
default:
|
||||
return 'desc';
|
||||
}
|
||||
})();
|
||||
const hasSort = type === 'ERC-1155' || (type === 'ERC-20' && tokenInfo.some(({ usd }) => usd));
|
||||
|
||||
return (
|
||||
<Box key={ type }>
|
||||
<Flex justifyContent="space-between">
|
||||
<Text mb={ 3 } color="gray.500" fontWeight={ 600 } fontSize="sm">{ type } tokens ({ tokenInfo.length })</Text>
|
||||
{ hasSort && (
|
||||
<Link data-type={ type } onClick={ onSortClick } aria-label={ `Sort ${ type } tokens` }>
|
||||
<Icon as={ arrowIcon } boxSize={ 5 } transform={ arrowTransform } transitionDuration="faster"/>
|
||||
</Link>
|
||||
) }
|
||||
</Flex>
|
||||
{ tokenInfo.sort(sortingFns[type](sortDirection)).map((data) => <TokenSelectItem key={ data.token.address + data.token_id } data={ data }/>) }
|
||||
</Box>
|
||||
);
|
||||
}) }
|
||||
</Flex>
|
||||
{ modifiedData.length === 0 && searchTerm && <Text fontSize="sm">Could not find any matches.</Text> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TokenSelectMenu);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useDisclosure, Modal, ModalContent, ModalCloseButton } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
|
||||
import TokenSelectButton from './TokenSelectButton';
|
||||
import TokenSelectMenu from './TokenSelectMenu';
|
||||
import useTokenSelect from './useTokenSelect';
|
||||
|
||||
interface Props {
|
||||
data: Array<AddressTokenBalance>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const TokenSelectMobile = ({ data, isLoading }: Props) => {
|
||||
const { isOpen, onToggle, onClose } = useDisclosure();
|
||||
const result = useTokenSelect(data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.modifiedData } isLoading={ isLoading }/>
|
||||
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
|
||||
<ModalContent>
|
||||
<ModalCloseButton/>
|
||||
<TokenSelectMenu { ...result }/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TokenSelectMobile);
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,45 @@
|
||||
import _groupBy from 'lodash/groupBy';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
|
||||
import type { Sort } from './utils';
|
||||
import { calculateUsdValue, filterTokens } from './utils';
|
||||
|
||||
export default function useData(data: Array<AddressTokenBalance>) {
|
||||
const [ searchTerm, setSearchTerm ] = React.useState('');
|
||||
const [ erc1155sort, setErc1155Sort ] = React.useState<Sort>('desc');
|
||||
const [ erc20sort, setErc20Sort ] = React.useState<Sort>('desc');
|
||||
|
||||
const onInputChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(event.target.value);
|
||||
}, []);
|
||||
|
||||
const onSortClick = React.useCallback((event: React.SyntheticEvent) => {
|
||||
const tokenType = (event.currentTarget as HTMLAnchorElement).getAttribute('data-type');
|
||||
if (tokenType === 'ERC-1155') {
|
||||
setErc1155Sort((prevValue) => prevValue === 'desc' ? 'asc' : 'desc');
|
||||
}
|
||||
if (tokenType === 'ERC-20') {
|
||||
setErc20Sort((prevValue) => prevValue === 'desc' ? 'asc' : 'desc');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modifiedData = React.useMemo(() => {
|
||||
return data.filter(filterTokens(searchTerm.toLowerCase())).map(calculateUsdValue);
|
||||
}, [ data, searchTerm ]);
|
||||
const groupedData = React.useMemo(() => {
|
||||
return _groupBy(modifiedData, 'token.type');
|
||||
}, [ modifiedData ]);
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
erc20sort,
|
||||
erc1155sort,
|
||||
onInputChange,
|
||||
onSortClick,
|
||||
modifiedData,
|
||||
groupedData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
import type { AddressTokenBalance } from 'types/api/address';
|
||||
import type { TokenType } from 'types/api/tokenInfo';
|
||||
|
||||
export type EnhancedData = AddressTokenBalance & {
|
||||
usd?: BigNumber ;
|
||||
}
|
||||
|
||||
export type Sort = 'desc' | 'asc';
|
||||
const TOKEN_GROUPS_ORDER: Array<TokenType> = [ 'ERC-20', 'ERC-721', 'ERC-1155' ];
|
||||
type TokenGroup = [string, Array<AddressTokenBalance>];
|
||||
|
||||
export const sortTokenGroups = (groupA: TokenGroup, groupB: TokenGroup) => {
|
||||
return TOKEN_GROUPS_ORDER.indexOf(groupA[0] as TokenType) > TOKEN_GROUPS_ORDER.indexOf(groupB[0] as TokenType) ? 1 : -1;
|
||||
};
|
||||
|
||||
const sortErc1155Tokens = (sort: Sort) => (dataA: AddressTokenBalance, dataB: AddressTokenBalance) => {
|
||||
if (dataA.value === dataB.value) {
|
||||
return 0;
|
||||
}
|
||||
if (sort === 'desc') {
|
||||
return Number(dataA.value) > Number(dataB.value) ? -1 : 1;
|
||||
}
|
||||
|
||||
return Number(dataA.value) > Number(dataB.value) ? 1 : -1;
|
||||
};
|
||||
const sortErc20Tokens = (sort: Sort) => (dataA: EnhancedData, dataB: EnhancedData) => {
|
||||
if (!dataA.usd && !dataB.usd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// keep tokens without usd value in the end of the group
|
||||
if (!dataB.usd) {
|
||||
return -1;
|
||||
}
|
||||
if (!dataA.usd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (sort === 'desc') {
|
||||
return dataA.usd.gt(dataB.usd) ? -1 : 1;
|
||||
}
|
||||
|
||||
return dataA.usd.gt(dataB.usd) ? 1 : -1;
|
||||
};
|
||||
|
||||
const sortErc721Tokens = () => () => 0;
|
||||
|
||||
export const sortingFns = {
|
||||
'ERC-20': sortErc20Tokens,
|
||||
'ERC-721': sortErc721Tokens,
|
||||
'ERC-1155': sortErc1155Tokens,
|
||||
};
|
||||
|
||||
export const filterTokens = (searchTerm: string) => ({ token }: AddressTokenBalance) => {
|
||||
if (!token.name) {
|
||||
return !searchTerm ? true : token.address.toLowerCase().includes(searchTerm);
|
||||
}
|
||||
|
||||
return token.name?.toLowerCase().includes(searchTerm);
|
||||
};
|
||||
|
||||
export const calculateUsdValue = (data: AddressTokenBalance): EnhancedData => {
|
||||
if (data.token.type !== 'ERC-20') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const exchangeRate = data.token.exchange_rate;
|
||||
if (!exchangeRate) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const decimals = Number(data.token.decimals || '18');
|
||||
return {
|
||||
...data,
|
||||
usd: BigNumber(data.value).div(BigNumber(10 ** decimals)).multipliedBy(BigNumber(exchangeRate)),
|
||||
};
|
||||
};
|
||||
@@ -1,35 +1,25 @@
|
||||
import { Grid, GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
|
||||
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const SkeletonRow = ({ w = '100%' }: { w?: string }) => (
|
||||
<>
|
||||
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
|
||||
<SkeletonCircle h={ 5 } w={ 5 }/>
|
||||
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
|
||||
</GridItem>
|
||||
<GridItem pl={{ base: 7, lg: 0 }}>
|
||||
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/>
|
||||
</GridItem>
|
||||
</>
|
||||
);
|
||||
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
|
||||
|
||||
const BlockDetailsSkeleton = () => {
|
||||
const sectionGap = <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>;
|
||||
|
||||
return (
|
||||
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
|
||||
<SkeletonRow w="25%"/>
|
||||
<SkeletonRow w="25%"/>
|
||||
<SkeletonRow w="65%"/>
|
||||
<SkeletonRow w="25%"/>
|
||||
<SkeletonRow/>
|
||||
<SkeletonRow/>
|
||||
<DetailsSkeletonRow w="25%"/>
|
||||
<DetailsSkeletonRow w="25%"/>
|
||||
<DetailsSkeletonRow w="65%"/>
|
||||
<DetailsSkeletonRow w="25%"/>
|
||||
<DetailsSkeletonRow/>
|
||||
<DetailsSkeletonRow/>
|
||||
{ sectionGap }
|
||||
<SkeletonRow w="50%"/>
|
||||
<SkeletonRow w="25%"/>
|
||||
<SkeletonRow w="50%"/>
|
||||
<SkeletonRow w="50%"/>
|
||||
<SkeletonRow w="50%"/>
|
||||
<DetailsSkeletonRow w="50%"/>
|
||||
<DetailsSkeletonRow w="25%"/>
|
||||
<DetailsSkeletonRow w="50%"/>
|
||||
<DetailsSkeletonRow w="50%"/>
|
||||
<DetailsSkeletonRow w="50%"/>
|
||||
{ sectionGap }
|
||||
<GridItem colSpan={{ base: undefined, lg: 2 }}>
|
||||
<Skeleton h={ 5 } borderRadius="full" w="100px"/>
|
||||
|
||||
@@ -17,7 +17,7 @@ const hooksConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export const test = base.extend<socketServer.SocketServerFixture>({
|
||||
const test = base.extend<socketServer.SocketServerFixture>({
|
||||
createSocket: socketServer.createSocket,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Text, chakra } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import getCurrencyValue from 'lib/getCurrencyValue';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
currency?: string;
|
||||
@@ -20,32 +21,14 @@ const CurrencyValue = ({ value, currency = '', decimals, exchangeRate, className
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const valueCurr = BigNumber(value).div(BigNumber(10 ** Number(decimals || '18')));
|
||||
const valueResult = accuracy ? valueCurr.dp(accuracy).toFormat() : valueCurr.toFormat();
|
||||
|
||||
let usdContent;
|
||||
if (exchangeRate !== undefined && exchangeRate !== null) {
|
||||
const exchangeRateBn = new BigNumber(exchangeRate);
|
||||
const usdBn = valueCurr.times(exchangeRateBn);
|
||||
let usdResult: string;
|
||||
if (accuracyUsd && !usdBn.isEqualTo(0)) {
|
||||
const usdBnDp = usdBn.dp(accuracyUsd);
|
||||
usdResult = usdBnDp.isEqualTo(0) ? usdBn.precision(accuracyUsd).toFormat() : usdBnDp.toFormat();
|
||||
} else {
|
||||
usdResult = usdBn.toFormat();
|
||||
}
|
||||
|
||||
usdContent = (
|
||||
<Text as="span" variant="secondary" fontWeight={ 400 }>(${ usdResult })</Text>
|
||||
);
|
||||
}
|
||||
const { valueStr: valueResult, usd: usdResult } = getCurrencyValue({ value, accuracy, accuracyUsd, exchangeRate, decimals });
|
||||
|
||||
return (
|
||||
<Box as="span" className={ className } display="inline-flex" rowGap={ 3 } columnGap={ 1 }>
|
||||
<Text display="inline-block">
|
||||
{ valueResult }{ currency ? ` ${ currency }` : '' }
|
||||
</Text>
|
||||
{ usdContent }
|
||||
{ usdResult && <Text as="span" variant="secondary" fontWeight={ 400 }>(${ usdResult })</Text> }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ const TokenLogo = ({ hash, name, className }: Props) => {
|
||||
|
||||
return (
|
||||
<Image
|
||||
borderRadius="base"
|
||||
className={ className }
|
||||
src={ logoSrc }
|
||||
alt={ `${ name || 'token' } logo` }
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,18 @@
|
||||
import { GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const DetailsSkeletonRow = ({ w = '100%' }: { w?: string }) => {
|
||||
return (
|
||||
<>
|
||||
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
|
||||
<SkeletonCircle h={ 5 } w={ 5 }/>
|
||||
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
|
||||
</GridItem>
|
||||
<GridItem pl={{ base: 7, lg: 0 }}>
|
||||
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/>
|
||||
</GridItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailsSkeletonRow;
|
||||
@@ -26,7 +26,7 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
await page.waitForResponse(API_URL),
|
||||
await page.waitForResponse(API_URL);
|
||||
await page.getByText('View details').click();
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { Grid, GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
|
||||
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const SkeletonRow = ({ w = '100%' }: { w?: string }) => (
|
||||
<>
|
||||
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
|
||||
<SkeletonCircle h={ 5 } w={ 5 }/>
|
||||
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
|
||||
</GridItem>
|
||||
<GridItem pl={{ base: 7, lg: 0 }}>
|
||||
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/>
|
||||
</GridItem>
|
||||
</>
|
||||
);
|
||||
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
|
||||
|
||||
const TxDetailsSkeleton = () => {
|
||||
const sectionGap = <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>;
|
||||
|
||||
return (
|
||||
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
|
||||
<SkeletonRow/>
|
||||
<SkeletonRow w="20%"/>
|
||||
<SkeletonRow w="50%"/>
|
||||
<SkeletonRow/>
|
||||
<SkeletonRow w="70%"/>
|
||||
<SkeletonRow w="70%"/>
|
||||
<DetailsSkeletonRow/>
|
||||
<DetailsSkeletonRow w="20%"/>
|
||||
<DetailsSkeletonRow w="50%"/>
|
||||
<DetailsSkeletonRow/>
|
||||
<DetailsSkeletonRow w="70%"/>
|
||||
<DetailsSkeletonRow w="70%"/>
|
||||
{ sectionGap }
|
||||
<SkeletonRow w="40%"/>
|
||||
<SkeletonRow w="40%"/>
|
||||
<SkeletonRow w="40%"/>
|
||||
<SkeletonRow w="40%"/>
|
||||
<SkeletonRow w="40%"/>
|
||||
<DetailsSkeletonRow w="40%"/>
|
||||
<DetailsSkeletonRow w="40%"/>
|
||||
<DetailsSkeletonRow w="40%"/>
|
||||
<DetailsSkeletonRow w="40%"/>
|
||||
<DetailsSkeletonRow w="40%"/>
|
||||
{ sectionGap }
|
||||
<GridItem colSpan={{ base: undefined, lg: 2 }}>
|
||||
<Skeleton h={ 5 } borderRadius="full" w="100px"/>
|
||||
|
||||