Commit 67c6404a authored by tom goriunov's avatar tom goriunov Committed by GitHub

Contract write: unable to add elements to a top level array argument field (#1848)

* fix form for top level nested arrays

* fix labels for fixed size arrays

* refactoring

* adjust test
parent 5c3e1bec
......@@ -9,9 +9,9 @@ import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props {
data: SmartContractMethodInput;
......@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]);
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......
......@@ -2,7 +2,7 @@ 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 type { SmartContractMethodInput } from 'types/api/contract';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
......@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils';
import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
level: number;
basePath: string;
isDisabled: boolean;
isArrayElement?: boolean;
size?: number;
}
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => {
const ContractMethodFieldInputArray = ({
data,
level,
basePath,
onAddClick,
onRemoveClick,
index: parentIndex,
isDisabled,
isArrayElement,
}: 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 arrayMatch = matchArray(data.type);
const hasFixedSize = arrayMatch !== null && arrayMatch.size !== Infinity;
const [ registeredIndices, setRegisteredIndices ] = React.useState(hasFixedSize ? Array(arrayMatch.size).fill(0).map((_, i) => i) : [ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
......@@ -39,52 +53,69 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
}
}, [ ]);
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;
if (arrayMatch?.isNested) {
return (
<>
{
registeredIndices.map((registeredIndex, index) => {
const itemData = transformDataForArrayItem(data, index);
const itemBasePath = `${ basePath }:${ registeredIndex }`;
const itemIsInvalid = fieldsWithErrors.some((field) => field.startsWith(itemBasePath));
return (
<ContractMethodFieldAccordion
key={ registeredIndex }
level={ level + 1 }
label={ getFieldLabel(itemData) }
isInvalid={ itemIsInvalid }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
>
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ itemBasePath }
level={ level + 1 }
isDisabled={ isDisabled }
isArrayElement
/>
</ContractMethodFieldAccordion>
);
})
}
</>
);
}
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
const isTupleArray = arrayMatch?.itemType.includes('tuple');
if (isNestedArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
if (isTupleArray) {
const content = (
<>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<ContractMethodFieldInputArray
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
</>
);
}
const isTupleArray = data.type.includes('tuple');
if (isArrayElement) {
return content;
}
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
......@@ -94,22 +125,7 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
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 }
/>
);
}) }
{ content }
</ContractMethodFieldAccordion>
);
}
......@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
// primitive value array
return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/>
{ !isArrayElement && <ContractMethodFieldLabel data={ data } level={ level }/> }
<Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
......@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
{ !hasFixedSize && registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 &&
{ !hasFixedSize && index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex>
);
......
......@@ -7,7 +7,7 @@ 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';
import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
......@@ -41,15 +41,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
);
}
const arrayMatch = component.type.match(ARRAY_REGEXP);
const arrayMatch = matchArray(component.type);
if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
level={ arrayMatch.itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
......
......@@ -53,6 +53,13 @@ const data: SmartContractWriteMethod = {
type: 'tuple[][]',
},
// TOP LEVEL NESTED ARRAY
{
internalType: 'int256[2][][3]',
name: 'ParentArray',
type: 'int256[2][][3]',
},
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
......@@ -125,9 +132,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
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.getByLabel('#1 FulfillmentComponent[] (tuple[])').getByText('#1 FulfillmentComponent (tuple)').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('ParentArray (int256[2][][3])').click();
await component.getByText('#1 int256[2][] (int256[2][])').click();
await component.getByLabel('#1 int256[2][] (int256[2][])').getByText('#1 int256[2] (int256[2])').click();
// submit form
await component.getByRole('button', { name: 'Write' }).click();
......
......@@ -11,11 +11,12 @@ import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/co
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs';
import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> {
......@@ -97,8 +98,25 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
}
const arrayMatch = input.type.match(ARRAY_REGEXP);
const arrayMatch = matchArray(input.type);
if (arrayMatch) {
if (arrayMatch.isNested) {
const fieldsWithErrors = Object.keys(formApi.formState.errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':'));
return (
<ContractMethodFieldAccordion
key={ index }
level={ 0 }
label={ getFieldLabel(input) }
isInvalid={ isInvalid }
>
<ContractMethodFieldInputArray data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>
</ContractMethodFieldAccordion>
);
}
return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
}
......
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: bigint;
max: bigint;
}
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 };
}
......@@ -2,7 +2,7 @@ import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
interface Params {
argType: SmartContractMethodArgType;
......
......@@ -3,7 +3,7 @@ import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
import { BYTES_REGEXP } from './utils';
interface Params {
......
import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract';
export type ContractMethodFormFields = Record<string, string | boolean | undefined>;
......@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export interface MatchArray {
itemType: SmartContractMethodArgType;
size: number;
isNested: boolean;
}
export const matchArray = (argType: SmartContractMethodArgType): MatchArray | null => {
const match = argType.match(ARRAY_REGEXP);
if (!match) {
return null;
}
const [ , itemType, size ] = match;
const isNested = Boolean(matchArray(itemType as SmartContractMethodArgType));
return {
itemType: itemType as SmartContractMethodArgType,
size: size ? Number(size) : Infinity,
isNested,
};
};
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export const matchInt = (argType: SmartContractMethodArgType): 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 };
};
export const transformDataForArrayItem = (data: SmartContractMethodInput, index: number): SmartContractMethodInput => {
const arrayMatchType = matchArray(data.type);
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType as SmartContractMethodArgType) : null;
const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', '');
const postfix = childrenInternalType ? ' ' + childrenInternalType : '';
return {
...data,
type: arrayMatchType?.itemType || data.type,
internalType: childrenInternalType,
name: `#${ index + 1 }${ postfix }`,
};
};
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = BigInt(2 ** power);
const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1);
......
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