Commit 88e1b433 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #687 from blockscout/nft-metadata

NFT metadata
parents 7a2d5858 52fab45e
import _capitalize from 'lodash/capitalize';
import type { Metadata } from 'types/client/token';
import dayjs from 'lib/date/dayjs';
function formatValue(value: string | number, display: string | undefined): string {
// https://docs.opensea.io/docs/metadata-standards#attributes
switch (display) {
case 'boost_number': {
return `+${ value } boost`;
}
case 'boost_percentage': {
return `${ value }% boost`;
}
case 'date': {
return dayjs(value).format('YYYY-MM-DD');
}
default: {
return String(value);
}
}
}
export default function attributesParser(attributes: Array<unknown>): Metadata['attributes'] {
return attributes
.map((item) => {
if (typeof item !== 'object' || !item) {
return;
}
const value = 'value' in item && (typeof item.value === 'string' || typeof item.value === 'number') ? item.value : undefined;
const trait = 'trait_type' in item && typeof item.trait_type === 'string' ? item.trait_type : undefined;
const display = 'display_type' in item && typeof item.display_type === 'string' ? item.display_type : undefined;
if (!value) {
return;
}
return {
value: formatValue(value, display),
trait_type: _capitalize(trait || 'property'),
};
})
.filter(Boolean);
}
import type { TokenInstance } from 'types/api/token';
import type { Metadata } from 'types/client/token';
import attributesParser from './metadata/attributesParser';
export default function parseMetadata(raw: TokenInstance['metadata'] | undefined): Metadata | undefined {
if (!raw) {
return;
}
const parsed: Metadata = {};
if ('name' in raw && typeof raw.name === 'string') {
parsed.name = raw.name;
}
if ('description' in raw && typeof raw.description === 'string') {
parsed.description = raw.description;
}
if ('attributes' in raw && Array.isArray(raw.attributes)) {
parsed.attributes = attributesParser(raw.attributes);
}
if (Object.keys(parsed).length === 0) {
return;
}
return parsed;
}
/* eslint-disable max-len */
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
import * as addressMock from '../address/address'; import * as addressMock from '../address/address';
...@@ -11,12 +12,170 @@ export const base: TokenInstance = { ...@@ -11,12 +12,170 @@ export const base: TokenInstance = {
is_unique: false, is_unique: false,
holder_address_hash: null, holder_address_hash: null,
metadata: { metadata: {
animation_url: null, attributes: [
description: 'Sign for you!', {
external_link: null, trait_type: 'skin',
value: '0',
},
{
trait_type: 'eye',
value: '2',
},
{
trait_type: 'nose',
value: '6',
},
{
trait_type: 'spectacles',
value: '4',
},
{
trait_type: 'hair',
value: '12',
},
{
trait_type: 'shirt',
value: '1',
},
{
trait_type: 'earrings',
value: '4',
},
{
trait_type: 'mouth',
value: '5',
},
{
trait_type: 'bg color',
value: '64',
},
{
trait_type: 'p1',
value: '57775',
},
{
trait_type: 'p2',
value: '57772',
},
{
display_type: 'number',
trait_type: 'difficulty',
value: 84,
},
{
display_type: 'number',
trait_type: 'items',
value: 3,
},
],
description: '**GENESIS #188848**, **22a5f8bbb1602995** :: *84th* generation of *#57772 and #57775* :: **eGenetic Hash Code (eDNA)** = *3c457cc7f60f7853* :: [Click here for full biography.](https://vipsland.com/nft/collections/genesis/188848) :: crafted by [vipsland](https://vipsland.com/)',
external_url: 'https://vipsland.com/nft/collections/genesis/188848',
image: 'https://i.seadn.io/gcs/files/1ee1c5e1ead058322615e3206abb8ba3.png?w=500&auto=format', image: 'https://i.seadn.io/gcs/files/1ee1c5e1ead058322615e3206abb8ba3.png?w=500&auto=format',
name: 'Sign4U', name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
}, },
owner: addressMock.withName, owner: addressMock.withName,
token: tokenInfoERC721a, token: tokenInfoERC721a,
}; };
export const withRichMetadata: TokenInstance = {
...base,
metadata: {
background_color: '000000',
chain: 'MATIC',
chain_address: '0x66edbdb80001da74cbf3e6c01ba91154f6e2fb7c',
name: 'Carmelo Anthony',
total_nfts: 0,
animation_url: 'https://nftu.com/nft-content/media/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/dee8734bbefb0d63d6156b6fa0e1385822480589daa1862cbd37a94f6bc2ba3a',
series_key: 'Series',
nft_id: 'c746af09-8dcb-4cec-aa8a-5ff02fffc3f1',
description: 'All-Conference and All-American honors await Carmelo Anthony during his Freshman season for Syracuse. However, Anthony must first defeat a worthy opponent in Georgetown with a double-double effort of 30 points and 15 rebounds.\n \n\n© Syracuse University',
immutable_uri: 'https://nftu.com/nft-content/metadata/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/7741920',
contract_address: '0x63cf7b3d5808cb190aa301b55aafd6b4bb95efbb',
is_pack: false,
pack_open_locked_until: '2022-03-05T16:58:30.998Z',
rarity_key: 'Rarity',
images: {
png: {
primary: {
url: 'https://nftu.com/nft-content/media/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/0c66645c4e119f9c5def80273b768138d797f00583f557065a50bb0dd491e8e3',
cid: 'Qmf9hHAP884ZwYngk3VdVU7rhKDToykTy24WmcoegapnG8',
},
secondary: {
more: {
deeper: {
jpeg: {
url: 'https://nftu.com/nft-content/media/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/0c66645c4e119f9c5def80273b768138d797f00583f557065a50bb0dd491e8e3/pfp_3.png',
},
},
},
},
},
mp4: {
primary: {
url: 'https://nftu.com/nft-content/media/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/dee8734bbefb0d63d6156b6fa0e1385822480589daa1862cbd37a94f6bc2ba3a',
cid: 'QmPGMksnyQemncHKQ67zGiuTAsnFi8HTJkY9ebQ6eVVQLv',
},
},
'default': 'mp4',
webp: [
'QmPGMksnyQemncHKQ67zGiuTAsnFi8HTJkY9ebQ6eVVQLv',
'https://nftu.com/nft/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/949',
{
label: 'fancy label',
data: [
{
name: 'John',
email: 'john@foo.com',
},
{
name: 'Mary',
email: 'mary@foo.com',
},
[ 1, 2 ],
],
},
[
{
address: 'unknown',
age: 523,
gender: 'male',
},
{
address: 'bar',
age: 24,
gender: 'https://nftu.com/nft/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/949',
},
],
],
},
royalty_amount: 1000,
rarity: 'Premium',
set_key: 'Set',
external_url: 'https://nftu.com/nft/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/949',
attributes: [
{
value: 'NCAABB',
trait_type: 'Sport',
},
{
value: 'Player',
trait_type: 'Type',
},
{
value: '15',
trait_type: 'Player Jersey Number',
display_type: 'number',
},
],
tags: [ 'foo', 123, true ],
token_id: '7741920',
serial_total: 1100,
blockchain_state: 'BURNING',
image: 'https://nftu.com/nft-content/media/PAPAYA/92ee5f5c-bce9-4d64-8a25-c7e1e6305572/dee8734bbefb0d63d6156b6fa0e1385822480589daa1862cbd37a94f6bc2ba3a',
revealed_nfts: null,
nft_data_id: '92ee5f5c-bce9-4d64-8a25-c7e1e6305572',
series: 'Tip-Off',
immutable_cid: 'QmVigZH1P3D6QWvp2SWVreTPKmDvUYUidNzcUrcYzATpyJ',
status: null,
},
};
export interface Metadata {
name?: string;
description?: string;
attributes?: Array<MetadataAttributes>;
}
export interface MetadataAttributes {
value: string;
trait_type: string;
}
export type ExcludeNull<T> = T extends null ? never : T;
export type KeysOfObjectOrNull<T> = T extends null ? never : keyof T; import type { ExcludeNull } from './ExcludeNull';
export type KeysOfObjectOrNull<T> = keyof ExcludeNull<T>;
...@@ -11,9 +11,9 @@ interface Props { ...@@ -11,9 +11,9 @@ interface Props {
const LinkExternal = ({ href, children, className }: Props) => { const LinkExternal = ({ href, children, className }: Props) => {
return ( return (
<Link className={ className } fontSize="sm" display="inline-flex" alignItems="center" target="_blank" href={ href }> <Link className={ className } fontSize="sm" display="inline-block" alignItems="center" target="_blank" href={ href }>
{ children } { children }
<Icon as={ arrowIcon } boxSize={ 4 }/> <Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle"/>
</Link> </Link>
); );
}; };
......
...@@ -10,18 +10,21 @@ interface Props { ...@@ -10,18 +10,21 @@ interface Props {
rightSlot?: React.ReactNode; rightSlot?: React.ReactNode;
beforeSlot?: React.ReactNode; beforeSlot?: React.ReactNode;
textareaMaxHeight?: string; textareaMaxHeight?: string;
showCopy?: boolean;
} }
const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textareaMaxHeight }: Props) => { const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textareaMaxHeight, showCopy = true }: Props) => {
// see issue in theme/components/Textarea.ts // see issue in theme/components/Textarea.ts
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b'); const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
return ( return (
<Box className={ className } as="section" title={ title }> <Box className={ className } as="section" title={ title }>
<Flex justifyContent={ title ? 'space-between' : 'flex-end' } alignItems="center" mb={ 3 }> { (title || rightSlot || showCopy) && (
{ title && <Text fontWeight={ 500 }>{ title }</Text> } <Flex justifyContent={ title ? 'space-between' : 'flex-end' } alignItems="center" mb={ 3 }>
{ rightSlot } { title && <Text fontWeight={ 500 }>{ title }</Text> }
{ typeof data === 'string' && <CopyToClipboard text={ data }/> } { rightSlot }
</Flex> { typeof data === 'string' && showCopy && <CopyToClipboard text={ data }/> }
</Flex>
) }
{ beforeSlot } { beforeSlot }
<Box <Box
p={ 4 } p={ 4 }
......
import { Box, Tag } from '@chakra-ui/react'; import { Box, Tag, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types'; import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import nftIcon from 'icons/nft_shield.svg';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext'; import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo'; import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import LinkExternal from 'ui/shared/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs'; import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenLogo from 'ui/shared/TokenLogo';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer'; import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenInstanceDetails from './TokenInstanceDetails'; import TokenInstanceDetails from './TokenInstanceDetails';
...@@ -64,7 +65,7 @@ const TokenInstanceContent = () => { ...@@ -64,7 +65,7 @@ const TokenInstanceContent = () => {
return <TokenInstanceSkeleton/>; return <TokenInstanceSkeleton/>;
} }
const tokenLogo = <TokenLogo hash={ tokenInstanceQuery.data.token.address } name={ tokenInstanceQuery.data.token.name } boxSize={ 6 }/>; const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 }/>;
const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>; const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = { const address = {
hash: hash || '', hash: hash || '',
...@@ -72,6 +73,31 @@ const TokenInstanceContent = () => { ...@@ -72,6 +73,31 @@ const TokenInstanceContent = () => {
implementation_name: null, implementation_name: null,
watchlist_names: [], watchlist_names: [],
}; };
const appLink = (() => {
if (!tokenInstanceQuery.data.external_app_url) {
return null;
}
try {
const url = new URL(tokenInstanceQuery.data.external_app_url);
return (
<Box fontSize="sm" mt={ 6 }>
<span>View in app </span>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
{ url.hostname }
</LinkExternal>
</Box>
);
} catch (error) {
return (
<Box fontSize="sm" mt={ 6 }>
<LinkExternal href={ tokenInstanceQuery.data.external_app_url }>
View in app
</LinkExternal>
</Box>
);
}
})();
return ( return (
<> <>
...@@ -80,12 +106,14 @@ const TokenInstanceContent = () => { ...@@ -80,12 +106,14 @@ const TokenInstanceContent = () => {
text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` } text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to token page" backLinkLabel="Back to token page"
additionalsLeft={ tokenLogo } additionalsLeft={ nftShieldIcon }
additionalsRight={ tokenTag } additionalsRight={ tokenTag }
/> />
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/> <AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
{ appLink }
<TokenInstanceDetails data={ tokenInstanceQuery.data } scrollRef={ scrollRef }/> <TokenInstanceDetails data={ tokenInstanceQuery.data } scrollRef={ scrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
...@@ -97,6 +125,8 @@ const TokenInstanceContent = () => { ...@@ -97,6 +125,8 @@ const TokenInstanceContent = () => {
rightSlot={ !isMobile && transfersQuery.isPaginationVisible && tab !== 'metadata' ? <Pagination { ...transfersQuery.pagination }/> : null } rightSlot={ !isMobile && transfersQuery.isPaginationVisible && tab !== 'metadata' ? <Pagination { ...transfersQuery.pagination }/> : null }
stickyEnabled={ !isMobile } stickyEnabled={ !isMobile }
/> />
{ !tokenInstanceQuery.isLoading && !tokenInstanceQuery.isError && <Box h={{ base: 0, lg: '40vh' }}/> }
</> </>
); );
}; };
......
...@@ -15,7 +15,7 @@ const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_coun ...@@ -15,7 +15,7 @@ const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_coun
hash: tokenInstanceMock.base.token.address, hash: tokenInstanceMock.base.token.address,
}); });
test('base view +@dark-mode', async({ mount, page }) => { test('base view +@dark-mode +@mobile', async({ mount, page }) => {
await page.route(API_URL_ADDRESS, (route) => route.fulfill({ await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(addressMock.contract), body: JSON.stringify(addressMock.contract),
......
import { Box, Flex, Grid } from '@chakra-ui/react'; import { Box, Flex, Grid, GridItem, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
import parseMetadata from 'lib/token/parseMetadata';
import Address from 'ui/shared/address/Address'; import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
...@@ -29,53 +30,126 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -29,53 +30,126 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
}, 500); }, 500);
}, [ scrollRef ]); }, [ scrollRef ]);
const metadata = parseMetadata(data.metadata);
const hasMetadata = metadata && Boolean((metadata.name || metadata.description || metadata.attributes));
const attributeBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const divider = (
<GridItem
colSpan={{ base: undefined, lg: 2 }}
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor="divider"
/>
);
return ( return (
<Flex alignItems="flex-start" mt={ 8 } flexDir={{ base: 'column-reverse', lg: 'row' }} columnGap={ 6 } rowGap={ 6 }> <>
<Flex alignItems="flex-start" mt={ 8 } flexDir={{ base: 'column-reverse', lg: 'row' }} columnGap={ 6 } rowGap={ 6 }>
<Grid
flexGrow={ 1 }
columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: '200px minmax(0, 1fr)' }}
overflow="hidden"
>
<DetailsInfoItem
title="Token"
hint="Token name"
>
<TokenSnippet hash={ data.token.address } name={ data.token.name }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Owner"
hint="Current owner of this token instance"
>
<Address>
<AddressIcon address={ data.owner }/>
<AddressLink type="address" hash={ data.owner.hash } ml={ 2 }/>
<CopyToClipboard text={ data.owner.hash }/>
</Address>
</DetailsInfoItem>
<TokenInstanceCreatorAddress hash={ data.token.address }/>
<DetailsInfoItem
title="Token ID"
hint="This token instance unique token ID"
>
<Flex alignItems="center" overflow="hidden">
<Box overflow="hidden" display="inline-block" w="100%">
<HashStringShortenDynamic hash={ data.id }/>
</Box>
<CopyToClipboard text={ data.id } ml={ 1 }/>
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ data.token.address } id={ data.id } onClick={ handleCounterItemClick }/>
</Grid>
<NftMedia
imageUrl={ data.image_url }
animationUrl={ data.animation_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
/>
</Flex>
<Grid <Grid
flexGrow={ 1 } mt={ 8 }
columnGap={ 8 } columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }} rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: '200px minmax(0, 1fr)' }} overflow="hidden" templateColumns={{ base: 'minmax(0, 1fr)', lg: '200px minmax(0, 1fr)' }}
overflow="hidden"
> >
<DetailsInfoItem { divider }
title="Token"
hint="Token name"
>
<TokenSnippet hash={ data.token.address } name={ data.token.name }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Owner"
hint="Current owner of this token instance"
>
<Address>
<AddressIcon address={ data.owner }/>
<AddressLink type="address" hash={ data.owner.hash } ml={ 2 }/>
<CopyToClipboard text={ data.owner.hash }/>
</Address>
</DetailsInfoItem>
<TokenInstanceCreatorAddress hash={ data.token.address }/>
<DetailsInfoItem
title="Token ID"
hint="This token instance unique token ID"
>
<Flex alignItems="center" overflow="hidden">
<Box overflow="hidden" display="inline-block" w="100%">
<HashStringShortenDynamic hash={ data.id }/>
</Box>
<CopyToClipboard text={ data.id } ml={ 1 }/>
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ data.token.address } id={ data.id } onClick={ handleCounterItemClick }/>
<DetailsSponsoredItem/> <DetailsSponsoredItem/>
{ hasMetadata && (
<>
{ divider }
{ metadata?.name && (
<DetailsInfoItem
title="Name"
hint="NFT name"
whiteSpace="normal"
wordBreak="break-word"
>
{ metadata.name }
</DetailsInfoItem>
) }
{ metadata?.description && (
<DetailsInfoItem
title="Description"
hint="NFT description"
whiteSpace="normal"
wordBreak="break-word"
>
{ metadata.description }
</DetailsInfoItem>
) }
{ metadata?.attributes && (
<DetailsInfoItem
title="Attributes"
hint="NFT attributes"
whiteSpace="normal"
>
<Grid gap={ 2 } templateColumns="repeat(auto-fill,minmax(160px, 1fr))" w="100%">
{ metadata.attributes.map((attribute, index) => (
<GridItem
key={ index }
bgColor={ attributeBgColor }
borderRadius="md"
px={ 4 }
py={ 2 }
>
<Box fontSize="xs" color="text_secondary" fontWeight={ 500 }>{ attribute.trait_type }</Box>
<Box fontSize="sm">{ attribute.value }</Box>
</GridItem>
)) }
</Grid>
</DetailsInfoItem>
) }
</>
) }
</Grid> </Grid>
<NftMedia </>
imageUrl={ data.image_url }
animationUrl={ data.animation_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
/>
</Flex>
); );
}; };
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
import TokenInstanceMetadata from './TokenInstanceMetadata';
test('base view +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<TokenInstanceMetadata data={ tokenInstanceMock.withRichMetadata.metadata }/>
</TestApp>,
);
await component.getByRole('button', { name: /png/i }).click();
await component.getByRole('button', { name: /primary/i }).click();
await component.getByRole('button', { name: /secondary/i }).click();
await component.getByRole('button', { name: /more/i }).click();
await component.getByRole('button', { name: /webp/i }).click();
await component.getByRole('button', { name: /attributes/i }).click();
await component.getByRole('button', { name: /tags/i }).click();
await expect(component).toHaveScreenshot();
});
test('raw view', async({ mount }) => {
const component = await mount(
<TestApp>
<TokenInstanceMetadata data={ tokenInstanceMock.withRichMetadata.metadata }/>
</TestApp>,
);
await component.locator('select').selectOption('JSON');
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react'; import { Box, Flex, Select, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenInstance } from 'types/api/token'; import type { TokenInstance } from 'types/api/token';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
import MetadataAccordion from './metadata/MetadataAccordion';
type Format = 'JSON' | 'Table'
interface Props { interface Props {
data: TokenInstance['metadata'] | undefined; data: TokenInstance['metadata'] | undefined;
} }
const TokenInstanceMetadata = ({ data }: Props) => { const TokenInstanceMetadata = ({ data }: Props) => {
const [ format, setFormat ] = React.useState<Format>('Table');
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setFormat(event.target.value as Format);
}, []);
if (!data) { if (!data) {
return <Box>There is no metadata for this NFT</Box>; return <Box>There is no metadata for this NFT</Box>;
} }
const content = format === 'Table' ?
<MetadataAccordion data={ data }/> :
<RawDataSnippet data={ JSON.stringify(data, undefined, 4) } showCopy={ false }/>;
return ( return (
<RawDataSnippet <Box>
data={ JSON.stringify(data, undefined, 4) } <Flex alignItems="center" mb={ 6 }>
/> <chakra.span fontWeight={ 500 }>Metadata</chakra.span>
<Select size="xs" borderRadius="base" value={ format } onChange={ handleSelectChange } focusBorderColor="none" w="auto" ml={ 5 }>
<option value="Table">Table</option>
<option value="JSON">JSON</option>
</Select>
{ format === 'JSON' && <CopyToClipboard text={ JSON.stringify(data) } ml="auto"/> }
</Flex>
{ content }
</Box>
); );
}; };
export default TokenInstanceMetadata; export default React.memo(TokenInstanceMetadata);
import { Accordion } from '@chakra-ui/react';
import React from 'react';
import MetadataItemArray from './MetadataItemArray';
import MetadataItemObject from './MetadataItemObject';
import MetadataItemPrimitive from './MetadataItemPrimitive';
interface Props {
data: Record<string, unknown>;
level?: number;
}
const MetadataAccordion = ({ data, level = 0 }: Props) => {
const ml = (() => {
if (level === 0) {
return 0;
}
if (level === 1) {
return 126;
}
return 24;
})();
const isFlat = Object.entries(data).every(([ , value ]) => typeof value !== 'object');
const renderItem = React.useCallback((name: string, value: unknown) => {
switch (typeof value) {
case 'string':
case 'number':
case 'boolean': {
return <MetadataItemPrimitive name={ name } value={ value } isFlat={ isFlat } level={ level }/>;
}
case 'object': {
if (value === null) {
return <MetadataItemPrimitive name={ name } value={ value } isFlat={ isFlat } level={ level }/>;
}
if (Array.isArray(value) && value.length > 0) {
return <MetadataItemArray name={ name } value={ value } level={ level }/>;
}
if (Object.keys(value).length > 0) {
return <MetadataItemObject name={ name } value={ value as Record<string, unknown> } level={ level }/>;
}
}
// eslint-disable-next-line no-fallthrough
default: {
return <MetadataItemPrimitive name={ name } value={ String(value) } isFlat={ isFlat } level={ level }/>;
}
}
}, [ level, isFlat ]);
return (
<Accordion allowMultiple fontSize="sm" ml={{ base: level === 0 ? 0 : 6, lg: `${ ml }px` }} defaultIndex={ level === 0 ? [ 0 ] : undefined }>
{ Object.entries(data).map(([ key, value ]) => renderItem(key, value)) }
</Accordion>
);
};
export default React.memo(MetadataAccordion);
import { AccordionItem, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
level?: number;
className?: string;
isFlat?: boolean;
}
const MetadataAccordionItem = ({ children, className, level, isFlat }: Props) => {
return (
<AccordionItem
className={ className }
display="flex"
alignItems="flex-start"
flexDir={{ base: 'column', lg: 'row' }}
py={ 2 }
pl={ isFlat ? 0 : 6 }
columnGap={ 3 }
borderTopWidth="1px"
borderColor="divider"
wordBreak="break-all"
rowGap={ 1 }
_last={{
borderBottomWidth: level === 0 ? '1px' : '0px',
}}
_first={{
borderTopWidth: level === 0 ? '1px' : '0px',
}}
>
{ children }
</AccordionItem>
);
};
export default React.memo(chakra(MetadataAccordionItem));
import { Box, chakra } from '@chakra-ui/react';
import React from 'react';
import { formatName } from './utils';
interface Props {
name: string;
className?: string;
}
const MetadataAccordionItemTitle = ({ name, className }: Props) => {
return (
<Box w={{ base: 'auto', lg: '90px' }} flexShrink={ 0 } fontWeight={ 600 } wordBreak="break-word" className={ className }>
{ formatName(name) }
</Box>
);
};
export default React.memo(chakra(MetadataAccordionItemTitle));
import { AccordionButton, AccordionIcon, AccordionPanel, Flex } from '@chakra-ui/react';
import React from 'react';
import MetadataAccordionItem from './MetadataAccordionItem';
import MetadataAccordionItemTitle from './MetadataAccordionItemTitle';
import MetadataItemPrimitive from './MetadataItemPrimitive';
interface Props {
name: string;
value: Array<unknown>;
level: number;
}
const MetadataItemArray = ({ name, value, level }: Props) => {
return (
<MetadataAccordionItem
flexDir={{ lg: 'column' }}
alignItems="stretch"
pl={{ base: 0, lg: 0 }}
py={ 0 }
>
<AccordionButton
px={ 0 }
py={ 2 }
_hover={{ bgColor: 'inherit' }}
fontSize="sm"
textAlign="left"
_expanded={{
borderColor: 'divider',
borderBottomWidth: '1px',
}}
>
<AccordionIcon boxSize={ 6 } p={ 1 }/>
<MetadataAccordionItemTitle name={ name }/>
</AccordionButton>
<AccordionPanel p={ 0 } ml={{ base: 6, lg: level === 0 ? '126px' : 6 }}>
{ value.map((item, index) => {
const content = (() => {
switch (typeof item) {
case 'string':
case 'number':
case 'boolean': {
return <MetadataItemPrimitive value={ item } isItem={ false } level={ level }/>;
}
case 'object': {
if (item) {
if (Array.isArray(item)) {
return <span>{ JSON.stringify(item, undefined, 2) }</span>;
} else {
return Object.entries(item).map(([ name, value ], index) => {
return (
<Flex key={ index } columnGap={ 3 }>
<MetadataAccordionItemTitle name={ name } fontWeight={ 400 } w={{ base: '90px' }}/>
<MetadataItemPrimitive
value={ typeof value === 'object' ? JSON.stringify(value, undefined, 2) : value }
isItem={ false }
level={ level }
/>
</Flex>
);
});
}
} else {
return <span>{ String(item) }</span>;
}
}
default: {
return <span>{ String(item) }</span>;
}
}
})();
return (
<Flex
key={ index }
py={ 2 }
_notFirst={{ borderColor: 'divider', borderTopWidth: '1px' }}
flexDir="column"
rowGap={ 2 }
>
{ content }
</Flex>
);
}) }
</AccordionPanel>
</MetadataAccordionItem>
);
};
export default React.memo(MetadataItemArray);
import { AccordionButton, AccordionIcon, AccordionPanel, Box } from '@chakra-ui/react';
import React from 'react';
import MetadataAccordion from './MetadataAccordion';
import MetadataAccordionItem from './MetadataAccordionItem';
import MetadataAccordionItemTitle from './MetadataAccordionItemTitle';
interface Props {
name: string;
value: Record<string, unknown>;
level: number;
}
const MetadataItemObject = ({ name, value, level }: Props) => {
if (level >= 4) {
return (
<MetadataAccordionItem level={ level } isFlat>
<MetadataAccordionItemTitle name={ name }/>
<Box whiteSpace="pre-wrap">{ JSON.stringify(value, undefined, 2) }</Box>
</MetadataAccordionItem>
);
}
return (
<MetadataAccordionItem
flexDir={{ lg: 'column' }}
alignItems="stretch"
py={ 0 }
isFlat
level={ level }
>
<AccordionButton
px={ 0 }
py={ 2 }
_hover={{ bgColor: 'inherit' }}
fontSize="sm"
textAlign="left"
_expanded={{
borderColor: 'divider',
borderBottomWidth: '1px',
}}
>
<AccordionIcon boxSize={ 6 } p={ 1 }/>
<MetadataAccordionItemTitle name={ name }/>
</AccordionButton>
<AccordionPanel p={ 0 }>
<MetadataAccordion data={ value as Record<string, unknown> } level={ level + 1 }/>
</AccordionPanel>
</MetadataAccordionItem>
);
};
export default React.memo(MetadataItemObject);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { Primitive } from 'react-hook-form';
import LinkExternal from 'ui/shared/LinkExternal';
import MetadataAccordionItem from './MetadataAccordionItem';
import MetadataAccordionItemTitle from './MetadataAccordionItemTitle';
interface Props {
name?: string;
value: Primitive;
isItem?: boolean;
isFlat?: boolean;
level: number;
}
const MetadataItemPrimitive = ({ name, value, isItem = true, isFlat, level }: Props) => {
const Component = isItem ? MetadataAccordionItem : Box;
const content = (() => {
switch (typeof value) {
case 'string': {
try {
if (!value.includes('http')) {
throw new Error();
}
const url = new URL(value);
return <LinkExternal href={ url.toString() }>{ value }</LinkExternal>;
} catch (error) {}
}
// eslint-disable-next-line no-fallthrough
default: {
return <div>{ String(value) }</div>;
}
}
})();
return (
<Component level={ level } isFlat={ isFlat }>
{ name && <MetadataAccordionItemTitle name={ name }/> }
{ content }
</Component>
);
};
export default React.memo(MetadataItemPrimitive);
import _upperFirst from 'lodash/upperFirst';
export function formatName(_name: string) {
const name = _name
.replaceAll('_', ' ')
.replaceAll(/\burl|nft|id\b/gi, (str) => str.toUpperCase());
return _upperFirst(name.trim());
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment