Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions apps/cowswap-frontend/src/entities/surplusModal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const initialState: OrdersToDisplayModal = {

const surplusModalAtom = atom<OrdersToDisplayModal>(initialState)

const surplusModalOrderIdsAtom = atom((get) => get(surplusModalAtom).orderIds)

const addSurplusOrderAtom = atom(null, (get, set, orderId: string) =>
set(surplusModalAtom, () => {
const state = get(surplusModalAtom)
Expand Down Expand Up @@ -52,3 +54,7 @@ export function useOrderIdForSurplusModal(): string | undefined {
export function useRemoveOrderFromSurplusQueue(): (orderId: string) => void {
return useSetAtom(removeSurplusOrderAtom)
}

export function useSurplusQueueOrderIds(): string[] {
return useAtomValue(surplusModalOrderIdsAtom)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
jest.mock('common/favicon', () => {
const mockPlay = jest.fn()

class MockFaviconAnimator {
play(_frames: string[], options?: { onComplete?: () => void }): void {
mockPlay(_frames)
options?.onComplete?.()
}
stop(): void {}
resetToDefault(): void {}
setDefaultFrame(): void {}
isAnimationRunning(): boolean {
return false
}
}

return {
FaviconAnimator: MockFaviconAnimator,
frameDurations: {
solving: 100,
completed: 100,
completedHold: 100,
backToDefault: 100,
},
__mockPlay__: mockPlay,
}
})

import { OrderProgressBarStepName } from 'modules/orderProgressBar'
import { OrderProgressBarState } from 'modules/orderProgressBar/types'

import { FaviconAnimationController } from './controller'

const { __mockPlay__: mockPlay } = jest.requireMock('common/favicon') as { __mockPlay__: jest.Mock }

describe('FaviconAnimationController', () => {
const frameSet = {
defaultFrame: 'default.ico',
completedFrames: ['completed-1', 'completed-2'],
completedHoldFrame: 'completed-hold',
backToDefaultFrames: ['back-1'],
solvingFrames: ['solving-1'],
}

const NOW = 10_000

function successState(): OrderProgressBarState {
return {
progressBarStepName: OrderProgressBarStepName.FINISHED,
lastTimeChangedSteps: NOW,
}
}

function solvingState(): OrderProgressBarState {
return {
progressBarStepName: OrderProgressBarStepName.SOLVING,
lastTimeChangedSteps: NOW,
countdown: 5,
}
}

beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW)
mockPlay.mockClear()
})

afterEach(() => {
jest.restoreAllMocks()
})

function countCompletionPlays(): number {
return mockPlay.mock.calls.filter(([frames]) => frames === frameSet.completedFrames).length
}

it('only queues a completion animation once for the same finished state', () => {
const controller = new FaviconAnimationController(frameSet)

controller.update({ orderA: successState() })
controller.update({}) // state pruned temporarily
controller.update({ orderA: successState() }) // re-added while still success

expect(countCompletionPlays()).toBe(1)
})

it('queues another completion when an order returns to solving and finishes again', () => {
const controller = new FaviconAnimationController(frameSet)

controller.update({ orderA: successState() })
controller.update({ orderA: solvingState() })
controller.update({ orderA: successState() })

expect(countCompletionPlays()).toBe(2)
})

it('ignores completion animations triggered while another order is still in progress', () => {
const controller = new FaviconAnimationController(frameSet)

controller.update({ orderA: successState(), orderB: solvingState() })
// Completing while another order is still solving should not enqueue anything.
expect(countCompletionPlays()).toBe(0)

controller.update({ orderB: successState() })

expect(countCompletionPlays()).toBe(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export class FaviconAnimationController {
private currentCompletion: string | null = null
private isInitialized = false
private frameSet: FrameSet
private completedOrders = new Set<string>()
private hasInProgress = false

constructor(frameSet: FrameSet) {
this.frameSet = frameSet
Expand All @@ -24,6 +26,7 @@ export class FaviconAnimationController {
const entries = Object.entries(state)
const suppressQueue = !this.isInitialized
const hasInProgress = entries.some(([, value]) => shouldAnimateInProgress(value))
this.hasInProgress = hasInProgress
this.enqueueCompleted(entries, suppressQueue)
this.isInitialized = true
if (hasInProgress) {
Expand Down Expand Up @@ -80,23 +83,56 @@ export class FaviconAnimationController {

private enqueueCompleted(entries: Array<[string, OrderProgressBarState]>, suppressQueue: boolean): void {
const nextSteps: Record<string, OrderProgressBarStepName | undefined> = {}
const seenOrderIds = new Set<string>()

for (const [orderId, state] of entries) {
const currentStep = state.progressBarStepName
nextSteps[orderId] = currentStep
seenOrderIds.add(orderId)

if (
!suppressQueue &&
isSuccess(currentStep) &&
this.previousSteps[orderId] !== currentStep &&
isRecentStateChange(state)
) {
if (!this.completionQueue.includes(orderId)) this.completionQueue.push(orderId)
}
this.updateCompletionCache(orderId, currentStep)
this.tryQueueCompletion(orderId, state, currentStep, suppressQueue)
}

this.cleanupCompletedOrders(seenOrderIds)
this.previousSteps = nextSteps
}

private updateCompletionCache(orderId: string, currentStep: OrderProgressBarStepName | undefined): void {
if (!isSuccess(currentStep)) {
this.completedOrders.delete(orderId)
}
}

private tryQueueCompletion(
orderId: string,
state: OrderProgressBarState,
currentStep: OrderProgressBarStepName | undefined,
suppressQueue: boolean,
): void {
if (
suppressQueue ||
!isSuccess(currentStep) ||
this.completedOrders.has(orderId) ||
this.previousSteps[orderId] === currentStep ||
this.hasInProgress ||
!isRecentStateChange(state)
) {
return
}

if (!this.completionQueue.includes(orderId)) this.completionQueue.push(orderId)
this.completedOrders.add(orderId)
}

private cleanupCompletedOrders(seenOrderIds: Set<string>): void {
for (const orderId of Array.from(this.completedOrders)) {
if (!seenOrderIds.has(orderId)) {
this.completedOrders.delete(orderId)
}
}
}

private handleInProgress(): void {
if (this.mode === 'completing' && this.currentCompletion) this.unshiftCurrentCompletion()
if (!this.frameSet.solvingFrames.length) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { OrderClass } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'

import { render } from '@testing-library/react'
import { useSurplusQueueOrderIds } from 'entities/surplusModal'

import type { Order } from 'legacy/state/orders/actions'
import { useOnlyPendingOrders } from 'legacy/state/orders/hooks'

import { useTradeConfirmState } from 'modules/trade'

import { OrderProgressStateUpdater } from './OrderProgressStateUpdater'

import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps'
Expand All @@ -24,6 +27,14 @@ jest.mock('../hooks/useOrderProgressBarProps', () => ({
useOrderProgressBarProps: jest.fn(),
}))

jest.mock('entities/surplusModal', () => ({
useSurplusQueueOrderIds: jest.fn(),
}))

jest.mock('modules/trade', () => ({
useTradeConfirmState: jest.fn(),
}))

const mockPruneOrders = jest.fn()

jest.mock('jotai', () => {
Expand All @@ -46,6 +57,8 @@ jest.mock('jotai', () => {
const useWalletInfoMock = useWalletInfo as jest.MockedFunction<typeof useWalletInfo>
const useOnlyPendingOrdersMock = useOnlyPendingOrders as jest.MockedFunction<typeof useOnlyPendingOrders>
const useOrderProgressBarPropsMock = useOrderProgressBarProps as jest.MockedFunction<typeof useOrderProgressBarProps>
const useSurplusQueueOrderIdsMock = useSurplusQueueOrderIds as jest.MockedFunction<typeof useSurplusQueueOrderIds>
const useTradeConfirmStateMock = useTradeConfirmState as jest.MockedFunction<typeof useTradeConfirmState>

type WalletInfo = ReturnType<typeof useWalletInfo>

Expand All @@ -57,6 +70,8 @@ describe('OrderProgressStateUpdater', () => {
props: {} as never,
activityDerivedState: null,
})
useSurplusQueueOrderIdsMock.mockReturnValue([])
useTradeConfirmStateMock.mockReturnValue({ transactionHash: null } as never)
})

afterEach(() => {
Expand Down Expand Up @@ -96,4 +111,47 @@ describe('OrderProgressStateUpdater', () => {
expect(useOrderProgressBarPropsMock).not.toHaveBeenCalled()
expect(mockPruneOrders).toHaveBeenLastCalledWith([])
})

it('keeps state for orders queued for the surplus modal', () => {
useWalletInfoMock.mockReturnValue({
chainId: undefined,
account: undefined,
} as unknown as WalletInfo)
useOnlyPendingOrdersMock.mockReturnValue([])
useSurplusQueueOrderIdsMock.mockReturnValue(['queued-order', 'next-order'])

render(<OrderProgressStateUpdater />)

expect(mockPruneOrders).toHaveBeenLastCalledWith(['queued-order', 'next-order'])
})

it('keeps state for the order currently displayed in the confirmation modal', () => {
useWalletInfoMock.mockReturnValue({
chainId: undefined,
account: undefined,
} as unknown as WalletInfo)
useOnlyPendingOrdersMock.mockReturnValue([])
useTradeConfirmStateMock.mockReturnValue({ transactionHash: '0xorder' } as never)

render(<OrderProgressStateUpdater />)

expect(mockPruneOrders).toHaveBeenLastCalledWith(['0xorder'])
})

it('tracks the union of pending, queued, and displayed orders without duplicates', () => {
useWalletInfoMock.mockReturnValue({
chainId: 1,
account: '0xabc',
} as unknown as WalletInfo)
useOnlyPendingOrdersMock.mockReturnValue([
stubOrder({ id: '1', class: OrderClass.MARKET }),
stubOrder({ id: '2', class: OrderClass.MARKET }),
])
useSurplusQueueOrderIdsMock.mockReturnValue(['2', '3'])
useTradeConfirmStateMock.mockReturnValue({ transactionHash: '2' } as never)

render(<OrderProgressStateUpdater />)

expect(mockPruneOrders).toHaveBeenLastCalledWith(['1', '2', '3'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import { ReactNode, useEffect, useMemo } from 'react'
import { OrderClass, SupportedChainId } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'

import { useSurplusQueueOrderIds } from 'entities/surplusModal'

import { Order } from 'legacy/state/orders/actions'
import { useOnlyPendingOrders } from 'legacy/state/orders/hooks'

import { useTradeConfirmState } from 'modules/trade'

import { useOrderProgressBarProps } from '../hooks/useOrderProgressBarProps'
import { pruneOrdersProgressBarState } from '../state/atoms'

Expand All @@ -18,6 +22,8 @@ function OrderProgressStateObserver({ chainId, order }: { chainId: SupportedChai
export function OrderProgressStateUpdater(): ReactNode {
const { chainId, account } = useWalletInfo()
const pruneProgressState = useSetAtom(pruneOrdersProgressBarState)
const { transactionHash } = useTradeConfirmState()
const surplusQueueOrderIds = useSurplusQueueOrderIds()

const pendingOrders = useOnlyPendingOrders(chainId as SupportedChainId, account)
const marketOrders = useMemo(
Expand All @@ -26,9 +32,20 @@ export function OrderProgressStateUpdater(): ReactNode {
)

useEffect(() => {
const trackedIds = account && chainId ? marketOrders.map((order) => order.id) : []
pruneProgressState(trackedIds)
}, [account, chainId, marketOrders, pruneProgressState])
const trackedIdsSet = new Set<string>()

if (account && chainId) {
marketOrders.forEach((order) => trackedIdsSet.add(order.id))
}

surplusQueueOrderIds.forEach((orderId) => trackedIdsSet.add(orderId))

if (transactionHash) {
trackedIdsSet.add(transactionHash)
}

pruneProgressState(Array.from(trackedIdsSet))
}, [account, chainId, marketOrders, pruneProgressState, surplusQueueOrderIds, transactionHash])

if (!chainId || !account) {
return null
Expand Down
Loading