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: Add Swap Settings #1210

Merged
merged 23 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 19 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: 5 additions & 0 deletions .changeset/clever-lies-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

-**feat**: added custom slippage support settings sub-component in the `Swap` component. By @cpcramer #1210
28 changes: 26 additions & 2 deletions src/swap/components/SwapProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ describe('SwapProvider', () => {
await act(async () => {
result.current.setLifeCycleStatus({
statusName: 'success',
statusData: { receipt: ['0x123'] },
statusData: { receipt: ['0x123'], maxSlippage: 5 },
});
});
expect(result.current.error).toBeUndefined();
Expand All @@ -260,7 +260,7 @@ describe('SwapProvider', () => {
await act(async () => {
result.current.setLifeCycleStatus({
statusName: 'success',
statusData: { transactionReceipt: '0x123' },
statusData: { transactionReceipt: '0x123', maxSlippage: 5 },
});
});
await waitFor(() => {
Expand Down Expand Up @@ -305,6 +305,7 @@ describe('SwapProvider', () => {
amountFrom: '10',
amountTo: '1e-9',
isMissingRequiredField: false,
maxSlippage: 5,
tokenFrom: {
address: '',
name: 'ETH',
Expand Down Expand Up @@ -344,6 +345,7 @@ describe('SwapProvider', () => {
amountFrom: '1e-9',
amountTo: '10',
isMissingRequiredField: false,
maxSlippage: 5,
tokenTo: {
address: '',
name: 'ETH',
Expand Down Expand Up @@ -411,6 +413,7 @@ describe('SwapProvider', () => {
statusName: 'init',
statusData: {
isMissingRequiredField: false,
maxSlippage: 10,
},
});
});
Expand Down Expand Up @@ -688,4 +691,25 @@ describe('SwapProvider', () => {
).toBe('UNCAUGHT_SWAP_ERROR');
});
});

it('should use default maxSlippage when not provided in experimental', () => {
const useTestHook = () => {
const { lifeCycleStatus } = useSwapContext();
return lifeCycleStatus;
};
const wrapper = ({ children }) => (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<SwapProvider experimental={{ useAggregator: true }}>
{children}
</SwapProvider>
</QueryClientProvider>
</WagmiProvider>
);
const { result } = renderHook(() => useTestHook(), { wrapper });
expect(result.current.statusName).toBe('init');
if (result.current.statusName === 'init') {
expect(result.current.statusData.maxSlippage).toBe(3);
}
});
});
21 changes: 15 additions & 6 deletions src/swap/components/SwapProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export function SwapProvider({
const { address } = useAccount();
// Feature flags
const { useAggregator } = experimental;

const [maxSlippage, _setMaxSlippage] = useState(
experimental.maxSlippage || 3,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

don't want this in state do we? If the experimental prop changes, should that update here?

Copy link
Contributor Author

@cpcramer cpcramer Sep 5, 2024

Choose a reason for hiding this comment

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

The experimental prop won't change after the component is rendered.

It will set the initial maxSlippage value. Any updates will be handled by LifeCycleStatus.slippageChange.maxSlippage property

https://github.com/coinbase/onchainkit/pull/1210/files#diff-9bca97e90be8d6cfaa2c6813dbf1d684ed792b80b395f6b033d3d06963f23371R91-R94

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@cpcramer cpcramer Sep 5, 2024

Choose a reason for hiding this comment

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

Some context on why we are setting it this way

Screenshot 2024-09-05 at 11 30 36 AM

Copy link
Contributor

Choose a reason for hiding this comment

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

If we are only using this const to initialize lifecycle status, can it just be a const? or alternatively we could move the initiallifecycle status into a const?

Copy link
Contributor

Choose a reason for hiding this comment

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

The experimental prop won't change after the component is rendered.

@cpcramer that sentence it's incorrect! We should always expect a prop can change, that's the all point of prop. :)

// Core Hooks
const config = useConfig();
const [loading, setLoading] = useState(false);
Expand All @@ -56,6 +58,7 @@ export function SwapProvider({
statusName: 'init',
statusData: {
isMissingRequiredField: true,
maxSlippage,
},
}); // Component lifecycle
const [hasHandledSuccess, setHasHandledSuccess] = useState(false);
Expand Down Expand Up @@ -120,10 +123,11 @@ export function SwapProvider({
statusName: 'init',
statusData: {
isMissingRequiredField: false,
maxSlippage,
},
});
}
}, [hasHandledSuccess, lifeCycleStatus.statusName]);
}, [hasHandledSuccess, lifeCycleStatus.statusName, maxSlippage]);

const handleToggle = useCallback(() => {
from.setAmount(to.amount);
Expand Down Expand Up @@ -153,6 +157,7 @@ export function SwapProvider({
statusData: {
amountFrom: from.amount,
amountTo: to.amount,
maxSlippage,
tokenFrom: from.token,
tokenTo: to.token,
// token is missing
Expand All @@ -175,6 +180,7 @@ export function SwapProvider({
// amount is irrelevant
amountFrom: type === 'from' ? amount : '',
amountTo: type === 'to' ? amount : '',
maxSlippage,
tokenFrom: from.token,
tokenTo: to.token,
// when fetching quote, the destination
Expand All @@ -189,7 +195,7 @@ export function SwapProvider({
amountReference: 'from',
from: source.token,
to: destination.token,
maxSlippage: experimental.maxSlippage?.toString(),
maxSlippage: maxSlippage.toString(),
useAggregator,
});
// If request resolves to error response set the quoteError
Expand All @@ -215,6 +221,7 @@ export function SwapProvider({
statusData: {
amountFrom: type === 'from' ? amount : formattedAmount,
amountTo: type === 'to' ? amount : formattedAmount,
maxSlippage,
tokenFrom: from.token,
tokenTo: to.token,
// if quote was fetched successfully, we
Expand All @@ -236,7 +243,7 @@ export function SwapProvider({
destination.setLoading(false);
}
},
[from, experimental.maxSlippage, to, useAggregator],
[from, maxSlippage, to, useAggregator],
);

const handleSubmit = useCallback(async () => {
Expand All @@ -247,6 +254,7 @@ export function SwapProvider({
statusName: 'init',
statusData: {
isMissingRequiredField: false,
maxSlippage,
},
});

Expand All @@ -257,7 +265,7 @@ export function SwapProvider({
from: from.token,
to: to.token,
useAggregator,
maxSlippage: experimental.maxSlippage?.toString(),
maxSlippage: maxSlippage.toString(),
});
if (isSwapError(response)) {
setLifeCycleStatus({
Expand All @@ -272,6 +280,7 @@ export function SwapProvider({
}
await processSwapTransaction({
config,
maxSlippage,
Copy link
Contributor

Choose a reason for hiding this comment

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

In case was miss, let's cover this comment

https://github.com/coinbase/onchainkit/pull/1210/files#r1744336194

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This link is broken, it is sending me to the top of the updated files page.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I don't think is clean to just pass the maxSlippage. Like think 7 steps ahead, statusData will contain other info that need to persist inside processSwapTransaction when setLifeCycleStatus is called.

So we should pass lifeCycleStatus, inside processSwapTransaction.

So that we can persit the info we need from statusData when we recall setLifeCycleStatus.

Copy link
Contributor

Choose a reason for hiding this comment

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

should useAggregator be handled the same way?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Zizzamia Updated to pass in lifeCycleStatus instead of maxSlippage.

And now within processSwapTransaction we are grabbing maxSlippage:

  const maxSlippage =
    lifeCycleStatus.statusName !== 'error'
      ? lifeCycleStatus.statusData.maxSlippage
      : 3;

sendTransactionAsync,
setLifeCycleStatus,
swapTransaction: response,
Expand All @@ -297,10 +306,10 @@ export function SwapProvider({
config,
from.amount,
from.token,
maxSlippage,
sendTransactionAsync,
to.token,
useAggregator,
experimental.maxSlippage,
]);

const value = useValue({
Expand Down
180 changes: 180 additions & 0 deletions src/swap/components/SwapSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, vi } from 'vitest';
import { useBreakpoints } from '../../useBreakpoints';
import { useIcon } from '../../wallet/hooks/useIcon';
import { SwapSettings } from './SwapSettings';
import { SwapSettingsSlippageDescription } from './SwapSettingsSlippageDescription';
import { SwapSettingsSlippageInput } from './SwapSettingsSlippageInput';
import { SwapSettingsSlippageTitle } from './SwapSettingsSlippageTitle';

vi.mock('../../wallet/hooks/useIcon', () => ({
useIcon: vi.fn(() => <svg data-testid="mock-icon" />),
}));

vi.mock('./SwapSettingsSlippageLayout', () => ({
SwapSettingsSlippageLayout: vi.fn(({ children }) => (
<div data-testid="mock-layout">{children}</div>
)),
}));

vi.mock('./SwapSettingsSlippageLayoutBottomSheet', () => ({
SwapSettingsSlippageLayoutBottomSheet: vi.fn(({ children }) => (
<div data-testid="mock-bottom-sheet">{children}</div>
)),
}));

vi.mock('./SwapSettingsSlippageTitle', () => ({
SwapSettingsSlippageTitle: vi.fn(() => <div>Title</div>),
}));

vi.mock('./SwapSettingsSlippageDescription', () => ({
SwapSettingsSlippageDescription: vi.fn(() => <div>Description</div>),
}));

vi.mock('./SwapSettingsSlippageInput', () => ({
SwapSettingsSlippageInput: vi.fn(() => <div>Input</div>),
}));

vi.mock('../../useBreakpoints', () => ({
useBreakpoints: vi.fn(),
}));

const useBreakpointsMock = useBreakpoints as vi.Mock;

const renderComponent = (props = {}) => {
return render(
<SwapSettings {...props}>
<SwapSettingsSlippageTitle />
<SwapSettingsSlippageDescription />
<SwapSettingsSlippageInput />
</SwapSettings>,
);
};

describe('SwapSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
useBreakpointsMock.mockReturnValue('md');
});

it('should render with default props and handle dropdown toggle for desktop', async () => {
renderComponent();
expect(screen.getByTestId('ockSwapSettings_Settings')).toBeInTheDocument();
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
expect(screen.getByTestId('mock-layout')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
});
fireEvent.click(button);
await waitFor(() => {
expect(
screen.queryByTestId('ockSwapSettingsDropdown'),
).not.toBeInTheDocument();
});
});

it('should render with custom props and handle outside click', async () => {
render(
<div>
<SwapSettings
text="Custom Text"
className="custom-class"
icon="custom-icon"
/>
<div data-testid="outside">Outside</div>
</div>,
);
expect(screen.getByText('Custom Text')).toBeInTheDocument();
expect(screen.getByTestId('ockSwapSettings_Settings')).toHaveClass(
'custom-class',
);
expect(useIcon).toHaveBeenCalledWith({ icon: 'custom-icon' });
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
});
fireEvent.mouseDown(screen.getByTestId('outside'));
await waitFor(() => {
expect(
screen.queryByTestId('ockSwapSettingsDropdown'),
).not.toBeInTheDocument();
});
});

it('should handle non-valid React elements as children', async () => {
render(
<SwapSettings>
<SwapSettingsSlippageTitle />
Plain text child
<SwapSettingsSlippageInput />
</SwapSettings>,
);
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByTestId('ockSwapSettingsDropdown')).toBeInTheDocument();
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Plain text child')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
});
});

it('renders SwapSettingsSlippageLayoutBottomSheet when breakpoint is "sm"', async () => {
useBreakpointsMock.mockReturnValue('sm');
renderComponent();
const button = screen.getByRole('button', {
name: /toggle swap settings/i,
});
const initialBottomSheet = screen.getByTestId(
'ockSwapSettingsSlippageLayoutBottomSheet_container',
);
expect(initialBottomSheet).toHaveClass('ease-in-out');
expect(initialBottomSheet).toHaveClass('group-[]:bottom-0');
fireEvent.click(button);
await waitFor(() => {
const parentDiv = screen
.getByTestId('ockSwapSettings_Settings')
.querySelector('div');
expect(parentDiv).toHaveClass('group');
const openBottomSheet = screen.getByTestId(
'ockSwapSettingsSlippageLayoutBottomSheet_container',
);
expect(openBottomSheet).toHaveClass('group-[]:bottom-0');
});
fireEvent.click(button);
await waitFor(() => {
const parentDiv = screen
.getByTestId('ockSwapSettings_Settings')
.querySelector('div');
expect(parentDiv).not.toHaveClass('group');
const closedBottomSheet = screen.getByTestId(
'ockSwapSettingsSlippageLayoutBottomSheet_container',
);
expect(closedBottomSheet).toHaveClass('ease-in-out');
});
});

it('removes event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = renderComponent();
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mousedown',
expect.any(Function),
);
removeEventListenerSpy.mockRestore();
});
});
Loading