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