chore(trading): new toasts designs (#2779)

This commit is contained in:
Art 2023-02-06 21:09:56 +01:00 committed by GitHub
parent 8bcdaf4cda
commit c7a6fdd879
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 876 additions and 361 deletions

View File

@ -3,8 +3,12 @@ import { useAssetsDataProvider } from '@vegaprotocol/assets';
import { ETHERSCAN_TX, useEtherscanLink } from '@vegaprotocol/environment'; import { ETHERSCAN_TX, useEtherscanLink } from '@vegaprotocol/environment';
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers'; import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit'; import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { ExternalLink, Intent, ProgressBar } from '@vegaprotocol/ui-toolkit'; import { ExternalLink, Intent, ProgressBar } from '@vegaprotocol/ui-toolkit';
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import type { EthStoredTxState } from '@vegaprotocol/web3'; import type { EthStoredTxState } from '@vegaprotocol/web3';
import { import {
@ -44,34 +48,35 @@ const EthTransactionDetails = ({ tx }: { tx: EthStoredTxState }) => {
if (isWithdraw) label = t('Withdraw'); if (isWithdraw) label = t('Withdraw');
if (isDeposit) label = t('Deposit'); if (isDeposit) label = t('Deposit');
assetInfo = ( assetInfo = (
<div className="mt-[5px]"> <strong>
<span className="font-mono text-xs p-1 bg-gray-100 rounded">
{label}{' '} {label}{' '}
{formatNumber(toBigNum(tx.args[1], asset.decimals), asset.decimals)}{' '} {formatNumber(toBigNum(tx.args[1], asset.decimals), asset.decimals)}{' '}
{asset.symbol} {asset.symbol}
</span> </strong>
</div>
); );
} }
} }
if (assetInfo || tx.requiresConfirmation) {
return ( return (
<> <Panel>
{assetInfo} {assetInfo}
{tx.status === EthTxStatus.Pending && ( {tx.status === EthTxStatus.Pending && (
<div className="mt-[10px]"> <>
<span className="font-mono text-xs"> <p className="mt-[2px]">
{t('Awaiting confirmations')}{' '} {t('Awaiting confirmations')}{' '}
{`(${tx.confirmations}/${tx.requiredConfirmations})`} {`(${tx.confirmations}/${tx.requiredConfirmations})`}
</span> </p>
<ProgressBar <ProgressBar
value={(tx.confirmations / tx.requiredConfirmations) * 100} value={(tx.confirmations / tx.requiredConfirmations) * 100}
intent={Intent.Warning}
/> />
</div>
)}
</> </>
)}
</Panel>
); );
}
return null;
}; };
type EthTxToastContentProps = { type EthTxToastContentProps = {
@ -80,26 +85,26 @@ type EthTxToastContentProps = {
const EthTxRequestedToastContent = ({ tx }: EthTxToastContentProps) => { const EthTxRequestedToastContent = ({ tx }: EthTxToastContentProps) => {
return ( return (
<div> <>
<h3 className="font-bold">{t('Action required')}</h3> <ToastHeading>{t('Action required')}</ToastHeading>
<p> <p>
{t( {t(
'Please go to your wallet application and approve or reject the transaction.' 'Please go to your wallet application and approve or reject the transaction.'
)} )}
</p> </p>
<EthTransactionDetails tx={tx} /> <EthTransactionDetails tx={tx} />
</div> </>
); );
}; };
const EthTxPendingToastContent = ({ tx }: EthTxToastContentProps) => { const EthTxPendingToastContent = ({ tx }: EthTxToastContentProps) => {
return ( return (
<div> <>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3> <ToastHeading>{t('Awaiting confirmation')}</ToastHeading>
<p>{t('Please wait for your transaction to be confirmed.')}</p> <p>{t('Please wait for your transaction to be confirmed.')}</p>
<EtherscanLink tx={tx} /> <EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} /> <EthTransactionDetails tx={tx} />
</div> </>
); );
}; };
@ -112,11 +117,11 @@ const EthTxErrorToastContent = ({ tx }: EthTxToastContentProps) => {
errorMessage = tx.error.message; errorMessage = tx.error.message;
} }
return ( return (
<div> <>
<h3 className="font-bold">{t('Error occurred')}</h3> <ToastHeading>{t('Error occurred')}</ToastHeading>
<p>{errorMessage}</p> <p className="first-letter:uppercase">{errorMessage}</p>
<EthTransactionDetails tx={tx} /> <EthTransactionDetails tx={tx} />
</div> </>
); );
}; };
@ -136,42 +141,63 @@ const EtherscanLink = ({ tx }: EthTxToastContentProps) => {
const EthTxConfirmedToastContent = ({ tx }: EthTxToastContentProps) => { const EthTxConfirmedToastContent = ({ tx }: EthTxToastContentProps) => {
return ( return (
<div> <>
<h3 className="font-bold">{t('Transaction confirmed')}</h3> <ToastHeading>{t('Transaction confirmed')}</ToastHeading>
<p>{t('Your transaction has been confirmed.')}</p> <p>{t('Your transaction has been confirmed.')}</p>
<EtherscanLink tx={tx} /> <EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} /> <EthTransactionDetails tx={tx} />
</div> </>
); );
}; };
const EthTxCompletedToastContent = ({ tx }: EthTxToastContentProps) => { const EthTxCompletedToastContent = ({ tx }: EthTxToastContentProps) => {
const isDeposit = isDepositTransaction(tx); const isDeposit = isDepositTransaction(tx);
return ( return (
<div> <>
<h3 className="font-bold"> <ToastHeading>
{t('Processing')} {isDeposit && t('deposit')} {t('Processing')} {isDeposit && t('deposit')}
</h3> </ToastHeading>
<p> <p>
{t('Your transaction has been completed.')}{' '} {t('Your transaction has been completed.')}{' '}
{isDeposit && t('Waiting for deposit confirmation.')} {isDeposit && t('Waiting for deposit confirmation.')}
</p> </p>
<EtherscanLink tx={tx} /> <EtherscanLink tx={tx} />
<EthTransactionDetails tx={tx} /> <EthTransactionDetails tx={tx} />
</div> </>
); );
}; };
const isFinal = (tx: EthStoredTxState) =>
[EthTxStatus.Confirmed, EthTxStatus.Error].includes(tx.status);
export const useEthereumTransactionToasts = () => { export const useEthereumTransactionToasts = () => {
const ethTransactions = useEthTransactionStore((state) => const [setToast, removeToast] = useToasts((store) => [
state.transactions.filter((transaction) => transaction?.dialogOpen) store.setToast,
); store.remove,
const dismissEthTransaction = useEthTransactionStore( ]);
(state) => state.dismiss
const [dismissTx, deleteTx] = useEthTransactionStore((state) => [
state.dismiss,
state.delete,
]);
const onClose = useCallback(
(tx: EthStoredTxState) => () => {
const safeToDelete = isFinal(tx);
if (safeToDelete) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
removeToast(`eth-${tx.id}`);
},
[deleteTx, dismissTx, removeToast]
); );
const fromEthTransaction = useCallback( const fromEthTransaction = useCallback(
(tx: EthStoredTxState): Toast => { (tx: EthStoredTxState): Toast => {
let content: ToastContent = <TransactionContent {...tx} />; let content: ToastContent = <TransactionContent {...tx} />;
const closeAfter = isFinal(tx) ? CLOSE_AFTER : undefined;
if (tx.status === EthTxStatus.Requested) { if (tx.status === EthTxStatus.Requested) {
content = <EthTxRequestedToastContent tx={tx} />; content = <EthTxRequestedToastContent tx={tx} />;
} }
@ -191,15 +217,21 @@ export const useEthereumTransactionToasts = () => {
return { return {
id: `eth-${tx.id}`, id: `eth-${tx.id}`,
intent: intentMap[tx.status], intent: intentMap[tx.status],
onClose: () => dismissEthTransaction(tx.id), onClose: onClose(tx),
loader: [EthTxStatus.Pending, EthTxStatus.Complete].includes(tx.status), loader: [EthTxStatus.Pending, EthTxStatus.Complete].includes(tx.status),
content, content,
closeAfter,
}; };
}, },
[dismissEthTransaction] [onClose]
); );
return useMemo(() => { useEthTransactionStore.subscribe(
return [...compact(ethTransactions).map(fromEthTransaction)]; (state) => compact(state.transactions.filter((tx) => tx?.dialogOpen)),
}, [ethTransactions, fromEthTransaction]); (txs) => {
txs.forEach((tx) => {
setToast(fromEthTransaction(tx));
});
}
);
}; };

View File

@ -1,8 +1,12 @@
import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers'; import { formatNumber, t, toBigNum } from '@vegaprotocol/react-helpers';
import type { Toast } from '@vegaprotocol/ui-toolkit'; import type { Toast } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { Intent } from '@vegaprotocol/ui-toolkit'; import { Intent } from '@vegaprotocol/ui-toolkit';
import { ApprovalStatus, VerificationStatus } from '@vegaprotocol/withdraws'; import { ApprovalStatus, VerificationStatus } from '@vegaprotocol/withdraws';
import { useCallback, useMemo } from 'react'; import { useCallback } from 'react';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import type { EthWithdrawalApprovalState } from '@vegaprotocol/web3'; import type { EthWithdrawalApprovalState } from '@vegaprotocol/web3';
import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3'; import { useEthWithdrawApprovalsStore } from '@vegaprotocol/web3';
@ -30,49 +34,72 @@ const EthWithdrawalApprovalToastContent = ({
if (tx.status === ApprovalStatus.Delayed) { if (tx.status === ApprovalStatus.Delayed) {
title = t('Delayed'); title = t('Delayed');
} }
if (tx.status === ApprovalStatus.Ready) {
title = t('Approved');
}
const num = formatNumber( const num = formatNumber(
toBigNum(tx.withdrawal.amount, tx.withdrawal.asset.decimals), toBigNum(tx.withdrawal.amount, tx.withdrawal.asset.decimals),
tx.withdrawal.asset.decimals tx.withdrawal.asset.decimals
); );
const details = ( const details = (
<div className="mt-[5px]"> <Panel>
<span className="font-mono text-xs p-1 bg-gray-100 rounded"> <strong>
{t('Withdraw')} {num} {tx.withdrawal.asset.symbol} {t('Withdraw')} {num} {tx.withdrawal.asset.symbol}
</span> </strong>
</div> </Panel>
); );
return ( return (
<div> <>
{title.length > 0 && <h3 className="font-bold">{title}</h3>} {title.length > 0 && (
<ToastHeading className="font-bold">{title}</ToastHeading>
)}
<VerificationStatus state={tx} /> <VerificationStatus state={tx} />
{details} {details}
</div> </>
); );
}; };
const isFinal = (tx: EthWithdrawalApprovalState) =>
[ApprovalStatus.Ready, ApprovalStatus.Error].includes(tx.status);
export const useEthereumWithdrawApprovalsToasts = () => { export const useEthereumWithdrawApprovalsToasts = () => {
const { withdrawApprovals, dismissWithdrawApproval } = const [setToast, remove] = useToasts((state) => [
useEthWithdrawApprovalsStore((state) => ({ state.setToast,
withdrawApprovals: state.transactions.filter( state.remove,
(transaction) => transaction?.dialogOpen ]);
), const [dismissTx, deleteTx] = useEthWithdrawApprovalsStore((state) => [
dismissWithdrawApproval: state.dismiss, state.dismiss,
})); state.delete,
]);
const fromWithdrawalApproval = useCallback( const fromWithdrawalApproval = useCallback(
(tx: EthWithdrawalApprovalState): Toast => ({ (tx: EthWithdrawalApprovalState): Toast => ({
id: `withdrawal-${tx.id}`, id: `withdrawal-${tx.id}`,
intent: intentMap[tx.status], intent: intentMap[tx.status],
onClose: () => dismissWithdrawApproval(tx.id), onClose: () => {
if ([ApprovalStatus.Error, ApprovalStatus.Ready].includes(tx.status)) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
remove(`withdrawal-${tx.id}`);
},
loader: tx.status === ApprovalStatus.Pending, loader: tx.status === ApprovalStatus.Pending,
content: <EthWithdrawalApprovalToastContent tx={tx} />, content: <EthWithdrawalApprovalToastContent tx={tx} />,
closeAfter: isFinal(tx) ? CLOSE_AFTER : undefined,
}), }),
[dismissWithdrawApproval] [deleteTx, dismissTx, remove]
); );
const toasts = useMemo(() => { useEthWithdrawApprovalsStore.subscribe(
return [...compact(withdrawApprovals).map(fromWithdrawalApproval)]; (state) =>
}, [fromWithdrawalApproval, withdrawApprovals]); compact(
state.transactions.filter((transaction) => transaction?.dialogOpen)
return toasts; ),
(txs) => {
txs.forEach((tx) => {
setToast(fromWithdrawalApproval(tx));
});
}
);
}; };

View File

@ -260,7 +260,7 @@ describe('VegaTransactionDetails', () => {
const { queryByTestId } = render( const { queryByTestId } = render(
<VegaTransactionDetails tx={unsupportedTransaction} /> <VegaTransactionDetails tx={unsupportedTransaction} />
); );
expect(queryByTestId('vega-tx-details')).toBeNull(); expect(queryByTestId('toast-panel')).toBeNull();
}); });
it.each([ it.each([
{ tx: withdraw, details: 'Withdraw 12.34 $A' }, { tx: withdraw, details: 'Withdraw 12.34 $A' },
@ -275,6 +275,6 @@ describe('VegaTransactionDetails', () => {
{ tx: batch, details: 'Batch market instruction' }, { tx: batch, details: 'Batch market instruction' },
])('display details for transaction', ({ tx, details }) => { ])('display details for transaction', ({ tx, details }) => {
const { queryByTestId } = render(<VegaTransactionDetails tx={tx} />); const { queryByTestId } = render(<VegaTransactionDetails tx={tx} />);
expect(queryByTestId('vega-tx-details')?.textContent).toEqual(details); expect(queryByTestId('toast-panel')?.textContent).toEqual(details);
}); });
}); });

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'; import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import first from 'lodash/first'; import first from 'lodash/first';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
import type { import type {
@ -25,6 +24,10 @@ import {
VegaTxStatus, VegaTxStatus,
} from '@vegaprotocol/wallet'; } from '@vegaprotocol/wallet';
import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit'; import type { Toast, ToastContent } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { Panel } from '@vegaprotocol/ui-toolkit';
import { CLOSE_AFTER } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit';
import { Button, ExternalLink, Intent } from '@vegaprotocol/ui-toolkit'; import { Button, ExternalLink, Intent } from '@vegaprotocol/ui-toolkit';
import { import {
addDecimalsFormatNumber, addDecimalsFormatNumber,
@ -40,6 +43,7 @@ import { DApp, EXPLORER_TX, useLinks } from '@vegaprotocol/environment';
import { getRejectionReason, useOrderByIdQuery } from '@vegaprotocol/orders'; import { getRejectionReason, useOrderByIdQuery } from '@vegaprotocol/orders';
import { useMarketList } from '@vegaprotocol/market-list'; import { useMarketList } from '@vegaprotocol/market-list';
import type { Side } from '@vegaprotocol/types'; import type { Side } from '@vegaprotocol/types';
import { OrderStatus } from '@vegaprotocol/types';
import { OrderStatusMapping } from '@vegaprotocol/types'; import { OrderStatusMapping } from '@vegaprotocol/types';
const intentMap: { [s in VegaTxStatus]: Intent } = { const intentMap: { [s in VegaTxStatus]: Intent } = {
@ -50,15 +54,6 @@ const intentMap: { [s in VegaTxStatus]: Intent } = {
Complete: Intent.Success, Complete: Intent.Success,
}; };
const getIntent = (tx: VegaStoredTxState) => {
// Transaction can be successful
// But the order can be rejected by the network
if (tx.order?.rejectionReason) {
return Intent.Danger;
}
return intentMap[tx.status];
};
const isClosePositionTransaction = (tx: VegaStoredTxState) => { const isClosePositionTransaction = (tx: VegaStoredTxState) => {
if (isBatchMarketInstructionsTransaction(tx.body)) { if (isBatchMarketInstructionsTransaction(tx.body)) {
const amendments = const amendments =
@ -98,20 +93,6 @@ const isTransactionTypeSupported = (tx: VegaStoredTxState) => {
); );
}; };
const Details = ({
children,
title = '',
}: {
children: ReactNode;
title?: string;
}) => (
<div className="pt-[5px]" data-testid="vega-tx-details" title={title}>
<div className="font-mono text-xs p-2 bg-neutral-100 rounded dark:bg-neutral-700 dark:text-white">
{children}
</div>
</div>
);
type SizeAtPriceProps = { type SizeAtPriceProps = {
side: Side; side: Side;
size: string; size: string;
@ -152,8 +133,8 @@ const SubmitOrderDetails = ({
const side = order ? order.side : data.side; const side = order ? order.side : data.side;
return ( return (
<Details> <Panel>
<h4 className="font-bold"> <h4>
{order {order
? t( ? t(
`Submit order - ${OrderStatusMapping[order.status].toLowerCase()}` `Submit order - ${OrderStatusMapping[order.status].toLowerCase()}`
@ -175,10 +156,7 @@ const SubmitOrderDetails = ({
price={price} price={price}
/> />
</p> </p>
{order && order.rejectionReason && ( </Panel>
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
); );
}; };
@ -194,9 +172,10 @@ const EditOrderDetails = ({
}); });
const { data: markets } = useMarketList(); const { data: markets } = useMarketList();
const originalOrder = orderById?.orderByID; const originalOrder = order || orderById?.orderByID;
const marketId = order?.marketId || orderById?.orderByID.market.id;
if (!originalOrder) return null; if (!originalOrder) return null;
const market = markets?.find((m) => m.id === originalOrder.market.id); const market = markets?.find((m) => m.id === marketId);
if (!market) return null; if (!market) return null;
const original = ( const original = (
@ -228,8 +207,8 @@ const EditOrderDetails = ({
); );
return ( return (
<Details title={data.orderId}> <Panel title={data.orderId}>
<h4 className="font-bold"> <h4>
{order {order
? t(`Edit order - ${OrderStatusMapping[order.status].toLowerCase()}`) ? t(`Edit order - ${OrderStatusMapping[order.status].toLowerCase()}`)
: t('Edit order')} : t('Edit order')}
@ -239,10 +218,7 @@ const EditOrderDetails = ({
<s>{original}</s> <s>{original}</s>
</p> </p>
<p>{edited}</p> <p>{edited}</p>
{order && order.rejectionReason && ( </Panel>
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
); );
}; };
@ -277,8 +253,8 @@ const CancelOrderDetails = ({
/> />
); );
return ( return (
<Details title={orderId}> <Panel title={orderId}>
<h4 className="font-bold"> <h4>
{order {order
? t( ? t(
`Cancel order - ${OrderStatusMapping[order.status].toLowerCase()}` `Cancel order - ${OrderStatusMapping[order.status].toLowerCase()}`
@ -289,10 +265,7 @@ const CancelOrderDetails = ({
<p> <p>
<s>{original}</s> <s>{original}</s>
</p> </p>
{order && order.rejectionReason && ( </Panel>
<p className="italic">{getRejectionReason(order)}</p>
)}
</Details>
); );
}; };
@ -311,9 +284,11 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
asset.decimals asset.decimals
); );
return ( return (
<Details> <Panel>
<strong>
{t('Withdraw')} {num} {asset.symbol} {t('Withdraw')} {num} {asset.symbol}
</Details> </strong>
</Panel>
); );
} }
} }
@ -330,7 +305,7 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
tx.body.orderCancellation.marketId === undefined && tx.body.orderCancellation.marketId === undefined &&
tx.body.orderCancellation.orderId === undefined tx.body.orderCancellation.orderId === undefined
) { ) {
return <Details>{t('Cancel all orders')}</Details>; return <Panel>{t('Cancel all orders')}</Panel>;
} }
// CANCEL // CANCEL
@ -353,11 +328,15 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
m.id === (tx.body as OrderCancellationBody).orderCancellation.marketId m.id === (tx.body as OrderCancellationBody).orderCancellation.marketId
)?.tradableInstrument.instrument.code; )?.tradableInstrument.instrument.code;
return ( return (
<Details> <Panel>
{marketName {marketName ? (
? `${t('Cancel all orders for')} ${marketName}` <>
: t('Cancel all orders')} {t('Cancel all orders for')} <strong>{marketName}</strong>
</Details> </>
) : (
t('Cancel all orders')
)}
</Panel>
); );
} }
} }
@ -379,15 +358,16 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
const market = marketId && markets?.find((m) => m.id === marketId); const market = marketId && markets?.find((m) => m.id === marketId);
if (market) { if (market) {
return ( return (
<Details> <Panel>
{t('Close position for')} {market.tradableInstrument.instrument.code} {t('Close position for')}{' '}
</Details> <strong>{market.tradableInstrument.instrument.code}</strong>
</Panel>
); );
} }
} }
if (isBatchMarketInstructionsTransaction(tx.body)) { if (isBatchMarketInstructionsTransaction(tx.body)) {
return <Details>{t('Batch market instruction')}</Details>; return <Panel>{t('Batch market instruction')}</Panel>;
} }
if (isTransferTransaction(tx.body)) { if (isTransferTransaction(tx.body)) {
@ -397,15 +377,15 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
if (transferAsset) { if (transferAsset) {
const value = addDecimalsFormatNumber(amount, transferAsset.decimals); const value = addDecimalsFormatNumber(amount, transferAsset.decimals);
return ( return (
<Details> <Panel>
<h4 className="font-bold">{t('Transfer')}</h4> <h4>{t('Transfer')}</h4>
<p> <p>
{t('To')} {truncateByChars(to)} {t('To')} {truncateByChars(to)}
</p> </p>
<p> <p>
{value} {transferAsset.symbol} {value} {transferAsset.symbol}
</p> </p>
</Details> </Panel>
); );
} }
} }
@ -416,22 +396,22 @@ export const VegaTransactionDetails = ({ tx }: { tx: VegaStoredTxState }) => {
type VegaTxToastContentProps = { tx: VegaStoredTxState }; type VegaTxToastContentProps = { tx: VegaStoredTxState };
const VegaTxRequestedToastContent = ({ tx }: VegaTxToastContentProps) => ( const VegaTxRequestedToastContent = ({ tx }: VegaTxToastContentProps) => (
<div> <>
<h3 className="font-bold">{t('Action required')}</h3> <ToastHeading>{t('Action required')}</ToastHeading>
<p> <p>
{t( {t(
'Please go to your Vega wallet application and approve or reject the transaction.' 'Please go to your Vega wallet application and approve or reject the transaction.'
)} )}
</p> </p>
<VegaTransactionDetails tx={tx} /> <VegaTransactionDetails tx={tx} />
</div> </>
); );
const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => { const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => {
const explorerLink = useLinks(DApp.Explorer); const explorerLink = useLinks(DApp.Explorer);
return ( return (
<div> <>
<h3 className="font-bold">{t('Awaiting confirmation')}</h3> <ToastHeading>{t('Awaiting confirmation')}</ToastHeading>
<p>{t('Please wait for your transaction to be confirmed')}</p> <p>{t('Please wait for your transaction to be confirmed')}</p>
{tx.txHash && ( {tx.txHash && (
<p className="break-all"> <p className="break-all">
@ -444,7 +424,7 @@ const VegaTxPendingToastContentProps = ({ tx }: VegaTxToastContentProps) => {
</p> </p>
)} )}
<VegaTransactionDetails tx={tx} /> <VegaTransactionDetails tx={tx} />
</div> </>
); );
}; };
@ -458,7 +438,7 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
if (isWithdrawTransaction(tx.body)) { if (isWithdrawTransaction(tx.body)) {
const completeWithdrawalButton = tx.withdrawal && ( const completeWithdrawalButton = tx.withdrawal && (
<div className="mt-[10px]"> <p className="mt-1">
<Button <Button
data-testid="toast-complete-withdrawal" data-testid="toast-complete-withdrawal"
size="xs" size="xs"
@ -471,11 +451,11 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
> >
{t('Complete withdrawal')} {t('Complete withdrawal')}
</Button> </Button>
</div> </p>
); );
return ( return (
<div> <>
<h3 className="font-bold">{t('Funds unlocked')}</h3> <ToastHeading>{t('Funds unlocked')}</ToastHeading>
<p>{t('Your funds have been unlocked for withdrawal')}</p> <p>{t('Your funds have been unlocked for withdrawal')}</p>
{tx.txHash && ( {tx.txHash && (
<p className="break-all"> <p className="break-all">
@ -489,7 +469,32 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
)} )}
<VegaTransactionDetails tx={tx} /> <VegaTransactionDetails tx={tx} />
{completeWithdrawalButton} {completeWithdrawalButton}
</div> </>
);
}
if (tx.order && tx.order.rejectionReason) {
return (
<>
<ToastHeading>{t('Order rejected')}</ToastHeading>
<p>
{t(
'Your order has been rejected because: %s',
getRejectionReason(tx.order) || ''
)}
</p>
{tx.txHash && (
<p className="break-all">
<ExternalLink
href={explorerLink(EXPLORER_TX.replace(':hash', tx.txHash))}
rel="noreferrer"
>
{t('View in block explorer')}
</ExternalLink>
</p>
)}
<VegaTransactionDetails tx={tx} />
</>
); );
} }
@ -524,8 +529,8 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
} }
return ( return (
<div> <>
<h3 className="font-bold">{t('Confirmed')}</h3> <ToastHeading>{t('Confirmed')}</ToastHeading>
<p>{t('Your transaction has been confirmed ')}</p> <p>{t('Your transaction has been confirmed ')}</p>
{tx.txHash && ( {tx.txHash && (
<p className="break-all"> <p className="break-all">
@ -538,7 +543,7 @@ const VegaTxCompleteToastsContent = ({ tx }: VegaTxToastContentProps) => {
</p> </p>
)} )}
<VegaTransactionDetails tx={tx} /> <VegaTransactionDetails tx={tx} />
</div> </>
); );
}; };
@ -561,7 +566,10 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
walletNoConnectionCodes.includes(tx.error.code); walletNoConnectionCodes.includes(tx.error.code);
if (orderRejection) { if (orderRejection) {
label = t('Order rejected'); label = t('Order rejected');
errorMessage = orderRejection; errorMessage = t(
'Your order has been rejected because: %s',
orderRejection
);
} }
if (walletError) { if (walletError) {
label = t('Wallet disconnected'); label = t('Wallet disconnected');
@ -569,30 +577,49 @@ const VegaTxErrorToastContent = ({ tx }: VegaTxToastContentProps) => {
} }
return ( return (
<div> <>
<h3 className="font-bold">{label}</h3> <ToastHeading>{label}</ToastHeading>
<p>{errorMessage}</p> <p className="first-letter:uppercase">{errorMessage}</p>
{walletError && ( {walletError && (
<Button size="xs" onClick={reconnectVegaWallet}> <Button size="xs" onClick={reconnectVegaWallet}>
{t('Connect vega wallet')} {t('Connect vega wallet')}
</Button> </Button>
)} )}
<VegaTransactionDetails tx={tx} /> <VegaTransactionDetails tx={tx} />
</div> </>
); );
}; };
const isFinal = (tx: VegaStoredTxState) =>
[VegaTxStatus.Error, VegaTxStatus.Complete].includes(tx.status);
export const useVegaTransactionToasts = () => { export const useVegaTransactionToasts = () => {
const vegaTransactions = useVegaTransactionStore((state) => const [setToast, removeToast] = useToasts((store) => [
state.transactions.filter((transaction) => transaction?.dialogOpen) store.setToast,
); store.remove,
const dismissVegaTransaction = useVegaTransactionStore( ]);
(state) => state.dismiss
const [dismissTx, deleteTx] = useVegaTransactionStore((state) => [
state.dismiss,
state.delete,
]);
const onClose = useCallback(
(tx: VegaStoredTxState) => () => {
const safeToDelete = isFinal(tx);
if (safeToDelete) {
deleteTx(tx.id);
} else {
dismissTx(tx.id);
}
removeToast(`vega-${tx.id}`);
},
[deleteTx, dismissTx, removeToast]
); );
const fromVegaTransaction = useCallback( const fromVegaTransaction = (tx: VegaStoredTxState): Toast => {
(tx: VegaStoredTxState): Toast => {
let content: ToastContent; let content: ToastContent;
const closeAfter = isFinal(tx) ? CLOSE_AFTER : undefined;
if (tx.status === VegaTxStatus.Requested) { if (tx.status === VegaTxStatus.Requested) {
content = <VegaTxRequestedToastContent tx={tx} />; content = <VegaTxRequestedToastContent tx={tx} />;
} }
@ -605,24 +632,32 @@ export const useVegaTransactionToasts = () => {
if (tx.status === VegaTxStatus.Error) { if (tx.status === VegaTxStatus.Error) {
content = <VegaTxErrorToastContent tx={tx} />; content = <VegaTxErrorToastContent tx={tx} />;
} }
// Transaction can be successful but the order can be rejected by the network
const intent =
tx.order && [OrderStatus.STATUS_REJECTED].includes(tx.order.status)
? Intent.Danger
: intentMap[tx.status];
return { return {
id: `vega-${tx.id}`, id: `vega-${tx.id}`,
intent: getIntent(tx), intent,
onClose: () => dismissVegaTransaction(tx.id), onClose: onClose(tx),
loader: tx.status === VegaTxStatus.Pending, loader: tx.status === VegaTxStatus.Pending,
content, content,
closeAfter,
}; };
}, };
[dismissVegaTransaction]
useVegaTransactionStore.subscribe(
(state) =>
compact(
state.transactions.filter(
(tx) => tx?.dialogOpen && isTransactionTypeSupported(tx)
)
),
(txs) => {
txs.forEach((tx) => setToast(fromVegaTransaction(tx)));
}
); );
const toasts = useMemo(() => {
return [
...compact(vegaTransactions)
.filter((tx) => isTransactionTypeSupported(tx))
.map(fromVegaTransaction),
];
}, [fromVegaTransaction, vegaTransactions]);
return toasts;
}; };

View File

@ -1,35 +1,16 @@
import { ToastsContainer } from '@vegaprotocol/ui-toolkit'; import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import { useMemo } from 'react';
import sortBy from 'lodash/sortBy';
import { useUpdateNetworkParametersToasts } from '@vegaprotocol/governance'; import { useUpdateNetworkParametersToasts } from '@vegaprotocol/governance';
import { useVegaTransactionToasts } from '../lib/hooks/use-vega-transaction-toasts'; import { useVegaTransactionToasts } from '../lib/hooks/use-vega-transaction-toasts';
import { useEthereumTransactionToasts } from '../lib/hooks/use-ethereum-transaction-toasts'; import { useEthereumTransactionToasts } from '../lib/hooks/use-ethereum-transaction-toasts';
import { useEthereumWithdrawApprovalsToasts } from '../lib/hooks/use-ethereum-withdraw-approval-toasts'; import { useEthereumWithdrawApprovalsToasts } from '../lib/hooks/use-ethereum-withdraw-approval-toasts';
export const ToastsManager = () => { export const ToastsManager = () => {
const updateNetworkParametersToasts = useUpdateNetworkParametersToasts(); useUpdateNetworkParametersToasts();
const vegaTransactionToasts = useVegaTransactionToasts(); useVegaTransactionToasts();
const ethTransactionToasts = useEthereumTransactionToasts(); useEthereumTransactionToasts();
const withdrawApprovalToasts = useEthereumWithdrawApprovalsToasts(); useEthereumWithdrawApprovalsToasts();
const toasts = useMemo(() => {
return sortBy(
[
...vegaTransactionToasts,
...ethTransactionToasts,
...withdrawApprovalToasts,
...updateNetworkParametersToasts,
],
['createdBy']
);
}, [
vegaTransactionToasts,
ethTransactionToasts,
withdrawApprovalToasts,
updateNetworkParametersToasts,
]);
const toasts = useToasts((store) => store.toasts);
return <ToastsContainer order="desc" toasts={toasts} />; return <ToastsContainer order="desc" toasts={toasts} />;
}; };

View File

@ -4,6 +4,7 @@ import type { UpdateNetworkParameter } from '@vegaprotocol/types';
import { ProposalStateMapping } from '@vegaprotocol/types'; import { ProposalStateMapping } from '@vegaprotocol/types';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
import type { Toast } from '@vegaprotocol/ui-toolkit'; import type { Toast } from '@vegaprotocol/ui-toolkit';
import { ToastHeading } from '@vegaprotocol/ui-toolkit';
import { useToasts } from '@vegaprotocol/ui-toolkit'; import { useToasts } from '@vegaprotocol/ui-toolkit';
import { ExternalLink, Intent } from '@vegaprotocol/ui-toolkit'; import { ExternalLink, Intent } from '@vegaprotocol/ui-toolkit';
import compact from 'lodash/compact'; import compact from 'lodash/compact';
@ -28,7 +29,7 @@ const UpdateNetworkParameterToastContent = ({
const enactment = Date.parse(proposal.terms.enactmentDatetime); const enactment = Date.parse(proposal.terms.enactmentDatetime);
return ( return (
<div> <div>
<h3 className="font-bold">{title}</h3> <ToastHeading>{title}</ToastHeading>
<p className="italic"> <p className="italic">
' '
{t( {t(
@ -52,9 +53,8 @@ const UpdateNetworkParameterToastContent = ({
); );
}; };
export const useUpdateNetworkParametersToasts = (): Toast[] => { export const useUpdateNetworkParametersToasts = () => {
const { proposalToasts, setToast, remove } = useToasts((store) => ({ const { setToast, remove } = useToasts((store) => ({
proposalToasts: store.toasts,
setToast: store.setToast, setToast: store.setToast,
remove: store.remove, remove: store.remove,
})); }));
@ -66,7 +66,9 @@ export const useUpdateNetworkParametersToasts = (): Toast[] => {
id: `update-network-param-proposal-${proposal.id}`, id: `update-network-param-proposal-${proposal.id}`,
intent: Intent.Warning, intent: Intent.Warning,
content: <UpdateNetworkParameterToastContent proposal={proposal} />, content: <UpdateNetworkParameterToastContent proposal={proposal} />,
onClose: () => remove(id), onClose: () => {
remove(id);
},
closeAfter: CLOSE_AFTER, closeAfter: CLOSE_AFTER,
}; };
}, },
@ -96,6 +98,4 @@ export const useUpdateNetworkParametersToasts = (): Toast[] => {
} }
}, },
}); });
return proposalToasts;
}; };

