Skip to content

feat: add custom error and success states to txn button #1460

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

Merged
merged 11 commits into from
Oct 24, 2024
5 changes: 5 additions & 0 deletions .changeset/real-dolphins-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

- **feat**: Added ability to customize error and success states for TransactionButton. By @abcrane123 #1460
102 changes: 102 additions & 0 deletions src/transaction/components/TransactionButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('TransactionButton', () => {
(useShowCallsStatus as Mock).mockReturnValue({
showCallsStatus: vi.fn(),
});
vi.clearAllMocks();
});

it('renders correctly', () => {
Expand Down Expand Up @@ -74,6 +75,70 @@ describe('TransactionButton', () => {
expect(text).toBeInTheDocument();
});

it('renders custom error text when error exists', () => {
const mockErrorFunc = vi.fn();
(useTransactionContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'init', statusData: null },
errorMessage: 'blah blah',
isLoading: false,
address: '123',
transactions: [{}],
});
render(
<TransactionButton
text="Transact"
errorOverride={{
text: 'oops',
onClick: mockErrorFunc,
}}
/>,
);
const text = screen.getByText('oops');
expect(text).toBeInTheDocument();
const button = screen.getByTestId('ockTransactionButton_Button');
fireEvent.click(button);
expect(mockErrorFunc).toHaveBeenCalled();
});

it('should call custom error handler when error exists', () => {
const mockErrorFunc = vi.fn();
(useTransactionContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'init', statusData: null },
errorMessage: 'blah blah',
isLoading: false,
address: '123',
transactions: [{}],
});
render(
<TransactionButton
text="Transact"
errorOverride={{
text: 'oops',
onClick: mockErrorFunc,
}}
/>,
);
const button = screen.getByTestId('ockTransactionButton_Button');
fireEvent.click(button);
expect(mockErrorFunc).toHaveBeenCalled();
});

it('should recall onSubmit when error exists and no custom handler provided', () => {
const mockOnSubmit = vi.fn();
(useTransactionContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'init', statusData: null },
errorMessage: 'blah blah',
isLoading: false,
address: '123',
transactions: [{}],
onSubmit: mockOnSubmit,
});
render(<TransactionButton text="Transact" />);
const button = screen.getByTestId('ockTransactionButton_Button');
fireEvent.click(button);
expect(mockOnSubmit).toHaveBeenCalled();
});

