Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onStatus #1034

Merged
merged 11 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .changeset/many-ravens-cry.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
"@coinbase/onchainkit": patch
---

-**chore**: Add testing to Transaction Toast. By @cpcramer #1023
-**chore**: Add Connect Wallet tests and refactor to use Vitest. By @cpcramer #1036
-**chore**: Increase Wallet dropdown png size to 18x18. By @cpcramer #1041
- **feat**: introduced `onStatus` listener, to help expose the internal `Transaction`'s component lifecycle. By @zizzamia #1034
- **chore**: increased `Wallet` dropdown png size to 18x18. By @cpcramer #1041
37 changes: 31 additions & 6 deletions src/transaction/components/Transaction.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { Transaction } from './Transaction';
import { TransactionProvider } from './TransactionProvider';

vi.mock('./TransactionProvider', () => ({
TransactionProvider: ({ children }: { children: React.ReactNode }) => (
TransactionProvider: vi.fn(({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
)),
}));

describe('Transaction', () => {
it('renders the children inside the TransactionProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should renders the children inside the TransactionProvider', () => {
render(
<Transaction
address="0x123"
Expand All @@ -23,11 +28,10 @@ describe('Transaction', () => {
<div>Test Child</div>
</Transaction>,
);

expect(screen.getByText('Test Child')).toBeInTheDocument();
});

it('applies the correct className', () => {
it('should applies the correct className', () => {
render(
<Transaction
address="0x123"
Expand All @@ -41,9 +45,30 @@ describe('Transaction', () => {
<div>Test Child</div>
</Transaction>,
);

// Checking if the div has the correct classes applied
const container = screen.getByText('Test Child').parentElement;
expect(container).toHaveClass('test-class');
});

it('should call TransactionProvider', () => {
const onError = vi.fn();
const onStatus = vi.fn();
const onSuccess = vi.fn();
render(
<Transaction
address="0x123"
capabilities={{}}
chainId={123}
className="test-class"
contracts={[]}
onError={onError}
onStatus={onStatus}
onSuccess={onSuccess}
>
<div>Test Child</div>
</Transaction>,
);
// Checking if the TransactionProvider is called
expect(TransactionProvider).toHaveBeenCalledTimes(1);
});
});
2 changes: 2 additions & 0 deletions src/transaction/components/Transaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function Transaction({
children,
contracts,
onError,
onStatus,
onSuccess,
}: TransactionReact) {
return (
Expand All @@ -19,6 +20,7 @@ export function Transaction({
chainId={chainId}
contracts={contracts}
onError={onError}
onStatus={onStatus}
onSuccess={onSuccess}
>
<div className={cn(className, 'flex w-full flex-col gap-2')}>
Expand Down
37 changes: 37 additions & 0 deletions src/transaction/components/TransactionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ vi.mock('../hooks/useWriteContracts', () => ({

const TestComponent = () => {
const context = useTransactionContext();
const handleSetLifeCycleStatus = async () => {
context.setLifeCycleStatus({
statusName: 'error',
statusData: { code: 'code', error: 'error_long_messages' },
});
};
return (
<div data-testid="test-component">
<button type="button" onClick={context.onSubmit}>
Expand All @@ -47,6 +53,9 @@ const TestComponent = () => {
<span data-testid="context-value-isToastVisible">
{`${context.isToastVisible}`}
</span>
<button type="button" onClick={handleSetLifeCycleStatus}>
setLifeCycleStatus.error
</button>
</div>
);
};
Expand Down Expand Up @@ -76,6 +85,34 @@ describe('TransactionProvider', () => {
});
});

it('should emit onError when setLifeCycleStatus is called with error', async () => {
const onErrorMock = vi.fn();
render(
<TransactionProvider address="0x123" contracts={[]} onError={onErrorMock}>
<TestComponent />
</TransactionProvider>,
);
const button = screen.getByText('setLifeCycleStatus.error');
fireEvent.click(button);
expect(onErrorMock).toHaveBeenCalled();
});

it('should emit onStatus when setLifeCycleStatus is called', async () => {
const onStatusMock = vi.fn();
render(
<TransactionProvider
address="0x123"
contracts={[]}
onStatus={onStatusMock}
>
<TestComponent />
</TransactionProvider>,
);
const button = screen.getByText('setLifeCycleStatus.error');
fireEvent.click(button);
expect(onStatusMock).toHaveBeenCalled();
});

it('should update context on handleSubmit', async () => {
const writeContractsAsyncMock = vi.fn();
(useWriteContracts as ReturnType<typeof vi.fn>).mockReturnValue({
Expand Down
41 changes: 33 additions & 8 deletions src/transaction/components/TransactionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useCallsStatus } from '../hooks/useCallsStatus';
import { useWriteContract } from '../hooks/useWriteContract';
import { useWriteContracts } from '../hooks/useWriteContracts';
import type {
LifeCycleStatus,
TransactionContextType,
TransactionProviderReact,
} from '../types';
Expand All @@ -51,43 +52,66 @@ export function TransactionProvider({
children,
contracts,
onError,
onStatus,
onSuccess,
}: TransactionProviderReact) {
// Core Hooks
const account = useAccount();
const config = useConfig();
const [errorMessage, setErrorMessage] = useState('');
const [transactionId, setTransactionId] = useState('');
const [isToastVisible, setIsToastVisible] = useState(false);
const [lifeCycleStatus, setLifeCycleStatus] = useState<LifeCycleStatus>({
statusName: 'init',
statusData: null,
}); // Component lifecycle
const [receiptArray, setReceiptArray] = useState<TransactionReceipt[]>([]);
const [transactionId, setTransactionId] = useState('');
const [transactionHashArray, setTransactionHashArray] = useState<Address[]>(
[],
);
const [receiptArray, setReceiptArray] = useState<TransactionReceipt[]>([]);
const account = useAccount();
const config = useConfig();
const { switchChainAsync } = useSwitchChain();

// Hooks that depend from Core Hooks
const { status: statusWriteContracts, writeContractsAsync } =
useWriteContracts({
onError,
setErrorMessage,
setLifeCycleStatus,
setTransactionId,
});
const {
status: statusWriteContract,
writeContractAsync,
data: writeContractTransactionHash,
} = useWriteContract({
onError,
setErrorMessage,
setLifeCycleStatus,
setTransactionHashArray,
transactionHashArray,
});
const { transactionHash, status: callStatus } = useCallsStatus({
onError,
setLifeCycleStatus,
transactionId,
});

const { data: receipt } = useWaitForTransactionReceipt({
hash: writeContractTransactionHash || transactionHash,
});

// Component lifecycle emitters
useEffect(() => {
// Emit Error
if (lifeCycleStatus.statusName === 'error') {
onError?.(lifeCycleStatus.statusData);
}
// Emit State
onStatus?.(lifeCycleStatus);
}, [
onError,
onStatus,
lifeCycleStatus,
lifeCycleStatus.statusData, // Keep statusData, so that the effect runs when it changes
lifeCycleStatus.statusName, // Keep statusName, so that the effect runs when it changes
]);

const getTransactionReceipts = useCallback(async () => {
const receipts = [];
for (const hash of transactionHashArray) {
Expand Down Expand Up @@ -208,6 +232,7 @@ export function TransactionProvider({
receipt,
setErrorMessage,
setIsToastVisible,
setLifeCycleStatus,
setTransactionId,
statusWriteContracts,
statusWriteContract,
Expand Down
22 changes: 11 additions & 11 deletions src/transaction/hooks/useCallsStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,34 @@ describe('useCallsStatus', () => {
status: 'CONFIRMED',
receipts: [{ transactionHash: '0x123' }],
};

(useCallsStatusWagmi as ReturnType<typeof vi.fn>).mockReturnValue({
data: mockData,
});

const { result } = renderHook(() => useCallsStatus({ transactionId }));

expect(result.current.status).toBe('CONFIRMED');
expect(result.current.transactionHash).toBe('0x123');
});

it('should handle errors and call onError callback', () => {
const mockOnError = vi.fn();
const mockSetLifeCycleStatus = vi.fn();
const mockError = new Error('Test error');

(useCallsStatusWagmi as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw mockError;
});

const { result } = renderHook(() =>
useCallsStatus({ transactionId, onError: mockOnError }),
useCallsStatus({
setLifeCycleStatus: mockSetLifeCycleStatus,
transactionId,
}),
);

expect(result.current.status).toBe('error');
expect(result.current.transactionHash).toBeUndefined();
expect(mockOnError).toHaveBeenCalledWith({
code: 'UNCAUGHT_CALL_STATUS_ERROR',
error: JSON.stringify(mockError),
expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({
statusName: 'error',
statusData: {
code: 'UNCAUGHT_CALL_STATUS_ERROR',
error: JSON.stringify(mockError),
},
});
});
});
13 changes: 7 additions & 6 deletions src/transaction/hooks/useCallsStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@ import type { UseCallsStatusParams } from '../types';
const uncaughtErrorCode = 'UNCAUGHT_CALL_STATUS_ERROR';

export function useCallsStatus({
onError,
setLifeCycleStatus,
transactionId,
}: UseCallsStatusParams) {
try {
const { data } = useCallsStatusWagmi({
id: transactionId,
query: {
refetchInterval: (data) => {
return data.state.data?.status === 'CONFIRMED' ? false : 1000;
refetchInterval: (query) => {
return query.state.data?.status === 'CONFIRMED' ? false : 1000;
},
enabled: !!transactionId,
},
});

const transactionHash = data?.receipts?.[0]?.transactionHash;

return { status: data?.status, transactionHash };
} catch (err) {
onError?.({ code: uncaughtErrorCode, error: JSON.stringify(err) });
setLifeCycleStatus({
statusName: 'error',
statusData: { code: uncaughtErrorCode, error: JSON.stringify(err) },
});
return { status: 'error', transactionHash: undefined };
}
}
10 changes: 0 additions & 10 deletions src/transaction/hooks/useGetTransactionStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,15 @@ describe('useGetTransactionStatus', () => {
(useTransactionContext as vi.Mock).mockReturnValue({
statusWriteContract: 'pending',
});

const { result } = renderHook(() => useGetTransactionStatus());

expect(result.current.label).toBe('Confirm in wallet.');
});

it('should return correct status and actionElement when transaction hash exists', () => {
(useTransactionContext as vi.Mock).mockReturnValue({
transactionHash: 'ab123',
});

const { result } = renderHook(() => useGetTransactionStatus());

expect(result.current.label).toBe('Transaction in progress...');
expect(result.current.actionElement).not.toBeNull();
});
Expand All @@ -42,9 +38,7 @@ describe('useGetTransactionStatus', () => {
receipt: 'receipt',
transactionHash: '123',
});

const { result } = renderHook(() => useGetTransactionStatus());

expect(result.current.label).toBe('Successful!');
expect(result.current.actionElement).not.toBeNull();
});
Expand All @@ -53,9 +47,7 @@ describe('useGetTransactionStatus', () => {
(useTransactionContext as vi.Mock).mockReturnValue({
errorMessage: 'error',
});

const { result } = renderHook(() => useGetTransactionStatus());

expect(result.current.label).toBe('error');
expect(result.current.labelClassName).toBe('text-ock-error');
});
Expand All @@ -64,9 +56,7 @@ describe('useGetTransactionStatus', () => {
(useTransactionContext as vi.Mock).mockReturnValue({
errorMessage: '',
});

const { result } = renderHook(() => useGetTransactionStatus());

expect(result.current.label).toBe('');
expect(result.current.actionElement).toBeNull();
});
Expand Down
Loading