Skip to content

Commit

Permalink
feat: evm interact form
Browse files Browse the repository at this point in the history
  • Loading branch information
songwongtp committed Jan 20, 2025
1 parent c33dff4 commit 6229431
Show file tree
Hide file tree
Showing 29 changed files with 540 additions and 9 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Features

- [#1202](https://github.com/alleslabs/celatone-frontend/pull/1202) Add EVM contract interaction form
- [#1199](https://github.com/alleslabs/celatone-frontend/pull/1199) Add EVM contract verification with Hardhat
- [#1198](https://github.com/alleslabs/celatone-frontend/pull/1198) Add EVM contract verification with Foundry
- [#1197](https://github.com/alleslabs/celatone-frontend/pull/1197) Add EVM contract verification with standard JSON input both for Solidity and Vyper
Expand Down
50 changes: 50 additions & 0 deletions src/lib/components/evm-abi/EvmAbiForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { JsonFragmentType } from "ethers";
import { JsonDataType } from "lib/types";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { FormFields } from "./fields";
import { getComponentsDefaultValues } from "./utils";
import { cloneDeep } from "lodash";

interface EvmAbiFormProps {
types: ReadonlyArray<JsonFragmentType>;
isPayable: boolean;
initialData?: JsonDataType[];
propsOnChange?: (data: JsonDataType[]) => void;
}

export const EvmAbiForm = ({
types,
isPayable,
initialData,
propsOnChange,
}: EvmAbiFormProps) => {
const defaultValues = useMemo(
() => initialData ?? getComponentsDefaultValues(types),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(initialData)]
);

const { control, reset, watch } = useForm<{
inputs: JsonDataType[];
payableAmount: string;
}>({
defaultValues: { inputs: defaultValues, payableAmount: "" },
mode: "all",
});
const { inputs } = watch();

useEffect(() => {
reset({ inputs: defaultValues });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(defaultValues), reset]);

useEffect(() => {
propsOnChange?.(cloneDeep(inputs));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(inputs), propsOnChange]);

return (
<FormFields control={control} components={types} isPayable={isPayable} />
);
};
32 changes: 32 additions & 0 deletions src/lib/components/evm-abi/fields/BaseField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { FieldProps } from "./types";
import { FieldValues, useController } from "react-hook-form";
import { FormControl, FormErrorMessage, Input } from "@chakra-ui/react";
import { getRules } from "./utils";

interface BaseFieldProps<T extends FieldValues> extends FieldProps<T> {
isRequired?: boolean;
}

export const BaseField = <T extends FieldValues>({
control,
name,
type,
isRequired = false,
}: BaseFieldProps<T>) => {
const {
field: { value, onBlur, onChange, ...fieldProps },
fieldState: { isTouched, isDirty, error },
} = useController({
name,
control,
rules: getRules<T>(type, isRequired),
});
const isError = (isTouched || isDirty) && !!error;

return (
<FormControl isInvalid={isError} {...fieldProps}>
<Input value={value} onBlur={onBlur} onChange={onChange} />
{isError && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl>
);
};
39 changes: 39 additions & 0 deletions src/lib/components/evm-abi/fields/BoolField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { SelectInput, SelectInputOption } from "lib/components/forms";
import { FieldValues, useController } from "react-hook-form";
import { FieldProps } from "./types";

const BOOL_FIELD_OPTIONS: SelectInputOption<string>[] = [
{
label: "True",
value: "1",
},
{
label: "False",
value: "0",
},
];

export const BoolField = <T extends FieldValues>({
control,
name,
}: FieldProps<T>) => {
const {
field: { value, onChange },
} = useController({
control,
name,
});

return (
<SelectInput
options={BOOL_FIELD_OPTIONS}
menuPortalTarget={document.body}
value={BOOL_FIELD_OPTIONS.find((option) => option.value === value)}
onChange={(newValue) => {
if (!newValue) return;
onChange(newValue.value);
}}
size="md"
/>
);
};
16 changes: 16 additions & 0 deletions src/lib/components/evm-abi/fields/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { FieldValues } from "react-hook-form";
import { BaseField } from "./BaseField";
import { BoolField } from "./BoolField";
import { TupleField } from "./TupleField";
import { FieldProps } from "./types";

export const Field = <T extends FieldValues>({
type,
components,
...rest
}: FieldProps<T>) => {
if (components)
return <TupleField type={type} components={components} {...rest} />;
if (type?.startsWith("bool")) return <BoolField type={type} {...rest} />;
return <BaseField type={type} isRequired {...rest} />;
};
95 changes: 95 additions & 0 deletions src/lib/components/evm-abi/fields/FieldTemplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Button, Flex, Text } from "@chakra-ui/react";
import { CustomIcon } from "lib/components/icon";
import { Option } from "lib/types";
import { FieldPath, FieldValues, useController } from "react-hook-form";
import { getDefaultValueFromDimensions } from "../utils";
import { Field } from "./Field";
import { FieldProps } from "./types";

interface FieldTemplateProps<T extends FieldValues> extends FieldProps<T> {
dimensions?: Option<number>[];
}

export const FieldTemplate = <T extends FieldValues>({
control,
name,
components,
dimensions = [],
...rest
}: FieldTemplateProps<T>) => {
const {
field: { value, onChange },
} = useController<T>({
control,
name,
});

if (dimensions.length === 0)
return (
<Field control={control} name={name} components={components} {...rest} />
);

const [currentDimension, ...restDimensions] = dimensions;
const isDynamic = currentDimension === undefined;

const arrayValue = value as unknown[];
return (
<Flex
direction="column"
gap={2}
w="full"
p={4}
border="1px solid var(--chakra-colors-gray-700)"
borderRadius="8px"
>
{arrayValue.length ? (
<>
{arrayValue.map((_, index) => (
<Flex key={index} align="center" gap={4}>
<FieldTemplate
name={`${name}.${index}` as FieldPath<T>}
control={control}
components={components}
dimensions={restDimensions}
{...rest}
/>
{isDynamic && (
<Button
w="56px"
h="56px"
variant="outline-gray"
size="lg"
onClick={() =>
onChange(arrayValue.filter((_, i) => i !== index))
}
p={0}
>
<CustomIcon name="delete" boxSize={3} />
</Button>
)}
</Flex>
))}
</>
) : (
<Text variant="body2" color="text.dark" textAlign="center">
Left blank to send as empty array
</Text>
)}
{isDynamic && (
<Button
variant="outline-gray"
mx="auto"
onClick={() =>
onChange([
...value,
getDefaultValueFromDimensions(restDimensions, components),
])
}
leftIcon={<CustomIcon name="plus" />}
>
Add Item
</Button>
)}
</Flex>
);
};
63 changes: 63 additions & 0 deletions src/lib/components/evm-abi/fields/TupleField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Flex } from "@chakra-ui/react";
import { JsonFragmentType } from "ethers";
import { FieldPath, FieldValues, useWatch } from "react-hook-form";
import { getTypeDimensions } from "../utils";
import { FieldTemplate } from "./FieldTemplate";
import { TypeLabel } from "./TypeLabel";
import { FieldProps } from "./types";

interface TupleFieldProps<T extends FieldValues> extends FieldProps<T> {
components: ReadonlyArray<JsonFragmentType>;
withoutBorder?: boolean;
}

export const TupleField = <T extends FieldValues>({
name,
control,
components,
withoutBorder,
}: TupleFieldProps<T>) => {
const values = useWatch<T>({
control,
name,
});

return (
<Flex
direction="column"
gap={2}
w="full"
{...(!withoutBorder && {
p: 4,
border: "1px solid var(--chakra-colors-gray-700)",
borderRadius: "8px",
})}
>
{(values as unknown[]).map((_, index) => {
const {
name: subfieldLabel,
type: subfieldType,
...rest
} = components[index];

return (
<TypeLabel
key={`${subfieldType}-${index}`}
label={subfieldLabel}
type={subfieldType}
isRequired
>
<FieldTemplate
name={`${name}.${index}` as FieldPath<T>}
control={control}
type={subfieldType}
label={subfieldLabel}
dimensions={getTypeDimensions(subfieldType)}
{...rest}
/>
</TypeLabel>
);
})}
</Flex>
);
};
36 changes: 36 additions & 0 deletions src/lib/components/evm-abi/fields/TypeLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Flex, Tag, Text } from "@chakra-ui/react";

interface TypeLabelProps {
label?: string;
type?: string;
isRequired?: boolean;
children?: JSX.Element;
}

export const TypeLabel = ({
label,
type,
isRequired = false,
children,
}: TypeLabelProps) => {
if (!label && !type) return children;
return (
<Flex width="full" direction="column" gap={2}>
<Flex align="center" gap="1">
<Text
variant="body3"
textColor={label ? "text.main" : "text.dark"}
fontWeight={700}
>
{label} {isRequired && <span style={{ color: "red" }}>*</span>}
</Text>
{type && (
<Tag variant="gray" size="xs">
{type}
</Tag>
)}
</Flex>
{children}
</Flex>
);
};
53 changes: 53 additions & 0 deletions src/lib/components/evm-abi/fields/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Control, FieldValues, Path } from "react-hook-form";
import { TupleField } from "./TupleField";
import { JsonFragmentType } from "ethers";
import { BaseField } from "./BaseField";
import { TypeLabel } from "./TypeLabel";
import { useEvmParams } from "lib/services/evm";
import { useAssetInfos } from "lib/services/assetService";
import { getTokenLabel } from "lib/utils";

interface FormFieldsProps<T extends FieldValues> {
control: Control<T>;
components: ReadonlyArray<JsonFragmentType>;
isPayable: boolean;
}

export const FormFields = <T extends FieldValues>({
control,
components,
isPayable,
}: FormFieldsProps<T>) => {
const { data: evmParamsData } = useEvmParams();
const { data: assetInfos } = useAssetInfos({
withPrices: true,
});

const feeDenom = evmParamsData?.params.feeDenom;
const feeLabel = feeDenom
? getTokenLabel(feeDenom, assetInfos?.[feeDenom].symbol)
: undefined;

return (
<>
<TupleField
control={control}
name={"inputs" as Path<T>}
components={components}
withoutBorder
/>
{isPayable && (
<TypeLabel
label={`Send native${feeLabel ? ` ${feeLabel}` : ""}`}
type="uint256"
>
<BaseField
control={control}
name={"payableAmount" as Path<T>}
type="uint256"
/>
</TypeLabel>
)}
</>
);
};
Loading

0 comments on commit 6229431

Please sign in to comment.