Commit 94ed0ff7 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #412 from blockscout/address-favorite

address page: add to watch list
parents 6c197428 5b70e91e
import React from 'react';
// prevent set focus on button when closing modal
export default function usePreventFocusAfterModalClosing() {
return React.useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
}
...@@ -51,15 +51,15 @@ const variantOutline = defineStyle((props) => { ...@@ -51,15 +51,15 @@ const variantOutline = defineStyle((props) => {
const activeBg = isGrayTheme ? mode('blue.50', 'gray.600')(props) : mode(`${ c }.50`, 'gray.600')(props); const activeBg = isGrayTheme ? mode('blue.50', 'gray.600')(props) : mode(`${ c }.50`, 'gray.600')(props);
const activeColor = (() => { const activeColor = (() => {
if (c === 'gray') { if (c === 'gray') {
return mode('blue.400', 'gray.50')(props); return mode('blue.600', 'gray.50')(props);
} }
if (c === 'gray-dark') { if (c === 'gray-dark') {
return mode('blue.700', 'gray.50')(props); return mode('blue.600', 'gray.50')(props);
} }
if (c === 'blue') { if (c === 'blue') {
return mode('blue.400', 'gray.50')(props); return mode('blue.600', 'gray.50')(props);
} }
return 'blue.400'; return 'blue.600';
})(); })();
return { return {
......
import type { AddressParam } from './addressParams';
export interface AddressTag { export interface AddressTag {
address_hash: string; address_hash: string;
name: string; name: string;
...@@ -63,6 +64,7 @@ export interface WatchlistAddress { ...@@ -63,6 +64,7 @@ export interface WatchlistAddress {
notification_settings: NotificationSettings; notification_settings: NotificationSettings;
notification_methods: NotificationMethods; notification_methods: NotificationMethods;
id: string; id: string;
address?: AddressParam;
} }
export interface WatchlistAddressNew { export interface WatchlistAddressNew {
......
import { Box, Flex, Text, Icon, Button, Grid } from '@chakra-ui/react'; import { Box, Flex, Text, Icon, Grid } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
...@@ -10,7 +10,6 @@ import { QueryKeys } from 'types/client/queries'; ...@@ -10,7 +10,6 @@ import { QueryKeys } from 'types/client/queries';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import metamaskIcon from 'icons/metamask.svg'; import metamaskIcon from 'icons/metamask.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
...@@ -21,6 +20,7 @@ import ExternalLink from 'ui/shared/ExternalLink'; ...@@ -21,6 +20,7 @@ import ExternalLink from 'ui/shared/ExternalLink';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import AddressDetailsSkeleton from './details/AddressDetailsSkeleton'; import AddressDetailsSkeleton from './details/AddressDetailsSkeleton';
import AddressFavoriteButton from './details/AddressFavoriteButton';
import AddressQrCode from './details/AddressQrCode'; import AddressQrCode from './details/AddressQrCode';
import TokenSelect from './tokenSelect/TokenSelect'; import TokenSelect from './tokenSelect/TokenSelect';
...@@ -69,9 +69,7 @@ const AddressDetails = ({ addressQuery }: Props) => { ...@@ -69,9 +69,7 @@ const AddressDetails = ({ addressQuery }: Props) => {
</Text> </Text>
<CopyToClipboard text={ addressQuery.data.hash }/> <CopyToClipboard text={ addressQuery.data.hash }/>
<Icon as={ metamaskIcon } boxSize={ 6 } ml={ 2 }/> <Icon as={ metamaskIcon } boxSize={ 6 } ml={ 2 }/>
<Button variant="outline" size="sm" ml={ 3 }> <AddressFavoriteButton hash={ addressQuery.data.hash } isAdded={ Boolean(addressQuery.data.watchlist_names?.length) } ml={ 3 }/>
<Icon as={ starOutlineIcon } boxSize={ 5 }/>
</Button>
<AddressQrCode hash={ addressQuery.data.hash } ml={ 2 }/> <AddressQrCode hash={ addressQuery.data.hash } ml={ 2 }/>
</Flex> </Flex>
{ explorers.length > 0 && ( { explorers.length > 0 && (
......
import { Icon, chakra, Tooltip, IconButton, useDisclosure } from '@chakra-ui/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import type { TWatchlist } from 'types/client/account';
import { QueryKeys as AccountQueryKeys } from 'types/client/accountQueries';
import { QueryKeys } from 'types/client/queries';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import useFetch from 'lib/hooks/useFetch';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
interface Props {
className?: string;
hash: string;
isAdded: boolean;
}
const AddressFavoriteButton = ({ className, hash, isAdded }: Props) => {
const addModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
const fetch = useFetch();
const profileData = queryClient.getQueryData<UserInfo>([ AccountQueryKeys.profile ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const watchListQuery = useQuery<unknown, unknown, TWatchlist>(
[ AccountQueryKeys.watchlist ],
async() => fetch('/node-api/account/watchlist'),
{
enabled: isAdded,
},
);
const handleClick = React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
return;
}
isAdded ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, isAdded, isAuth, loginUrl ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey: [ QueryKeys.address, router.query.id ] });
addModalProps.onClose();
}, [ addModalProps, queryClient, router.query.id ]);
const handleAddModalClose = React.useCallback(() => {
addModalProps.onClose();
}, [ addModalProps ]);
const handleDeleteModalClose = React.useCallback(() => {
deleteModalProps.onClose();
}, [ deleteModalProps ]);
const formData = React.useMemo(() => {
return {
address_hash: hash,
// FIXME temporary solution
// there is no endpoint in api what can return watchlist address info by its hash
// so we look up in the whole watchlist and hope we can find a necessary item
id: watchListQuery.data?.find((address) => address.address?.hash === hash)?.id || '',
};
}, [ hash, watchListQuery.data ]);
return (
<>
<Tooltip label={ `${ isAdded ? 'Remove address from Watch list' : 'Add address to Watch list' }` }>
<IconButton
isActive={ isAdded }
className={ className }
aria-label="edit"
variant="outline"
size="sm"
pl="6px"
pr="6px"
onClick={ handleClick }
icon={ <Icon as={ isAdded ? starFilledIcon : starOutlineIcon } boxSize={ 5 }/> }
onFocusCapture={ usePreventFocusAfterModalClosing() }
/>
</Tooltip>
<WatchlistAddModal
{ ...addModalProps }
isAdd
onClose={ handleAddModalClose }
onSuccess={ handleAddOrDeleteSuccess }
data={ formData }
/>
<DeleteAddressModal
{ ...deleteModalProps }
onClose={ handleDeleteModalClose }
data={ formData }
onSuccess={ handleAddOrDeleteSuccess }
/>
</>
);
};
export default chakra(AddressFavoriteButton);
import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { TWatchlist, TWatchlistItem } from 'types/client/account'; import type { TWatchlist, TWatchlistItem } from 'types/client/account';
...@@ -22,6 +22,7 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable'; ...@@ -22,6 +22,7 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => { const WatchList: React.FC = () => {
const { data, isLoading, isError } = const { data, isLoading, isError } =
useQuery<unknown, unknown, TWatchlist>([ QueryKeys.watchlist ], async() => fetch('/node-api/account/watchlist/get-with-tokens')); useQuery<unknown, unknown, TWatchlist>([ QueryKeys.watchlist ], async() => fetch('/node-api/account/watchlist/get-with-tokens'));
const queryClient = useQueryClient();
const addressModalProps = useDisclosure(); const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
...@@ -42,6 +43,12 @@ const WatchList: React.FC = () => { ...@@ -42,6 +43,12 @@ const WatchList: React.FC = () => {
addressModalProps.onClose(); addressModalProps.onClose();
}, [ addressModalProps ]); }, [ addressModalProps ]);
const onAddOrEditSuccess = useCallback(async() => {
await queryClient.refetchQueries([ QueryKeys.watchlist ]);
setAddressModalData(undefined);
addressModalProps.onClose();
}, [ addressModalProps, queryClient ]);
const onDeleteClick = useCallback((data: TWatchlistItem) => { const onDeleteClick = useCallback((data: TWatchlistItem) => {
setDeleteModalData(data); setDeleteModalData(data);
deleteModalProps.onOpen(); deleteModalProps.onOpen();
...@@ -52,6 +59,12 @@ const WatchList: React.FC = () => { ...@@ -52,6 +59,12 @@ const WatchList: React.FC = () => {
deleteModalProps.onClose(); deleteModalProps.onClose();
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const onDeleteSuccess = useCallback(async() => {
queryClient.setQueryData([ QueryKeys.watchlist ], (prevData: TWatchlist | undefined) => {
return prevData?.filter((item) => item.id !== deleteModalData?.id);
});
}, [ deleteModalData?.id, queryClient ]);
const description = ( const description = (
<AccountPageDescription> <AccountPageDescription>
An email notification can be sent to you when an address on your watch list sends or receives any transactions. An email notification can be sent to you when an address on your watch list sends or receives any transactions.
...@@ -107,8 +120,21 @@ const WatchList: React.FC = () => { ...@@ -107,8 +120,21 @@ const WatchList: React.FC = () => {
Add address Add address
</Button> </Button>
</Box> </Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/> <AddressModal
{ deleteModalData && <DeleteAddressModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> } { ...addressModalProps }
onClose={ onAddressModalClose }
onSuccess={ onAddOrEditSuccess }
data={ addressModalData }
isAdd={ !addressModalData }
/>
{ deleteModalData && (
<DeleteAddressModal
{ ...deleteModalProps }
onClose={ onDeleteModalClose }
onSuccess={ onDeleteSuccess }
data={ deleteModalData }
/>
) }
</> </>
); );
} }
......
import { Tooltip, IconButton, Icon, HStack } from '@chakra-ui/react'; import { Tooltip, IconButton, Icon, HStack } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React from 'react';
import DeleteIcon from 'icons/delete.svg'; import DeleteIcon from 'icons/delete.svg';
import EditIcon from 'icons/edit.svg'; import EditIcon from 'icons/edit.svg';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
type Props = { type Props = {
onEditClick: () => void; onEditClick: () => void;
...@@ -10,8 +11,7 @@ type Props = { ...@@ -10,8 +11,7 @@ type Props = {
} }
const TableItemActionButtons = ({ onEditClick, onDeleteClick }: Props) => { const TableItemActionButtons = ({ onEditClick, onDeleteClick }: Props) => {
// prevent set focus on button when closing modal const onFocusCapture = usePreventFocusAfterModalClosing();
const onFocusCapture = useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
return ( return (
<HStack spacing={ 6 } alignSelf="flex-end"> <HStack spacing={ 6 } alignSelf="flex-end">
......
...@@ -4,14 +4,13 @@ import { ...@@ -4,14 +4,13 @@ import {
Text, Text,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import type { WatchlistErrors } from 'types/api/account'; import type { WatchlistErrors } from 'types/api/account';
import type { TWatchlistItem } from 'types/client/account'; import type { TWatchlistItem } from 'types/client/account';
import { QueryKeys } from 'types/client/accountQueries';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import type { ErrorType } from 'lib/hooks/useFetch'; import type { ErrorType } from 'lib/hooks/useFetch';
...@@ -29,9 +28,10 @@ const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const; ...@@ -29,9 +28,10 @@ const NOTIFICATIONS = [ 'native', 'ERC-20', 'ERC-721' ] as const;
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
data?: TWatchlistItem; data?: Partial<TWatchlistItem>;
onClose: () => void; onSuccess: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void; setAlertVisible: (isAlertVisible: boolean) => void;
isAdd: boolean;
} }
type Inputs = { type Inputs = {
...@@ -62,12 +62,12 @@ type Checkboxes = 'notification' | ...@@ -62,12 +62,12 @@ type Checkboxes = 'notification' |
'notification_settings.ERC-721.outcoming' | 'notification_settings.ERC-721.outcoming' |
'notification_settings.ERC-721.incoming'; 'notification_settings.ERC-721.incoming';
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const AddressForm: React.FC<Props> = ({ data, onSuccess, setAlertVisible, isAdd }) => {
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const formBackgroundColor = useColorModeValue('white', 'gray.900'); const formBackgroundColor = useColorModeValue('white', 'gray.900');
let notificationsDefault = {} as Inputs['notification_settings']; let notificationsDefault = {} as Inputs['notification_settings'];
if (!data) { if (!data?.notification_settings) {
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true }); NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true });
} else { } else {
notificationsDefault = data.notification_settings; notificationsDefault = data.notification_settings;
...@@ -77,13 +77,12 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -77,13 +77,12 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
defaultValues: { defaultValues: {
address: data?.address_hash || '', address: data?.address_hash || '',
tag: data?.name || '', tag: data?.name || '',
notification: data ? data.notification_methods.email : true, notification: data?.notification_methods ? data.notification_methods.email : true,
notification_settings: notificationsDefault, notification_settings: notificationsDefault,
}, },
mode: 'onTouched', mode: 'onTouched',
}); });
const queryClient = useQueryClient();
const fetch = useFetch(); const fetch = useFetch();
function updateWatchlist(formData: Inputs) { function updateWatchlist(formData: Inputs) {
...@@ -95,7 +94,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -95,7 +94,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
email: formData.notification, email: formData.notification,
}, },
}; };
if (data) { if (!isAdd && data) {
// edit address // edit address
return fetch<TWatchlistItem, WatchlistErrors>(`/node-api/account/watchlist/${ data.id }`, { method: 'PUT', body }); return fetch<TWatchlistItem, WatchlistErrors>(`/node-api/account/watchlist/${ data.id }`, { method: 'PUT', body });
...@@ -106,11 +105,9 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -106,11 +105,9 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
} }
const { mutate } = useMutation(updateWatchlist, { const { mutate } = useMutation(updateWatchlist, {
onSuccess: () => { onSuccess: async() => {
queryClient.refetchQueries([ QueryKeys.watchlist ]).then(() => { await onSuccess();
onClose(); setPending(false);
setPending(false);
});
}, },
onError: (e: ErrorType<WatchlistErrors>) => { onError: (e: ErrorType<WatchlistErrors>) => {
setPending(false); setPending(false);
...@@ -193,7 +190,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -193,7 +190,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
isLoading={ pending } isLoading={ pending }
disabled={ !isValid || !isDirty } disabled={ !isValid || !isDirty }
> >
{ data ? 'Save changes' : 'Add address' } { !isAdd ? 'Save changes' : 'Add address' }
</Button> </Button>
</Box> </Box>
</form> </form>
......
...@@ -7,20 +7,22 @@ import FormModal from 'ui/shared/FormModal'; ...@@ -7,20 +7,22 @@ import FormModal from 'ui/shared/FormModal';
import AddressForm from './AddressForm'; import AddressForm from './AddressForm';
type Props = { type Props = {
isAdd: boolean;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data?: TWatchlistItem; onSuccess: () => Promise<void>;
data?: Partial<TWatchlistItem>;
} }
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const AddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data, isAdd }) => {
const title = data ? 'Edit watch list address' : 'New address to watch list'; const title = !isAdd ? 'Edit watch list address' : 'New address to watch list';
const text = !data ? 'An email notification can be sent to you when an address on your watch list sends or receives any transactions.' : ''; const text = isAdd ? '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 [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => { const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>; return <AddressForm data={ data } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible } isAdd={ isAdd }/>;
}, [ data, onClose ]); }, [ data, isAdd, onSuccess ]);
return ( return (
<FormModal<TWatchlistItem> <FormModal<TWatchlistItem>
......
import { Text } from '@chakra-ui/react'; import { Text } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { TWatchlistItem, TWatchlist } from 'types/client/account'; import type { TWatchlistItem } from 'types/client/account';
import { QueryKeys } from 'types/client/accountQueries';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
...@@ -12,11 +10,11 @@ import DeleteModal from 'ui/shared/DeleteModal'; ...@@ -12,11 +10,11 @@ import DeleteModal from 'ui/shared/DeleteModal';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data: TWatchlistItem; onSuccess: () => Promise<void>;
data: Pick<TWatchlistItem, 'address_hash' | 'id'>;
} }
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data }) => {
const queryClient = useQueryClient();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const fetch = useFetch(); const fetch = useFetch();
...@@ -24,12 +22,6 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -24,12 +22,6 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
return fetch(`/node-api/account/watchlist/${ data?.id }`, { method: 'DELETE' }); return fetch(`/node-api/account/watchlist/${ data?.id }`, { method: 'DELETE' });
}, [ data?.id, fetch ]); }, [ data?.id, fetch ]);
const onSuccess = useCallback(async() => {
queryClient.setQueryData([ QueryKeys.watchlist ], (prevData: TWatchlist | undefined) => {
return prevData?.filter((item) => item.id !== data.id);
});
}, [ data, queryClient ]);
const address = data?.address_hash; const address = data?.address_hash;
const renderModalContent = useCallback(() => { const renderModalContent = useCallback(() => {
......
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