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
This commit is contained in:
Art 2022-10-06 17:40:34 +02:00 committed by GitHub
parent 4ca22c4e98
commit 48ce7978ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 450 additions and 256 deletions

View File

@ -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 = () => {
<p>{t('withdrawalsText')}</p>
<p className="mb-8">{t('withdrawalsPreparedWarningText')}</p>
<div className="w-full h-[500px]">
<WithdrawalsTable withdrawals={withdrawals} />
{pending && pending.length > 0 && (
<PendingWithdrawalsTable rowData={pending} />
)}
<h4 className="pt-3 pb-1">{t('Withdrawal history')}</h4>
<WithdrawalsTable rowData={completed} />
</div>
<WithdrawalDialogs
withdrawDialog={withdrawDialog}

View File

@ -1,5 +1,6 @@
import { AsyncRenderer, Button } from '@vegaprotocol/ui-toolkit';
import {
PendingWithdrawalsTable,
useWithdrawals,
WithdrawalDialogs,
WithdrawalsTable,
@ -10,7 +11,7 @@ import { VegaWalletContainer } from '../../components/vega-wallet-container';
import { Web3Container } from '@vegaprotocol/web3';
export const WithdrawalsContainer = () => {
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')}
</Button>
</header>
<div>
<div className="h-full px-4">
<AsyncRenderer
data={withdrawals}
data={{ pending, completed }}
loading={loading}
error={error}
render={(data) => {
return <WithdrawalsTable withdrawals={data} />;
}}
render={({ pending, completed }) => (
<>
{pending && pending.length > 0 && (
<>
<h4 className="pt-3 pb-1">{t('Pending withdrawals')}</h4>
<PendingWithdrawalsTable rowData={pending} />
</>
)}
<h4 className="pt-3 pb-1">{t('Withdrawal history')}</h4>
<WithdrawalsTable rowData={completed} />
</>
)}
/>
</div>
</div>

View File

@ -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<ICellRendererParams, TRow, TField>;
export interface GetRowsParams<T> extends IGetRowsParams {
successCallback(rowsThisBlock: T[], lastRow?: number): void;
}
export interface Datasource<T> extends IDatasource {
getRows(params: GetRowsParams<T>): void;
}
export interface TypedDataAgGrid<T> extends AgGridReactProps {
rowData?: T[] | null;
datasource?: Datasource<T>;
}

View File

@ -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';

View File

@ -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<WithdrawalFields>) => (
<MockedProvider>
<PendingWithdrawalsTable {...props} />
</MockedProvider>
);
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(<CompleteCell complete={mockComplete} data={data} />);
fireEvent.click(
screen.getByText('Complete withdrawal', { selector: 'button' })
);
expect(mockComplete).toBeCalled();
});
});

View File

@ -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<WithdrawalFields>
) => {
const { ETHERSCAN_URL } = useEnvironment();
const {
submit,
reset: resetTx,
Dialog: EthereumTransactionDialog,
} = useCompleteWithdraw();
const {
verify,
state: verifyState,
reset: resetVerification,
} = useVerifyWithdrawal();
return (
<>
<AgGrid
overlayNoRowsTemplate={t('No withdrawals')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%' }}
components={{ CompleteCell }}
suppressCellFocus={true}
domLayout="autoHeight"
rowHeight={30}
{...props}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
headerName={t('Amount')}
field="amount"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<WithdrawalFields, 'amount'>) => {
return addDecimalsFormatNumber(value, data.asset.decimals);
}}
/>
<AgGridColumn
headerName={t('Recipient')}
field="details.receiverAddress"
cellRenderer={({
ethUrl,
value,
valueFormatted,
}: VegaICellRendererParams<
WithdrawalFields,
'details.receiverAddress'
> & {
ethUrl: string;
}) => (
<Link
title={t('View on Etherscan (opens in a new tab)')}
href={`${ethUrl}/address/${value}`}
data-testid="etherscan-link"
target="_blank"
>
{valueFormatted}
</Link>
)}
cellRendererParams={{ ethUrl: ETHERSCAN_URL }}
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'details.receiverAddress'
>) => {
if (!value) return '-';
return truncateByChars(value);
}}
/>
<AgGridColumn
headerName={t('Created')}
field="createdTimestamp"
valueFormatter={({
value,
}: VegaValueFormatterParams<
WithdrawalFields,
'createdTimestamp'
>) => {
return getDateTimeFormat().format(new Date(value));
}}
/>
<AgGridColumn
headerName=""
field="status"
flex={2}
cellRendererParams={{
complete: async (withdrawal: WithdrawalFields) => {
const verified = await verify(withdrawal);
if (!verified) {
return;
}
submit(withdrawal.id);
},
}}
cellRenderer="CompleteCell"
/>
</AgGrid>
<Dialog
title={t('Withdrawal verification')}
onChange={(isOpen) => {
if (!isOpen) {
resetTx();
resetVerification();
}
}}
open={verifyState.dialogOpen}
size="small"
{...getVerifyDialogProps(verifyState.status)}
>
<VerificationStatus state={verifyState} />
</Dialog>
<EthereumTransactionDialog />
</>
);
};
export type CompleteCellProps = {
data: WithdrawalFields;
complete: (withdrawal: WithdrawalFields) => void;
};
export const CompleteCell = ({ data, complete }: CompleteCellProps) => (
<Button size="xs" onClick={() => complete(data)}>
{t('Complete withdrawal')}
</Button>
);
const getVerifyDialogProps = (status: ApprovalStatus) => {
if (status === ApprovalStatus.Error) {
return {
intent: Intent.Danger,
icon: <Icon name="warning-sign" />,
};
}
if (status === ApprovalStatus.Pending) {
return { intent: Intent.None, icon: <Loader size="small" /> };
}
if (status === ApprovalStatus.Delayed) {
return { intent: Intent.Warning, icon: <Icon name="time" /> };
}
return { intent: Intent.None };
};
const VerificationStatus = ({ state }: { state: VerifyState }) => {
if (state.status === ApprovalStatus.Error) {
return <p>{t('Something went wrong')}</p>;
}
if (state.status === ApprovalStatus.Pending) {
return <p>{t('Verifying...')}</p>;
}
if (state.status === ApprovalStatus.Delayed && state.completeTimestamp) {
const formattedTime = getDateTimeFormat().format(
new Date(state.completeTimestamp)
);
return (
<>
<p className="mb-2">
{t("The amount you're withdrawing has triggered a time delay")}
</p>
<p>{t(`Cannot be completed until ${formattedTime}`)}</p>
</>
);
}
return null;
};

View File

@ -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,
};
};

View File

@ -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<WithdrawalFields>) => (
<MockedProvider>
<WithdrawalsTable {...props} />
</MockedProvider>
@ -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(<StatusCell {...props} />);
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(<StatusCell {...props} />);
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(<StatusCell {...props} />);
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(<StatusCell {...props} />);
expect(screen.getByText('Rejected')).toBeInTheDocument();
});
});

View File

@ -6,49 +6,28 @@ 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<WithdrawalFields>) => {
const { ETHERSCAN_URL } = useEnvironment();
const {
submit,
reset: resetTx,
Dialog: EthereumTransactionDialog,
} = useCompleteWithdraw();
const {
verify,
state: verifyState,
reset: resetVerification,
} = useVerifyWithdrawal();
return (
<>
<AgGrid
rowData={withdrawals}
overlayNoRowsTemplate={t('No withdrawals')}
defaultColDef={{ flex: 1, resizable: true }}
style={{ width: '100%', height: '100%' }}
components={{ StatusCell, RecipientCell }}
style={{ width: '100%' }}
components={{ RecipientCell, StatusCell }}
suppressCellFocus={true}
domLayout="autoHeight"
rowHeight={30}
{...props}
>
<AgGridColumn headerName="Asset" field="asset.symbol" />
<AgGridColumn
@ -77,19 +56,26 @@ export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
}}
/>
<AgGridColumn
headerName={t('Created at')}
field="createdTimestamp"
headerName={t('Completed')}
field="withdrawnTimestamp"
valueFormatter={({
value,
data,
}: VegaValueFormatterParams<
WithdrawalFields,
'createdTimestamp'
'withdrawnTimestamp'
>) => {
return getDateTimeFormat().format(new Date(value));
const ts = data.withdrawnTimestamp;
if (!ts) return '-';
return getDateTimeFormat().format(new Date(ts));
}}
/>
<AgGridColumn
headerName={t('TX hash')}
headerName={t('Status')}
field="status"
cellRenderer="StatusCell"
/>
<AgGridColumn
headerName={t('Transaction')}
field="txHash"
cellRenderer={({
value,
@ -107,80 +93,21 @@ export const WithdrawalsTable = ({ withdrawals }: WithdrawalsTableProps) => {
);
}}
/>
<AgGridColumn
headerName={t('Status')}
field="status"
cellRenderer="StatusCell"
cellRendererParams={{
complete: async (withdrawal: WithdrawalFields) => {
const verified = await verify(withdrawal);
if (!verified) {
return;
}
submit(withdrawal.id);
},
ethUrl: ETHERSCAN_URL,
}}
/>
</AgGrid>
<Dialog
title={t('Withdrawal verification')}
onChange={(isOpen) => {
if (!isOpen) {
resetTx();
resetVerification();
}
}}
open={verifyState.dialogOpen}
size="small"
{...getVerifyDialogProps(verifyState.status)}
>
<VerificationStatus state={verifyState} />
</Dialog>
<EthereumTransactionDialog />
</>
);
};
export interface StatusCellProps
extends VegaICellRendererParams<WithdrawalFields, 'status'> {
ethUrl: string;
complete: (withdrawal: WithdrawalFields) => void;
}
export const StatusCell = ({ ethUrl, data, complete }: StatusCellProps) => {
if (data.pendingOnForeignChain) {
return (
<div className="flex justify-between gap-8">
{t('Pending')}
{data.txHash && (
<Link
title={t('View transaction on Etherscan')}
href={`${ethUrl}/tx/${data.txHash}`}
data-testid="etherscan-link"
target="_blank"
>
{t('View on Etherscan')}
</Link>
)}
</div>
);
export const StatusCell = ({ data }: { data: WithdrawalFields }) => {
if (data.pendingOnForeignChain || !data.txHash) {
return <span>{t('Pending')}</span>;
}
if (!data.txHash) {
return (
<div className="flex justify-between gap-8">
{t('Open')}
<button className="underline" onClick={() => complete(data)}>
{t('Complete')}
</button>
</div>
);
if (data.status === WithdrawalStatus.STATUS_FINALIZED) {
return <span>{t('Completed')}</span>;
}
return <span>{t('Finalized')}</span>;
if (data.status === WithdrawalStatus.STATUS_REJECTED) {
return <span>{t('Rejected')}</span>;
}
return <span>{t('Failed')}</span>;
};
export interface RecipientCellProps
@ -204,48 +131,3 @@ const RecipientCell = ({
</Link>
);
};
const getVerifyDialogProps = (status: ApprovalStatus) => {
if (status === ApprovalStatus.Error) {
return {
intent: Intent.Danger,
icon: <Icon name="warning-sign" />,
};
}
if (status === ApprovalStatus.Pending) {
return { intent: Intent.None, icon: <Loader size="small" /> };
}
if (status === ApprovalStatus.Delayed) {
return { intent: Intent.Warning, icon: <Icon name="time" /> };
}
return { intent: Intent.None };
};
const VerificationStatus = ({ state }: { state: VerifyState }) => {
if (state.status === ApprovalStatus.Error) {
return <p>{t('Something went wrong')}</p>;
}
if (state.status === ApprovalStatus.Pending) {
return <p>{t('Verifying...')}</p>;
}
if (state.status === ApprovalStatus.Delayed && state.completeTimestamp) {
const formattedTime = getDateTimeFormat().format(
new Date(state.completeTimestamp)
);
return (
<>
<p className="mb-2">
{t("The amount you're withdrawing has triggered a time delay")}
</p>
<p>{t(`Cannot be completed until ${formattedTime}`)}</p>
</>
);
}
return null;
};