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

Display arrays of structs on Read/Write contract tabs (#1556)

* prototype

* change host for preview

* improve labels for nested arrays

* add field path

* bind fields to react form

* transform form fields into method args

* add styles for non-array args

* styles for tuple array

* style nested array inputs

* clear and multiply buttons for inputs

* show errors in inputs

* pass disabled state to inputs

* change direction of accordioin icon

* remove old code

* add asterix to required fields

* improvements and comments

* change styles

* highlight sections with errors

* refactoring field method name

* fix lable color

* fix ts

* preliminary tests

* better complex field labels

* improve tests

* remove outputs for write methods

* rollback review value
parent 9dbb2b0c
...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x01',
}, },
{ {
constant: false, constant: false,
...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: true, payable: true,
stateMutability: 'payable', stateMutability: 'payable',
type: 'function', type: 'function',
method_id: '0x02',
}, },
{ {
stateMutability: 'payable', stateMutability: 'payable',
...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x03',
}, },
{ {
constant: false, constant: false,
...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x04',
}, },
{ {
constant: false, constant: false,
...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x05',
}, },
{ {
constant: false, constant: false,
...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x06',
}, },
]; ];
...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase { ...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase {
type: 'function'; type: 'function';
payable: boolean; payable: boolean;
error?: string; error?: string;
}
export interface SmartContractReadMethod extends SmartContractMethodBase {
method_id: string; method_id: string;
} }
export type SmartContractReadMethod = SmartContractMethodBase;
export interface SmartContractWriteFallback { export interface SmartContractWriteFallback {
payable?: true; payable?: true;
stateMutability: 'payable'; stateMutability: 'payable';
...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr ...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput { export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType; internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string; name: string;
type: SmartContractMethodArgType; type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>; components?: Array<SmartContractMethodInput>;
......
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract';
import ContractMethodField from './ContractMethodField';
import ContractMethodFieldArray from './ContractMethodFieldArray';
import { ARRAY_REGEXP } from './utils';
interface Props {
fieldName: string;
fieldType?: SmartContractMethodInput['fieldType'];
argName: string;
argType: SmartContractMethodArgType;
onChange: () => void;
isDisabled: boolean;
isGrouped?: boolean;
isOptional?: boolean;
}
const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => {
const { control, getValues, setValue } = useFormContext<MethodFormFields>();
const arrayTypeMatch = argType.match(ARRAY_REGEXP);
const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const content = arrayTypeMatch ? (
<ContractMethodFieldArray
name={ fieldName }
argType={ arrayTypeMatch[1] as SmartContractMethodArgType }
size={ Number(arrayTypeMatch[2] || Infinity) }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
) : (
<ContractMethodField
name={ fieldName }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
isOptional={ isOptional }
onChange={ onChange }
/>
);
const isNativeCoinField = fieldType === 'native_coin';
return (
<Flex
flexDir={{ base: 'column', lg: 'row' }}
columnGap={ 3 }
rowGap={{ base: 2, lg: 0 }}
bgColor={ isNativeCoinField ? nativeCoinFieldBgColor : undefined }
py={ isNativeCoinField ? 1 : undefined }
px={ isNativeCoinField ? '6px' : undefined }
mx={ isNativeCoinField ? '-6px' : undefined }
borderRadius="base"
>
<Box
position="relative"
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
color={ isGrouped ? 'text_secondary' : 'initial' }
wordBreak="break-word"
w={{ lg: '250px' }}
flexShrink={ 0 }
>
{ argName }{ isOptional ? '' : '*' } ({ argType })
</Box>
{ content }
</Flex>
);
};
export default React.memo(ContractMethodCallableRow);
import {
Box,
FormControl,
Input,
InputGroup,
InputRightElement,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FieldError, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import { isAddress, isHex, getAddress } from 'viem';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils';
interface Props {
name: string;
index?: number;
groupName?: string;
placeholder: string;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
isDisabled: boolean;
isOptional?: boolean;
onChange: () => void;
}
const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const bgColor = useColorModeValue('white', 'black');
const handleClear = React.useCallback(() => {
setValue(name, '');
onChange();
ref.current?.focus();
}, [ name, onChange, setValue ]);
const handleAddZeroesClick = React.useCallback((power: number) => {
const value = groupName && index !== undefined ? getValues()[groupName][index] : getValues()[name];
const zeroes = Array(power).fill('0').join('');
const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue);
onChange();
}, [ getValues, groupName, index, name, onChange, setValue ]);
const intMatch = React.useMemo(() => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned, power, min, max };
}, [ argType ]);
const bytesMatch = React.useMemo(() => {
return argType.match(BYTES_REGEXP);
}, [ argType ]);
const renderInput = React.useCallback((
{ field, formState }: { field: ControllerRenderProps<MethodFormFields>; formState: UseFormStateReturn<MethodFormFields> },
) => {
const error: FieldError | undefined = index !== undefined && groupName !== undefined ?
(formState.errors[groupName] as unknown as Array<FieldError>)?.[index] :
formState.errors[name];
// show control for all inputs which allows to insert 10^18 or greater numbers
const hasZerosControl = intMatch && Number(intMatch.power) >= 64;
return (
<Box w="100%">
<FormControl
id={ name }
isDisabled={ isDisabled }
>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(intMatch ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !intMatch.isUnsigned,
} : {}) }
ref={ ref }
isInvalid={ Boolean(error) }
required={ !isOptional }
placeholder={ placeholder }
paddingRight={ hasZerosControl ? '120px' : '40px' }
autoComplete="off"
bgColor={ bgColor }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
</FormControl>
{ error && <Box color="error" fontSize="sm" mt={ 1 }>{ error.message }</Box> }
</Box>
);
}, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]);
const validate = React.useCallback((_value: string | Array<string> | undefined) => {
if (typeof _value === 'object' || !_value) {
return;
}
const value = _value.replace('\n', '');
if (!value && !isOptional) {
return 'Field is required';
}
if (argType === 'address') {
if (!isAddress(value)) {
return 'Invalid address format';
}
// all lowercase addresses are valid
const isInLowerCase = value === value.toLowerCase();
if (isInLowerCase) {
return true;
}
// check if address checksum is valid
return getAddress(value) === value ? true : 'Invalid address checksum';
}
if (intMatch) {
const formattedValue = Number(value.replace(/\s/g, ''));
if (Object.is(formattedValue, NaN)) {
return 'Invalid integer format';
}
if (formattedValue > intMatch.max || formattedValue < intMatch.min) {
const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`;
const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`;
return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
}
return true;
}
if (argType === 'bool') {
const formattedValue = formatBooleanValue(value);
if (formattedValue === undefined) {
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
}
}
if (bytesMatch) {
const [ , length ] = bytesMatch;
if (!isHex(value)) {
return 'Invalid bytes format';
}
if (length) {
const valueLengthInBytes = value.replace('0x', '').length / 2;
return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true;
}
return true;
}
return true;
}, [ isOptional, argType, intMatch, bytesMatch ]);
return (
<Controller
name={ name }
control={ control }
render={ renderInput }
rules={{ required: isOptional ? false : 'Field is required', validate }}
/>
);
};
export default React.memo(ContractMethodField);
import { Flex, IconButton } from '@chakra-ui/react';
import React from 'react';
import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg';
import ContractMethodField from './ContractMethodField';
interface Props {
name: string;
size: number;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
isDisabled: boolean;
onChange: () => void;
}
const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => {
const { fields, append, remove } = useFieldArray({
name: name as never,
control,
});
React.useEffect(() => {
if (fields.length === 0) {
if (size === Infinity) {
append('');
} else {
for (let i = 0; i < size - 1; i++) {
// a little hack to append multiple empty fields in the array
// had to adjust code in ContractMethodField as well
append('\n');
}
}
}
}, [ fields.length, append, size ]);
const handleAddButtonClick = React.useCallback(() => {
append('');
}, [ append ]);
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
remove(Number(itemIndex));
}
}, [ remove ]);
return (
<Flex flexDir="column" rowGap={ 3 } w="100%">
{ fields.map((field, index, array) => {
return (
<Flex key={ field.id } columnGap={ 3 }>
<ContractMethodField
name={ `${ name }[${ index }]` }
groupName={ name }
index={ index }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
{ array.length > 1 && size === Infinity && (
<IconButton
aria-label="remove"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleRemoveButtonClick }
icon={ <IconSvg name="minus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
{ index === array.length - 1 && size === Infinity && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleAddButtonClick }
icon={ <IconSvg name="plus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
</Flex>
);
}) }
</Flex>
);
};
export default React.memo(ContractMethodFieldArray);
...@@ -45,8 +45,10 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -45,8 +45,10 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }> <Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left"> <AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && ( { 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }> <Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
<Box <Box
...@@ -83,12 +85,14 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -83,12 +85,14 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }/>
) } ) }
<AccordionIcon/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)"> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { renderContent(data, index, id) }
</AccordionPanel> </AccordionPanel>
</>
) }
</AccordionItem> </AccordionItem>
); );
}; };
......
...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); await component.getByPlaceholder(/address/i).fill('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
await component.getByText(/read/i).click(); await component.getByText(/read/i).click();
await component.getByText(/wei/i).click(); await component.getByText(/wei/i).click();
......
...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractMethodConstant from './ContractMethodConstant'; import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult'; import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount'; import useWatchAccount from './useWatchAccount';
const ContractRead = () => { const ContractRead = () => {
...@@ -40,7 +40,7 @@ const ContractRead = () => { ...@@ -40,7 +40,7 @@ const ContractRead = () => {
}, },
}); });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<string | Array<unknown>>) => { const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', { return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
...@@ -72,11 +72,12 @@ const ContractRead = () => { ...@@ -72,11 +72,12 @@ const ContractRead = () => {
} }
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult } resultComponent={ ContractReadResult }
methodType="read"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractWriteResult from './ContractWriteResult'; import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi'; import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils'; import { getNativeCoinValue, prepareAbi } from './utils';
...@@ -39,12 +39,14 @@ const ContractWrite = () => { ...@@ -39,12 +39,14 @@ const ContractWrite = () => {
}, },
queryOptions: { queryOptions: {
enabled: Boolean(addressHash), enabled: Boolean(addressHash),
refetchOnMount: false,
}, },
}); });
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi }); const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<string | Array<unknown>>) => { // TODO @tom2drum maybe move this inside the form
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) { if (!isConnected) {
throw new Error('Wallet is not connected'); throw new Error('Wallet is not connected');
} }
...@@ -66,21 +68,22 @@ const ContractWrite = () => { ...@@ -66,21 +68,22 @@ const ContractWrite = () => {
return { hash }; return { hash };
} }
const _args = 'stateMutability' in item && item.stateMutability === 'payable' ? args.slice(0, -1) : args;
const value = 'stateMutability' in item && item.stateMutability === 'payable' ? getNativeCoinValue(args[args.length - 1]) : undefined;
const methodName = item.name; const methodName = item.name;
if (!methodName) { if (!methodName) {
throw new Error('Method name is not defined'); throw new Error('Method name is not defined');
} }
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const abi = prepareAbi(contractAbi, item); const abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({ const hash = await walletClient?.writeContract({
args: _args, args: _args,
abi, abi,
functionName: methodName, functionName: methodName,
address: addressHash as `0x${ string }`, address: addressHash as `0x${ string }`,
value: value as undefined, value,
}); });
return { hash }; return { hash };
...@@ -88,12 +91,12 @@ const ContractWrite = () => { ...@@ -88,12 +91,12 @@ const ContractWrite = () => {
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => { const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult } resultComponent={ ContractWriteResult }
isWrite methodType="write"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
import React from 'react'; import React from 'react';
import { useWaitForTransaction } from 'wagmi'; import { useWaitForTransaction } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types'; import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb'; import ContractWriteResultDumb from './ContractWriteResultDumb';
interface Props { const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
result: ContractMethodWriteResult;
onSettle: () => void;
}
const ContractWriteResult = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined; const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransaction({ const txInfo = useWaitForTransaction({
hash: txHash, hash: txHash,
}); });
return <ContractWriteResultDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>; return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
}; };
export default React.memo(ContractWriteResult); export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
import { IconButton, chakra } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
index: number;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
isDisabled?: boolean;
type: 'add' | 'remove';
className?: string;
}
const ContractMethodArrayButton = ({ className, type, index, onClick, isDisabled }: Props) => {
return (
<IconButton
className={ className }
aria-label={ type }
data-index={ index }
variant="outline"
w="20px"
h="20px"
flexShrink={ 0 }
onClick={ onClick }
icon={ <IconSvg name={ type === 'remove' ? 'minus' : 'plus' } boxSize={ 3 }/> }
isDisabled={ isDisabled }
/>
);
};
export default React.memo(chakra(ContractMethodArrayButton));
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import ContractMethodArrayButton from './ContractMethodArrayButton';
export interface Props {
label: string;
level: number;
children: React.ReactNode;
onAddClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onRemoveClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
index?: number;
isInvalid?: boolean;
}
const ContractMethodFieldAccordion = ({ label, level, children, onAddClick, onRemoveClick, index, isInvalid }: Props) => {
const bgColorLevel0 = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const bgColor = useColorModeValue('whiteAlpha.700', 'blackAlpha.700');
return (
<Accordion allowToggle w="100%" bgColor={ level === 0 ? bgColorLevel0 : bgColor } borderRadius="base">
<AccordionItem _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<AccordionButton
as="div"
cursor="pointer"
px="6px"
py="6px"
wordBreak="break-all"
textAlign="left"
_hover={{ bgColor: 'inherit' }}
>
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
<Box fontSize="sm" lineHeight={ 5 } fontWeight={ 700 } mr="auto" ml={ 1 } color={ isInvalid ? 'error' : undefined }>
{ label }
</Box>
{ onRemoveClick && <ContractMethodArrayButton index={ index } onClick={ onRemoveClick } type="remove"/> }
{ onAddClick && <ContractMethodArrayButton index={ index } onClick={ onAddClick } type="add" ml={ 2 }/> }
</AccordionButton>
<AccordionPanel display="flex" flexDir="column" rowGap={ 1 } pl="18px" pr="6px">
{ children }
</AccordionPanel>
</>
) }
</AccordionItem>
</Accordion>
);
};
export default React.memo(ContractMethodFieldAccordion);
import { Box, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useValidateField from './useValidateField';
interface Props {
data: SmartContractMethodInput;
hideLabel?: boolean;
path: string;
className?: string;
isDisabled: boolean;
level: number;
}
const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDisabled, level }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const { control, setValue, getValues } = useFormContext();
const { field, fieldState } = useController({ control, name, rules: { validate, required: isOptional ? false : 'Field is required' } });
const inputBgColor = useColorModeValue('white', 'black');
const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700');
const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64;
const handleClear = React.useCallback(() => {
setValue(name, '');
ref.current?.focus();
}, [ name, setValue ]);
const handleMultiplyButtonClick = React.useCallback((power: number) => {
const zeroes = Array(power).fill('0').join('');
const value = getValues(name);
const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue);
}, [ getValues, name, setValue ]);
const error = fieldState.error;
return (
<Flex
className={ className }
flexDir={{ base: 'column', md: 'row' }}
alignItems="flex-start"
columnGap={ 3 }
w="100%"
bgColor={ isNativeCoin ? nativeCoinRowBgColor : undefined }
borderRadius="base"
px="6px"
py={ isNativeCoin ? 1 : 0 }
>
{ !hideLabel && <ContractMethodFieldLabel data={ data } isOptional={ isOptional } level={ level }/> }
<FormControl isDisabled={ isDisabled }>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(argTypeMatchInt ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !argTypeMatchInt.isUnsigned,
} : {}) }
ref={ ref }
required={ !isOptional }
isInvalid={ Boolean(error) }
placeholder={ data.type }
autoComplete="off"
bgColor={ inputBgColor }
paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
{ error && <Box color="error" fontSize="sm" lineHeight={ 5 } mt={ 1 }>{ error.message }</Box> }
</FormControl>
</Flex>
);
};
export default React.memo(chakra(ContractMethodFieldInput));
import { Flex } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
level: number;
basePath: string;
isDisabled: boolean;
}
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => {
const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
setRegisteredIndices((prev) => [ ...prev, prev[prev.length - 1] + 1 ]);
}, []);
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
setRegisteredIndices((prev) => prev.filter((index) => index !== Number(itemIndex)));
}
}, [ ]);
const getItemData = (index: number) => {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType;
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', '');
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : '';
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : '';
const nameIndex = index + 1;
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
if (isNestedArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
const isTupleArray = data.type.includes('tuple');
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
// primitive value array
return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/>
<Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
<ContractMethodFieldInput
data={ itemData }
hideLabel
path={ `${ basePath }:${ index }` }
level={ level }
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex>
);
}) }
</Flex>
</Flex>
);
};
export default React.memo(ContractMethodFieldInputArray);
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
basePath: string;
level: number;
isDisabled: boolean;
}
const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...accordionProps }: Props) => {
const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
return (
<ContractMethodFieldAccordion
{ ...accordionProps }
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ level + 1 }
isDisabled={ isDisabled }
/>
);
}
const arrayMatch = component.type.match(ARRAY_REGEXP);
if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
}
return (
<ContractMethodFieldInput
key={ index }
data={ component }
path={ `${ basePath }:${ index }` }
isDisabled={ isDisabled }
level={ level }
/>
);
}) }
</ContractMethodFieldAccordion>
);
};
export default React.memo(ContractMethodFieldInputTuple);
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract';
import { getFieldLabel } from './utils';
interface Props {
data: SmartContractMethodInput;
isOptional?: boolean;
level: number;
}
const ContractMethodFieldLabel = ({ data, isOptional, level }: Props) => {
const color = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
return (
<Box
w="250px"
fontSize="sm"
lineHeight={ 5 }
py="6px"
flexShrink={ 0 }
fontWeight={ 500 }
color={ level > 1 ? color : undefined }
>
{ getFieldLabel(data, !isOptional) }
</Box>
);
};
export default React.memo(ContractMethodFieldLabel);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` });
const resultComponent = () => null;
const data: SmartContractWriteMethod = {
inputs: [
// TUPLE
{
components: [
{ internalType: 'address', name: 'offerToken', type: 'address' },
{ internalType: 'uint256', name: 'offerIdentifier', type: 'uint256' },
{ internalType: 'enum BasicOrderType', name: 'basicOrderType', type: 'uint8' },
{
components: [
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
{ internalType: 'address payable', name: 'recipient', type: 'address' },
],
internalType: 'struct AdditionalRecipient[]',
name: 'additionalRecipients',
type: 'tuple[]',
},
{ internalType: 'bytes', name: 'signature', type: 'bytes' },
],
internalType: 'struct BasicOrderParameters',
name: 'parameters',
type: 'tuple',
},
// NESTED ARRAY OF TUPLES
{
components: [
{
internalType: 'uint256',
name: 'orderIndex',
type: 'uint256',
},
{
internalType: 'uint256',
name: 'itemIndex',
type: 'uint256',
},
],
internalType: 'struct FulfillmentComponent[][]',
name: '',
type: 'tuple[][]',
},
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' },
{ internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' },
],
method_id: '87201b41',
name: 'fulfillAvailableAdvancedOrders',
outputs: [
{ internalType: 'bool[]', name: '', type: 'bool[]' },
{
components: [
{
components: [
{ internalType: 'enum ItemType', name: 'itemType', type: 'uint8' },
{ internalType: 'address', name: 'token', type: 'address' },
{ internalType: 'uint256', name: 'identifier', type: 'uint256' },
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
{ internalType: 'address payable', name: 'recipient', type: 'address' },
],
internalType: 'struct ReceivedItem',
name: 'item',
type: 'tuple',
},
{ internalType: 'address', name: 'offerer', type: 'address' },
{ internalType: 'bytes32', name: 'conduitKey', type: 'bytes32' },
],
internalType: 'struct Execution[]',
name: '',
type: 'tuple[]',
},
],
stateMutability: 'payable',
type: 'function',
payable: true,
constant: false,
};
test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
);
// fill top level fields
await component.getByPlaceholder('address').last().fill('0x0000');
await component.getByPlaceholder('uint256').last().fill('42');
await component.getByRole('button', { name: '×' }).last().click();
await component.getByPlaceholder('bytes32').last().fill('aa');
await component.getByRole('button', { name: 'add' }).last().click();
await component.getByRole('button', { name: 'add' }).last().click();
await component.getByPlaceholder('int8', { exact: true }).first().fill('1');
await component.getByPlaceholder('int8', { exact: true }).last().fill('3');
// expand all sections
await component.getByText('parameters').click();
await component.getByText('additionalRecipients').click();
await component.getByText('#1 AdditionalRecipient').click();
await component.getByRole('button', { name: 'add' }).first().click();
await component.getByPlaceholder('uint256').nth(1).fill('42');
await component.getByPlaceholder('address').nth(1).fill('0xd789a607CEac2f0E14867de4EB15b15C9FFB5859');
await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
// submit form
await component.getByRole('button', { name: 'Write' }).click();
await expect(component).toHaveScreenshot();
});
import { Box, Button, chakra, Flex } from '@chakra-ui/react'; import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import ContractMethodCallableRow from './ContractMethodCallableRow'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import { formatFieldValues, transformFieldsToArgs } from './utils'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
interface ResultComponentProps<T extends SmartContractMethod> { import ContractMethodFormOutputs from './ContractMethodFormOutputs';
item: T; import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
result: ContractMethodCallResult<T>; import type { ContractMethodFormFields } from './utils';
onSettle: () => void;
}
interface Props<T extends SmartContractMethod> { interface Props<T extends SmartContractMethod> {
data: T; data: T;
onSubmit: (data: T, args: Array<string | Array<unknown>>) => Promise<ContractMethodCallResult<T>>; onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null; resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
isWrite?: boolean; methodType: 'read' | 'write';
} }
// groupName%groupIndex:inputName%inputIndex const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => {
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
`${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>(); const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ isLoading, setLoading ] = React.useState(false); const [ isLoading, setLoading ] = React.useState(false);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => { const formApi = useForm<ContractMethodFormFields>({
return [ mode: 'all',
...('inputs' in data ? data.inputs : []), shouldUnregister: true,
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []),
];
}, [ data ]);
const formApi = useForm<MethodFormFields>({
mode: 'onBlur',
}); });
const handleTxSettle = React.useCallback(() => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
setLoading(false); const args = transformFormDataToMethodArgs(formData);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const formattedData = formatFieldValues(formData, inputs);
const args = transformFieldsToArgs(formattedData);
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}) })
.finally(() => { .finally(() => {
mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, {
'Method type': isWrite ? 'Write' : 'Read', 'Method type': methodType === 'write' ? 'Write' : 'Read',
'Method name': 'name' in data ? data.name : 'Fallback', 'Method name': 'name' in data ? data.name : 'Fallback',
}); });
}); });
}, [ inputs, onSubmit, data, isWrite ]); }, [ data, methodType, onSubmit ]);
const handleTxSettle = React.useCallback(() => {
setLoading(false);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => {
return [
...('inputs' in data ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []),
];
}, [ data ]);
const outputs = 'outputs' in data && data.outputs ? data.outputs : []; const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
onSubmit={ formApi.handleSubmit(onFormSubmit) } onSubmit={ formApi.handleSubmit(onFormSubmit) }
onChange={ handleFormChange } onChange={ handleFormChange }
> >
<Flex <Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
flexDir="column"
rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => { { inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index }); if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
if (input.type === 'tuple' && input.components) { }
return (
<React.Fragment key={ fieldName }>
{ index !== 0 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
<Box
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
wordBreak="break-word"
>
{ input.name } ({ input.type })
</Box>
{ input.components.map((component, componentIndex) => {
const fieldName = getFormFieldName(
{ name: component.name, index: componentIndex },
{ name: input.name, index },
);
return ( const arrayMatch = input.type.match(ARRAY_REGEXP);
<ContractMethodCallableRow if (arrayMatch) {
key={ fieldName } return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
fieldName={ fieldName }
argName={ component.name }
argType={ component.type }
isDisabled={ isLoading }
onChange={ handleFormChange }
isGrouped
/>
);
}) }
{ index !== inputs.length - 1 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
</React.Fragment>
);
} }
return ( return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>;
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
fieldType={ input.fieldType }
argName={ input.name }
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) } }) }
</Flex> </Flex>
<Button <Button
isLoading={ isLoading } isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' } loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline" variant="outline"
size="sm" size="sm"
flexShrink={ 0 } flexShrink={ 0 }
...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
px={ 4 } px={ 4 }
type="submit" type="submit"
> >
{ isWrite ? 'Write' : 'Read' } { methodType === 'write' ? 'Write' : 'Read' }
</Button> </Button>
</chakra.form> </chakra.form>
</FormProvider> </FormProvider>
{ !isWrite && outputs.length > 0 && ( { methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ outputs.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < outputs.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
) }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box> </Box>
); );
}; };
export default React.memo(ContractMethodCallable) as typeof ContractMethodCallable; export default React.memo(ContractMethodForm) as typeof ContractMethodForm;
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: Array<SmartContractMethodOutput>;
}
const ContractMethodFormOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
return (
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ data.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
);
};
export default React.memo(ContractMethodFormOutputs);
...@@ -21,7 +21,7 @@ interface Props { ...@@ -21,7 +21,7 @@ interface Props {
isDisabled?: boolean; isDisabled?: boolean;
} }
const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18); const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18);
const [ customValue, setCustomValue ] = React.useState<number>(); const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
onClick={ onToggle } onClick={ onToggle }
isDisabled={ isDisabled } isDisabled={ isDisabled }
> >
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } boxSize={ 6 }/> <IconSvg
name="arrows/east-mini"
transitionDuration="fast"
transitionProperty="transform"
transitionTimingFunction="ease-in-out"
transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' }
boxSize={ 6 }
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
); );
}; };
export default React.memo(ContractMethodFieldZeroes); export default React.memo(ContractMethodMultiplyButton);
import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethod } from 'types/api/contract';
export interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
import type { SmartContractMethodArgType } from 'types/api/contract';
import { INT_REGEXP, getIntBoundaries } from './utils';
interface Params {
argType: SmartContractMethodArgType;
}
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: number;
max: number;
}
export default function useArgTypeMatchInt({ argType }: Params): MatchInt | null {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
}
import React from 'react';
import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP, formatBooleanValue } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argTypeMatchInt: MatchInt | null;
isOptional: boolean;
}
export default function useValidateField({ isOptional, argType, argTypeMatchInt }: Params) {
const bytesMatch = React.useMemo(() => {
return argType.match(BYTES_REGEXP);
}, [ argType ]);
return React.useCallback((value: string | undefined) => {
if (!value) {
return isOptional ? true : 'Field is required';
}
if (argType === 'address') {
if (!isAddress(value)) {
return 'Invalid address format';
}
// all lowercase addresses are valid
const isInLowerCase = value === value.toLowerCase();
if (isInLowerCase) {
return true;
}
// check if address checksum is valid
return getAddress(value) === value ? true : 'Invalid address checksum';
}
if (argTypeMatchInt) {
const formattedValue = Number(value.replace(/\s/g, ''));
if (Object.is(formattedValue, NaN)) {
return 'Invalid integer format';
}
if (formattedValue > argTypeMatchInt.max || formattedValue < argTypeMatchInt.min) {
const lowerBoundary = argTypeMatchInt.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(argTypeMatchInt.power) - 1 }`;
const upperBoundary = argTypeMatchInt.isUnsigned ? `2 ^ ${ argTypeMatchInt.power } - 1` : `2 ^ ${ Number(argTypeMatchInt.power) - 1 } - 1`;
return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
}
return true;
}
if (argType === 'bool') {
const formattedValue = formatBooleanValue(value);
if (formattedValue === undefined) {
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
}
}
if (bytesMatch) {
const [ , length ] = bytesMatch;
if (!isHex(value)) {
return 'Invalid bytes format';
}
if (length) {
const valueLengthInBytes = value.replace('0x', '').length / 2;
return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true;
}
return true;
}
return true;
}, [ isOptional, argType, argTypeMatchInt, bytesMatch ]);
}
import { transformFormDataToMethodArgs } from './utils';
describe('transformFormDataToMethodArgs', () => {
it('should transform form data to method args array', () => {
const formData = {
'1': '1',
'2': '2',
'0:1': '0:1',
'0:0:0': '0:0:0',
'0:0:1:0': '0:0:1:0',
'0:0:1:3': '0:0:1:3',
'0:0:2:1:0': '0:0:2:1:0',
'0:0:2:1:1': '0:0:2:1:1',
'0:0:2:2:0': '0:0:2:2:0',
'0:0:2:2:2': '0:0:2:2:2',
'0:0:2:5:3': '0:0:2:5:3',
'0:0:2:5:8': '0:0:2:5:8',
};
const result = transformFormDataToMethodArgs(formData);
expect(result).toEqual([
[
[
'0:0:0',
[
'0:0:1:0',
'0:0:1:3',
],
[
[
'0:0:2:1:0',
'0:0:2:1:1',
],
[
'0:0:2:2:0',
'0:0:2:2:2',
],
[
'0:0:2:5:3',
'0:0:2:5:8',
],
],
],
'0:1',
],
'1',
'2',
]);
});
});
import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
export type ContractMethodFormFields = Record<string, string | undefined>;
export const INT_REGEXP = /^(u)?int(\d+)?$/i;
export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = 2 ** power;
const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1;
const min = isUnsigned ? 0 : -maxUnsigned / 2;
return [ min, max ];
};
export const formatBooleanValue = (value: string) => {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
export function transformFormDataToMethodArgs(formData: ContractMethodFormFields) {
const result: Array<unknown> = [];
for (const field in formData) {
const value = formData[field];
if (value !== undefined) {
_set(result, field.replaceAll(':', '.'), value);
}
}
return filterOurEmptyItems(result);
}
function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
// The undefined value may occur in two cases:
// 1. When an optional form field is left blank by the user.
// The only optional field is the native coin value, which is safely handled in the form submit handler.
// 2. When the user adds and removes items from a field array.
// In this scenario, empty items need to be filtered out to maintain the correct sequence of arguments.
return array
.map((item) => Array.isArray(item) ? filterOurEmptyItems(item) : item)
.filter((item) => item !== undefined);
}
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
}
import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/api/contract'; import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr ...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined; export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> = export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends { method_id: string } ? ContractMethodReadResult : ContractMethodWriteResult; T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import type { SmartContractMethodInput } from 'types/api/contract'; import { prepareAbi } from './utils';
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
describe('function prepareAbi()', () => { describe('function prepareAbi()', () => {
const commonAbi = [ const commonAbi = [
...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => { ...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => {
type: 'function' as const, type: 'function' as const,
constant: false, constant: false,
payable: true, payable: true,
method_id: '0x2e0e2d3e',
}; };
it('if there is only one method with provided name, does nothing', () => { it('if there is only one method with provided name, does nothing', () => {
...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => { ...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => {
expect(item).toEqual(commonAbi[2]); expect(item).toEqual(commonAbi[2]);
}); });
}); });
describe('function formatFieldValues()', () => {
const formFields = {
'_tx%0:nonce%0': '1 000 000 000 000 000 000',
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
'_tx%0:targets%2': [
'1',
'true',
],
'_l2OutputIndex%1': '0xaeff',
'_paused%2': '0',
'_withdrawalProof%3': [
'0x0f',
'0x02',
],
};
const inputs: Array<SmartContractMethodInput> = [
{
components: [
{ internalType: 'uint256', name: 'nonce', type: 'uint256' },
{ internalType: 'address', name: 'sender', type: 'address' },
{ internalType: 'bool[]', name: 'targets', type: 'bool[]' },
],
internalType: 'tuple',
name: '_tx',
type: 'tuple',
},
{ internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' },
{
internalType: 'bool',
name: '_paused',
type: 'bool',
},
{
internalType: 'bytes32[]',
name: '_withdrawalProof',
type: 'bytes32[]',
},
];
it('converts values to correct format', () => {
const result = formatFieldValues(formFields, inputs);
expect(result).toEqual({
'_tx%0:nonce%0': '1000000000000000000',
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
'_tx%0:targets%2': [
true,
true,
],
'_l2OutputIndex%1': '0xaeff',
'_paused%2': false,
'_withdrawalProof%3': [
'0x0f',
'0x02',
],
});
});
it('converts nested array string representation to correct format', () => {
const formFields = {
'_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]',
};
const inputs: Array<SmartContractMethodInput> = [
{ internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' },
];
const result = formatFieldValues(formFields, inputs);
expect(result).toEqual({
'_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ],
});
});
});
describe('function transformFieldsToArgs()', () => {
it('groups struct and array fields', () => {
const formFields = {
'_paused%2': 'primitive_1',
'_l2OutputIndex%1': 'primitive_0',
'_tx%0:nonce%0': 'struct_0',
'_tx%0:sender%1': 'struct_1',
'_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ],
'_withdrawalProof%3': [
'array_0',
'array_1',
],
};
const args = transformFieldsToArgs(formFields);
expect(args).toEqual([
[ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ],
'primitive_0',
'primitive_1',
[ 'array_0', 'array_1' ],
]);
});
});
import type { Abi } from 'abitype'; import type { Abi } from 'abitype';
import _mapValues from 'lodash/mapValues';
import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; import type { SmartContractWriteMethod } from 'types/api/contract';
import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract';
export const INT_REGEXP = /^(u)?int(\d+)?$/i; export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = 2 ** power;
const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1;
const min = isUnsigned ? 0 : -maxUnsigned / 2;
return [ min, max ];
};
export const formatBooleanValue = (value: string) => {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
export const getNativeCoinValue = (value: string | Array<unknown>) => {
const _value = Array.isArray(value) ? value[0] : value;
if (typeof _value !== 'string') {
return BigInt(0); return BigInt(0);
} }
return BigInt(_value); return BigInt(value);
}; };
interface ExtendedError extends Error {
detectedNetwork?: {
chain: number;
name: string;
};
reason?: string;
}
export function isExtendedError(error: unknown): error is ExtendedError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) { if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1; const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1;
...@@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { ...@@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
return abi; return abi;
} }
function getFieldType(fieldName: string, inputs: Array<SmartContractMethodInput>) {
const chunks = fieldName.split(':');
if (chunks.length === 1) {
const [ , index ] = chunks[0].split('%');
return inputs[Number(index)].type;
} else {
const group = chunks[0].split('%');
const input = chunks[1].split('%');
return inputs[Number(group[1])].components?.[Number(input[1])].type;
}
}
function parseArrayValue(value: string) {
try {
const parsedResult = JSON.parse(value);
if (Array.isArray(parsedResult)) {
return parsedResult as Array<string>;
}
throw new Error('Not an array');
} catch (error) {
return '';
}
}
function castValue(value: string, type: SmartContractMethodArgType) {
if (type === 'bool') {
return formatBooleanValue(value) === 'true';
}
const intMatch = type.match(INT_REGEXP);
if (intMatch) {
return value.replaceAll(' ', '');
}
const isNestedArray = (type.match(/\[/g) || []).length > 1;
const isNestedTuple = type.includes('tuple');
if (isNestedArray || isNestedTuple) {
return parseArrayValue(value) || value;
}
return value;
}
export function formatFieldValues(formFields: MethodFormFields, inputs: Array<SmartContractMethodInput>) {
const formattedFields = _mapValues(formFields, (value, key) => {
const type = getFieldType(key, inputs);
if (!type) {
return value;
}
if (Array.isArray(value)) {
const arrayMatch = type.match(ARRAY_REGEXP);
if (arrayMatch) {
return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType));
}
return value;
}
return castValue(value, type);
});
return formattedFields;
}
export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) {
const unGroupedFields = Object.entries(formFields)
.reduce((
result: Record<string, MethodArgType>,
[ key, value ]: [ string, MethodArgType ],
) => {
const chunks = key.split(':');
if (chunks.length > 1) {
const groupKey = chunks[0];
const [ , fieldIndex ] = chunks[1].split('%');
if (result[groupKey] === undefined) {
result[groupKey] = [];
}
(result[groupKey] as Array<MethodArgType>)[Number(fieldIndex)] = value;
return result;
}
result[key] = value;
return result;
}, {});
const args = (Object.entries(unGroupedFields)
.map(([ key, value ]) => {
const [ , index ] = key.split('%');
return [ Number(index), value ];
}) as Array<[ number, string | Array<string> ]>)
.sort((a, b) => a[0] - b[0])
.map(([ , value ]) => value);
return args;
}
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