Commit 36547c01 authored by isstuev's avatar isstuev

error handlers

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