Commit f93310d4 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Combine Read/Write for Contract page (#2343)

* combine tabs and add method type filter

* add badge with contract method type

* add name filter

* add filters to mud system tab

* support array of tabs ids and route old tabs to the new one

* fix tests

* support adding the custom ABI from the contract page

* update margins and add test

* fix bugs and update screenshots

* some design updates

* update preset for garnet

* [skip ci] fix garnet preset

* improve links to contract methods
parent 2c35fb18
# Set of ENVs for Garnet (dev only) # Set of ENVs for Garnet Testnet network explorer
# https://https://explorer.garnetchain.com// # https://explorer.garnetchain.com
# This is an auto-generated file. To update all values, run "yarn preset:sync --name=garnet"
# app configuration # Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000 NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# blockchain parameters # Instance ENVs
NEXT_PUBLIC_NETWORK_NAME="Garnet Testnet" NEXT_PUBLIC_AD_BANNER_PROVIDER=none
NEXT_PUBLIC_NETWORK_ID=17069 NEXT_PUBLIC_AD_TEXT_PROVIDER=none
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_RPC_URL=https://partner-rpc.garnetchain.com/tireless-strand-dreamt-overcome
# api configuration
NEXT_PUBLIC_API_HOST=explorer.garnetchain.com
NEXT_PUBLIC_API_BASE_PATH=/ NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=explorer.garnetchain.com
# ui config NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
## homepage
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
## views
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
# app features NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/login
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/redstone-testnet.json NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/redstone-testnet.json
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/redstone.json NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/redstone.json
NEXT_PUBLIC_AD_BANNER_PROVIDER=none NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x5b0ba69f2cf5fbc6da96b6cf475c5521f7a385efd9d68673f69c1fc54f737a52
## sidebar NEXT_PUBLIC_HAS_MUD_FRAMEWORK=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgb(169, 31, 47)']}
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_LOGOUT_URL=https://redstone-lattice.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_ENABLED=false
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet.svg NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet.svg
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet-dark.svg NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/garnet-dark.svg
NEXT_PUBLIC_NETWORK_ID=17069
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet-dark.svg NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/garnet-dark.svg
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=rgb(169, 31, 47) NEXT_PUBLIC_NETWORK_NAME=Garnet Testnet
NEXT_PUBLIC_OG_DESCRIPTION="Redstone is the home for onchain games, worlds, and other MUD applications" NEXT_PUBLIC_NETWORK_RPC_URL=https://partner-rpc.garnetchain.com/tireless-strand-dreamt-overcome
# rollup NEXT_PUBLIC_NETWORK_SHORT_NAME=Garnet Testnet
NEXT_PUBLIC_ROLLUP_TYPE=optimistic NEXT_PUBLIC_OG_DESCRIPTION=Redstone is the home for onchain games, worlds, and other MUD applications
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/garnet.png
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-holesky.blockscout.com/ NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-holesky.blockscout.com/
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://garnet.qry.live/withdraw NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://garnet.qry.live/withdraw
NEXT_PUBLIC_HAS_MUD_FRAMEWORK=true NEXT_PUBLIC_ROLLUP_TYPE=optimistic
\ No newline at end of file NEXT_PUBLIC_STATS_API_HOST=https://stats-redstone-garnet.k8s.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
...@@ -24,6 +24,20 @@ const variantSubtle = defineStyle((props) => { ...@@ -24,6 +24,20 @@ const variantSubtle = defineStyle((props) => {
}; };
} }
if (c === 'black-blue') {
return {
bg: mode('blue.50', 'blue.800')(props),
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
};
}
if (c === 'black-purple') {
return {
bg: mode('purple.100', 'purple.800')(props),
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
};
}
return { return {
bg: mode(`${ c }.50`, `${ c }.800`)(props), bg: mode(`${ c }.50`, `${ c }.800`)(props),
color: mode(`${ c }.500`, `${ c }.100`)(props), color: mode(`${ c }.500`, `${ c }.100`)(props),
......
...@@ -112,6 +112,9 @@ const variantRadioGroup = defineStyle((props) => { ...@@ -112,6 +112,9 @@ const variantRadioGroup = defineStyle((props) => {
_active: { _active: {
bgColor: 'none', bgColor: 'none',
}, },
_notFirst: {
borderLeftWidth: 0,
},
// We have a special state for this button variant that serves as a popover trigger. // We have a special state for this button variant that serves as a popover trigger.
// When any items (filters) are selected in the popover, the button should change its background and text color. // When any items (filters) are selected in the popover, the button should change its background and text color.
// The last CSS selector is for redefining styles for the TabList component. // The last CSS selector is for redefining styles for the TabList component.
......
...@@ -51,9 +51,6 @@ const variantRadioGroup = definePartsStyle((props) => { ...@@ -51,9 +51,6 @@ const variantRadioGroup = definePartsStyle((props) => {
&[data-selected=true][aria-selected=true] &[data-selected=true][aria-selected=true]
`], `],
borderRadius: 'none', borderRadius: 'none',
_notFirst: {
borderLeftWidth: 0,
},
'&[role="tab"]': { '&[role="tab"]': {
_first: { _first: {
borderTopLeftRadius: 'base', borderTopLeftRadius: 'base',
......
...@@ -10,6 +10,7 @@ const PRESETS = { ...@@ -10,6 +10,7 @@ const PRESETS = {
eth: 'https://eth.blockscout.com', eth: 'https://eth.blockscout.com',
eth_goerli: 'https://eth-goerli.blockscout.com', eth_goerli: 'https://eth-goerli.blockscout.com',
eth_sepolia: 'https://eth-sepolia.blockscout.com', eth_sepolia: 'https://eth-sepolia.blockscout.com',
garnet: 'https://explorer.garnetchain.com',
gnosis: 'https://gnosis.blockscout.com', gnosis: 'https://gnosis.blockscout.com',
optimism: 'https://optimism.blockscout.com', optimism: 'https://optimism.blockscout.com',
optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com', optimism_celestia: 'https://opcelestia-raspberry.gelatoscout.com',
......
import type { Abi } from 'viem';
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
export interface AddressTag { export interface AddressTag {
address_hash: string; address_hash: string;
...@@ -111,22 +113,7 @@ export interface CustomAbi { ...@@ -111,22 +113,7 @@ export interface CustomAbi {
id: number; id: number;
contract_address_hash: string; contract_address_hash: string;
contract_address: AddressParam; contract_address: AddressParam;
abi: Array<AbiItem>; abi: Abi;
}
export interface AbiItem {
type: 'function';
stateMutability: 'nonpayable' | 'view';
payable: boolean;
outputs: Array<AbiInputOutput>;
name: string;
inputs: Array<AbiInputOutput>;
constant: boolean;
}
interface AbiInputOutput {
type: 'uint256' | 'address';
name: string;
} }
export type WatchlistErrors = { export type WatchlistErrors = {
......
...@@ -64,12 +64,12 @@ test.describe('ABI functionality', () => { ...@@ -64,12 +64,12 @@ test.describe('ABI functionality', () => {
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible(); await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('setReserveInterestRateStrategyAddress').click(); await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled(); await expect(component.getByLabel('4.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeEnabled(); await expect(component.getByLabel('4.').getByRole('button', { name: 'Write' })).toBeEnabled();
await component.getByText('pause').click(); await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden(); await expect(component.getByLabel('7.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled(); await expect(component.getByLabel('7.').getByRole('button', { name: 'Write' })).toBeEnabled();
}); });
test('write, no wallet client', async({ render, createSocket, mockEnvs }) => { test('write, no wallet client', async({ render, createSocket, mockEnvs }) => {
...@@ -86,11 +86,11 @@ test.describe('ABI functionality', () => { ...@@ -86,11 +86,11 @@ test.describe('ABI functionality', () => {
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden(); await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('setReserveInterestRateStrategyAddress').click(); await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled(); await expect(component.getByLabel('4.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeDisabled(); await expect(component.getByLabel('4.').getByRole('button', { name: 'Write' })).toBeDisabled();
await component.getByText('pause').click(); await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden(); await expect(component.getByLabel('7.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled(); await expect(component.getByLabel('7.').getByRole('button', { name: 'Write' })).toBeDisabled();
}); });
}); });
...@@ -4,18 +4,24 @@ import React from 'react'; ...@@ -4,18 +4,24 @@ import React from 'react';
import type { SmartContractMethod } from './types'; import type { SmartContractMethod } from './types';
import { route } from 'nextjs-routes';
import { apos } from 'lib/html-entities';
import LinkInternal from 'ui/shared/links/LinkInternal';
import ContractAbiItem from './ContractAbiItem'; import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit'; import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod'; import useScrollToMethod from './useScrollToMethod';
interface Props { interface Props {
abi: Array<SmartContractMethod>; abi: Array<SmartContractMethod>;
visibleItems?: Array<number>;
addressHash: string; addressHash: string;
tab: string; tab: string;
sourceAddress?: string; sourceAddress?: string;
} }
const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => { const ContractAbi = ({ abi, addressHash, sourceAddress, tab, visibleItems }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(abi.length === 1 ? [ 0 ] : []); const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(abi.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0); const [ id, setId ] = React.useState(0);
...@@ -43,8 +49,10 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => { ...@@ -43,8 +49,10 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => {
setId((id) => id + 1); setId((id) => id + 1);
}, []); }, []);
const hasVisibleItems = !visibleItems || visibleItems.length > 0;
return ( return (
<> <div>
<Flex mb={ 3 }> <Flex mb={ 3 }>
<Box fontWeight={ 500 } mr="auto">Contract information</Box> <Box fontWeight={ 500 } mr="auto">Contract information</Box>
{ abi.length > 1 && ( { abi.length > 1 && (
...@@ -58,9 +66,10 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => { ...@@ -58,9 +66,10 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => {
{ abi.map((item, index) => ( { abi.map((item, index) => (
<ContractAbiItem <ContractAbiItem
key={ index } key={ index }
data={ item }
id={ id } id={ id }
index={ index } index={ index }
data={ item }
isVisible={ !visibleItems || visibleItems.includes(index) }
addressHash={ addressHash } addressHash={ addressHash }
sourceAddress={ sourceAddress } sourceAddress={ sourceAddress }
tab={ tab } tab={ tab }
...@@ -68,7 +77,22 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => { ...@@ -68,7 +77,22 @@ const ContractAbi = ({ abi, addressHash, sourceAddress, tab }: Props) => {
/> />
)) } )) }
</Accordion> </Accordion>
</> { !hasVisibleItems && (
<div>
<div>Couldn{ apos }t find any method that matches your query.</div>
<div>
You can use custom ABI for this contract without verifying the contract in the{ ' ' }
<LinkInternal
href={ route({ pathname: '/address/[hash]', query: { hash: addressHash, tab: 'read_write_custom_methods' } }) }
scroll={ false }
>
Custom ABI
</LinkInternal>
{ ' ' }tab.
</div>
</div>
) }
</div>
); );
}; };
......
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box } from '@chakra-ui/react'; import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Tag } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll'; import { Element } from 'react-scroll';
...@@ -7,12 +7,12 @@ import type { FormSubmitHandler, SmartContractMethod } from './types'; ...@@ -7,12 +7,12 @@ import type { FormSubmitHandler, SmartContractMethod } from './types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import ContractMethodForm from './form/ContractMethodForm'; import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod'; import { getElementId, getElementName } from './useScrollToMethod';
import { isReadMethod } from './utils';
interface Props { interface Props {
data: SmartContractMethod; data: SmartContractMethod;
...@@ -22,16 +22,13 @@ interface Props { ...@@ -22,16 +22,13 @@ interface Props {
sourceAddress?: string; sourceAddress?: string;
tab: string; tab: string;
onSubmit: FormSubmitHandler; onSubmit: FormSubmitHandler;
isVisible?: boolean;
} }
const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onSubmit }: Props) => { const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onSubmit, isVisible = true }: Props) => {
const [ attempt, setAttempt ] = React.useState(0); const [ attempt, setAttempt ] = React.useState(0);
const url = React.useMemo(() => { const url = React.useMemo(() => {
if (!('method_id' in data)) {
return '';
}
return config.app.baseUrl + route({ return config.app.baseUrl + route({
pathname: '/address/[hash]', pathname: '/address/[hash]',
query: { query: {
...@@ -39,7 +36,7 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS ...@@ -39,7 +36,7 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS
tab, tab,
...(sourceAddress ? { source_address: sourceAddress } : {}), ...(sourceAddress ? { source_address: sourceAddress } : {}),
}, },
hash: data.method_id, hash: getElementId(data),
}); });
}, [ addressHash, data, tab, sourceAddress ]); }, [ addressHash, data, tab, sourceAddress ]);
...@@ -55,23 +52,37 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS ...@@ -55,23 +52,37 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS
setAttempt((prev) => prev + 1); setAttempt((prev) => prev + 1);
}, []); }, []);
const isRead = isReadMethod(data);
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }} display={ isVisible ? 'block' : 'none' }>
{ ({ isExpanded }) => ( { ({ isExpanded }) => (
<> <>
<Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }> <Element as="h2" name={ getElementName(data) }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer"> <AccordionButton
{ 'method_id' in data && <CopyToClipboard text={ url } onClick={ handleCopyLinkClick } type="link" mr={ 2 } ml={ 0 } color="text_secondary"/> } px={ 0 }
<Box as="span" fontWeight={ 500 } mr={ 1 }> py={ 3 }
_hover={{ bgColor: 'inherit' }}
wordBreak="break-all"
textAlign="left"
as="div"
cursor="pointer"
display="flex"
alignItems="center"
columnGap={ 2 }
>
<CopyToClipboard text={ url } onClick={ handleCopyLinkClick } type="link" ml={ 0 } color="text_secondary"/>
<Box as="div" fontWeight={ 500 } display="flex" alignItems="center">
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } { index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name }
</Box>
{ data.type === 'fallback' && ( { data.type === 'fallback' && (
<Hint <Hint
label={ label={
`The fallback function is executed on a call to the contract if none of the other functions match `The fallback function is executed on a call to the contract if none of the other functions match
the given function signature, or if no data was supplied at all and there is no receive Ether function. the given function signature, or if no data was supplied at all and there is no receive Ether function.
The fallback function always receives data, but in order to also receive Ether it must be marked payable.` The fallback function always receives data, but in order to also receive Ether it must be marked payable.`
}/> }
ml={ 1 }
/>
) } ) }
{ data.type === 'receive' && ( { data.type === 'receive' && (
<Hint <Hint
...@@ -81,13 +92,17 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS ...@@ -81,13 +92,17 @@ const ContractAbiItem = ({ data, index, id, addressHash, sourceAddress, tab, onS
If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer.
If neither a receive Ether nor a payable fallback function is present, If neither a receive Ether nor a payable fallback function is present,
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }
ml={ 1 }
/>
) } ) }
</Box>
<Tag colorScheme={ isRead ? 'black-purple' : 'black-blue' } flexShrink={ 0 }>{ isRead ? 'read' : 'write' }</Tag>
{ 'method_id' in data && ( { 'method_id' in data && (
<> <Tag display="inline-flex" alignItems="center" flexShrink={ 0 }>
<Tag>{ data.method_id }</Tag> { data.method_id }
<CopyToClipboard text={ `${ data.name } (${ data.method_id })` } onClick={ handleCopyMethodIdClick }/> <CopyToClipboard text={ data.method_id } onClick={ handleCopyMethodIdClick }/>
</> </Tag>
) } ) }
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
......
...@@ -50,7 +50,7 @@ const ContractConnectWallet = ({ isLoading }: Props) => { ...@@ -50,7 +50,7 @@ const ContractConnectWallet = ({ isLoading }: Props) => {
})(); })();
return ( return (
<Skeleton isLoaded={ !isLoading } mb={ 6 }> <Skeleton isLoaded={ !isLoading }>
<Alert status={ web3Wallet.address ? 'success' : 'warning' }> <Alert status={ web3Wallet.address ? 'success' : 'warning' }>
{ content } { content }
</Alert> </Alert>
......
...@@ -8,7 +8,7 @@ interface Props { ...@@ -8,7 +8,7 @@ interface Props {
const ContractCustomAbiAlert = ({ isLoading }: Props) => { const ContractCustomAbiAlert = ({ isLoading }: Props) => {
return ( return (
<Skeleton isLoaded={ !isLoading }> <Skeleton isLoaded={ !isLoading }>
<Alert status="warning" mb={ 6 }> <Alert status="warning">
Note: Contract with custom ABI is only meant for debugging purpose and it is the user’s responsibility to ensure that the provided ABI Note: Contract with custom ABI is only meant for debugging purpose and it is the user’s responsibility to ensure that the provided ABI
matches the contract, otherwise errors may occur or results returned may be incorrect. matches the contract, otherwise errors may occur or results returned may be incorrect.
Blockscout is not responsible for any losses that arise from the use of Read & Write contract. Blockscout is not responsible for any losses that arise from the use of Read & Write contract.
......
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { MethodType, SmartContractMethod } from './types'; import type { MethodType } from './types';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ContractAbi';
interface Props { interface Props {
abi: Array<SmartContractMethod>;
isLoading?: boolean; isLoading?: boolean;
isError?: boolean; isError?: boolean;
isEmpty?: boolean;
type: MethodType; type: MethodType;
sourceAddress?: string; children: JSX.Element;
} }
const ContractMethods = ({ abi, isLoading, isError, type, sourceAddress }: Props) => { const ContractMethodsContainer = ({ isLoading, isError, isEmpty, type, children }: Props) => {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
if (isLoading) { if (isLoading) {
return <ContentLoader/>; return <ContentLoader w="fit-content"/>;
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
if (abi.length === 0) { if (isEmpty) {
const typeText = type === 'all' ? '' : type; const typeText = type === 'all' ? '' : type;
return <span>No public { typeText } functions were found for this contract.</span>; return <span>No public { typeText } functions were found for this contract.</span>;
} }
return <ContractAbi abi={ abi } tab={ tab } addressHash={ addressHash } sourceAddress={ sourceAddress }/>; return children;
}; };
export default React.memo(ContractMethods); export default React.memo(ContractMethodsContainer);
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import type { AbiItem } from 'viem';
import * as addressMock from 'mocks/address/address';
import * as methodsMock from 'mocks/contract/methods';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test, expect } from 'playwright/lib';
import ContractMethodsCustom from './ContractMethodsCustom';
const addressHash = addressMock.hash;
const authTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
authTest('without data', async({ render }) => {
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'read_write_custom_methods' },
},
};
const component = await render(<ContractMethodsCustom/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
authTest('with data', async({ render, mockApiResponse }) => {
const abi: Array<AbiItem> = [ ...methodsMock.read, ...methodsMock.write ];
await mockApiResponse('custom_abi', [ {
abi,
contract_address_hash: addressHash,
contract_address: addressMock.withName,
id: 1,
name: 'Test',
} ]);
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'read_write_custom_methods' },
},
};
const component = await render(<ContractMethodsCustom/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
import { Button, Flex, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { MethodType, SmartContractMethod } from './types'; import type { SmartContract } from 'types/api/contract';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import ContractAbi from './ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractMethods from './ContractMethods'; import ContractMethodsContainer from './ContractMethodsContainer';
import ContractMethodsFilters from './ContractMethodsFilters';
import useMethodsFilters from './useMethodsFilters';
import { enrichWithMethodId, isMethod } from './utils';
interface Props { interface Props {
abi: Array<SmartContractMethod>;
isLoading?: boolean; isLoading?: boolean;
type: MethodType;
} }
const ContractMethodsCustom = ({ abi, isLoading, type }: Props) => { const ContractMethodsCustom = ({ isLoading: isLoadingProp }: Props) => {
const modal = useDisclosure();
const router = useRouter();
const queryClient = useQueryClient();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const isAuth = useIsAuth();
const customAbiQuery = useApiQuery('custom_abi', {
queryOptions: {
enabled: !isLoadingProp && isAuth,
refetchOnMount: false,
},
});
const contractQueryData = queryClient.getQueryData<SmartContract>(getResourceKey('contract', { pathParams: { hash: addressHash } }));
const isLoading = isLoadingProp || (isAuth && customAbiQuery.isLoading);
const currentInfo = customAbiQuery.data?.find((item) => item.contract_address_hash.toLowerCase() === addressHash.toLowerCase());
const modalData = currentInfo ?? (contractQueryData ? {
name: contractQueryData.name || '',
contract_address_hash: addressHash,
} : undefined);
const abi = React.useMemo(() => {
return currentInfo?.abi
.filter(isMethod)
.map(enrichWithMethodId) ?? [];
}, [ currentInfo ]);
const filters = useMethodsFilters({ abi });
const updateButton = React.useMemo(() => {
return ( return (
<Skeleton isLoaded={ !isLoading } ml="auto" mr="3" borderRadius="base">
<Button
size="sm"
variant="outline"
onClick={ modal.onOpen }
>
Update
</Button>
</Skeleton>
);
}, [ isLoading, modal.onOpen ]);
return (
<Flex flexDir="column" rowGap={ 6 }>
{ currentInfo ? (
<> <>
<Flex flexDir="column" rowGap={ 2 }>
<ContractConnectWallet isLoading={ isLoading }/> <ContractConnectWallet isLoading={ isLoading }/>
<ContractCustomAbiAlert isLoading={ isLoading }/> <ContractCustomAbiAlert isLoading={ isLoading }/>
<ContractMethods abi={ abi } isLoading={ isLoading } type={ type }/> </Flex>
<RawDataSnippet
data={ JSON.stringify(abi) }
title="Contract ABI"
textareaMaxHeight="150px"
isLoading={ isLoading }
rightSlot={ updateButton }
/>
<ContractMethodsFilters
defaultMethodType={ filters.methodType }
defaultSearchTerm={ filters.searchTerm }
onChange={ filters.onChange }
isLoading={ isLoading }
/>
<ContractMethodsContainer isLoading={ isLoading } isEmpty={ abi.length === 0 } type={ filters.methodType }>
<ContractAbi abi={ abi } tab={ tab } addressHash={ addressHash } visibleItems={ filters.visibleItems }/>
</ContractMethodsContainer>
</>
) : (
<>
<Skeleton isLoaded={ !isLoading }>
Add custom ABIs for this contract and access when logged into your account. Helpful for debugging,
functional testing and contract interaction.
</Skeleton>
<AuthGuard onAuthSuccess={ modal.onOpen }>
{ ({ onClick }) => (
<Skeleton isLoaded={ !isLoading } w="fit-content">
<Button
size="sm"
onClick={ onClick }
>
Add custom ABI
</Button>
</Skeleton>
) }
</AuthGuard>
</> </>
) }
<CustomAbiModal { ...modal } data={ modalData }/>
</Flex>
); );
}; };
......
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { MethodType } from './types';
import FilterInput from 'ui/shared/filters/FilterInput';
import RadioButtonGroup from 'ui/shared/radioButtonGroup/RadioButtonGroup';
import type { MethodsFilters } from './useMethodsFilters';
import { TYPE_FILTER_OPTIONS } from './utils';
interface Props {
defaultMethodType: MethodType;
defaultSearchTerm: string;
onChange: (filter: MethodsFilters) => void;
isLoading?: boolean;
}
const ContractMethodsFilters = ({ defaultMethodType, defaultSearchTerm, onChange, isLoading }: Props) => {
const handleTypeChange = React.useCallback((value: MethodType) => {
onChange({ type: 'method_type', value });
}, [ onChange ]);
const handleSearchTermChange = React.useCallback((value: string) => {
onChange({ type: 'method_name', value });
}, [ onChange ]);
return (
<Flex columnGap={ 3 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }}>
<RadioButtonGroup<MethodType>
name="contract-methods-filter"
defaultValue={ defaultMethodType }
options={ TYPE_FILTER_OPTIONS }
onChange={ handleTypeChange }
w={{ lg: 'fit-content' }}
isLoading={ isLoading }
/>
<FilterInput
initialValue={ defaultSearchTerm }
onChange={ handleSearchTermChange }
placeholder="Search by method name"
w={{ base: '100%', lg: '450px' }}
size="xs"
isLoading={ isLoading }
/>
</Flex>
);
};
export default React.memo(ContractMethodsFilters);
import { Box } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -9,8 +9,11 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -9,8 +9,11 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import type { Item } from '../ContractSourceAddressSelector'; import type { Item } from '../ContractSourceAddressSelector';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector'; import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractAbi from './ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods'; import ContractMethodsContainer from './ContractMethodsContainer';
import ContractMethodsFilters from './ContractMethodsFilters';
import useMethodsFilters from './useMethodsFilters';
import { enrichWithMethodId, isMethod } from './utils'; import { enrichWithMethodId, isMethod } from './utils';
interface Props { interface Props {
...@@ -23,6 +26,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => { ...@@ -23,6 +26,7 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
const addressHash = getQueryParamString(router.query.hash); const addressHash = getQueryParamString(router.query.hash);
const sourceAddress = getQueryParamString(router.query.source_address); const sourceAddress = getQueryParamString(router.query.source_address);
const tab = getQueryParamString(router.query.tab);
const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === sourceAddress) || items[0]); const [ selectedItem, setSelectedItem ] = React.useState(items.find((item) => item.address === sourceAddress) || items[0]);
...@@ -38,31 +42,45 @@ const ContractMethodsMudSystem = ({ items }: Props) => { ...@@ -38,31 +42,45 @@ const ContractMethodsMudSystem = ({ items }: Props) => {
setSelectedItem(item as SmartContractMudSystemItem); setSelectedItem(item as SmartContractMudSystemItem);
}, []); }, []);
if (items.length === 0) { const abi = React.useMemo(() => {
return <span>No MUD System found for this contract.</span>; return systemInfoQuery.data?.abi?.filter(isMethod).map(enrichWithMethodId) || [];
} }, [ systemInfoQuery.data?.abi ]);
const abi = systemInfoQuery.data?.abi?.filter(isMethod).map(enrichWithMethodId) || []; const filters = useMethodsFilters({ abi });
return ( return (
<Box> <Flex flexDir="column" rowGap={ 6 }>
<ContractConnectWallet/> <ContractConnectWallet/>
<div>
<ContractSourceAddressSelector <ContractSourceAddressSelector
items={ items } items={ items }
selectedItem={ selectedItem } selectedItem={ selectedItem }
onItemSelect={ handleItemSelect } onItemSelect={ handleItemSelect }
label="System address" label="System address"
mb={ 6 } mb={ 3 }
/> />
<ContractMethods <ContractMethodsFilters
defaultMethodType={ filters.methodType }
defaultSearchTerm={ filters.searchTerm }
onChange={ filters.onChange }
/>
</div>
<ContractMethodsContainer
key={ selectedItem.address } key={ selectedItem.address }
abi={ abi }
isLoading={ systemInfoQuery.isPending } isLoading={ systemInfoQuery.isPending }
isEmpty={ abi.length === 0 }
type={ filters.methodType }
isError={ systemInfoQuery.isError } isError={ systemInfoQuery.isError }
>
<ContractAbi
abi={ abi }
tab={ tab }
addressHash={ addressHash }
visibleItems={ filters.visibleItems }
sourceAddress={ selectedItem.address } sourceAddress={ selectedItem.address }
type="all"
/> />
</Box> </ContractMethodsContainer>
</Flex>
); );
}; };
......
...@@ -20,7 +20,7 @@ test('with one implementation +@mobile', async({ render, mockApiResponse }) => { ...@@ -20,7 +20,7 @@ test('with one implementation +@mobile', async({ render, mockApiResponse }) => {
]; ];
await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } }); await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } });
const component = await render(<ContractMethodsProxy implementations={ implementations } type="read"/>, { hooksConfig }); const component = await render(<ContractMethodsProxy implementations={ implementations }/>, { hooksConfig });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -36,6 +36,6 @@ test('with multiple implementations +@mobile', async({ render, mockApiResponse } ...@@ -36,6 +36,6 @@ test('with multiple implementations +@mobile', async({ render, mockApiResponse }
]; ];
await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } }); await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } });
const component = await render(<ContractMethodsProxy implementations={ implementations } type="read"/>, { hooksConfig }); const component = await render(<ContractMethodsProxy implementations={ implementations }/>, { hooksConfig });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { Box } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { MethodType } from './types';
import type { AddressImplementation } from 'types/api/addressParams'; import type { AddressImplementation } from 'types/api/addressParams';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContractSourceAddressSelector from '../ContractSourceAddressSelector'; import ContractSourceAddressSelector from '../ContractSourceAddressSelector';
import ContractAbi from './ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods'; import ContractMethodsContainer from './ContractMethodsContainer';
import { enrichWithMethodId, isReadMethod, isWriteMethod } from './utils'; import ContractMethodsFilters from './ContractMethodsFilters';
import useMethodsFilters from './useMethodsFilters';
import { enrichWithMethodId, isMethod } from './utils';
interface Props { interface Props {
type: MethodType;
implementations: Array<AddressImplementation>; implementations: Array<AddressImplementation>;
isLoading?: boolean; isLoading?: boolean;
} }
const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoading }: Props) => { const ContractMethodsProxy = ({ implementations, isLoading: isInitialLoading }: Props) => {
const router = useRouter(); const router = useRouter();
const contractAddress = getQueryParamString(router.query.source_address); const sourceAddress = getQueryParamString(router.query.source_address);
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const [ selectedItem, setSelectedItem ] = React.useState(implementations.find((item) => item.address === contractAddress) || implementations[0]); const [ selectedItem, setSelectedItem ] = React.useState(implementations.find((item) => item.address === sourceAddress) || implementations[0]);
const contractQuery = useApiQuery('contract', { const contractQuery = useApiQuery('contract', {
pathParams: { hash: selectedItem.address }, pathParams: { hash: selectedItem.address },
...@@ -33,28 +36,47 @@ const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoadi ...@@ -33,28 +36,47 @@ const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoadi
}, },
}); });
const abi = contractQuery.data?.abi?.filter(type === 'read' ? isReadMethod : isWriteMethod).map(enrichWithMethodId) || []; const abi = React.useMemo(() => {
return contractQuery.data?.abi?.filter(isMethod).map(enrichWithMethodId) || [];
}, [ contractQuery.data?.abi ]);
const filters = useMethodsFilters({ abi });
return ( return (
<Box> <Flex flexDir="column" rowGap={ 6 }>
<ContractConnectWallet isLoading={ isInitialLoading }/> <ContractConnectWallet isLoading={ isInitialLoading }/>
<div>
<ContractSourceAddressSelector <ContractSourceAddressSelector
items={ implementations } items={ implementations }
selectedItem={ selectedItem } selectedItem={ selectedItem }
onItemSelect={ setSelectedItem } onItemSelect={ setSelectedItem }
isLoading={ isInitialLoading } isLoading={ isInitialLoading }
label="Implementation address" label="Implementation address"
mb={ 6 } mb={ 3 }
/>
<ContractMethodsFilters
defaultMethodType={ filters.methodType }
defaultSearchTerm={ filters.searchTerm }
onChange={ filters.onChange }
isLoading={ isInitialLoading }
/> />
<ContractMethods </div>
<ContractMethodsContainer
key={ selectedItem.address } key={ selectedItem.address }
abi={ abi }
isLoading={ isInitialLoading || contractQuery.isPending } isLoading={ isInitialLoading || contractQuery.isPending }
isEmpty={ abi.length === 0 }
type={ filters.methodType }
isError={ contractQuery.isError } isError={ contractQuery.isError }
>
<ContractAbi
abi={ abi }
tab={ tab }
addressHash={ addressHash }
visibleItems={ filters.visibleItems }
sourceAddress={ selectedItem.address } sourceAddress={ selectedItem.address }
type={ type }
/> />
</Box> </ContractMethodsContainer>
</Flex>
); );
}; };
......
import React from 'react'; import React from 'react';
import type { SmartContractMethod } from './types';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import * as methodsMock from 'mocks/contract/methods'; import * as methodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
...@@ -8,7 +10,7 @@ import ContractMethodsRegular from './ContractMethodsRegular'; ...@@ -8,7 +10,7 @@ import ContractMethodsRegular from './ContractMethodsRegular';
const addressHash = addressMock.hash; const addressHash = addressMock.hash;
test('read methods', async({ render, mockContractReadResponse }) => { test('can read method', async({ render, mockContractReadResponse }) => {
// for some reason it takes a long time for "wagmi" library to parse response result in the test environment // for some reason it takes a long time for "wagmi" library to parse response result in the test environment
// so I had to increase the test timeout // so I had to increase the test timeout
test.slow(); test.slow();
...@@ -25,21 +27,21 @@ test('read methods', async({ render, mockContractReadResponse }) => { ...@@ -25,21 +27,21 @@ test('read methods', async({ render, mockContractReadResponse }) => {
result: [ 'USDC' ], result: [ 'USDC' ],
}); });
const component = await render(<ContractMethodsRegular abi={ methodsMock.read } type="read"/>, { hooksConfig }); const component = await render(<ContractMethodsRegular abi={ methodsMock.read }/>, { hooksConfig });
await component.getByText(/expand all/i).click(); await component.getByText(/expand all/i).click();
await expect(component.getByText('USDC')).toBeVisible({ timeout: 20_000 }); await expect(component.getByText('USDC')).toBeVisible({ timeout: 20_000 });
await expect(component).toHaveScreenshot();
}); });
test('write methods +@dark-mode +@mobile', async({ render }) => { test('all methods +@dark-mode +@mobile', async({ render }) => {
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: addressHash, tab: 'write_contract' }, query: { hash: addressHash, tab: 'read_write_contract' },
}, },
}; };
const component = await render(<ContractMethodsRegular abi={ methodsMock.write } type="write"/>, { hooksConfig }); const abi: Array<SmartContractMethod> = [ ...methodsMock.read, ...methodsMock.write ];
const component = await render(<ContractMethodsRegular abi={ abi }/>, { hooksConfig });
await component.getByText(/expand all/i).click(); await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { MethodType, SmartContractMethod } from './types'; import type { SmartContractMethod } from './types';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractAbi from './ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods'; import ContractMethodsContainer from './ContractMethodsContainer';
import ContractMethodsFilters from './ContractMethodsFilters';
import useMethodsFilters from './useMethodsFilters';
interface Props { interface Props {
abi: Array<SmartContractMethod>; abi: Array<SmartContractMethod>;
isLoading?: boolean; isLoading?: boolean;
type: MethodType;
} }
const ContractMethodsRegular = ({ abi, isLoading, type }: Props) => { const ContractMethodsRegular = ({ abi, isLoading }: Props) => {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const filters = useMethodsFilters({ abi });
return ( return (
<> <Flex flexDir="column" rowGap={ 6 }>
<ContractConnectWallet isLoading={ isLoading }/> <ContractConnectWallet isLoading={ isLoading }/>
<ContractMethods abi={ abi } isLoading={ isLoading } type={ type }/> <ContractMethodsFilters
</> defaultMethodType={ filters.methodType }
defaultSearchTerm={ filters.searchTerm }
onChange={ filters.onChange }
isLoading={ isLoading }
/>
<ContractMethodsContainer isLoading={ isLoading } isEmpty={ abi.length === 0 } type={ filters.methodType }>
<ContractAbi abi={ abi } tab={ tab } addressHash={ addressHash } visibleItems={ filters.visibleItems }/>
</ContractMethodsContainer>
</Flex>
); );
}; };
......
...@@ -121,7 +121,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -121,7 +121,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
<InputRightElement w="auto" right={ 1 } bgColor={ inputBgColor } h="calc(100% - 4px)" top="2px" borderRadius="base"> <InputRightElement w="auto" right={ 1 } bgColor={ inputBgColor } h="calc(100% - 4px)" top="2px" borderRadius="base">
{ field.value !== undefined && field.value !== '' && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> } { field.value !== undefined && field.value !== '' && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ data.type === 'address' && <ContractMethodAddressButton onClick={ handleAddressButtonClick } isDisabled={ isDisabled }/> } { data.type === 'address' && <ContractMethodAddressButton onClick={ handleAddressButtonClick } isDisabled={ isDisabled }/> }
{ argTypeMatchInt && (hasTimestampButton ? ( { argTypeMatchInt && !isNativeCoin && (hasTimestampButton ? (
<Button <Button
variant="subtle" variant="subtle"
colorScheme="gray" colorScheme="gray"
......
import _pickBy from 'lodash/pickBy';
import { useRouter } from 'next/router';
import React from 'react';
import type { MethodType, SmartContractMethod } from './types';
import getQueryParamString from 'lib/router/getQueryParamString';
import type { CONTRACT_MAIN_TAB_IDS } from '../utils';
import { TYPE_FILTER_OPTIONS, isReadMethod, isWriteMethod } from './utils';
function getInitialMethodType(tab: string) {
switch (tab) {
case 'read_contract':
case 'read_proxy':
case 'read_custom_methods':
return 'read';
case 'write_contract':
case 'write_proxy':
case 'write_custom_methods':
return 'write';
default:
return 'all';
}
}
const METHOD_TABS_MATRIX: Array<Array<typeof CONTRACT_MAIN_TAB_IDS[number]>> = [
[ 'read_write_contract', 'read_contract', 'write_contract' ],
[ 'read_write_proxy', 'read_proxy', 'write_proxy' ],
[ 'read_write_custom_methods', 'read_custom_methods', 'write_custom_methods' ],
];
interface MethodFilterType {
type: 'method_type';
value: MethodType;
}
interface MethodFilterName {
type: 'method_name';
value: string;
}
export type MethodsFilters = MethodFilterType | MethodFilterName;
interface Params {
abi: Array<SmartContractMethod>;
}
export default function useMethodsFilters({ abi }: Params) {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const [ methodType, setMethodType ] = React.useState<MethodType>(getInitialMethodType(tab));
const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const changeTabInQuery = React.useCallback((methodType: MethodType) => {
const currentTab = getQueryParamString(router.query.tab);
const tabIndex = TYPE_FILTER_OPTIONS.findIndex(({ value }) => value === methodType);
const nextTab = METHOD_TABS_MATRIX.find((tabsSet) => tabsSet.includes(currentTab))?.[tabIndex];
if (!nextTab) {
return;
}
const queryForPathname = _pickBy(router.query, (value, key) => router.pathname.includes(`[${ key }]`));
router.push(
{ pathname: router.pathname, query: { ...queryForPathname, tab: nextTab } },
undefined,
{ shallow: true },
);
}, [ router ]);
const onChange = React.useCallback((filters: MethodsFilters) => {
if (filters.type === 'method_type') {
setMethodType(filters.value);
changeTabInQuery(filters.value);
} else if (filters.type === 'method_name') {
setSearchTerm(filters.value);
}
}, [ changeTabInQuery ]);
return React.useMemo(() => {
const typeFilterFn = (() => {
switch (methodType) {
case 'all':
return () => true;
case 'read':
return isReadMethod;
case 'write':
return isWriteMethod;
}
})();
const nameFilterFn = (method: SmartContractMethod) => {
const searchTermLower = searchTerm.toLowerCase().trim();
if (searchTermLower === '') {
return true;
}
if (method.type === 'fallback') {
return 'fallback'.includes(searchTermLower);
}
if (method.type === 'receive') {
return 'receive'.includes(searchTermLower);
}
return method.name.toLowerCase().includes(searchTermLower);
};
return {
methodType,
searchTerm,
onChange,
visibleItems: abi
.map((method, index) => typeFilterFn(method) && nameFilterFn(method) ? index : -1)
.filter((item) => item !== -1),
};
}, [ methodType, searchTerm, onChange, abi ]);
}
...@@ -3,19 +3,33 @@ import { scroller } from 'react-scroll'; ...@@ -3,19 +3,33 @@ import { scroller } from 'react-scroll';
import type { SmartContractMethod } from './types'; import type { SmartContractMethod } from './types';
export const getElementName = (id: string) => `method_${ id }`; export const getElementId = (data: SmartContractMethod) => {
if ('method_id' in data) {
return data.method_id;
}
if ('name' in data) {
return data.name;
}
return data.type;
};
export const getElementName = (data: SmartContractMethod) => {
return `method_${ getElementId(data) }`;
};
export default function useScrollToMethod(data: Array<SmartContractMethod>, onScroll: (indices: Array<number>) => void) { export default function useScrollToMethod(data: Array<SmartContractMethod>, onScroll: (indices: Array<number>) => void) {
React.useEffect(() => { React.useEffect(() => {
const id = window.location.hash.replace('#', ''); const hash = window.location.hash.replace('#', '');
if (!id) { if (!hash) {
return; return;
} }
const index = data.findIndex((item) => 'method_id' in item && item.method_id === id); const index = data.findIndex((item) => getElementId(item) === hash);
if (index > -1) { if (index > -1) {
scroller.scrollTo(getElementName(id), { scroller.scrollTo(getElementName(data[ index ]), {
duration: 500, duration: 500,
smooth: true, smooth: true,
offset: -100, offset: -100,
......
...@@ -2,7 +2,7 @@ import type { Abi, AbiFallback, AbiReceive } from 'abitype'; ...@@ -2,7 +2,7 @@ import type { Abi, AbiFallback, AbiReceive } from 'abitype';
import type { AbiFunction } from 'viem'; import type { AbiFunction } from 'viem';
import { toFunctionSelector } from 'viem'; import { toFunctionSelector } from 'viem';
import type { SmartContractMethod, SmartContractMethodRead, SmartContractMethodWrite } from './types'; import type { MethodType, SmartContractMethod, SmartContractMethodRead, SmartContractMethodWrite } from './types';
export const getNativeCoinValue = (value: unknown) => { export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
...@@ -12,11 +12,6 @@ export const getNativeCoinValue = (value: unknown) => { ...@@ -12,11 +12,6 @@ export const getNativeCoinValue = (value: unknown) => {
return BigInt(value); return BigInt(value);
}; };
interface DividedAbi {
read: Array<SmartContractMethodRead>;
write: Array<SmartContractMethodWrite>;
}
export const isMethod = (method: Abi[number]): method is SmartContractMethod => export const isMethod = (method: Abi[number]): method is SmartContractMethod =>
(method.type === 'function' || method.type === 'fallback' || method.type === 'receive'); (method.type === 'function' || method.type === 'fallback' || method.type === 'receive');
...@@ -37,7 +32,7 @@ export const enrichWithMethodId = (method: AbiFunction | AbiFallback | AbiReceiv ...@@ -37,7 +32,7 @@ export const enrichWithMethodId = (method: AbiFunction | AbiFallback | AbiReceiv
try { try {
return { return {
...method, ...method,
method_id: toFunctionSelector(method).slice(2), method_id: toFunctionSelector(method),
}; };
} catch (error) { } catch (error) {
return { return {
...@@ -47,13 +42,8 @@ export const enrichWithMethodId = (method: AbiFunction | AbiFallback | AbiReceiv ...@@ -47,13 +42,8 @@ export const enrichWithMethodId = (method: AbiFunction | AbiFallback | AbiReceiv
} }
}; };
export function divideAbiIntoMethodTypes(abi: Abi): DividedAbi { export const TYPE_FILTER_OPTIONS: Array<{ value: MethodType; title: string }> = [
return { { value: 'all', title: 'All' },
read: abi { value: 'read', title: 'Read' },
.filter(isReadMethod) { value: 'write', title: 'Write' },
.map(enrichWithMethodId) as Array<SmartContractMethodRead>, ];
write: abi
.filter(isWriteMethod)
.map(enrichWithMethodId) as Array<SmartContractMethodWrite>,
};
}
...@@ -3,8 +3,8 @@ import React from 'react'; ...@@ -3,8 +3,8 @@ import React from 'react';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import * as stubs from 'stubs/contract'; import * as stubs from 'stubs/contract';
...@@ -13,14 +13,14 @@ import ContractMethodsCustom from 'ui/address/contract/methods/ContractMethodsCu ...@@ -13,14 +13,14 @@ import ContractMethodsCustom from 'ui/address/contract/methods/ContractMethodsCu
import ContractMethodsMudSystem from 'ui/address/contract/methods/ContractMethodsMudSystem'; import ContractMethodsMudSystem from 'ui/address/contract/methods/ContractMethodsMudSystem';
import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy'; import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy';
import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsRegular'; import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsRegular';
import { divideAbiIntoMethodTypes } from 'ui/address/contract/methods/utils'; import { enrichWithMethodId, isMethod } from 'ui/address/contract/methods/utils';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import type { CONTRACT_MAIN_TAB_IDS } from './utils'; import type { CONTRACT_MAIN_TAB_IDS } from './utils';
import { CONTRACT_DETAILS_TAB_IDS, CONTRACT_TAB_IDS } from './utils'; import { CONTRACT_DETAILS_TAB_IDS, CONTRACT_TAB_IDS } from './utils';
interface ContractTab { interface ContractTab {
id: typeof CONTRACT_MAIN_TAB_IDS[number]; id: typeof CONTRACT_MAIN_TAB_IDS[number] | Array<typeof CONTRACT_MAIN_TAB_IDS[number]>;
title: string; title: string;
component: JSX.Element; component: JSX.Element;
subTabs?: Array<string>; subTabs?: Array<string>;
...@@ -52,13 +52,6 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder ...@@ -52,13 +52,6 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
}, },
}); });
const customAbiQuery = useApiQuery('custom_abi', {
queryOptions: {
enabled: isEnabled && isQueryEnabled && Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
refetchOnMount: false,
},
});
const mudSystemsQuery = useApiQuery('contract_mud_systems', { const mudSystemsQuery = useApiQuery('contract_mud_systems', {
pathParams: { hash: data?.hash }, pathParams: { hash: data?.hash },
queryOptions: { queryOptions: {
...@@ -75,15 +68,7 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder ...@@ -75,15 +68,7 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
onSocketError: enableQuery, onSocketError: enableQuery,
}); });
const methods = React.useMemo(() => divideAbiIntoMethodTypes(contractQuery.data?.abi ?? []), [ contractQuery.data?.abi ]); const methods = React.useMemo(() => contractQuery.data?.abi?.filter(isMethod).map(enrichWithMethodId) ?? [], [ contractQuery.data?.abi ]);
const methodsCustomAbi = React.useMemo(() => {
return divideAbiIntoMethodTypes(
customAbiQuery.data
?.find((item) => data && item.contract_address_hash.toLowerCase() === data.hash.toLowerCase())
?.abi ??
[],
);
}, [ customAbiQuery.data, data ]);
const verifiedImplementations = React.useMemo(() => { const verifiedImplementations = React.useMemo(() => {
return data?.implementations?.filter(({ name, address }) => name && address && address !== data?.hash) || []; return data?.implementations?.filter(({ name, address }) => name && address && address !== data?.hash) || [];
...@@ -98,47 +83,20 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder ...@@ -98,47 +83,20 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
component: <ContractDetails mainContractQuery={ contractQuery } channel={ channel } addressHash={ data.hash }/>, component: <ContractDetails mainContractQuery={ contractQuery } channel={ channel } addressHash={ data.hash }/>,
subTabs: CONTRACT_DETAILS_TAB_IDS as unknown as Array<string>, subTabs: CONTRACT_DETAILS_TAB_IDS as unknown as Array<string>,
}, },
methods.read.length > 0 && { methods.length > 0 && {
id: 'read_contract' as const, id: [ 'read_write_contract' as const, 'read_contract' as const, 'write_contract' as const ],
title: 'Read contract', title: 'Read/Write contract',
component: <ContractMethodsRegular type="read" abi={ methods.read } isLoading={ contractQuery.isPlaceholderData }/>, component: <ContractMethodsRegular abi={ methods } isLoading={ contractQuery.isPlaceholderData }/>,
},
methodsCustomAbi.read.length > 0 && {
id: 'read_custom_methods' as const,
title: 'Read custom',
component: <ContractMethodsCustom type="read" abi={ methodsCustomAbi.read } isLoading={ contractQuery.isPlaceholderData }/>,
}, },
verifiedImplementations.length > 0 && { verifiedImplementations.length > 0 && {
id: 'read_proxy' as const, id: [ 'read_write_proxy' as const, 'read_proxy' as const, 'write_proxy' as const ],
title: 'Read proxy', title: 'Read/Write proxy',
component: ( component: <ContractMethodsProxy implementations={ verifiedImplementations } isLoading={ contractQuery.isPlaceholderData }/>,
<ContractMethodsProxy
type="read"
implementations={ verifiedImplementations }
isLoading={ contractQuery.isPlaceholderData }
/>
),
}, },
methods.write.length > 0 && { config.features.account.isEnabled && {
id: 'write_contract' as const, id: [ 'read_write_custom_methods' as const, 'read_custom_methods' as const, 'write_custom_methods' as const ],
title: 'Write contract', title: 'Custom ABI',
component: <ContractMethodsRegular type="write" abi={ methods.write } isLoading={ contractQuery.isPlaceholderData }/>, component: <ContractMethodsCustom isLoading={ contractQuery.isPlaceholderData }/>,
},
methodsCustomAbi.write.length > 0 && {
id: 'write_custom_methods' as const,
title: 'Write custom',
component: <ContractMethodsCustom type="write" abi={ methodsCustomAbi.write } isLoading={ contractQuery.isPlaceholderData }/>,
},
verifiedImplementations.length > 0 && {
id: 'write_proxy' as const,
title: 'Write proxy',
component: (
<ContractMethodsProxy
type="write"
implementations={ verifiedImplementations }
isLoading={ contractQuery.isPlaceholderData }
/>
),
}, },
hasMudTab && { hasMudTab && {
id: 'mud_system' as const, id: 'mud_system' as const,
...@@ -151,15 +109,13 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder ...@@ -151,15 +109,13 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
isLoading: contractQuery.isPlaceholderData, isLoading: contractQuery.isPlaceholderData,
}; };
}, [ }, [
data?.hash,
contractQuery, contractQuery,
channel, channel,
data?.hash, methods,
methods.read,
methods.write,
methodsCustomAbi.read,
methodsCustomAbi.write,
verifiedImplementations, verifiedImplementations,
mudSystemsQuery,
hasMudTab, hasMudTab,
mudSystemsQuery.isPlaceholderData,
mudSystemsQuery.data?.items,
]); ]);
} }
export const CONTRACT_MAIN_TAB_IDS = [ export const CONTRACT_MAIN_TAB_IDS = [
'contract_code', 'contract_code',
'read_contract', 'read_contract',
'read_contract_rpc',
'read_proxy',
'read_custom_methods',
'write_contract', 'write_contract',
'write_contract_rpc', 'read_write_contract',
'read_proxy',
'write_proxy', 'write_proxy',
'read_write_proxy',
'read_custom_methods',
'write_custom_methods', 'write_custom_methods',
'read_write_custom_methods',
'mud_system', 'mud_system',
] as const; ] as const;
......
...@@ -16,9 +16,15 @@ import getErrorMessage from 'lib/getErrorMessage'; ...@@ -16,9 +16,15 @@ import getErrorMessage from 'lib/getErrorMessage';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress'; import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText'; import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
export type FormData = CustomAbi | {
contract_address_hash: string;
name: string;
} | undefined;
type Props = { type Props = {
data?: CustomAbi; data: FormData;
onClose: () => void; onClose: () => void;
onSuccess?: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void; setAlertVisible: (isAlertVisible: boolean) => void;
} }
...@@ -30,12 +36,12 @@ type Inputs = { ...@@ -30,12 +36,12 @@ type Inputs = {
const NAME_MAX_LENGTH = 255; const NAME_MAX_LENGTH = 255;
const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const CustomAbiForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const formApi = useForm<Inputs>({ const formApi = useForm<Inputs>({
defaultValues: { defaultValues: {
contract_address_hash: data?.contract_address_hash || '', contract_address_hash: data?.contract_address_hash || '',
name: data?.name || '', name: data?.name || '',
abi: JSON.stringify(data?.abi) || '', abi: data && 'abi' in data ? JSON.stringify(data.abi) : '',
}, },
mode: 'onTouched', mode: 'onTouched',
}); });
...@@ -58,7 +64,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -58,7 +64,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: customAbiKey, mutationFn: customAbiKey,
onSuccess: (data) => { onSuccess: async(data) => {
const response = data as unknown as CustomAbi; const response = data as unknown as CustomAbi;
queryClient.setQueryData([ resourceKey('custom_abi') ], (prevData: CustomAbis | undefined) => { queryClient.setQueryData([ resourceKey('custom_abi') ], (prevData: CustomAbis | undefined) => {
const isExisting = prevData && prevData.some((item) => item.id === response.id); const isExisting = prevData && prevData.some((item) => item.id === response.id);
...@@ -75,7 +81,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -75,7 +81,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
return [ response, ...(prevData || []) ]; return [ response, ...(prevData || []) ];
}); });
await onSuccess?.();
onClose(); onClose();
}, },
onError: (error: ResourceErrorAccount<CustomAbiErrors>) => { onError: (error: ResourceErrorAccount<CustomAbiErrors>) => {
...@@ -94,7 +100,8 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -94,7 +100,8 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const onSubmit: SubmitHandler<Inputs> = useCallback(async(formData) => { const onSubmit: SubmitHandler<Inputs> = useCallback(async(formData) => {
setAlertVisible(false); setAlertVisible(false);
await mutation.mutateAsync({ ...formData, id: data?.id ? String(data.id) : undefined }); const id = data && 'id' in data ? String(data.id) : undefined;
await mutation.mutateAsync({ ...formData, id });
}, [ mutation, data, setAlertVisible ]); }, [ mutation, data, setAlertVisible ]);
return ( return (
...@@ -105,6 +112,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -105,6 +112,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
placeholder="Smart contract address (0x...)" placeholder="Smart contract address (0x...)"
isRequired isRequired
bgColor="dialog_bg" bgColor="dialog_bg"
isReadOnly={ Boolean(data && 'contract_address_hash' in data) }
mb={ 5 } mb={ 5 }
/> />
<FormFieldText<Inputs> <FormFieldText<Inputs>
...@@ -134,7 +142,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -134,7 +142,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
isDisabled={ !formApi.formState.isDirty } isDisabled={ !formApi.formState.isDirty }
isLoading={ mutation.isPending } isLoading={ mutation.isPending }
> >
{ data ? 'Save' : 'Create custom ABI' } { data && 'id' in data ? 'Save' : 'Create custom ABI' }
</Button> </Button>
</Box> </Box>
</form> </form>
......
...@@ -4,23 +4,24 @@ import type { CustomAbi } from 'types/api/account'; ...@@ -4,23 +4,24 @@ import type { CustomAbi } from 'types/api/account';
import FormModal from 'ui/shared/FormModal'; import FormModal from 'ui/shared/FormModal';
import CustomAbiForm from './CustomAbiForm'; import CustomAbiForm, { type FormData } from './CustomAbiForm';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data?: CustomAbi; onSuccess?: () => Promise<void>;
data: FormData;
} }
const CustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const CustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data, onSuccess }) => {
const title = data ? 'Edit custom ABI' : 'New custom ABI'; const title = data && 'id' in data ? 'Edit custom ABI' : 'New custom ABI';
const text = !data ? 'Double check the ABI matches the contract to prevent errors or incorrect results.' : ''; const text = !(data && 'id' in data) ? 'Double check the ABI matches the contract to prevent errors or incorrect results.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false); const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => { const renderForm = useCallback(() => {
return <CustomAbiForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>; return <CustomAbiForm data={ data } onClose={ onClose } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]); }, [ data, onClose, onSuccess ]);
return ( return (
<FormModal<CustomAbi> <FormModal<CustomAbi>
isOpen={ isOpen } isOpen={ isOpen }
......
...@@ -116,7 +116,10 @@ const Marketplace = () => { ...@@ -116,7 +116,10 @@ const Marketplace = () => {
const selectedApp = displayedApps.find(app => app.id === selectedAppId); const selectedApp = displayedApps.find(app => app.id === selectedAppId);
const handleCategoryChange = React.useCallback((index: number) => { const handleCategoryChange = React.useCallback((index: number) => {
onCategoryChange(categoryTabs[index].id); const tabId = categoryTabs[index].id;
if (typeof tabId === 'string') {
onCategoryChange(tabId);
}
}, [ categoryTabs, onCategoryChange ]); }, [ categoryTabs, onCategoryChange ]);
const handleAppClick = React.useCallback((event: MouseEvent, id: string) => { const handleAppClick = React.useCallback((event: MouseEvent, id: string) => {
......
...@@ -110,7 +110,7 @@ const AdaptiveTabsList = (props: Props) => { ...@@ -110,7 +110,7 @@ const AdaptiveTabsList = (props: Props) => {
return ( return (
<Tab <Tab
key={ tab.id } key={ tab.id.toString() }
ref={ tabsRefs[index] } ref={ tabsRefs[index] }
{ ...(index < tabsCut ? {} : hiddenItemStyles) } { ...(index < tabsCut ? {} : hiddenItemStyles) }
scrollSnapAlign="start" scrollSnapAlign="start"
......
...@@ -43,8 +43,9 @@ const RoutedTabs = ({ ...@@ -43,8 +43,9 @@ const RoutedTabs = ({
const nextTab = tabs[index]; const nextTab = tabs[index];
const queryForPathname = _pickBy(router.query, (value, key) => router.pathname.includes(`[${ key }]`)); const queryForPathname = _pickBy(router.query, (value, key) => router.pathname.includes(`[${ key }]`));
const tabId = Array.isArray(nextTab.id) ? nextTab.id[0] : nextTab.id;
router.push( router.push(
{ pathname: router.pathname, query: { ...queryForPathname, tab: nextTab.id } }, { pathname: router.pathname, query: { ...queryForPathname, tab: tabId } },
undefined, undefined,
{ shallow: true }, { shallow: true },
); );
......
...@@ -58,7 +58,7 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act ...@@ -58,7 +58,7 @@ const TabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRef, act
<PopoverBody display="flex" flexDir="column"> <PopoverBody display="flex" flexDir="column">
{ tabs.slice(tabsCut).map((tab, index) => ( { tabs.slice(tabsCut).map((tab, index) => (
<Button <Button
key={ tab.id } key={ tab.id?.toString() }
variant="ghost" variant="ghost"
onClick={ handleItemClick } onClick={ handleItemClick }
isActive={ activeTab ? activeTab.id === tab.id : false } isActive={ activeTab ? activeTab.id === tab.id : false }
......
...@@ -37,15 +37,15 @@ const TabsSkeleton = ({ className, tabs, size = 'md' }: Props) => { ...@@ -37,15 +37,15 @@ const TabsSkeleton = ({ className, tabs, size = 'md' }: Props) => {
<Flex className={ className } my={ 8 } alignItems="center" overflow="hidden"> <Flex className={ className } my={ 8 } alignItems="center" overflow="hidden">
{ tabs.slice(0, tabIndex).map(({ title, id }) => ( { tabs.slice(0, tabIndex).map(({ title, id }) => (
<SkeletonTabText <SkeletonTabText
key={ id } key={ id.toString() }
title={ title } title={ title }
size={ size } size={ size }
/> />
)) } )) }
{ tabs.slice(tabIndex, tabIndex + 1).map(({ title, id }) => ( { tabs.slice(tabIndex, tabIndex + 1).map(({ title, id }) => (
<Box key={ id } bgColor={ bgColor } py={ size === 'sm' ? 1 : 2 } borderRadius="base" flexShrink={ 0 }> <Box key={ id.toString() } bgColor={ bgColor } py={ size === 'sm' ? 1 : 2 } borderRadius="base" flexShrink={ 0 }>
<SkeletonTabText <SkeletonTabText
key={ id } key={ id.toString() }
title={ title } title={ title }
size={ size } size={ size }
/> />
...@@ -53,7 +53,7 @@ const TabsSkeleton = ({ className, tabs, size = 'md' }: Props) => { ...@@ -53,7 +53,7 @@ const TabsSkeleton = ({ className, tabs, size = 'md' }: Props) => {
)) } )) }
{ tabs.slice(tabIndex + 1).map(({ title, id }) => ( { tabs.slice(tabIndex + 1).map(({ title, id }) => (
<SkeletonTabText <SkeletonTabText
key={ id } key={ id.toString() }
title={ title } title={ title }
size={ size } size={ size }
/> />
......
...@@ -117,7 +117,7 @@ const TabsWithScroll = ({ ...@@ -117,7 +117,7 @@ const TabsWithScroll = ({
isLoading={ isLoading } isLoading={ isLoading }
/> />
<TabPanels> <TabPanels>
{ tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id }>{ tab.component }</TabPanel>) } { tabsList.map((tab) => <TabPanel padding={ 0 } key={ tab.id?.toString() }>{ tab.component }</TabPanel>) }
</TabPanels> </TabPanels>
</Tabs> </Tabs>
); );
......
import type React from 'react'; import type React from 'react';
export interface TabItem { export interface TabItem {
id: string; // NOTE, in case of array of ids, when switching tabs, the first id will be used
// switching between other ids should be handled in the underlying component
id: string | Array<string>;
title: string | (() => React.ReactNode); title: string | (() => React.ReactNode);
count?: number | null; count?: number | null;
component: React.ReactNode; component: React.ReactNode;
......
...@@ -12,7 +12,13 @@ export default function useTabIndexFromQuery(tabs: Array<RoutedTab>) { ...@@ -12,7 +12,13 @@ export default function useTabIndexFromQuery(tabs: Array<RoutedTab>) {
return 0; return 0;
} }
const tabIndex = tabs.findIndex(({ id, subTabs }) => id === tabFromQuery || subTabs?.some((id) => id === tabFromQuery)); const tabIndex = tabs.findIndex(({ id, subTabs }) => {
if (Array.isArray(id)) {
return id.includes(tabFromQuery);
}
return id === tabFromQuery || subTabs?.some((id) => id === tabFromQuery);
});
if (tabIndex < 0) { if (tabIndex < 0) {
return 0; return 0;
......
...@@ -11,9 +11,10 @@ import { useLinkStyles } from './useLinkStyles'; ...@@ -11,9 +11,10 @@ import { useLinkStyles } from './useLinkStyles';
type Props = LinkProps & { type Props = LinkProps & {
variant?: Variants; variant?: Variants;
isLoading?: boolean; isLoading?: boolean;
scroll?: boolean;
} }
const LinkInternal = ({ isLoading, variant, ...props }: Props, ref: LegacyRef<HTMLAnchorElement>) => { const LinkInternal = ({ isLoading, variant, scroll = true, ...props }: Props, ref: LegacyRef<HTMLAnchorElement>) => {
const styleProps = useLinkStyles({}, variant); const styleProps = useLinkStyles({}, variant);
if (isLoading) { if (isLoading) {
...@@ -25,7 +26,7 @@ const LinkInternal = ({ isLoading, variant, ...props }: Props, ref: LegacyRef<HT ...@@ -25,7 +26,7 @@ const LinkInternal = ({ isLoading, variant, ...props }: Props, ref: LegacyRef<HT
} }
return ( return (
<NextLink href={ props.href as NextLinkProps['href'] } passHref target={ props.target } legacyBehavior> <NextLink href={ props.href as NextLinkProps['href'] } passHref target={ props.target } legacyBehavior scroll={ scroll }>
<Link { ...props } ref={ ref } { ...styleProps }/> <Link { ...props } ref={ ref } { ...styleProps }/>
</NextLink> </NextLink>
); );
......
import { ButtonGroup, Button, Flex, useRadio, useRadioGroup } from '@chakra-ui/react'; import { chakra, ButtonGroup, Button, Flex, useRadio, useRadioGroup, Skeleton } from '@chakra-ui/react';
import type { UseRadioProps } from '@chakra-ui/react'; import type { ChakraProps, UseRadioProps } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { IconName } from 'ui/shared/IconSvg'; import type { IconName } from 'ui/shared/IconSvg';
...@@ -8,7 +8,7 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -8,7 +8,7 @@ import IconSvg from 'ui/shared/IconSvg';
type RadioItemProps = { type RadioItemProps = {
title: string; title: string;
icon?: IconName; icon?: IconName;
onlyIcon: false | undefined; onlyIcon?: false;
contentAfter?: React.ReactNode; contentAfter?: React.ReactNode;
} | { } | {
title: string; title: string;
...@@ -67,16 +67,20 @@ type RadioButtonGroupProps<T extends string> = { ...@@ -67,16 +67,20 @@ type RadioButtonGroupProps<T extends string> = {
defaultValue: string; defaultValue: string;
options: Array<{ value: T } & RadioItemProps>; options: Array<{ value: T } & RadioItemProps>;
autoWidth?: boolean; autoWidth?: boolean;
className?: string;
isLoading?: boolean;
} }
const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, options, autoWidth = false }: RadioButtonGroupProps<T>) => { const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, options, autoWidth = false, className, isLoading }: RadioButtonGroupProps<T>) => {
const { getRootProps, getRadioProps } = useRadioGroup({ name, defaultValue, onChange }); const { getRootProps, getRadioProps } = useRadioGroup({ name, defaultValue, onChange });
const group = getRootProps(); const group = getRootProps();
return ( return (
<Skeleton isLoaded={ !isLoading }>
<ButtonGroup <ButtonGroup
{ ...group } { ...group }
className={ className }
isAttached isAttached
size="sm" size="sm"
display="grid" display="grid"
...@@ -87,7 +91,11 @@ const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, opti ...@@ -87,7 +91,11 @@ const RadioButtonGroup = <T extends string>({ onChange, name, defaultValue, opti
return <RadioButton { ...props } key={ option.value } { ...option }/>; return <RadioButton { ...props } key={ option.value } { ...option }/>;
}) } }) }
</ButtonGroup> </ButtonGroup>
</Skeleton>
); );
}; };
export default RadioButtonGroup; const WrappedRadioButtonGroup = chakra(RadioButtonGroup);
type WrappedComponent = <T extends string>(props: RadioButtonGroupProps<T> & ChakraProps) => JSX.Element;
export default React.memo(WrappedRadioButtonGroup) as WrappedComponent;
...@@ -28,6 +28,10 @@ export default function useLogout() { ...@@ -28,6 +28,10 @@ export default function useLogout() {
queryKey: getResourceKey('user_info'), queryKey: getResourceKey('user_info'),
exact: true, exact: true,
}); });
queryClient.resetQueries({
queryKey: getResourceKey('custom_abi'),
exact: true,
});
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Logged out' }, { send_immediately: true }); mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Logged out' }, { send_immediately: true });
......
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