Commit dd29c592 authored by eddie's avatar eddie Committed by GitHub

feat: permit2 animations WEB-2036 (#6590)

* test: swap flow cypress tests

* fix: use default parameter

* feat: use Swap Component on TDP

* feat: auto nav for TDP tokens

* chore: merge

* chore: merge

* chore: merge

* chore: merge

* fix: remove extra inputCurrency URL parsing logic

* fix: undo last change

* fix: pass expected chain id to swap component

* fix: search for default tokens on unconnected networks if needed

* test: e2e test for l2 token

* fix: delete irrelevant tests

* fix: address comments

* fix: lint error

* test: update TDP e2e tests

* fix: use pageChainId for filter

* fix: rename chainId

* fix: typecheck

* fix: chainId bug

* fix: chainId required fixes

* fix: bad merge in e2e test

* fix: remove unused test util

* fix: remove unnecessary variable

* fix: token defaults

* fix: address comments

* fix: address comments and fix tests

* fix: e2e test formatting, remove Maybe<>

* fix: remove unused variable

* fix: use feature flag for swap component on TDP

* fix: back button

* feat: copy review screen UI from widgetg

* fix: modal padding

* feat: add final detail row

* fix: remove widget comment

* fix: update unit tests

* fix: code style consistency

* fix: remove padding from AutoColumn

* fix: update snapshots

* fix: use semantic gaps

* fix: more px and gaps

* fix: design feedbacks

* fix: button radius in summary modal

* fix: design nits

* feat: update design of summary modal

* fix: font weight and vertical spacing

* fix: update snapshots

* fix: css nits

* wip: move approval to summary modal

* wip: not working

* feat: working

* fix: fix flow

* feat: simplify states and build new modal UI

* feat: todos and differs fix

* feat: update tx status modal

* feat: split up approve and permit

* feat: error state

* feat: update success and error states

* feat: undo changes to TxConfirmationModal

* feat: re-order functions

* wip: move approval to summary modal

* wip: not working

* feat: update permit2 e2e tests

* feat: tests passing

* fix: swap test

* fix: bad merge

* wip: move approval to summary modal

* wip: not working

* feat: PendingModalContent tests

* feat: useMaxAmountIn

* fix: bad merge

* fix: naming

* fix: modal flicker when refetching trade

* wip: move approval to summary modal

* wip: not working

* feat: working

* fix: fix flow

* feat: simplify states and build new modal UI

* feat: todos and differs fix

* feat: update tx status modal

* feat: split up approve and permit

* feat: error state

* feat: update success and error states

* feat: undo changes to TxConfirmationModal

* feat: remove step indicators when only one step

* feat: move content into PendingModalContent component

* fix: lint

* chore: merge

* fix: update tests for new modal

* feat: add l2 chain logo to modal

* feat: add unit test

* fix: correct modal state when moving between steps

* fix: correct modal state when moving between steps

* fix: proper error handling of user rejection of swap

* feat: update e2e test

* fix: typecheck

* feat: design updates, state updates

* fix: comments

* fix: code style improvements

* feat: require trade to be defined

* fix: remove extra props from ThemedTexts

* fix: one more trans

* fix: remove unused export

* feat: remove undefined checks and other fixes

* fix: update test

* fix: add missing dollar sign

* fix: remove null check and update test

* fix: remove max width from detail row value

* fix: remove isOpen prop

* fix: isopen

* feat: refactor approval flow into a hook

* fix: custom error type

* fix: testid fix

* fix: text colors

* fix: add comment

* wip: permit2 modal animations

* fix: entrance animations

* feat: step indicator transitions

* feat: icon aniamtions

* feat: fix spinner icon

* fix: re-organize new code

* fix: svg import

* fix: tradeMeaningfullyDiffers improvement and prepareFlow fix

* fix: address  comments

* fix: headerContent prop

* fix: change tooltip to external link

* feat: add comments explaining async state

* fix: test updates

* fix: nits

* fix: design nits

* fix: reduce nesting

* fix: address comments

* test: remove line from test for debugging

* fix: update tests

* fix: address  comments

* fix: comments

* fix: update tests

* fix: update tests

* fix: more nesting in test

* fix: update test

* fix: update e2e test

* fix: update error test

* fix: dont show loader unless onchain processing is happening

* fix: update designs and add comments

* fix: update content in test

* fix: update tests more

* fix: reorganize test code

* fix: mainnet loading indicator on last step

* fix: re-use opacity css code

* fix: testid issue with test

* fix: lint

* fix: modal height and css improvements

* fix: empty
parent 8c2a0f19
......@@ -59,6 +59,9 @@ describe('Permit2', () => {
})
it('swaps after completing full permit2 approval process', () => {
cy.hardhat().then(({ provider }) => {
cy.spy(provider, 'send').as('permitApprovalSpy')
})
initiateSwap()
cy.contains('Allow trading DAI on Uniswap').should('exist')
cy.contains('Approved').should('exist')
......@@ -73,6 +76,7 @@ describe('Permit2', () => {
expectTokenAllowanceForPermit2ToBeMax()
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
cy.get('@permitApprovalSpy').should('have.been.calledWith', 'eth_signTypedData_v4')
})
})
......
......@@ -58,3 +58,27 @@ export function LoaderV2() {
</StyledRotatingSVG>
)
}
export function LoaderV3({ size = '16px', color, ...rest }: { size?: string; color?: string; [k: string]: any }) {
const theme = useTheme()
return (
<StyledRotatingSVG
size={size}
viewBox="0 0 54 54"
xmlns="http://www.w3.org/2000/svg"
fill={color ?? theme.textTertiary}
stroke={color ?? theme.textTertiary}
{...rest}
>
<path
opacity="0.1"
d="M53.6666 26.9999C53.6666 41.7275 41.7276 53.6666 27 53.6666C12.2724 53.6666 0.333313 41.7275 0.333313 26.9999C0.333313 12.2723 12.2724 0.333252 27 0.333252C41.7276 0.333252 53.6666 12.2723 53.6666 26.9999ZM8.33331 26.9999C8.33331 37.3092 16.6907 45.6666 27 45.6666C37.3093 45.6666 45.6666 37.3092 45.6666 26.9999C45.6666 16.6906 37.3093 8.33325 27 8.33325C16.6907 8.33325 8.33331 16.6906 8.33331 26.9999Z"
fill={color ?? theme.textTertiary}
/>
<path
d="M49.6666 26.9999C51.8758 26.9999 53.6973 25.1992 53.3672 23.0149C53.0452 20.884 52.4652 18.7951 51.6368 16.795C50.2966 13.5597 48.3324 10.62 45.8562 8.14374C43.3799 5.66751 40.4402 3.70326 37.2049 2.36313C35.2048 1.53466 33.1159 0.954747 30.985 0.632693C28.8007 0.30256 27 2.12411 27 4.33325C27 6.54239 28.8108 8.29042 30.9695 8.76019C32.0523 8.99585 33.1146 9.32804 34.1434 9.75417C36.4081 10.6923 38.4659 12.0672 40.1993 13.8006C41.9327 15.534 43.3076 17.5918 44.2457 19.8565C44.6719 20.8853 45.004 21.9476 45.2397 23.0304C45.7095 25.1891 47.4575 26.9999 49.6666 26.9999Z"
fill={color ?? theme.textTertiary}
/>
</StyledRotatingSVG>
)
}
......@@ -24,12 +24,8 @@ import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import { ConfirmationModalContent, StyledLogo } from '../TransactionConfirmationModal'
import {
ErrorModalContent,
PendingConfirmModalState,
PendingModalContent,
PendingModalError,
} from './PendingModalContent'
import { PendingConfirmModalState, PendingModalContent } from './PendingModalContent'
import { ErrorModalContent, PendingModalError } from './PendingModalContent/ErrorModalContent'
import SwapModalFooter from './SwapModalFooter'
import SwapModalHeader from './SwapModalHeader'
......
import { Trans } from '@lingui/macro'
import { ButtonPrimary } from 'components/Button'
import { ColumnCenter } from 'components/Column'
import QuestionHelper from 'components/QuestionHelper'
import Row from 'components/Row'
import { AlertTriangle } from 'react-feather'
import { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
import { PendingModalContainer } from '.'
export enum PendingModalError {
TOKEN_APPROVAL_ERROR,
PERMIT_ERROR,
CONFIRMATION_ERROR,
}
interface ErrorModalContentProps {
errorType: PendingModalError
onRetry: () => void
}
function getErrorContent(errorType: PendingModalError) {
switch (errorType) {
case PendingModalError.TOKEN_APPROVAL_ERROR:
return {
title: <Trans>Token approval failed</Trans>,
label: <Trans>Why are approvals required?</Trans>,
tooltipText: (
<Trans>
This provides the Uniswap protocol access to your token for trading. For security, this will expire after 30
days.
</Trans>
),
}
case PendingModalError.PERMIT_ERROR:
return {
title: <Trans>Permit approval failed</Trans>,
label: <Trans>Why are permits required?</Trans>,
tooltipText: (
<Trans>Permit2 allows token approvals to be shared and managed across different applications.</Trans>
),
}
case PendingModalError.CONFIRMATION_ERROR:
return {
title: <Trans>Swap failed</Trans>,
}
}
}
export function ErrorModalContent({ errorType, onRetry }: ErrorModalContentProps) {
const theme = useTheme()
const { title, label, tooltipText } = getErrorContent(errorType)
return (
<PendingModalContainer gap="lg">
<AlertTriangle data-testid="pending-modal-failure-icon" strokeWidth={1} color={theme.accentFailure} size="48px" />
<ColumnCenter gap="md">
<ThemedText.HeadlineSmall>{title}</ThemedText.HeadlineSmall>
<Row justify="center">
{label && <ThemedText.Caption color="textSecondary">{label}</ThemedText.Caption>}
{tooltipText && <QuestionHelper text={tooltipText} />}
</Row>
</ColumnCenter>
<Row justify="center">
<ButtonPrimary marginX="24px" onClick={onRetry}>
<Trans>Retry</Trans>
</ButtonPrimary>
</Row>
</PendingModalContainer>
)
}
import { Currency } from '@uniswap/sdk-core'
import { ReactComponent as PapersIcon } from 'assets/svg/papers-text.svg'
import { LoaderV3 } from 'components/Icons/LoadingSpinner'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import { useUnmountingAnimation } from 'hooks/useUnmountingAnimation'
import { useRef } from 'react'
import styled, { css, keyframes, useTheme } from 'styled-components/macro'
export const LogoContainer = styled.div`
height: 48px;
width: 48px;
position: relative;
display: flex;
border-radius: 50%;
overflow: visible;
`
const fadeIn = keyframes`
from { opacity: 0;}
to { opacity: 1;}
`
const fadeAndScaleIn = keyframes`
from { opacity: 0; transform: scale(0); }
to { opacity: 1; transform: scale(1); }
`
const fadeInAnimation = css`
animation: ${fadeIn} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const fadeAndScaleInAnimation = css`
animation: ${fadeAndScaleIn} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const fadeOut = keyframes`
from { opacity: 1; }
to { opacity: 0; }
`
const fadeAndScaleOut = keyframes`
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0); }
`
const fadeOutAnimation = css`
animation: ${fadeOut} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
const fadeAndScaleOutAnimation = css`
animation: ${fadeAndScaleOut} ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
`
export enum AnimationType {
EXITING = 'exiting',
}
const FadeWrapper = styled.div<{ $scale: boolean }>`
transition: display ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`},
transform ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
${({ $scale }) => ($scale ? fadeAndScaleInAnimation : fadeInAnimation)}
&.${AnimationType.EXITING} {
${({ $scale }) => ($scale ? fadeAndScaleOutAnimation : fadeOutAnimation)}
}
`
function FadePresence({
children,
className,
$scale = false,
...rest
}: {
children: React.ReactNode
className?: string
$scale?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
useUnmountingAnimation(ref, () => AnimationType.EXITING)
return (
<FadeWrapper ref={ref} className={className} $scale={$scale} {...rest}>
{children}
</FadeWrapper>
)
}
const CurrencyLoaderContainer = styled(FadePresence)<{ asBadge: boolean }>`
z-index: 2;
border-radius: 50%;
transition: all ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.inOut}`};
position: absolute;
height: ${({ asBadge }) => (asBadge ? '20px' : '100%')};
width: ${({ asBadge }) => (asBadge ? '20px' : '100%')};
bottom: ${({ asBadge }) => (asBadge ? '-4px' : 0)};
right: ${({ asBadge }) => (asBadge ? '-4px' : 0)};
outline: ${({ theme, asBadge }) => (asBadge ? `2px solid ${theme.background}` : '')};
`
const RaisedCurrencyLogo = styled(CurrencyLogo)`
z-index: 1;
`
export function CurrencyLoader({ currency, asBadge = false }: { currency?: Currency; asBadge?: boolean }) {
return (
<CurrencyLoaderContainer asBadge={asBadge} data-testid={`pending-modal-currency-logo-${currency?.symbol}`}>
<RaisedCurrencyLogo currency={currency} size="100%" />
</CurrencyLoaderContainer>
)
}
const PinkCircle = styled(FadePresence)`
position: absolute;
display: flex;
height: 100%;
width: 100%;
border-radius: 50%;
align-items: center;
justify-content: center;
background-color: ${({ theme }) => theme.userThemeColor};
z-index: 1;
`
export function PaperIcon() {
return (
<PinkCircle>
<PapersIcon />
</PinkCircle>
)
}
const LoadingIndicator = styled(LoaderV3)`
stroke: ${({ theme }) => theme.textTertiary};
fill: ${({ theme }) => theme.textTertiary};
width: calc(100% + 8px);
height: calc(100% + 8px);
top: -4px;
left: -4px;
position: absolute;
`
export function LoadingIndicatorOverlay() {
return (
<FadePresence>
<LoadingIndicator />
</FadePresence>
)
}
function ConfirmedIcon({ className }: { className?: string }) {
const theme = useTheme()
return (
<FadePresence $scale>
<svg
data-testid="confirmed-icon"
width="54"
height="54"
viewBox="0 0 54 54"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M27 0.333008C12.28 0.333008 0.333313 12.2797 0.333313 26.9997C0.333313 41.7197 12.28 53.6663 27 53.6663C41.72 53.6663 53.6666 41.7197 53.6666 26.9997C53.6666 12.2797 41.72 0.333008 27 0.333008ZM37.7466 22.1997L25.2933 34.6263C24.9199 35.0263 24.4133 35.2131 23.8799 35.2131C23.3733 35.2131 22.8666 35.0263 22.4666 34.6263L16.2533 28.4131C15.48 27.6398 15.48 26.3596 16.2533 25.5863C17.0266 24.8129 18.3066 24.8129 19.08 25.5863L23.8799 30.3864L34.92 19.373C35.6933 18.573 36.9733 18.573 37.7466 19.373C38.52 20.1464 38.52 21.3997 37.7466 22.1997Z"
fill={theme.accentSuccess}
/>
</svg>
</FadePresence>
)
}
export const AnimatedEntranceConfirmationIcon = styled(ConfirmedIcon)`
height: 48px;
width: 48px;
`
......@@ -3,8 +3,9 @@ import { TEST_TRADE_EXACT_INPUT } from 'test-utils/constants'
import { mocked } from 'test-utils/mocked'
import { render, screen } from 'test-utils/render'
import { ConfirmModalState } from './ConfirmSwapModal'
import { ErrorModalContent, PendingModalContent, PendingModalError } from './PendingModalContent'
import { ConfirmModalState } from '../ConfirmSwapModal'
import { PendingModalContent } from '.'
import { ErrorModalContent, PendingModalError } from './ErrorModalContent'
jest.mock('state/transactions/hooks')
......@@ -83,7 +84,7 @@ describe('PendingModalContent', () => {
trade={TEST_TRADE_EXACT_INPUT}
/>
)
expect(screen.getByTestId('papers-icon-container-ABC')).toBeInTheDocument()
expect(screen.getByTestId('pending-modal-currency-logo-ABC')).toBeInTheDocument()
expect(screen.queryByTestId('pending-modal-failure-icon')).toBeNull()
})
......@@ -108,7 +109,7 @@ describe('PendingModalContent', () => {
)
expect(screen.queryByTestId('pending-modal-failure-icon')).toBeNull()
expect(screen.queryByTestId('pending-modal-currency-logo-loader')).toBeNull()
expect(screen.getByTestId('animated-confirmation')).toBeInTheDocument()
expect(screen.getByTestId('confirmed-icon')).toBeInTheDocument()
})
})
})
import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Row from 'components/Row'
import { ArrowRight } from 'react-feather'
import { InterfaceTrade } from 'state/routing/types'
import { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
export function TradeSummary({ trade }: { trade: InterfaceTrade }) {
const theme = useTheme()
return (
<Row gap="sm" justify="center" align="center">
<CurrencyLogo currency={trade.inputAmount.currency} size="16px" />
<ThemedText.LabelSmall color="textPrimary">
{formatCurrencyAmount(trade.inputAmount, NumberType.SwapTradeAmount)} {trade.inputAmount.currency.symbol}
</ThemedText.LabelSmall>
<ArrowRight color={theme.textPrimary} size="12px" />
<CurrencyLogo currency={trade.outputAmount.currency} size="16px" />
<ThemedText.LabelSmall color="textPrimary">
{formatCurrencyAmount(trade.outputAmount, NumberType.SwapTradeAmount)} {trade.outputAmount.currency.symbol}
</ThemedText.LabelSmall>
</Row>
)
}
import { RefObject, useEffect } from 'react'
function isAnimating(node?: Animatable | Document) {
return (node?.getAnimations?.().length ?? 0) > 0
}
export function useUnmountingAnimation(
node: RefObject<HTMLElement>,
getAnimatingClass: () => string,
animatedElements?: RefObject<HTMLElement>[],
skip = false
) {
useEffect(() => {
const current = node.current
const animated = animatedElements?.map((element) => element.current) ?? [current]
const parent = current?.parentElement
const removeChild = parent?.removeChild
if (!(parent && removeChild) || skip) return
parent.removeChild = function <T extends Node>(child: T) {
if ((child as Node) === current && animated) {
animated.forEach((element) => element?.classList.add(getAnimatingClass()))
const animating = animated.find((element) => isAnimating(element ?? undefined))
if (animating) {
animating?.addEventListener('animationend', (x) => {
// This check is needed because the animationend event will fire for all animations on the
// element or its children.
if (x.target === animating) {
removeChild.call(parent, child)
}
})
} else {
removeChild.call(parent, child)
}
return child
} else {
return removeChild.call(parent, child) as T
}
}
return () => {
parent.removeChild = removeChild
}
}, [animatedElements, getAnimatingClass, node, skip])
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment