chore: re add use vega transaction and dialog

This commit is contained in:
Matthew Russell
2023-08-28 23:16:33 -07:00
parent 627e7b9afb
commit 0f4f7e88c3
9 changed files with 657 additions and 20 deletions
+2
View File
@@ -1,5 +1,6 @@
export * from './connectors';
export * from './context';
export * from './use-vega-transaction';
export * from './use-vega-wallet';
export * from './use-vega-transaction-manager';
export * from './use-vega-transaction-store';
@@ -11,5 +12,6 @@ export * from './provider';
export * from './connect-dialog';
export * from './utils';
export * from './storage';
export * from './types';
export * from './__generated__/TransactionResult';
export * from './__generated__/WithdrawalApproval';
+15
View File
@@ -0,0 +1,15 @@
export enum VegaTxStatus {
Default = 'Default',
Requested = 'Requested',
Pending = 'Pending',
Error = 'Error',
Complete = 'Complete',
}
export interface VegaTxState {
status: VegaTxStatus;
error: Error | null;
txHash: string | null;
signature: string | null;
dialogOpen: boolean;
}
@@ -2,14 +2,12 @@ import { useVegaWallet } from './use-vega-wallet';
import { useEffect, useRef } from 'react';
import { WalletError } from './connectors';
import { ClientErrors } from './connectors';
import {
useVegaTransactionStore,
VegaTxStatus,
} from './use-vega-transaction-store';
import { useVegaTransactionStore } from './use-vega-transaction-store';
import {
WalletClientError,
WalletHttpError,
} from '@vegaprotocol/wallet-client';
import { VegaTxStatus } from './types';
const orderErrorResolve = (err: Error | unknown): Error => {
if (err instanceof WalletClientError || err instanceof WalletError) {
+3 -16
View File
@@ -21,22 +21,8 @@ import type {
import type { WithdrawalApprovalQuery } from './__generated__/WithdrawalApproval';
import { subscribeWithSelector } from 'zustand/middleware';
export enum VegaTxStatus {
Default = 'Default',
Requested = 'Requested',
Pending = 'Pending',
Error = 'Error',
Complete = 'Complete',
}
export interface VegaTxState {
status: VegaTxStatus;
error: Error | null;
txHash: string | null;
signature: string | null;
dialogOpen: boolean;
}
import type { VegaTxState } from './types';
import { VegaTxStatus } from './types';
export interface VegaStoredTxState extends VegaTxState {
id: number;
@@ -48,6 +34,7 @@ export interface VegaStoredTxState extends VegaTxState {
withdrawalApproval?: WithdrawalApprovalQuery['erc20WithdrawalApproval'];
order?: OrderTxUpdateFieldsFragment;
}
export interface VegaTransactionStore {
transactions: (VegaStoredTxState | undefined)[];
create: (tx: Transaction, order?: OrderTxUpdateFieldsFragment) => number;
@@ -0,0 +1,159 @@
import { act, renderHook } from '@testing-library/react';
import type { VegaWalletContextShape } from './context';
import { VegaWalletContext } from './context';
import type { ReactNode } from 'react';
import { initialState, useVegaTransaction } from './use-vega-transaction';
import type { Transaction } from './connectors';
import { WalletError } from './connectors';
import { VegaTxStatus } from './types';
const mockPubKey = '0x123';
const defaultWalletContext = {
pubKey: null,
pubKeys: [],
isReadOnly: false,
sendTx: jest.fn(),
connect: jest.fn(),
disconnect: jest.fn(),
selectPubKey: jest.fn(),
connector: null,
} as unknown as VegaWalletContextShape;
function setup(context?: Partial<VegaWalletContextShape>) {
const wrapper = ({ children }: { children: ReactNode }) => (
<VegaWalletContext.Provider value={{ ...defaultWalletContext, ...context }}>
{children}
</VegaWalletContext.Provider>
);
return renderHook(() => useVegaTransaction(), { wrapper });
}
describe('useVegaTransaction', () => {
it('has the correct default state', () => {
const { result } = setup();
expect(result.current).toEqual({
transaction: initialState,
send: expect.any(Function),
reset: expect.any(Function),
setComplete: expect.any(Function),
setTransaction: expect.any(Function),
Dialog: expect.any(Function),
});
});
it('resets state if sendTx returns null (user rejects)', async () => {
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(null));
const { result } = setup({ sendTx: mockSendTx });
await act(async () => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Default);
});
it('handles a single wallet error', () => {
const error = new WalletError('test error', 1, 'test data');
const mockSendTx = jest.fn(() => {
throw error;
});
const { result } = setup({ sendTx: mockSendTx });
act(() => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toHaveProperty(
'message',
error.message
);
expect(result.current.transaction.error).toHaveProperty('code', 1);
expect(result.current.transaction.error).toHaveProperty('data', error.data);
});
it('handles a single error', () => {
const error = new Error('test error');
const mockSendTx = jest.fn(() => {
throw error;
});
const { result } = setup({ sendTx: mockSendTx });
act(() => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toHaveProperty(
'message',
error.message
);
});
it('handles an unknown error', () => {
const unknownThrow = { foo: 'bar' };
const mockSendTx = jest.fn(() => {
throw unknownThrow;
});
const { result } = setup({ sendTx: mockSendTx });
act(() => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Error);
expect(result.current.transaction.error).toHaveProperty(
'title',
'Something went wrong'
);
expect(result.current.transaction.error).toHaveProperty('code', 105);
expect(result.current.transaction.error).toHaveProperty(
'message',
'Unknown error occurred'
);
expect(result.current.transaction.error).toHaveProperty(
'data',
'Unknown error occurred'
);
});
it('sets txHash and signature to state if successful', async () => {
const successObj = {
transactionHash: '0x123',
signature: 'signature',
};
const mockSendTx = jest.fn().mockReturnValue(Promise.resolve(successObj));
const { result } = setup({ sendTx: mockSendTx });
await act(async () => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toEqual(VegaTxStatus.Pending);
expect(result.current.transaction.txHash).toEqual(
successObj.transactionHash
);
expect(result.current.transaction.signature).toEqual(successObj.signature);
});
it('setComplete sets VegaTxStatus', () => {
const { result } = setup();
act(() => {
result.current.setComplete();
});
expect(result.current.transaction.status).toBe(VegaTxStatus.Complete);
});
it('reset resets transaction status', async () => {
const mockSendTx = jest.fn().mockReturnValue(
Promise.resolve({
transactionHash: '0x123',
signature: 'signature',
})
);
const { result } = setup({ sendTx: mockSendTx });
await act(async () => {
result.current.send(mockPubKey, {} as Transaction);
});
expect(result.current.transaction.status).toBe(VegaTxStatus.Pending);
act(() => {
result.current.reset();
});
expect(result.current.transaction).toEqual(initialState);
});
});
+131
View File
@@ -0,0 +1,131 @@
import type { ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import {
WalletClientError,
WalletHttpError,
} from '@vegaprotocol/wallet-client';
import { useVegaWallet } from './use-vega-wallet';
import type { VegaTransactionContentMap } from './vega-transaction-dialog';
import { VegaTransactionDialog } from './vega-transaction-dialog';
import type { Intent } from '@vegaprotocol/ui-toolkit';
import type { Transaction } from './connectors';
import { WalletError } from './connectors';
import { ClientErrors } from './connectors';
import type { VegaTxState } from './types';
import { VegaTxStatus } from './types';
export interface DialogProps {
intent?: Intent;
title?: string;
icon?: ReactNode;
content?: VegaTransactionContentMap;
}
export const initialState = {
status: VegaTxStatus.Default,
error: null,
txHash: null,
signature: null,
dialogOpen: false,
};
export const orderErrorResolve = (err: Error | unknown): Error => {
if (err instanceof WalletClientError || err instanceof WalletError) {
return err;
} else if (err instanceof WalletHttpError) {
return ClientErrors.UNKNOWN;
} else if (err instanceof TypeError) {
return ClientErrors.NO_SERVICE;
} else if (err instanceof Error) {
return err;
}
return ClientErrors.UNKNOWN;
};
export const useVegaTransaction = () => {
const { sendTx, disconnect } = useVegaWallet();
const [transaction, _setTransaction] = useState<VegaTxState>(initialState);
const setTransaction = useCallback((update: Partial<VegaTxState>) => {
_setTransaction((curr) => ({
...curr,
...update,
}));
}, []);
const reset = useCallback(() => {
setTransaction(initialState);
}, [setTransaction]);
const setComplete = useCallback(() => {
setTransaction({ status: VegaTxStatus.Complete });
}, [setTransaction]);
const send = useCallback(
async (pubKey: string, tx: Transaction) => {
try {
setTransaction({
error: null,
txHash: null,
signature: null,
status: VegaTxStatus.Requested,
dialogOpen: true,
});
const res = await sendTx(pubKey, tx);
if (res === null) {
// User rejected
reset();
return null;
}
if (res.signature && res.transactionHash) {
setTransaction({
status: VegaTxStatus.Pending,
txHash: res.transactionHash,
signature: res.signature,
});
return res;
}
return null;
} catch (err) {
const error = orderErrorResolve(err);
if ((error as WalletError).code === ClientErrors.NO_SERVICE.code) {
disconnect();
}
setTransaction({
error,
status: VegaTxStatus.Error,
});
return null;
}
},
[sendTx, setTransaction, reset, disconnect]
);
const Dialog = useMemo(() => {
return (props: DialogProps) => (
<VegaTransactionDialog
{...props}
isOpen={transaction.dialogOpen}
onChange={(isOpen) => {
if (!isOpen) reset();
setTransaction({ dialogOpen: isOpen });
}}
transaction={transaction}
/>
);
}, [transaction, setTransaction, reset]);
return {
send,
transaction,
reset,
setComplete,
setTransaction,
Dialog,
};
};
@@ -0,0 +1 @@
export * from './vega-transaction-dialog';
@@ -0,0 +1,133 @@
import { render, screen } from '@testing-library/react';
import { WalletError } from '../connectors';
import { VegaTxStatus } from '../use-vega-transaction';
import type { VegaTransactionDialogProps } from './vega-transaction-dialog';
import { VegaTransactionDialog } from './vega-transaction-dialog';
jest.mock('@vegaprotocol/environment', () => ({
useEnvironment: () => ({
VEGA_EXPLORER_URL: 'https://test.explorer.vega.network',
VEGA_ENV: 'TESTNET',
}),
Networks: {
MAINNET: 'MAINNET',
TESTNET: 'TESTNET',
},
}));
describe('VegaTransactionDialog', () => {
let props: VegaTransactionDialogProps;
beforeEach(() => {
props = {
isOpen: true,
onChange: () => false,
transaction: {
dialogOpen: true,
status: VegaTxStatus.Requested,
error: null,
txHash: null,
signature: null,
},
};
});
it('requested', () => {
render(<VegaTransactionDialog {...props} />);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/confirm/i);
expect(screen.getByTestId(VegaTxStatus.Requested)).toHaveTextContent(
/please open your wallet/i
);
expect(screen.getByTestId('testnet-transaction-info')).toHaveTextContent(
/^\[This is TESTNET transaction only\]$/
);
});
it('pending', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: VegaTxStatus.Pending,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/awaiting/i);
expect(screen.getByTestId(VegaTxStatus.Pending)).toHaveTextContent(
/please wait/i
);
testBlockExplorerLink('tx-hash');
});
it('error', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
error: new WalletError('rejected', 1),
status: VegaTxStatus.Error,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/failed/i);
expect(screen.getByTestId(VegaTxStatus.Error)).toHaveTextContent(
/rejected/i
);
});
it('default complete', () => {
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: VegaTxStatus.Complete,
}}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(/complete/i);
expect(screen.getByTestId(VegaTxStatus.Complete)).toHaveTextContent(
/confirmed/i
);
testBlockExplorerLink('tx-hash');
});
it.each(Object.keys(VegaTxStatus))(
'renders custom content for %s',
(status) => {
const title = `${status} title`;
const text = `${status} content`;
const content = {
[status]: <div>{text}</div>,
};
render(
<VegaTransactionDialog
{...props}
transaction={{
...props.transaction,
txHash: 'tx-hash',
status: status as VegaTxStatus,
}}
title={title}
content={content}
/>
);
expect(screen.getByTestId('dialog-title')).toHaveTextContent(title);
expect(screen.getByText(text)).toBeInTheDocument();
}
);
function testBlockExplorerLink(txHash: string) {
expect(screen.getByTestId('tx-block-explorer')).toHaveTextContent(
'View in block explorer'
);
expect(screen.getByTestId('tx-block-explorer')).toHaveAttribute(
'href',
`https://test.explorer.vega.network/txs/0x${txHash}`
);
}
});
@@ -0,0 +1,211 @@
import type { ReactNode } from 'react';
import { t } from '@vegaprotocol/i18n';
import { Dialog, Icon, Intent, Loader } from '@vegaprotocol/ui-toolkit';
import { WalletClientError } from '@vegaprotocol/wallet-client';
import type { VegaTxState } from '../types';
import { VegaTxStatus } from '../types';
import { useVegaWallet } from '../use-vega-wallet';
export type VegaTransactionContentMap = {
[C in VegaTxStatus]?: JSX.Element;
};
export interface VegaTransactionDialogProps {
isOpen: boolean;
transaction: VegaTxState;
onChange?: (isOpen: boolean) => void;
intent?: Intent;
title?: string;
icon?: ReactNode;
content?: VegaTransactionContentMap;
}
export const VegaTransactionDialog = ({
isOpen,
transaction,
onChange,
intent,
title,
icon,
content,
}: VegaTransactionDialogProps) => {
const computedIntent = intent ? intent : getIntent(transaction);
const computedTitle = title ? title : getTitle(transaction);
const computedIcon = icon ? icon : getIcon(transaction);
return (
<Dialog
open={isOpen}
onChange={onChange}
intent={computedIntent}
title={computedTitle}
icon={computedIcon}
size="small"
>
<Content transaction={transaction} content={content} />
</Dialog>
);
};
interface ContentProps {
transaction: VegaTxState;
content?: VegaTransactionContentMap;
}
const Content = ({ transaction, content }: ContentProps) => {
if (!content) {
return <VegaDialog transaction={transaction} />;
}
if (transaction.status === VegaTxStatus.Default && content.Default) {
return content.Default;
}
if (transaction.status === VegaTxStatus.Requested && content.Requested) {
return content.Requested;
}
if (transaction.status === VegaTxStatus.Pending && content.Pending) {
return content.Pending;
}
if (transaction.status === VegaTxStatus.Error && content.Error) {
return content.Error;
}
if (transaction.status === VegaTxStatus.Complete && content.Complete) {
return content.Complete;
}
return <VegaDialog transaction={transaction} />;
};
interface VegaDialogProps {
transaction: VegaTxState;
}
/**
* Default dialog content
*/
export const VegaDialog = ({ transaction }: VegaDialogProps) => {
const { links, network } = useVegaWallet();
let content = null;
if (transaction.status === VegaTxStatus.Requested) {
content = (
<>
<p data-testid={transaction.status}>
{t(
'Please open your wallet application and confirm or reject the transaction'
)}
</p>
{network !== 'MAINNET' && (
<p data-testid="testnet-transaction-info">
{t('[This is %s transaction only]').replace('%s', network)}
</p>
)}
</>
);
}
if (transaction.status === VegaTxStatus.Error) {
let messageText = '';
if (transaction.error instanceof WalletClientError) {
messageText = `${transaction.error.title}: ${transaction.error.message}`;
} else if (transaction.error instanceof Error) {
messageText = transaction.error.message;
}
content = <div data-testid={transaction.status}>{messageText}</div>;
}
if (transaction.status === VegaTxStatus.Pending) {
content = (
<div data-testid={transaction.status}>
<p className="break-all">
{t('Please wait for your transaction to be confirmed')} - &nbsp;
{transaction.txHash && (
<a
className="underline"
data-testid="tx-block-explorer"
href={`${links.explorer}/txs/0x${transaction.txHash}`}
target="_blank"
rel="noreferrer"
>
{t('View in block explorer')}
</a>
)}
</p>
</div>
);
}
if (transaction.status === VegaTxStatus.Complete) {
content = (
<div data-testid={transaction.status}>
<p className="break-all">
{t('Your transaction has been confirmed')} - &nbsp;
{transaction.txHash && (
<a
className="underline"
data-testid="tx-block-explorer"
href={`${links.explorer}/txs/0x${transaction.txHash}`}
target="_blank"
rel="noreferrer"
>
{t('View in block explorer')}
</a>
)}
</p>
</div>
);
}
return <div className="text-sm">{content}</div>;
};
const getIntent = (transaction: VegaTxState) => {
switch (transaction.status) {
case VegaTxStatus.Requested:
return Intent.Warning;
case VegaTxStatus.Pending:
return Intent.None;
case VegaTxStatus.Error:
return Intent.Danger;
case VegaTxStatus.Complete:
return Intent.Success;
default:
return Intent.None;
}
};
const getTitle = (transaction: VegaTxState) => {
switch (transaction.status) {
case VegaTxStatus.Requested:
return t('Confirm transaction in wallet');
case VegaTxStatus.Pending:
return t('Awaiting network confirmation');
case VegaTxStatus.Error:
return t('Transaction failed');
case VegaTxStatus.Complete:
return t('Transaction complete');
default:
return '';
}
};
const getIcon = (transaction: VegaTxState) => {
switch (transaction.status) {
case VegaTxStatus.Requested:
return <Icon name="hand-up" />;
case VegaTxStatus.Pending:
return (
<span className="mt-1">
<Loader size="small" />
</span>
);
case VegaTxStatus.Error:
return <Icon name="warning-sign" />;
case VegaTxStatus.Complete:
return <Icon name="tick" />;
default:
return '';
}
};