Commit 3cc3937c authored by tom goriunov's avatar tom goriunov Committed by GitHub

Contract page improvements (#2419)

* display address selector for non-verified proxy contract

* add code view snippet for ABI and Compiler Settings

* syntax highlighting for Scilla contracts

* ENV preset for zilliqa

* fix tests

* add native coin value to simulate method call

* fix test
parent e8a46424
......@@ -11,6 +11,12 @@ export function monaco(): CspDev.DirectiveDescriptor {
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/editor/editor.main.nls.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/solidity/solidity.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/elixir/elixir.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/javascript/javascript.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/basic-languages/typescript/typescript.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/json/jsonMode.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/json/jsonWorker.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/typescript/tsMode.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/language/typescript/tsWorker.js',
'https://cdn.jsdelivr.net/npm/monaco-editor@0.33.0/min/vs/base/worker/workerMain.js',
],
'style-src': [
......
......@@ -40,6 +40,7 @@ const fixture: TestFixture<MockContractReadResponseFixture, { page: Page }> = as
args,
}),
to: address,
value: params?.value,
};
if (_isEqual(params, callParams) && id) {
......
......@@ -131,41 +131,9 @@ test('self destructed', async({ render, mockApiResponse, page }) => {
});
test('non verified', async({ render, mockApiResponse }) => {
await mockApiResponse('address', { ...addressMock.contract, name: null }, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', contractMock.nonVerified, { pathParams: { hash: addressMock.contract.hash } });
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await expect(component).toHaveScreenshot();
});
test('implementation info', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { hash: addressMock.contract.hash, tab: 'contract_compiler' },
},
};
const implementationName = addressMock.contract.implementations?.[0].name as string;
const implementationAddress = addressMock.contract.implementations?.[0].address as string;
const implementationContract = {
...contractMock.verified,
compiler_settings: {
evmVersion: 'london',
libraries: {},
metadata: {
bytecodeHash: 'ipfs',
useLiteralContent: false,
},
optimizer: {
enabled: true,
runs: 1000000,
},
},
};
await mockApiResponse('contract', contractMock.verified, { pathParams: { hash: addressMock.contract.hash } });
await mockApiResponse('contract', implementationContract, { pathParams: { hash: implementationAddress } });
const component = await render(<ContractDetails/>, { hooksConfig }, { withSocket: true });
await component.getByRole('combobox').selectOption(implementationName);
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
......@@ -38,7 +39,7 @@ const ContractDetails = ({ addressHash, channel, mainContractQuery }: Props) =>
const addressInfo = queryClient.getQueryData<AddressInfo>(getResourceKey('address', { pathParams: { hash: addressHash } }));
const sourceItems: Array<AddressImplementation> = React.useMemo(() => {
const currentAddressItem = { address: addressHash, name: addressInfo?.name || 'Contract' };
const currentAddressItem = { address: addressHash, name: addressInfo?.name || 'Current contract' };
if (!addressInfo || !addressInfo.implementations || addressInfo.implementations.length === 0) {
return [ currentAddressItem ];
}
......@@ -108,14 +109,21 @@ const ContractDetails = ({ addressHash, channel, mainContractQuery }: Props) =>
addressHash={ addressHash }
/>
) }
<RoutedTabs
tabs={ tabs }
isLoading={ isPlaceholderData }
variant="radio_group"
size="sm"
leftSlot={ addressSelector }
tabListProps={ TAB_LIST_PROPS }
/>
{ tabs.length > 1 ? (
<RoutedTabs
tabs={ tabs }
isLoading={ isPlaceholderData }
variant="radio_group"
size="sm"
leftSlot={ addressSelector }
tabListProps={ TAB_LIST_PROPS }
/>
) : (
<>
{ addressSelector && <Box mb={ 6 }>{ addressSelector }</Box> }
<div>{ tabs[0].component }</div>
</>
) }
</>
);
};
......
......@@ -7,6 +7,8 @@ import type { FormSubmitResult, MethodCallStrategy, SmartContractMethod } from '
import config from 'configs/app';
import useAccount from 'lib/web3/useAccount';
import { getNativeCoinValue } from './utils';
interface Params {
item: SmartContractMethod;
args: Array<unknown>;
......@@ -31,6 +33,7 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
// for write payable methods we add additional input for native coin value
// so in simulate mode we need to strip it off
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const params = {
abi: [ item ],
......@@ -38,6 +41,7 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
args: _args,
address,
account,
value,
};
const result = strategy === 'read' ? await publicClient.readContract(params) : await publicClient.simulateContract(params);
......
......@@ -3,6 +3,7 @@ import React from 'react';
import type { SmartContract } from 'types/api/contract';
import CodeViewSnippet from 'ui/shared/CodeViewSnippet';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import ContractDetailsConstructorArgs from './ContractDetailsConstructorArgs';
......@@ -58,10 +59,11 @@ export default function useContractDetailsTabs({ data, isLoading, addressHash, s
id: 'contract_compiler' as const,
title: 'Compiler',
component: (
<RawDataSnippet
data={ JSON.stringify(data.compiler_settings, undefined, 4) }
<CodeViewSnippet
data={ JSON.stringify(data.compiler_settings, undefined, 2) }
language="json"
title="Compiler Settings"
textareaMaxHeight="600px"
copyData={ JSON.stringify(data.compiler_settings) }
isLoading={ isLoading }
/>
),
......@@ -71,10 +73,11 @@ export default function useContractDetailsTabs({ data, isLoading, addressHash, s
id: 'contract_abi' as const,
title: 'ABI',
component: (
<RawDataSnippet
data={ JSON.stringify(data.abi, undefined, 4) }
<CodeViewSnippet
data={ JSON.stringify(data.abi, undefined, 2) }
language="json"
title="Contract ABI"
textareaMaxHeight="600px"
copyData={ JSON.stringify(data.abi) }
isLoading={ isLoading }
/>
),
......
import { Box, chakra, Flex, Skeleton } from '@chakra-ui/react';
import React from 'react';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
interface Props {
data: string;
copyData?: string;
language: string;
title?: string;
className?: string;
rightSlot?: React.ReactNode;
isLoading?: boolean;
}
const CodeViewSnippet = ({ data, copyData, language, title, className, rightSlot, isLoading }: Props) => {
const editorData = React.useMemo(() => {
return [ { file_path: 'index', source_code: data } ];
}, [ data ]);
return (
<Box className={ className } as="section" title={ title }>
{ (title || rightSlot) && (
<Flex justifyContent={ title ? 'space-between' : 'flex-end' } alignItems="center" mb={ 3 }>
{ title && <Skeleton fontWeight={ 500 } isLoaded={ !isLoading }>{ title }</Skeleton> }
{ rightSlot }
<CopyToClipboard text={ copyData ?? data } isLoading={ isLoading }/>
</Flex>
) }
{ isLoading ? <Skeleton height="500px" w="100%"/> : <CodeEditor data={ editorData } language={ language }/> }
</Box>
);
};
export default React.memo(chakra(CodeViewSnippet));
......@@ -20,10 +20,10 @@ import CodeEditorTabs from './CodeEditorTabs';
import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration';
import addFileImportDecorations from './utils/addFileImportDecorations';
import addMainContractCodeDecoration from './utils/addMainContractCodeDecoration';
import { defScilla, configScilla } from './utils/defScilla';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes';
import useThemeColors from './utils/useThemeColors';
const EDITOR_OPTIONS: EditorProps['options'] = {
readOnly: true,
minimap: { enabled: false },
......@@ -62,7 +62,20 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN
const editorWidth = containerRect ? containerRect.width - (isMobile ? 0 : SIDE_BAR_WIDTH) : 0;
const editorLanguage = language === 'vyper' ? 'elixir' : 'sol';
const editorLanguage = (() => {
switch (language) {
case 'vyper':
return 'elixir';
case 'json':
return 'json';
case 'solidity':
return 'sol';
case 'scilla':
return 'scilla';
default:
return 'javascript';
}
})();
React.useEffect(() => {
instance?.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
......@@ -76,6 +89,12 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN
monaco.editor.defineTheme('blockscout-dark', themes.dark);
monaco.editor.setTheme(colorMode === 'light' ? 'blockscout-light' : 'blockscout-dark');
if (editorLanguage === 'scilla') {
monaco.languages.register({ id: editorLanguage });
monaco.languages.setMonarchTokensProvider(editorLanguage, defScilla);
monaco.languages.setLanguageConfiguration(editorLanguage, configScilla);
}
const loadedModels = monaco.editor.getModels();
const loadedModelsPaths = loadedModels.map((model) => model.uri.path);
const newModels = data.slice(1)
......
/* eslint-disable max-len */
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export const configScilla: monaco.languages.LanguageConfiguration = {
comments: {
blockComment: [ '(*', '*)' ],
},
brackets: [
[ '{', '}' ],
[ '[', ']' ],
[ '(', ')' ],
],
autoClosingPairs: [
{ open: '"', close: '"', notIn: [ 'string', 'comment' ] },
{ open: '{', close: '}', notIn: [ 'string', 'comment' ] },
{ open: '[', close: ']', notIn: [ 'string', 'comment' ] },
{ open: '(', close: ')', notIn: [ 'string', 'comment' ] },
],
};
export const defScilla: monaco.languages.IMonarchLanguage = {
// defaultToken: 'invalid',
brackets: [
{ token: 'delimiter.curly', open: '{', close: '}' },
{ token: 'delimiter.parenthesis', open: '(', close: ')' },
{ token: 'delimiter.square', open: '[', close: ']' },
],
keywords: [
'let',
'contains',
'delete',
'put',
'remove',
'library',
'import',
'contract',
'event',
'field',
'send',
'fun',
'transition',
'procedure',
'match',
'end',
'with',
'builtin',
'Emp',
'of',
'scilla_version',
],
builtins: [
'eq',
'add',
'sub',
'mul',
'div',
'rem',
'lt',
'blt',
'in',
'substr',
'sha256hash',
'keccak256hash',
'ripemd160hash',
'to_byStr',
'to_nat',
'pow',
'to_uint256',
'to_uint32',
'to_uint64',
'to_uint128',
'to_int256',
'to_int32',
'to_int64',
'to_int128',
'schnorr_verify',
'concat',
'andb',
'orb',
'bool_to_string',
'negb',
'Nil',
],
operators: [
'=',
'=>',
'<=',
'->',
'<-',
],
// we include these common regular expressions
symbols: /[=><!~?:&|+\-*/^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
integersuffix: /(ll|LL|[uUlL])?(ll|LL|[uUlL])?/,
floatsuffix: /[fl]?/i,
// numbers
decpart: /\d(_?\d)*/,
decimal: /0|@decpart/,
tokenizer: {
root: [
// identifiers and keywords
[
/[a-z_]\w*[!?=]?/i,
{
cases: {
'@keywords': { token: 'keyword.$0' },
'@builtins': 'predefined',
'@default': 'identifier',
},
},
],
// the rule above does not work for Nil and Emp for some reason
// so we have to explicitly define them
[ /\b(Nil)\b/, 'predefined' ],
[ /\b(Emp)\b/, 'keyword' ],
// types
[ /\b(String|Uint32|Uint64|Uint128|Uint256|Int32|Int64|Int128|Int256|Map|True|False|ByStr|ByStr20|ByStr32|ByStr64|ByStr33|BNum|Option|None|Bool|Some|List|Cons|Pair|type|Zero|Succ|Message)\b/, 'type' ],
// whitespace
{ include: '@whitespace' },
// numbers
[ /0x[0-9a-f](_?[0-9a-f])*/i, 'number.hex' ],
[ /0[_o][0-7](_?[0-7])*/i, 'number.octal' ],
[ /0b[01](_?[01])*/i, 'number.binary' ],
[ /0[dD]@decpart/, 'number' ],
[
/@decimal((\.@decpart)?([eE][-+]?@decpart)?)/,
{
cases: {
$1: 'number.float',
'@default': 'number',
},
},
],
// strings
[ /"([^"\\]|\\.)*$/, 'string.invalid' ], // non-teminated string
[ /"/, 'string', '@string' ],
// characters
[ /'[^\\']'/, 'string' ],
[ /(')(@escapes)(')/, [ 'string', 'string.escape', 'string' ] ],
[ /'/, 'string.invalid' ],
],
whitespace: [
[ /[ \t\r\n]+/, 'white' ],
[ /\(\*.*$/, 'comment' ],
],
comment: [
[ /[^(*]+/, 'comment' ],
[ /[(*]/, 'comment' ],
],
string: [
[ /[^\\"]+/, 'string' ],
[ /@escapes/, 'string.escape' ],
[ /\\./, 'string.escape.invalid' ],
[ /"/, { token: 'string.quote', bracket: '@close', next: '@pop' } ],
],
},
};
export const light = {
base: 'vs' as const,
inherit: true,
rules: [],
rules: [
{ token: 'predefined', foreground: '#cd3131' },
],
colors: {
'editor.background': '#f5f5f6',
'editorWidget.background': '#f5f5f6',
......@@ -45,7 +47,9 @@ export const light = {
export const dark = {
base: 'vs-dark' as const,
inherit: true,
rules: [],
rules: [
{ token: 'predefined', foreground: '#f44747' },
],
colors: {
'editor.background': '#1a1b1b',
'editorWidget.background': '#1a1b1b',
......
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