it('should have disabled attribute when isDisabled is true', () => {
const { getByRole } = render(
<TransactionButton disabled={true} text="Submit" />,
Expand Down Expand Up @@ -116,6 +181,43 @@ describe('TransactionButton', () => {
expect(showCallsStatus).toHaveBeenCalledWith({ id: '456' });
});

it('should render custom success text when it exists', () => {
const showCallsStatus = vi.fn();
(useShowCallsStatus as Mock).mockReturnValue({ showCallsStatus });
(useTransactionContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'init', statusData: null },
receipt: '123',
transactionId: '456',
});
render(
<TransactionButton text="Transact" successOverride={{ text: 'yay' }} />,
);
const button = screen.getByText('yay');
fireEvent.click(button);
expect(showCallsStatus).toHaveBeenCalledWith({ id: '456' });
});

it('should call custom success handler when it exists', () => {
const mockSuccessHandler = vi.fn();
(useTransactionContext as Mock).mockReturnValue({
lifecycleStatus: { statusName: 'init', statusData: null },
receipt: '123',
transactionId: '456',
});
render(
<TransactionButton
text="Transact"
successOverride={{
text: 'yay',
onClick: mockSuccessHandler,
}}
/>,
);
const button = screen.getByText('yay');
fireEvent.click(button);
expect(mockSuccessHandler).toHaveBeenCalledWith('123');
});

it('should enable button when not in progress, not missing props, and not waiting for receipt', () => {
(useTransactionContext as Mock).mockReturnValue({
isLoading: false,
Expand Down
76 changes: 53 additions & 23 deletions src/transaction/components/TransactionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export function TransactionButton({
className,
disabled = false,
text: buttonText = 'Transact',
errorOverride,
successOverride,
}: TransactionButtonReact) {
const {
chainId,
Expand Down Expand Up @@ -50,41 +52,69 @@ export function TransactionButton({
transactionId,
});

const { errorText, successText } = useMemo(() => {
const successText = successOverride?.text
? successOverride?.text
: 'View transaction';

const errorText = errorOverride?.text ? errorOverride?.text : 'Try again';

return { successText, errorText };
}, [errorOverride, successOverride]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also feel like this might be cleaner if the top-level prop is not necessarily an override but the state itself, then we can set the default behavior using a default value:

e.g.

success: { text: 'View Transaction', onClick = () => { return window.open(basescan) } };

but more of a nit

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then we can eliminate lines 55-87 (around 30~ LOC)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the issue is the default success state can be either showCallsStatus({ id: transactionId }); or window.open(...) depending on wallet type


const successHandler = useCallback(() => {
if (successOverride?.onClick && receipt) {
return successOverride?.onClick?.(receipt);
}
// SW will have txn id so open in wallet
if (receipt && transactionId) {
return showCallsStatus({ id: transactionId });
}
// EOA will not have txn id so open in explorer
const chainExplorer = getChainExplorer(accountChainId);
return window.open(
`${chainExplorer}/tx/${transactionHash}`,
'_blank',
'noopener,noreferrer',
);
}, [
accountChainId,
successOverride,
showCallsStatus,
transactionId,
transactionHash,
receipt,
]);

const errorHandler = useCallback(() => {
if (errorOverride?.onClick) {
return errorOverride?.onClick?.();
}
// if no custom logic, retry submit
return onSubmit();
}, [errorOverride, onSubmit]);

const buttonContent = useMemo(() => {
// txn successful
if (receipt) {
return 'View transaction';
return successText;
}
if (errorMessage) {
return 'Try again';
return errorText;
}
return buttonText;
}, [buttonText, errorMessage, receipt]);
}, [errorText, buttonText, successText, errorMessage, receipt]);

const handleSubmit = useCallback(() => {
// SW will have txn id so open in wallet
if (receipt && transactionId) {
showCallsStatus({ id: transactionId });
// EOA will not have txn id so open in explorer
} else if (receipt) {
const chainExplorer = getChainExplorer(accountChainId);
window.open(
`${chainExplorer}/tx/${transactionHash}`,
'_blank',
'noopener,noreferrer',
);
if (receipt) {
successHandler();
} else if (errorMessage) {
errorHandler();
// if no receipt or error, submit txn
} else {
// if no receipt, submit txn
onSubmit();
}
}, [
accountChainId,
onSubmit,
receipt,
showCallsStatus,
transactionHash,
transactionId,
]);
}, [errorMessage, errorHandler, onSubmit, receipt, successHandler]);

return (
<button
Expand Down
7 changes: 7 additions & 0 deletions src/transaction/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import type {

export type Call = { to: Hex; data?: Hex; value?: bigint };

type TransactionButtonOverride = {
text?: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are stuck with the text prop name, but any reason not to allow a ReactNode here? this would allow someone to use their own custom loading icon for the pending state, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i agree i'll update type but keep text name for consistency since i don't think it is worth causing a breaking change

onClick?: (receipt?: TransactionReceipt) => void;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define in TransactionButton if this is used for a singular purpose

Copy link
Contributor Author

@abcrane123 abcrane123 Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is used in type for TransactionButton prop so i need to define here


/**
* List of transaction lifecycle statuses.
* The order of the statuses loosely follows the transaction lifecycle.
Expand Down Expand Up @@ -71,6 +76,8 @@ export type TransactionButtonReact = {
className?: string; // An optional CSS class name for styling the button component.
disabled?: boolean; // A optional prop to disable the submit button
text?: string; // An optional text to be displayed in the button component.
errorOverride?: TransactionButtonOverride; // Optional overrides for text and onClick handler in error state (default is resubmit txn)
successOverride?: TransactionButtonOverride; // Optional overrides for text and onClick handler in success state (default is view txn on block explorer)
};

export type TransactionContextType = {
Expand Down