Commit 09f3a1d1 authored by tom's avatar tom

Merge branch 'main' of github.com:tom2drum/block-scout into public-keys-backend

parents 35a7925d 29b8fd2b
// maybe it depends on the network??
export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/;
export const ADDRESS_LENGTH = 42;
export const EMAIL_REGEXP = /^[\w.%+-]+@[a-zA-Z\d-]+(?:\.[a-zA-Z\d-]+)+$/;
// maybe it depends on the network??
export const TRANSACTION_HASH_REGEXP = /^0x[a-fA-F\d]{64}$/;
export const TRANSACTION_HASH_LENGTH = 66;
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import CustomAbi from 'ui/pages/CustomAbi';
const CustomAbiPage: NextPage = () => {
return (
<>
<Head><title>Custom ABI</title></Head>
<CustomAbi/>
</>
);
};
export default CustomAbiPage;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/custom_abis/${ req.query.id }`;
};
const customAbiHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default customAbiHandler;
import type { CustomAbis } from 'types/api/account';
import handler from 'lib/api/handler';
const customAbiHandler = handler<CustomAbis>(() => '/account/v1/user/custom_abis', [ 'GET', 'POST' ]);
export default customAbiHandler;
...@@ -6,21 +6,92 @@ import type { Dict } from '@chakra-ui/utils'; ...@@ -6,21 +6,92 @@ import type { Dict } from '@chakra-ui/utils';
import getDefaultFormColors from '../utils/getDefaultFormColors'; import getDefaultFormColors from '../utils/getDefaultFormColors';
const activeInputStyles = { const getActiveLabelStyles = (theme: Dict, fc: string, bc: string, size: 'md' | 'lg') => {
paddingTop: '30px', const baseStyles = {
paddingBottom: '10px', backgroundColor: bc,
color: getColor(theme, fc),
fontSize: 'xs',
lineHeight: '16px',
borderTopRightRadius: 'none',
};
switch (size) {
case 'md': {
return {
...baseStyles,
padding: '10px 16px 2px 16px',
};
}
case 'lg': {
return {
...baseStyles,
padding: '16px 24px 2px 24px',
};
}
}
};
const getDefaultLabelStyles = (size: 'md' | 'lg') => {
switch (size) {
case 'md': {
return {
fontSize: 'md',
lineHeight: '20px',
padding: '18px 16px',
right: '18px',
};
}
case 'lg': {
return {
fontSize: 'md',
lineHeight: '24px',
padding: '28px 24px',
right: '26px',
};
}
}
};
const getPaddingX = (size: 'md' | 'lg') => {
switch (size) {
case 'md': {
return '16px';
}
case 'lg': {
return '24px';
}
}
}; };
const getActiveLabelStyles = (theme: Dict, fc: string) => ({ const getActiveInputStyles = (size: 'md' | 'lg') => {
color: getColor(theme, fc), switch (size) {
transform: 'scale(0.75) translateY(-10px)', case 'md': {
}); return {
paddingTop: '26px',
paddingBottom: '10px',
};
}
case 'lg': {
return {
paddingTop: '38px',
paddingBottom: '18px',
};
}
}
};
const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionProps) => { const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionProps) => {
const { theme } = props; const { theme, backgroundColor, size = 'md' } = props;
const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props); const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
const bc = backgroundColor || mode('white', 'black')(props);
const activeLabelStyles = getActiveLabelStyles(theme, fc); const px = getPaddingX(size);
const activeInputStyles = getActiveInputStyles(size);
const activeLabelStyles = getActiveLabelStyles(theme, fc, bc, size);
return { return {
container: { container: {
...@@ -37,22 +108,19 @@ const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionP ...@@ -37,22 +108,19 @@ const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionP
}, },
// label's styles // label's styles
label: { label: {
left: '22px', ...getDefaultLabelStyles(size),
left: '2px',
top: '2px',
zIndex: 2, zIndex: 2,
position: 'absolute', position: 'absolute',
color: mode('gray.500', 'whiteAlpha.400')(props), borderRadius: 'base',
boxSizing: 'border-box',
color: 'gray.500',
backgroundColor: 'transparent', backgroundColor: 'transparent',
pointerEvents: 'none', pointerEvents: 'none',
margin: 0, margin: 0,
transformOrigin: 'left top', transformOrigin: 'top left',
fontSize: 'md', transitionProperty: 'font-size, line-height, padding, top, background-color',
lineHeight: '20px',
},
'input + label': {
top: 'calc(50% - 10px);',
},
'textarea + label': {
top: '20px',
}, },
'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': { 'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': {
...activeLabelStyles, ...activeLabelStyles,
...@@ -62,7 +130,10 @@ const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionP ...@@ -62,7 +130,10 @@ const variantFloating: PartsStyleFunction<typeof parts> = (props: StyleFunctionP
}, },
// input's styles // input's styles
'input, textarea': { 'input, textarea': {
padding: '20px', padding: px,
},
'input[disabled] + label, textarea[disabled] + label': {
backgroundColor: 'transparent',
}, },
'input:not(:placeholder-shown), textarea:not(:placeholder-shown)': { 'input:not(:placeholder-shown), textarea:not(:placeholder-shown)': {
...activeInputStyles, ...activeInputStyles,
......
...@@ -7,16 +7,17 @@ const baseStyleDialog: SystemStyleFunction = (props) => { ...@@ -7,16 +7,17 @@ const baseStyleDialog: SystemStyleFunction = (props) => {
return { return {
padding: 8, padding: 8,
borderRadius: 'lg', borderRadius: 'lg',
bg: mode('white', 'gray.800')(props), bg: mode('white', 'gray.900')(props),
}; };
}; };
const baseStyleHeader = { const baseStyleHeader: SystemStyleFunction = (props) => ({
padding: 0, padding: 0,
marginBottom: 8, marginBottom: 8,
fontSize: '2xl', fontSize: '2xl',
lineHeight: 10, lineHeight: 10,
}; color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
});
const baseStyleBody = { const baseStyleBody = {
padding: 0, padding: 0,
...@@ -34,7 +35,7 @@ const baseStyleCloseButton: SystemStyleFunction = (props) => { ...@@ -34,7 +35,7 @@ const baseStyleCloseButton: SystemStyleFunction = (props) => {
right: 8, right: 8,
height: 10, height: 10,
width: 10, width: 10,
color: mode('gray.700', 'gray.600')(props), color: mode('gray.700', 'gray.500')(props),
_hover: { color: 'blue.400' }, _hover: { color: 'blue.400' },
_active: { bg: 'none' }, _active: { bg: 'none' },
}; };
...@@ -45,7 +46,7 @@ const baseStyleOverlay = { ...@@ -45,7 +46,7 @@ const baseStyleOverlay = {
const baseStyle: PartsStyleFunction<typeof parts> = (props) => ({ const baseStyle: PartsStyleFunction<typeof parts> = (props) => ({
dialog: baseStyleDialog(props), dialog: baseStyleDialog(props),
header: baseStyleHeader, header: baseStyleHeader(props),
body: baseStyleBody, body: baseStyleBody,
footer: baseStyleFooter, footer: baseStyleFooter,
closeButton: baseStyleCloseButton(props), closeButton: baseStyleCloseButton(props),
......
...@@ -11,7 +11,7 @@ const variantSimple: PartsStyleFunction<typeof parts> = (props) => { ...@@ -11,7 +11,7 @@ const variantSimple: PartsStyleFunction<typeof parts> = (props) => {
return { return {
th: { th: {
border: 0, border: 0,
color: mode('gray.600', 'gray.50')(props), color: mode('gray.600', 'whiteAlpha.700')(props),
...transitionProps, ...transitionProps,
}, },
thead: { thead: {
......
...@@ -5,17 +5,18 @@ import getDefaultFormColors from './getDefaultFormColors'; ...@@ -5,17 +5,18 @@ import getDefaultFormColors from './getDefaultFormColors';
import getDefaultTransitionProps from './getDefaultTransitionProps'; import getDefaultTransitionProps from './getDefaultTransitionProps';
export default function getOutlinedFieldStyles(props: StyleFunctionProps) { export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
const { theme } = props; const { theme, borderColor } = props;
const { focusColor: fc, errorColor: ec, filledColor: flc } = getDefaultFormColors(props); const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
const transitionProps = getDefaultTransitionProps(); const transitionProps = getDefaultTransitionProps();
return { return {
border: '2px solid', border: '2px solid',
bg: 'inherit', // filled input
borderColor: getColor(theme, flc), backgroundColor: 'transparent',
borderColor: mode('gray.300', 'gray.600')(props),
...transitionProps, ...transitionProps,
_hover: { _hover: {
borderColor: mode('gray.200', 'whiteAlpha.400')(props), borderColor: mode('gray.200', 'gray.500')(props),
}, },
_readOnly: { _readOnly: {
boxShadow: 'none !important', boxShadow: 'none !important',
...@@ -23,7 +24,7 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) { ...@@ -23,7 +24,7 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
}, },
_disabled: { _disabled: {
opacity: 1, opacity: 1,
background: mode('gray.200', 'whiteAlpha.400')(props), backgroundColor: mode('gray.200', 'whiteAlpha.200')(props),
border: 'none', border: 'none',
cursor: 'not-allowed', cursor: 'not-allowed',
}, },
...@@ -39,7 +40,8 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) { ...@@ -39,7 +40,8 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
_placeholder: { _placeholder: {
color: mode('blackAlpha.600', 'whiteAlpha.600')(props), color: mode('blackAlpha.600', 'whiteAlpha.600')(props),
}, },
':placeholder-shown:not(:focus-visible):not(:hover)': { borderColor: mode('blackAlpha.100', 'whiteAlpha.200')(props) }, // not filled input
':placeholder-shown:not(:focus-visible):not(:hover)': { borderColor: borderColor || mode('gray.100', 'gray.700')(props) },
':-webkit-autofill': { transition: 'background-color 5000s ease-in-out 0s' }, ':-webkit-autofill': { transition: 'background-color 5000s ease-in-out 0s' },
':-webkit-autofill:hover': { transition: 'background-color 5000s ease-in-out 0s' }, ':-webkit-autofill:hover': { transition: 'background-color 5000s ease-in-out 0s' },
':-webkit-autofill:focus': { transition: 'background-color 5000s ease-in-out 0s' }, ':-webkit-autofill:focus': { transition: 'background-color 5000s ease-in-out 0s' },
......
...@@ -89,3 +89,27 @@ export type PublicTagNew = Omit<PublicTag, 'addresses' | 'id'> & { ...@@ -89,3 +89,27 @@ export type PublicTagNew = Omit<PublicTag, 'addresses' | 'id'> & {
} }
export type PublicTags = Array<PublicTag>; export type PublicTags = Array<PublicTag>;
export type CustomAbis = Array<CustomAbi>
export interface CustomAbi {
name: string;
id: number;
contract_address_hash: string;
abi: Array<AbiItem>;
}
export interface AbiItem {
type: 'function';
stateMutability: 'nonpayable' | 'view';
payable: boolean;
outputs: Array<AbiInputOutput>;
name: string;
inputs: Array<AbiInputOutput>;
constant: boolean;
}
interface AbiInputOutput {
type: 'uint256';
name: string;
}
...@@ -4,9 +4,10 @@ import { ...@@ -4,9 +4,10 @@ import {
FormControl, FormControl,
FormLabel, FormLabel,
Input, Input,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
...@@ -22,17 +23,18 @@ type Inputs = { ...@@ -22,17 +23,18 @@ type Inputs = {
name: string; name: string;
} }
// idk, maybe there is no limit const NAME_MAX_LENGTH = 255;
const NAME_MAX_LENGTH = 100;
const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => { const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>(); const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
mode: 'all',
defaultValues: {
token: data?.api_key || '',
name: data?.name || '',
},
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const formBackgroundColor = useColorModeValue('white', 'gray.900');
useEffect(() => {
setValue('token', data?.api_key || '');
setValue('name', data?.name || '');
}, [ setValue, data ]);
const updateApiKey = (data: Inputs) => { const updateApiKey = (data: Inputs) => {
const body = JSON.stringify({ name: data.name }); const body = JSON.stringify({ name: data.name });
...@@ -88,7 +90,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -88,7 +90,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
const renderNameInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'name'>}) => { const renderNameInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'name'>}) => {
return ( return (
<FormControl variant="floating" id="name" isRequired> <FormControl variant="floating" id="name" isRequired backgroundColor={ formBackgroundColor }>
<Input <Input
{ ...field } { ...field }
isInvalid={ Boolean(errors.name) } isInvalid={ Boolean(errors.name) }
...@@ -97,7 +99,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -97,7 +99,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
<FormLabel>Application name for API key (e.g Web3 project)</FormLabel> <FormLabel>Application name for API key (e.g Web3 project)</FormLabel>
</FormControl> </FormControl>
); );
}, [ errors ]); }, [ errors, formBackgroundColor ]);
return ( return (
<> <>
......
import {
Box,
Button,
FormControl,
FormLabel,
Input,
Textarea,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, SubmitHandler } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { CustomAbi, CustomAbis } from 'types/api/account';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
type Props = {
data?: CustomAbi;
onClose: () => void;
}
type Inputs = {
contract_address_hash: string;
name: string;
abi: string;
}
const NAME_MAX_LENGTH = 255;
const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
const { control, formState: { errors }, handleSubmit } = useForm<Inputs>({
defaultValues: {
contract_address_hash: data?.contract_address_hash || '',
name: data?.name || '',
abi: JSON.stringify(data?.abi) || '',
},
mode: 'all',
});
const queryClient = useQueryClient();
const customAbiKey = (data: Inputs & { id?: number }) => {
const body = JSON.stringify({ name: data.name, contract_address_hash: data.contract_address_hash, abi: data.abi });
if (!data.id) {
return fetch('/api/account/custom-abis', { method: 'POST', body });
}
return fetch(`/api/account/custom-abis/${ data.id }`, { method: 'PUT', body });
};
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const mutation = useMutation(customAbiKey, {
onSuccess: async(data) => {
const response: CustomAbi = await data.json();
queryClient.setQueryData([ 'custom-abis' ], (prevData: CustomAbis | undefined) => {
const isExisting = prevData && prevData.some((item) => item.id === response.id);
if (isExisting) {
return prevData.map((item) => {
if (item.id === response.id) {
return response;
}
return item;
});
}
return [ ...(prevData || []), response ];
});
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onSubmit: SubmitHandler<Inputs> = useCallback((formData) => {
mutation.mutate({ ...formData, id: data?.id });
}, [ mutation, data ]);
const renderContractAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'contract_address_hash'>}) => {
return (
<AddressInput<Inputs, 'contract_address_hash'>
field={ field }
isInvalid={ Boolean(errors.contract_address_hash) }
backgroundColor={ formBackgroundColor }
placeholder="Smart contract address (0x...)"
/>
);
}, [ errors, formBackgroundColor ]);
const renderNameInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'name'>}) => {
return (
<FormControl variant="floating" id="name" isRequired backgroundColor={ formBackgroundColor }>
<Input
{ ...field }
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
/>
<FormLabel>Project name</FormLabel>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
const renderAbiInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'abi'>}) => {
return (
<FormControl variant="floating" id="abi" isRequired backgroundColor={ formBackgroundColor }>
<Textarea
{ ...field }
size="lg"
isInvalid={ Boolean(errors.abi) }
/>
<FormLabel>{ `Custom ABI [{...}] (JSON format)` }</FormLabel>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
return (
<>
<Box>
<Controller
name="contract_address_hash"
control={ control }
render={ renderContractAddressInput }
rules={{ pattern: ADDRESS_REGEXP }}
/>
</Box>
<Box marginTop={ 5 }>
<Controller
name="name"
control={ control }
render={ renderNameInput }
/>
</Box>
<Box marginTop={ 5 }>
<Controller
name="abi"
control={ control }
render={ renderAbiInput }
/>
</Box>
<Box marginTop={ 8 }>
<Button
size="lg"
variant="primary"
onClick={ handleSubmit(onSubmit) }
disabled={ Object.keys(errors).length > 0 }
isLoading={ mutation.isLoading }
>
{ data ? 'Save' : 'Create custom ABI' }
</Button>
</Box>
</>
);
};
export default React.memo(CustomAbiForm);
import React, { useCallback } from 'react';
import type { CustomAbi } from 'types/api/account';
import FormModal from 'ui/shared/FormModal';
import CustomAbiForm from './CustomAbiForm';
type Props = {
isOpen: boolean;
onClose: () => void;
data?: CustomAbi;
}
const CustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit custom ABI' : 'New custom ABI';
const text = 'Double check the ABI matches the contract to prevent errors or incorrect results.';
const renderForm = useCallback(() => {
return <CustomAbiForm data={ data } onClose={ onClose }/>;
}, [ data, onClose ]);
return (
<FormModal<CustomAbi>
isOpen={ isOpen }
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
/>
);
};
export default React.memo(CustomAbiModal);
import {
Table,
Thead,
Tbody,
Tr,
Th,
TableContainer,
} from '@chakra-ui/react';
import React from 'react';
import type { CustomAbis, CustomAbi } from 'types/api/account';
import CustomAbiTableItem from './CustomAbiTableItem';
interface Props {
data: CustomAbis;
onEditClick: (item: CustomAbi) => void;
onDeleteClick: (item: CustomAbi) => void;
}
const CustomAbiTable = ({ data, onDeleteClick, onEditClick }: Props) => {
return (
<TableContainer width="100%">
<Table variant="simple" minWidth="600px">
<Thead>
<Tr>
<Th>ABI for Smart contract address (0x...)</Th>
<Th width="108px"></Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<CustomAbiTableItem
item={ item }
key={ item.id }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
)) }
</Tbody>
</Table>
</TableContainer>
);
};
export default React.memo(CustomAbiTable);
import {
Tr,
Td,
HStack,
Text,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { CustomAbi } from 'types/api/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
interface Props {
item: CustomAbi;
onEditClick: (item: CustomAbi) => void;
onDeleteClick: (item: CustomAbi) => void;
}
const CustomAbiTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<HStack>
<Text fontSize="md" fontWeight={ 600 }>{ item.contract_address_hash }</Text>
<CopyToClipboard text={ item.contract_address_hash }/>
</HStack>
<Text fontSize="sm" marginTop={ 0.5 } variant="secondary">{ item.name }</Text>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td>
</Tr>
);
};
export default React.memo(CustomAbiTableItem);
import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
data: CustomAbi;
}
const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient();
const deleteApiKey = () => {
return fetch(`/api/account/custom-abis/${ data.id }`, { method: 'DELETE' });
};
const mutation = useMutation(deleteApiKey, {
onSuccess: async() => {
queryClient.setQueryData([ 'custom-abis' ], (prevData: CustomAbis | undefined) => {
return prevData?.filter((item) => item.id !== data.id);
});
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onDelete = useCallback(() => {
mutation.mutate(data);
}, [ data, mutation ]);
const renderText = useCallback(() => {
return (
<Text display="flex">Custom ABI for<Text fontWeight="600" whiteSpace="pre">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text>
);
}, [ data.name ]);
return (
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
onDelete={ onDelete }
title="Remove custom ABI"
renderContent={ renderText }
pending={ mutation.isLoading }
/>
);
};
export default React.memo(DeleteCustomAbiModal);
...@@ -30,6 +30,7 @@ const SearchBar = () => { ...@@ -30,6 +30,7 @@ const SearchBar = () => {
placeholder="Search by addresses / transactions / block / token... " placeholder="Search by addresses / transactions / block / token... "
ml="1px" ml="1px"
onChange={ handleChange } onChange={ handleChange }
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
/> />
</InputGroup> </InputGroup>
</form> </form>
......
...@@ -19,7 +19,7 @@ const NetworkMenu = ({ isCollapsed }: Props) => { ...@@ -19,7 +19,7 @@ const NetworkMenu = ({ isCollapsed }: Props) => {
as={ networksIcon } as={ networksIcon }
width="16px" width="16px"
height="16px" height="16px"
color={ useColorModeValue('gray.500', 'white') } color={ useColorModeValue('gray.500', 'gray.400') }
_hover={{ color: 'blue.400' }} _hover={{ color: 'blue.400' }}
marginLeft={ isCollapsed ? '0px' : '27px' } marginLeft={ isCollapsed ? '0px' : '27px' }
cursor="pointer" cursor="pointer"
......
import { Box, Button, HStack, Text, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonTable from 'ui/shared/SkeletonTable';
const CustomAbiPage: React.FC = () => {
const customAbiModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>();
const [ deleteModalData, setDeleteModalData ] = useState<CustomAbi>();
const { data, isLoading, isError } = useQuery<unknown, unknown, CustomAbis>([ 'custom-abis' ], async() => {
const response = await fetch('/api/account/custom-abis');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
const onEditClick = useCallback((data: CustomAbi) => {
setCustomAbiModalData(data);
customAbiModalProps.onOpen();
}, [ customAbiModalProps ]);
const onCustomAbiModalClose = useCallback(() => {
setCustomAbiModalData(undefined);
customAbiModalProps.onClose();
}, [ customAbiModalProps ]);
const onDeleteClick = useCallback((data: CustomAbi) => {
setDeleteModalData(data);
deleteModalProps.onOpen();
}, [ deleteModalProps ]);
const onDeleteModalClose = useCallback(() => {
setDeleteModalData(undefined);
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const content = (() => {
if (isLoading || isError) {
return (
<>
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
}
return (
<>
{ data.length > 0 && (
<CustomAbiTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
<HStack marginTop={ 8 } spacing={ 5 }>
<Button
variant="primary"
size="lg"
onClick={ customAbiModalProps.onOpen }
>
Add custom ABI
</Button>
</HStack>
</>
);
})();
return (
<Page>
<Box h="100%">
<AccountPageHeader text="Custom ABI"/>
<Text marginBottom={ 12 }>
Add custom ABIs for any contract and access when logged into your account. Helpful for debugging, functional testing and contract interaction.
</Text>
{ content }
</Box>
<CustomAbiModal { ...customAbiModalProps } onClose={ onCustomAbiModalClose } data={ customAbiModalData }/>
{ deleteModalData && <DeleteCustomAbiModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</Page>
);
};
export default CustomAbiPage;
import { import {
Box, Box,
Button, Button,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import type { AddressTag } from 'types/api/account'; import type { AddressTag } from 'types/api/account';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput'; import AddressInput from 'ui/shared/AddressInput';
import TagInput from 'ui/shared/TagInput'; import TagInput from 'ui/shared/TagInput';
const ADDRESS_LENGTH = 42;
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
...@@ -27,12 +28,15 @@ type Inputs = { ...@@ -27,12 +28,15 @@ type Inputs = {
const AddressForm: React.FC<Props> = ({ data, onClose }) => { const AddressForm: React.FC<Props> = ({ data, onClose }) => {
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>(); const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
mode: 'all',
defaultValues: {
address: data?.address_hash || '',
tag: data?.name || '',
},
});
useEffect(() => { const formBackgroundColor = useColorModeValue('white', 'gray.900');
setValue('address', data?.address_hash || '');
setValue('tag', data?.name || '');
}, [ setValue, data ]);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -67,12 +71,12 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -67,12 +71,12 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
}; };
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => { const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) }/>; return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => { const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) }/>; return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
return ( return (
<> <>
...@@ -81,8 +85,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -81,8 +85,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
name="address" name="address"
control={ control } control={ control }
rules={{ rules={{
maxLength: ADDRESS_LENGTH, pattern: ADDRESS_REGEXP,
minLength: ADDRESS_LENGTH,
}} }}
render={ renderAddressInput } render={ renderAddressInput }
/> />
......
import { import {
Box, Box,
Button, Button,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import type { TransactionTag } from 'types/api/account'; import type { TransactionTag } from 'types/api/account';
import { TRANSACTION_HASH_LENGTH, TRANSACTION_HASH_REGEXP } from 'lib/validations/transaction';
import TagInput from 'ui/shared/TagInput'; import TagInput from 'ui/shared/TagInput';
import TransactionInput from 'ui/shared/TransactionInput'; import TransactionInput from 'ui/shared/TransactionInput';
const HASH_LENGTH = 66;
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
...@@ -27,12 +28,15 @@ type Inputs = { ...@@ -27,12 +28,15 @@ type Inputs = {
const TransactionForm: React.FC<Props> = ({ data, onClose }) => { const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>(); const formBackgroundColor = useColorModeValue('white', 'gray.900');
useEffect(() => { const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
setValue('transaction', data?.transaction_hash || ''); mode: 'all',
setValue('tag', data?.name || ''); defaultValues: {
}, [ setValue, data ]); transaction: data?.transaction_hash || '',
tag: data?.name || '',
},
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -68,12 +72,12 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -68,12 +72,12 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
}; };
const renderTransactionInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'transaction'>}) => { const renderTransactionInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'transaction'>}) => {
return <TransactionInput field={ field } isInvalid={ Boolean(errors.transaction) }/>; return <TransactionInput field={ field } isInvalid={ Boolean(errors.transaction) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => { const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) }/>; return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
return ( return (
<> <>
...@@ -82,8 +86,9 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -82,8 +86,9 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
name="transaction" name="transaction"
control={ control } control={ control }
rules={{ rules={{
maxLength: HASH_LENGTH, maxLength: TRANSACTION_HASH_LENGTH,
minLength: HASH_LENGTH, minLength: TRANSACTION_HASH_LENGTH,
pattern: TRANSACTION_HASH_REGEXP,
}} }}
render={ renderTransactionInput } render={ renderTransactionInput }
/> />
......
...@@ -5,6 +5,7 @@ import { Controller } from 'react-hook-form'; ...@@ -5,6 +5,7 @@ import { Controller } from 'react-hook-form';
import MinusIcon from 'icons/minus.svg'; import MinusIcon from 'icons/minus.svg';
import PlusIcon from 'icons/plus.svg'; import PlusIcon from 'icons/plus.svg';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput'; import AddressInput from 'ui/shared/AddressInput';
import type { Inputs } from './PublicTagsForm'; import type { Inputs } from './PublicTagsForm';
...@@ -38,6 +39,7 @@ export default function PublicTagFormAction({ control, index, fieldsLength, hasE ...@@ -38,6 +39,7 @@ export default function PublicTagFormAction({ control, index, fieldsLength, hasE
name={ `addresses.${ index }.address` } name={ `addresses.${ index }.address` }
control={ control } control={ control }
render={ renderAddressInput } render={ renderAddressInput }
rules={{ pattern: ADDRESS_REGEXP }}
/> />
{ index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && ( { index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && (
<IconButton <IconButton
......
...@@ -5,6 +5,8 @@ import { Controller } from 'react-hook-form'; ...@@ -5,6 +5,8 @@ import { Controller } from 'react-hook-form';
import type { Inputs } from './PublicTagsForm'; import type { Inputs } from './PublicTagsForm';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props { interface Props {
control: Control<Inputs>; control: Control<Inputs>;
} }
...@@ -12,7 +14,7 @@ interface Props { ...@@ -12,7 +14,7 @@ interface Props {
export default function PublicTagFormComment({ control }: Props) { export default function PublicTagFormComment({ control }: Props) {
const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => { const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => {
return ( return (
<FormControl variant="floating" id={ field.name }> <FormControl variant="floating" id={ field.name } size="lg">
<Textarea <Textarea
{ ...field } { ...field }
size="lg" size="lg"
...@@ -27,6 +29,7 @@ export default function PublicTagFormComment({ control }: Props) { ...@@ -27,6 +29,7 @@ export default function PublicTagFormComment({ control }: Props) {
name="comment" name="comment"
control={ control } control={ control }
render={ renderComment } render={ renderComment }
rules={{ maxLength: TEXT_INPUT_MAX_LENGTH }}
/> />
); );
} }
...@@ -13,6 +13,8 @@ import { useForm, useFieldArray } from 'react-hook-form'; ...@@ -13,6 +13,8 @@ import { useForm, useFieldArray } from 'react-hook-form';
import type { PublicTags, PublicTag, PublicTagNew } from 'types/api/account'; import type { PublicTags, PublicTag, PublicTagNew } from 'types/api/account';
import { EMAIL_REGEXP } from 'lib/validations/email';
import PublicTagFormAction from './PublicTagFormAction'; import PublicTagFormAction from './PublicTagFormAction';
import PublicTagFormAddressInput from './PublicTagFormAddressInput'; import PublicTagFormAddressInput from './PublicTagFormAddressInput';
import PublicTagFormComment from './PublicTagFormComment'; import PublicTagFormComment from './PublicTagFormComment';
...@@ -53,16 +55,17 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -53,16 +55,17 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
defaultValues: { defaultValues: {
fullName: data?.full_name, fullName: data?.full_name || '',
email: data?.email, email: data?.email || '',
companyName: data?.company, companyName: data?.company || '',
companyUrl: data?.website, companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; '), tags: data?.tags.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.split(';').map((address, index: number) => ({ name: `address.${ index }.address`, address })) || addresses: data?.addresses.split(';').map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ], [ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment, comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report', action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
}, },
mode: 'all',
}); });
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
...@@ -133,16 +136,36 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -133,16 +136,36 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
<Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text> <Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text>
<Grid templateColumns="1fr 1fr" rowGap={ 4 } columnGap={ 5 }> <Grid templateColumns="1fr 1fr" rowGap={ 4 } columnGap={ 5 }>
<GridItem> <GridItem>
<PublicTagsFormInput<Inputs> fieldName="fullName" control={ control } label={ placeholders.fullName } required/> <PublicTagsFormInput<Inputs>
fieldName="fullName"
control={ control }
label={ placeholders.fullName }
required
/>
</GridItem> </GridItem>
<GridItem> <GridItem>
<PublicTagsFormInput<Inputs> fieldName="companyName" control={ control } label={ placeholders.companyName }/> <PublicTagsFormInput<Inputs>
fieldName="companyName"
control={ control }
label={ placeholders.companyName }
/>
</GridItem> </GridItem>
<GridItem> <GridItem>
<PublicTagsFormInput<Inputs> fieldName="email" control={ control } label={ placeholders.email } required/> <PublicTagsFormInput<Inputs>
fieldName="email"
control={ control }
label={ placeholders.email }
pattern={ EMAIL_REGEXP }
hasError={ Boolean(errors.email) }
required
/>
</GridItem> </GridItem>
<GridItem> <GridItem>
<PublicTagsFormInput<Inputs> fieldName="companyUrl" control={ control } label={ placeholders.companyUrl }/> <PublicTagsFormInput<Inputs>
fieldName="companyUrl"
control={ control }
label={ placeholders.companyUrl }
/>
</GridItem> </GridItem>
</Grid> </Grid>
<Box marginTop={ 4 } marginBottom={ 8 }> <Box marginTop={ 4 } marginBottom={ 8 }>
...@@ -150,14 +173,19 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -150,14 +173,19 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
</Box> </Box>
<Text size="sm" variant="secondary" marginBottom={ 5 }>Public tags (2 tags maximum, please use &quot;;&quot; as a divider)</Text> <Text size="sm" variant="secondary" marginBottom={ 5 }>Public tags (2 tags maximum, please use &quot;;&quot; as a divider)</Text>
<Box marginBottom={ 4 }> <Box marginBottom={ 4 }>
<PublicTagsFormInput<Inputs> fieldName="tags" control={ control } label={ placeholders.tags } required/> <PublicTagsFormInput<Inputs>
fieldName="tags"
control={ control }
label={ placeholders.tags }
hasError={ Boolean(errors.tags) }
required/>
</Box> </Box>
{ fields.map((field, index) => { { fields.map((field, index) => {
return ( return (
<Box position="relative" key={ field.id } marginBottom={ 4 }> <Box position="relative" key={ field.id } marginBottom={ 4 }>
<PublicTagFormAddressInput <PublicTagFormAddressInput
control={ control } control={ control }
hasError={ Boolean(errors.addresses) } hasError={ Boolean(errors?.addresses?.[index]) }
index={ index } index={ index }
fieldsLength={ fields.length } fieldsLength={ fields.length }
onAddFieldClick={ onAddFieldClick } onAddFieldClick={ onAddFieldClick }
......
...@@ -3,31 +3,45 @@ import React, { useCallback } from 'react'; ...@@ -3,31 +3,45 @@ import React, { useCallback } from 'react';
import type { ControllerRenderProps, FieldValues, Path, Control } from 'react-hook-form'; import type { ControllerRenderProps, FieldValues, Path, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props<TInputs extends FieldValues> { interface Props<TInputs extends FieldValues> {
fieldName: Path<TInputs>; fieldName: Path<TInputs>;
label: string; label: string;
required?: boolean; required?: boolean;
control: Control<TInputs, object>; control: Control<TInputs, object>;
pattern?: RegExp;
hasError?: boolean;
} }
export default function PublicTagsFormInput<Inputs extends FieldValues>({ label, control, required, fieldName }: Props<Inputs>) { export default function PublicTagsFormInput<Inputs extends FieldValues>({
label,
control,
required,
fieldName,
pattern,
hasError,
}: Props<Inputs>) {
const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => { const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => {
return ( return (
<FormControl variant="floating" id={ field.name } isRequired={ required }> <FormControl variant="floating" id={ field.name } isRequired={ required } size="lg">
<Input <Input
{ ...field } { ...field }
size="lg" size="lg"
required={ required } required={ required }
isInvalid={ hasError }
maxLength={ TEXT_INPUT_MAX_LENGTH }
/> />
<FormLabel>{ label }</FormLabel> <FormLabel>{ label }</FormLabel>
</FormControl> </FormControl>
); );
}, [ label, required ]); }, [ label, required, hasError ]);
return ( return (
<Controller <Controller
name={ fieldName } name={ fieldName }
control={ control } control={ control }
render={ renderInput } render={ renderInput }
rules={{ pattern }}
/> />
); );
} }
...@@ -6,13 +6,14 @@ import { ...@@ -6,13 +6,14 @@ import {
import React from 'react'; import React from 'react';
import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form'; import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
const ADDRESS_LENGTH = 42; import { ADDRESS_LENGTH } from 'lib/validations/address';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = { type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>; field: ControllerRenderProps<TInputs, TInputName>;
isInvalid: boolean; isInvalid: boolean;
size?: string; size?: string;
placeholder?: string; placeholder?: string;
backgroundColor?: string;
} }
export default function AddressInput<Inputs extends FieldValues, Name extends Path<Inputs>>( export default function AddressInput<Inputs extends FieldValues, Name extends Path<Inputs>>(
...@@ -21,9 +22,10 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa ...@@ -21,9 +22,10 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa
isInvalid, isInvalid,
size, size,
placeholder = 'Address (0x...)', placeholder = 'Address (0x...)',
backgroundColor,
}: Props<Inputs, Name>) { }: Props<Inputs, Name>) {
return ( return (
<FormControl variant="floating" id="address" isRequired> <FormControl variant="floating" id="address" isRequired backgroundColor={ backgroundColor } size={ size }>
<Input <Input
{ ...field } { ...field }
isInvalid={ isInvalid } isInvalid={ isInvalid }
......
...@@ -11,11 +11,12 @@ const TAG_MAX_LENGTH = 35; ...@@ -11,11 +11,12 @@ const TAG_MAX_LENGTH = 35;
type Props<Field> = { type Props<Field> = {
field: Field; field: Field;
isInvalid: boolean; isInvalid: boolean;
backgroundColor?: string;
} }
function TagInput<Field extends Partial<ControllerRenderProps<FieldValues, 'tag'>>>({ field, isInvalid }: Props<Field>) { function TagInput<Field extends Partial<ControllerRenderProps<FieldValues, 'tag'>>>({ field, isInvalid, backgroundColor }: Props<Field>) {
return ( return (
<FormControl variant="floating" id="tag" isRequired> <FormControl variant="floating" id="tag" isRequired backgroundColor={ backgroundColor }>
<Input <Input
{ ...field } { ...field }
isInvalid={ isInvalid } isInvalid={ isInvalid }
......
...@@ -11,11 +11,12 @@ const HASH_LENGTH = 66; ...@@ -11,11 +11,12 @@ const HASH_LENGTH = 66;
type Props<Field> = { type Props<Field> = {
field: Field; field: Field;
isInvalid: boolean; isInvalid: boolean;
backgroundColor?: string;
} }
function AddressInput<Field extends Partial<ControllerRenderProps<FieldValues, 'transaction'>>>({ field, isInvalid }: Props<Field>) { function AddressInput<Field extends Partial<ControllerRenderProps<FieldValues, 'transaction'>>>({ field, isInvalid, backgroundColor }: Props<Field>) {
return ( return (
<FormControl variant="floating" id="transaction" isRequired> <FormControl variant="floating" id="transaction" isRequired backgroundColor={ backgroundColor }>
<Input <Input
{ ...field } { ...field }
isInvalid={ isInvalid } isInvalid={ isInvalid }
......
...@@ -5,20 +5,23 @@ import { ...@@ -5,20 +5,23 @@ import {
Text, Text,
Grid, Grid,
GridItem, GridItem,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import type { TWatchlistItem } from 'types/client/account'; import type { TWatchlistItem } from 'types/client/account';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput'; import AddressInput from 'ui/shared/AddressInput';
import TagInput from 'ui/shared/TagInput'; import TagInput from 'ui/shared/TagInput';
// does it depend on the network?
const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const; const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const;
const NOTIFICATIONS_NAMES = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ]; const NOTIFICATIONS_NAMES = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ];
const ADDRESS_LENGTH = 42;
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
...@@ -58,7 +61,24 @@ type Checkboxes = 'notification' | ...@@ -58,7 +61,24 @@ type Checkboxes = 'notification' |
const AddressForm: React.FC<Props> = ({ data, onClose }) => { const AddressForm: React.FC<Props> = ({ data, onClose }) => {
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>(); const formBackgroundColor = useColorModeValue('white', 'gray.900');
let notificationsDefault = {} as Inputs['notification_settings'];
if (!data) {
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true });
} else {
notificationsDefault = data.notification_settings;
}
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
defaultValues: {
address: data?.address_hash || '',
tag: data?.name || '',
notification: data ? data.notification_methods.email : true,
notification_settings: notificationsDefault,
},
mode: 'all',
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -96,22 +116,13 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -96,22 +116,13 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
mutate(formData); mutate(formData);
}; };
useEffect(() => {
const notificationsDefault = {} as Inputs['notification_settings'];
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true });
setValue('address', data?.address_hash || '');
setValue('tag', data?.name || '');
setValue('notification', data ? data.notification_methods.email : true);
setValue('notification_settings', data ? data.notification_settings : notificationsDefault);
}, [ setValue, data ]);
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => { const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) }/>; return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => { const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) }/>; return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
}, [ errors ]); }, [ errors, formBackgroundColor ]);
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => ( const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
...@@ -133,8 +144,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => { ...@@ -133,8 +144,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
name="address" name="address"
control={ control } control={ control }
rules={{ rules={{
maxLength: ADDRESS_LENGTH, pattern: ADDRESS_REGEXP,
minLength: ADDRESS_LENGTH,
}} }}
render={ renderAddressInput } render={ renderAddressInput }
/> />
......
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