feat: add slippage warning to MenuButton (#6548)
* feat: initial commit * chore: add unit tests * chore: move menubutton to sep. component * chore: simplify styles and add real focused state * chore: fix tests + some other tweaks * chore: rename * test: add snapshot tests * tweaks
This commit is contained in:
parent
629fe2c144
commit
a6e1a7e6d9
@ -119,11 +119,11 @@ describe('Swap', () => {
|
||||
it('Opens and closes the settings menu', () => {
|
||||
cy.visit('/swap')
|
||||
cy.contains('Settings').should('not.exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Max slippage').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').should('exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Settings').should('not.exist')
|
||||
})
|
||||
|
||||
|
36
src/components/Settings/MenuButton/index.test.tsx
Normal file
36
src/components/Settings/MenuButton/index.test.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import { mocked } from 'test-utils/mocked'
|
||||
import { render, screen } from 'test-utils/render'
|
||||
import { lightTheme } from 'theme/colors'
|
||||
import noop from 'utils/noop'
|
||||
|
||||
import MenuButton from '.'
|
||||
|
||||
jest.mock('state/user/hooks')
|
||||
|
||||
const renderButton = () => {
|
||||
render(<MenuButton disabled={false} onClick={noop} isActive={false} />)
|
||||
}
|
||||
|
||||
describe('MenuButton', () => {
|
||||
it('should render an icon when slippage is Auto', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([SlippageTolerance.Auto, noop])
|
||||
renderButton()
|
||||
expect(screen.queryByText('slippage')).not.toBeInTheDocument()
|
||||
})
|
||||
it('should render an icon with a custom slippage value', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(5, 10_000), noop])
|
||||
renderButton()
|
||||
expect(screen.queryByText('0.05% slippage')).toBeInTheDocument()
|
||||
})
|
||||
it('should render an icon with a custom slippage and a warning when value is out of bounds', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(1, 10_000), noop])
|
||||
renderButton()
|
||||
expect(screen.getByTestId('settings-icon-with-slippage')).toHaveStyleRule(
|
||||
'background-color',
|
||||
lightTheme.accentWarningSoft
|
||||
)
|
||||
})
|
||||
})
|
91
src/components/Settings/MenuButton/index.tsx
Normal file
91
src/components/Settings/MenuButton/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { t, Trans } from '@lingui/macro'
|
||||
import Row from 'components/Row'
|
||||
import { Settings } from 'react-feather'
|
||||
import { useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import validateUserSlippageTolerance, { SlippageValidationResult } from 'utils/validateUserSlippageTolerance'
|
||||
|
||||
const Icon = styled(Settings)`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
> * {
|
||||
stroke: ${({ theme }) => theme.textSecondary};
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled.button<{ isActive: boolean }>`
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
:not([disabled]):hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
${({ isActive }) => isActive && `opacity: 0.7`}
|
||||
`
|
||||
|
||||
const IconContainer = styled(Row)`
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
`
|
||||
|
||||
const IconContainerWithSlippage = styled(IconContainer)<{ displayWarning?: boolean }>`
|
||||
div {
|
||||
color: ${({ theme, displayWarning }) => (displayWarning ? theme.accentWarning : theme.textSecondary)};
|
||||
}
|
||||
|
||||
background-color: ${({ theme, displayWarning }) =>
|
||||
displayWarning ? theme.accentWarningSoft : theme.backgroundModule};
|
||||
`
|
||||
|
||||
const ButtonContent = () => {
|
||||
const [userSlippageTolerance] = useUserSlippageTolerance()
|
||||
|
||||
if (userSlippageTolerance === SlippageTolerance.Auto) {
|
||||
return (
|
||||
<IconContainer>
|
||||
<Icon />
|
||||
</IconContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const isInvalidSlippage = validateUserSlippageTolerance(userSlippageTolerance) !== SlippageValidationResult.Valid
|
||||
|
||||
return (
|
||||
<IconContainerWithSlippage data-testid="settings-icon-with-slippage" gap="sm" displayWarning={isInvalidSlippage}>
|
||||
<ThemedText.Caption>
|
||||
<Trans>{userSlippageTolerance.toFixed(2)}% slippage</Trans>
|
||||
</ThemedText.Caption>
|
||||
<Icon />
|
||||
</IconContainerWithSlippage>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenuButton({
|
||||
disabled,
|
||||
onClick,
|
||||
isActive,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
isActive: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
id="open-settings-dialog-button"
|
||||
data-testid="open-settings-dialog-button"
|
||||
aria-label={t`Transaction Settings`}
|
||||
>
|
||||
<ButtonContent />
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { t } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { AutoColumn } from 'components/Column'
|
||||
@ -7,74 +6,38 @@ import { L2_CHAIN_IDS } from 'constants/chains'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
|
||||
import { useRef } from 'react'
|
||||
import { Settings } from 'react-feather'
|
||||
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
import MaxSlippageSettings from './MaxSlippageSettings'
|
||||
import MenuButton from './MenuButton'
|
||||
import RouterPreferenceSettings from './RouterPreferenceSettings'
|
||||
import TransactionDeadlineSettings from './TransactionDeadlineSettings'
|
||||
|
||||
const StyledMenuIcon = styled(Settings)`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
> * {
|
||||
stroke: ${({ theme }) => theme.textSecondary};
|
||||
}
|
||||
`
|
||||
|
||||
const StyledMenuButton = styled.button<{ disabled: boolean }>`
|
||||
const Menu = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0.5rem;
|
||||
height: 20px;
|
||||
${({ disabled }) =>
|
||||
!disabled &&
|
||||
`
|
||||
:hover,
|
||||
:focus {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
const StyledMenu = styled.div`
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border: none;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
const MenuFlyout = styled.span`
|
||||
const MenuFlyout = styled(AutoColumn)`
|
||||
min-width: 20.125rem;
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
|
||||
0px 24px 32px rgba(0, 0, 0, 0.01);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 1rem;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 0rem;
|
||||
top: 100%;
|
||||
margin-top: 10px;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
|
||||
min-width: 18.125rem;
|
||||
`};
|
||||
user-select: none;
|
||||
gap: 16px;
|
||||
padding: 1rem;
|
||||
`
|
||||
|
||||
const Divider = styled.div`
|
||||
@ -90,37 +53,29 @@ export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent })
|
||||
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
|
||||
|
||||
const node = useRef<HTMLDivElement | null>(null)
|
||||
const open = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
const isOpen = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
|
||||
const toggle = useToggleSettingsMenu()
|
||||
useOnClickOutside(node, open ? toggle : undefined)
|
||||
const toggleMenu = useToggleSettingsMenu()
|
||||
useOnClickOutside(node, isOpen ? toggleMenu : undefined)
|
||||
|
||||
const isSupportedChain = isSupportedChainId(chainId)
|
||||
|
||||
return (
|
||||
<StyledMenu ref={node}>
|
||||
<StyledMenuButton
|
||||
disabled={!isSupportedChainId(chainId)}
|
||||
onClick={toggle}
|
||||
id="open-settings-dialog-button"
|
||||
data-testid="open-settings-dialog-button"
|
||||
aria-label={t`Transaction Settings`}
|
||||
>
|
||||
<StyledMenuIcon data-testid="swap-settings-button" />
|
||||
</StyledMenuButton>
|
||||
{open && (
|
||||
<Menu ref={node}>
|
||||
<MenuButton disabled={!isSupportedChain} isActive={isOpen} onClick={toggleMenu} />
|
||||
{isOpen && (
|
||||
<MenuFlyout>
|
||||
<AutoColumn gap="16px" style={{ padding: '1rem' }}>
|
||||
{isSupportedChainId(chainId) && <RouterPreferenceSettings />}
|
||||
<Divider />
|
||||
<MaxSlippageSettings autoSlippage={autoSlippage} />
|
||||
{showDeadlineSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<TransactionDeadlineSettings />
|
||||
</>
|
||||
)}
|
||||
</AutoColumn>
|
||||
<RouterPreferenceSettings />
|
||||
<Divider />
|
||||
<MaxSlippageSettings autoSlippage={autoSlippage} />
|
||||
{showDeadlineSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<TransactionDeadlineSettings />
|
||||
</>
|
||||
)}
|
||||
</MenuFlyout>
|
||||
)}
|
||||
</StyledMenu>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
@ -1,28 +1,21 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useFiatOnRampButtonEnabled } from 'featureFlags/flags/fiatOnRampButton'
|
||||
import { subhead } from 'nft/css/common.css'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import SettingsTab from '../Settings'
|
||||
import SwapBuyFiatButton from './SwapBuyFiatButton'
|
||||
|
||||
const StyledSwapHeader = styled.div`
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
const StyledSwapHeader = styled(RowBetween)`
|
||||
margin-bottom: 10px;
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
`
|
||||
|
||||
const TextHeader = styled.div`
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
const HeaderButtonContainer = styled(RowFixed)`
|
||||
padding: 0 12px;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
|
||||
@ -30,17 +23,15 @@ export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent })
|
||||
|
||||
return (
|
||||
<StyledSwapHeader>
|
||||
<RowBetween>
|
||||
<RowFixed style={{ gap: '8px' }}>
|
||||
<TextHeader className={subhead}>
|
||||
<Trans>Swap</Trans>
|
||||
</TextHeader>
|
||||
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
|
||||
</RowFixed>
|
||||
<RowFixed>
|
||||
<SettingsTab autoSlippage={autoSlippage} />
|
||||
</RowFixed>
|
||||
</RowBetween>
|
||||
<HeaderButtonContainer>
|
||||
<ThemedText.SubHeader>
|
||||
<Trans>Swap</Trans>
|
||||
</ThemedText.SubHeader>
|
||||
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
|
||||
</HeaderButtonContainer>
|
||||
<RowFixed>
|
||||
<SettingsTab autoSlippage={autoSlippage} />
|
||||
</RowFixed>
|
||||
</StyledSwapHeader>
|
||||
)
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export const SwapWrapper = styled.main<{ chainId: number | undefined }>`
|
||||
border-radius: 16px;
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
padding: 8px;
|
||||
padding-top: 12px;
|
||||
box-shadow: ${({ chainId }) => !!chainId && chainId === SupportedChainId.BNB && '0px 40px 120px 0px #f0b90b29'};
|
||||
z-index: ${Z_INDEX.default};
|
||||
transition: transform 250ms ease;
|
||||
|
@ -149,10 +149,16 @@ export function useUserSlippageTolerance(): [
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => [userSlippageTolerance, setUserSlippageTolerance],
|
||||
[setUserSlippageTolerance, userSlippageTolerance]
|
||||
)
|
||||
return [userSlippageTolerance, setUserSlippageTolerance]
|
||||
}
|
||||
|
||||
/**
|
||||
*Returns user slippage tolerance, replacing the auto with a default value
|
||||
* @param defaultSlippageTolerance the value to replace auto with
|
||||
*/
|
||||
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
|
||||
const [allowedSlippage] = useUserSlippageTolerance()
|
||||
return allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage
|
||||
}
|
||||
|
||||
export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions: boolean) => void] {
|
||||
@ -170,18 +176,6 @@ export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions:
|
||||
return [hideClosedPositions, setHideClosedPositions]
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as above but replaces the auto with a default value
|
||||
* @param defaultSlippageTolerance the default value to replace auto with
|
||||
*/
|
||||
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
|
||||
const allowedSlippage = useUserSlippageTolerance()[0]
|
||||
return useMemo(
|
||||
() => (allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage),
|
||||
[allowedSlippage, defaultSlippageTolerance]
|
||||
)
|
||||
}
|
||||
|
||||
export function useUserTransactionTTL(): [number, (slippage: number) => void] {
|
||||
const { chainId } = useWeb3React()
|
||||
const dispatch = useAppDispatch()
|
||||
|
23
src/utils/validateUserSlippageTolerance.test.ts
Normal file
23
src/utils/validateUserSlippageTolerance.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
|
||||
import validateUserSlippageTolerance, {
|
||||
MAXIMUM_RECOMMENDED_SLIPPAGE,
|
||||
MINIMUM_RECOMMENDED_SLIPPAGE,
|
||||
SlippageValidationResult,
|
||||
} from './validateUserSlippageTolerance'
|
||||
|
||||
describe('validateUserSlippageTolerance', () => {
|
||||
it('should return warning when slippage is too low', () => {
|
||||
expect(validateUserSlippageTolerance(new Percent(4, 10_000))).toBe(SlippageValidationResult.TooLow)
|
||||
})
|
||||
it('should return warning when slippage is too high', () => {
|
||||
expect(validateUserSlippageTolerance(new Percent(2, 100))).toBe(SlippageValidationResult.TooHigh)
|
||||
})
|
||||
it('should not return warning when slippage is in bounds', () => {
|
||||
expect(validateUserSlippageTolerance(new Percent(1, 100))).toBe(SlippageValidationResult.Valid)
|
||||
})
|
||||
it('should not return warning when slippage is equal to lower or upper bound', () => {
|
||||
expect(validateUserSlippageTolerance(MINIMUM_RECOMMENDED_SLIPPAGE)).toBe(SlippageValidationResult.Valid)
|
||||
expect(validateUserSlippageTolerance(MAXIMUM_RECOMMENDED_SLIPPAGE)).toBe(SlippageValidationResult.Valid)
|
||||
})
|
||||
})
|
20
src/utils/validateUserSlippageTolerance.ts
Normal file
20
src/utils/validateUserSlippageTolerance.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
|
||||
export enum SlippageValidationResult {
|
||||
TooLow,
|
||||
TooHigh,
|
||||
Valid,
|
||||
}
|
||||
|
||||
export const MINIMUM_RECOMMENDED_SLIPPAGE = new Percent(5, 10_000)
|
||||
export const MAXIMUM_RECOMMENDED_SLIPPAGE = new Percent(1, 100)
|
||||
|
||||
export default function validateUserSlippageTolerance(userSlippageTolerance: Percent) {
|
||||
if (userSlippageTolerance.lessThan(MINIMUM_RECOMMENDED_SLIPPAGE)) {
|
||||
return SlippageValidationResult.TooLow
|
||||
} else if (userSlippageTolerance.greaterThan(MAXIMUM_RECOMMENDED_SLIPPAGE)) {
|
||||
return SlippageValidationResult.TooHigh
|
||||
} else {
|
||||
return SlippageValidationResult.Valid
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user