From 48ce7978ee4f8b61171b25b242107476541d8f27 Mon Sep 17 00:00:00 2001 From: Art Date: Thu, 6 Oct 2022 17:40:34 +0200 Subject: [PATCH] feat: updated withdrawal tab (849) (#1579) * feat: updated withdrawal tab (849) * added pending table to the token app, added pending completed filtering to the data provider * amended and added unit tests * amended time formats * added units * fixes errors * addressed comments --- apps/token/src/routes/withdrawals/index.tsx | 9 +- .../pages/portfolio/withdrawals-container.tsx | 25 +- .../src/components/ag-grid/index.tsx | 16 + libs/withdraws/src/index.ts | 1 + .../lib/pending-withdrawals-table.spec.tsx | 65 ++++ .../src/lib/pending-withdrawals-table.tsx | 201 ++++++++++++ libs/withdraws/src/lib/use-withdrawals.ts | 22 ++ .../src/lib/withdrawals-table.spec.tsx | 75 ++--- libs/withdraws/src/lib/withdrawals-table.tsx | 292 ++++++------------ 9 files changed, 450 insertions(+), 256 deletions(-) create mode 100644 libs/withdraws/src/lib/pending-withdrawals-table.spec.tsx create mode 100644 libs/withdraws/src/lib/pending-withdrawals-table.tsx diff --git a/apps/token/src/routes/withdrawals/index.tsx b/apps/token/src/routes/withdrawals/index.tsx index 486fc7fa3..9fe4e9ae9 100644 --- a/apps/token/src/routes/withdrawals/index.tsx +++ b/apps/token/src/routes/withdrawals/index.tsx @@ -5,6 +5,7 @@ import { Heading } from '../../components/heading'; import { SplashLoader } from '../../components/splash-loader'; import { VegaWalletContainer } from '../../components/vega-wallet-container'; import { + PendingWithdrawalsTable, useWithdrawals, WithdrawalDialogs, WithdrawalsTable, @@ -30,7 +31,7 @@ const Withdrawals = ({ name }: RouteChildProps) => { const WithdrawPendingContainer = () => { const [withdrawDialog, setWithdrawDialog] = useState(false); const { t } = useTranslation(); - const { withdrawals, loading, error } = useWithdrawals(); + const { pending, completed, loading, error } = useWithdrawals(); if (error) { return ( @@ -58,7 +59,11 @@ const WithdrawPendingContainer = () => {

{t('withdrawalsText')}

{t('withdrawalsPreparedWarningText')}

- + {pending && pending.length > 0 && ( + + )} +

{t('Withdrawal history')}

+
{ - const { withdrawals, loading, error } = useWithdrawals(); + const { pending, completed, loading, error } = useWithdrawals(); const [withdrawDialog, setWithdrawDialog] = useState(false); return ( @@ -25,17 +26,27 @@ export const WithdrawalsContainer = () => { onClick={() => setWithdrawDialog(true)} data-testid="withdraw-dialog-button" > - {t('Withdraw')} + {t('Make withdrawal')} -
+
{ - return ; - }} + render={({ pending, completed }) => ( + <> + {pending && pending.length > 0 && ( + <> +

{t('Pending withdrawals')}

+ + + )} + +

{t('Withdrawal history')}

+ + + )} />
diff --git a/libs/ui-toolkit/src/components/ag-grid/index.tsx b/libs/ui-toolkit/src/components/ag-grid/index.tsx index 19da00e74..9db7ee4d9 100644 --- a/libs/ui-toolkit/src/components/ag-grid/index.tsx +++ b/libs/ui-toolkit/src/components/ag-grid/index.tsx @@ -4,6 +4,9 @@ import type { ValueFormatterParams, } from 'ag-grid-community'; +import type { IDatasource, IGetRowsParams } from 'ag-grid-community'; +import type { AgGridReactProps } from 'ag-grid-react'; + export * from './ag-grid-lazy'; export * from './ag-grid-dynamic'; @@ -27,3 +30,16 @@ export type VegaICellRendererParams< TRow, TField extends Field = string > = RowHelper; + +export interface GetRowsParams extends IGetRowsParams { + successCallback(rowsThisBlock: T[], lastRow?: number): void; +} + +export interface Datasource extends IDatasource { + getRows(params: GetRowsParams): void; +} + +export interface TypedDataAgGrid extends AgGridReactProps { + rowData?: T[] | null; + datasource?: Datasource; +} diff --git a/libs/withdraws/src/index.ts b/libs/withdraws/src/index.ts index 329c09dd4..eb989a97b 100644 --- a/libs/withdraws/src/index.ts +++ b/libs/withdraws/src/index.ts @@ -3,6 +3,7 @@ export * from './lib/withdraw-form'; export * from './lib/withdraw-form-container'; export * from './lib/withdraw-manager'; export * from './lib/withdrawals-table'; +export * from './lib/pending-withdrawals-table'; export * from './lib/withdrawal-feedback'; export * from './lib/use-complete-withdraw'; export * from './lib/use-create-withdraw'; diff --git a/libs/withdraws/src/lib/pending-withdrawals-table.spec.tsx b/libs/withdraws/src/lib/pending-withdrawals-table.spec.tsx new file mode 100644 index 000000000..3c6f4ef41 --- /dev/null +++ b/libs/withdraws/src/lib/pending-withdrawals-table.spec.tsx @@ -0,0 +1,65 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { generateWithdrawal } from './test-helpers'; +import { CompleteCell } from './pending-withdrawals-table'; +import { PendingWithdrawalsTable } from './pending-withdrawals-table'; +import { getTimeFormat } from '@vegaprotocol/react-helpers'; +import type { TypedDataAgGrid } from '@vegaprotocol/ui-toolkit'; +import type { WithdrawalFields } from './__generated__/WithdrawalFields'; + +jest.mock('@web3-react/core', () => ({ + useWeb3React: () => ({ provider: undefined }), +})); + +const generateTable = (props: TypedDataAgGrid) => ( + + + +); + +describe('PendingWithdrawalsTable', () => { + it('displays correct columns', async () => { + const withdrawal = generateWithdrawal(); + await act(async () => { + render(generateTable({ rowData: [withdrawal] })); + }); + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(5); + expect(headers.map((h) => h.textContent?.trim())).toEqual([ + 'Asset', + 'Amount', + 'Recipient', + 'Created', + '', + ]); + }); + it('displays given withdrawals', async () => { + const withdrawal = generateWithdrawal(); + await act(async () => { + render(generateTable({ rowData: [withdrawal] })); + }); + const cells = screen.getAllByRole('gridcell'); + const expectedValues = [ + 'asset-symbol', + '1.00', + '123456…123456', + getTimeFormat().format(new Date(withdrawal.createdTimestamp)), + 'Complete withdrawal', + ]; + cells.forEach((cell, i) => { + expect(cell).toHaveTextContent(expectedValues[i]); + }); + }); +}); + +describe('CompleteCell', () => { + const mockComplete = jest.fn(); + const data = generateWithdrawal(); + it('opens the dialog', () => { + render(); + fireEvent.click( + screen.getByText('Complete withdrawal', { selector: 'button' }) + ); + expect(mockComplete).toBeCalled(); + }); +}); diff --git a/libs/withdraws/src/lib/pending-withdrawals-table.tsx b/libs/withdraws/src/lib/pending-withdrawals-table.tsx new file mode 100644 index 000000000..b58163ca9 --- /dev/null +++ b/libs/withdraws/src/lib/pending-withdrawals-table.tsx @@ -0,0 +1,201 @@ +import { AgGridColumn } from 'ag-grid-react'; +import { + getDateTimeFormat, + t, + truncateByChars, + addDecimalsFormatNumber, +} from '@vegaprotocol/react-helpers'; +import type { + TypedDataAgGrid, + VegaICellRendererParams, + VegaValueFormatterParams, +} from '@vegaprotocol/ui-toolkit'; +import { Button } from '@vegaprotocol/ui-toolkit'; +import { + Dialog, + Link, + AgGridDynamic as AgGrid, + Intent, + Loader, + Icon, +} from '@vegaprotocol/ui-toolkit'; +import { useEnvironment } from '@vegaprotocol/environment'; +import { useCompleteWithdraw } from './use-complete-withdraw'; +import type { WithdrawalFields } from './__generated__/WithdrawalFields'; +import type { VerifyState } from './use-verify-withdrawal'; +import { ApprovalStatus, useVerifyWithdrawal } from './use-verify-withdrawal'; + +export const PendingWithdrawalsTable = ( + props: TypedDataAgGrid +) => { + const { ETHERSCAN_URL } = useEnvironment(); + const { + submit, + reset: resetTx, + Dialog: EthereumTransactionDialog, + } = useCompleteWithdraw(); + const { + verify, + state: verifyState, + reset: resetVerification, + } = useVerifyWithdrawal(); + + return ( + <> + + + ) => { + return addDecimalsFormatNumber(value, data.asset.decimals); + }} + /> + & { + ethUrl: string; + }) => ( + + {valueFormatted} + + )} + cellRendererParams={{ ethUrl: ETHERSCAN_URL }} + valueFormatter={({ + value, + }: VegaValueFormatterParams< + WithdrawalFields, + 'details.receiverAddress' + >) => { + if (!value) return '-'; + return truncateByChars(value); + }} + /> + ) => { + return getDateTimeFormat().format(new Date(value)); + }} + /> + { + const verified = await verify(withdrawal); + + if (!verified) { + return; + } + + submit(withdrawal.id); + }, + }} + cellRenderer="CompleteCell" + /> + + { + if (!isOpen) { + resetTx(); + resetVerification(); + } + }} + open={verifyState.dialogOpen} + size="small" + {...getVerifyDialogProps(verifyState.status)} + > + + + + + ); +}; + +export type CompleteCellProps = { + data: WithdrawalFields; + complete: (withdrawal: WithdrawalFields) => void; +}; +export const CompleteCell = ({ data, complete }: CompleteCellProps) => ( + +); + +const getVerifyDialogProps = (status: ApprovalStatus) => { + if (status === ApprovalStatus.Error) { + return { + intent: Intent.Danger, + icon: , + }; + } + + if (status === ApprovalStatus.Pending) { + return { intent: Intent.None, icon: }; + } + + if (status === ApprovalStatus.Delayed) { + return { intent: Intent.Warning, icon: }; + } + + return { intent: Intent.None }; +}; + +const VerificationStatus = ({ state }: { state: VerifyState }) => { + if (state.status === ApprovalStatus.Error) { + return

{t('Something went wrong')}

; + } + + if (state.status === ApprovalStatus.Pending) { + return

{t('Verifying...')}

; + } + + if (state.status === ApprovalStatus.Delayed && state.completeTimestamp) { + const formattedTime = getDateTimeFormat().format( + new Date(state.completeTimestamp) + ); + return ( + <> +

+ {t("The amount you're withdrawing has triggered a time delay")} +

+

{t(`Cannot be completed until ${formattedTime}`)}

+ + ); + } + + return null; +}; diff --git a/libs/withdraws/src/lib/use-withdrawals.ts b/libs/withdraws/src/lib/use-withdrawals.ts index 18c6fbebf..98017daf4 100644 --- a/libs/withdraws/src/lib/use-withdrawals.ts +++ b/libs/withdraws/src/lib/use-withdrawals.ts @@ -111,11 +111,33 @@ export const useWithdrawals = () => { ); }, [data]); + /** + * withdrawals that have to be completed by a user. + */ + const pending = useMemo(() => { + return withdrawals.filter((w) => !w.txHash); + }, [withdrawals]); + + /** + * withdrawals that are completed or being completed + */ + const completed = useMemo(() => { + return withdrawals + .filter((w) => w.txHash) + .sort((a, b) => + (b.withdrawnTimestamp || b.createdTimestamp).localeCompare( + a.withdrawnTimestamp || a.createdTimestamp + ) + ); + }, [withdrawals]); + return { data, loading, error, withdrawals, + pending, + completed, }; }; diff --git a/libs/withdraws/src/lib/withdrawals-table.spec.tsx b/libs/withdraws/src/lib/withdrawals-table.spec.tsx index 69cf3496f..3e80f038b 100644 --- a/libs/withdraws/src/lib/withdrawals-table.spec.tsx +++ b/libs/withdraws/src/lib/withdrawals-table.spec.tsx @@ -1,24 +1,18 @@ import { MockedProvider } from '@apollo/client/testing'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { - addDecimalsFormatNumber, - getDateTimeFormat, -} from '@vegaprotocol/react-helpers'; -import { WithdrawalStatus, WithdrawalStatusMapping } from '@vegaprotocol/types'; +import { act, render, screen } from '@testing-library/react'; +import { getTimeFormat } from '@vegaprotocol/react-helpers'; +import { WithdrawalStatus } from '@vegaprotocol/types'; +import type { TypedDataAgGrid } from '@vegaprotocol/ui-toolkit'; import { generateWithdrawal } from './test-helpers'; -import type { - StatusCellProps, - WithdrawalsTableProps, -} from './withdrawals-table'; import { StatusCell } from './withdrawals-table'; import { WithdrawalsTable } from './withdrawals-table'; -import type { Withdrawals_party_withdrawalsConnection_edges_node } from './__generated__/Withdrawals'; +import type { WithdrawalFields } from './__generated__/WithdrawalFields'; jest.mock('@web3-react/core', () => ({ useWeb3React: () => ({ provider: undefined }), })); -const generateJsx = (props: WithdrawalsTableProps) => ( +const generateJsx = (props: TypedDataAgGrid) => ( @@ -28,7 +22,7 @@ describe('renders the correct columns', () => { it('incomplete withdrawal', async () => { const withdrawal = generateWithdrawal(); await act(async () => { - render(generateJsx({ withdrawals: [withdrawal] })); + render(generateJsx({ rowData: [withdrawal] })); }); const headers = screen.getAllByRole('columnheader'); @@ -37,19 +31,19 @@ describe('renders the correct columns', () => { 'Asset', 'Amount', 'Recipient', - 'Created at', - 'TX hash', + 'Completed', 'Status', + 'Transaction', ]); const cells = screen.getAllByRole('gridcell'); const expectedValues = [ 'asset-symbol', - addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals), - '123456\u2026123456', - getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)), + '1.00', + '123456…123456', + '-', + 'Pending', '-', - WithdrawalStatusMapping[withdrawal.status], ]; cells.forEach((cell, i) => { expect(cell).toHaveTextContent(expectedValues[i]); @@ -59,21 +53,22 @@ describe('renders the correct columns', () => { it('completed withdrawal', async () => { const withdrawal = generateWithdrawal({ txHash: '0x1234567891011121314', + withdrawnTimestamp: '2022-04-21T00:00:00', status: WithdrawalStatus.STATUS_FINALIZED, }); await act(async () => { - render(generateJsx({ withdrawals: [withdrawal] })); + render(generateJsx({ rowData: [withdrawal] })); }); const cells = screen.getAllByRole('gridcell'); const expectedValues = [ 'asset-symbol', - addDecimalsFormatNumber(withdrawal.amount, withdrawal.asset.decimals), + '1.00', '123456…123456', - getDateTimeFormat().format(new Date(withdrawal.createdTimestamp)), + getTimeFormat().format(new Date(withdrawal.withdrawnTimestamp as string)), + 'Completed', '0x1234…121314', - WithdrawalStatusMapping[withdrawal.status], ]; cells.forEach((cell, i) => { expect(cell).toHaveTextContent(expectedValues[i]); @@ -82,51 +77,47 @@ describe('renders the correct columns', () => { }); describe('StatusCell', () => { - let props: StatusCellProps; - let withdrawal: Withdrawals_party_withdrawalsConnection_edges_node; - let mockComplete: jest.Mock; + let props: { data: WithdrawalFields }; + let withdrawal: WithdrawalFields; beforeEach(() => { withdrawal = generateWithdrawal(); - mockComplete = jest.fn(); - // @ts-ignore dont need full ICellRendererParams props = { - value: withdrawal.status, data: withdrawal, - complete: mockComplete, }; }); it('Open', () => { - props.value = WithdrawalStatus.STATUS_FINALIZED; props.data.pendingOnForeignChain = false; props.data.txHash = null; render(); - expect(screen.getByText('Open')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Complete', { selector: 'button' })); - expect(mockComplete).toHaveBeenCalled(); + expect(screen.getByText('Pending')).toBeInTheDocument(); }); it('Pending', () => { - props.value = WithdrawalStatus.STATUS_FINALIZED; props.data.pendingOnForeignChain = true; props.data.txHash = '0x123'; render(); expect(screen.getByText('Pending')).toBeInTheDocument(); - expect(screen.getByText('View on Etherscan')).toHaveAttribute( - 'href', - expect.stringContaining(props.data.txHash) - ); }); - it('Finalized', () => { - props.value = WithdrawalStatus.STATUS_FINALIZED; + it('Completed', () => { props.data.pendingOnForeignChain = false; props.data.txHash = '0x123'; + props.data.status = WithdrawalStatus.STATUS_FINALIZED; render(); - expect(screen.getByText('Finalized')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('Rejected', () => { + props.data.pendingOnForeignChain = false; + props.data.txHash = '0x123'; + props.data.status = WithdrawalStatus.STATUS_REJECTED; + render(); + + expect(screen.getByText('Rejected')).toBeInTheDocument(); }); }); diff --git a/libs/withdraws/src/lib/withdrawals-table.tsx b/libs/withdraws/src/lib/withdrawals-table.tsx index 66994eeff..96e724437 100644 --- a/libs/withdraws/src/lib/withdrawals-table.tsx +++ b/libs/withdraws/src/lib/withdrawals-table.tsx @@ -6,181 +6,108 @@ import { addDecimalsFormatNumber, } from '@vegaprotocol/react-helpers'; import type { + TypedDataAgGrid, VegaICellRendererParams, VegaValueFormatterParams, } from '@vegaprotocol/ui-toolkit'; -import { - Dialog, - Link, - AgGridDynamic as AgGrid, - Intent, - Loader, - Icon, -} from '@vegaprotocol/ui-toolkit'; +import { Link, AgGridDynamic as AgGrid } from '@vegaprotocol/ui-toolkit'; import { useEnvironment } from '@vegaprotocol/environment'; -import { useCompleteWithdraw } from './use-complete-withdraw'; import type { WithdrawalFields } from './__generated__/WithdrawalFields'; -import type { VerifyState } from './use-verify-withdrawal'; -import { ApprovalStatus, useVerifyWithdrawal } from './use-verify-withdrawal'; +import { WithdrawalStatus } from '@vegaprotocol/types'; -export interface WithdrawalsTableProps { - withdrawals: WithdrawalFields[]; -} - -export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => { +export const WithdrawalsTable = (props: TypedDataAgGrid) => { const { ETHERSCAN_URL } = useEnvironment(); - const { - submit, - reset: resetTx, - Dialog: EthereumTransactionDialog, - } = useCompleteWithdraw(); - const { - verify, - state: verifyState, - reset: resetVerification, - } = useVerifyWithdrawal(); return ( - <> - - - ) => { - return addDecimalsFormatNumber(value, data.asset.decimals); - }} - /> - ) => { - if (!value) return '-'; - return truncateByChars(value); - }} - /> - ) => { - return getDateTimeFormat().format(new Date(value)); - }} - /> - ) => { - if (!value) return '-'; - return ( - - {truncateByChars(value)} - - ); - }} - /> - { - const verified = await verify(withdrawal); - - if (!verified) { - return; - } - - submit(withdrawal.id); - }, - ethUrl: ETHERSCAN_URL, - }} - /> - - { - if (!isOpen) { - resetTx(); - resetVerification(); - } + + + ) => { + return addDecimalsFormatNumber(value, data.asset.decimals); }} - open={verifyState.dialogOpen} - size="small" - {...getVerifyDialogProps(verifyState.status)} - > - - - - + /> + ) => { + if (!value) return '-'; + return truncateByChars(value); + }} + /> + ) => { + const ts = data.withdrawnTimestamp; + if (!ts) return '-'; + return getDateTimeFormat().format(new Date(ts)); + }} + /> + + ) => { + if (!value) return '-'; + return ( + + {truncateByChars(value)} + + ); + }} + /> + ); }; -export interface StatusCellProps - extends VegaICellRendererParams { - ethUrl: string; - complete: (withdrawal: WithdrawalFields) => void; -} - -export const StatusCell = ({ ethUrl, data, complete }: StatusCellProps) => { - if (data.pendingOnForeignChain) { - return ( -
- {t('Pending')} - {data.txHash && ( - - {t('View on Etherscan')} - - )} -
- ); +export const StatusCell = ({ data }: { data: WithdrawalFields }) => { + if (data.pendingOnForeignChain || !data.txHash) { + return {t('Pending')}; } - - if (!data.txHash) { - return ( -
- {t('Open')} - -
- ); + if (data.status === WithdrawalStatus.STATUS_FINALIZED) { + return {t('Completed')}; } - - return {t('Finalized')}; + if (data.status === WithdrawalStatus.STATUS_REJECTED) { + return {t('Rejected')}; + } + return {t('Failed')}; }; export interface RecipientCellProps @@ -204,48 +131,3 @@ const RecipientCell = ({ ); }; - -const getVerifyDialogProps = (status: ApprovalStatus) => { - if (status === ApprovalStatus.Error) { - return { - intent: Intent.Danger, - icon: , - }; - } - - if (status === ApprovalStatus.Pending) { - return { intent: Intent.None, icon: }; - } - - if (status === ApprovalStatus.Delayed) { - return { intent: Intent.Warning, icon: }; - } - - return { intent: Intent.None }; -}; - -const VerificationStatus = ({ state }: { state: VerifyState }) => { - if (state.status === ApprovalStatus.Error) { - return

{t('Something went wrong')}

; - } - - if (state.status === ApprovalStatus.Pending) { - return

{t('Verifying...')}

; - } - - if (state.status === ApprovalStatus.Delayed && state.completeTimestamp) { - const formattedTime = getDateTimeFormat().format( - new Date(state.completeTimestamp) - ); - return ( - <> -

- {t("The amount you're withdrawing has triggered a time delay")} -

-

{t(`Cannot be completed until ${formattedTime}`)}

- - ); - } - - return null; -};