View File

@ -1,6 +1,6 @@
import type { MockedResponse } from '@apollo/client/testing'; import type { MockedResponse } from '@apollo/client/testing';
import { MockedProvider } from '@apollo/client/testing'; import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { ProposalState } from '@vegaprotocol/types'; import { ProposalState } from '@vegaprotocol/types';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useUpdateNetworkParametersToasts } from './use-update-network-paramaters-toasts'; import { useUpdateNetworkParametersToasts } from './use-update-network-paramaters-toasts';
@ -9,8 +9,8 @@ import type {
OnUpdateNetworkParametersSubscription, OnUpdateNetworkParametersSubscription,
} from './__generated__/Proposal'; } from './__generated__/Proposal';
import { OnUpdateNetworkParametersDocument } from './__generated__/Proposal'; import { OnUpdateNetworkParametersDocument } from './__generated__/Proposal';
import waitForNextTick from 'flush-promises';
import { useToasts } from '@vegaprotocol/ui-toolkit'; import { useToasts } from '@vegaprotocol/ui-toolkit';
import { waitFor } from '@testing-library/react';
const render = (mocks?: MockedResponse[]) => { const render = (mocks?: MockedResponse[]) => {
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
@ -92,11 +92,10 @@ const mockedEvent: MockedResponse<OnUpdateNetworkParametersSubscription> = {
}, },
}; };
const INITIAL = useToasts.getState();
const clear = () => { const clear = () => {
const { result: clearer } = renderHook(() => useToasts.setState(INITIAL);
useToasts((store) => store.removeAll)
);
act(() => clearer.current());
}; };
describe('useUpdateNetworkParametersToasts', () => { describe('useUpdateNetworkParametersToasts', () => {
@ -104,29 +103,23 @@ describe('useUpdateNetworkParametersToasts', () => {
afterAll(clear); afterAll(clear);
it('returns toast for update network parameters bus event', async () => { it('returns toast for update network parameters bus event', async () => {
const { waitForNextUpdate, result } = render([mockedEvent]); render([mockedEvent]);
await act(async () => { await waitFor(() => {
waitForNextUpdate(); expect(useToasts.getState().count).toBe(1);
await waitForNextTick();
}); });
expect(result.current.length).toBe(1);
}); });
it('does not return toast for empty event', async () => { it('does not return toast for empty event', async () => {
const { waitForNextUpdate, result } = render([mockedEmptyEvent]); render([mockedEmptyEvent]);
await act(async () => { await waitFor(() => {
waitForNextUpdate(); expect(useToasts.getState().count).toBe(0);
await waitForNextTick();
}); });
expect(result.current.length).toBe(0);
}); });
it('does not return toast for wrong event', async () => { it('does not return toast for wrong event', async () => {
const { waitForNextUpdate, result } = render([mockedWrongEvent]); render([mockedWrongEvent]);
await act(async () => { await waitFor(() => {
waitForNextUpdate(); expect(useToasts.getState().count).toBe(0);
await waitForNextTick(); });
});
expect(result.current.length).toBe(0);
}); });
}); });

View File

@ -174,11 +174,16 @@ module.exports = {
'60%': { transform: 'rotate( 0.0deg)' }, '60%': { transform: 'rotate( 0.0deg)' },
'100%': { transform: 'rotate( 0.0deg)' }, '100%': { transform: 'rotate( 0.0deg)' },
}, },
progress: {
from: { width: '0' },
to: { width: '100%' },
},
}, },
animation: { animation: {
rotate: 'rotate 2s linear alternate infinite', rotate: 'rotate 2s linear alternate infinite',
'rotate-back': 'rotate 2s linear reverse infinite', 'rotate-back': 'rotate 2s linear reverse infinite',
wave: 'wave 2s linear infinite', wave: 'wave 2s linear infinite',
progress: 'progress 5s cubic-bezier(.39,.58,.57,1) 1',
}, },
data: { data: {
selected: 'state~="checked"', selected: 'state~="checked"',

View File

@ -11,6 +11,7 @@ interface ProgressBarProps {
export const ProgressBar = ({ className, intent, value }: ProgressBarProps) => { export const ProgressBar = ({ className, intent, value }: ProgressBarProps) => {
return ( return (
<div <div
data-progress-bar
style={{ height: '6px' }} style={{ height: '6px' }}
className={classNames( className={classNames(
'bg-neutral-300 dark:bg-neutral-700 relative', 'bg-neutral-300 dark:bg-neutral-700 relative',
@ -18,6 +19,7 @@ export const ProgressBar = ({ className, intent, value }: ProgressBarProps) => {
)} )}
> >
<div <div
data-progress-bar-value
className={classNames( className={classNames(
'absolute left-0 top-0 bottom-0', 'absolute left-0 top-0 bottom-0',
intent === undefined || intent === Intent.None intent === undefined || intent === Intent.None

View File

@ -1,21 +1,17 @@
.initial { .initial {
top: 20px;
opacity: 0; opacity: 0;
max-height: 0; transition: all 0.3s;
border: 0;
margin-bottom: 0; margin-bottom: 0;
} }
.showing { .showing {
right: 0;
opacity: 1; opacity: 1;
transition: all 0.3s; transition: all 0.3s;
max-height: 100vw; max-height: 100vw;
} }
.expired { .expired {
right: -375px;
opacity: 0; opacity: 0;
transition: all 0.5s;
max-height: 0; max-height: 0;
transition: all 0.75s;
} }

View File

@ -1,7 +1,9 @@
/* eslint-disable jsx-a11y/accessible-emoji */ /* eslint-disable jsx-a11y/accessible-emoji */
import { Toast } from './toast'; import { Panel, Toast, ToastHeading } from './toast';
import type { ComponentStory, ComponentMeta } from '@storybook/react'; import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Intent } from '../../utils/intent'; import { Intent } from '../../utils/intent';
import { ExternalLink } from '../link';
import { ProgressBar } from '../progress-bar';
export default { export default {
title: 'Toast', title: 'Toast',
@ -9,14 +11,7 @@ export default {
} as ComponentMeta<typeof Toast>; } as ComponentMeta<typeof Toast>;
const Template: ComponentStory<typeof Toast> = (args) => { const Template: ComponentStory<typeof Toast> = (args) => {
const toastContent = ( return <Toast {...args} />;
<>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
<p>Eaque exercitationem saepe cupiditate sunt impedit.</p>
<p>I really like 🥪🥪🥪!</p>
</>
);
return <Toast {...args} content={toastContent} />;
}; };
export const Default = Template.bind({}); export const Default = Template.bind({});
@ -24,6 +19,16 @@ Default.args = {
id: 'def', id: 'def',
intent: Intent.None, intent: Intent.None,
state: 'showing', state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
</>
),
onClose: () => undefined,
}; };
export const Primary = Template.bind({}); export const Primary = Template.bind({});
@ -31,6 +36,17 @@ Primary.args = {
id: 'pri', id: 'pri',
intent: Intent.Primary, intent: Intent.Primary,
state: 'showing', state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
}; };
export const Danger = Template.bind({}); export const Danger = Template.bind({});
@ -38,6 +54,17 @@ Danger.args = {
id: 'dan', id: 'dan',
intent: Intent.Danger, intent: Intent.Danger,
state: 'showing', state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
}; };
export const Warning = Template.bind({}); export const Warning = Template.bind({});
@ -45,6 +72,21 @@ Warning.args = {
id: 'war', id: 'war',
intent: Intent.Warning, intent: Intent.Warning,
state: 'showing', state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>
<strong>Deposit 10.00 tUSDX</strong>
<p className="mt-[2px]">Awaiting confirmations (1/3)</p>
<ProgressBar value={33.33} />
</Panel>
</>
),
onClose: () => undefined,
}; };
export const Success = Template.bind({}); export const Success = Template.bind({});
@ -52,4 +94,15 @@ Success.args = {
id: 'suc', id: 'suc',
intent: Intent.Success, intent: Intent.Success,
state: 'showing', state: 'showing',
content: (
<>
<ToastHeading>Optional heading</ToastHeading>
<p>This is a message that can return over multiple lines.</p>
<p>
<ExternalLink>Optional link</ExternalLink>
</p>
<Panel>Lorem ipsum dolor sit amet consectetur adipisicing elit</Panel>
</>
),
onClose: () => undefined,
}; };

View File

@ -3,7 +3,8 @@ import styles from './toast.module.css';
import type { IconName } from '@blueprintjs/icons'; import type { IconName } from '@blueprintjs/icons';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffect } from 'react'; import type { HTMLAttributes, HtmlHTMLAttributes } from 'react';
import { forwardRef, useEffect } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useLayoutEffect } from 'react'; import { useLayoutEffect } from 'react';
import { useRef } from 'react'; import { useRef } from 'react';
@ -33,20 +34,43 @@ const toastIconMapping: { [i in Intent]: IconName } = {
[Intent.None]: IconNames.HELP, [Intent.None]: IconNames.HELP,
[Intent.Primary]: IconNames.INFO_SIGN, [Intent.Primary]: IconNames.INFO_SIGN,
[Intent.Success]: IconNames.TICK_CIRCLE, [Intent.Success]: IconNames.TICK_CIRCLE,
[Intent.Warning]: IconNames.ERROR, [Intent.Warning]: IconNames.WARNING_SIGN,
[Intent.Danger]: IconNames.ERROR, [Intent.Danger]: IconNames.ERROR,
}; };
const getToastAccent = (intent: Intent) => ({ export const CLOSE_DELAY = 500;
// strip export const TICKER = 100;
'bg-gray-200 text-black text-opacity-70': intent === Intent.None, export const CLOSE_AFTER = 5000;
'bg-vega-blue text-white text-opacity-70': intent === Intent.Primary,
'bg-success text-white text-opacity-70': intent === Intent.Success,
'bg-warning text-white text-opacity-70': intent === Intent.Warning,
'bg-vega-pink text-white text-opacity-70': intent === Intent.Danger,
});
export const CLOSE_DELAY = 750; export const Panel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ children, className, ...props }, ref) => {
return (
<div
data-panel
ref={ref}
data-testid="toast-panel"
className={classNames(
'p-2 rounded mt-[10px]',
'font-mono text-[12px] leading-[16px] font-normal',
'[&>h4]:font-bold',
className
)}
{...props}
>
{children}
</div>
);
}
);
export const ToastHeading = forwardRef<
HTMLHeadingElement,
HtmlHTMLAttributes<HTMLHeadingElement>
>(({ children, ...props }, ref) => (
<h3 ref={ref} className="text-sm uppercase mb-1" {...props}>
{children}
</h3>
));
export const Toast = ({ export const Toast = ({
id, id,
@ -59,6 +83,9 @@ export const Toast = ({
loader = false, loader = false,
}: ToastProps) => { }: ToastProps) => {
const toastRef = useRef<HTMLDivElement>(null); const toastRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const ticker = useRef<number>(0);
const lock = useRef<boolean>(false);
const closeToast = useCallback(() => { const closeToast = useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -80,16 +107,21 @@ export const Toast = ({
} }
}); });
return () => cancelAnimationFrame(req); return () => cancelAnimationFrame(req);
}, [id]); }, [id, intent, content]); // DO NOT REMOVE DEPS: intent, content
useEffect(() => { useEffect(() => {
let t: NodeJS.Timeout; const i = setInterval(() => {
if (closeAfter && closeAfter > 0) { if (!closeAfter || closeAfter === 0) return;
t = setTimeout(() => { if (!lock.current) {
closeToast(); ticker.current += 100;
}, closeAfter);
} }
return () => clearTimeout(t); if (ticker.current >= closeAfter) {
closeToast();
}
}, 100);
return () => {
clearInterval(i);
};
}, [closeAfter, closeToast]); }, [closeAfter, closeToast]);
useEffect(() => { useEffect(() => {
@ -98,14 +130,76 @@ export const Toast = ({
} }
}, [closeToast, signal]); }, [closeToast, signal]);
const withProgress = Boolean(closeAfter && closeAfter > 0);
return ( return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<div <div
data-testid="toast" data-testid="toast"
data-toast-id={id} data-toast-id={id}
ref={toastRef} ref={toastRef}
role="dialog"
onMouseLeave={() => {
lock.current = false;
if (progressRef.current) {
progressRef.current.style.animationPlayState = 'running';
}
}}
onMouseEnter={() => {
lock.current = true;
if (progressRef.current) {
progressRef.current.style.animationPlayState = 'paused';
}
}}
className={classNames( className={classNames(
'relative w-[300px] top-0 rounded-md border overflow-hidden mb-2', 'w-[320px] rounded-md overflow-hidden',
'text-black bg-white dark:border-zinc-700', 'shadow-[8px_8px_16px_0_rgba(0,0,0,0.4)]',
'text-black dark:text-white',
'font-alpha liga-0-calt-0 text-[14px] leading-[19px]',
// background
{
'bg-vega-light-100 dark:bg-vega-dark-100 ': intent === Intent.None,
'bg-vega-blue-300 dark:bg-vega-blue-700': intent === Intent.Primary,
'bg-vega-green-300 dark:bg-vega-green-700': intent === Intent.Success,
'bg-vega-orange-300 dark:bg-vega-orange-700':
intent === Intent.Warning,
'bg-vega-pink-300 dark:bg-vega-pink-700': intent === Intent.Danger,
},
// panel's colours
{
'[&_[data-panel]]:bg-vega-light-150 [&_[data-panel]]:dark:bg-vega-dark-150 ':
intent === Intent.None,
'[&_[data-panel]]:bg-vega-blue-350 [&_[data-panel]]:dark:bg-vega-blue-650':
intent === Intent.Primary,
'[&_[data-panel]]:bg-vega-green-350 [&_[data-panel]]:dark:bg-vega-green-650':
intent === Intent.Success,
'[&_[data-panel]]:bg-vega-orange-350 [&_[data-panel]]:dark:bg-vega-orange-650':
intent === Intent.Warning,
'[&_[data-panel]]:bg-vega-pink-350 [&_[data-panel]]:dark:bg-vega-pink-650':
intent === Intent.Danger,
},
// panels's progress bar colours
'[&_[data-progress-bar]]:mt-[10px] [&_[data-progress-bar]]:mb-[4px]',
{
'[&_[data-progress-bar]]:bg-vega-light-200 [&_[data-progress-bar]]:dark:bg-vega-dark-200 ':
intent === Intent.None,
'[&_[data-progress-bar]]:bg-vega-blue-400 [&_[data-progress-bar]]:dark:bg-vega-blue-600':
intent === Intent.Primary,
'[&_[data-progress-bar-value]]:bg-vega-blue-500 [&_[data-progress-bar-value]]:dark:bg-vega-blue-500':
intent === Intent.Primary,
'[&_[data-progress-bar]]:bg-vega-green-400 [&_[data-progress-bar]]:dark:bg-vega-green-600':
intent === Intent.Success,
'[&_[data-progress-bar-value]]:bg-vega-green-600 [&_[data-progress-bar-value]]:dark:bg-vega-green-500':
intent === Intent.Success,
'[&_[data-progress-bar]]:bg-vega-orange-400 [&_[data-progress-bar]]:dark:bg-vega-orange-600':
intent === Intent.Warning,
'[&_[data-progress-bar-value]]:bg-vega-orange-500 [&_[data-progress-bar-value]]:dark:bg-vega-orange-500':
intent === Intent.Warning,
'[&_[data-progress-bar]]:bg-vega-pink-400 [&_[data-progress-bar]]:dark:bg-vega-pink-600':
intent === Intent.Danger,
'[&_[data-progress-bar-value]]:bg-vega-pink-500 [&_[data-progress-bar-value]]:dark:bg-vega-pink-500':
intent === Intent.Danger,
},
{ {
[styles['initial']]: state === 'initial', [styles['initial']]: state === 'initial',
[styles['showing']]: state === 'showing', [styles['showing']]: state === 'showing',
@ -118,26 +212,81 @@ export const Toast = ({
type="button" type="button"
data-testid="toast-close" data-testid="toast-close"
onClick={closeToast} onClick={closeToast}
className="absolute p-2 top-0 right-0" className="absolute p-[8px] top-[3px] right-[3px] z-20"
> >
<Icon name="cross" size={3} className="!block dark:text-white" /> <Icon
name="cross"
size={3}
className="!block dark:text-white !w-[11px] !h-[11px]"
/>
</button> </button>
<div <div
className={classNames(getToastAccent(intent), 'p-2 pt-3 text-center')} data-testid="toast-accent"
className={classNames(
{
// gray
'bg-vega-light-200 dark:bg-vega-dark-200 text-vega-light-400 dark:text-vega-dark-100':
intent === Intent.None,
// blue
'bg-vega-blue-500 text-vega-blue-600': intent === Intent.Primary,
// green
'bg-vega-green-500 text-vega-green-600':
intent === Intent.Success,
// orange
'bg-vega-orange-500 text-vega-orange-600':
intent === Intent.Warning,
// pink
'bg-vega-pink-500 text-vega-pink-600': intent === Intent.Danger,
},
'w-8 p-[9px]',
'flex justify-center'
)}
> >
{loader ? ( {loader ? (
<div className="w-4 h-4"> <div className="w-[15px] h-[15px]">
<Loader size="small" forceTheme="dark" /> <Loader size="small" forceTheme="dark" />
</div> </div>
) : ( ) : (
<Icon name={toastIconMapping[intent]} size={4} className="!block" /> <Icon
name={toastIconMapping[intent]}
size={4}
className="!block !w-[14px] !h-[14px]"
/>
)} )}
</div> </div>
<div <div
className="flex-1 p-2 pr-6 text-sm overflow-auto dark:bg-black dark:text-white" className={classNames(
'relative',
'overflow-auto flex-1 p-4 pr-[40px] [&>p]:mb-[2.5px]'
)}
data-testid="toast-content" data-testid="toast-content"
> >
{content} {content}
{withProgress && (
<div
ref={progressRef}
data-testid="toast-progress-bar"
className={classNames(
{
'bg-vega-light-200 dark:bg-vega-dark-200 ':
intent === Intent.None,
'bg-vega-blue-400 dark:bg-vega-blue-600':
intent === Intent.Primary,
'bg-vega-green-400 dark:bg-vega-green-600':
intent === Intent.Success,
'bg-vega-orange-400 dark:bg-vega-orange-600':
intent === Intent.Warning,
'bg-vega-pink-400 dark:bg-vega-pink-600':
intent === Intent.Danger,
},
'absolute bottom-0 left-0 w-full h-[4px]',
'animate-progress'
)}
style={{
animationDuration: `${closeAfter}ms`,
}}
></div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { act, render, renderHook, screen } from '@testing-library/react'; import { act, render, renderHook, screen } from '@testing-library/react';
import { ToastsContainer, useToasts } from '..'; import { CLOSE_DELAY, ToastsContainer, useToasts } from '..';
import { Intent } from '../../utils/intent'; import { Intent } from '../../utils/intent';
describe('ToastsContainer', () => { describe('ToastsContainer', () => {
@ -108,7 +108,7 @@ describe('ToastsContainer', () => {
) as HTMLButtonElement; ) as HTMLButtonElement;
act(() => { act(() => {
closeBtn.click(); closeBtn.click();
jest.runAllTimers(); jest.advanceTimersByTime(CLOSE_DELAY);
}); });
rerender(<ToastsContainer order="asc" toasts={result.current.toasts} />); rerender(<ToastsContainer order="asc" toasts={result.current.toasts} />);
const toasts = [...screen.queryAllByTestId('toast-content')].map((t) => const toasts = [...screen.queryAllByTestId('toast-content')].map((t) =>

View File

@ -2,6 +2,7 @@
import type { ComponentStory, ComponentMeta } from '@storybook/react'; import type { ComponentStory, ComponentMeta } from '@storybook/react';
import { Intent } from '../../utils/intent'; import { Intent } from '../../utils/intent';
import type { Toast } from './toast'; import type { Toast } from './toast';
import { ToastHeading } from './toast';
import { ToastsContainer } from './toasts-container'; import { ToastsContainer } from './toasts-container';
import random from 'lodash/random'; import random from 'lodash/random';
import sample from 'lodash/sample'; import sample from 'lodash/sample';
@ -59,7 +60,8 @@ const randomWords = [
]; ];
const randomToast = (): Toast => { const randomToast = (): Toast => {
const content = sample(contents); const now = new Date().toISOString();
const content = now + ' ' + sample(contents);
return { return {
id: String(uniqueId('toast_')), id: String(uniqueId('toast_')),
intent: sample<Intent>([ intent: sample<Intent>([
@ -83,7 +85,7 @@ const usePrice = create<PriceStore>((set) => ({
const Template: ComponentStory<typeof ToastsContainer> = (args) => { const Template: ComponentStory<typeof ToastsContainer> = (args) => {
const setPrice = usePrice((state) => state.setPrice); const setPrice = usePrice((state) => state.setPrice);
const { add, close, closeAll, update, remove, toasts } = useToasts( const { add, close, closeAll, update, remove, toasts, setToast } = useToasts(
(state) => ({ (state) => ({
add: state.add, add: state.add,
close: state.close, close: state.close,
@ -91,6 +93,7 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
update: state.update, update: state.update,
remove: state.remove, remove: state.remove,
toasts: state.toasts, toasts: state.toasts,
setToast: state.setToast,
}) })
); );
@ -195,6 +198,26 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
> >
🧽 🧽
</button> </button>
<button
onClick={() => {
const toasts = Object.values(useToasts.getState().toasts);
if (toasts.length > 0) {
const t = toasts[toasts.length - 1];
setToast({
...t,
intent: Intent.Danger,
content: (
<>
<ToastHeading>Error occurred</ToastHeading>
<p>Something went terribly wrong</p>
</>
),
});
}
}}
>
Set first as Error
</button>
<ToastsContainer {...args} toasts={toasts} /> <ToastsContainer {...args} toasts={toasts} />
</div> </div>
); );
@ -202,5 +225,5 @@ const Template: ComponentStory<typeof ToastsContainer> = (args) => {
export const Default = Template.bind({}); export const Default = Template.bind({});
Default.args = { Default.args = {
order: 'asc', order: 'desc',
}; };

View File

@ -1,8 +1,16 @@
import { t, usePrevious } from '@vegaprotocol/react-helpers';
import classNames from 'classnames'; import classNames from 'classnames';
import type { Ref } from 'react';
import { useLayoutEffect, useRef } from 'react';
import { Button } from '../button';
import { Toast } from './toast'; import { Toast } from './toast';
import type { Toasts } from './use-toasts';
import { useToasts } from './use-toasts';
import { Portal } from '@radix-ui/react-portal';
type ToastsContainerProps = { type ToastsContainerProps = {
toasts: Toast[]; toasts: Toasts;
order: 'asc' | 'desc'; order: 'asc' | 'desc';
}; };
@ -10,23 +18,75 @@ export const ToastsContainer = ({
toasts, toasts,
order = 'asc', order = 'asc',
}: ToastsContainerProps) => { }: ToastsContainerProps) => {
const ref = useRef<HTMLDivElement>();
const closeAll = useToasts((store) => store.closeAll);
// Scroll to top for desc, bottom for asc when a toast is added.
const count = usePrevious(Object.keys(toasts).length) || 0;
useLayoutEffect(() => {
const t = setTimeout(
() => {
if (Object.keys(toasts).length > count) {
ref.current?.scrollTo({
top: order === 'desc' ? 0 : ref.current.scrollHeight,
behavior: 'smooth',
});
}
},
300 // need to delay scroll down in order for the toast to appear
);
return () => {
clearTimeout(t);
};
}, [count, order, toasts]);
return ( return (
<ul <Portal
ref={ref as Ref<HTMLDivElement>}
className={classNames( className={classNames(
'absolute top-0 right-0 pt-2 pr-2 max-w-full z-20 max-h-full overflow-x-hidden overflow-y-auto', 'group',
'absolute bottom-0 right-0 z-20 ',
'p-[8px_16px_16px_16px]',
'max-w-full max-h-full overflow-x-hidden overflow-y-auto',
{ {
'flex flex-col-reverse': order === 'desc', hidden: Object.keys(toasts).length === 0,
} }
)} )}
>
<ul
className={classNames('relative mt-[38px]', 'flex flex-col gap-[8px]', {
'flex-col-reverse': order === 'desc',
})}
> >
{toasts && {toasts &&
toasts.map((toast) => { Object.values(toasts).map((toast) => {
return ( return (
<li key={toast.id}> <li key={toast.id}>
<Toast {...toast} /> <Toast {...toast} />
</li> </li>
); );
})} })}
<Button
title={t('Dismiss all toasts')}
size="sm"
fill={true}
className={classNames(
'absolute top-[-38px] right-0 z-20',
'transition-opacity',
'opacity-0 group-hover:opacity-50 hover:!opacity-100',
'text-sm text-black dark:text-white bg-white dark:bg-black hover:!bg-white hover:dark:!bg-black',
{
hidden: Object.keys(toasts).length === 0,
}
)}
onClick={() => {
closeAll();
}}
variant={'default'}
>
{t('Dismiss all')}
</Button>
</ul> </ul>
</Portal>
); );
}; };

View File

@ -0,0 +1,127 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Intent } from '../../utils/intent';
import type { Toast } from './toast';
import { useToasts } from './use-toasts';
const T1: Toast = {
id: 'TEST-1',
intent: Intent.None,
content: undefined,
};
const T2: Toast = {
id: 'TEST-2',
intent: Intent.None,
content: undefined,
};
const T3: Toast = {
id: 'TEST-3',
intent: Intent.None,
content: undefined,
};
const INITIAL = useToasts.getState();
describe('useToasts', () => {
beforeEach(() => {
useToasts.setState(INITIAL, true);
});
afterAll(() => {
useToasts.setState(INITIAL, true);
});
it('adds toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
});
expect(result.current.toasts[T1.id]).toEqual(T1);
expect(result.current.count).toEqual(1);
});
it('removes toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.remove(T1.id);
result.current.remove(T1.id);
result.current.remove(T1.id);
});
expect(result.current.toasts[T1.id]).toBeUndefined();
expect(result.current.count).toEqual(2);
});
it('updates toast', () => {
const { result } = renderHook(() => useToasts());
const data = { content: <p>Burning hot toast</p> };
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.update(T2.id, data);
});
expect(result.current.toasts[T2.id]).toHaveProperty(
'content',
data.content
);
expect(result.current.count).toEqual(3);
});
it('removes all toasts', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.removeAll();
});
expect(result.current.toasts).toEqual({});
expect(result.current.count).toEqual(0);
});
it('sends close signal to toast', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.close(T2.id);
});
expect(result.current.toasts[T2.id]).toHaveProperty('signal', 'close');
});
it('sends close signal to all toasts', () => {
const { result } = renderHook(() => useToasts());
act(() => {
result.current.add(T1);
result.current.add(T2);
result.current.add(T3);
result.current.closeAll();
});
Object.values(result.current.toasts).forEach((t) => {
expect(t).toHaveProperty('signal', 'close');
});
});
it('sets toast (adds or update if exists)', () => {
const { result } = renderHook(() => useToasts());
const data = { content: <p>Burning hot toast</p> };
act(() => {
result.current.setToast(T1);
result.current.setToast(T1);
result.current.setToast(T2);
result.current.setToast(T3);
result.current.setToast(T3);
result.current.setToast({ ...T3, ...data });
});
expect(result.current.toasts[T3.id]).toHaveProperty(
'content',
data.content
);
expect(result.current.count).toEqual(3);
});
});

View File

@ -1,11 +1,20 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { Toast } from './toast'; import type { Toast } from './toast';
import omit from 'lodash/omit';
import isEqual from 'lodash/isEqual';
type ToastsStore = { export type Toasts = Record<string, Toast>;
/**
* A list of active toasts const isUpdateable = (a: Toast, b: Toast) =>
*/ isEqual(omit(a, 'onClose'), omit(b, 'onClose'));
toasts: Toast[];
type State = {
toasts: Toasts;
count: number;
};
type Actions = {
/** /**
* Adds/displays a new toast * Adds/displays a new toast
*/ */
@ -36,44 +45,54 @@ type ToastsStore = {
removeAll: () => void; removeAll: () => void;
}; };
const add = type ToastsStore = State & Actions;
(toast: Toast) =>
(store: ToastsStore): Partial<ToastsStore> => ({
toasts: [...store.toasts, toast],
});
const update = export const useToasts = create(
(id: string, toastData: Partial<Toast>) => immer<ToastsStore>((set, get) => ({
(store: ToastsStore): Partial<ToastsStore> => { toasts: {},
const toasts = [...store.toasts]; count: 0,
const toastIdx = toasts.findIndex((t) => t.id === id); add: (toast) =>
if (toastIdx > -1) toasts[toastIdx] = { ...toasts[toastIdx], ...toastData }; set((state) => {
return { toasts }; state.toasts[toast.id] = toast;
}; ++state.count;
}),
export const useToasts = create<ToastsStore>((set) => ({ update: (id, toastData) =>
toasts: [], set((state) => {
add: (toast) => set(add(toast)), const found = state.toasts[id];
update: (id, toastData) => set(update(id, toastData)), if (found) {
setToast: (toast: Toast) => Object.assign(found, toastData);
set((store) => { }
if (store.toasts.find((t) => t.id === toast.id)) { }),
return update(toast.id, toast)(store); setToast: (toast: Toast) =>
} else { set((state) => {
return add(toast)(store); const found = state.toasts[toast.id];
if (found) {
if (!isUpdateable(found, toast)) {
Object.assign(found, toast);
}
} else {
state.toasts[toast.id] = toast;
++state.count;
}
}),
close: (id) =>
set((state) => {
const found = state.toasts[id];
if (found) {
found.signal = 'close';
} }
}), }),
close: (id) => set(update(id, { signal: 'close' })),
closeAll: () => closeAll: () =>
set((store) => ({ set((state) => {
toasts: [...store.toasts].map((t) => ({ ...t, signal: 'close' })), Object.values(state.toasts).forEach((t) => (t.signal = 'close'));
})), }),
remove: (id) => remove: (id) =>
set((store) => ({ set((state) => {
toasts: [...store.toasts].filter((t) => t.id !== id), if (state.toasts[id]) {
})), delete state.toasts[id];
removeAll: () => --state.count;
set(() => ({ }
toasts: [], }),
})), removeAll: () => set({ toasts: {}, count: 0 }),
})); }))
);

View File

@ -20,6 +20,7 @@ import type {
} from './__generated__/TransactionResult'; } from './__generated__/TransactionResult';
import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval'; import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval';
import { subscribeWithSelector } from 'zustand/middleware';
export interface VegaStoredTxState extends VegaTxState { export interface VegaStoredTxState extends VegaTxState {
id: number; id: number;
createdAt: Date; createdAt: Date;
@ -51,8 +52,8 @@ export interface VegaTransactionStore {
) => void; ) => void;
} }
export const useVegaTransactionStore = create<VegaTransactionStore>( export const useVegaTransactionStore = create(
(set, get) => ({ subscribeWithSelector<VegaTransactionStore>((set, get) => ({
transactions: [] as VegaStoredTxState[], transactions: [] as VegaStoredTxState[],
create: (body: Transaction) => { create: (body: Transaction) => {
const transactions = get().transactions; const transactions = get().transactions;
@ -204,5 +205,5 @@ export const useVegaTransactionStore = create<VegaTransactionStore>(
}) })
); );
}, },
}) }))
); );

View File

@ -9,6 +9,7 @@ import type { DepositBusEventFieldsFragment } from '@vegaprotocol/wallet';
import type { EthTxState } from './use-ethereum-transaction'; import type { EthTxState } from './use-ethereum-transaction';
import { EthTxStatus } from './use-ethereum-transaction'; import { EthTxStatus } from './use-ethereum-transaction';
import { subscribeWithSelector } from 'zustand/middleware';
type Contract = MultisigControl | CollateralBridge | Token | TokenFaucetable; type Contract = MultisigControl | CollateralBridge | Token | TokenFaucetable;
type ContractMethod = type ContractMethod =
@ -54,8 +55,8 @@ export interface EthTransactionStore {
delete: (index: number) => void; delete: (index: number) => void;
} }
export const useEthTransactionStore = create<EthTransactionStore>( export const useEthTransactionStore = create(
(set, get) => ({ subscribeWithSelector<EthTransactionStore>((set, get) => ({
transactions: [] as EthStoredTxState[], transactions: [] as EthStoredTxState[],
create: ( create: (
contract: Contract | null, contract: Contract | null,
@ -139,5 +140,5 @@ export const useEthTransactionStore = create<EthTransactionStore>(
}) })
); );
}, },
}) }))
); );

View File

@ -103,7 +103,7 @@ export const useEthWithdrawApprovalsManager = () => {
update(transaction.id, { update(transaction.id, {
status: ApprovalStatus.Ready, status: ApprovalStatus.Ready,
approval, approval,
dialogOpen: false, dialogOpen: true,
}); });
const signer = provider.getSigner(); const signer = provider.getSigner();
createEthTransaction( createEthTransaction(

View File

@ -5,6 +5,7 @@ import type { WithdrawalBusEventFieldsFragment } from '@vegaprotocol/wallet';
import { useVegaTransactionStore } from '@vegaprotocol/wallet'; import { useVegaTransactionStore } from '@vegaprotocol/wallet';
import type { WithdrawalApprovalQuery } from '@vegaprotocol/wallet'; import type { WithdrawalApprovalQuery } from '@vegaprotocol/wallet';
import { subscribeWithSelector } from 'zustand/middleware';
export enum ApprovalStatus { export enum ApprovalStatus {
Idle = 'Idle', Idle = 'Idle',
@ -45,10 +46,11 @@ export interface EthWithdrawApprovalStore {
> >
) => void; ) => void;
dismiss: (index: number) => void; dismiss: (index: number) => void;
delete: (index: number) => void;
} }
export const useEthWithdrawApprovalsStore = create<EthWithdrawApprovalStore>( export const useEthWithdrawApprovalsStore = create(
(set, get) => ({ subscribeWithSelector<EthWithdrawApprovalStore>((set, get) => ({
transactions: [] as EthWithdrawalApprovalState[], transactions: [] as EthWithdrawalApprovalState[],
create: ( create: (
withdrawal: EthWithdrawalApprovalState['withdrawal'], withdrawal: EthWithdrawalApprovalState['withdrawal'],
@ -107,5 +109,12 @@ export const useEthWithdrawApprovalsStore = create<EthWithdrawApprovalStore>(
}) })
); );
}, },
delete: (index: number) => {
set(
produce((state: EthWithdrawApprovalStore) => {
delete state.transactions[index];
}) })
); );
},
}))
);

View File

@ -229,13 +229,15 @@ export const VerificationStatus = ({ state }: { state: VerifyState }) => {
); );
return ( return (
<> <>
<p className="mb-2"> <p>{t("The amount you're withdrawing has triggered a time delay")}</p>
{t("The amount you're withdrawing has triggered a time delay")}
</p>
<p>{t(`Cannot be completed until ${formattedTime}`)}</p> <p>{t(`Cannot be completed until ${formattedTime}`)}</p>
</> </>
); );
} }
if (state.status === ApprovalStatus.Ready) {
return <p>{t('The withdrawal has been approved.')}</p>;
}
return null; return null;
}; };