Commit 36547c01 authored by isstuev's avatar isstuev

error handlers

parent bcc6e990
......@@ -5,8 +5,8 @@ import * as cookies from 'lib/cookies';
type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE';
export default function handler<TRes>(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
return async(_req: NextApiRequest, res: NextApiResponse<TRes>) => {
export default function handler<TRes, TErrRes>(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
return async(_req: NextApiRequest, res: NextApiResponse<TRes | TErrRes>) => {
if (_req.method && allowedMethods.includes(_req.method as Methods)) {
const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD';
const networkType = _req.cookies[cookies.NAMES.NETWORK_TYPE];
......@@ -23,11 +23,9 @@ export default function handler<TRes>(getUrl: (_req: NextApiRequest) => string,
body: isBodyDisallowed ? undefined : _req.body,
});
// FIXME: add error handlers
if (response.status !== 200) {
// eslint-disable-next-line no-console
console.error(response.statusText);
res.status(500).end('Unknown error');
const error = await response.json() as { errors: TErrRes };
res.status(500).json(error?.errors || {} as TErrRes);
return;
}
......
......@@ -4,7 +4,7 @@ export interface ErrorType<T> {
statusText: Response['statusText'];
}
export default function clientFetch<Response, Error>(path: string, init: RequestInit): Promise<Response | ErrorType<Error>> {
export default function clientFetch<Success, Error>(path: string, init?: RequestInit): Promise<Success | ErrorType<Error>> {
return fetch(path, init).then(response => {
if (!response.ok) {
return response.json().then(
......@@ -20,7 +20,7 @@ export default function clientFetch<Response, Error>(path: string, init: Request
);
} else {
return response.json() as Promise<Response>;
return response.json() as Promise<Success>;
}
});
}
export default function getErrorMessage(error: Record<string, Array<string>> | undefined, field: string) {
return error?.[field]?.join(', ');
}
export default function getPlaceholderWithError(text: string, errorText?: string) {
return `${ text }${ errorText ? ' - ' + errorText : '' }`;
}
import { useQuery } from '@tanstack/react-query';
import type { NextPage } from 'next';
import Head from 'next/head';
import React, { useCallback, useState } from 'react';
......@@ -8,32 +7,11 @@ import PrivateTags from 'ui/pages/PrivateTags';
const TABS = [ 'address', 'transaction' ];
const PrivateTagsPage: NextPage = () => {
const [ activeTab, setActiveTab ] = useState(TABS[0]);
const [ , setActiveTab ] = useState(TABS[0]);
const onChangeTab = useCallback((index: number) => {
setActiveTab(TABS[index]);
}, [ setActiveTab ]);
// eslint-disable-next-line no-console
console.log(activeTab);
// FIXME: request data only for active tab and only once
// don't refetch after tab change
useQuery([ 'address' ], async() => {
const response = await fetch('/api/account/private-tags/address');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
useQuery([ 'transaction' ], async() => {
const response = await fetch('/api/account/private-tags/transaction');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
return (
<>
<Head><title>Private tags</title></Head>
......
import { useQuery } from '@tanstack/react-query';
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
......@@ -6,14 +5,6 @@ import React from 'react';
import WatchList from 'ui/pages/Watchlist';
const WatchListPage: NextPage = () => {
useQuery([ 'watchlist' ], async() => {
const response = await fetch('/api/account/watchlist/get-with-tokens');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
return (
<>
<Head><title>Watch list</title></Head>
......
import type { NextApiRequest } from 'next';
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/watchlist/${ req.query.id }`;
};
const addressEditHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
const addressEditHandler = handler<WatchlistAddresses, WatchlistErrors>(getUrl, [ 'DELETE', 'PUT' ]);
export default addressEditHandler;
......@@ -8,15 +8,14 @@ import fetch from 'lib/api/fetch';
const watchlistWithTokensHandler = async(_req: NextApiRequest, res: NextApiResponse<Array<TWatchlistItem>>) => {
const watchlistResponse = await fetch('/account/v1/user/watchlist', { method: 'GET' });
const watchlistData = await watchlistResponse.json() as WatchlistAddresses;
if (watchlistResponse.status !== 200) {
// eslint-disable-next-line no-console
console.error(watchlistResponse.statusText);
res.status(500).end('Unknown error');
res.status(500).end(watchlistData || 'Unknown error');
return;
}
const watchlistData = await watchlistResponse.json() as WatchlistAddresses;
const data = await Promise.all(watchlistData.map(async item => {
const tokens = await fetch(`?module=account&action=tokenlist&address=${ item.address_hash }`);
......
import type { WatchlistAddresses } from 'types/api/account';
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
import handler from 'lib/api/handler';
const watchlistHandler = handler<WatchlistAddresses>(() => '/account/v1/user/watchlist', [ 'GET', 'POST' ]);
const watchlistHandler = handler<WatchlistAddresses, WatchlistErrors>(() => '/account/v1/user/watchlist', [ 'GET', 'POST' ]);
export default watchlistHandler;
import type { ComponentStyleConfig } from '@chakra-ui/theme';
const Alert: ComponentStyleConfig = {
baseStyle: {
container: {
borderRadius: '12px',
px: 6,
py: 4,
},
},
};
export default Alert;
import Alert from './Alert';
import Button from './Button';
import Checkbox from './Checkbox';
import Form from './Form';
......@@ -16,6 +17,7 @@ import Textarea from './Textarea';
import Tooltip from './Tooltip';
const components = {
Alert,
Button,
Checkbox,
Heading,
......
......@@ -19,6 +19,10 @@ const colors = {
},
red: {
'500': '#E53E3E',
'100': '#FED7D7',
},
orange: {
'100': '#FEEBCB',
},
gray: {
'50': '#F7FAFC', // <-
......
......@@ -111,3 +111,41 @@ interface AbiInputOutput {
type: 'uint256';
name: string;
}
export type WatchlistErrors = {
address_hash?: Array<string>;
name?: Array<string>;
watchlist_id?: Array<string>;
}
export type CustomAbiErrors = {
address_hash?: Array<string>;
name?: Array<string>;
abi?: Array<string>;
identity_id?: Array<string>;
}
export type ApiKeyErrors = {
name?: Array<string>;
identity_id?: Array<string>;
};
export type AddressTagErrors = {
address_hash: Array<string>;
name: Array<string>;
identity_id?: Array<string>;
}
export type TransactionTagErrors = {
tx_hash: Array<string>;
name: Array<string>;
identity_id?: Array<string>;
}
export type PublicTagErrors = {
additional_comment: Array<string>;
addresses: Array<string>;
email: Array<string>;
full_name: Array<string>;
tags: Array<string>;
}
......@@ -11,11 +11,17 @@ import React, { useCallback } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { ApiKey, ApiKeys } from 'types/api/account';
import type { ApiKey, ApiKeys, ApiKeyErrors } from 'types/api/account';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
type Props = {
data?: ApiKey;
onClose: () => void;
setAlertVisible: (isAlertVisible: boolean) => void;
}
type Inputs = {
......@@ -25,8 +31,8 @@ type Inputs = {
const NAME_MAX_LENGTH = 255;
const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, handleSubmit, formState: { errors }, setError } = useForm<Inputs>({
mode: 'all',
defaultValues: {
token: data?.api_key || '',
......@@ -48,7 +54,7 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
const mutation = useMutation(updateApiKey, {
onSuccess: async(data) => {
const response: ApiKey = await data.json();
const response = data as unknown as ApiKey;
queryClient.setQueryData([ 'api-keys' ], (prevData: ApiKeys | undefined) => {
const isExisting = prevData && prevData.some((item) => item.api_key === response.api_key);
......@@ -68,13 +74,21 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
onError: (e: ErrorType<ApiKeyErrors>) => {
if (e?.error?.name) {
setError('name', { type: 'custom', message: getErrorMessage(e.error, 'name') });
} else if (e?.error?.identity_id) {
setError('name', { type: 'custom', message: getErrorMessage(e.error, 'identity_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
setAlertVisible(false);
mutation.mutate(data);
}, [ mutation ]);
}, [ mutation, setAlertVisible ]);
const renderTokenInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'token'>}) => {
return (
......@@ -96,7 +110,9 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose }) => {
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
/>
<FormLabel>Application name for API key (e.g Web3 project)</FormLabel>
<FormLabel>
{ getPlaceholderWithError('Application name for API key (e.g Web3 project)', errors.name?.message) }
</FormLabel>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
......
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { ApiKey } from 'types/api/account';
......@@ -14,10 +14,12 @@ type Props = {
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit API key' : 'New API key';
const text = 'Add an application name to identify your API key. Click the button below to auto-generate the associated key.';
const text = !data ? 'Add an application name to identify your API key. Click the button below to auto-generate the associated key.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <ApiKeyForm data={ data } onClose={ onClose }/>;
return <ApiKeyForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return (
<FormModal<ApiKey>
......@@ -25,8 +27,9 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
isAlertVisible={ isAlertVisible }
setAlertVisible={ setAlertVisible }
/>
);
};
......
import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { ApiKey, ApiKeys } from 'types/api/account';
import fetch from 'lib/client/fetch';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
......@@ -13,28 +14,17 @@ type Props = {
}
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient();
const deleteApiKey = () => {
const mutationFn = useCallback(() => {
return fetch(`/api/account/api-keys/${ data.api_key }`, { method: 'DELETE' });
};
}, [ data ]);
const mutation = useMutation(deleteApiKey, {
onSuccess: async() => {
const onSuccess = useCallback(async() => {
queryClient.setQueryData([ 'api-keys' ], (prevData: ApiKeys | undefined) => {
return prevData?.filter((item) => item.api_key !== data.api_key);
});
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
});
const onDelete = useCallback(() => {
mutation.mutate(data);
}, [ data, mutation ]);
}, [ data, queryClient ]);
const renderText = useCallback(() => {
return (
......@@ -46,10 +36,10 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
onDelete={ onDelete }
title="Remove API key"
renderContent={ renderText }
pending={ mutation.isLoading }
mutationFn={ mutationFn }
onSuccess={ onSuccess }
/>
);
};
......
......@@ -12,14 +12,19 @@ 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 type { CustomAbi, CustomAbis, CustomAbiErrors } from 'types/api/account';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
type Props = {
data?: CustomAbi;
onClose: () => void;
setAlertVisible: (isAlertVisible: boolean) => void;
}
type Inputs = {
......@@ -30,8 +35,8 @@ type Inputs = {
const NAME_MAX_LENGTH = 255;
const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
const { control, formState: { errors }, handleSubmit } = useForm<Inputs>({
const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, formState: { errors }, handleSubmit, setError } = useForm<Inputs>({
defaultValues: {
contract_address_hash: data?.contract_address_hash || '',
name: data?.name || '',
......@@ -46,18 +51,17 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
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<CustomAbi, CustomAbiErrors>('/api/account/custom-abis', { method: 'POST', body });
}
return fetch(`/api/account/custom-abis/${ data.id }`, { method: 'PUT', body });
return fetch<CustomAbi, CustomAbiErrors>(`/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();
onSuccess: (data) => {
const response = data as unknown as CustomAbi;
queryClient.setQueryData([ 'custom-abis' ], (prevData: CustomAbis | undefined) => {
const isExisting = prevData && prevData.some((item) => item.id === response.id);
......@@ -76,19 +80,29 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
onClose();
},
// eslint-disable-next-line no-console
onError: console.error,
onError: (e: ErrorType<CustomAbiErrors>) => {
if (e?.error?.address_hash || e?.error?.name || e?.error?.abi) {
e?.error?.address_hash && setError('contract_address_hash', { type: 'custom', message: getErrorMessage(e.error, 'address_hash') });
e?.error?.name && setError('name', { type: 'custom', message: getErrorMessage(e.error, 'name') });
e?.error?.abi && setError('abi', { type: 'custom', message: getErrorMessage(e.error, 'abi') });
} else if (e?.error?.identity_id) {
setError('contract_address_hash', { type: 'custom', message: getErrorMessage(e.error, 'identity_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((formData) => {
setAlertVisible(false);
mutation.mutate({ ...formData, id: data?.id });
}, [ mutation, data ]);
}, [ mutation, data, setAlertVisible ]);
const renderContractAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'contract_address_hash'>}) => {
return (
<AddressInput<Inputs, 'contract_address_hash'>
field={ field }
isInvalid={ Boolean(errors.contract_address_hash) }
error={ errors.contract_address_hash?.message }
backgroundColor={ formBackgroundColor }
placeholder="Smart contract address (0x...)"
/>
......@@ -103,7 +117,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
/>
<FormLabel>Project name</FormLabel>
<FormLabel>{ getPlaceholderWithError('Project name', errors.name?.message) }</FormLabel>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
......@@ -116,7 +130,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose }) => {
size="lg"
isInvalid={ Boolean(errors.abi) }
/>
<FormLabel>{ `Custom ABI [{...}] (JSON format)` }</FormLabel>
<FormLabel>{ getPlaceholderWithError(`Custom ABI [{...}] (JSON format)`, errors.abi?.message) }</FormLabel>
</FormControl>
);
}, [ errors, formBackgroundColor ]);
......
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { CustomAbi } from 'types/api/account';
......@@ -14,10 +14,12 @@ type Props = {
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 text = !data ? 'Double check the ABI matches the contract to prevent errors or incorrect results.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <CustomAbiForm data={ data } onClose={ onClose }/>;
return <CustomAbiForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return (
<FormModal<CustomAbi>
......@@ -25,8 +27,9 @@ const CustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
isAlertVisible={ isAlertVisible }
setAlertVisible={ setAlertVisible }
/>
);
};
......
import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account';
......@@ -16,25 +16,15 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient();
const deleteApiKey = () => {
const mutationFn = useCallback(() => {
return fetch(`/api/account/custom-abis/${ data.id }`, { method: 'DELETE' });
};
}, [ data ]);
const mutation = useMutation(deleteApiKey, {
onSuccess: async() => {
const onSuccess = useCallback(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 ]);
}, [ data, queryClient ]);
const renderText = useCallback(() => {
return (
......@@ -46,10 +36,10 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
onDelete={ onDelete }
title="Remove custom ABI"
renderContent={ renderText }
pending={ mutation.isLoading }
mutationFn={ mutationFn }
onSuccess={ onSuccess }
/>
);
};
......
......@@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react';
import type { ApiKey, ApiKeys } from 'types/api/account';
import fetch from 'lib/client/fetch';
import { space } from 'lib/html-entities';
import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal';
import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
......@@ -12,6 +13,8 @@ import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert';
const DATA_LIMIT = 3;
const ApiKeysPage: React.FC = () => {
......@@ -21,13 +24,7 @@ const ApiKeysPage: React.FC = () => {
const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>();
const { data, isLoading, isError } = useQuery<unknown, unknown, ApiKeys>([ 'api-keys' ], async() => {
const response = await fetch('/api/account/api-keys');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
const { data, isLoading, isError } = useQuery<unknown, unknown, ApiKeys>([ 'api-keys' ], async() => await fetch('/api/account/api-keys'));
const onEditClick = useCallback((data: ApiKey) => {
setApiKeyModalData(data);
......@@ -49,19 +46,32 @@ const ApiKeysPage: React.FC = () => {
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const description = (
<Text marginBottom={ 12 }>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="#">“How to use a Blockscout API key”</Link>.
</Text>
);
const content = (() => {
if (isLoading || isError) {
if (isLoading && !data) {
return (
<>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="48px" width="156px" marginTop={ 8 }/>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
const canAdd = data.length < DATA_LIMIT;
return (
<>
{ description }
{ Boolean(data.length) && (
<ApiKeyTable
data={ data }
......@@ -85,6 +95,8 @@ const ApiKeysPage: React.FC = () => {
</Text>
) }
</HStack>
<ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/>
{ deleteModalData && <DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</>
);
})();
......@@ -93,14 +105,8 @@ const ApiKeysPage: React.FC = () => {
<Page>
<Box h="100%">
<AccountPageHeader text="API keys"/>
<Text marginBottom={ 12 }>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="#">“How to use a Blockscout API key”</Link>.
</Text>
{ content }
</Box>
<ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/>
{ deleteModalData && <DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</Page>
);
};
......
......@@ -4,6 +4,7 @@ import React, { useCallback, useState } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account';
import fetch from 'lib/client/fetch';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
......@@ -11,6 +12,8 @@ import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert';
const CustomAbiPage: React.FC = () => {
const customAbiModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -18,13 +21,7 @@ const CustomAbiPage: React.FC = () => {
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 { data, isLoading, isError } = useQuery<unknown, unknown, CustomAbis>([ 'custom-abis' ], async() => await fetch('/api/account/custom-abis'));
const onEditClick = useCallback((data: CustomAbi) => {
setCustomAbiModalData(data);
......@@ -46,18 +43,30 @@ const CustomAbiPage: React.FC = () => {
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const description = (
<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>
);
const content = (() => {
if (isLoading || isError) {
if (isLoading && !data) {
return (
<>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ data.length > 0 && (
<CustomAbiTable
data={ data }
......@@ -74,6 +83,8 @@ const CustomAbiPage: React.FC = () => {
Add custom ABI
</Button>
</HStack>
<CustomAbiModal { ...customAbiModalProps } onClose={ onCustomAbiModalClose } data={ customAbiModalData }/>
{ deleteModalData && <DeleteCustomAbiModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</>
);
})();
......@@ -82,13 +93,8 @@ const CustomAbiPage: React.FC = () => {
<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>
);
};
......
......@@ -6,11 +6,8 @@ import {
TabPanel,
TabPanels,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { AddressTags, TransactionTags } from 'types/api/account';
import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags';
import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
......@@ -21,10 +18,6 @@ type Props = {
}
const PrivateTags = ({ onChangeTab: onChangeTabProps }: Props) => {
const queryClient = useQueryClient();
const addressData = queryClient.getQueryData([ 'address' ]) as AddressTags;
const txData = queryClient.getQueryData([ 'transaction' ]) as TransactionTags;
const onTabChange = useCallback((index: number) => {
onChangeTabProps(index);
}, [ onChangeTabProps ]);
......@@ -40,10 +33,10 @@ const PrivateTags = ({ onChangeTab: onChangeTabProps }: Props) => {
</TabList>
<TabPanels>
<TabPanel padding={ 0 }>
<PrivateAddressTags addressTags={ addressData }/>
<PrivateAddressTags/>
</TabPanel>
<TabPanel padding={ 0 }>
<PrivateTransactionTags transactionTags={ txData }/>
<PrivateTransactionTags/>
</TabPanel>
</TabPanels>
</Tabs>
......
import {
Box,
} from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll';
......
import { Box, Button, Text, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import fetch from 'lib/client/fetch';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from 'ui/watchlist/AddressModal/AddressModal';
......@@ -12,8 +14,8 @@ import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => {
const queryClient = useQueryClient();
const watchlistData = queryClient.getQueryData([ 'watchlist' ]) as TWatchlist;
const { data, isLoading, isError } =
useQuery<unknown, unknown, TWatchlist>([ 'watchlist' ], async() => fetch('/api/account/watchlist/get-with-tokens'));
const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -41,25 +43,33 @@ const WatchList: React.FC = () => {
deleteModalProps.onClose();
}, [ deleteModalProps ]);
return (
<Page>
<Box h="100%">
<AccountPageHeader text="Watch list"/>
<Text marginBottom={ 12 }>An email notification can be sent to you when an address on your watch list sends or receives any transactions.</Text>
{ !watchlistData && (
const description = (
<Text marginBottom={ 12 }>
An email notification can be sent to you when an address on your watch list sends or receives any transactions.
</Text>
);
let content;
if (isLoading && !data) {
content = (
<>
{ description }
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
) }
{ Boolean(watchlistData?.length) && (
);
} else if (isError) {
content = <DataFetchAlert/>;
} else {
content = (
<>
{ Boolean(data?.length) && (
<WatchlistTable
data={ watchlistData }
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
{ Boolean(watchlistData) && (
<Box marginTop={ 8 }>
<Button
variant="primary"
......@@ -69,10 +79,18 @@ const WatchList: React.FC = () => {
Add address
</Button>
</Box>
) }
</Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/>
<DeleteAddressModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/>
{ deleteModalData && <DeleteAddressModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</>
);
}
return (
<Page>
<Box h="100%">
<AccountPageHeader text="Watch list"/>
{ content }
</Box>
</Page>
);
};
......
......@@ -8,8 +8,11 @@ import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { AddressTag } from 'types/api/account';
import type { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import TagInput from 'ui/shared/TagInput';
......@@ -19,6 +22,7 @@ const TAG_MAX_LENGTH = 35;
type Props = {
data?: AddressTag;
onClose: () => void;
setAlertVisible: (isAlertVisible: boolean) => void;
}
type Inputs = {
......@@ -26,9 +30,9 @@ type Inputs = {
tag: string;
}
const AddressForm: React.FC<Props> = ({ data, onClose }) => {
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
const { control, handleSubmit, formState: { errors }, setError } = useForm<Inputs>({
mode: 'all',
defaultValues: {
address: data?.address_hash || '',
......@@ -53,12 +57,19 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
return fetch('/api/account/private-tags/address', { method: 'POST', body });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
onError: (e: ErrorType<AddressTagErrors>) => {
setPending(false);
if (e?.error?.address_hash || e?.error?.name) {
e?.error?.address_hash && setError('address', { type: 'custom', message: getErrorMessage(e.error, 'address_hash') });
e?.error?.name && setError('tag', { type: 'custom', message: getErrorMessage(e.error, 'name') });
} else if (e?.error?.identity_id) {
setError('address', { type: 'custom', message: getErrorMessage(e.error, 'identity_id') });
} else {
setAlertVisible(true);
}
},
onSuccess: () => {
queryClient.refetchQueries([ 'address' ]).then(() => {
queryClient.refetchQueries([ 'address-tags' ]).then(() => {
onClose();
setPending(false);
});
......@@ -66,16 +77,17 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
});
const onSubmit: SubmitHandler<Inputs> = (formData) => {
setAlertVisible(false);
setPending(true);
mutate(formData);
};
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) } backgroundColor={ formBackgroundColor }/>;
return <AddressInput<Inputs, 'address'> field={ field } error={ errors.address?.message } backgroundColor={ formBackgroundColor }/>;
}, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag?.message } backgroundColor={ formBackgroundColor }/>;
}, [ errors, formBackgroundColor ]);
return (
......
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { AddressTag } from 'types/api/account';
......@@ -14,10 +14,12 @@ type Props = {
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit address tag' : 'New address tag';
const text = 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.';
const text = !data ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose }/>;
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return (
<FormModal<AddressTag>
......@@ -25,8 +27,9 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
isAlertVisible={ isAlertVisible }
setAlertVisible={ setAlertVisible }
/>
);
};
......
import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { AddressTag, TransactionTag } from 'types/api/account';
import type { AddressTag, TransactionTag, AddressTags, TransactionTags } from 'types/api/account';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
data?: AddressTag | TransactionTag;
data: AddressTag | TransactionTag;
type: 'address' | 'transaction';
}
const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type }) => {
const [ pending, setPending ] = useState(false);
const tag = data?.name;
const id = data?.id;
const tag = data.name;
const id = data.id;
const queryClient = useQueryClient();
const { mutate } = useMutation(() => {
const mutationFn = useCallback(() => {
return fetch(`/api/account/private-tags/${ type }/${ id }`, { method: 'DELETE' });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
},
onSuccess: () => {
queryClient.refetchQueries([ type ]).then(() => {
onClose();
setPending(false);
}, [ type, id ]);
const onSuccess = useCallback(async() => {
if (type === 'address') {
queryClient.setQueryData([ type ], (prevData: AddressTags | undefined) => {
return prevData?.filter((item: AddressTag) => item.id !== id);
});
},
} else {
queryClient.setQueryData([ type ], (prevData: TransactionTags | undefined) => {
return prevData?.filter((item: TransactionTag) => item.id !== id);
});
const onDelete = useCallback(() => {
setPending(true);
mutate();
}, [ mutate ]);
}
}, [ type, id, queryClient ]);
const renderText = useCallback(() => {
return (
......@@ -51,10 +45,10 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type })
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
onDelete={ onDelete }
title="Removal of private tag"
renderContent={ renderText }
pending={ pending }
mutationFn={ mutationFn }
onSuccess={ onSuccess }
/>
);
};
......
import { Box, Button, Text, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { AddressTags, AddressTag } from 'types/api/account';
import fetch from 'lib/client/fetch';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from './AddressModal/AddressModal';
import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
type Props = {
addressTags?: AddressTags;
}
const PrivateAddressTags = () => {
const { data: addressTagsData, isLoading, isError } =
useQuery<unknown, unknown, AddressTags>([ 'address-tags' ], async() => fetch('/api/account/private-tags/address'), { refetchOnMount: false });
const PrivateAddressTags = ({ addressTags }: Props) => {
const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -47,7 +49,7 @@ const PrivateAddressTags = ({ addressTags }: Props) => {
</Text>
);
if (!addressTags) {
if (isLoading && !addressTagsData) {
return (
<>
{ description }
......@@ -57,12 +59,16 @@ const PrivateAddressTags = ({ addressTags }: Props) => {
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ Boolean(addressTags?.length) && (
{ Boolean(addressTagsData?.length) && (
<AddressTagTable
data={ addressTags }
data={ addressTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
......@@ -77,12 +83,14 @@ const PrivateAddressTags = ({ addressTags }: Props) => {
</Button>
</Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/>
{ deleteModalData && (
<DeletePrivateTagModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
data={ deleteModalData }
type="address"
/>
) }
</>
);
};
......
import { Box, Button, Skeleton, Text, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TransactionTags, TransactionTag } from 'types/api/account';
import fetch from 'lib/client/fetch';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
import TransactionModal from './TransactionModal/TransactionModal';
import TransactionTagTable from './TransactionTagTable/TransactionTagTable';
type Props = {
transactionTags?: TransactionTags;
}
const PrivateTransactionTags = () => {
const { data: transactionTagsData, isLoading, isError } =
useQuery<unknown, unknown, TransactionTags>([ 'transaction-tags' ], async() => fetch('/api/account/private-tags/transaction'), { refetchOnMount: false });
const PrivateTransactionTags = ({ transactionTags }: Props) => {
const transactionModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -47,7 +49,7 @@ const PrivateTransactionTags = ({ transactionTags }: Props) => {
</Text>
);
if (!transactionTags) {
if (isLoading && !transactionTagsData) {
return (
<>
{ description }
......@@ -57,12 +59,16 @@ const PrivateTransactionTags = ({ transactionTags }: Props) => {
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ Boolean(transactionTags.length) && (
{ Boolean(transactionTagsData.length) && (
<TransactionTagTable
data={ transactionTags }
data={ transactionTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
......@@ -77,12 +83,14 @@ const PrivateTransactionTags = ({ transactionTags }: Props) => {
</Button>
</Box>
<TransactionModal { ...transactionModalProps } onClose={ onAddressModalClose } data={ transactionModalData }/>
{ deleteModalData && (
<DeletePrivateTagModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
data={ deleteModalData }
type="transaction"
/>
) }
</>
);
};
......
......@@ -8,9 +8,12 @@ import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { TransactionTag } from 'types/api/account';
import type { TransactionTag, TransactionTagErrors } from 'types/api/account';
import { TRANSACTION_HASH_LENGTH, TRANSACTION_HASH_REGEXP } from 'lib/validations/transaction';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import { TRANSACTION_HASH_REGEXP } from 'lib/validations/transaction';
import TagInput from 'ui/shared/TagInput';
import TransactionInput from 'ui/shared/TransactionInput';
......@@ -19,6 +22,7 @@ const TAG_MAX_LENGTH = 35;
type Props = {
data?: TransactionTag;
onClose: () => void;
setAlertVisible: (isAlertVisible: boolean) => void;
}
type Inputs = {
......@@ -26,11 +30,11 @@ type Inputs = {
tag: string;
}
const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
const TransactionForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const [ pending, setPending ] = useState(false);
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
const { control, handleSubmit, formState: { errors }, setError } = useForm<Inputs>({
mode: 'all',
defaultValues: {
transaction: data?.transaction_hash || '',
......@@ -53,12 +57,19 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
return fetch('/api/account/private-tags/transaction', { method: 'POST', body });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
onError: (e: ErrorType<TransactionTagErrors>) => {
setPending(false);
if (e?.error?.tx_hash || e?.error?.name) {
e?.error?.tx_hash && setError('transaction', { type: 'custom', message: getErrorMessage(e.error, 'tx_hash') });
e?.error?.name && setError('tag', { type: 'custom', message: getErrorMessage(e.error, 'name') });
} else if (e?.error?.identity_id) {
setError('transaction', { type: 'custom', message: getErrorMessage(e.error, 'identity_id') });
} else {
setAlertVisible(true);
}
},
onSuccess: () => {
queryClient.refetchQueries([ 'transaction' ]).then(() => {
queryClient.refetchQueries([ 'transaction-tags' ]).then(() => {
onClose();
setPending(false);
});
......@@ -67,16 +78,15 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
const onSubmit: SubmitHandler<Inputs> = formData => {
setPending(true);
// api method for editing is not implemented now!!!
mutate(formData);
};
const renderTransactionInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'transaction'>}) => {
return <TransactionInput field={ field } isInvalid={ Boolean(errors.transaction) } backgroundColor={ formBackgroundColor }/>;
return <TransactionInput field={ field } error={ errors.transaction?.message } backgroundColor={ formBackgroundColor }/>;
}, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag?.message } backgroundColor={ formBackgroundColor }/>;
}, [ errors, formBackgroundColor ]);
return (
......@@ -86,8 +96,6 @@ const TransactionForm: React.FC<Props> = ({ data, onClose }) => {
name="transaction"
control={ control }
rules={{
maxLength: TRANSACTION_HASH_LENGTH,
minLength: TRANSACTION_HASH_LENGTH,
pattern: TRANSACTION_HASH_REGEXP,
}}
render={ renderTransactionInput }
......
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { TransactionTag } from 'types/api/account';
......@@ -14,10 +14,12 @@ type Props = {
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit transaction tag' : 'New transaction tag';
const text = 'Label any transaction with a private transaction tag (up to 35 chars) to customize your explorer experience.';
const text = !data ? 'Label any transaction with a private transaction tag (up to 35 chars) to customize your explorer experience.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <TransactionForm data={ data } onClose={ onClose }/>;
return <TransactionForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return (
<FormModal<TransactionTag>
......@@ -25,8 +27,9 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
isAlertVisible={ isAlertVisible }
setAlertVisible={ setAlertVisible }
/>
);
};
......
......@@ -4,6 +4,8 @@ import React, { useCallback, useState } from 'react';
import type { PublicTags, PublicTag } from 'types/api/account';
import fetch from 'lib/client/fetch';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePublicTagModal from './DeletePublicTagModal';
......@@ -18,13 +20,7 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const deleteModalProps = useDisclosure();
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const { data, isLoading, isError } = useQuery<unknown, unknown, PublicTags>([ 'public-tags' ], async() => {
const response = await fetch('/api/account/public-tags');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
});
const { data, isLoading, isError } = useQuery<unknown, unknown, PublicTags>([ 'public-tags' ], async() => await fetch('/api/account/public-tags'));
const onDeleteModalClose = useCallback(() => {
setDeleteModalData(undefined);
......@@ -44,18 +40,32 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
deleteModalProps.onOpen();
}, [ deleteModalProps ]);
const content = (() => {
if (isLoading || isError) {
const description = (
<Text marginBottom={ 12 }>
You can request a public category tag which is displayed to all Blockscout users.
Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag.
Clicking a tag opens a page with related information and helps provide context and data organization.
Requests are sent to a moderator for review and approval. This process can take several days.
</Text>
);
if (isLoading) {
return (
<>
{ description }
<SkeletonTable columns={ [ '50%', '25%', '25%', '108px' ] }/>
<Skeleton height="48px" width="270px" marginTop={ 8 }/>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ data.length > 0 && <PublicTagTable data={ data } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/> }
<Box marginTop={ 8 }>
<Button
......@@ -66,19 +76,6 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
Request to add public tag
</Button>
</Box>
</>
);
})();
return (
<>
<Text marginBottom={ 12 }>
You can request a public category tag which is displayed to all Blockscout users.
Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag.
Clicking a tag opens a page with related information and helps provide context and data organization.
Requests are sent to a moderator for review and approval. This process can take several days.
</Text>
{ content }
{ deleteModalData && (
<DeletePublicTagModal
{ ...deleteModalProps }
......
......@@ -14,24 +14,24 @@ interface Props {
control: Control<Inputs>;
index: number;
fieldsLength: number;
hasError: boolean;
error?: string;
onAddFieldClick: (e: React.SyntheticEvent) => void;
onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void;
}
const MAX_INPUTS_NUM = 10;
export default function PublicTagFormAction({ control, index, fieldsLength, hasError, onAddFieldClick, onRemoveFieldClick }: Props) {
export default function PublicTagFormAction({ control, index, fieldsLength, error, onAddFieldClick, onRemoveFieldClick }: Props) {
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, `addresses.${ number }.address`>}) => {
return (
<AddressInput<Inputs, `addresses.${ number }.address`>
field={ field }
isInvalid={ hasError }
error={ error }
size="lg"
placeholder="Smart contract / Address (0x...)"
/>
);
}, [ hasError ]);
}, [ error ]);
return (
<>
......
......@@ -3,26 +3,32 @@ import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import type { Inputs } from './PublicTagsForm';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props {
control: Control<Inputs>;
error?: string;
}
export default function PublicTagFormComment({ control }: Props) {
export default function PublicTagFormComment({ control, error }: Props) {
const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => {
return (
<FormControl variant="floating" id={ field.name } size="lg">
<FormControl variant="floating" id={ field.name } size="lg" isRequired>
<Textarea
{ ...field }
isInvalid={ Boolean(error) }
size="lg"
/>
<FormLabel>Specify the reason for adding tags and color preference(s).</FormLabel>
<FormLabel>
{ getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error) }
</FormLabel>
</FormControl>
);
}, []);
}, [ error ]);
return (
<Controller
......
......@@ -7,13 +7,17 @@ import {
HStack,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { Path, SubmitHandler } from 'react-hook-form';
import { useForm, useFieldArray } from 'react-hook-form';
import type { PublicTags, PublicTag, PublicTagNew } from 'types/api/account';
import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types/api/account';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import { EMAIL_REGEXP } from 'lib/validations/email';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
import PublicTagFormAction from './PublicTagFormAction';
import PublicTagFormAddressInput from './PublicTagFormAddressInput';
......@@ -53,7 +57,7 @@ const ADDRESS_INPUT_BUTTONS_WIDTH = 170;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient();
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
const { control, handleSubmit, formState: { errors }, setError } = useForm<Inputs>({
defaultValues: {
fullName: data?.full_name || '',
email: data?.email || '',
......@@ -73,6 +77,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control,
});
const [ isAlertVisible, setAlertVisible ] = useState(false);
const onAddFieldClick = useCallback(() => append({ address: '' }), [ append ]);
const onRemoveFieldClick = useCallback((index: number) => () => remove(index), [ remove ]);
......@@ -91,15 +97,15 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const body = JSON.stringify(payload);
if (!data?.id) {
return fetch('/api/account/public-tags', { method: 'POST', body });
return fetch<PublicTag, PublicTagErrors>('/api/account/public-tags', { method: 'POST', body });
}
return fetch(`/api/account/public-tags/${ data.id }`, { method: 'PUT', body });
return fetch<PublicTag, PublicTagErrors>(`/api/account/public-tags/${ data.id }`, { method: 'PUT', body });
};
const mutation = useMutation(updatePublicTag, {
onSuccess: async(data) => {
const response: PublicTag = await data.json();
const response = data as unknown as PublicTag;
queryClient.setQueryData([ 'public-tags' ], (prevData: PublicTags | undefined) => {
const isExisting = prevData && prevData.some((item) => item.id === response.id);
......@@ -119,20 +125,32 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
changeToDataScreen(true);
},
// eslint-disable-next-line no-console
onError: console.error,
onError: (e: ErrorType<PublicTagErrors>) => {
if (e.error?.full_name || e.error?.email || e.error?.tags || e.error?.addresses || e.error?.additional_comment) {
e.error?.full_name && setError('fullName', { type: 'custom', message: getErrorMessage(e.error, 'full_name') });
e.error?.email && setError('email', { type: 'custom', message: getErrorMessage(e.error, 'email') });
e.error?.tags && setError('tags', { type: 'custom', message: getErrorMessage(e.error, 'tags') });
e.error?.addresses && setError('addresses.0', { type: 'custom', message: getErrorMessage(e.error, 'addresses') });
e.error?.additional_comment && setError('comment', { type: 'custom', message: getErrorMessage(e.error, 'additional_comment') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
setAlertVisible(false);
mutation.mutate(data);
}, [ mutation ]);
const changeToData = useCallback(() => {
setAlertVisible(false);
changeToDataScreen(false);
}, [ changeToDataScreen ]);
return (
<Box width={ `calc(100% - ${ ADDRESS_INPUT_BUTTONS_WIDTH }px)` } maxWidth="844px">
{ isAlertVisible && <FormSubmitAlert/> }
<Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text>
<Grid templateColumns="1fr 1fr" rowGap={ 4 } columnGap={ 5 }>
<GridItem>
......@@ -140,6 +158,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldName="fullName"
control={ control }
label={ placeholders.fullName }
error={ errors.fullName?.message }
required
/>
</GridItem>
......@@ -148,6 +167,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldName="companyName"
control={ control }
label={ placeholders.companyName }
error={ errors.companyName?.message }
/>
</GridItem>
<GridItem>
......@@ -156,7 +176,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control }
label={ placeholders.email }
pattern={ EMAIL_REGEXP }
hasError={ Boolean(errors.email) }
error={ errors.email?.message }
required
/>
</GridItem>
......@@ -165,6 +185,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldName="companyUrl"
control={ control }
label={ placeholders.companyUrl }
error={ errors?.companyUrl?.message }
/>
</GridItem>
</Grid>
......@@ -177,7 +198,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldName="tags"
control={ control }
label={ placeholders.tags }
hasError={ Boolean(errors.tags) }
error={ errors.tags?.message }
required/>
</Box>
{ fields.map((field, index) => {
......@@ -185,7 +206,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
<Box position="relative" key={ field.id } marginBottom={ 4 }>
<PublicTagFormAddressInput
control={ control }
hasError={ Boolean(errors?.addresses?.[index]) }
error={ errors?.addresses?.[index]?.message }
index={ index }
fieldsLength={ fields.length }
onAddFieldClick={ onAddFieldClick }
......@@ -195,14 +216,15 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
);
}) }
<Box marginBottom={ 8 }>
<PublicTagFormComment control={ control }/>
<PublicTagFormComment control={ control } error={ errors.comment?.message }/>
</Box>
<HStack spacing={ 6 }>
<Button
size="lg"
variant="primary"
onClick={ handleSubmit(onSubmit) }
disabled={ Object.keys(errors).length > 0 || mutation.isLoading }
disabled={ Object.keys(errors).length > 0 }
isLoading={ mutation.isLoading }
>
Send request
</Button>
......@@ -210,6 +232,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
size="lg"
variant="secondary"
onClick={ changeToData }
disabled={ mutation.isLoading }
>
Cancel
</Button>
......
......@@ -3,6 +3,8 @@ import React, { useCallback } from 'react';
import type { ControllerRenderProps, FieldValues, Path, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
const TEXT_INPUT_MAX_LENGTH = 255;
interface Props<TInputs extends FieldValues> {
......@@ -11,7 +13,7 @@ interface Props<TInputs extends FieldValues> {
required?: boolean;
control: Control<TInputs, object>;
pattern?: RegExp;
hasError?: boolean;
error?: string;
}
export default function PublicTagsFormInput<Inputs extends FieldValues>({
......@@ -20,7 +22,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
required,
fieldName,
pattern,
hasError,
error,
}: Props<Inputs>) {
const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => {
return (
......@@ -29,13 +31,13 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
{ ...field }
size="lg"
required={ required }
isInvalid={ hasError }
isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH }
/>
<FormLabel>{ label }</FormLabel>
<FormLabel>{ getPlaceholderWithError(label, error) }</FormLabel>
</FormControl>
);
}, [ label, required, hasError ]);
}, [ label, required, error ]);
return (
<Controller
name={ fieldName }
......
......@@ -6,20 +6,21 @@ import {
import React from 'react';
import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import { ADDRESS_LENGTH } from 'lib/validations/address';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
isInvalid: boolean;
size?: string;
placeholder?: string;
backgroundColor?: string;
error?: string;
}
export default function AddressInput<Inputs extends FieldValues, Name extends Path<Inputs>>(
{
error,
field,
isInvalid,
size,
placeholder = 'Address (0x...)',
backgroundColor,
......@@ -28,11 +29,11 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa
<FormControl variant="floating" id="address" isRequired backgroundColor={ backgroundColor } size={ size }>
<Input
{ ...field }
isInvalid={ isInvalid }
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
size={ size }
/>
<FormLabel>{ placeholder }</FormLabel>
<FormLabel>{ getPlaceholderWithError(placeholder, error) }</FormLabel>
</FormControl>
);
}
import {
Checkbox,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
text: string;
}
export default function CheckboxInput<Inputs extends FieldValues, Name extends Path<Inputs>>(
{
field,
text,
}: Props<Inputs, Name>) {
return (
<Checkbox
isChecked={ field.value }
onChange={ field.onChange }
ref={ field.ref }
colorScheme="blue"
size="lg"
>
{ text }
</Checkbox>
);
}
import { Alert, AlertDescription } from '@chakra-ui/react';
import React from 'react';
const DataFetchAlert = () => {
return (
<Alert status="warning" as="span">
<AlertDescription>
Something went wrong. Try refreshing the page or come back later.
</AlertDescription>
</Alert>
);
};
export default DataFetchAlert;
import {
Box,
Button,
Modal,
ModalOverlay,
......@@ -8,30 +9,58 @@ import {
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
type Props = {
isOpen: boolean;
onClose: () => void;
onDelete: () => void;
title: string;
renderContent: () => JSX.Element;
pending?: boolean;
mutationFn: () => Promise<unknown>;
onSuccess: () => Promise<void>;
}
const DeleteModal: React.FC<Props> = ({ isOpen, onClose, onDelete, title, renderContent, pending }) => {
const DeleteModal: React.FC<Props> = ({
isOpen,
onClose,
title,
renderContent,
mutationFn,
onSuccess,
}) => {
const [ isAlertVisible, setAlertVisible ] = useState(false);
const onModalClose = useCallback(() => {
setAlertVisible(false);
onClose();
}, [ onClose, setAlertVisible ]);
const mutation = useMutation(mutationFn, {
onSuccess: async() => {
onSuccess();
onClose();
},
onError: () => {
setAlertVisible(true);
},
});
const onDeleteClick = useCallback(() => {
onDelete();
}, [ onDelete ]);
setAlertVisible(false);
mutation.mutate();
}, [ setAlertVisible, mutation ]);
return (
<Modal isOpen={ isOpen } onClose={ onClose } size="md">
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md">
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader>
<ModalCloseButton/>
<ModalBody>
{ isAlertVisible && <Box mb={ 4 }><FormSubmitAlert/></Box> }
{ renderContent() }
</ModalBody>
<ModalFooter>
......@@ -39,7 +68,7 @@ const DeleteModal: React.FC<Props> = ({ isOpen, onClose, onDelete, title, render
variant="primary"
size="lg"
onClick={ onDeleteClick }
isLoading={ pending }
isLoading={ mutation.isLoading }
// FIXME: chackra's button is disabled when isLoading
disabled={ false }
>
......
import {
Box,
Modal,
ModalOverlay,
ModalContent,
......@@ -7,7 +8,9 @@ import {
ModalCloseButton,
Text,
} from '@chakra-ui/react';
import React from 'react';
import React, { useCallback } from 'react';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
interface Props<TData> {
isOpen: boolean;
......@@ -16,21 +19,42 @@ interface Props<TData> {
title: string;
text: string;
renderForm: () => JSX.Element;
isAlertVisible: boolean;
setAlertVisible: (isAlertVisible: boolean) => void;
}
export default function FormModal<TData>({ isOpen, onClose, data, title, text, renderForm }: Props<TData>) {
export default function FormModal<TData>({
isOpen,
onClose,
title,
text,
renderForm,
isAlertVisible,
setAlertVisible,
}: Props<TData>) {
const onModalClose = useCallback(() => {
setAlertVisible(false);
onClose();
}, [ onClose, setAlertVisible ]);
return (
<Modal isOpen={ isOpen } onClose={ onClose } size="md">
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md" >
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
{ !data && (
<Text lineHeight="30px" marginBottom={ 12 }>
{ (isAlertVisible || text) && (
<Box marginBottom={ 12 }>
{ text && (
<Text lineHeight="30px" mb={ 3 }>
{ text }
</Text>
) }
{ isAlertVisible && <FormSubmitAlert/> }
</Box>
) }
{ renderForm() }
</ModalBody>
</ModalContent>
......
import { Alert, AlertDescription } from '@chakra-ui/react';
import React from 'react';
const FormSubmitAlert = () => {
return (
<Alert status="error">
<AlertDescription>
There has been an error processing your request
</AlertDescription>
</Alert>
);
};
export default FormSubmitAlert;
......@@ -4,25 +4,27 @@ import {
FormLabel,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldValues } from 'react-hook-form';
import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
const TAG_MAX_LENGTH = 35;
type Props<Field> = {
field: Field;
isInvalid: boolean;
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
error?: string;
backgroundColor?: string;
}
function TagInput<Field extends Partial<ControllerRenderProps<FieldValues, 'tag'>>>({ field, isInvalid, backgroundColor }: Props<Field>) {
function TagInput<Inputs extends FieldValues, Name extends Path<Inputs>>({ field, error, backgroundColor }: Props<Inputs, Name>) {
return (
<FormControl variant="floating" id="tag" isRequired backgroundColor={ backgroundColor }>
<Input
{ ...field }
isInvalid={ isInvalid }
isInvalid={ Boolean(error) }
maxLength={ TAG_MAX_LENGTH }
/>
<FormLabel>Private tag (max 35 characters)</FormLabel>
<FormLabel>{ getPlaceholderWithError(`Private tag (max 35 characters)`, error) }</FormLabel>
</FormControl>
);
}
......
......@@ -6,25 +6,26 @@ import {
import React from 'react';
import type { ControllerRenderProps, FieldValues } from 'react-hook-form';
const HASH_LENGTH = 66;
import getPlaceholderWithError from 'lib/getPlaceholderWithError';
import { TRANSACTION_HASH_LENGTH } from 'lib/validations/transaction';
type Props<Field> = {
field: Field;
isInvalid: boolean;
error?: string;
backgroundColor?: string;
}
function AddressInput<Field extends Partial<ControllerRenderProps<FieldValues, 'transaction'>>>({ field, isInvalid, backgroundColor }: Props<Field>) {
function TransactionInput<Field extends Partial<ControllerRenderProps<FieldValues, 'transaction'>>>({ field, error, backgroundColor }: Props<Field>) {
return (
<FormControl variant="floating" id="transaction" isRequired backgroundColor={ backgroundColor }>
<Input
{ ...field }
isInvalid={ isInvalid }
maxLength={ HASH_LENGTH }
isInvalid={ Boolean(error) }
maxLength={ TRANSACTION_HASH_LENGTH }
/>
<FormLabel>Transaction hash (0x...)</FormLabel>
<FormLabel>{ getPlaceholderWithError('Transaction hash (0x...)', error) }</FormLabel>
</FormControl>
);
}
export default AddressInput;
export default TransactionInput;
import {
Box,
Button,
Checkbox,
Text,
Grid,
GridItem,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
......@@ -12,21 +9,28 @@ import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { WatchlistErrors } from 'types/api/account';
import type { TWatchlistItem } from 'types/client/account';
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import CheckboxInput from 'ui/shared/CheckboxInput';
import TagInput from 'ui/shared/TagInput';
import AddressFormNotifications from './AddressFormNotifications';
// does it depend on the network?
const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const;
const NOTIFICATIONS_NAMES = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ];
const TAG_MAX_LENGTH = 35;
type Props = {
data?: TWatchlistItem;
onClose: () => void;
setAlertVisible: (isAlertVisible: boolean) => void;
}
type Inputs = {
......@@ -57,9 +61,7 @@ type Checkboxes = 'notification' |
'notification_settings.ERC-721.outcoming' |
'notification_settings.ERC-721.incoming';
// TODO: mb we need to create an abstract form here?
const AddressForm: React.FC<Props> = ({ data, onClose }) => {
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const [ pending, setPending ] = useState(false);
const formBackgroundColor = useColorModeValue('white', 'gray.900');
......@@ -70,7 +72,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
notificationsDefault = data.notification_settings;
}
const { control, handleSubmit, formState: { errors } } = useForm<Inputs>({
const { control, handleSubmit, formState: { errors }, setError } = useForm<Inputs>({
defaultValues: {
address: data?.address_hash || '',
tag: data?.name || '',
......@@ -82,7 +84,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => {
function updateWatchlist(formData: Inputs) {
const requestParams = {
name: formData?.tag,
address_hash: formData?.address,
......@@ -93,53 +95,62 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
};
if (data) {
// edit address
return fetch(`/api/account/watchlist/${ data.id }`, { method: 'PUT', body: JSON.stringify(requestParams) });
return fetch<TWatchlistItem, WatchlistErrors>(`/api/account/watchlist/${ data.id }`, { method: 'PUT', body: JSON.stringify(requestParams) });
} else {
// add address
return fetch('/api/account/watchlist', { method: 'POST', body: JSON.stringify(requestParams) });
return fetch<TWatchlistItem, WatchlistErrors>('/api/account/watchlist', { method: 'POST', body: JSON.stringify(requestParams) });
}
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
},
}
const { mutate } = useMutation(updateWatchlist, {
onSuccess: () => {
queryClient.refetchQueries([ 'watchlist' ]).then(() => {
onClose();
setPending(false);
});
},
onError: (e: ErrorType<WatchlistErrors>) => {
setPending(false);
if (e?.error?.address_hash || e?.error?.name) {
e?.error?.address_hash && setError('address', { type: 'custom', message: getErrorMessage(e.error, 'address_hash') });
e?.error?.name && setError('tag', { type: 'custom', message: getErrorMessage(e.error, 'name') });
} else if (e?.error?.watchlist_id) {
setError('address', { type: 'custom', message: getErrorMessage(e.error, 'watchlist_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = (formData) => {
setAlertVisible(false);
setPending(true);
mutate(formData);
};
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) } backgroundColor={ formBackgroundColor }/>;
return (
<AddressInput<Inputs, 'address'>
field={ field }
backgroundColor={ formBackgroundColor }
error={ errors.address?.message }
/>
);
}, [ errors, formBackgroundColor ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) } backgroundColor={ formBackgroundColor }/>;
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag?.message } backgroundColor={ formBackgroundColor }/>;
}, [ errors, formBackgroundColor ]);
// eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
<Checkbox
isChecked={ field.value }
onChange={ field.onChange }
ref={ field.ref }
colorScheme="blue"
size="lg"
>
{ text }
</Checkbox>
<CheckboxInput<Inputs, Checkboxes> text={ text } field={ field }/>
), []);
return (
<>
<Box marginBottom={ 5 }>
<Box marginBottom={ 5 } marginTop={ 5 }>
<Controller
name="address"
control={ control }
......@@ -163,33 +174,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose }) => {
Please select what types of notifications you will receive
</Text>
<Box marginBottom={ 8 }>
<Grid templateColumns="repeat(3, max-content)" gap="20px 24px">
{ NOTIFICATIONS.map((notification: string, index: number) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes;
return (
<React.Fragment key={ notification }>
<GridItem>{ NOTIFICATIONS_NAMES[index] }</GridItem>
<GridItem>
<Controller
name={ incomingFieldName }
control={ control }
render={ renderCheckbox('Incoming') }
/>
</GridItem>
<GridItem>
<Controller
name={ outgoingFieldName }
control={ control }
render={ renderCheckbox('Outgoing') }
/>
</GridItem>
</React.Fragment>
);
}) }
</Grid>
<AddressFormNotifications control={ control }/>
</Box>
<Text variant="secondary" fontSize="sm" marginBottom={ 5 }>Notification methods</Text>
<Controller
......
import { Grid, GridItem } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { Controller } from 'react-hook-form';
import type { Path, ControllerRenderProps, FieldValues, Control } from 'react-hook-form';
import CheckboxInput from 'ui/shared/CheckboxInput';
// does it depend on the network?
const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const;
const NOTIFICATIONS_NAMES = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ];
type Props<Inputs extends FieldValues> = {
control: Control<Inputs>;
}
export default function AddressFormNotifications<Inputs extends FieldValues, Checkboxes extends Path<Inputs>>({ control }: Props<Inputs>) {
// eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
<CheckboxInput<Inputs, Checkboxes> text={ text } field={ field }/>
), []);
return (
<Grid templateColumns="repeat(3, max-content)" gap="20px 24px">
{ NOTIFICATIONS.map((notification: string, index: number) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes;
return (
<React.Fragment key={ notification }>
<GridItem>{ NOTIFICATIONS_NAMES[index] }</GridItem>
<GridItem>
<Controller
name={ incomingFieldName }
control={ control }
render={ renderCheckbox('Incoming') }
/>
</GridItem>
<GridItem>
<Controller
name={ outgoingFieldName }
control={ control }
render={ renderCheckbox('Outgoing') }
/>
</GridItem>
</React.Fragment>
);
}) }
</Grid>
);
}
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account';
......@@ -14,19 +14,23 @@ type Props = {
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit watch list address' : 'New address to watch list';
const text = 'An email notification can be sent to you when an address on your watch list sends or receives any transactions.';
const text = !data ? 'An email notification can be sent to you when an address on your watch list sends or receives any transactions.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose }/>;
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return (
<FormModal<TWatchlistItem>
isOpen={ isOpen }
onClose={ onClose }
title={ title }
text={ text }
data={ data }
renderForm={ renderForm }
isAlertVisible={ isAlertVisible }
setAlertVisible={ setAlertVisible }
/>
);
};
......
import { Text } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { TWatchlistItem } from 'types/client/account';
import type { TWatchlistItem, TWatchlist } from 'types/client/account';
import fetch from 'lib/client/fetch';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
data?: TWatchlistItem;
data: TWatchlistItem;
}
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const [ pending, setPending ] = useState(false);
const queryClient = useQueryClient();
const { mutate } = useMutation(() => {
return fetch(`/api/account/watchlist/${ data?.id }`, { method: 'DELETE' });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
},
onSuccess: () => {
queryClient.refetchQueries([ 'watchlist' ]).then(() => {
onClose();
setPending(false);
});
},
});
const mutationFn = useCallback(() => {
return fetch(`/api/account1/watchlist/${ data?.id }`, { method: 'DELETE' });
}, [ data ]);
const onDelete = useCallback(() => {
setPending(true);
mutate();
}, [ mutate ]);
const onSuccess = useCallback(async() => {
queryClient.setQueryData([ 'watchlist' ], (prevData: TWatchlist | undefined) => {
return prevData?.filter((item) => item.id !== data.id);
});
}, [ data, queryClient ]);
const address = data?.address_hash;
const renderText = useCallback(() => {
const renderModalContent = useCallback(() => {
return (
<Text display="flex">Address <Text fontWeight="600" whiteSpace="pre"> { address || 'address' } </Text> will be deleted</Text>
);
......@@ -49,10 +38,10 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
<DeleteModal
isOpen={ isOpen }
onClose={ onClose }
onDelete={ onDelete }
title="Remove address from watch list"
renderContent={ renderText }
pending={ pending }
renderContent={ renderModalContent }
mutationFn={ mutationFn }
onSuccess={ onSuccess }
/>
);
};
......
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