Commit e830a617 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #86 from blockscout/watchlist-backend

watchlist + backend
parents 60a9e905 238330dd
import { useQuery } from '@tanstack/react-query';
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
......@@ -5,6 +6,14 @@ 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>
......
......@@ -6,6 +6,6 @@ const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/tags/address/${ req.query.id }`;
};
const addressDeleteHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
const addressEditHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default addressDeleteHandler;
export default addressEditHandler;
......@@ -6,6 +6,6 @@ const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/tags/transaction/${ req.query.id }`;
};
const transactionDeleteHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
const transactionEditHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default transactionDeleteHandler;
export default transactionEditHandler;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/watchlist/${ req.query.id }`;
};
const addressEditHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default addressEditHandler;
import type { NextApiRequest, NextApiResponse } from 'next';
import type { WatchlistAddresses } from 'types/api/account';
import type { Tokenlist } from 'types/api/tokenlist';
import type { TWatchlistItem } from 'types/client/account';
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;
const data = await Promise.all(watchlistData.map(async item => {
const tokens = await fetch(`?module=account&action=tokenlist&address=${ item.address_hash }`);
const tokensData = await tokens.json() as Tokenlist;
return ({ ...item, tokens_count: Array.isArray(tokensData.result) ? tokensData.result.length : 0 });
}));
res.status(200).json(data);
};
export default watchlistWithTokensHandler;
import type { WatchlistAddresses } from 'types/api/account';
import handler from 'lib/api/handler';
const watchlistHandler = handler<WatchlistAddresses>(() => '/account/v1/user/watchlist', [ 'GET', 'POST' ]);
export default watchlistHandler;
......@@ -23,9 +23,13 @@ export interface NotificationDirection {
}
export interface NotificationSettings {
_native?: NotificationDirection;
erc20?: NotificationDirection;
erc7211155?: NotificationDirection;
'native': NotificationDirection;
'ERC-20': NotificationDirection;
'ERC-721': NotificationDirection;
}
export interface NotificationMethods {
email: boolean;
}
export interface Transaction {
......@@ -51,12 +55,14 @@ export interface UserInfo {
}
export interface WatchlistAddress {
addressHash: string;
addressName: string;
addressBalance: number;
coinName: string;
exchangeRate?: number;
notificationSettings: NotificationSettings;
address_hash: string;
name: string;
address_balance: number;
coin_name: string;
exchange_rate: number;
notification_settings: NotificationSettings;
notification_methods: NotificationMethods;
id: string;
}
export interface WatchlistAddressNew {
......
export type Tokenlist = {
message: string;
result: Array<TokenlistItem> | string;
}
export type TokenlistItem = {
balance: number;
contractAddress: string;
decimals?: number;
id: number;
name: string;
symbol: string;
type: string;
}
// here will be types if some back-end models are needed to be extended
// in order to fit the client's needs
export {};
import type { WatchlistAddress } from '../api/account';
export type TWatchlistItem = WatchlistAddress & {tokens_count: number};
export type TWatchlist = Array<TWatchlistItem>;
import { Box, Button, Text, useDisclosure } from '@chakra-ui/react';
import { Box, Button, Text, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'data/watchlist';
import { watchlist } from 'data/watchlist';
import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from 'ui/watchlist/AddressModal/AddressModal';
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 addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const [ addressModalData, setAddressModalData ] = useState<TWatchlistItem>();
const [ deleteModalData, setDeleteModalData ] = useState<string>();
const [ deleteModalData, setDeleteModalData ] = useState<TWatchlistItem>();
const onEditClick = useCallback((data: TWatchlistItem) => {
setAddressModalData(data);
......@@ -27,7 +32,7 @@ const WatchList: React.FC = () => {
}, [ addressModalProps ]);
const onDeleteClick = useCallback((data: TWatchlistItem) => {
setDeleteModalData(data.address);
setDeleteModalData(data);
deleteModalProps.onOpen();
}, [ deleteModalProps ]);
......@@ -41,13 +46,19 @@ const WatchList: React.FC = () => {
<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>
{ Boolean(watchlist.length) && (
{ !watchlistData && (
<>
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
) }
{ Boolean(watchlistData?.length) && (
<>
<WatchlistTable
data={ watchlist }
data={ watchlistData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
<Box marginTop={ 8 }>
<Button
variant="primary"
......@@ -57,9 +68,11 @@ const WatchList: React.FC = () => {
Add address
</Button>
</Box>
</>
) }
</Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/>
<DeleteAddressModal { ...deleteModalProps } onClose={ onDeleteModalClose } address={ deleteModalData }/>
<DeleteAddressModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/>
</Page>
);
};
......
......@@ -6,39 +6,104 @@ import {
Grid,
GridItem,
} from '@chakra-ui/react';
import React, { useCallback, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { TWatchlistItem } from 'data/watchlist';
import type { TWatchlistItem } from 'types/client/account';
import AddressInput from 'ui/shared/AddressInput';
import TagInput from 'ui/shared/TagInput';
const NOTIFICATIONS = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ];
const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const;
const NOTIFICATIONS_NAMES = [ 'xDAI', 'ERC-20', 'ERC-721, ERC-1155 (NFT)' ];
const ADDRESS_LENGTH = 42;
const TAG_MAX_LENGTH = 35;
type Props = {
data?: TWatchlistItem;
onClose: () => void;
}
type Inputs = {
address: string;
tag: string;
notification: boolean;
notification_settings: {
'native': {
outcoming: boolean;
incoming: boolean;
};
'ERC-721': {
outcoming: boolean;
incoming: boolean;
};
'ERC-20': {
outcoming: boolean;
incoming: boolean;
};
};
}
const AddressForm: React.FC<Props> = ({ data }) => {
type Checkboxes = 'notification' |
'notification_settings.native.outcoming' |
'notification_settings.native.incoming' |
'notification_settings.ERC-20.outcoming' |
'notification_settings.ERC-20.incoming' |
'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 [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors }, setValue } = useForm<Inputs>();
useEffect(() => {
setValue('address', data?.address || '');
setValue('tag', data?.tag || '');
setValue('notification', Boolean(data?.notification));
}, [ setValue, data ]);
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => {
const requestParams = {
name: formData?.tag,
address_hash: formData?.address,
notification_settings: formData.notification_settings,
notification_methods: {
email: formData.notification,
},
};
if (data) {
// edit address
return fetch(`/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) });
}
}, {
onError: () => {
// eslint-disable-next-line no-console
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
console.log('error');
},
onSuccess: () => {
queryClient.refetchQueries([ 'watchlist' ]).then(() => {
onClose();
setPending(false);
});
},
});
const onSubmit: SubmitHandler<Inputs> = (formData) => {
setPending(true);
mutate(formData);
};
useEffect(() => {
const notificationsDefault = {} as Inputs['notification_settings'];
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true });
setValue('address', data?.address_hash || '');
setValue('tag', data?.name || '');
setValue('notification', data ? data.notification_methods.email : true);
setValue('notification_settings', data ? data.notification_settings : notificationsDefault);
}, [ setValue, data ]);
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } isInvalid={ Boolean(errors.address) }/>;
......@@ -48,7 +113,8 @@ const AddressForm: React.FC<Props> = ({ data }) => {
return <TagInput field={ field } isInvalid={ Boolean(errors.tag) }/>;
}, [ errors ]);
const renderCheckbox = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'notification'>}) => (
// eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
<Checkbox
isChecked={ field.value }
onChange={ field.onChange }
......@@ -56,7 +122,7 @@ const AddressForm: React.FC<Props> = ({ data }) => {
colorScheme="blue"
size="lg"
>
Email notifications
{ text }
</Checkbox>
), []);
......@@ -87,14 +153,29 @@ const AddressForm: React.FC<Props> = ({ data }) => {
Please select what types of notifications you will receive
</Text>
<Box marginBottom={ 8 }>
{ /* add them to the form later */ }
<Grid templateColumns="repeat(3, max-content)" gap="20px 24px">
{ NOTIFICATIONS.map((notification: string) => {
{ 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>{ notification }</GridItem>
<GridItem><Checkbox colorScheme="blue" size="lg">Incoming</Checkbox></GridItem>
<GridItem><Checkbox colorScheme="blue" size="lg">Outgoing</Checkbox></GridItem>
<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>
);
}) }
......@@ -102,15 +183,16 @@ const AddressForm: React.FC<Props> = ({ data }) => {
</Box>
<Text variant="secondary" fontSize="sm" marginBottom={ 5 }>Notification methods</Text>
<Controller
name="notification"
name={ 'notification' as Checkboxes }
control={ control }
render={ renderCheckbox }
render={ renderCheckbox('Email notifications') }
/>
<Box marginTop={ 8 }>
<Button
size="lg"
variant="primary"
onClick={ handleSubmit(onSubmit) }
isLoading={ pending }
disabled={ Object.keys(errors).length > 0 }
>
{ data ? 'Save changes' : 'Add address' }
......
import React, { useCallback } from 'react';
import type { TWatchlistItem } from 'data/watchlist';
import type { TWatchlistItem } from 'types/client/account';
import FormModal from 'ui/shared/FormModal';
import AddressForm from './AddressForm';
......@@ -16,8 +17,8 @@ const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const text = 'An email notification can be sent to you when an address on your watch list sends or receives any transactions.';
const renderForm = useCallback(() => {
return <AddressForm data={ data }/>;
}, [ data ]);
return <AddressForm data={ data } onClose={ onClose }/>;
}, [ data, onClose ]);
return (
<FormModal<TWatchlistItem>
isOpen={ isOpen }
......
import { Text } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
isOpen: boolean;
onClose: () => void;
address?: string;
data?: TWatchlistItem;
}
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, address }) => {
const onDelete = useCallback(() => {
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('delete', address);
}, [ address ]);
console.log('error');
},
onSuccess: () => {
queryClient.refetchQueries([ 'watchlist' ]).then(() => {
onClose();
setPending(false);
});
},
});
const onDelete = useCallback(() => {
setPending(true);
mutate();
}, [ mutate ]);
const address = data?.address_hash;
const renderText = useCallback(() => {
return (
......@@ -28,6 +52,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, address }) => {
onDelete={ onDelete }
title="Remove address from watch list"
renderContent={ renderText }
pending={ pending }
/>
);
};
......
import { Link, HStack, VStack, Image, Text, Icon, useColorModeValue } from '@chakra-ui/react';
import { HStack, VStack, Image, Text, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { TWatchlistItem } from 'data/watchlist';
import type { TWatchlistItem } from 'types/client/account';
import TokensIcon from 'icons/tokens.svg';
import WalletIcon from 'icons/wallet.svg';
// import WalletIcon from 'icons/wallet.svg';
import { nbsp } from 'lib/html-entities';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
// now this component works only for xDAI
// for other networks later we will use config or smth
const DECIMALS = 18;
const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const mainTextColor = useColorModeValue('gray.700', 'gray.50');
const nativeBalance = ((item.address_balance || 0) / 10 ** DECIMALS).toFixed(1);
const nativeBalanceUSD = item.exchange_rate ? `$${ Number(nativeBalance) * item.exchange_rate } USD` : 'N/A';
return (
<HStack spacing={ 3 } align="top">
<AddressIcon address={ item.address }/>
<AddressIcon address={ item.address_hash }/>
<VStack spacing={ 2 } align="stretch" overflow="hidden" fontWeight={ 500 } color="gray.700">
<AddressLinkWithTooltip address={ item.address }/>
{ item.tokenBalance && (
<AddressLinkWithTooltip address={ item.address_hash }/>
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<Image src="/xdai.png" alt="chain-logo" marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + item.tokenBalance }</Text>
<Text variant="secondary">{ `${ nbsp }($${ item.tokenBalanceUSD } USD)` }</Text>
<Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + nativeBalance }</Text>
<Text variant="secondary">{ `${ nbsp }(${ nativeBalanceUSD })` }</Text>
</HStack>
) }
{ item.tokensAmount && (
{ item.tokens_count && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<Icon as={ TokensIcon } marginRight="10px" w="17px" h="16px"/>
<Text color={ mainTextColor }>{ `Tokens:${ nbsp }` + item.tokensAmount }</Text>
<Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text>
<Text color={ mainTextColor }>{ `Tokens:${ nbsp }` + item.tokens_count }</Text>
{ /* api does not provide token prices */ }
{ /* <Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text> */ }
<Text variant="secondary">{ `${ nbsp }(N/A)` }</Text>
</HStack>
) }
{ item.totalUSD && (
{ /* api does not provide token prices */ }
{ /* { item.address_balance && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<Icon as={ WalletIcon } marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `Net worth:${ nbsp }` }</Text>
<Link href="#">{ `$${ item.totalUSD } USD` }</Link>
</HStack>
) }
) } */ }
</VStack>
</HStack>
);
......
......@@ -5,9 +5,11 @@ import {
Switch,
HStack,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account';
import type { TWatchlistItem } from 'data/watchlist';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
......@@ -21,6 +23,7 @@ interface Props {
}
const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
......@@ -29,17 +32,34 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
const { mutate } = useMutation(() => {
const data = { ...item, notification_methods: { email: !notificationEnabled } };
return fetch(`/api/account/watchlist/${ item.id }`, { method: 'PUT', body: JSON.stringify(data) });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
},
onSuccess: () => {
setNotificationEnabled(prevState => !prevState);
},
});
const onSwitch = useCallback(() => {
return mutate();
}, [ mutate ]);
return (
<Tr alignItems="top" key={ item.address }>
<Tr alignItems="top" key={ item.address_hash }>
<Td><WatchListAddressItem item={ item }/></Td>
<Td>
<TruncatedTextTooltip label={ item.tag }>
<TruncatedTextTooltip label={ item.name }>
<Tag variant="gray" lineHeight="24px">
{ item.tag }
{ item.name }
</Tag>
</TruncatedTextTooltip>
</Td>
<Td><Switch colorScheme="blue" size="md" isChecked={ item.notification }/></Td>
<Td><Switch colorScheme="blue" size="md" isChecked={ notificationEnabled } onChange={ onSwitch }/></Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
......
......@@ -8,7 +8,7 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { TWatchlist, TWatchlistItem } from 'data/watchlist';
import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import WatchlistTableItem from './WatchListTableItem';
......@@ -34,7 +34,7 @@ const WatchlistTable = ({ data, onDeleteClick, onEditClick }: Props) => {
{ data.map((item) => (
<WatchlistTableItem
item={ item }
key={ item.address }
key={ item.address_hash }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment