Commit e6200709 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #1883 from blockscout/action-button

Action button
parents 42845ae4 a4ba2e3a
......@@ -46,6 +46,7 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_AD_BANNER_PROVIDER=hype
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
......
......@@ -51,5 +51,6 @@ NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
NEXT_PUBLIC_STATS_API_HOST=http://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=http://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
\ No newline at end of file
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
......@@ -21,6 +21,10 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
'tooltipTitle',
'tooltipDescription',
'tooltipUrl',
'appID',
'appMarketplaceURL',
'appLogoURL',
'appActionButtonText',
];
for (const stringField of stringFields) {
......
......@@ -7,6 +7,7 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
action_button_exp: boolean;
}
export const growthBook = (() => {
......
......@@ -105,6 +105,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
} | {
'Type': 'Security score';
'Source': 'Analyzed contracts popup';
} | {
'Type': 'Action button';
'Info': string;
'Source': 'Txn' | 'NFT collection' | 'NFT item';
} | {
'Type': 'Address tag';
'Info': string;
......
......@@ -61,3 +61,18 @@ export const protocolTag: AddressMetadataTagApi = {
ordinal: 0,
meta: null,
};
export const protocolTagWithMeta: AddressMetadataTagApi = {
slug: 'uniswap',
name: 'Uniswap',
tagType: 'protocol',
ordinal: 0,
meta: {
appID: 'uniswap',
appMarketplaceURL: 'https://example.com',
appLogoURL: 'https://localhost:3100/icon.svg',
appActionButtonText: 'Swap',
textColor: '#FFFFFF',
bgColor: '#FF007A',
},
};
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
const appID = 'uniswap';
const appMarketplaceURL = 'https://example.com';
export const appLogoURL = 'https://localhost:3100/icon.svg';
const appActionButtonText = 'Swap';
const textColor = '#FFFFFF';
const bgColor = '#FF007A';
export const buttonWithoutStyles: AddressMetadataTagApi['meta'] = {
appID,
appMarketplaceURL,
appLogoURL,
appActionButtonText,
};
export const linkWithoutStyles: AddressMetadataTagApi['meta'] = {
appMarketplaceURL,
appLogoURL,
appActionButtonText,
};
export const buttonWithStyles: AddressMetadataTagApi['meta'] = {
appID,
appMarketplaceURL,
appLogoURL,
appActionButtonText,
textColor,
bgColor,
};
export const linkWithStyles: AddressMetadataTagApi['meta'] = {
appMarketplaceURL,
appLogoURL,
appActionButtonText,
textColor,
bgColor,
};
......@@ -60,4 +60,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
noWalletClient: [
[ 'NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID', '' ],
],
noNftMarketplaces: [
[ 'NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES', '' ],
],
};
......@@ -26,5 +26,9 @@ export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'>
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
appID?: string;
appMarketplaceURL?: string;
appLogoURL?: string;
appActionButtonText?: string;
} | null;
}
......@@ -2,7 +2,7 @@ import { Box, Center, useColorMode, Flex } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { DappscoutIframeProvider, useDappscoutIframe } from 'dappscout-iframe';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
......@@ -16,6 +16,7 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam';
import ContentLoader from 'ui/shared/ContentLoader';
import MarketplaceAppTopBar from '../marketplace/MarketplaceAppTopBar';
......@@ -36,9 +37,10 @@ type Props = {
address: string | undefined;
data: MarketplaceAppOverview | undefined;
isPending: boolean;
appUrl?: string;
};
const MarketplaceAppContent = ({ address, data, isPending }: Props) => {
const MarketplaceAppContent = ({ address, data, isPending, appUrl }: Props) => {
const { iframeRef, isReady } = useDappscoutIframe();
const [ iframeKey, setIframeKey ] = useState(0);
......@@ -89,7 +91,7 @@ const MarketplaceAppContent = ({ address, data, isPending }: Props) => {
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ data.url }
src={ appUrl }
title={ data.title }
onLoad={ handleIframeLoad }
/>
......@@ -132,6 +134,26 @@ const MarketplaceApp = () => {
const { data, isPending } = query;
const { setIsAutoConnectDisabled } = useMarketplaceContext();
const appUrl = useMemo(() => {
if (!data?.url) {
return;
}
try {
const customUrl = getQueryParamString(router.query.url);
const customOrigin = new URL(customUrl).origin;
const appOrigin = new URL(data.url).origin;
if (customOrigin === appOrigin) {
return customUrl;
} else {
removeQueryParam(router, 'url');
}
} catch (err) {}
return data.url;
}, [ data?.url, router ]);
useEffect(() => {
if (data) {
metadata.update(
......@@ -153,13 +175,13 @@ const MarketplaceApp = () => {
/>
<DappscoutIframeProvider
address={ address }
appUrl={ data?.url }
appUrl={ appUrl }
rpcUrl={ config.chain.rpcUrl }
sendTransaction={ sendTransaction }
signMessage={ signMessage }
signTypedData={ signTypedData }
>
<MarketplaceAppContent address={ address } data={ data } isPending={ isPending }/>
<MarketplaceAppContent address={ address } data={ data } isPending={ isPending } appUrl={ appUrl }/>
</DappscoutIframeProvider>
</Flex>
);
......
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
import * as actionButtonMetadataMock from 'mocks/metadata/appActionButton';
import { test, expect } from 'playwright/lib';
import AppActionButton from './AppActionButton';
test.beforeEach(async({ mockAssetResponse }) => {
await mockAssetResponse(actionButtonMetadataMock.appLogoURL as string, './playwright/mocks/image_s.jpg');
});
test('button without styles +@dark-mode', async({ render }) => {
const component = await render(
<Flex w="200px">
<AppActionButton
data={ actionButtonMetadataMock.buttonWithoutStyles as NonNullable<AddressMetadataTagFormatted['meta']> }
source="Txn"
/>
</Flex>,
);
await expect(component).toHaveScreenshot();
});
test('link without styles +@dark-mode', async({ render }) => {
const component = await render(
<Flex w="200px">
<AppActionButton
data={ actionButtonMetadataMock.linkWithoutStyles as NonNullable<AddressMetadataTagFormatted['meta']> }
source="Txn"
/>
</Flex>,
);
await expect(component).toHaveScreenshot();
});
test('button with styles', async({ render }) => {
const component = await render(
<Flex w="200px">
<AppActionButton
data={ actionButtonMetadataMock.buttonWithStyles as NonNullable<AddressMetadataTagFormatted['meta']> }
source="Txn"
/>
</Flex>,
);
await expect(component).toHaveScreenshot();
});
test('link with styles', async({ render }) => {
const component = await render(
<Flex w="200px">
<AppActionButton
data={ actionButtonMetadataMock.linkWithStyles as NonNullable<AddressMetadataTagFormatted['meta']> }
source="Txn"
/>
</Flex>,
);
await expect(component).toHaveScreenshot();
});
import { Button, Image, Text, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from '../LinkExternal';
type Props = {
data: NonNullable<AddressMetadataTagFormatted['meta']>;
className?: string;
txHash?: string;
source: 'Txn' | 'NFT collection' | 'NFT item';
}
const AppActionButton = ({ data, className, txHash, source }: Props) => {
const defaultTextColor = useColorModeValue('blue.600', 'blue.300');
const defaultBg = useColorModeValue('gray.100', 'gray.700');
const { appID, textColor, bgColor, appActionButtonText, appLogoURL, appMarketplaceURL } = data;
const actionURL = appMarketplaceURL?.replace('{chainId}', config.chain.id || '').replace('{txHash}', txHash || '');
const handleClick = React.useCallback(() => {
const info = appID || actionURL;
if (info) {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Action button', Info: info, Source: source });
}
}, [ source, appID, actionURL ]);
if ((!appID && !appMarketplaceURL) || (!appActionButtonText && !appLogoURL)) {
return null;
}
const content = (
<>
{ appLogoURL && (
<Image
src={ appLogoURL }
alt={ `${ appActionButtonText } button` }
boxSize={ 5 }
borderRadius="sm"
mr={ 2 }
/>
) }
<Text fontSize="sm" fontWeight="500" color="currentColor">
{ appActionButtonText }
</Text>
</>
);
return appID ? (
<Button
className={ className }
as="a"
href={ route({ pathname: '/apps/[id]', query: { id: appID, action: 'connect', ...(actionURL ? { url: actionURL } : {}) } }) }
onClick={ handleClick }
display="flex"
size="sm"
px={ 2 }
color={ textColor || defaultTextColor }
bg={ bgColor || defaultBg }
_hover={{ bg: bgColor, opacity: 0.9 }}
_active={{ bg: bgColor, opacity: 0.9 }}
>
{ content }
</Button>
) : (
<LinkExternal
className={ className }
href={ actionURL }
onClick={ handleClick }
variant="subtle"
display="flex"
px={ 2 }
iconColor={ textColor }
color={ textColor }
bg={ bgColor }
_hover={{ color: textColor }}
_active={{ color: textColor }}
>
{ content }
</LinkExternal>
);
};
export default chakra(AppActionButton);
import { useMemo } from 'react';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
export default function useAppActionData(address: string | undefined = '', isEnabled = false) {
const memoizedArray = useMemo(() => (address && isEnabled) ? [ address ] : [], [ address, isEnabled ]);
const { data } = useAddressMetadataInfoQuery(memoizedArray);
const metadata = data?.addresses[address?.toLowerCase()];
const tag = metadata?.tags?.find(({ tagType }) => tagType === 'protocol');
if (tag?.meta?.appMarketplaceURL || tag?.meta?.appID) {
return tag.meta;
}
return null;
}
......@@ -7,13 +7,17 @@ import { scroller } from 'react-scroll';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMounted from 'lib/hooks/useIsMounted';
import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token';
import AppActionButton from 'ui/shared/AppActionButton/AppActionButton';
import useAppActionData from 'ui/shared/AppActionButton/useAppActionData';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import TruncatedValue from 'ui/shared/TruncatedValue';
......@@ -27,6 +31,8 @@ interface Props {
const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false);
const hash = router.query.hash?.toString();
const tokenCountersQuery = useApiQuery('token_counters', {
......@@ -34,6 +40,8 @@ const TokenDetails = ({ tokenQuery }: Props) => {
queryOptions: { enabled: Boolean(router.query.hash), placeholderData: TOKEN_COUNTERS },
});
const appActionData = useAppActionData(hash, isActionButtonExperiment);
const changeUrlAndScroll = useCallback((tab: TokenTabs) => () => {
router.push(
{ pathname: '/token/[hash]', query: { hash: hash || '', tab } },
......@@ -167,7 +175,26 @@ const TokenDetails = ({ tokenQuery }: Props) => {
</DetailsInfoItem>
) }
{ type !== 'ERC-20' && <TokenNftMarketplaces hash={ hash } isLoading={ tokenQuery.isPlaceholderData }/> }
{ type !== 'ERC-20' && (
<TokenNftMarketplaces
hash={ hash }
isLoading={ tokenQuery.isPlaceholderData }
appActionData={ appActionData }
source="NFT collection"
isActionButtonExperiment={ isActionButtonExperiment }
/>
) }
{ (type !== 'ERC-20' && config.UI.views.nft.marketplaces.length === 0 && appActionData && isActionButtonExperiment) && (
<DetailsInfoItem
title="Dapp"
hint="Link to the dapp"
alignSelf="center"
py={ 1 }
>
<AppActionButton data={ appActionData } height="30px" source="NFT collection"/>
</DetailsInfoItem>
) }
<DetailsSponsoredItem isLoading={ tokenQuery.isPlaceholderData }/>
</Grid>
......
import { Image, Link, Skeleton, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
import config from 'configs/app';
import AppActionButton from 'ui/shared/AppActionButton/AppActionButton';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import TextSeparator from 'ui/shared/TextSeparator';
interface Props {
hash: string | undefined;
id?: string;
isLoading?: boolean;
appActionData?: AddressMetadataTagFormatted['meta'];
source: 'NFT collection' | 'NFT item';
isActionButtonExperiment?: boolean;
}
const TokenNftMarketplaces = ({ hash, id, isLoading }: Props) => {
const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isActionButtonExperiment }: Props) => {
if (!hash || config.UI.views.nft.marketplaces.length === 0) {
return null;
}
......@@ -21,8 +28,9 @@ const TokenNftMarketplaces = ({ hash, id, isLoading }: Props) => {
hint="Marketplaces trading this NFT"
alignSelf="center"
isLoading={ isLoading }
py={ (appActionData && isActionButtonExperiment) ? 1 : { base: 1, lg: 2 } }
>
<Skeleton isLoaded={ !isLoading } display="flex" columnGap={ 3 } flexWrap="wrap">
<Skeleton isLoaded={ !isLoading } display="flex" columnGap={ 3 } flexWrap="wrap" alignItems="center">
{ config.UI.views.nft.marketplaces.map((item) => {
const hrefTemplate = id ? item.instance_url : item.collection_url;
......@@ -41,6 +49,12 @@ const TokenNftMarketplaces = ({ hash, id, isLoading }: Props) => {
</Tooltip>
);
}) }
{ (appActionData && isActionButtonExperiment) && (
<>
<TextSeparator color="gray.500" margin={ 0 }/>
<AppActionButton data={ appActionData } height="30px" source={ source }/>
</>
) }
</Skeleton>
</DetailsInfoItem>
);
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { AddressMetadataInfo, AddressMetadataTagApi } from 'types/api/addressMetadata';
import config from 'configs/app';
import * as addressMock from 'mocks/address/address';
import { protocolTagWithMeta } from 'mocks/metadata/address';
import { tokenInfoERC721a } from 'mocks/tokens/tokenInfo';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import TokenInstanceDetails from './TokenInstanceDetails';
const hash = tokenInfoERC721a.address;
const API_URL_ADDRESS = buildApiUrl('address', { hash });
const API_URL_TOKEN_TRANSFERS_COUNT = buildApiUrl('token_instance_transfers_count', {
id: tokenInstanceMock.unique.id,
hash,
const addressMetadataQueryParams = {
addresses: [ hash ],
chainId: config.chain.id,
tagsLimit: '20',
};
function generateAddressMetadataResponse(tag: AddressMetadataTagApi) {
return {
addresses: {
[ hash.toLowerCase() as string ]: {
tags: [ {
...tag,
meta: JSON.stringify(tag.meta),
} ],
},
},
} as AddressMetadataInfo;
}
test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('address', addressMock.contract, { pathParams: { hash } });
await mockApiResponse('token_instance_transfers_count', { transfers_count: 42 }, { pathParams: { id: tokenInstanceMock.unique.id, hash } });
await mockAssetResponse('http://localhost:3000/nft-marketplace-logo.png', './playwright/mocks/image_s.jpg');
});
test('base view +@dark-mode +@mobile', async({ mount, page }) => {
await page.route('http://localhost:3000/nft-marketplace-logo.png', (route) => route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
}));
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.contract),
}));
await page.route(API_URL_TOKEN_TRANSFERS_COUNT, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ transfers_count: 42 }),
}));
const component = await mount(
test('base view +@dark-mode +@mobile', async({ render, page }) => {
const component = await render(
<TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</TestApp>,
......@@ -43,3 +53,40 @@ test('base view +@dark-mode +@mobile', async({ mount, page }) => {
maskColor: configs.maskColor,
});
});
test.describe('action button', () => {
test.beforeEach(async({ mockFeatures, mockApiResponse, mockAssetResponse }) => {
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg');
});
test('base view +@dark-mode +@mobile', async({ render, page }) => {
const component = await render(
<TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</TestApp>,
);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
test('without marketplaces +@dark-mode +@mobile', async({ render, page, mockEnvs }) => {
mockEnvs(ENVS_MAP.noNftMarketplaces);
const component = await render(
<TestApp>
<TokenInstanceDetails data={ tokenInstanceMock.unique } token={ tokenInfoERC721a }/>
</TestApp>,
);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
});
......@@ -3,6 +3,10 @@ import React from 'react';
import type { TokenInfo, TokenInstance } from 'types/api/token';
import config from 'configs/app';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import AppActionButton from 'ui/shared/AppActionButton/AppActionButton';
import useAppActionData from 'ui/shared/AppActionButton/useAppActionData';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
......@@ -24,6 +28,9 @@ interface Props {
}
const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false);
const appActionData = useAppActionData(token?.address, isActionButtonExperiment && !isLoading);
const handleCounterItemClick = React.useCallback(() => {
window.setTimeout(() => {
// cannot do scroll instantly, have to wait a little
......@@ -71,7 +78,24 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ isLoading ? '' : token.address } id={ isLoading ? '' : data.id } onClick={ handleCounterItemClick }/>
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
<TokenNftMarketplaces
isLoading={ isLoading }
hash={ token.address }
id={ data.id }
appActionData={ appActionData }
source="NFT item"
isActionButtonExperiment={ isActionButtonExperiment }
/>
{ (config.UI.views.nft.marketplaces.length === 0 && appActionData && isActionButtonExperiment) && (
<DetailsInfoItem
title="Dapp"
hint="Link to the dapp"
alignSelf="center"
py={ 1 }
>
<AppActionButton data={ appActionData } height="30px" source="NFT item"/>
</DetailsInfoItem>
) }
</Grid>
<NftMedia
animationUrl={ data.animation_url }
......
import React from 'react';
import type { AddressMetadataInfo, AddressMetadataTagApi } from 'types/api/addressMetadata';
import config from 'configs/app';
import { protocolTagWithMeta } from 'mocks/metadata/address';
import * as txMock from 'mocks/txs/tx';
import { txInterpretation } from 'mocks/txs/txInterpretation';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
......@@ -16,6 +20,25 @@ const txQuery = {
isError: false,
} as TxQuery;
const addressMetadataQueryParams = {
addresses: [ txMock.base.to?.hash as string ],
chainId: config.chain.id,
tagsLimit: '20',
};
function generateAddressMetadataResponse(tag: AddressMetadataTagApi) {
return {
addresses: {
[ txMock.base.to?.hash?.toLowerCase() as string ]: {
tags: [ {
...tag,
meta: JSON.stringify(tag.meta),
} ],
},
},
} as AddressMetadataInfo;
}
test('no interpretation +@mobile', async({ render }) => {
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
......@@ -32,6 +55,16 @@ test.describe('blockscout provider', () => {
await expect(component).toHaveScreenshot();
});
test('with interpretation and action button +@mobile +@dark-mode', async({ render, mockApiResponse, mockAssetResponse, mockFeatures }) => {
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg');
await mockApiResponse('tx_interpretation', txInterpretation, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
test('with interpretation and view all link +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse(
'tx_interpretation',
......@@ -42,13 +75,40 @@ test.describe('blockscout provider', () => {
await expect(component).toHaveScreenshot();
});
test('no interpretation, has method called', async({ render, mockApiResponse }) => {
test('with interpretation and view all link, and action button (external link) +@mobile', async({
render, mockApiResponse, mockAssetResponse, mockFeatures,
}) => {
await mockFeatures([ [ 'action_button_exp', true ] ]);
delete protocolTagWithMeta?.meta?.appID;
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg');
await mockApiResponse(
'tx_interpretation',
{ data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } },
{ pathParams: { hash } },
);
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
test('no interpretation, has method called', async({ render, mockApiResponse, mockFeatures }) => {
// the action button should not render if there is no interpretation
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
test('no interpretation', async({ render, mockApiResponse }) => {
test('no interpretation', async({ render, mockApiResponse, mockFeatures }) => {
// the action button should not render if there is no interpretation
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
const txPendingQuery = {
data: txMock.pending,
isPlaceholderData: false,
......
......@@ -3,9 +3,12 @@ import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import AppActionButton from 'ui/shared/AppActionButton/AppActionButton';
import useAppActionData from 'ui/shared/AppActionButton/useAppActionData';
import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
......@@ -26,6 +29,9 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
const hasInterpretationFeature = feature.isEnabled;
const isNovesInterpretation = hasInterpretationFeature && feature.provider === 'noves';
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false);
const appActionData = useAppActionData(txQuery.data?.to?.hash, isActionButtonExperiment && !txQuery.isPlaceholderData);
const txInterpretationQuery = useApiQuery('tx_interpretation', {
pathParams: { hash },
queryOptions: {
......@@ -42,16 +48,20 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
},
});
const content = (() => {
const hasNovesInterpretation = isNovesInterpretation &&
const hasNovesInterpretation = isNovesInterpretation &&
(novesInterpretationQuery.isPlaceholderData || Boolean(novesInterpretationQuery.data?.classificationData.description));
const hasInternalInterpretation = (hasInterpretationFeature && !isNovesInterpretation) &&
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
const hasInternalInterpretation = (hasInterpretationFeature && !isNovesInterpretation) &&
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
const hasViewAllInterpretationsLink =
!txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1;
const hasViewAllInterpretationsLink =
!txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1;
const hasAnyInterpretation =
(hasNovesInterpretation && novesInterpretationQuery.data && !novesInterpretationQuery.isPlaceholderData) ||
(hasInternalInterpretation && !txInterpretationQuery.isPlaceholderData);
const content = (() => {
if (hasNovesInterpretation && novesInterpretationQuery.data) {
const novesSummary = createNovesSummaryObject(novesInterpretationQuery.data);
......@@ -108,9 +118,18 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
return (
<Box display={{ base: 'block', lg: 'flex' }} alignItems="center" w="100%">
{ content }
<Flex alignItems="center" justifyContent={{ base: 'start', lg: 'space-between' }} flexGrow={ 1 }>
{ !hasTag && <AccountActionsMenu mr={ 3 } mt={{ base: 3, lg: 0 }}/> }
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 0, lg: 'auto' }} mt={{ base: 3, lg: 0 }}/>
<Flex
alignItems="center"
justifyContent={{ base: 'start', lg: 'space-between' }}
flexGrow={ 1 }
gap={ 3 }
mt={{ base: 3, lg: 0 }}
>
{ !hasTag && <AccountActionsMenu/> }
{ (appActionData && isActionButtonExperiment && hasAnyInterpretation) && (
<AppActionButton data={ appActionData } txHash={ hash } source="Txn"/>
) }
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 0, lg: 'auto' }}/>
</Flex>
</Box>
);
......
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