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:
Mike Grabowski 2023-05-16 11:41:14 +04:00 committed by GitHub
parent 629fe2c144
commit a6e1a7e6d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 224 additions and 113 deletions

@ -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')
})

@ -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
)
})
})

@ -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,26 +53,19 @@ 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 />}
<RouterPreferenceSettings />
<Divider />
<MaxSlippageSettings autoSlippage={autoSlippage} />
{showDeadlineSettings && (
@ -118,9 +74,8 @@ export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent })
<TransactionDeadlineSettings />
</>
)}
</AutoColumn>
</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}>
<HeaderButtonContainer>
<ThemedText.SubHeader>
<Trans>Swap</Trans>
</TextHeader>
</ThemedText.SubHeader>
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed>
</HeaderButtonContainer>
<RowFixed>
<SettingsTab autoSlippage={autoSlippage} />
</RowFixed>
</RowBetween>
</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()

@ -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)
})
})

@ -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
}
}