Skip to content

Commit

Permalink
feat: Buy component (#1729)
Browse files Browse the repository at this point in the history
Co-authored-by: Alissa Crane <[email protected]>
  • Loading branch information
abcrane123 and alissacrane-cb authored Dec 18, 2024
1 parent 6248ffc commit ecd3d0e
Show file tree
Hide file tree
Showing 39 changed files with 3,373 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/happy-avocados-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

- **feat**: Added Buy component. By @abcrane123. #1729
1 change: 1 addition & 0 deletions site/docs/pages/token/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type TokenChipReact = {
token: Token; // Rendered token
onClick?: (token: Token) => void;
className?: string;
isPressable?: boolean; // Default: true
};
```

Expand Down
158 changes: 158 additions & 0 deletions src/buy/components/Buy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type Config,
type UseConnectReturnType,
useAccount,
useConnect,
} from 'wagmi';
import { useOnchainKit } from '../../core-react/useOnchainKit';
import { degenToken } from '../../token/constants';
import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick';
import { Buy } from './Buy';
import { useBuyContext } from './BuyProvider';

vi.mock('./BuyProvider', () => ({
useBuyContext: vi.fn(),
BuyProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-BuyProvider">{children}</div>
),
}));

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

vi.mock('../../core-react/internal/hooks/useTheme', () => ({
useTheme: vi.fn(),
}));

vi.mock('../../ui/react/internal/hooks/useOutsideClick', () => ({
useOutsideClick: vi.fn(),
}));

vi.mock('../../core-react/useOnchainKit', () => ({
useOnchainKit: vi.fn(),
}));

vi.mock('wagmi', () => ({
useAccount: vi.fn(),
useConnect: vi.fn(),
}));

type useOutsideClickType = ReturnType<
typeof vi.fn<
(
ref: React.RefObject<HTMLElement>,
callback: (event: MouseEvent) => void,
) => void
>
>;

describe('Buy', () => {
let mockSetIsOpen: ReturnType<typeof vi.fn>;
let mockOutsideClickCallback: (e: MouseEvent) => void;

beforeEach(() => {
mockSetIsOpen = vi.fn();
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: false,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
address: '0x123',
});

(useAccount as Mock).mockReturnValue({
address: '0x123',
});

vi.mocked(useConnect).mockReturnValue({
connectors: [{ id: 'mockConnector' }],
connect: vi.fn(),
status: 'connected',
} as unknown as UseConnectReturnType<Config, unknown>);

(useOutsideClick as unknown as useOutsideClickType).mockImplementation(
(_, callback) => {
mockOutsideClickCallback = callback;
},
);

(useOnchainKit as Mock).mockReturnValue({
projectId: 'mock-project-id',
});

vi.clearAllMocks();
});

it('renders the Buy component', () => {
render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByText('Buy')).toBeInTheDocument();
expect(screen.getByText('DEGEN')).toBeInTheDocument();
});

it('closes the dropdown when clicking outside the container', () => {
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: true,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
});

render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined();
mockOutsideClickCallback({} as MouseEvent);

expect(mockSetIsOpen).toHaveBeenCalledWith(false);
});

it('does not close the dropdown when clicking inside the container', () => {
(useBuyContext as Mock).mockReturnValue({
isDropdownOpen: true,
setIsDropdownOpen: mockSetIsOpen,
lifecycleStatus: {
statusName: 'idle',
statusData: {
maxSlippage: 10,
},
},
to: {
token: degenToken,
amount: 10,
setAmount: vi.fn(),
},
});

render(<Buy className="test-class" toToken={degenToken} />);

expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined();
fireEvent.click(screen.getByTestId('mock-BuyDropdown'));
expect(mockSetIsOpen).not.toHaveBeenCalled();
});

it('should not trigger click handler when dropdown is closed', () => {
render(<Buy className="test-class" toToken={degenToken} />);
expect(screen.queryByTestId('mock-BuyDropdown')).not.toBeInTheDocument();
});
});
65 changes: 65 additions & 0 deletions src/buy/components/Buy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useOutsideClick } from '@/ui-react/internal/hooks/useOutsideClick';
import { useRef } from 'react';
import { useTheme } from '../../core-react/internal/hooks/useTheme';
import { cn } from '../../styles/theme';
import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants';
import type { BuyReact } from '../types';
import { BuyAmountInput } from './BuyAmountInput';
import { BuyButton } from './BuyButton';
import { BuyDropdown } from './BuyDropdown';
import { BuyMessage } from './BuyMessage';
import { BuyProvider, useBuyContext } from './BuyProvider';

function BuyContent({ className }: { className?: string }) {
const componentTheme = useTheme();
const { isDropdownOpen, setIsDropdownOpen } = useBuyContext();
const buyContainerRef = useRef<HTMLDivElement>(null);

useOutsideClick(buyContainerRef, () => {
if (isDropdownOpen) {
setIsDropdownOpen(false);
}
});

return (
<div
ref={buyContainerRef}
className={cn('relative flex flex-col gap-2', componentTheme, className)}
>
<div className={cn('flex items-center gap-4')}>
<BuyAmountInput />
<BuyButton />
{isDropdownOpen && <BuyDropdown />}
</div>
<BuyMessage />
</div>
);
}
export function Buy({
config = {
maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE,
},
className,
experimental = { useAggregator: false },
isSponsored = false,
onError,
onStatus,
onSuccess,
toToken,
fromToken,
}: BuyReact) {
return (
<BuyProvider
config={config}
experimental={experimental}
isSponsored={isSponsored}
onError={onError}
onStatus={onStatus}
onSuccess={onSuccess}
toToken={toToken}
fromToken={fromToken}
>
<BuyContent className={className} />
</BuyProvider>
);
}
111 changes: 111 additions & 0 deletions src/buy/components/BuyAmountInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { BuyAmountInput } from './BuyAmountInput';
import { useBuyContext } from './BuyProvider';

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

vi.mock('../../internal/components/TextInput', () => ({
TextInput: ({
value,
setValue,
onChange,
disabled,
}: {
disabled: boolean;
value: string;
setValue: (value: string) => void;
onChange: (value: string) => void;
}) => (
<input
data-testid="text-input"
value={value}
onChange={(e) => {
onChange(e.target.value);
setValue(e.target.value);
}}
disabled={disabled}
/>
),
}));

vi.mock('../../token', () => ({
TokenChip: ({ token }: { token: string }) => (
<div data-testid="token-chip">{token}</div>
),
}));

describe('BuyAmountInput', () => {
const mockHandleAmountChange = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: vi.fn(),
loading: false,
},
handleAmountChange: mockHandleAmountChange,
});
});

it('renders null when there is no token', () => {
(useBuyContext as Mock).mockReturnValue({
to: { token: null },
handleAmountChange: mockHandleAmountChange,
});

const { container } = render(<BuyAmountInput />);
expect(container.firstChild).toBeNull();
});

it('renders the input and token chip when a token is present', () => {
render(<BuyAmountInput />);

expect(screen.getByTestId('text-input')).toBeInTheDocument();
expect(screen.getByTestId('token-chip')).toBeInTheDocument();
expect(screen.getByTestId('token-chip')).toHaveTextContent('ETH');
});

it('calls handleAmountChange and setAmount on input change', () => {
const mockSetAmount = vi.fn();
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: mockSetAmount,
loading: false,
},
handleAmountChange: mockHandleAmountChange,
});

render(<BuyAmountInput />);

const input = screen.getByTestId('text-input');
fireEvent.change(input, { target: { value: '20' } });

expect(mockHandleAmountChange).toHaveBeenCalledWith('20');
expect(mockSetAmount).toHaveBeenCalledWith('20');
});

it('disables the input when loading is true', () => {
(useBuyContext as Mock).mockReturnValue({
to: {
token: 'ETH',
amount: 10,
setAmount: vi.fn(),
loading: true,
},
handleAmountChange: mockHandleAmountChange,
});

render(<BuyAmountInput />);

const input = screen.getByTestId('text-input');
expect(input).toBeDisabled();
});
});
44 changes: 44 additions & 0 deletions src/buy/components/BuyAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { isValidAmount } from '../../core/utils/isValidAmount';
import { TextInput } from '../../internal/components/TextInput';
import { background, cn, color } from '../../styles/theme';
import { formatAmount } from '../../swap/utils/formatAmount';
import { TokenChip } from '../../token';
import { useBuyContext } from './BuyProvider';

export function BuyAmountInput() {
const { to, handleAmountChange } = useBuyContext();

if (!to?.token) {
return null;
}

return (
<div
className={cn(
'flex h-full items-center rounded-lg border px-2 pl-4',
background.default,
)}
>
<TextInput
className={cn(
'mr-2 w-full border-none font-display',
'leading-none outline-none disabled:cursor-not-allowed',
background.default,
color.foreground,
)}
placeholder="0.0"
delayMs={1000}
value={formatAmount(to.amount)}
setValue={to.setAmount}
disabled={to.loading}
onChange={handleAmountChange}
inputValidator={isValidAmount}
/>
<TokenChip
className={cn(color.foreground, 'rounded-md')}
token={to.token}
isPressable={false}
/>
</div>
);
}
Loading

0 comments on commit ecd3d0e

Please sign in to comment.