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

"Open in IDE" button for contracts (#1544)

* "Open in IDE" button for contracts

Fixes #1508

* small fixes and tests

* update icon styles

* [skip ci] change ENV value
parent 8f822b33
import type { ContractCodeIde } from 'types/client/contract';
import { NAVIGATION_LINK_IDS, type NavItemExternal, type NavigationLinkId } from 'types/client/navigation-items';
import type { ChainIndicatorId } from 'types/homepage';
import type { NetworkExplorer } from 'types/networks';
......@@ -66,6 +67,9 @@ const UI = Object.freeze({
explorers: {
items: parseEnvJson<Array<NetworkExplorer>>(getEnvValue('NEXT_PUBLIC_NETWORK_EXPLORERS')) || [],
},
ides: {
items: parseEnvJson<Array<ContractCodeIde>>(getEnvValue('NEXT_PUBLIC_CONTRACT_CODE_IDES')) || [],
},
});
export default UI;
......@@ -49,6 +49,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_HAS_USER_OPS='true'
#meta
......
......@@ -33,6 +33,7 @@ NEXT_PUBLIC_GIT_TAG=v1.0.11
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'},{'name':'LooksRare','collection_url':'https://looksrare.org/collections/{hash}','instance_url':'https://looksrare.org/collections/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'}]
## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/%23address={hash}&blockscout=eth-goerli.blockscout.com'}]
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE=
# app features
......
......@@ -11,6 +11,7 @@ import * as yup from 'yup';
import type { AdButlerConfig } from '../../../types/client/adButlerConfig';
import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS } from '../../../types/client/adProviders';
import type { AdTextProviders, AdBannerProviders } from '../../../types/client/adProviders';
import type { ContractCodeIde } from '../../../types/client/contract';
import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
......@@ -263,6 +264,13 @@ const networkExplorerSchema: yup.ObjectSchema<NetworkExplorer> = yup
}),
});
const contractCodeIdeSchema: yup.ObjectSchema<ContractCodeIde> = yup
.object({
title: yup.string().required(),
url: yup.string().test(urlTest).required(),
icon_url: yup.string().test(urlTest).required(),
});
const nftMarketplaceSchema: yup.ObjectSchema<NftMarketplaceItem> = yup
.object({
name: yup.string().required(),
......@@ -417,6 +425,11 @@ const schema = yup
.transform(replaceQuotes)
.json()
.of(networkExplorerSchema),
NEXT_PUBLIC_CONTRACT_CODE_IDES: yup
.array()
.transform(replaceQuotes)
.json()
.of(contractCodeIdeSchema),
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS: yup.boolean(),
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS: yup.boolean(),
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(),
......
......@@ -9,6 +9,7 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS=[{'id':'1','title':'Ethereum','short_title':'ETH','base_url':'https://example.com'}]
NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES=[{'type':'omni','title':'OmniBridge','short_title':'OMNI'}]
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://example.com/icon.svg'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://example.com
NEXT_PUBLIC_FEATURED_NETWORKS=https://example.com
NEXT_PUBLIC_FOOTER_LINKS=https://example.com
......
......@@ -161,6 +161,7 @@ frontend:
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
......@@ -74,6 +74,7 @@ frontend:
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_HAS_USER_OPS: true
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
......@@ -260,6 +260,7 @@ Settings for meta tags and OG tags
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_NETWORK_EXPLORERS | `Array<NetworkExplorer>` where `NetworkExplorer` can have following [properties](#network-explorer-configuration-properties) | Used to build up links to transactions, blocks, addresses in other chain explorers. | - | - | `[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/tx'}}]` |
| NEXT_PUBLIC_CONTRACT_CODE_IDES | `Array<ContractCodeIde>` where `ContractCodeIde` can have following [properties](#contract-code-ide-configuration-properties) | Used to build up links to IDEs with contract source code. | - | - | `[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://example.com/icon.svg'}]` |
| NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS | `boolean` | Set to `true` to hide indexing alert in the page header about indexing chain's blocks | - | `false` | `true` |
| NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS | `boolean` | Set to `true` to hide indexing alert in the page footer about indexing block's internal transactions | - | `false` | `true` |
| NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE | `string` | Used for displaying custom announcements or alerts in the header of the site. Could be a regular string or a HTML code. | - | - | `Hello world! 🤪` |
......@@ -274,6 +275,14 @@ Settings for meta tags and OG tags
*Note* The url of an entity will be constructed as `<baseUrl><paths[<entity-type>]><entity-id>`, e.g `https://explorer.anyblock.tools/ethereum/poa/core/tx/<tx-id>`
#### Contract code IDE configuration properties
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| title | `string` | Displayed name of the IDE | Required | - | `Remix IDE` |
| url | `string` | URL of the IDE with placeholders for contract hash (`{hash}`) and current domain (`{domain}`) | Required | - | `https://remix.blockscout.com/?address={hash}&blockscout={domain}` |
| icon_url | `string` | URL of the IDE icon | Required | - | `https://example.com/icon.svg` |
&nbsp;
## App features
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<g clip-path="url(#a)" fill="currentColor">
<path d="M5.176 5.291a1.253 1.253 0 0 0-1.76.159L.29 9.2a1.247 1.247 0 0 0 0 1.6l3.125 3.75a1.25 1.25 0 1 0 1.919-1.6L2.876 10l2.459-2.949a1.25 1.25 0 0 0-.159-1.76Z"/>
<path d="M5.176 5.291a1.253 1.253 0 0 0-1.76.159L.29 9.2a1.247 1.247 0 0 0 0 1.6l3.125 3.75a1.25 1.25 0 1 0 1.919-1.6L2.876 10l2.459-2.949a1.25 1.25 0 0 0-.159-1.76ZM11.494 2.525a1.261 1.261 0 0 0-1.47.981l-2.5 12.5A1.247 1.247 0 0 0 8.754 17.5a1.249 1.249 0 0 0 1.223-1.006l2.5-12.5a1.246 1.246 0 0 0-.982-1.469Z"/>
<path d="M11.494 2.525a1.261 1.261 0 0 0-1.47.981l-2.5 12.5A1.247 1.247 0 0 0 8.754 17.5a1.249 1.249 0 0 0 1.223-1.006l2.5-12.5a1.246 1.246 0 0 0-.982-1.469ZM19.708 9.2l-3.124-3.75a1.249 1.249 0 1 0-1.92 1.601l2.46 2.95-2.46 2.948a1.25 1.25 0 1 0 1.92 1.602l3.124-3.75a1.246 1.246 0 0 0 0-1.601Z"/>
<path d="m19.708 9.2-3.124-3.75a1.249 1.249 0 1 0-1.92 1.601l2.46 2.95-2.46 2.948a1.25 1.25 0 1 0 1.92 1.602l3.124-3.75a1.246 1.246 0 0 0 0-1.601Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
// This file is generated by npm run build:icons
export type IconName =
| "ABI"
| "ABI_slim"
| "ABI"
| "API"
| "apps"
| "arrows/down-right"
......
export interface ContractCodeIde {
title: string;
url: string;
icon_url: string;
}
......@@ -122,6 +122,9 @@ test('verified with multiple sources', async({ mount, page }) => {
await page.getByRole('button', { name: 'View external libraries' }).click();
await expect(section).toHaveScreenshot();
await page.getByRole('button', { name: 'Open source code in IDE' }).click();
await expect(section).toHaveScreenshot();
});
test('verified via sourcify', async({ mount, page }) => {
......
import { Flex, Button, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Image, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
className?: string;
hash: string;
}
const ContractCodeIde = ({ className, hash }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const defaultIconColor = useColorModeValue('gray.600', 'gray.500');
const ideLinks = React.useMemo(() => {
return config.UI.ides.items
.map((ide) => {
const url = decodeURIComponent(ide.url.replace('{hash}', hash).replace('{domain}', config.app.host || ''));
const icon = 'icon_url' in ide ?
<Image boxSize={ 5 } mr={ 2 } src={ ide.icon_url } alt={ `${ ide.title } icon` }/> :
<IconSvg name="ABI_slim" boxSize={ 5 } color={ defaultIconColor } mr={ 2 }/>;
return (
<LinkExternal key={ ide.title } href={ url } display="inline-flex" alignItems="center">
{ icon }
{ ide.title }
</LinkExternal>
);
});
}, [ defaultIconColor, hash ]);
if (ideLinks.length === 0) {
return null;
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
className={ className }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Open source code in IDE"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<span>Open in</span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/>
</Button>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">Redactors</chakra.span>
<Flex
flexDir="column"
alignItems="flex-start"
columnGap={ 6 }
rowGap={ 3 }
mt={ 3 }
>
{ ideLinks }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(chakra(ContractCodeIde));
......@@ -13,6 +13,7 @@ import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
import ContractCodeIdes from './ContractCodeIdes';
import ContractExternalLibraries from './ContractExternalLibraries';
const SOURCE_CODE_OPTIONS = [
......@@ -116,6 +117,8 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}/> :
null;
const ides = sourceType === 'secondary' ? <ContractCodeIdes hash={ implementationAddress }/> : <ContractCodeIdes hash={ address }/>;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceType(event.target.value as SourceCodeType);
}, []);
......@@ -188,6 +191,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
{ editorSourceTypeSelector }
{ externalLibraries }
{ diagramLink }
{ ides }
{ copyToClipboard }
</Flex>
{ content }
......
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